chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
@@ -0,0 +1,725 @@
import { AllocationStatus, SystemRole } from "@planarchy/shared";
import { describe, expect, it, vi } from "vitest";
import { allocationRouter } from "../router/allocation.js";
import { emitAllocationCreated, emitAllocationDeleted } from "../sse/event-bus.js";
import { createCallerFactory } from "../trpc.js";
vi.mock("../sse/event-bus.js", () => ({
emitAllocationCreated: vi.fn(),
emitAllocationDeleted: vi.fn(),
emitAllocationUpdated: vi.fn(),
}));
const createCaller = createCallerFactory(allocationRouter);
function createManagerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "manager@example.com", name: "Manager", image: null },
expires: "2026-03-13T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.MANAGER,
permissionOverrides: null,
},
});
}
describe("allocation entry resolution router", () => {
it("creates an open demand through allocation.create without requiring isPlaceholder", async () => {
const createdDemandRequirement = {
id: "demand_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 4,
percentage: 50,
role: "FX Artist",
roleId: "role_fx",
headcount: 2,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_fx", name: "FX Artist", color: "#222222" },
};
const db = {
project: {
findUnique: vi.fn().mockResolvedValue({ id: "project_1" }),
},
demandRequirement: {
create: vi.fn().mockResolvedValue(createdDemandRequirement),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const caller = createManagerCaller(db);
const result = await caller.create({
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 4,
percentage: 50,
role: "FX Artist",
roleId: "role_fx",
headcount: 2,
status: AllocationStatus.PROPOSED,
metadata: {},
});
expect(result.id).toBe("demand_1");
expect(result.isPlaceholder).toBe(true);
expect(db.demandRequirement.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
headcount: 2,
}),
}),
);
});
it("creates an assignment through allocation.create without requiring isPlaceholder", async () => {
const createdAssignment = {
id: "assignment_1",
demandRequirementId: null,
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 8,
percentage: 100,
role: "Compositor",
roleId: "role_comp",
dailyCostCents: 40000,
status: AllocationStatus.ACTIVE,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
resource: {
id: "resource_1",
displayName: "Alice",
eid: "E-001",
lcrCents: 5000,
},
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" },
demandRequirement: null,
};
const db = {
project: {
findUnique: vi.fn().mockResolvedValue({ id: "project_1" }),
},
resource: {
findUnique: vi.fn().mockResolvedValue({
id: "resource_1",
displayName: "Alice",
eid: "E-001",
lcrCents: 5000,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
},
}),
},
allocation: {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn(),
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(createdAssignment),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const caller = createManagerCaller(db);
const result = await caller.create({
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 8,
percentage: 100,
role: "Compositor",
roleId: "role_comp",
status: AllocationStatus.ACTIVE,
metadata: {},
});
expect(result.id).toBe("assignment_1");
expect(result.isPlaceholder).toBe(false);
expect(db.allocation.create).not.toHaveBeenCalled();
expect(db.assignment.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
resourceId: "resource_1",
}),
}),
);
});
it("creates an explicit demand requirement without dual-writing a legacy allocation row", async () => {
vi.mocked(emitAllocationCreated).mockClear();
const createdDemandRequirement = {
id: "demand_explicit_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 4,
percentage: 50,
role: "FX Artist",
roleId: "role_fx",
headcount: 2,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_fx", name: "FX Artist", color: "#222222" },
};
const db = {
project: {
findUnique: vi.fn().mockResolvedValue({ id: "project_1" }),
},
demandRequirement: {
create: vi.fn().mockResolvedValue(createdDemandRequirement),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const caller = createManagerCaller(db);
const result = await caller.createDemandRequirement({
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 4,
percentage: 50,
role: "FX Artist",
roleId: "role_fx",
headcount: 2,
status: AllocationStatus.PROPOSED,
metadata: {},
});
expect(result.id).toBe("demand_explicit_1");
expect((db as { allocation?: { create?: unknown } }).allocation?.create).toBeUndefined();
expect(db.demandRequirement.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
headcount: 2,
}),
}),
);
expect(emitAllocationCreated).toHaveBeenCalledWith({
id: "demand_explicit_1",
projectId: "project_1",
resourceId: null,
});
});
it("creates an explicit assignment without dual-writing a legacy allocation row", async () => {
vi.mocked(emitAllocationCreated).mockClear();
const createdAssignment = {
id: "assignment_explicit_1",
demandRequirementId: null,
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 8,
percentage: 100,
role: "Compositor",
roleId: "role_comp",
dailyCostCents: 40000,
status: AllocationStatus.ACTIVE,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
resource: {
id: "resource_1",
displayName: "Alice",
eid: "E-001",
lcrCents: 5000,
},
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" },
demandRequirement: null,
};
const db = {
project: {
findUnique: vi.fn().mockResolvedValue({ id: "project_1" }),
},
resource: {
findUnique: vi.fn().mockResolvedValue({
id: "resource_1",
lcrCents: 5000,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
},
}),
},
allocation: {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn(),
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(createdAssignment),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const caller = createManagerCaller(db);
const result = await caller.createAssignment({
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 8,
percentage: 100,
role: "Compositor",
roleId: "role_comp",
status: AllocationStatus.ACTIVE,
metadata: {},
});
expect(result.id).toBe("assignment_explicit_1");
expect(db.allocation.create).not.toHaveBeenCalled();
expect(db.assignment.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
resourceId: "resource_1",
}),
}),
);
expect(emitAllocationCreated).toHaveBeenCalledWith({
id: "assignment_explicit_1",
projectId: "project_1",
resourceId: "resource_1",
});
});
it("deletes an explicit demand requirement without routing through allocation.delete", async () => {
vi.mocked(emitAllocationDeleted).mockClear();
const existingDemandRequirement = {
id: "demand_explicit_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 4,
percentage: 50,
role: "FX Artist",
roleId: "role_fx",
headcount: 2,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
project: {
id: "project_1",
name: "Project One",
shortCode: "PRJ",
status: "ACTIVE",
endDate: new Date("2026-03-20"),
},
roleEntity: { id: "role_fx", name: "FX Artist", color: "#222222" },
assignments: [],
};
const db = {
demandRequirement: {
findUnique: vi.fn().mockResolvedValue(existingDemandRequirement),
delete: vi.fn().mockResolvedValue(existingDemandRequirement),
},
assignment: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
allocation: {
findUnique: vi.fn().mockResolvedValue(null),
delete: vi.fn(),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const caller = createManagerCaller(db);
const result = await caller.deleteDemandRequirement({ id: "demand_explicit_1" });
expect(result).toEqual({ success: true });
expect(db.assignment.updateMany).toHaveBeenCalledWith({
where: { demandRequirementId: "demand_explicit_1" },
data: { demandRequirementId: null },
});
expect(db.demandRequirement.delete).toHaveBeenCalledWith({
where: { id: "demand_explicit_1" },
});
expect(db.allocation.delete).not.toHaveBeenCalled();
expect(emitAllocationDeleted).toHaveBeenCalledWith("demand_explicit_1", "project_1");
});
it("deletes an explicit assignment without routing through allocation.delete", async () => {
vi.mocked(emitAllocationDeleted).mockClear();
const existingAssignment = {
id: "assignment_explicit_1",
demandRequirementId: null,
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 8,
percentage: 100,
role: "Compositor",
roleId: "role_comp",
dailyCostCents: 40000,
status: AllocationStatus.ACTIVE,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
resource: {
id: "resource_1",
displayName: "Alice",
eid: "E-001",
lcrCents: 5000,
},
project: {
id: "project_1",
name: "Project One",
shortCode: "PRJ",
status: "ACTIVE",
endDate: new Date("2026-03-20"),
},
roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" },
demandRequirement: null,
};
const db = {
assignment: {
findUnique: vi.fn().mockResolvedValue(existingAssignment),
delete: vi.fn().mockResolvedValue(existingAssignment),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const caller = createManagerCaller(db);
const result = await caller.deleteAssignment({ id: "assignment_explicit_1" });
expect(result).toEqual({ success: true });
expect(db.assignment.delete).toHaveBeenCalledWith({
where: { id: "assignment_explicit_1" },
});
expect(emitAllocationDeleted).toHaveBeenCalledWith("assignment_explicit_1", "project_1");
});
it("updates an explicit demand row through allocation.update", async () => {
const existingDemand = {
id: "demand_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 4,
percentage: 50,
role: "FX Artist",
roleId: "role_fx",
headcount: 1,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_fx", name: "FX Artist", color: "#222222" },
};
const updatedDemand = {
...existingDemand,
headcount: 2,
status: AllocationStatus.CONFIRMED,
metadata: { source: "router-test" },
updatedAt: new Date("2026-03-14"),
};
const db = {
allocation: {
findUnique: vi.fn().mockResolvedValue(null),
},
demandRequirement: {
findUnique: vi.fn().mockResolvedValue(existingDemand),
update: vi.fn().mockResolvedValue(updatedDemand),
},
assignment: {
findUnique: vi.fn().mockResolvedValue(null),
update: vi.fn(),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const caller = createManagerCaller(db);
const result = await caller.update({
id: "demand_1",
data: {
headcount: 2,
status: AllocationStatus.CONFIRMED,
metadata: { source: "router-test" },
},
});
expect(result.id).toBe("demand_1");
expect(result.isPlaceholder).toBe(true);
expect(result.headcount).toBe(2);
expect(db.demandRequirement.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "demand_1" },
}),
);
});
it("updates a demand row by its direct id", async () => {
const existingDemand = {
id: "demand_stale",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 4,
percentage: 50,
role: "FX Artist",
roleId: "role_fx",
headcount: 1,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_fx", name: "FX Artist", color: "#222222" },
};
const updatedDemand = {
...existingDemand,
headcount: 2,
status: AllocationStatus.CONFIRMED,
updatedAt: new Date("2026-03-14"),
};
const db = {
demandRequirement: {
findUnique: vi.fn().mockImplementation(
({ where }: { where: { id?: string } }) => {
if (where.id === "demand_stale") {
return existingDemand;
}
return null;
},
),
update: vi.fn().mockResolvedValue(updatedDemand),
},
assignment: {
findUnique: vi.fn().mockResolvedValue(null),
update: vi.fn(),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const caller = createManagerCaller(db);
const result = await caller.update({
id: "demand_stale",
data: {
headcount: 2,
status: AllocationStatus.CONFIRMED,
},
});
expect(result.id).toBe("demand_stale");
expect(result.isPlaceholder).toBe(true);
expect(db.demandRequirement.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "demand_stale" },
}),
);
});
it("batch deletes explicit demand and assignment rows through allocation.batchDelete", async () => {
const explicitDemand = {
id: "demand_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 4,
percentage: 50,
role: "FX Artist",
roleId: "role_fx",
headcount: 1,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_fx", name: "FX Artist", color: "#222222" },
};
const explicitAssignment = {
id: "assignment_1",
demandRequirementId: null,
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 8,
percentage: 100,
role: "Compositor",
roleId: "role_comp",
dailyCostCents: 32000,
status: AllocationStatus.ACTIVE,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
resource: { id: "resource_1", displayName: "Alice", eid: "E-001", lcrCents: 5000 },
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" },
demandRequirement: null,
};
const db = {
allocation: {
findUnique: vi.fn().mockResolvedValue(null),
},
demandRequirement: {
findUnique: vi.fn().mockImplementation(({ where }: { where: { id: string } }) =>
where.id === "demand_1" ? explicitDemand : null,
),
delete: vi.fn().mockResolvedValue({}),
},
assignment: {
findUnique: vi.fn().mockImplementation(({ where }: { where: { id: string } }) =>
where.id === "assignment_1" ? explicitAssignment : null,
),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
delete: vi.fn().mockResolvedValue({}),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const caller = createManagerCaller(db);
const result = await caller.batchDelete({
ids: ["demand_1", "assignment_1"],
});
expect(result.count).toBe(2);
expect(db.assignment.updateMany).toHaveBeenCalledWith({
where: { demandRequirementId: "demand_1" },
data: { demandRequirementId: null },
});
expect(db.demandRequirement.delete).toHaveBeenCalledWith({
where: { id: "demand_1" },
});
expect(db.assignment.delete).toHaveBeenCalledWith({
where: { id: "assignment_1" },
});
});
it("deletes an assignment through allocation.delete by its direct id", async () => {
const existingAssignment = {
id: "assignment_stale",
demandRequirementId: null,
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 8,
percentage: 100,
role: "Compositor",
roleId: "role_comp",
dailyCostCents: 32000,
status: AllocationStatus.ACTIVE,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
resource: { id: "resource_1", displayName: "Alice", eid: "E-001", lcrCents: 5000 },
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" },
demandRequirement: null,
};
const db = {
demandRequirement: {
findUnique: vi.fn().mockResolvedValue(null),
},
assignment: {
findUnique: vi.fn().mockImplementation(
({ where }: { where: { id?: string } }) => {
if (where.id === "assignment_stale") {
return existingAssignment;
}
return null;
},
),
delete: vi.fn().mockResolvedValue({}),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const caller = createManagerCaller(db);
const result = await caller.delete({
id: "assignment_stale",
});
expect(result).toEqual({ success: true });
expect(db.assignment.delete).toHaveBeenCalledWith({
where: { id: "assignment_stale" },
});
});
});
@@ -0,0 +1,101 @@
import { BlueprintTarget, FieldType, type BlueprintFieldDefinition } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { describe, expect, it, vi } from "vitest";
import { assertBlueprintDynamicFields } from "../router/blueprint-validation.js";
function createDbMock(result: { fieldDefs: unknown; target: BlueprintTarget } | null) {
return {
blueprint: {
findUnique: vi.fn().mockResolvedValue(result),
},
};
}
describe("assertBlueprintDynamicFields", () => {
it("returns early when no blueprint is set", async () => {
const db = createDbMock(null);
await expect(
assertBlueprintDynamicFields({
db,
blueprintId: undefined,
dynamicFields: {},
target: BlueprintTarget.PROJECT,
}),
).resolves.toBeUndefined();
expect(db.blueprint.findUnique).not.toHaveBeenCalled();
});
it("rejects a missing blueprint", async () => {
const db = createDbMock(null);
await expect(
assertBlueprintDynamicFields({
db,
blueprintId: "bp_missing",
dynamicFields: {},
target: BlueprintTarget.PROJECT,
}),
).rejects.toMatchObject({ code: "NOT_FOUND" } satisfies Partial<TRPCError>);
});
it("rejects a blueprint with the wrong target", async () => {
const db = createDbMock({ fieldDefs: [], target: BlueprintTarget.RESOURCE });
await expect(
assertBlueprintDynamicFields({
db,
blueprintId: "bp_resource",
dynamicFields: {},
target: BlueprintTarget.PROJECT,
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" } satisfies Partial<TRPCError>);
});
it("rejects invalid dynamic field values", async () => {
const fieldDefs: BlueprintFieldDefinition[] = [
{
id: "cost-center",
key: "costCenter",
label: "Cost Center",
order: 0,
type: FieldType.NUMBER,
required: true,
},
];
const db = createDbMock({ fieldDefs, target: BlueprintTarget.PROJECT });
await expect(
assertBlueprintDynamicFields({
db,
blueprintId: "bp_project",
dynamicFields: { costCenter: "abc" },
target: BlueprintTarget.PROJECT,
}),
).rejects.toMatchObject({ code: "UNPROCESSABLE_CONTENT" } satisfies Partial<TRPCError>);
});
it("accepts valid dynamic field values", async () => {
const fieldDefs: BlueprintFieldDefinition[] = [
{
id: "cost-center",
key: "costCenter",
label: "Cost Center",
order: 0,
type: FieldType.NUMBER,
required: true,
},
];
const db = createDbMock({ fieldDefs, target: BlueprintTarget.PROJECT });
await expect(
assertBlueprintDynamicFields({
db,
blueprintId: "bp_project",
dynamicFields: { costCenter: 42 },
target: BlueprintTarget.PROJECT,
}),
).resolves.toBeUndefined();
});
});
@@ -0,0 +1,30 @@
import { FieldType } from "@planarchy/shared";
import { describe, expect, it } from "vitest";
import { buildDynamicFieldWhereClauses } from "../router/custom-field-filters.js";
describe("buildDynamicFieldWhereClauses", () => {
it("builds prisma-style clauses for supported field types", () => {
expect(
buildDynamicFieldWhereClauses([
{ key: "isRemote", value: "true", type: FieldType.BOOLEAN },
{ key: "seniority", value: "3.5", type: FieldType.NUMBER },
{ key: "tools", value: "houdini", type: FieldType.MULTI_SELECT },
{ key: "notes", value: "lead", type: FieldType.TEXT },
]),
).toEqual([
{ path: ["isRemote"], equals: true },
{ path: ["seniority"], equals: 3.5 },
{ path: ["tools"], array_contains: "houdini" },
{ path: ["notes"], string_contains: "lead" },
]);
});
it("skips empty and invalid numeric filters", () => {
expect(
buildDynamicFieldWhereClauses([
{ key: "empty", value: "", type: FieldType.TEXT },
{ key: "invalidNumber", value: "abc", type: FieldType.NUMBER },
]),
).toEqual([]);
});
});
@@ -0,0 +1,93 @@
import { AllocationStatus } from "@planarchy/shared";
import { describe, expect, it, vi } from "vitest";
import { loadProjectPlanningReadModel } from "../router/project-planning-read-model.js";
describe("loadProjectPlanningReadModel", () => {
it("applies active-only filters to demand and assignment loaders", async () => {
const demandFindMany = vi.fn().mockResolvedValue([]);
const assignmentFindMany = vi.fn().mockResolvedValue([]);
await loadProjectPlanningReadModel(
{
demandRequirement: { findMany: demandFindMany },
assignment: { findMany: assignmentFindMany },
} as never,
{ projectId: "project_1", activeOnly: true },
);
expect(demandFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { projectId: "project_1", status: { not: AllocationStatus.CANCELLED } },
}),
);
expect(assignmentFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { projectId: "project_1", status: { not: AllocationStatus.CANCELLED } },
}),
);
});
it("builds a split read model from demand and assignment rows", async () => {
const result = await loadProjectPlanningReadModel(
{
demandRequirement: {
findMany: vi.fn().mockResolvedValue([
{
id: "demand_1",
projectId: "project_1",
startDate: new Date("2026-03-17"),
endDate: new Date("2026-03-18"),
hoursPerDay: 8,
percentage: 100,
role: "FX",
roleId: "role_fx",
headcount: 2,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
},
]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "assignment_1",
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-17"),
endDate: new Date("2026-03-18"),
hoursPerDay: 8,
percentage: 100,
role: "Lead",
roleId: "role_lead",
dailyCostCents: 32000,
status: AllocationStatus.ACTIVE,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
resource: {
id: "resource_1",
displayName: "Alice",
eid: "E-001",
chapter: "CGI",
lcrCents: 5000,
availability: { monday: 8 },
},
},
]),
},
} as never,
{ projectId: "project_1" },
);
expect(result.readModel.demands.map((entry) => entry.sourceAllocationId)).toEqual(["demand_1"]);
expect(result.readModel.assignments.map((entry) => entry.sourceAllocationId)).toEqual([
"assignment_1",
]);
expect(result.readModel.allocations.map((entry) => entry.id)).toEqual([
"demand_1",
"assignment_1",
]);
});
});
@@ -0,0 +1,117 @@
import { AllocationStatus } from "@planarchy/shared";
import { describe, expect, it, vi } from "vitest";
import { projectRouter } from "../router/project.js";
import { createCallerFactory } from "../trpc.js";
const createCaller = createCallerFactory(projectRouter);
function createProtectedCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "user@example.com", name: "User", image: null },
expires: "2026-03-13T00:00:00.000Z",
},
db: db as never,
dbUser: null,
});
}
describe("project router planning counts", () => {
it("returns planning entry counts in project.list", async () => {
const db = {
project: {
findMany: vi.fn().mockResolvedValue([
{
id: "project_1",
shortCode: "PRJ",
name: "Project One",
orderType: "CHARGEABLE",
allocationType: "PROJECT",
winProbability: 100,
budgetCents: 100000,
startDate: new Date("2026-03-17"),
endDate: new Date("2026-03-28"),
status: "ACTIVE",
responsiblePerson: null,
dynamicFields: {},
staffingReqs: [],
blueprintId: null,
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
_count: { allocations: 1 },
},
]),
count: vi.fn().mockResolvedValue(1),
},
allocation: {
findMany: vi.fn().mockResolvedValue([
{
id: "legacy_demand",
resourceId: null,
projectId: "project_1",
startDate: new Date("2026-03-17"),
endDate: new Date("2026-03-18"),
hoursPerDay: 8,
percentage: 100,
role: "FX",
roleId: "role_fx",
isPlaceholder: true,
headcount: 2,
dailyCostCents: 0,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
},
]),
},
demandRequirement: {
findMany: vi.fn().mockResolvedValue([
{
id: "demand_1",
projectId: "project_1",
startDate: new Date("2026-03-17"),
endDate: new Date("2026-03-18"),
hoursPerDay: 8,
percentage: 100,
role: "FX",
roleId: "role_fx",
headcount: 2,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
},
]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "assignment_1",
demandRequirementId: null,
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-19"),
endDate: new Date("2026-03-20"),
hoursPerDay: 8,
percentage: 100,
role: "FX Lead",
roleId: "role_fx",
dailyCostCents: 32000,
status: AllocationStatus.ACTIVE,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
},
]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.list({ limit: 50, page: 1 });
expect(result.total).toBe(1);
expect(result.projects).toHaveLength(1);
expect(result.projects[0]?._count.allocations).toBe(2);
});
});
@@ -0,0 +1,175 @@
import { describe, expect, it, vi } from "vitest";
import type { inferProcedureInput } from "@trpc/server";
import type { AppRouter } from "../router/index.js";
// Minimal mock helpers
function mockCtx(overrides: Record<string, unknown> = {}) {
return {
ctx: {
session: { user: { id: "user_1", systemRole: "MANAGER" } },
db: overrides,
},
};
}
describe("rateCard router", () => {
describe("list", () => {
it("returns rate cards with line counts", async () => {
const findMany = vi.fn().mockResolvedValue([
{ id: "rc_1", name: "Standard 2026", currency: "EUR", isActive: true, _count: { lines: 5 } },
{ id: "rc_2", name: "India Rates", currency: "INR", isActive: true, _count: { lines: 3 } },
]);
const result = await findMany({
where: {},
include: { _count: { select: { lines: true } } },
orderBy: [{ isActive: "desc" }, { effectiveFrom: "desc" }, { name: "asc" }],
});
expect(result).toHaveLength(2);
expect(result[0]._count.lines).toBe(5);
});
});
describe("create", () => {
it("creates a rate card with lines", async () => {
const create = vi.fn().mockResolvedValue({
id: "rc_new",
name: "Q1 2026 Rates",
currency: "EUR",
isActive: true,
lines: [
{ id: "rcl_1", costRateCents: 9500, billRateCents: 14000, chapter: "Digital Content Production" },
],
});
const result = await create({
data: {
name: "Q1 2026 Rates",
currency: "EUR",
lines: {
create: [{ costRateCents: 9500, billRateCents: 14000, chapter: "Digital Content Production" }],
},
},
});
expect(result.id).toBe("rc_new");
expect(result.lines).toHaveLength(1);
expect(result.lines[0].costRateCents).toBe(9500);
});
});
describe("resolveRate", () => {
it("returns the most specific matching line", () => {
const lines = [
{ id: "rcl_1", roleId: null, chapter: "Digital Content Production", costRateCents: 7000, billRateCents: 12000 },
{ id: "rcl_2", roleId: "role_3d", chapter: "Digital Content Production", costRateCents: 9500, billRateCents: 14000 },
{ id: "rcl_3", roleId: null, chapter: null, costRateCents: 6000, billRateCents: 10000 },
];
const criteria = { roleId: "role_3d", chapter: "Digital Content Production" };
const scored = lines.map((line) => {
let score = 0;
let mismatch = false;
if (criteria.roleId && line.roleId) {
if (line.roleId === criteria.roleId) score += 4;
else mismatch = true;
}
if (criteria.chapter && line.chapter) {
if (line.chapter === criteria.chapter) score += 2;
else mismatch = true;
}
return { line, score, mismatch };
});
const candidates = scored
.filter((s) => !s.mismatch)
.sort((a, b) => b.score - a.score);
const best = candidates[0];
const result = best ? best.line : null;
// Most specific match: role + chapter = score 6
expect(result?.id).toBe("rcl_2");
expect(result?.costRateCents).toBe(9500);
});
it("returns null when no lines match", () => {
const lines = [
{ id: "rcl_1", roleId: "role_pm", chapter: "Project Management", costRateCents: 7000 },
];
const criteria = { roleId: "role_3d", chapter: "Digital Content Production" };
const scored = lines.map((line) => {
let score = 0;
let mismatch = false;
if (criteria.roleId && line.roleId) {
if (line.roleId === criteria.roleId) score += 4;
else mismatch = true;
}
if (criteria.chapter && line.chapter) {
if (line.chapter === criteria.chapter) score += 2;
else mismatch = true;
}
return { line, score, mismatch };
});
const candidates = scored.filter((s) => !s.mismatch);
const best = candidates[0];
expect(best).toBeUndefined();
});
it("falls back to generic line when specific criteria don't match", () => {
const lines = [
{ id: "rcl_1", roleId: null, chapter: null, costRateCents: 6000 },
{ id: "rcl_2", roleId: "role_pm", chapter: "Project Management", costRateCents: 8000 },
];
const criteria = { roleId: "role_3d", chapter: "Digital Content Production" };
const scored = lines.map((line) => {
let score = 0;
let mismatch = false;
if (criteria.roleId && line.roleId) {
if (line.roleId === criteria.roleId) score += 4;
else mismatch = true;
}
if (criteria.chapter && line.chapter) {
if (line.chapter === criteria.chapter) score += 2;
else mismatch = true;
}
return { line, score, mismatch };
});
const candidates = scored
.filter((s) => !s.mismatch)
.sort((a, b) => b.score - a.score);
const best = candidates[0];
const result = best ? best.line : null;
// Generic line (no criteria set) should match as fallback
expect(result?.id).toBe("rcl_1");
expect(result?.costRateCents).toBe(6000);
});
});
describe("replaceLines", () => {
it("deletes all lines and creates new ones in a transaction", async () => {
const deleteMany = vi.fn().mockResolvedValue({ count: 3 });
const createLine = vi.fn()
.mockResolvedValueOnce({ id: "rcl_new_1", costRateCents: 8000 })
.mockResolvedValueOnce({ id: "rcl_new_2", costRateCents: 9500 });
await deleteMany({ where: { rateCardId: "rc_1" } });
const line1 = await createLine({ data: { rateCardId: "rc_1", costRateCents: 8000 } });
const line2 = await createLine({ data: { rateCardId: "rc_1", costRateCents: 9500 } });
expect(deleteMany).toHaveBeenCalledWith({ where: { rateCardId: "rc_1" } });
expect(line1.id).toBe("rcl_new_1");
expect(line2.id).toBe("rcl_new_2");
});
});
});
@@ -0,0 +1,169 @@
import { AllocationStatus, SystemRole } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { describe, expect, it, vi } from "vitest";
import { roleRouter } from "../router/role.js";
import { createCallerFactory } from "../trpc.js";
vi.mock("../sse/event-bus.js", () => ({
emitRoleCreated: vi.fn(),
emitRoleDeleted: vi.fn(),
emitRoleUpdated: vi.fn(),
}));
const createCaller = createCallerFactory(roleRouter);
function createManagerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "manager@example.com", name: "Manager", image: null },
expires: "2026-03-13T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.MANAGER,
permissionOverrides: null,
},
});
}
describe("role router planning counts", () => {
it("reports planning entry counts for roles", async () => {
const db = {
role: {
findMany: vi.fn().mockResolvedValue([
{
id: "role_fx",
name: "FX",
description: null,
color: "#111111",
isActive: true,
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
_count: { resourceRoles: 2 },
},
]),
},
allocation: {
findMany: vi.fn().mockResolvedValue([
{
id: "legacy_demand",
resourceId: null,
projectId: "project_1",
startDate: new Date("2026-03-17"),
endDate: new Date("2026-03-18"),
hoursPerDay: 8,
percentage: 100,
role: "FX",
roleId: "role_fx",
isPlaceholder: true,
headcount: 2,
dailyCostCents: 0,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
},
]),
},
demandRequirement: {
findMany: vi.fn().mockResolvedValue([
{
id: "demand_1",
projectId: "project_1",
startDate: new Date("2026-03-17"),
endDate: new Date("2026-03-18"),
hoursPerDay: 8,
percentage: 100,
role: "FX",
roleId: "role_fx",
headcount: 2,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
},
]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "assignment_1",
demandRequirementId: null,
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-19"),
endDate: new Date("2026-03-20"),
hoursPerDay: 8,
percentage: 100,
role: "FX Lead",
roleId: "role_fx",
dailyCostCents: 32000,
status: AllocationStatus.ACTIVE,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
},
]),
},
};
const caller = createManagerCaller(db);
const result = await caller.list({});
expect(result).toHaveLength(1);
expect(result[0]?._count.resourceRoles).toBe(2);
expect(result[0]?._count.allocations).toBe(2);
});
it("blocks deleting a role that is only used by explicit demand or assignment rows", async () => {
const db = {
role: {
findUnique: vi.fn().mockResolvedValue({
id: "role_fx",
name: "FX",
description: null,
color: "#111111",
isActive: true,
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
_count: { resourceRoles: 0 },
}),
delete: vi.fn(),
},
allocation: {
findMany: vi.fn().mockResolvedValue([]),
},
demandRequirement: {
findMany: vi.fn().mockResolvedValue([
{
id: "demand_1",
projectId: "project_1",
startDate: new Date("2026-03-17"),
endDate: new Date("2026-03-18"),
hoursPerDay: 8,
percentage: 100,
role: "FX",
roleId: "role_fx",
headcount: 1,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
},
]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createManagerCaller(db);
await expect(caller.delete({ id: "role_fx" })).rejects.toMatchObject({
code: "PRECONDITION_FAILED",
} satisfies Partial<TRPCError>);
expect(db.role.delete).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,285 @@
import { AllocationStatus, SystemRole } from "@planarchy/shared";
import { describe, expect, it, vi } from "vitest";
import { timelineRouter } from "../router/timeline.js";
import { createCallerFactory } from "../trpc.js";
vi.mock("../sse/event-bus.js", () => ({
emitAllocationCreated: vi.fn(),
emitAllocationDeleted: vi.fn(),
emitAllocationUpdated: vi.fn(),
emitProjectShifted: vi.fn(),
}));
const createCaller = createCallerFactory(timelineRouter);
function createManagerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "manager@example.com", name: "Manager", image: null },
expires: "2026-03-13T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.MANAGER,
permissionOverrides: null,
},
});
}
describe("timeline allocation entry resolution", () => {
it("creates a quick assignment without dual-writing a legacy allocation row", async () => {
const createdAssignment = {
id: "assignment_quick_1",
demandRequirementId: null,
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 8,
percentage: 100,
role: "Team Member",
roleId: null,
dailyCostCents: 40000,
status: AllocationStatus.PROPOSED,
metadata: { source: "quickAssign" },
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
resource: {
id: "resource_1",
displayName: "Alice",
eid: "E-001",
lcrCents: 5000,
},
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: null,
demandRequirement: null,
};
const db = {
project: {
findUnique: vi.fn().mockResolvedValue({ id: "project_1" }),
},
resource: {
findUnique: vi.fn().mockResolvedValue({
id: "resource_1",
lcrCents: 5000,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
},
}),
},
allocation: {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn(),
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(createdAssignment),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const caller = createManagerCaller(db);
const result = await caller.quickAssign({
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 8,
role: "Team Member",
status: AllocationStatus.PROPOSED,
});
expect(result.id).toBe("assignment_quick_1");
expect(result.isPlaceholder).toBe(false);
expect(db.allocation.create).not.toHaveBeenCalled();
expect(db.assignment.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
resourceId: "resource_1",
metadata: { source: "quickAssign" },
}),
}),
);
});
it("updates an explicit assignment through updateAllocationInline", async () => {
const existingAssignment = {
id: "assignment_1",
demandRequirementId: null,
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 4,
percentage: 50,
role: "Compositor",
roleId: "role_comp",
dailyCostCents: 20000,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
resource: {
id: "resource_1",
displayName: "Alice",
eid: "E-001",
lcrCents: 5000,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
},
},
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" },
demandRequirement: null,
};
const updatedAssignment = {
...existingAssignment,
hoursPerDay: 6,
endDate: new Date("2026-03-21"),
percentage: 75,
dailyCostCents: 30000,
metadata: { includeSaturday: true },
updatedAt: new Date("2026-03-14"),
};
const db = {
allocation: {
findUnique: vi.fn().mockResolvedValue(null),
},
demandRequirement: {
findUnique: vi.fn().mockResolvedValue(null),
},
assignment: {
findUnique: vi.fn().mockResolvedValue(existingAssignment),
update: vi.fn().mockResolvedValue(updatedAssignment),
},
resource: {
findUnique: vi.fn().mockResolvedValue({
id: "resource_1",
lcrCents: 5000,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
},
}),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const caller = createManagerCaller(db);
const result = await caller.updateAllocationInline({
allocationId: "assignment_1",
hoursPerDay: 6,
endDate: new Date("2026-03-21"),
includeSaturday: true,
});
expect(result.id).toBe("assignment_1");
expect(result.hoursPerDay).toBe(6);
expect(db.assignment.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "assignment_1" },
}),
);
});
it("updates an explicit demand row through updateAllocationInline", async () => {
const existingDemand = {
id: "demand_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 4,
percentage: 50,
role: "FX Artist",
roleId: "role_fx",
headcount: 1,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_fx", name: "FX Artist", color: "#222222" },
};
const updatedDemand = {
...existingDemand,
hoursPerDay: 6,
endDate: new Date("2026-03-21"),
percentage: 50,
metadata: { includeSaturday: true },
updatedAt: new Date("2026-03-14"),
};
const db = {
allocation: {
findUnique: vi.fn().mockResolvedValue(null),
},
demandRequirement: {
findUnique: vi.fn().mockResolvedValue(existingDemand),
update: vi.fn().mockResolvedValue(updatedDemand),
},
assignment: {
findUnique: vi.fn().mockResolvedValue(null),
update: vi.fn(),
},
resource: {
findUnique: vi.fn(),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const caller = createManagerCaller(db);
const result = await caller.updateAllocationInline({
allocationId: "demand_1",
hoursPerDay: 6,
endDate: new Date("2026-03-21"),
includeSaturday: true,
});
expect(result.id).toBe("demand_1");
expect(result.hoursPerDay).toBe(6);
expect(db.resource.findUnique).not.toHaveBeenCalled();
expect(db.demandRequirement.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "demand_1" },
}),
);
});
});
@@ -0,0 +1,80 @@
import { AllocationStatus } from "@planarchy/shared";
import { describe, expect, it } from "vitest";
import { buildTimelineShiftPlan } from "../router/timeline-shift-planning.js";
describe("buildTimelineShiftPlan", () => {
it("builds validation assignments from explicit assignments", () => {
const result = buildTimelineShiftPlan({
demandRequirements: [
{
id: "demand_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 8,
percentage: 100,
role: "FX",
roleId: "role_fx",
headcount: 2,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
},
],
assignments: [
{
id: "assignment_1",
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 6,
percentage: 75,
role: "Comp",
roleId: "role_comp",
dailyCostCents: 30000,
status: AllocationStatus.ACTIVE,
metadata: { includeSaturday: true },
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
resource: {
id: "resource_1",
displayName: "Alice",
eid: "E-001",
lcrCents: 5000,
availability: { monday: 8 },
},
},
],
allAssignmentWindows: [
{
id: "assignment_1",
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 6,
status: AllocationStatus.ACTIVE,
},
],
});
expect(result.validationAllocations).toHaveLength(1);
expect(result.validationAllocations.map((entry) => entry.sourceAllocationId)).toEqual([
"assignment_1",
]);
expect(result.validationAllocations[0]?.includeSaturday).toBe(true);
expect(result.validationAllocations[0]?.allAllocationsForResource).toEqual([
{
id: "assignment_1",
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 6,
status: AllocationStatus.ACTIVE,
},
]);
});
});
+65
View File
@@ -0,0 +1,65 @@
import OpenAI, { AzureOpenAI } from "openai";
type AiSettings = {
aiProvider?: string | null;
azureOpenAiEndpoint?: string | null;
azureOpenAiDeployment?: string | null;
azureOpenAiApiKey?: string | null;
azureApiVersion?: string | null;
aiMaxCompletionTokens?: number | null;
aiTemperature?: number | null;
};
/** Returns true if the settings have enough information to make an API call. */
export function isAiConfigured(settings: AiSettings | null | undefined): boolean {
if (!settings?.azureOpenAiApiKey || !settings.azureOpenAiDeployment) return false;
if (settings.aiProvider === "azure" && !settings.azureOpenAiEndpoint) return false;
return true;
}
/** Instantiates the right OpenAI client based on the stored provider setting. */
export function createAiClient(settings: AiSettings): OpenAI {
if (settings.aiProvider === "azure") {
return new AzureOpenAI({
endpoint: settings.azureOpenAiEndpoint!,
apiKey: settings.azureOpenAiApiKey!,
apiVersion: settings.azureApiVersion ?? "2025-01-01-preview",
deployment: settings.azureOpenAiDeployment!,
});
}
// Default: regular OpenAI (sk-... key)
return new OpenAI({ apiKey: settings.azureOpenAiApiKey! });
}
/** Turns raw API errors into actionable human-readable messages. */
export function parseAiError(err: unknown): string {
const msg = err instanceof Error ? err.message : String(err);
const lower = msg.toLowerCase();
if (lower.includes("401") || lower.includes("unauthorized") || lower.includes("invalid_api_key") || lower.includes("incorrect api key")) {
return "Invalid API key — make sure you copied it correctly from your provider's dashboard.";
}
if (lower.includes("insufficient_quota") || lower.includes("exceeded your current quota") || lower.includes("billing")) {
return "Account quota exceeded or billing issue — check your usage limits at platform.openai.com.";
}
if (lower.includes("403") || lower.includes("forbidden")) {
return "Access denied — your key may not have permission to use this model/deployment.";
}
if (lower.includes("deploymentnotfound") || lower.includes("model_not_found") || (lower.includes("404") && lower.includes("deployment"))) {
return "Deployment not found — check the deployment name matches exactly what's configured in Azure.";
}
if (lower.includes("404") || lower.includes("not found")) {
return "Model not found — verify the model name (e.g. gpt-4o-mini) is correct and available on your account.";
}
if (lower.includes("429") || lower.includes("rate limit") || lower.includes("ratelimiterror")) {
return "Rate limit exceeded — wait a moment and try again.";
}
if (lower.includes("econnrefused") || lower.includes("enotfound") || lower.includes("fetch failed") || lower.includes("failed to fetch")) {
return "Cannot reach the API endpoint — check the endpoint URL and your network connection.";
}
if (lower.includes("context_length_exceeded") || lower.includes("maximum context")) {
return "Request too large — the prompt exceeded the model's context limit.";
}
// Fall back to the raw message but strip noise
return msg.replace(/^Error: /, "").slice(0, 300);
}
+3
View File
@@ -0,0 +1,3 @@
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";
+81
View File
@@ -0,0 +1,81 @@
/**
* Email sending utility using nodemailer.
* Non-blocking — errors are logged, not thrown.
*/
import nodemailer from "nodemailer";
import { prisma as db } from "@planarchy/db";
interface EmailPayload {
to: string | string[];
subject: string;
text: string;
html?: string;
}
async function getSmtpConfig() {
const settings = await db.systemSettings.findUnique({ where: { id: "singleton" } });
if (!settings?.smtpHost) return null;
return {
host: settings.smtpHost,
port: settings.smtpPort ?? 587,
secure: settings.smtpTls === false ? false : true,
auth:
settings.smtpUser && settings.smtpPassword
? { user: settings.smtpUser, pass: settings.smtpPassword }
: undefined,
from: settings.smtpFrom ?? settings.smtpUser ?? "noreply@planarchy.app",
};
}
/**
* Send an email. Swallows errors so calling code is never blocked.
* Returns true if sent successfully.
*/
export async function sendEmail(payload: EmailPayload): Promise<boolean> {
try {
const config = await getSmtpConfig();
if (!config) return false;
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.auth,
});
await transporter.sendMail({
from: config.from,
to: Array.isArray(payload.to) ? payload.to.join(", ") : payload.to,
subject: payload.subject,
text: payload.text,
html: payload.html,
});
return true;
} catch (err) {
console.error("[email] Failed to send email:", err);
return false;
}
}
/**
* Test SMTP connection. Returns { ok: boolean; error?: string }.
*/
export async function testSmtpConnection(): Promise<{ ok: boolean; error?: string }> {
try {
const config = await getSmtpConfig();
if (!config) return { ok: false, error: "SMTP not configured" };
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.auth,
});
await transporter.verify();
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
+610
View File
@@ -0,0 +1,610 @@
import {
buildSplitAllocationReadModel,
createAssignment,
createDemandRequirement,
deleteAssignment,
deleteAllocationEntry,
deleteDemandRequirement,
fillDemandRequirement,
fillOpenDemand,
loadAllocationEntry,
updateAllocationEntry,
updateAssignment,
updateDemandRequirement,
} from "@planarchy/application";
import {
AllocationStatus,
CreateAllocationSchema,
CreateAssignmentSchema,
CreateDemandRequirementSchema,
FillDemandRequirementSchema,
FillOpenDemandByAllocationSchema,
PermissionKey,
UpdateAssignmentSchema,
UpdateAllocationSchema,
UpdateDemandRequirementSchema,
} from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { emitAllocationCreated, emitAllocationDeleted, emitAllocationUpdated } from "../sse/event-bus.js";
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
const DEMAND_INCLUDE = {
project: { select: { id: true, name: true, shortCode: true, status: true, endDate: true } },
roleEntity: { select: { id: true, name: true, color: true } },
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 } },
},
},
} 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 } },
demandRequirement: {
select: {
id: true,
projectId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
percentage: true,
role: true,
roleId: true,
headcount: true,
status: true,
},
},
} as const;
type AllocationListFilters = {
projectId?: string | undefined;
resourceId?: string | undefined;
status?: AllocationStatus | undefined;
};
type AllocationEntryUpdateInput = z.infer<typeof UpdateAllocationSchema>;
function toDemandRequirementUpdateInput(input: AllocationEntryUpdateInput) {
return {
...(input.projectId !== undefined ? { projectId: input.projectId } : {}),
...(input.startDate !== undefined ? { startDate: input.startDate } : {}),
...(input.endDate !== undefined ? { endDate: input.endDate } : {}),
...(input.hoursPerDay !== undefined ? { hoursPerDay: input.hoursPerDay } : {}),
...(input.percentage !== undefined ? { percentage: input.percentage } : {}),
...(input.role !== undefined ? { role: input.role } : {}),
...(input.roleId !== undefined ? { roleId: input.roleId } : {}),
...(input.headcount !== undefined ? { headcount: input.headcount } : {}),
...(input.status !== undefined ? { status: input.status } : {}),
...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
};
}
function toAssignmentUpdateInput(input: AllocationEntryUpdateInput) {
return {
...(input.resourceId !== undefined ? { resourceId: input.resourceId } : {}),
...(input.projectId !== undefined ? { projectId: input.projectId } : {}),
...(input.startDate !== undefined ? { startDate: input.startDate } : {}),
...(input.endDate !== undefined ? { endDate: input.endDate } : {}),
...(input.hoursPerDay !== undefined ? { hoursPerDay: input.hoursPerDay } : {}),
...(input.percentage !== undefined ? { percentage: input.percentage } : {}),
...(input.role !== undefined ? { role: input.role } : {}),
...(input.roleId !== undefined ? { roleId: input.roleId } : {}),
...(input.status !== undefined ? { status: input.status } : {}),
...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
};
}
async function loadAllocationReadModel(
db: Pick<import("@planarchy/db").PrismaClient, "demandRequirement" | "assignment">,
input: AllocationListFilters,
) {
const [demandRequirements, assignments] = await Promise.all([
input.resourceId
? Promise.resolve([])
: db.demandRequirement.findMany({
where: {
...(input.projectId ? { projectId: input.projectId } : {}),
...(input.status ? { status: input.status } : {}),
},
include: DEMAND_INCLUDE,
orderBy: { startDate: "asc" },
}),
db.assignment.findMany({
where: {
...(input.projectId ? { projectId: input.projectId } : {}),
...(input.resourceId ? { resourceId: input.resourceId } : {}),
...(input.status ? { status: input.status } : {}),
},
include: ASSIGNMENT_INCLUDE,
orderBy: { startDate: "asc" },
}),
]);
return buildSplitAllocationReadModel({ demandRequirements, assignments });
}
async function findAllocationEntryOrNull(
db: Pick<import("@planarchy/db").PrismaClient, "demandRequirement" | "assignment">,
id: string,
) {
try {
return await loadAllocationEntry(db, id);
} catch (error) {
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
return null;
}
throw error;
}
}
export const allocationRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
projectId: z.string().optional(),
resourceId: z.string().optional(),
status: z.nativeEnum(AllocationStatus).optional(),
}),
)
.query(async ({ ctx, input }) => {
const readModel = await loadAllocationReadModel(ctx.db, input);
return readModel.allocations;
}),
listView: protectedProcedure
.input(
z.object({
projectId: z.string().optional(),
resourceId: z.string().optional(),
status: z.nativeEnum(AllocationStatus).optional(),
}),
)
.query(async ({ ctx, input }) => loadAllocationReadModel(ctx.db, input)),
create: managerProcedure
.input(CreateAllocationSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const allocation = await ctx.db.$transaction(async (tx) => {
if (!input.resourceId) {
const demandRequirement = await createDemandRequirement(
tx as unknown as Parameters<typeof createDemandRequirement>[0],
{
projectId: input.projectId,
startDate: input.startDate,
endDate: input.endDate,
hoursPerDay: input.hoursPerDay,
percentage: input.percentage,
role: input.role,
roleId: input.roleId,
headcount: input.headcount,
status: input.status,
metadata: input.metadata,
},
);
return buildSplitAllocationReadModel({
demandRequirements: [demandRequirement],
assignments: [],
}).allocations[0]!;
}
const assignment = await createAssignment(
tx as unknown as Parameters<typeof createAssignment>[0],
{
resourceId: input.resourceId,
projectId: input.projectId,
startDate: input.startDate,
endDate: input.endDate,
hoursPerDay: input.hoursPerDay,
percentage: input.percentage,
role: input.role,
roleId: input.roleId,
status: input.status,
metadata: input.metadata,
},
);
return buildSplitAllocationReadModel({
demandRequirements: [],
assignments: [assignment],
}).allocations[0]!;
});
emitAllocationCreated({
id: allocation.id,
projectId: allocation.projectId,
resourceId: allocation.resourceId,
});
return allocation;
}),
listDemands: protectedProcedure
.input(
z.object({
projectId: z.string().optional(),
status: z.nativeEnum(AllocationStatus).optional(),
roleId: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
return ctx.db.demandRequirement.findMany({
where: {
...(input.projectId ? { projectId: input.projectId } : {}),
...(input.status ? { status: input.status } : {}),
...(input.roleId ? { roleId: input.roleId } : {}),
},
include: DEMAND_INCLUDE,
orderBy: { startDate: "asc" },
});
}),
listAssignments: protectedProcedure
.input(
z.object({
projectId: z.string().optional(),
resourceId: z.string().optional(),
status: z.nativeEnum(AllocationStatus).optional(),
demandRequirementId: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
return ctx.db.assignment.findMany({
where: {
...(input.projectId ? { projectId: input.projectId } : {}),
...(input.resourceId ? { resourceId: input.resourceId } : {}),
...(input.status ? { status: input.status } : {}),
...(input.demandRequirementId ? { demandRequirementId: input.demandRequirementId } : {}),
},
include: ASSIGNMENT_INCLUDE,
orderBy: { startDate: "asc" },
});
}),
createDemandRequirement: managerProcedure
.input(CreateDemandRequirementSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const demandRequirement = await ctx.db.$transaction(async (tx) => {
return createDemandRequirement(
tx as unknown as Parameters<typeof createDemandRequirement>[0],
input,
);
});
emitAllocationCreated({
id: demandRequirement.id,
projectId: demandRequirement.projectId,
resourceId: null,
});
return demandRequirement;
}),
updateDemandRequirement: managerProcedure
.input(z.object({ id: z.string(), data: UpdateDemandRequirementSchema }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const updated = await ctx.db.$transaction(async (tx) => {
return updateDemandRequirement(
tx as unknown as Parameters<typeof updateDemandRequirement>[0],
input.id,
input.data,
);
});
emitAllocationUpdated({
id: updated.id,
projectId: updated.projectId,
resourceId: null,
});
return updated;
}),
createAssignment: managerProcedure
.input(CreateAssignmentSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const assignment = await ctx.db.$transaction(async (tx) => {
return createAssignment(
tx as unknown as Parameters<typeof createAssignment>[0],
input,
);
});
emitAllocationCreated({
id: assignment.id,
projectId: assignment.projectId,
resourceId: assignment.resourceId,
});
return assignment;
}),
updateAssignment: managerProcedure
.input(z.object({ id: z.string(), data: UpdateAssignmentSchema }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const updated = await ctx.db.$transaction(async (tx) => {
return updateAssignment(
tx as unknown as Parameters<typeof updateAssignment>[0],
input.id,
input.data,
);
});
emitAllocationUpdated({
id: updated.id,
projectId: updated.projectId,
resourceId: updated.resourceId,
});
return updated;
}),
deleteDemandRequirement: managerProcedure
.input(z.object({ id: z.string() }))
.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" });
}
await ctx.db.$transaction(async (tx) => {
await deleteDemandRequirement(
tx as unknown as Parameters<typeof deleteDemandRequirement>[0],
input.id,
);
await tx.auditLog.create({
data: {
entityType: "DemandRequirement",
entityId: input.id,
action: "DELETE",
changes: { before: existing } as unknown as import("@planarchy/db").Prisma.InputJsonValue,
},
});
});
emitAllocationDeleted(existing.id, existing.projectId);
return { success: true };
}),
fillDemandRequirement: managerProcedure
.input(FillDemandRequirementSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const result = await fillDemandRequirement(ctx.db, input);
emitAllocationCreated({
id: result.assignment.id,
projectId: result.assignment.projectId,
resourceId: result.assignment.resourceId,
});
emitAllocationUpdated({
id: result.updatedDemandRequirement.id,
projectId: result.updatedDemandRequirement.projectId,
resourceId: null,
});
return result;
}),
fillOpenDemandByAllocation: managerProcedure
.input(FillOpenDemandByAllocationSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const result = await fillOpenDemand(ctx.db, input);
emitAllocationCreated(result.createdAllocation);
if (result.updatedAllocation) {
emitAllocationUpdated(result.updatedAllocation);
}
return result;
}),
update: managerProcedure
.input(z.object({ id: z.string(), data: UpdateAllocationSchema }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const existing = await loadAllocationEntry(ctx.db, input.id);
const updated = await ctx.db.$transaction(async (tx) => {
const { allocation: updatedAllocation } = await updateAllocationEntry(
tx as unknown as Parameters<typeof updateAllocationEntry>[0],
{
id: input.id,
demandRequirementUpdate:
existing.kind === "assignment" ? {} : toDemandRequirementUpdateInput(input.data),
assignmentUpdate:
existing.kind === "demand" ? {} : toAssignmentUpdateInput(input.data),
},
);
await tx.auditLog.create({
data: {
entityType: "Allocation",
entityId: input.id,
action: "UPDATE",
changes: {
before: existing.entry,
after: updatedAllocation,
} as unknown as import("@planarchy/db").Prisma.InputJsonValue,
},
});
return updatedAllocation;
});
emitAllocationUpdated({
id: updated.id,
projectId: updated.projectId,
resourceId: updated.resourceId,
});
return updated;
}),
deleteAssignment: managerProcedure
.input(z.object({ id: z.string() }))
.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" });
}
await ctx.db.$transaction(async (tx) => {
await deleteAssignment(
tx as unknown as Parameters<typeof deleteAssignment>[0],
input.id,
);
await tx.auditLog.create({
data: {
entityType: "Assignment",
entityId: input.id,
action: "DELETE",
changes: { before: existing } as unknown as import("@planarchy/db").Prisma.InputJsonValue,
},
});
});
emitAllocationDeleted(existing.id, existing.projectId);
return { success: true };
}),
delete: managerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const existing = await loadAllocationEntry(ctx.db, input.id);
await ctx.db.$transaction(async (tx) => {
await deleteAllocationEntry(
tx as unknown as Parameters<typeof deleteAllocationEntry>[0],
existing,
);
await tx.auditLog.create({
data: {
entityType: "Allocation",
entityId: input.id,
action: "DELETE",
changes: { before: existing.entry } as unknown as import("@planarchy/db").Prisma.InputJsonValue,
},
});
});
emitAllocationDeleted(existing.entry.id, existing.projectId);
return { success: true };
}),
batchDelete: managerProcedure
.input(z.object({ ids: z.array(z.string()).min(1).max(100) }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const existing = (
await Promise.all(input.ids.map(async (id) => findAllocationEntryOrNull(ctx.db, id)))
).filter((entry): entry is NonNullable<typeof entry> => Boolean(entry));
await ctx.db.$transaction(async (tx) => {
for (const allocation of existing) {
await deleteAllocationEntry(
tx as unknown as Parameters<typeof deleteAllocationEntry>[0],
allocation,
);
}
await tx.auditLog.create({
data: {
entityType: "Allocation",
entityId: input.ids.join(","),
action: "DELETE",
changes: {
before: existing.map((a) => ({ id: a.entry.id, projectId: a.projectId })),
} as unknown as import("@planarchy/db").Prisma.InputJsonValue,
},
});
});
for (const a of existing) {
emitAllocationDeleted(a.entry.id, a.projectId);
}
return { count: existing.length };
}),
batchUpdateStatus: managerProcedure
.input(
z.object({
ids: z.array(z.string()).min(1).max(100),
status: z.nativeEnum(AllocationStatus),
}),
)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const updated = await ctx.db.$transaction(async (tx) => {
const updatedAllocations = await Promise.all(
input.ids.map(async (id) =>
(
await updateAllocationEntry(
tx as unknown as Parameters<typeof updateAllocationEntry>[0],
{
id,
demandRequirementUpdate: { status: input.status },
assignmentUpdate: { status: input.status },
},
)
).allocation,
),
);
return updatedAllocations;
});
await ctx.db.auditLog.create({
data: {
entityType: "Allocation",
entityId: input.ids.join(","),
action: "UPDATE",
changes: { after: { status: input.status, ids: input.ids } },
},
});
for (const a of updated) {
emitAllocationUpdated({ id: a.id, projectId: a.projectId, resourceId: a.resourceId });
}
return { count: updated.length };
}),
});
@@ -0,0 +1,54 @@
import { validateCustomFields } from "@planarchy/engine";
import { BlueprintTarget, type BlueprintFieldDefinition } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
interface BlueprintLookup {
blueprint: {
findUnique: (args: {
where: { id: string };
select: { fieldDefs: true; target: true };
}) => Promise<{ fieldDefs: unknown; target: string } | null>;
};
}
interface AssertBlueprintDynamicFieldsInput {
db: BlueprintLookup;
blueprintId: string | undefined;
dynamicFields: Record<string, unknown>;
target: BlueprintTarget;
}
export async function assertBlueprintDynamicFields({
db,
blueprintId,
dynamicFields,
target,
}: 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" });
}
if (blueprint.target !== target) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `${target} entities require a ${target.toLowerCase()} blueprint`,
});
}
const fieldDefs = blueprint.fieldDefs as BlueprintFieldDefinition[];
const errors = validateCustomFields(fieldDefs, dynamicFields);
if (errors.length > 0) {
throw new TRPCError({
code: "UNPROCESSABLE_CONTENT",
message: errors.map((error) => error.message).join("; "),
});
}
}
+129
View File
@@ -0,0 +1,129 @@
import { BlueprintTarget, CreateBlueprintSchema, UpdateBlueprintSchema, type BlueprintFieldDefinition } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
export const blueprintRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
target: z.nativeEnum(BlueprintTarget).optional(),
isActive: z.boolean().optional().default(true),
}),
)
.query(async ({ ctx, input }) => {
return ctx.db.blueprint.findMany({
where: {
...(input.target ? { target: input.target } : {}),
isActive: input.isActive,
},
orderBy: { name: "asc" },
});
}),
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" });
}
return blueprint;
}),
create: adminProcedure
.input(CreateBlueprintSchema)
.mutation(async ({ ctx, input }) => {
return ctx.db.blueprint.create({
data: {
name: input.name,
target: input.target,
description: input.description,
fieldDefs: input.fieldDefs as unknown as import("@planarchy/db").Prisma.InputJsonValue,
defaults: input.defaults as unknown as import("@planarchy/db").Prisma.InputJsonValue,
validationRules: input.validationRules as unknown as import("@planarchy/db").Prisma.InputJsonValue,
} as unknown as Parameters<typeof ctx.db.blueprint.create>[0]["data"],
});
}),
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" });
}
return ctx.db.blueprint.update({
where: { id: input.id },
data: {
...(input.data.name !== undefined ? { name: input.data.name } : {}),
...(input.data.description !== undefined ? { description: input.data.description } : {}),
...(input.data.fieldDefs !== undefined ? { fieldDefs: input.data.fieldDefs as unknown as import("@planarchy/db").Prisma.InputJsonValue } : {}),
...(input.data.defaults !== undefined ? { defaults: input.data.defaults as unknown as import("@planarchy/db").Prisma.InputJsonValue } : {}),
...(input.data.validationRules !== undefined ? { validationRules: input.data.validationRules as unknown as import("@planarchy/db").Prisma.InputJsonValue } : {}),
} as unknown as Parameters<typeof ctx.db.blueprint.update>[0]["data"],
});
}),
/** Dedicated mutation for saving role presets — separate from field defs to avoid Zod depth issues */
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" });
}
return ctx.db.blueprint.update({
where: { id: input.id },
data: { rolePresets: input.rolePresets as unknown as import("@planarchy/db").Prisma.InputJsonValue },
});
}),
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
// Soft delete — mark as inactive
return ctx.db.blueprint.update({
where: { id: input.id },
data: { isActive: false },
});
}),
batchDelete: adminProcedure
.input(z.object({ ids: z.array(z.string()).min(1).max(100) }))
.mutation(async ({ ctx, input }) => {
// Soft delete
const updated = await ctx.db.$transaction(
input.ids.map((id) =>
ctx.db.blueprint.update({ where: { id }, data: { isActive: false } }),
),
);
return { count: updated.length };
}),
getGlobalFieldDefs: protectedProcedure
.input(z.object({ target: z.nativeEnum(BlueprintTarget) }))
.query(async ({ ctx, input }) => {
const blueprints = await ctx.db.blueprint.findMany({
where: { target: input.target, isGlobal: true, isActive: true },
select: { id: true, name: true, fieldDefs: true },
});
return blueprints.flatMap((b) =>
(b.fieldDefs as unknown as BlueprintFieldDefinition[]).map((f) => ({
...f,
blueprintId: b.id,
blueprintName: b.name,
})),
);
}),
setGlobal: adminProcedure
.input(z.object({ id: z.string(), isGlobal: z.boolean() }))
.mutation(async ({ ctx, input }) => {
return ctx.db.blueprint.update({
where: { id: input.id },
data: { isGlobal: input.isGlobal },
});
}),
});
@@ -0,0 +1,245 @@
import {
deriveResourceForecast,
calculateGroupChargeability,
calculateGroupTarget,
sumFte,
getMonthRange,
getMonthKeys,
countWorkingDaysInOverlap,
calculateSAH,
type AssignmentSlice,
} from "@planarchy/engine";
import type { SpainScheduleRule } from "@planarchy/shared";
import { listAssignmentBookings } from "@planarchy/application";
import { VacationStatus } from "@planarchy/db";
import { z } from "zod";
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
export const chargeabilityReportRouter = createTRPCRouter({
getReport: controllerProcedure
.input(
z.object({
startMonth: z.string().regex(/^\d{4}-\d{2}$/), // "2026-01"
endMonth: z.string().regex(/^\d{4}-\d{2}$/),
orgUnitId: z.string().optional(),
managementLevelGroupId: z.string().optional(),
countryId: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
const { startMonth, endMonth } = input;
// Parse month range
const [startYear, startMo] = startMonth.split("-").map(Number) as [number, number];
const [endYear, endMo] = endMonth.split("-").map(Number) as [number, number];
const rangeStart = getMonthRange(startYear, startMo).start;
const rangeEnd = getMonthRange(endYear, endMo).end;
const monthKeys = getMonthKeys(rangeStart, rangeEnd);
// Fetch resources with filters
const resourceWhere = {
isActive: true,
chgResponsibility: true,
departed: false,
rolledOff: false,
...(input.orgUnitId ? { orgUnitId: input.orgUnitId } : {}),
...(input.managementLevelGroupId ? { managementLevelGroupId: input.managementLevelGroupId } : {}),
...(input.countryId ? { countryId: input.countryId } : {}),
};
const resources = await ctx.db.resource.findMany({
where: resourceWhere,
select: {
id: true,
eid: true,
displayName: true,
fte: true,
chargeabilityTarget: true,
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
orgUnit: { select: { id: true, name: true } },
managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } },
managementLevel: { select: { id: true, name: true } },
metroCity: { select: { id: true, name: true } },
},
orderBy: { displayName: "asc" },
});
if (resources.length === 0) {
return {
monthKeys,
resources: [],
groupTotals: monthKeys.map((key) => ({
monthKey: key,
totalFte: 0,
chg: 0,
target: 0,
gap: 0,
})),
};
}
// Fetch all bookings (assignments + legacy allocations) in the date range
const resourceIds = resources.map((r) => r.id);
const allBookings = await listAssignmentBookings(ctx.db, {
startDate: rangeStart,
endDate: rangeEnd,
resourceIds,
});
// Enrich with utilization category — fetch project util categories in bulk
const projectIds = [...new Set(allBookings.map((b) => b.projectId))];
const projectUtilCats = projectIds.length > 0
? await ctx.db.project.findMany({
where: { id: { in: projectIds } },
select: { id: true, utilizationCategory: { select: { code: true } } },
})
: [];
const projectUtilCatMap = new Map(
projectUtilCats.map((p) => [p.id, p.utilizationCategory?.code ?? null]),
);
// Normalize bookings to a common shape
const assignments = allBookings
.filter((b) => b.resourceId !== null)
.map((b) => ({
resourceId: b.resourceId!,
startDate: b.startDate,
endDate: b.endDate,
hoursPerDay: b.hoursPerDay,
project: {
status: b.project.status,
utilizationCategory: { code: projectUtilCatMap.get(b.projectId) ?? null },
},
}));
// Fetch vacations/absences in the range
const vacations = await ctx.db.vacation.findMany({
where: {
resourceId: { in: resourceIds },
status: VacationStatus.APPROVED,
startDate: { lte: rangeEnd },
endDate: { gte: rangeStart },
},
select: {
resourceId: true,
startDate: true,
endDate: true,
},
});
// Build per-resource, per-month forecasts
const resourceRows = resources.map((resource) => {
const resourceAssignments = assignments.filter((a) => a.resourceId === resource.id);
const resourceVacations = vacations.filter((v) => v.resourceId === resource.id);
// Prefer mgmt level group target; fall back to legacy chargeabilityTarget (0-100 → 0-1)
const targetPct = resource.managementLevelGroup?.targetPercentage
?? (resource.chargeabilityTarget / 100);
const dailyHours = resource.country?.dailyWorkingHours ?? 8;
const scheduleRules = resource.country?.scheduleRules as SpainScheduleRule | null;
const months = monthKeys.map((key) => {
const [y, m] = key.split("-").map(Number) as [number, number];
const { start: monthStart, end: monthEnd } = getMonthRange(y, m);
// Compute absence days for SAH
const absenceDates: string[] = [];
for (const v of resourceVacations) {
const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
if (vStart > vEnd) continue;
const cursor = new Date(vStart);
cursor.setUTCHours(0, 0, 0, 0);
const endNorm = new Date(vEnd);
endNorm.setUTCHours(0, 0, 0, 0);
while (cursor <= endNorm) {
absenceDates.push(cursor.toISOString().slice(0, 10));
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
}
// Calculate SAH for this resource+month
const sahResult = calculateSAH({
dailyWorkingHours: dailyHours,
scheduleRules,
fte: resource.fte,
periodStart: monthStart,
periodEnd: monthEnd,
publicHolidays: [], // TODO: integrate public holidays from country
absenceDays: absenceDates,
});
// Build assignment slices for this month
const slices: AssignmentSlice[] = [];
for (const a of resourceAssignments) {
// Skip DRAFT projects
if (a.project.status === "DRAFT" || a.project.status === "CANCELLED") continue;
const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
if (workingDays <= 0) continue;
const categoryCode = a.project.utilizationCategory?.code ?? "Chg";
slices.push({
hoursPerDay: a.hoursPerDay,
workingDays,
categoryCode,
});
}
const forecast = deriveResourceForecast({
fte: resource.fte,
targetPercentage: targetPct,
assignments: slices,
sah: sahResult.standardAvailableHours,
});
return {
monthKey: key,
sah: sahResult.standardAvailableHours,
...forecast,
};
});
return {
id: resource.id,
eid: resource.eid,
displayName: resource.displayName,
fte: resource.fte,
country: resource.country?.code ?? null,
city: resource.metroCity?.name ?? null,
orgUnit: resource.orgUnit?.name ?? null,
mgmtGroup: resource.managementLevelGroup?.name ?? null,
mgmtLevel: resource.managementLevel?.name ?? null,
targetPct,
months,
};
});
// Compute group totals per month
const groupTotals = monthKeys.map((key, monthIdx) => {
const groupInputs = resourceRows.map((r) => ({
fte: r.fte,
chargeability: r.months[monthIdx]!.chg,
}));
const targetInputs = resourceRows.map((r) => ({
fte: r.fte,
targetPercentage: r.targetPct,
}));
const chg = calculateGroupChargeability(groupInputs);
const target = calculateGroupTarget(targetInputs);
return {
monthKey: key,
totalFte: sumFte(resourceRows),
chg,
target,
gap: chg - target,
};
});
return {
monthKeys,
resources: resourceRows,
groupTotals,
};
}),
});
+137
View File
@@ -0,0 +1,137 @@
import { CreateClientSchema, UpdateClientSchema } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createTRPCRouter, managerProcedure, protectedProcedure } from "../trpc.js";
import type { ClientTree } from "@planarchy/shared";
interface FlatClient {
id: string;
name: string;
code: string | null;
parentId: string | null;
isActive: boolean;
sortOrder: number;
createdAt: Date;
updatedAt: Date;
}
function buildClientTree(flatItems: FlatClient[], parentId: string | null = null): ClientTree[] {
return flatItems
.filter((item) => item.parentId === parentId)
.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))
.map((item) => ({
...item,
children: buildClientTree(flatItems, item.id),
}));
}
export const clientRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
parentId: z.string().nullable().optional(),
isActive: z.boolean().optional(),
search: z.string().optional(),
}).optional(),
)
.query(async ({ ctx, input }) => {
return ctx.db.client.findMany({
where: {
...(input?.parentId !== undefined ? { parentId: input.parentId } : {}),
...(input?.isActive !== undefined ? { isActive: input.isActive } : {}),
...(input?.search
? { name: { contains: input.search, mode: "insensitive" as const } }
: {}),
},
include: { _count: { select: { children: true, projects: true } } },
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
});
}),
getTree: protectedProcedure
.input(z.object({ isActive: z.boolean().optional() }).optional())
.query(async ({ ctx, input }) => {
const all = await ctx.db.client.findMany({
where: {
...(input?.isActive !== undefined ? { isActive: input.isActive } : {}),
},
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
});
return buildClientTree(all);
}),
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" });
return client;
}),
create: managerProcedure
.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" });
}
if (input.code) {
const codeConflict = await ctx.db.client.findUnique({ where: { code: input.code } });
if (codeConflict) {
throw new TRPCError({ code: "CONFLICT", message: `Client code "${input.code}" already exists` });
}
}
return ctx.db.client.create({
data: {
name: input.name,
...(input.code ? { code: input.code } : {}),
...(input.parentId ? { parentId: input.parentId } : {}),
sortOrder: input.sortOrder,
},
});
}),
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" });
if (input.data.code && input.data.code !== existing.code) {
const conflict = await ctx.db.client.findUnique({ where: { code: input.data.code } });
if (conflict) {
throw new TRPCError({ code: "CONFLICT", message: `Client code "${input.data.code}" already exists` });
}
}
return ctx.db.client.update({
where: { id: input.id },
data: {
...(input.data.name !== undefined ? { name: input.data.name } : {}),
...(input.data.code !== undefined ? { code: input.data.code } : {}),
...(input.data.sortOrder !== undefined ? { sortOrder: input.data.sortOrder } : {}),
...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}),
...(input.data.parentId !== undefined ? { parentId: input.data.parentId } : {}),
},
});
}),
deactivate: managerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.db.client.update({
where: { id: input.id },
data: { isActive: false },
});
}),
});
+128
View File
@@ -0,0 +1,128 @@
import {
CreateCountrySchema,
CreateMetroCitySchema,
UpdateCountrySchema,
UpdateMetroCitySchema,
} from "@planarchy/shared";
import { Prisma } from "@planarchy/db";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
/** Convert nullable JSON to Prisma-compatible value (null → Prisma.JsonNull). */
function jsonOrNull(val: unknown): Prisma.InputJsonValue | typeof Prisma.JsonNull {
if (val === null || val === undefined) return Prisma.JsonNull;
return val as Prisma.InputJsonValue;
}
export const countryRouter = createTRPCRouter({
list: protectedProcedure
.input(z.object({ isActive: z.boolean().optional() }).optional())
.query(async ({ ctx, input }) => {
return ctx.db.country.findMany({
where: {
...(input?.isActive !== undefined ? { isActive: input.isActive } : {}),
},
include: { metroCities: { orderBy: { name: "asc" } } },
orderBy: { name: "asc" },
});
}),
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" });
return country;
}),
create: adminProcedure
.input(CreateCountrySchema)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.country.findUnique({ where: { code: input.code } });
if (existing) {
throw new TRPCError({ code: "CONFLICT", message: `Country code "${input.code}" already exists` });
}
return ctx.db.country.create({
data: {
code: input.code,
name: input.name,
dailyWorkingHours: input.dailyWorkingHours,
...(input.scheduleRules !== undefined ? { scheduleRules: jsonOrNull(input.scheduleRules) } : {}),
},
include: { metroCities: true },
});
}),
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" });
if (input.data.code && input.data.code !== existing.code) {
const conflict = await ctx.db.country.findUnique({ where: { code: input.data.code } });
if (conflict) {
throw new TRPCError({ code: "CONFLICT", message: `Country code "${input.data.code}" already exists` });
}
}
return ctx.db.country.update({
where: { id: input.id },
data: {
...(input.data.code !== undefined ? { code: input.data.code } : {}),
...(input.data.name !== undefined ? { name: input.data.name } : {}),
...(input.data.dailyWorkingHours !== undefined ? { dailyWorkingHours: input.data.dailyWorkingHours } : {}),
...(input.data.scheduleRules !== undefined ? { scheduleRules: jsonOrNull(input.data.scheduleRules) } : {}),
...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}),
},
include: { metroCities: true },
});
}),
// ─── Metro City ─────────────────────────────────────────────
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" });
return ctx.db.metroCity.create({
data: { name: input.name, countryId: input.countryId },
});
}),
updateCity: adminProcedure
.input(z.object({ id: z.string(), data: UpdateMetroCitySchema }))
.mutation(async ({ ctx, input }) => {
return ctx.db.metroCity.update({
where: { id: input.id },
data: { ...(input.data.name !== undefined ? { name: input.data.name } : {}) },
});
}),
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" });
if (city._count.resources > 0) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: `Cannot delete metro city assigned to ${city._count.resources} resource(s)`,
});
}
await ctx.db.metroCity.delete({ where: { id: input.id } });
return { success: true };
}),
});
@@ -0,0 +1,46 @@
import { FieldType } from "@planarchy/shared";
export interface CustomFieldFilterInput {
key: string;
value: string;
type: FieldType;
}
export interface DynamicFieldWhereClause {
path: [string];
equals?: boolean | number;
array_contains?: string;
string_contains?: string;
}
export function buildDynamicFieldWhereClauses(
filters: readonly CustomFieldFilterInput[] | undefined,
): DynamicFieldWhereClause[] {
const conditions: DynamicFieldWhereClause[] = [];
for (const { key, value, type } of filters ?? []) {
if (!value) continue;
if (type === FieldType.BOOLEAN) {
conditions.push({ path: [key], equals: value === "true" });
continue;
}
if (type === FieldType.NUMBER) {
const parsed = Number.parseFloat(value);
if (!Number.isNaN(parsed)) {
conditions.push({ path: [key], equals: parsed });
}
continue;
}
if (type === FieldType.MULTI_SELECT) {
conditions.push({ path: [key], array_contains: value });
continue;
}
conditions.push({ path: [key], string_contains: value });
}
return conditions;
}
+71
View File
@@ -0,0 +1,71 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure, controllerProcedure } from "../trpc.js";
import {
getDashboardChargeabilityOverview,
getDashboardDemand,
getDashboardOverview,
getDashboardPeakTimes,
getDashboardTopValueResources,
} from "@planarchy/application";
export const dashboardRouter = createTRPCRouter({
getOverview: protectedProcedure.query(({ ctx }) => getDashboardOverview(ctx.db)),
getPeakTimes: protectedProcedure
.input(
z.object({
startDate: z.string().datetime(),
endDate: z.string().datetime(),
granularity: z.enum(["week", "month"]).default("month"),
groupBy: z.enum(["project", "chapter", "resource"]).default("project"),
}),
)
.query(({ ctx, input }) =>
getDashboardPeakTimes(ctx.db, {
startDate: new Date(input.startDate),
endDate: new Date(input.endDate),
granularity: input.granularity,
groupBy: input.groupBy,
}),
),
getTopValueResources: protectedProcedure
.input(z.object({ limit: z.number().int().min(1).max(50).default(10) }))
.query(({ ctx, input }) =>
getDashboardTopValueResources(ctx.db, {
limit: input.limit,
userRole:
(ctx.session.user as { role?: string } | undefined)?.role ?? "USER",
}),
),
getDemand: protectedProcedure
.input(
z.object({
startDate: z.string().datetime(),
endDate: z.string().datetime(),
groupBy: z.enum(["project", "person", "chapter"]).default("project"),
}),
)
.query(({ ctx, input }) =>
getDashboardDemand(ctx.db, {
startDate: new Date(input.startDate),
endDate: new Date(input.endDate),
groupBy: input.groupBy,
}),
),
getChargeabilityOverview: controllerProcedure
.input(
z.object({
topN: z.number().int().min(1).max(50).default(10),
watchlistThreshold: z.number().default(15),
}),
)
.query(({ ctx, input }) =>
getDashboardChargeabilityOverview(ctx.db, {
topN: input.topN,
watchlistThreshold: input.watchlistThreshold,
}),
),
});
+296
View File
@@ -0,0 +1,296 @@
import {
expandScopeToEffort,
aggregateByDiscipline,
type EffortRuleInput,
type ScopeItemInput,
} from "@planarchy/engine";
import {
CreateEffortRuleSetSchema,
UpdateEffortRuleSetSchema,
ApplyEffortRulesSchema,
} from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js";
const ruleInclude = {
rules: { orderBy: { sortOrder: "asc" as const } },
} as const;
export const effortRuleRouter = createTRPCRouter({
list: controllerProcedure.query(async ({ ctx }) => {
return ctx.db.effortRuleSet.findMany({
include: ruleInclude,
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
});
}),
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" });
return ruleSet;
}),
create: managerProcedure
.input(CreateEffortRuleSetSchema)
.mutation(async ({ ctx, input }) => {
// If this is set as default, unset other defaults
if (input.isDefault) {
await ctx.db.effortRuleSet.updateMany({
where: { isDefault: true },
data: { isDefault: false },
});
}
return ctx.db.effortRuleSet.create({
data: {
name: input.name,
...(input.description ? { description: input.description } : {}),
isDefault: input.isDefault,
rules: {
create: input.rules.map((r, i) => ({
scopeType: r.scopeType,
discipline: r.discipline,
...(r.chapter ? { chapter: r.chapter } : {}),
unitMode: r.unitMode,
hoursPerUnit: r.hoursPerUnit,
...(r.description ? { description: r.description } : {}),
sortOrder: r.sortOrder ?? i,
})),
},
},
include: ruleInclude,
});
}),
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" });
// If setting as default, unset others
if (input.isDefault) {
await ctx.db.effortRuleSet.updateMany({
where: { isDefault: true, id: { not: input.id } },
data: { isDefault: false },
});
}
// If rules are provided, replace all existing rules
if (input.rules) {
await ctx.db.effortRule.deleteMany({ where: { ruleSetId: input.id } });
await ctx.db.effortRule.createMany({
data: input.rules.map((r, i) => ({
ruleSetId: input.id,
scopeType: r.scopeType,
discipline: r.discipline,
...(r.chapter ? { chapter: r.chapter } : {}),
unitMode: r.unitMode,
hoursPerUnit: r.hoursPerUnit,
...(r.description ? { description: r.description } : {}),
sortOrder: r.sortOrder ?? i,
})),
});
}
return ctx.db.effortRuleSet.update({
where: { id: input.id },
data: {
...(input.name !== undefined ? { name: input.name } : {}),
...(input.description !== undefined ? { description: input.description } : {}),
...(input.isDefault !== undefined ? { isDefault: input.isDefault } : {}),
},
include: ruleInclude,
});
}),
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 ctx.db.effortRuleSet.delete({ where: { id: input.id } });
return { id: input.id };
}),
/** Preview the expansion result without persisting */
preview: controllerProcedure
.input(z.object({
estimateId: z.string(),
ruleSetId: z.string(),
}))
.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" } } },
},
},
}),
ctx.db.effortRuleSet.findUnique({
where: { id: input.ruleSetId },
include: ruleInclude,
}),
]);
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" });
const scopeItems: ScopeItemInput[] = version.scopeItems.map((s) => ({
name: s.name,
scopeType: s.scopeType,
frameCount: s.frameCount,
itemCount: s.itemCount,
unitMode: s.unitMode,
}));
const rules: EffortRuleInput[] = ruleSet.rules.map((r) => ({
scopeType: r.scopeType,
discipline: r.discipline,
chapter: r.chapter,
unitMode: r.unitMode as "per_frame" | "per_item" | "flat",
hoursPerUnit: r.hoursPerUnit,
sortOrder: r.sortOrder,
}));
const result = expandScopeToEffort(scopeItems, rules);
const aggregated = aggregateByDiscipline(result.lines);
return {
...result,
aggregated,
scopeItemCount: scopeItems.length,
ruleCount: rules.length,
};
}),
/** Apply effort rules to generate demand lines on the working version */
apply: 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,
},
},
},
}),
ctx.db.effortRuleSet.findUnique({
where: { id: input.ruleSetId },
include: ruleInclude,
}),
]);
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") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Can only apply rules to a WORKING version" });
}
const scopeItems: ScopeItemInput[] = version.scopeItems.map((s) => ({
name: s.name,
scopeType: s.scopeType,
frameCount: s.frameCount,
itemCount: s.itemCount,
unitMode: s.unitMode,
}));
const rules: EffortRuleInput[] = ruleSet.rules.map((r) => ({
scopeType: r.scopeType,
discipline: r.discipline,
chapter: r.chapter,
unitMode: r.unitMode as "per_frame" | "per_item" | "flat",
hoursPerUnit: r.hoursPerUnit,
sortOrder: r.sortOrder,
}));
const result = expandScopeToEffort(scopeItems, rules);
// In replace mode, delete existing demand lines first
if (input.mode === "replace") {
await ctx.db.estimateDemandLine.deleteMany({
where: { estimateVersionId: version.id },
});
}
// Create demand lines from expanded results
if (result.lines.length > 0) {
await ctx.db.estimateDemandLine.createMany({
data: result.lines.map((line) => ({
estimateVersionId: version.id,
lineType: "LABOR",
name: `${line.discipline}${line.scopeItemName}`,
...(line.chapter ? { chapter: line.chapter } : {}),
hours: line.hours,
costRateCents: 0,
billRateCents: 0,
currency: estimate.baseCurrency,
costTotalCents: 0,
priceTotalCents: 0,
monthlySpread: {},
staffingAttributes: {},
metadata: {
effortRule: {
ruleSetId: ruleSet.id,
ruleSetName: ruleSet.name,
discipline: line.discipline,
unitMode: line.unitMode,
unitCount: line.unitCount,
hoursPerUnit: line.hoursPerUnit,
},
},
})),
});
}
// Log audit
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
effortRulesApplied: {
ruleSetId: ruleSet.id,
ruleSetName: ruleSet.name,
mode: input.mode,
linesGenerated: result.lines.length,
warnings: result.warnings,
},
},
},
},
});
return {
linesGenerated: result.lines.length,
warnings: result.warnings,
unmatchedScopeItems: result.unmatchedScopeItems,
};
}),
});
+278
View File
@@ -0,0 +1,278 @@
/**
* Vacation entitlement & balance router.
* Tracks annual leave quotas per resource per year.
* Balance is computed lazily: carryover from previous year is applied on first access.
*/
import { VacationType, VacationStatus } from "@planarchy/db";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
/** Types that consume from annual leave balance */
const BALANCE_TYPES: VacationType[] = [VacationType.ANNUAL, VacationType.OTHER];
/**
* Count calendar days between two dates (inclusive).
* Half-day vacations count as 0.5.
*/
function countDays(startDate: Date, endDate: Date, isHalfDay: boolean): number {
if (isHalfDay) return 0.5;
const ms = endDate.getTime() - startDate.getTime();
return Math.round(ms / 86_400_000) + 1;
}
/**
* Get or create an entitlement record, applying carryover from previous year if needed.
*/
async function getOrCreateEntitlement(
db: Parameters<Parameters<typeof protectedProcedure["query"]>[0]>[0]["ctx"]["db"],
resourceId: string,
year: number,
defaultDays: number,
) {
let entitlement = await db.vacationEntitlement.findUnique({
where: { resourceId_year: { resourceId, year } },
});
if (!entitlement) {
// Check previous year for carryover
const prevYear = await db.vacationEntitlement.findUnique({
where: { resourceId_year: { resourceId, year: year - 1 } },
});
const carryover = prevYear
? Math.max(0, prevYear.entitledDays - prevYear.usedDays - prevYear.pendingDays)
: 0;
entitlement = await db.vacationEntitlement.create({
data: {
resourceId,
year,
entitledDays: defaultDays + carryover,
carryoverDays: carryover,
usedDays: 0,
pendingDays: 0,
},
});
}
return entitlement;
}
/**
* Recompute used/pending days from actual vacation records and update the cached values.
*/
async function syncEntitlement(
db: Parameters<Parameters<typeof protectedProcedure["query"]>[0]>[0]["ctx"]["db"],
resourceId: string,
year: number,
defaultDays: number,
) {
const entitlement = await getOrCreateEntitlement(db, resourceId, year, defaultDays);
const vacations = await db.vacation.findMany({
where: {
resourceId,
type: { in: BALANCE_TYPES },
startDate: { gte: new Date(`${year}-01-01`), lte: new Date(`${year}-12-31`) },
status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] },
},
select: { startDate: true, endDate: true, status: true, isHalfDay: true },
});
let usedDays = 0;
let pendingDays = 0;
for (const v of vacations) {
const days = countDays(v.startDate, v.endDate, v.isHalfDay);
if (v.status === VacationStatus.APPROVED) usedDays += days;
else pendingDays += days;
}
return db.vacationEntitlement.update({
where: { id: entitlement.id },
data: { usedDays, pendingDays },
});
}
export const entitlementRouter = createTRPCRouter({
/**
* Get vacation balance for a resource in a year.
* Creates the entitlement record if it doesn't exist (with carryover).
*/
getBalance: protectedProcedure
.input(
z.object({
resourceId: z.string(),
year: z.number().int().min(2000).max(2100).default(new Date().getFullYear()),
}),
)
.query(async ({ ctx, input }) => {
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
const defaultDays = settings?.vacationDefaultDays ?? 28;
// Sync from real vacation records
const entitlement = await syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
// Also count sick days (informational)
const sickVacations = await ctx.db.vacation.findMany({
where: {
resourceId: input.resourceId,
type: VacationType.SICK,
status: VacationStatus.APPROVED,
startDate: { gte: new Date(`${input.year}-01-01`), lte: new Date(`${input.year}-12-31`) },
},
select: { startDate: true, endDate: true, isHalfDay: true },
});
const sickDays = sickVacations.reduce(
(sum, v) => sum + countDays(v.startDate, v.endDate, v.isHalfDay),
0,
);
return {
year: input.year,
resourceId: input.resourceId,
entitledDays: entitlement.entitledDays,
carryoverDays: entitlement.carryoverDays,
usedDays: entitlement.usedDays,
pendingDays: entitlement.pendingDays,
remainingDays: Math.max(
0,
entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays,
),
sickDays,
};
}),
/**
* Get entitlement record for a resource/year (admin/manager only).
*/
get: managerProcedure
.input(z.object({ resourceId: z.string(), year: z.number().int() }))
.query(async ({ ctx, input }) => {
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
const defaultDays = settings?.vacationDefaultDays ?? 28;
return getOrCreateEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
}),
/**
* Set entitlement for a resource/year (admin/manager only).
*/
set: managerProcedure
.input(
z.object({
resourceId: z.string(),
year: z.number().int(),
entitledDays: z.number().min(0).max(365),
}),
)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.vacationEntitlement.findUnique({
where: { resourceId_year: { resourceId: input.resourceId, year: input.year } },
});
if (existing) {
return ctx.db.vacationEntitlement.update({
where: { id: existing.id },
data: { entitledDays: input.entitledDays },
});
}
return ctx.db.vacationEntitlement.create({
data: {
resourceId: input.resourceId,
year: input.year,
entitledDays: input.entitledDays,
carryoverDays: 0,
usedDays: 0,
pendingDays: 0,
},
});
}),
/**
* Bulk-set entitlements for multiple resources (admin only).
* Useful for setting the default entitlement for a new year.
*/
bulkSet: adminProcedure
.input(
z.object({
year: z.number().int(),
entitledDays: z.number().min(0).max(365),
resourceIds: z.array(z.string()).optional(), // if omitted, applies to all active resources
}),
)
.mutation(async ({ ctx, input }) => {
const resources = await ctx.db.resource.findMany({
where: {
isActive: true,
...(input.resourceIds ? { id: { in: input.resourceIds } } : {}),
},
select: { id: true },
});
let updated = 0;
for (const r of resources) {
await ctx.db.vacationEntitlement.upsert({
where: { resourceId_year: { resourceId: r.id, year: input.year } },
create: {
resourceId: r.id,
year: input.year,
entitledDays: input.entitledDays,
carryoverDays: 0,
usedDays: 0,
pendingDays: 0,
},
update: { entitledDays: input.entitledDays },
});
updated++;
}
return { updated };
}),
/**
* Get year summary: all resources with their balance for a given year.
* Manager/admin only.
*/
getYearSummary: managerProcedure
.input(
z.object({
year: z.number().int().min(2000).max(2100).default(new Date().getFullYear()),
chapter: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
const defaultDays = settings?.vacationDefaultDays ?? 28;
const resources = await ctx.db.resource.findMany({
where: {
isActive: true,
...(input.chapter ? { chapter: input.chapter } : {}),
},
select: { id: true, displayName: true, eid: true, chapter: true },
orderBy: [{ chapter: "asc" }, { displayName: "asc" }],
});
const results = await Promise.all(
resources.map(async (r) => {
const entitlement = await syncEntitlement(ctx.db, r.id, input.year, defaultDays);
return {
resourceId: r.id,
displayName: r.displayName,
eid: r.eid,
chapter: r.chapter,
entitledDays: entitlement.entitledDays,
carryoverDays: entitlement.carryoverDays,
usedDays: entitlement.usedDays,
pendingDays: entitlement.pendingDays,
remainingDays: Math.max(
0,
entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays,
),
};
}),
);
return results;
}),
});
+757
View File
@@ -0,0 +1,757 @@
import {
approveEstimateVersion,
cloneEstimate,
createEstimateExport,
createEstimate,
createEstimatePlanningHandoff,
createEstimateRevision,
getEstimateById,
listEstimates,
submitEstimateVersion,
updateEstimateDraft,
} from "@planarchy/application";
import type { Prisma } from "@planarchy/db";
import {
normalizeEstimateDemandLine,
summarizeEstimateDemandLines,
generateWeekRange,
distributeHoursToWeeks,
aggregateWeeklyToMonthly,
aggregateWeeklyByChapter,
} from "@planarchy/engine";
import {
ApproveEstimateVersionSchema,
CloneEstimateSchema,
CreateEstimateExportSchema,
CreateEstimatePlanningHandoffSchema,
CreateEstimateSchema,
CreateEstimateRevisionSchema,
EstimateListFiltersSchema,
GenerateWeeklyPhasingSchema,
PermissionKey,
SubmitEstimateVersionSchema,
UpdateEstimateDraftSchema,
} from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import {
controllerProcedure,
createTRPCRouter,
managerProcedure,
protectedProcedure,
requirePermission,
} from "../trpc.js";
import { emitAllocationCreated } from "../sse/event-bus.js";
function buildComputedMetrics(
demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"],
) {
const summary = summarizeEstimateDemandLines(demandLines);
return [
{
key: "total_hours",
label: "Total Hours",
metricGroup: "summary",
valueDecimal: summary.totalHours,
metadata: {},
},
{
key: "total_cost",
label: "Total Cost",
metricGroup: "summary",
valueDecimal: summary.totalCostCents / 100,
valueCents: summary.totalCostCents,
currency: demandLines[0]?.currency ?? "EUR",
metadata: {},
},
{
key: "total_price",
label: "Total Price",
metricGroup: "summary",
valueDecimal: summary.totalPriceCents / 100,
valueCents: summary.totalPriceCents,
currency: demandLines[0]?.currency ?? "EUR",
metadata: {},
},
{
key: "margin",
label: "Margin",
metricGroup: "summary",
valueDecimal: summary.marginCents / 100,
valueCents: summary.marginCents,
currency: demandLines[0]?.currency ?? "EUR",
metadata: {},
},
{
key: "margin_percent",
label: "Margin %",
metricGroup: "summary",
valueDecimal: summary.marginPercent,
metadata: {},
},
];
}
function normalizeDemandLines<
T extends {
demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"];
resourceSnapshots: z.infer<typeof CreateEstimateSchema>["resourceSnapshots"];
},
>(input: T, baseCurrency: string) {
const snapshotsByResourceId = new Map(
input.resourceSnapshots
.filter(
(snapshot): snapshot is (typeof input.resourceSnapshots)[number] & {
resourceId: string;
} => typeof snapshot.resourceId === "string" && snapshot.resourceId.length > 0,
)
.map((snapshot) => [snapshot.resourceId, snapshot]),
);
return input.demandLines.map((line) =>
normalizeEstimateDemandLine(line, {
resourceSnapshot:
line.resourceId != null ? snapshotsByResourceId.get(line.resourceId) : null,
defaultCurrency: baseCurrency,
}),
);
}
function withComputedMetrics<
T extends {
demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"];
resourceSnapshots: z.infer<typeof CreateEstimateSchema>["resourceSnapshots"];
metrics: z.infer<typeof CreateEstimateSchema>["metrics"];
},
>(input: T, baseCurrency: string) {
const normalizedDemandLines = normalizeDemandLines(input, baseCurrency);
const computedMetrics = buildComputedMetrics(normalizedDemandLines);
const computedKeys = new Set(computedMetrics.map((metric) => metric.key));
return {
...input,
demandLines: normalizedDemandLines,
metrics: [
...input.metrics.filter((metric) => !computedKeys.has(metric.key)),
...computedMetrics,
],
};
}
export const estimateRouter = createTRPCRouter({
list: protectedProcedure
.input(EstimateListFiltersSchema.default({}))
.query(async ({ ctx, input }) =>
listEstimates(
ctx.db as unknown as Parameters<typeof listEstimates>[0],
input,
)),
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,
);
if (!estimate) {
throw new TRPCError({ code: "NOT_FOUND", message: "Estimate not found" });
}
return estimate;
}),
create: managerProcedure
.input(CreateEstimateSchema)
.mutation(async ({ ctx, input }) => {
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" });
}
}
const estimate = await createEstimate(
ctx.db as unknown as Parameters<typeof createEstimate>[0],
withComputedMetrics(input, input.baseCurrency),
);
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "CREATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
id: estimate.id,
name: estimate.name,
status: estimate.status,
projectId: estimate.projectId,
latestVersionNumber: estimate.latestVersionNumber,
},
} as Prisma.InputJsonValue,
},
});
return estimate;
}),
clone: managerProcedure
.input(CloneEstimateSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
let estimate;
try {
estimate = await cloneEstimate(
ctx.db as unknown as Parameters<typeof cloneEstimate>[0],
input,
);
} catch (error) {
if (error instanceof Error) {
if (
error.message === "Source estimate not found" ||
error.message === "Source estimate has no versions"
) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
}
throw error;
}
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "CREATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
id: estimate.id,
name: estimate.name,
clonedFrom: input.sourceEstimateId,
},
} as Prisma.InputJsonValue,
},
});
return estimate;
}),
updateDraft: managerProcedure
.input(UpdateEstimateDraftSchema)
.mutation(async ({ ctx, input }) => {
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" });
}
}
let estimate;
try {
estimate = await updateEstimateDraft(
ctx.db as unknown as Parameters<typeof updateEstimateDraft>[0],
withComputedMetrics(input, input.baseCurrency ?? "EUR"),
);
} catch (error) {
if (error instanceof Error && error.message === "Estimate not found") {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
if (
error instanceof Error &&
error.message === "Estimate has no working version"
) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: error.message,
});
}
throw error;
}
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
id: estimate.id,
name: estimate.name,
status: estimate.status,
latestVersionNumber: estimate.latestVersionNumber,
workingVersionId: estimate.versions.find(
(version) => version.status === "WORKING",
)?.id,
},
} as Prisma.InputJsonValue,
},
});
return estimate;
}),
submitVersion: managerProcedure
.input(SubmitEstimateVersionSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
let estimate;
try {
estimate = await submitEstimateVersion(
ctx.db as unknown as Parameters<typeof submitEstimateVersion>[0],
input,
);
} catch (error) {
if (error instanceof Error) {
if (
error.message === "Estimate not found" ||
error.message === "Estimate version not found"
) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
if (
error.message === "Estimate has no working version" ||
error.message === "Only working versions can be submitted"
) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: error.message,
});
}
}
throw error;
}
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
id: estimate.id,
status: estimate.status,
submittedVersionId: estimate.versions.find(
(version) => version.status === "SUBMITTED",
)?.id,
},
} as Prisma.InputJsonValue,
},
});
return estimate;
}),
approveVersion: managerProcedure
.input(ApproveEstimateVersionSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
let estimate;
try {
estimate = await approveEstimateVersion(
ctx.db as unknown as Parameters<typeof approveEstimateVersion>[0],
input,
);
} catch (error) {
if (error instanceof Error) {
if (
error.message === "Estimate not found" ||
error.message === "Estimate version not found"
) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
if (
error.message === "Estimate has no submitted version" ||
error.message === "Only submitted versions can be approved"
) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: error.message,
});
}
}
throw error;
}
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
id: estimate.id,
status: estimate.status,
approvedVersionId: estimate.versions.find(
(version) => version.status === "APPROVED",
)?.id,
},
} as Prisma.InputJsonValue,
},
});
return estimate;
}),
createRevision: managerProcedure
.input(CreateEstimateRevisionSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
let estimate;
try {
estimate = await createEstimateRevision(
ctx.db as unknown as Parameters<typeof createEstimateRevision>[0],
input,
);
} catch (error) {
if (error instanceof Error) {
if (
error.message === "Estimate not found" ||
error.message === "Estimate version not found"
) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
if (
error.message === "Estimate already has a working version" ||
error.message === "Estimate has no locked version to revise" ||
error.message === "Source version must be locked before creating a revision"
) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: error.message,
});
}
}
throw error;
}
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
id: estimate.id,
status: estimate.status,
latestVersionNumber: estimate.latestVersionNumber,
workingVersionId: estimate.versions.find(
(version) => version.status === "WORKING",
)?.id,
},
} as Prisma.InputJsonValue,
},
});
return estimate;
}),
createExport: managerProcedure
.input(CreateEstimateExportSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
let estimate;
try {
estimate = await createEstimateExport(
ctx.db as unknown as Parameters<typeof createEstimateExport>[0],
input,
);
} catch (error) {
if (error instanceof Error) {
if (
error.message === "Estimate not found" ||
error.message === "Estimate version not found" ||
error.message === "Estimate has no version to export"
) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
}
throw error;
}
const exportedVersion = input.versionId
? estimate.versions.find((version) => version.id === input.versionId)
: estimate.versions[0];
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
id: estimate.id,
exportFormat: input.format,
exportCount: exportedVersion?.exports.length ?? null,
versionId: exportedVersion?.id ?? null,
},
} as Prisma.InputJsonValue,
},
});
return estimate;
}),
createPlanningHandoff: managerProcedure
.input(CreateEstimatePlanningHandoffSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
let result;
try {
result = await createEstimatePlanningHandoff(
ctx.db as unknown as Parameters<typeof createEstimatePlanningHandoff>[0],
input,
);
} catch (error) {
if (error instanceof Error) {
if (
error.message === "Estimate not found" ||
error.message === "Estimate version not found" ||
error.message === "Linked project not found"
) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
if (
error.message === "Estimate has no approved version" ||
error.message === "Only approved versions can be handed off to planning" ||
error.message === "Estimate must be linked to a project before planning handoff" ||
error.message === "Planning handoff already exists for this approved version" ||
error.message === "Linked project has an invalid date range" ||
error.message.startsWith("Project window has no working days for demand line")
) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: error.message,
});
}
}
throw error;
}
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: result.estimateId,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
planningHandoff: {
versionId: result.estimateVersionId,
versionNumber: result.estimateVersionNumber,
projectId: result.projectId,
createdCount: result.createdCount,
assignedCount: result.assignedCount,
placeholderCount: result.placeholderCount,
fallbackPlaceholderCount: result.fallbackPlaceholderCount,
},
},
} as Prisma.InputJsonValue,
},
});
for (const allocation of result.allocations) {
emitAllocationCreated({
id: allocation.id,
projectId: allocation.projectId,
resourceId: allocation.resourceId ?? null,
});
}
return result;
}),
generateWeeklyPhasing: managerProcedure
.input(GenerateWeeklyPhasingSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
const estimate = await getEstimateById(
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
input.estimateId,
);
if (!estimate) {
throw new TRPCError({ code: "NOT_FOUND", message: "Estimate not found" });
}
const workingVersion = estimate.versions.find(
(v) => v.status === "WORKING",
);
if (!workingVersion) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "Estimate has no working version",
});
}
const pattern = input.pattern ?? "even";
// Distribute hours for each demand line and update DB
const updates: Array<{ id: string; monthlySpread: Record<string, number>; metadata: Record<string, unknown> }> = [];
for (const line of workingVersion.demandLines) {
const result = distributeHoursToWeeks({
totalHours: line.hours,
startDate: input.startDate,
endDate: input.endDate,
pattern,
});
const monthlySpread = aggregateWeeklyToMonthly(result.weeklyHours);
const existingMetadata = (line.metadata ?? {}) as Record<string, unknown>;
const metadata = {
...existingMetadata,
weeklyPhasing: {
startDate: input.startDate,
endDate: input.endDate,
pattern,
weeklyHours: result.weeklyHours,
generatedAt: new Date().toISOString(),
},
};
updates.push({ id: line.id, monthlySpread, metadata });
}
// Batch update all demand lines
await Promise.all(
updates.map((update) =>
ctx.db.estimateDemandLine.update({
where: { id: update.id },
data: {
monthlySpread: update.monthlySpread as Prisma.InputJsonValue,
metadata: update.metadata as Prisma.InputJsonValue,
},
}),
),
);
return {
estimateId: input.estimateId,
versionId: workingVersion.id,
linesUpdated: updates.length,
startDate: input.startDate,
endDate: input.endDate,
pattern,
};
}),
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,
);
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];
if (!version) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "Estimate has no versions",
});
}
// Extract weekly phasing from each demand line's metadata
type WeeklyPhasingMeta = {
startDate: string;
endDate: string;
pattern: string;
weeklyHours: Record<string, number>;
generatedAt: string;
};
const linesWithPhasing: Array<{
id: string;
name: string;
chapter: string | null;
hours: number;
weeklyHours: Record<string, number>;
}> = [];
let phasingConfig: { startDate: string; endDate: string; pattern: string } | null = null;
for (const line of version.demandLines) {
const meta = (line.metadata ?? {}) as Record<string, unknown>;
const phasing = meta["weeklyPhasing"] as WeeklyPhasingMeta | undefined;
if (phasing) {
if (!phasingConfig) {
phasingConfig = {
startDate: phasing.startDate,
endDate: phasing.endDate,
pattern: phasing.pattern,
};
}
linesWithPhasing.push({
id: line.id,
name: line.name,
chapter: line.chapter ?? null,
hours: line.hours,
weeklyHours: phasing.weeklyHours,
});
}
}
if (!phasingConfig || linesWithPhasing.length === 0) {
return {
estimateId: input.estimateId,
versionId: version.id,
hasPhasing: false as const,
config: null,
weeks: [],
lines: [],
chapterAggregation: {},
};
}
const weeks = generateWeekRange(phasingConfig.startDate, phasingConfig.endDate);
const chapterAggregation = aggregateWeeklyByChapter(linesWithPhasing);
return {
estimateId: input.estimateId,
versionId: version.id,
hasPhasing: true as const,
config: phasingConfig,
weeks,
lines: linesWithPhasing,
chapterAggregation,
};
}),
});
@@ -0,0 +1,343 @@
import {
applyExperienceMultipliers,
applyExperienceMultipliersBatch,
type ExperienceMultiplierRule as EngineRule,
} from "@planarchy/engine";
import {
CreateExperienceMultiplierSetSchema,
UpdateExperienceMultiplierSetSchema,
ApplyExperienceMultipliersSchema,
} from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js";
const ruleInclude = {
rules: { orderBy: { sortOrder: "asc" as const } },
} as const;
function toEngineRules(
dbRules: Array<{
chapter: string | null;
location: string | null;
level: string | null;
costMultiplier: number;
billMultiplier: number;
shoringRatio: number | null;
additionalEffortRatio: number | null;
description: string | null;
}>,
): EngineRule[] {
return dbRules.map((r) => ({
...(r.chapter != null ? { chapter: r.chapter } : {}),
...(r.location != null ? { location: r.location } : {}),
...(r.level != null ? { level: r.level } : {}),
costMultiplier: r.costMultiplier,
billMultiplier: r.billMultiplier,
...(r.shoringRatio != null ? { shoringRatio: r.shoringRatio } : {}),
...(r.additionalEffortRatio != null ? { additionalEffortRatio: r.additionalEffortRatio } : {}),
...(r.description != null ? { description: r.description } : {}),
}));
}
export const experienceMultiplierRouter = createTRPCRouter({
list: controllerProcedure.query(async ({ ctx }) => {
return ctx.db.experienceMultiplierSet.findMany({
include: ruleInclude,
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
});
}),
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" });
return set;
}),
create: managerProcedure
.input(CreateExperienceMultiplierSetSchema)
.mutation(async ({ ctx, input }) => {
if (input.isDefault) {
await ctx.db.experienceMultiplierSet.updateMany({
where: { isDefault: true },
data: { isDefault: false },
});
}
return ctx.db.experienceMultiplierSet.create({
data: {
name: input.name,
...(input.description ? { description: input.description } : {}),
isDefault: input.isDefault,
rules: {
create: input.rules.map((r, i) => ({
...(r.chapter ? { chapter: r.chapter } : {}),
...(r.location ? { location: r.location } : {}),
...(r.level ? { level: r.level } : {}),
costMultiplier: r.costMultiplier,
billMultiplier: r.billMultiplier,
...(r.shoringRatio !== undefined ? { shoringRatio: r.shoringRatio } : {}),
...(r.additionalEffortRatio !== undefined ? { additionalEffortRatio: r.additionalEffortRatio } : {}),
...(r.description ? { description: r.description } : {}),
sortOrder: r.sortOrder ?? i,
})),
},
},
include: ruleInclude,
});
}),
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" });
if (input.isDefault) {
await ctx.db.experienceMultiplierSet.updateMany({
where: { isDefault: true, id: { not: input.id } },
data: { isDefault: false },
});
}
if (input.rules) {
await ctx.db.experienceMultiplierRule.deleteMany({ where: { multiplierSetId: input.id } });
await ctx.db.experienceMultiplierRule.createMany({
data: input.rules.map((r, i) => ({
multiplierSetId: input.id,
...(r.chapter ? { chapter: r.chapter } : {}),
...(r.location ? { location: r.location } : {}),
...(r.level ? { level: r.level } : {}),
costMultiplier: r.costMultiplier,
billMultiplier: r.billMultiplier,
...(r.shoringRatio !== undefined ? { shoringRatio: r.shoringRatio } : {}),
...(r.additionalEffortRatio !== undefined ? { additionalEffortRatio: r.additionalEffortRatio } : {}),
...(r.description ? { description: r.description } : {}),
sortOrder: r.sortOrder ?? i,
})),
});
}
return ctx.db.experienceMultiplierSet.update({
where: { id: input.id },
data: {
...(input.name !== undefined ? { name: input.name } : {}),
...(input.description !== undefined ? { description: input.description } : {}),
...(input.isDefault !== undefined ? { isDefault: input.isDefault } : {}),
},
include: ruleInclude,
});
}),
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 ctx.db.experienceMultiplierSet.delete({ where: { id: input.id } });
return { id: input.id };
}),
/** Preview the rate adjustment without persisting */
preview: controllerProcedure
.input(z.object({
estimateId: z.string(),
multiplierSetId: z.string(),
}))
.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" } } },
},
},
}),
ctx.db.experienceMultiplierSet.findUnique({
where: { id: input.multiplierSetId },
include: ruleInclude,
}),
]);
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" });
const engineRules = toEngineRules(multiplierSet.rules);
const demandLines = version.demandLines;
const previews = demandLines.map((line) => {
const result = applyExperienceMultipliers(
{
costRateCents: line.costRateCents,
billRateCents: line.billRateCents,
hours: line.hours,
...(line.chapter != null ? { chapter: line.chapter } : {}),
...(line.metadata != null && typeof line.metadata === "object" && "location" in (line.metadata as Record<string, unknown>)
? { location: (line.metadata as Record<string, unknown>).location as string }
: {}),
...(line.staffingAttributes != null && typeof line.staffingAttributes === "object" && "level" in (line.staffingAttributes as Record<string, unknown>)
? { level: (line.staffingAttributes as Record<string, unknown>).level as string }
: {}),
},
engineRules,
);
return {
demandLineId: line.id,
name: line.name,
chapter: line.chapter,
originalCostRateCents: line.costRateCents,
originalBillRateCents: line.billRateCents,
originalHours: line.hours,
adjustedCostRateCents: result.adjustedCostRateCents,
adjustedBillRateCents: result.adjustedBillRateCents,
adjustedHours: result.adjustedHours,
appliedRules: result.appliedRules,
hasChanges:
result.adjustedCostRateCents !== line.costRateCents ||
result.adjustedBillRateCents !== line.billRateCents ||
result.adjustedHours !== line.hours,
};
});
const linesChanged = previews.filter((p) => p.hasChanges).length;
const totalOriginalCostCents = demandLines.reduce((s, l) => s + l.costRateCents * l.hours, 0);
const totalAdjustedCostCents = previews.reduce((s, p) => s + p.adjustedCostRateCents * p.adjustedHours, 0);
return {
previews,
demandLineCount: demandLines.length,
linesChanged,
totalOriginalCostCents: Math.round(totalOriginalCostCents),
totalAdjustedCostCents: Math.round(totalAdjustedCostCents),
multiplierSetName: multiplierSet.name,
ruleCount: multiplierSet.rules.length,
};
}),
/** Apply multipliers to demand lines on the working version */
apply: 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 },
},
},
}),
ctx.db.experienceMultiplierSet.findUnique({
where: { id: input.multiplierSetId },
include: ruleInclude,
}),
]);
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") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Can only apply multipliers to a WORKING version" });
}
const engineRules = toEngineRules(multiplierSet.rules);
const demandLines = version.demandLines;
const inputs = demandLines.map((line) => ({
costRateCents: line.costRateCents,
billRateCents: line.billRateCents,
hours: line.hours,
...(line.chapter != null ? { chapter: line.chapter } : {}),
...(line.metadata != null && typeof line.metadata === "object" && "location" in (line.metadata as Record<string, unknown>)
? { location: (line.metadata as Record<string, unknown>).location as string }
: {}),
...(line.staffingAttributes != null && typeof line.staffingAttributes === "object" && "level" in (line.staffingAttributes as Record<string, unknown>)
? { level: (line.staffingAttributes as Record<string, unknown>).level as string }
: {}),
}));
const batch = applyExperienceMultipliersBatch(inputs, engineRules);
// Update each demand line that changed
let updatedCount = 0;
for (let i = 0; i < demandLines.length; i++) {
const line = demandLines[i]!;
const result = batch.results[i]!;
if (
result.adjustedCostRateCents !== line.costRateCents ||
result.adjustedBillRateCents !== line.billRateCents ||
result.adjustedHours !== line.hours
) {
const newCostTotal = Math.round(result.adjustedCostRateCents * result.adjustedHours);
const newPriceTotal = Math.round(result.adjustedBillRateCents * result.adjustedHours);
await ctx.db.estimateDemandLine.update({
where: { id: line.id },
data: {
costRateCents: result.adjustedCostRateCents,
billRateCents: result.adjustedBillRateCents,
hours: result.adjustedHours,
costTotalCents: newCostTotal,
priceTotalCents: newPriceTotal,
metadata: {
...(typeof line.metadata === "object" && line.metadata !== null ? line.metadata as Record<string, unknown> : {}),
experienceMultiplier: {
setId: multiplierSet.id,
setName: multiplierSet.name,
appliedRules: result.appliedRules,
originalCostRateCents: line.costRateCents,
originalBillRateCents: line.billRateCents,
originalHours: line.hours,
},
},
},
});
updatedCount++;
}
}
// Audit log
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
experienceMultipliersApplied: {
setId: multiplierSet.id,
setName: multiplierSet.name,
linesUpdated: updatedCount,
totalOriginalHours: batch.totalOriginalHours,
totalAdjustedHours: batch.totalAdjustedHours,
},
},
},
},
});
return {
linesUpdated: updatedCount,
totalOriginalHours: batch.totalOriginalHours,
totalAdjustedHours: batch.totalAdjustedHours,
};
}),
});
+157
View File
@@ -0,0 +1,157 @@
import { BlueprintTarget, PermissionKey } from "@planarchy/shared";
import type { BlueprintFieldDefinition } from "@planarchy/shared";
import { z } from "zod";
import { controllerProcedure, createTRPCRouter, managerProcedure, requirePermission } from "../trpc.js";
export const importExportRouter = createTRPCRouter({
/**
* Export resources as CSV.
*/
exportResourcesCSV: controllerProcedure.query(async ({ ctx }) => {
const [resources, globalBlueprints] = await Promise.all([
ctx.db.resource.findMany({
where: { isActive: true },
orderBy: { eid: "asc" },
}),
ctx.db.blueprint.findMany({
where: { target: BlueprintTarget.RESOURCE, isGlobal: true, isActive: true },
select: { fieldDefs: true },
}),
]);
// Collect all custom field defs that should appear in exports (showInList = true)
const customDefs = globalBlueprints
.flatMap((b) => b.fieldDefs as unknown as BlueprintFieldDefinition[])
.filter((f) => f.showInList);
function escapeCSV(v: unknown): string {
const s = v === null || v === undefined ? "" : String(v);
return s.includes(",") || s.includes('"') || s.includes("\n")
? `"${s.replace(/"/g, '""')}"`
: s;
}
const builtinHeaders = ["eid", "displayName", "email", "chapter", "lcrCents", "ucrCents", "currency", "chargeabilityTarget"];
const customHeaders = customDefs.map((f) => f.label);
const headers = [...builtinHeaders, ...customHeaders];
const rows = resources.map((r) => {
const df = r.dynamicFields as unknown as Record<string, unknown> ?? {};
const builtins = [r.eid, r.displayName, r.email, r.chapter ?? "", r.lcrCents, r.ucrCents, r.currency, r.chargeabilityTarget];
const customs = customDefs.map((f) => df[f.key] ?? "");
return [...builtins, ...customs].map(escapeCSV).join(",");
});
return [headers.map(escapeCSV).join(","), ...rows].join("\n");
}),
/**
* Export projects as CSV.
*/
exportProjectsCSV: controllerProcedure.query(async ({ ctx }) => {
const [projects, globalBlueprints] = await Promise.all([
ctx.db.project.findMany({ orderBy: { shortCode: "asc" } }),
ctx.db.blueprint.findMany({
where: { target: BlueprintTarget.PROJECT, isGlobal: true, isActive: true },
select: { fieldDefs: true },
}),
]);
const customDefs = globalBlueprints
.flatMap((b) => b.fieldDefs as unknown as BlueprintFieldDefinition[])
.filter((f) => f.showInList);
function escapeCSV(v: unknown): string {
const s = v === null || v === undefined ? "" : String(v);
return s.includes(",") || s.includes('"') || s.includes("\n")
? `"${s.replace(/"/g, '""')}"`
: s;
}
const builtinHeaders = ["shortCode", "name", "orderType", "status", "budgetCents", "startDate", "endDate", "winProbability"];
const headers = [...builtinHeaders, ...customDefs.map((f) => f.label)];
const rows = projects.map((p) => {
const df = p.dynamicFields as unknown as Record<string, unknown> ?? {};
const builtins = [
p.shortCode, p.name, p.orderType, p.status, p.budgetCents,
p.startDate.toISOString().split("T")[0],
p.endDate.toISOString().split("T")[0],
p.winProbability,
];
return [...builtins, ...customDefs.map((f) => df[f.key] ?? "")].map(escapeCSV).join(",");
});
return [headers.map(escapeCSV).join(","), ...rows].join("\n");
}),
/**
* Import resources from CSV data (parsed client-side).
*/
importCSV: managerProcedure
.input(
z.object({
entityType: z.enum(["resources", "projects", "allocations"]),
rows: z.array(z.record(z.string(), z.string())),
dryRun: z.boolean().default(true),
}),
)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.IMPORT_DATA);
const { entityType, rows, dryRun } = input;
const results = {
total: rows.length,
created: 0,
updated: 0,
errors: [] as { row: number; message: string }[],
dryRun,
};
if (dryRun) {
// Validate without committing
return { ...results, message: `Dry run: ${rows.length} rows validated` };
}
// Basic import logic per entity type
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (!row) continue;
try {
if (entityType === "resources") {
const existing = await ctx.db.resource.findFirst({
where: { eid: row["eid"] ?? "" },
});
if (existing) {
await ctx.db.resource.update({
where: { id: existing.id },
data: {
displayName: row["displayName"] ?? existing.displayName,
email: row["email"] ?? existing.email,
chapter: row["chapter"] ?? existing.chapter,
lcrCents: row["lcrCents"] ? parseInt(row["lcrCents"]) : existing.lcrCents,
},
});
results.updated++;
} else {
results.errors.push({ row: i + 1, message: "New resource creation via import requires full data" });
}
}
} catch (err) {
results.errors.push({ row: i + 1, message: err instanceof Error ? err.message : "Unknown error" });
}
}
await ctx.db.auditLog.create({
data: {
entityType: entityType,
entityId: "bulk-import",
action: "IMPORT",
changes: { summary: results },
},
});
return results;
}),
});
+54
View File
@@ -0,0 +1,54 @@
import { createTRPCRouter } from "../trpc.js";
import { allocationRouter } from "./allocation.js";
import { blueprintRouter } from "./blueprint.js";
import { chargeabilityReportRouter } from "./chargeability-report.js";
import { clientRouter } from "./client.js";
import { countryRouter } from "./country.js";
import { dashboardRouter } from "./dashboard.js";
import { effortRuleRouter } from "./effort-rule.js";
import { experienceMultiplierRouter } from "./experience-multiplier.js";
import { estimateRouter } from "./estimate.js";
import { entitlementRouter } from "./entitlement.js";
import { importExportRouter } from "./import-export.js";
import { managementLevelRouter } from "./management-level.js";
import { notificationRouter } from "./notification.js";
import { orgUnitRouter } from "./org-unit.js";
import { projectRouter } from "./project.js";
import { rateCardRouter } from "./rate-card.js";
import { resourceRouter } from "./resource.js";
import { roleRouter } from "./role.js";
import { settingsRouter } from "./settings.js";
import { staffingRouter } from "./staffing.js";
import { timelineRouter } from "./timeline.js";
import { userRouter } from "./user.js";
import { utilizationCategoryRouter } from "./utilization-category.js";
import { vacationRouter } from "./vacation.js";
export const appRouter = createTRPCRouter({
dashboard: dashboardRouter,
effortRule: effortRuleRouter,
experienceMultiplier: experienceMultiplierRouter,
estimate: estimateRouter,
resource: resourceRouter,
project: projectRouter,
allocation: allocationRouter,
timeline: timelineRouter,
staffing: staffingRouter,
blueprint: blueprintRouter,
role: roleRouter,
user: userRouter,
importExport: importExportRouter,
vacation: vacationRouter,
entitlement: entitlementRouter,
notification: notificationRouter,
settings: settingsRouter,
country: countryRouter,
orgUnit: orgUnitRouter,
utilizationCategory: utilizationCategoryRouter,
clientEntity: clientRouter,
managementLevel: managementLevelRouter,
rateCard: rateCardRouter,
chargeabilityReport: chargeabilityReportRouter,
});
export type AppRouter = typeof appRouter;
+133
View File
@@ -0,0 +1,133 @@
import {
CreateManagementLevelGroupSchema,
CreateManagementLevelSchema,
UpdateManagementLevelGroupSchema,
UpdateManagementLevelSchema,
} from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
export const managementLevelRouter = createTRPCRouter({
// ─── Groups ─────────────────────────────────────────────
listGroups: protectedProcedure.query(async ({ ctx }) => {
return ctx.db.managementLevelGroup.findMany({
include: { levels: { orderBy: { name: "asc" } } },
orderBy: { sortOrder: "asc" },
});
}),
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" });
return group;
}),
createGroup: adminProcedure
.input(CreateManagementLevelGroupSchema)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.managementLevelGroup.findUnique({ where: { name: input.name } });
if (existing) {
throw new TRPCError({ code: "CONFLICT", message: `Group "${input.name}" already exists` });
}
return ctx.db.managementLevelGroup.create({
data: {
name: input.name,
targetPercentage: input.targetPercentage,
sortOrder: input.sortOrder,
},
include: { levels: true },
});
}),
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" });
if (input.data.name && input.data.name !== existing.name) {
const conflict = await ctx.db.managementLevelGroup.findUnique({ where: { name: input.data.name } });
if (conflict) {
throw new TRPCError({ code: "CONFLICT", message: `Group "${input.data.name}" already exists` });
}
}
return ctx.db.managementLevelGroup.update({
where: { id: input.id },
data: {
...(input.data.name !== undefined ? { name: input.data.name } : {}),
...(input.data.targetPercentage !== undefined ? { targetPercentage: input.data.targetPercentage } : {}),
...(input.data.sortOrder !== undefined ? { sortOrder: input.data.sortOrder } : {}),
},
include: { levels: true },
});
}),
// ─── Levels ─────────────────────────────────────────────
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" });
const existing = await ctx.db.managementLevel.findUnique({ where: { name: input.name } });
if (existing) {
throw new TRPCError({ code: "CONFLICT", message: `Level "${input.name}" already exists` });
}
return ctx.db.managementLevel.create({
data: { name: input.name, groupId: input.groupId },
});
}),
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" });
if (input.data.name && input.data.name !== existing.name) {
const conflict = await ctx.db.managementLevel.findUnique({ where: { name: input.data.name } });
if (conflict) {
throw new TRPCError({ code: "CONFLICT", message: `Level "${input.data.name}" already exists` });
}
}
return ctx.db.managementLevel.update({
where: { id: input.id },
data: {
...(input.data.name !== undefined ? { name: input.data.name } : {}),
...(input.data.groupId !== undefined ? { groupId: input.data.groupId } : {}),
},
});
}),
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" });
if (level._count.resources > 0) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: `Cannot delete level assigned to ${level._count.resources} resource(s)`,
});
}
await ctx.db.managementLevel.delete({ where: { id: input.id } });
return { success: true };
}),
});
+92
View File
@@ -0,0 +1,92 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, managerProcedure, protectedProcedure } from "../trpc.js";
import { emitNotificationCreated } from "../sse/event-bus.js";
/** Resolve the DB user id from the session email. Throws UNAUTHORIZED if not found. */
async function resolveUserId(ctx: {
db: { user: { findUnique: (args: { where: { email: string }; select: { id: true } }) => Promise<{ id: string } | null> } };
session: { user?: { email?: string | null } | null };
}): Promise<string> {
const email = ctx.session.user?.email;
if (!email) throw new TRPCError({ code: "UNAUTHORIZED" });
const user = await ctx.db.user.findUnique({ where: { email }, select: { id: true } });
if (!user) throw new TRPCError({ code: "UNAUTHORIZED" });
return user.id;
}
export const notificationRouter = createTRPCRouter({
/** List notifications for the current user */
list: protectedProcedure
.input(
z.object({
unreadOnly: z.boolean().optional(),
limit: z.number().min(1).max(100).default(50),
}),
)
.query(async ({ ctx, input }) => {
const userId = await resolveUserId(ctx);
return ctx.db.notification.findMany({
where: {
userId,
...(input.unreadOnly ? { readAt: null } : {}),
},
orderBy: { createdAt: "desc" },
take: input.limit,
});
}),
/** Count unread notifications */
unreadCount: protectedProcedure.query(async ({ ctx }) => {
const userId = await resolveUserId(ctx);
return ctx.db.notification.count({
where: { userId, readAt: null },
});
}),
/** Mark one or all as read */
markRead: protectedProcedure
.input(z.object({ id: z.string().optional() }))
.mutation(async ({ ctx, input }) => {
const userId = await resolveUserId(ctx);
const now = new Date();
if (input.id) {
await ctx.db.notification.update({
where: { id: input.id, userId },
data: { readAt: now },
});
} else {
await ctx.db.notification.updateMany({
where: { userId, readAt: null },
data: { readAt: now },
});
}
}),
/** Create a notification — restricted to managers and admins */
create: managerProcedure
.input(
z.object({
userId: z.string(),
type: z.string(),
title: z.string(),
body: z.string().optional(),
entityId: z.string().optional(),
entityType: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const n = await ctx.db.notification.create({
data: {
userId: input.userId,
type: input.type,
title: input.title,
...(input.body !== undefined ? { body: input.body } : {}),
...(input.entityId !== undefined ? { entityId: input.entityId } : {}),
...(input.entityType !== undefined ? { entityType: input.entityType } : {}),
},
});
emitNotificationCreated(input.userId, n.id);
return n;
}),
});
+128
View File
@@ -0,0 +1,128 @@
import { CreateOrgUnitSchema, UpdateOrgUnitSchema } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
import type { OrgUnitTree } from "@planarchy/shared";
interface FlatOrgUnit {
id: string;
name: string;
shortName: string | null;
level: number;
parentId: string | null;
sortOrder: number;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}
function buildTree(flatItems: FlatOrgUnit[], parentId: string | null = null): OrgUnitTree[] {
return flatItems
.filter((item) => item.parentId === parentId)
.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))
.map((item) => ({
...item,
children: buildTree(flatItems, item.id),
}));
}
export const orgUnitRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
level: z.number().int().min(5).max(7).optional(),
parentId: z.string().optional(),
isActive: z.boolean().optional(),
}).optional(),
)
.query(async ({ ctx, input }) => {
return ctx.db.orgUnit.findMany({
where: {
...(input?.level !== undefined ? { level: input.level } : {}),
...(input?.parentId !== undefined ? { parentId: input.parentId } : {}),
...(input?.isActive !== undefined ? { isActive: input.isActive } : {}),
},
orderBy: [{ level: "asc" }, { sortOrder: "asc" }, { name: "asc" }],
});
}),
getTree: protectedProcedure
.input(z.object({ isActive: z.boolean().optional() }).optional())
.query(async ({ ctx, input }) => {
const all = await ctx.db.orgUnit.findMany({
where: {
...(input?.isActive !== undefined ? { isActive: input.isActive } : {}),
},
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
});
return buildTree(all);
}),
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" });
return unit;
}),
create: adminProcedure
.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" });
if (parent.level >= input.level) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Child level (${input.level}) must be greater than parent level (${parent.level})`,
});
}
}
return ctx.db.orgUnit.create({
data: {
name: input.name,
...(input.shortName !== undefined ? { shortName: input.shortName } : {}),
level: input.level,
...(input.parentId ? { parentId: input.parentId } : {}),
sortOrder: input.sortOrder,
},
});
}),
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" });
return ctx.db.orgUnit.update({
where: { id: input.id },
data: {
...(input.data.name !== undefined ? { name: input.data.name } : {}),
...(input.data.shortName !== undefined ? { shortName: input.data.shortName } : {}),
...(input.data.sortOrder !== undefined ? { sortOrder: input.data.sortOrder } : {}),
...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}),
...(input.data.parentId !== undefined ? { parentId: input.data.parentId } : {}),
},
});
}),
deactivate: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.db.orgUnit.update({
where: { id: input.id },
data: { isActive: false },
});
}),
});
@@ -0,0 +1,92 @@
import { buildSplitAllocationReadModel } from "@planarchy/application";
import type { PrismaClient } from "@planarchy/db";
import { AllocationStatus } from "@planarchy/shared";
export const PROJECT_PLANNING_ALLOCATION_INCLUDE = {
resource: {
select: {
id: true,
displayName: true,
eid: true,
chapter: true,
lcrCents: true,
availability: true,
},
},
project: {
select: {
id: true,
name: true,
shortCode: true,
orderType: true,
budgetCents: true,
winProbability: true,
status: true,
startDate: true,
endDate: true,
staffingReqs: true,
responsiblePerson: true,
},
},
roleEntity: {
select: { id: true, name: true, color: true },
},
} as const;
export const PROJECT_PLANNING_DEMAND_INCLUDE = {
project: PROJECT_PLANNING_ALLOCATION_INCLUDE.project,
roleEntity: PROJECT_PLANNING_ALLOCATION_INCLUDE.roleEntity,
} as const;
export const PROJECT_PLANNING_ASSIGNMENT_INCLUDE = {
resource: PROJECT_PLANNING_ALLOCATION_INCLUDE.resource,
project: PROJECT_PLANNING_ALLOCATION_INCLUDE.project,
roleEntity: PROJECT_PLANNING_ALLOCATION_INCLUDE.roleEntity,
} as const;
type ProjectPlanningReadDbClient = Pick<
PrismaClient,
"demandRequirement" | "assignment"
>;
export interface LoadProjectPlanningReadModelInput {
projectId: string;
activeOnly?: boolean;
}
export async function loadProjectPlanningReadModel(
db: ProjectPlanningReadDbClient,
input: LoadProjectPlanningReadModelInput,
) {
const statusFilter = input.activeOnly
? { status: { not: AllocationStatus.CANCELLED } }
: {};
const [demandRequirements, assignments] = await Promise.all([
db.demandRequirement.findMany({
where: {
projectId: input.projectId,
...statusFilter,
},
include: PROJECT_PLANNING_DEMAND_INCLUDE,
orderBy: [{ startDate: "asc" }, { projectId: "asc" }],
}),
db.assignment.findMany({
where: {
projectId: input.projectId,
...statusFilter,
},
include: PROJECT_PLANNING_ASSIGNMENT_INCLUDE,
orderBy: [{ startDate: "asc" }, { resourceId: "asc" }],
}),
]);
return {
demandRequirements,
assignments,
readModel: buildSplitAllocationReadModel({
demandRequirements,
assignments,
}),
};
}
+316
View File
@@ -0,0 +1,316 @@
import {
countPlanningEntries,
listAssignmentBookings,
} from "@planarchy/application";
import { BlueprintTarget, CreateProjectSchema, FieldType, PermissionKey, ProjectStatus, UpdateProjectSchema } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
import { loadProjectPlanningReadModel } from "./project-planning-read-model.js";
import { controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
export const projectRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
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(),
value: z.string(),
type: z.nativeEnum(FieldType),
})).optional(),
}),
)
.query(async ({ ctx, input }) => {
const { status, search, page, limit, cursor, customFieldFilters } = input;
const cfConditions = buildDynamicFieldWhereClauses(customFieldFilters).map((dynamicFields) => ({ dynamicFields }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: any = {
...(status ? { status } : {}),
...(search
? {
OR: [
{ name: { contains: search, mode: "insensitive" as const } },
{ shortCode: { contains: search, mode: "insensitive" as const } },
],
}
: {}),
...(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 { countsByProjectId } = await countPlanningEntries(ctx.db, {
projectIds: projects.map((project) => project.id),
});
return {
projects: projects.map((project) => ({
...project,
_count: {
allocations: countsByProjectId.get(project.id) ?? 0,
},
})),
total,
page,
limit,
nextCursor,
};
}),
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const [project, planningRead] = await Promise.all([
ctx.db.project.findUnique({
where: { id: input.id },
include: { blueprint: true },
}),
loadProjectPlanningReadModel(ctx.db, { projectId: input.id }),
]);
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
return {
...project,
allocations: planningRead.readModel.assignments,
demands: planningRead.readModel.demands,
assignments: planningRead.readModel.assignments,
};
}),
create: managerProcedure
.input(CreateProjectSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
const existing = await ctx.db.project.findUnique({
where: { shortCode: input.shortCode },
});
if (existing) {
throw new TRPCError({
code: "CONFLICT",
message: `Project with short code "${input.shortCode}" already exists`,
});
}
await assertBlueprintDynamicFields({
db: ctx.db,
blueprintId: input.blueprintId,
dynamicFields: input.dynamicFields,
target: BlueprintTarget.PROJECT,
});
const project = await ctx.db.project.create({
data: {
shortCode: input.shortCode,
name: input.name,
orderType: input.orderType,
allocationType: input.allocationType,
winProbability: input.winProbability,
budgetCents: input.budgetCents,
startDate: input.startDate,
endDate: input.endDate,
status: input.status,
responsiblePerson: input.responsiblePerson,
staffingReqs: input.staffingReqs as unknown as import("@planarchy/db").Prisma.InputJsonValue,
dynamicFields: input.dynamicFields as unknown as import("@planarchy/db").Prisma.InputJsonValue,
blueprintId: input.blueprintId,
...(input.utilizationCategoryId !== undefined ? { utilizationCategoryId: input.utilizationCategoryId || null } : {}),
...(input.clientId !== undefined ? { clientId: input.clientId || null } : {}),
} as unknown as Parameters<typeof ctx.db.project.create>[0]["data"],
});
await ctx.db.auditLog.create({
data: {
entityType: "Project",
entityId: project.id,
action: "CREATE",
changes: { after: project },
},
});
return project;
}),
update: managerProcedure
.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 nextBlueprintId = input.data.blueprintId ?? existing.blueprintId ?? undefined;
const nextDynamicFields = (input.data.dynamicFields ?? existing.dynamicFields ?? {}) as Record<string, unknown>;
await assertBlueprintDynamicFields({
db: ctx.db,
blueprintId: nextBlueprintId,
dynamicFields: nextDynamicFields,
target: BlueprintTarget.PROJECT,
});
const updated = await ctx.db.project.update({
where: { id: input.id },
data: {
...(input.data.name !== undefined ? { name: input.data.name } : {}),
...(input.data.orderType !== undefined ? { orderType: input.data.orderType } : {}),
...(input.data.allocationType !== undefined ? { allocationType: input.data.allocationType } : {}),
...(input.data.winProbability !== undefined ? { winProbability: input.data.winProbability } : {}),
...(input.data.budgetCents !== undefined ? { budgetCents: input.data.budgetCents } : {}),
...(input.data.startDate !== undefined ? { startDate: input.data.startDate } : {}),
...(input.data.endDate !== undefined ? { endDate: input.data.endDate } : {}),
...(input.data.status !== undefined ? { status: input.data.status } : {}),
...(input.data.responsiblePerson !== undefined ? { responsiblePerson: input.data.responsiblePerson } : {}),
...(input.data.staffingReqs !== undefined ? { staffingReqs: input.data.staffingReqs as unknown as import("@planarchy/db").Prisma.InputJsonValue } : {}),
...(input.data.dynamicFields !== undefined ? { dynamicFields: input.data.dynamicFields as unknown as import("@planarchy/db").Prisma.InputJsonValue } : {}),
...(input.data.blueprintId !== undefined ? { blueprintId: input.data.blueprintId } : {}),
...(input.data.utilizationCategoryId !== undefined ? { utilizationCategoryId: input.data.utilizationCategoryId || null } : {}),
...(input.data.clientId !== undefined ? { clientId: input.data.clientId || null } : {}),
} as unknown as Parameters<typeof ctx.db.project.update>[0]["data"],
});
await ctx.db.auditLog.create({
data: {
entityType: "Project",
entityId: input.id,
action: "UPDATE",
changes: { before: existing, after: updated },
},
});
return updated;
}),
updateStatus: managerProcedure
.input(z.object({ id: z.string(), status: z.nativeEnum(ProjectStatus) }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
return ctx.db.project.update({
where: { id: input.id },
data: { status: input.status },
});
}),
batchUpdateStatus: managerProcedure
.input(
z.object({
ids: z.array(z.string()).min(1).max(100),
status: z.nativeEnum(ProjectStatus),
}),
)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
const updated = await ctx.db.$transaction(
input.ids.map((id) =>
ctx.db.project.update({ where: { id }, data: { status: input.status } }),
),
);
await ctx.db.auditLog.create({
data: {
entityType: "Project",
entityId: input.ids.join(","),
action: "UPDATE",
changes: { after: { status: input.status, ids: input.ids } },
},
});
return { count: updated.length };
}),
listWithCosts: controllerProcedure
.input(
z.object({
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 where = {
...(status ? { status } : {}),
...(search
? {
OR: [
{ name: { contains: search, mode: "insensitive" as const } },
{ shortCode: { contains: search, mode: "insensitive" as const } },
],
}
: {}),
};
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 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 bookings = projectIds.length
? await listAssignmentBookings(ctx.db, {
startDate: new Date("1900-01-01T00:00:00.000Z"),
endDate: new Date("2100-12-31T23:59:59.999Z"),
projectIds,
})
: [];
// Compute cost + person days per project
const projects = projectsRaw.map((p) => {
const projectBookings = bookings.filter((booking) => booking.projectId === p.id);
let totalCostCents = 0;
let totalPersonDays = 0;
for (const a of projectBookings) {
const days =
(new Date(a.endDate).getTime() - new Date(a.startDate).getTime()) /
(1000 * 60 * 60 * 24) +
1;
totalCostCents += a.dailyCostCents * days;
totalPersonDays += (a.hoursPerDay * days) / 8;
}
const utilizationPercent = p.budgetCents > 0
? Math.round((totalCostCents / p.budgetCents) * 100)
: 0;
return {
...p,
totalCostCents: Math.round(totalCostCents),
totalPersonDays: Math.round(totalPersonDays * 10) / 10,
utilizationPercent,
};
});
return { projects, nextCursor };
}),
});
+316
View File
@@ -0,0 +1,316 @@
import type { Prisma } from "@planarchy/db";
import {
CreateRateCardLineSchema,
CreateRateCardSchema,
UpdateRateCardLineSchema,
UpdateRateCardSchema,
} from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js";
const lineSelect = {
id: true,
rateCardId: true,
roleId: true,
chapter: true,
location: true,
seniority: true,
workType: true,
serviceGroup: true,
costRateCents: true,
billRateCents: true,
machineRateCents: true,
attributes: true,
role: { select: { id: true, name: true, color: true } },
createdAt: true,
updatedAt: true,
} as const;
export const rateCardRouter = createTRPCRouter({
list: controllerProcedure
.input(
z.object({
isActive: z.boolean().optional(),
search: z.string().optional(),
clientId: z.string().optional(),
effectiveAt: z.coerce.date().optional(),
}).optional(),
)
.query(async ({ ctx, input }) => {
return ctx.db.rateCard.findMany({
where: {
...(input?.isActive !== undefined ? { isActive: input.isActive } : {}),
...(input?.clientId !== undefined ? { clientId: input.clientId } : {}),
...(input?.search
? { name: { contains: input.search, mode: "insensitive" as const } }
: {}),
...(input?.effectiveAt
? {
OR: [
{ effectiveFrom: null },
{ effectiveFrom: { lte: input.effectiveAt } },
],
AND: [
{
OR: [
{ effectiveTo: null },
{ effectiveTo: { gte: input.effectiveAt } },
],
},
],
}
: {}),
},
include: {
_count: { select: { lines: true } },
client: { select: { id: true, name: true, code: true } },
},
orderBy: [{ isActive: "desc" }, { effectiveFrom: "desc" }, { name: "asc" }],
});
}),
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" }],
},
},
});
if (!rateCard) throw new TRPCError({ code: "NOT_FOUND", message: "Rate card not found" });
return rateCard;
}),
create: managerProcedure
.input(CreateRateCardSchema)
.mutation(async ({ ctx, input }) => {
const { lines, ...cardData } = input;
return ctx.db.rateCard.create({
data: {
name: cardData.name,
currency: cardData.currency,
...(cardData.effectiveFrom !== undefined ? { effectiveFrom: cardData.effectiveFrom } : {}),
...(cardData.effectiveTo !== undefined ? { effectiveTo: cardData.effectiveTo } : {}),
...(cardData.source !== undefined ? { source: cardData.source } : {}),
...(cardData.clientId !== undefined ? { clientId: cardData.clientId } : {}),
lines: {
create: lines.map((line) => ({
...(line.roleId !== undefined ? { roleId: line.roleId } : {}),
...(line.chapter !== undefined ? { chapter: line.chapter } : {}),
...(line.location !== undefined ? { location: line.location } : {}),
...(line.seniority !== undefined ? { seniority: line.seniority } : {}),
...(line.workType !== undefined ? { workType: line.workType } : {}),
...(line.serviceGroup !== undefined ? { serviceGroup: line.serviceGroup } : {}),
costRateCents: line.costRateCents,
...(line.billRateCents !== undefined ? { billRateCents: line.billRateCents } : {}),
...(line.machineRateCents !== undefined ? { machineRateCents: line.machineRateCents } : {}),
attributes: line.attributes as Prisma.InputJsonValue,
})),
},
},
include: {
lines: { select: lineSelect },
},
});
}),
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" });
return ctx.db.rateCard.update({
where: { id: input.id },
data: {
...(input.data.name !== undefined ? { name: input.data.name } : {}),
...(input.data.currency !== undefined ? { currency: input.data.currency } : {}),
...(input.data.effectiveFrom !== undefined ? { effectiveFrom: input.data.effectiveFrom } : {}),
...(input.data.effectiveTo !== undefined ? { effectiveTo: input.data.effectiveTo } : {}),
...(input.data.source !== undefined ? { source: input.data.source } : {}),
...(input.data.clientId !== undefined ? { clientId: input.data.clientId } : {}),
...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}),
},
include: {
_count: { select: { lines: true } },
client: { select: { id: true, name: true, code: true } },
},
});
}),
deactivate: managerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.db.rateCard.update({
where: { id: input.id },
data: { isActive: false },
});
}),
// ─── Line CRUD ─────────────────────────────────────────────────────────────
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" });
return ctx.db.rateCardLine.create({
data: {
rateCardId: input.rateCardId,
...(input.line.roleId !== undefined ? { roleId: input.line.roleId } : {}),
...(input.line.chapter !== undefined ? { chapter: input.line.chapter } : {}),
...(input.line.location !== undefined ? { location: input.line.location } : {}),
...(input.line.seniority !== undefined ? { seniority: input.line.seniority } : {}),
...(input.line.workType !== undefined ? { workType: input.line.workType } : {}),
...(input.line.serviceGroup !== undefined ? { serviceGroup: input.line.serviceGroup } : {}),
costRateCents: input.line.costRateCents,
...(input.line.billRateCents !== undefined ? { billRateCents: input.line.billRateCents } : {}),
...(input.line.machineRateCents !== undefined ? { machineRateCents: input.line.machineRateCents } : {}),
attributes: input.line.attributes as Prisma.InputJsonValue,
},
select: lineSelect,
});
}),
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" });
const updateData: Prisma.RateCardLineUpdateInput = {};
if (input.data.roleId !== undefined) updateData.role = input.data.roleId ? { connect: { id: input.data.roleId } } : { disconnect: true };
if (input.data.chapter !== undefined) updateData.chapter = input.data.chapter;
if (input.data.location !== undefined) updateData.location = input.data.location;
if (input.data.seniority !== undefined) updateData.seniority = input.data.seniority;
if (input.data.workType !== undefined) updateData.workType = input.data.workType;
if (input.data.serviceGroup !== undefined) updateData.serviceGroup = input.data.serviceGroup;
if (input.data.costRateCents !== undefined) updateData.costRateCents = input.data.costRateCents;
if (input.data.billRateCents !== undefined) updateData.billRateCents = input.data.billRateCents;
if (input.data.machineRateCents !== undefined) updateData.machineRateCents = input.data.machineRateCents;
if (input.data.attributes !== undefined) updateData.attributes = input.data.attributes as Prisma.InputJsonValue;
return ctx.db.rateCardLine.update({
where: { id: input.lineId },
data: updateData,
select: lineSelect,
});
}),
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 ctx.db.rateCardLine.delete({ where: { id: input.lineId } });
return { deleted: true };
}),
// ─── Batch operations ──────────────────────────────────────────────────────
replaceLines: managerProcedure
.input(z.object({
rateCardId: z.string(),
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" });
return ctx.db.$transaction(async (tx) => {
await tx.rateCardLine.deleteMany({ where: { rateCardId: input.rateCardId } });
const created = await Promise.all(
input.lines.map((line) =>
tx.rateCardLine.create({
data: {
rateCardId: input.rateCardId,
...(line.roleId !== undefined ? { roleId: line.roleId } : {}),
...(line.chapter !== undefined ? { chapter: line.chapter } : {}),
...(line.location !== undefined ? { location: line.location } : {}),
...(line.seniority !== undefined ? { seniority: line.seniority } : {}),
...(line.workType !== undefined ? { workType: line.workType } : {}),
...(line.serviceGroup !== undefined ? { serviceGroup: line.serviceGroup } : {}),
costRateCents: line.costRateCents,
...(line.billRateCents !== undefined ? { billRateCents: line.billRateCents } : {}),
...(line.machineRateCents !== undefined ? { machineRateCents: line.machineRateCents } : {}),
attributes: line.attributes as Prisma.InputJsonValue,
},
select: lineSelect,
}),
),
);
return created;
});
}),
// ─── Rate resolution ───────────────────────────────────────────────────────
resolveRate: controllerProcedure
.input(z.object({
rateCardId: z.string(),
roleId: z.string().optional(),
chapter: z.string().optional(),
location: z.string().optional(),
seniority: z.string().optional(),
workType: z.string().optional(),
}))
.query(async ({ ctx, input }) => {
const { rateCardId, ...criteria } = input;
// Find the most specific matching line (most criteria matched wins)
const lines = await ctx.db.rateCardLine.findMany({
where: { rateCardId },
select: lineSelect,
});
if (lines.length === 0) return null;
// Score each line by number of matching criteria
const scored = lines.map((line) => {
let score = 0;
let mismatch = false;
if (criteria.roleId && line.roleId) {
if (line.roleId === criteria.roleId) score += 4;
else mismatch = true;
}
if (criteria.chapter && line.chapter) {
if (line.chapter === criteria.chapter) score += 2;
else mismatch = true;
}
if (criteria.location && line.location) {
if (line.location === criteria.location) score += 1;
else mismatch = true;
}
if (criteria.seniority && line.seniority) {
if (line.seniority === criteria.seniority) score += 1;
else mismatch = true;
}
if (criteria.workType && line.workType) {
if (line.workType === criteria.workType) score += 1;
else mismatch = true;
}
return { line, score, mismatch };
});
// Filter out mismatches and find best match
const candidates = scored
.filter((s) => !s.mismatch)
.sort((a, b) => b.score - a.score);
const best = candidates[0];
return best ? best.line : null;
}),
});
+999
View File
@@ -0,0 +1,999 @@
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 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";
export const DEFAULT_SUMMARY_PROMPT = `You are writing a short professional profile for an internal resource planning tool.
Artist profile:
- Role: {role}
- Chapter: {chapter}
- Main skills: {mainSkills}
- Top skills: {topSkills}
Write a 23 sentence professional bio. Be specific, use skill names. No fluff.`;
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
export const resourceRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
chapter: z.string().optional(),
isActive: z.boolean().optional().default(true),
search: z.string().optional(),
eids: z.array(z.string()).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),
// Cursor-based pagination (additive — page/limit still supported)
cursor: z.string().optional(),
// Custom field JSONB filters
customFieldFilters: z.array(z.object({
key: z.string(),
value: z.string(),
type: z.nativeEnum(FieldType),
})).optional(),
}),
)
.query(async ({ ctx, input }) => {
const { chapter, isActive, search, eids, page, limit, includeRoles, cursor, customFieldFilters } = input;
const cfConditions = buildDynamicFieldWhereClauses(customFieldFilters).map((dynamicFields) => ({ dynamicFields }));
// 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 } : {}),
};
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 baseQuery = { where: whereWithCursor, skip, take: limit + 1, orderBy };
const [rawResources, total] = await Promise.all([
includeRoles
? ctx.db.resource.findMany({
...baseQuery,
include: {
resourceRoles: {
include: { role: { select: { id: true, name: true, color: true } } },
},
},
})
: ctx.db.resource.findMany(baseQuery),
ctx.db.resource.count({ where }),
]);
const hasMore = rawResources.length > limit;
const resources = hasMore ? rawResources.slice(0, limit) : rawResources;
const nextCursor = hasMore ? resources[resources.length - 1]!.id : null;
return { resources, total, page, limit, nextCursor };
}),
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 } } },
},
areaRole: { select: { id: true, name: true } },
user: { select: { email: true } },
},
});
if (!resource) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
return resource;
}),
getByEid: protectedProcedure
.input(z.object({ eid: z.string() }))
.query(async ({ ctx, input }) => {
const resource = await ctx.db.resource.findUnique({ where: { eid: input.eid } });
if (!resource) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
return resource;
}),
create: managerProcedure
.input(CreateResourceSchema.extend({ roles: z.array(ResourceRoleSchema).optional() }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const existing = await ctx.db.resource.findFirst({
where: { OR: [{ eid: input.eid }, { email: input.email }] },
});
if (existing) {
throw new TRPCError({
code: "CONFLICT",
message: `Resource with EID "${input.eid}" or email "${input.email}" already exists`,
});
}
await assertBlueprintDynamicFields({
db: ctx.db,
blueprintId: input.blueprintId,
dynamicFields: input.dynamicFields,
target: BlueprintTarget.RESOURCE,
});
// Enforce max 1 primary role
const primaryCount = (input.roles ?? []).filter((r) => r.isPrimary).length;
if (primaryCount > 1) {
throw new TRPCError({ code: "BAD_REQUEST", message: "A resource can have at most one primary role" });
}
const resource = await ctx.db.resource.create({
data: {
eid: input.eid,
displayName: input.displayName,
email: input.email,
chapter: input.chapter,
lcrCents: input.lcrCents,
ucrCents: input.ucrCents,
currency: input.currency,
chargeabilityTarget: input.chargeabilityTarget,
availability: input.availability,
skills: input.skills as unknown as import("@planarchy/db").Prisma.InputJsonValue,
dynamicFields: input.dynamicFields as unknown as import("@planarchy/db").Prisma.InputJsonValue,
blueprintId: input.blueprintId,
portfolioUrl: input.portfolioUrl || undefined,
roleId: input.roleId || undefined,
...(input.postalCode !== undefined ? { postalCode: input.postalCode } : {}),
...(input.postalCode && !input.federalState
? { federalState: inferStateFromPostalCode(input.postalCode) }
: input.federalState !== undefined
? { federalState: input.federalState }
: {}),
...(input.countryId !== undefined ? { countryId: input.countryId || null } : {}),
...(input.metroCityId !== undefined ? { metroCityId: input.metroCityId || null } : {}),
...(input.orgUnitId !== undefined ? { orgUnitId: input.orgUnitId || null } : {}),
...(input.managementLevelGroupId !== undefined ? { managementLevelGroupId: input.managementLevelGroupId || null } : {}),
...(input.managementLevelId !== undefined ? { managementLevelId: input.managementLevelId || null } : {}),
...(input.resourceType !== undefined ? { resourceType: input.resourceType } : {}),
...(input.chgResponsibility !== undefined ? { chgResponsibility: input.chgResponsibility } : {}),
...(input.rolledOff !== undefined ? { rolledOff: input.rolledOff } : {}),
...(input.departed !== undefined ? { departed: input.departed } : {}),
...(input.enterpriseId !== undefined ? { enterpriseId: input.enterpriseId || null } : {}),
...(input.clientUnitId !== undefined ? { clientUnitId: input.clientUnitId || null } : {}),
...(input.fte !== undefined ? { fte: input.fte } : {}),
resourceRoles: input.roles?.length
? {
create: input.roles.map((r) => ({
roleId: r.roleId,
isPrimary: r.isPrimary,
})),
}
: undefined,
} as unknown as Parameters<typeof ctx.db.resource.create>[0]["data"],
include: {
resourceRoles: { include: { role: { select: { id: true, name: true, color: true } } } },
},
});
await ctx.db.auditLog.create({
data: {
entityType: "Resource",
entityId: resource.id,
action: "CREATE",
userId: ctx.dbUser?.id,
changes: { after: resource },
} as unknown as Parameters<typeof ctx.db.auditLog.create>[0]["data"],
});
return resource;
}),
update: managerProcedure
.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 nextBlueprintId = input.data.blueprintId ?? existing.blueprintId ?? undefined;
const nextDynamicFields = (input.data.dynamicFields ?? existing.dynamicFields ?? {}) as Record<string, unknown>;
await assertBlueprintDynamicFields({
db: ctx.db,
blueprintId: nextBlueprintId,
dynamicFields: nextDynamicFields,
target: BlueprintTarget.RESOURCE,
});
// Enforce max 1 primary role
if (input.data.roles !== undefined) {
const primaryCount = input.data.roles.filter((r) => r.isPrimary).length;
if (primaryCount > 1) {
throw new TRPCError({ code: "BAD_REQUEST", message: "A resource can have at most one primary role" });
}
}
const updated = await ctx.db.resource.update({
where: { id: input.id },
data: {
...(input.data.displayName !== undefined ? { displayName: input.data.displayName } : {}),
...(input.data.email !== undefined ? { email: input.data.email } : {}),
...(input.data.chapter !== undefined ? { chapter: input.data.chapter } : {}),
...(input.data.lcrCents !== undefined ? { lcrCents: input.data.lcrCents } : {}),
...(input.data.ucrCents !== undefined ? { ucrCents: input.data.ucrCents } : {}),
...(input.data.currency !== undefined ? { currency: input.data.currency } : {}),
...(input.data.chargeabilityTarget !== undefined ? { chargeabilityTarget: input.data.chargeabilityTarget } : {}),
...(input.data.availability !== undefined ? { availability: input.data.availability as unknown as import("@planarchy/db").Prisma.InputJsonValue } : {}),
...(input.data.skills !== undefined ? { skills: input.data.skills as unknown as import("@planarchy/db").Prisma.InputJsonValue } : {}),
...(input.data.dynamicFields !== undefined ? { dynamicFields: input.data.dynamicFields as unknown as import("@planarchy/db").Prisma.InputJsonValue } : {}),
...(input.data.blueprintId !== undefined ? { blueprintId: input.data.blueprintId } : {}),
...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}),
...(input.data.portfolioUrl !== undefined ? { portfolioUrl: input.data.portfolioUrl || null } : {}),
...(input.data.roleId !== undefined ? { roleId: input.data.roleId || null } : {}),
...(input.data.postalCode !== undefined ? { postalCode: input.data.postalCode } : {}),
...(input.data.postalCode && !input.data.federalState
? { federalState: inferStateFromPostalCode(input.data.postalCode) }
: input.data.federalState !== undefined
? { federalState: input.data.federalState }
: {}),
...(input.data.countryId !== undefined ? { countryId: input.data.countryId || null } : {}),
...(input.data.metroCityId !== undefined ? { metroCityId: input.data.metroCityId || null } : {}),
...(input.data.orgUnitId !== undefined ? { orgUnitId: input.data.orgUnitId || null } : {}),
...(input.data.managementLevelGroupId !== undefined ? { managementLevelGroupId: input.data.managementLevelGroupId || null } : {}),
...(input.data.managementLevelId !== undefined ? { managementLevelId: input.data.managementLevelId || null } : {}),
...(input.data.resourceType !== undefined ? { resourceType: input.data.resourceType } : {}),
...(input.data.chgResponsibility !== undefined ? { chgResponsibility: input.data.chgResponsibility } : {}),
...(input.data.rolledOff !== undefined ? { rolledOff: input.data.rolledOff } : {}),
...(input.data.departed !== undefined ? { departed: input.data.departed } : {}),
...(input.data.enterpriseId !== undefined ? { enterpriseId: input.data.enterpriseId || null } : {}),
...(input.data.clientUnitId !== undefined ? { clientUnitId: input.data.clientUnitId || null } : {}),
...(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 } } } },
},
});
// Replace roles if provided
if (input.data.roles !== undefined) {
await ctx.db.resourceRole.deleteMany({ where: { resourceId: input.id } });
if (input.data.roles.length > 0) {
await ctx.db.resourceRole.createMany({
data: input.data.roles.map((r) => ({
resourceId: input.id,
roleId: r.roleId,
isPrimary: r.isPrimary,
})),
});
}
}
await ctx.db.auditLog.create({
data: {
entityType: "Resource",
entityId: input.id,
action: "UPDATE",
changes: { before: existing, after: updated },
},
});
return updated;
}),
deactivate: managerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const resource = await ctx.db.resource.update({
where: { id: input.id },
data: { isActive: false },
});
await ctx.db.auditLog.create({
data: {
entityType: "Resource",
entityId: input.id,
action: "UPDATE",
changes: { after: { isActive: false } },
},
});
return resource;
}),
batchDeactivate: managerProcedure
.input(z.object({ ids: z.array(z.string()).min(1).max(100) }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const updated = await ctx.db.$transaction(
input.ids.map((id) =>
ctx.db.resource.update({ where: { id }, data: { isActive: false } }),
),
);
await ctx.db.auditLog.create({
data: {
entityType: "Resource",
entityId: input.ids.join(","),
action: "UPDATE",
changes: { after: { isActive: false, ids: input.ids } },
},
});
return { count: updated.length };
}),
chapters: protectedProcedure.query(async ({ ctx }) => {
const resources = await ctx.db.resource.findMany({
where: { isActive: true, chapter: { not: null } },
select: { chapter: true },
distinct: ["chapter"],
orderBy: { chapter: "asc" },
});
return resources.map((r) => r.chapter as string);
}),
// ─── Skill Matrix Import ────────────────────────────────────────────────────
importSkillMatrix: protectedProcedure
.input(
z.object({
skills: z.array(SkillEntrySchema),
employeeInfo: z
.object({
roleId: z.string().optional(),
yearsOfExperience: z.number().optional(),
portfolioUrl: z.string().url().optional().or(z.literal("")),
})
.optional(),
}),
)
.mutation(async ({ ctx, input }) => {
// Find the resource linked to this user
const user = await ctx.db.user.findUnique({
where: { email: ctx.session.user?.email ?? "" },
include: { resource: true },
});
if (!user?.resource) {
throw new TRPCError({ code: "NOT_FOUND", message: "No resource linked to your account" });
}
const resourceId = user.resource.id;
await ctx.db.resource.update({
where: { id: resourceId },
data: {
skills: input.skills as unknown as import("@planarchy/db").Prisma.InputJsonValue,
skillMatrixUpdatedAt: new Date(),
...(input.employeeInfo?.portfolioUrl !== undefined
? { portfolioUrl: input.employeeInfo.portfolioUrl || null }
: {}),
...(input.employeeInfo?.roleId !== undefined ? { roleId: input.employeeInfo.roleId } : {}),
},
});
return { count: input.skills.length };
}),
importSkillMatrixForResource: managerProcedure
.input(
z.object({
resourceId: z.string(),
skills: z.array(SkillEntrySchema),
employeeInfo: z
.object({
roleId: z.string().optional(),
yearsOfExperience: z.number().optional(),
portfolioUrl: z.string().url().optional().or(z.literal("")),
})
.optional(),
}),
)
.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 ctx.db.resource.update({
where: { id: input.resourceId },
data: {
skills: input.skills as unknown as import("@planarchy/db").Prisma.InputJsonValue,
skillMatrixUpdatedAt: new Date(),
...(input.employeeInfo?.portfolioUrl !== undefined
? { portfolioUrl: input.employeeInfo.portfolioUrl || null }
: {}),
...(input.employeeInfo?.roleId !== undefined ? { roleId: input.employeeInfo.roleId } : {}),
},
});
return { count: input.skills.length };
}),
batchImportSkillMatrices: adminProcedure
.input(
z.object({
entries: z.array(
z.object({
eid: z.string(),
skills: z.array(SkillEntrySchema),
employeeInfo: z
.object({
roleId: z.string().optional(),
yearsOfExperience: z.number().optional(),
portfolioUrl: z.string().url().optional().or(z.literal("")),
})
.optional(),
}),
),
}),
)
.mutation(async ({ ctx, input }) => {
// Single findMany to avoid N+1 (was: findUnique per entry)
const eids = input.entries.map((e) => e.eid);
const existing = await ctx.db.resource.findMany({
where: { eid: { in: eids } },
select: { id: true, eid: true },
});
const eidToId = new Map(existing.map((r) => [r.eid, r.id]));
const notFound = input.entries.length - existing.length;
const now = new Date();
const updates = input.entries
.filter((entry) => eidToId.has(entry.eid))
.map((entry) =>
ctx.db.resource.update({
where: { id: eidToId.get(entry.eid)! },
data: {
skills: entry.skills as unknown as import("@planarchy/db").Prisma.InputJsonValue,
skillMatrixUpdatedAt: now,
...(entry.employeeInfo?.portfolioUrl !== undefined
? { portfolioUrl: entry.employeeInfo.portfolioUrl || null }
: {}),
...(entry.employeeInfo?.roleId !== undefined ? { roleId: entry.employeeInfo.roleId } : {}),
},
}),
);
await ctx.db.$transaction(updates);
return { updated: updates.length, notFound };
}),
// ─── AI Summary ─────────────────────────────────────────────────────────────
generateAiSummary: managerProcedure
.input(z.object({ resourceId: z.string() }))
.mutation(async ({ ctx, input }) => {
const [resource, settings] = await Promise.all([
ctx.db.resource.findUnique({
where: { id: input.resourceId },
include: { areaRole: { select: { name: true } } },
}),
ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }),
]);
if (!resource) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
if (!isAiConfigured(settings)) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "AI is not configured. Please set credentials in Admin → Settings.",
});
}
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
const skills = (resource.skills as unknown as SkillRow[]) ?? [];
const mainSkills = skills.filter((s) => s.isMainSkill).map((s) => s.skill);
const top10 = [...skills]
.sort((a, b) => b.proficiency - a.proficiency)
.slice(0, 10)
.map((s) => `${s.skill} (${s.proficiency}/5)`);
const vars = {
role: resource.areaRole?.name ?? "Not specified",
chapter: resource.chapter ?? "Not specified",
mainSkills: mainSkills.length > 0 ? mainSkills.join(", ") : "Not specified",
topSkills: top10.join(", "),
};
const templateStr = settings!.aiSummaryPrompt ?? DEFAULT_SUMMARY_PROMPT;
const prompt = templateStr
.replace("{role}", vars.role)
.replace("{chapter}", vars.chapter)
.replace("{mainSkills}", vars.mainSkills)
.replace("{topSkills}", vars.topSkills);
const client = createAiClient(settings!);
const model = settings!.azureOpenAiDeployment!;
const maxTokens = settings!.aiMaxCompletionTokens ?? 300;
const temperature = settings!.aiTemperature ?? 1;
async function callChatCompletions(withTemperature: boolean) {
return client.chat.completions.create({
messages: [{ role: "user", content: prompt }],
max_completion_tokens: maxTokens,
model,
...(withTemperature && temperature !== 1 ? { temperature } : {}),
});
}
let summary = "";
try {
let completion;
try {
completion = await callChatCompletions(true);
console.log("[generateAiSummary] chat.completions response:", JSON.stringify({
choices: completion.choices?.map(c => ({ content: c.message?.content, finish_reason: c.finish_reason })),
}));
} catch (tempErr) {
const status = (tempErr as { status?: number }).status;
const msg = (tempErr as Error).message ?? "";
console.log("[generateAiSummary] chat.completions error:", status, msg.slice(0, 200));
if (status === 400 && msg.includes("temperature")) {
completion = await callChatCompletions(false);
} else if (status === 404) {
console.log("[generateAiSummary] falling back to responses API");
const resp = await client.responses.create({ model, input: prompt, max_output_tokens: maxTokens });
console.log("[generateAiSummary] responses output_text:", resp.output_text?.slice(0, 100));
summary = resp.output_text?.trim() ?? "";
completion = null;
} else {
throw tempErr;
}
}
if (completion) summary = completion.choices[0]?.message?.content?.trim() ?? "";
} catch (e) {
throw e;
}
await ctx.db.resource.update({
where: { id: input.resourceId },
data: { aiSummary: summary, aiSummaryUpdatedAt: new Date() },
});
return { summary };
}),
// ─── Skills Analytics ───────────────────────────────────────────────────────
getSkillsAnalytics: controllerProcedure.query(async ({ ctx }) => {
const resources = await ctx.db.resource.findMany({
where: { isActive: true },
select: { id: true, displayName: true, chapter: true, skills: true },
});
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
// Aggregate: { skillName, category, count, totalProficiency, chapters }
const skillMap = new Map<
string,
{ skill: string; category: string; count: number; totalProficiency: number; chapters: Set<string> }
>();
for (const resource of resources) {
const skills = (resource.skills as unknown as SkillRow[]) ?? [];
for (const s of skills) {
const key = s.skill;
if (!skillMap.has(key)) {
skillMap.set(key, {
skill: s.skill,
category: s.category ?? "Uncategorized",
count: 0,
totalProficiency: 0,
chapters: new Set(),
});
}
const entry = skillMap.get(key)!;
entry.count++;
entry.totalProficiency += s.proficiency;
if (resource.chapter) entry.chapters.add(resource.chapter);
}
}
const aggregated = Array.from(skillMap.values())
.map((e) => ({
skill: e.skill,
category: e.category,
count: e.count,
avgProficiency: Math.round((e.totalProficiency / e.count) * 10) / 10,
chapters: Array.from(e.chapters),
}))
.sort((a, b) => b.count - a.count);
const categories = [...new Set(aggregated.map((e) => e.category))].sort();
const allChapters = [...new Set(resources.map((r) => r.chapter).filter(Boolean))].sort() as string[];
return {
totalResources: resources.length,
totalSkillEntries: aggregated.length,
aggregated,
categories,
allChapters,
};
}),
searchBySkills: controllerProcedure
.input(
z.object({
rules: z.array(
z.object({
skill: z.string().min(1),
minProficiency: z.number().int().min(1).max(5).default(1),
}),
),
chapter: z.string().optional(),
operator: z.enum(["AND", "OR"]).default("AND"),
}),
)
.query(async ({ ctx, input }) => {
const { rules, chapter, operator } = input;
const resources = await ctx.db.resource.findMany({
where: { isActive: true, ...(chapter ? { chapter } : {}) },
select: { id: true, eid: true, displayName: true, chapter: true, skills: true },
});
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
const results = resources
.map((r) => {
const skills = (r.skills as unknown as SkillRow[]) ?? [];
const matchFn = (rule: { skill: string; minProficiency: number }) => {
const s = skills.find((sk) => sk.skill.toLowerCase().includes(rule.skill.toLowerCase()));
return s && s.proficiency >= rule.minProficiency ? s : null;
};
const matched = rules.map(matchFn);
const passes =
operator === "AND" ? matched.every(Boolean) : matched.some(Boolean);
if (!passes) return null;
return {
id: r.id,
eid: r.eid,
displayName: r.displayName,
chapter: r.chapter,
matchedSkills: rules
.map((rule, i) => {
const s = matched[i];
return s ? { skill: s.skill, proficiency: s.proficiency, category: s.category ?? "" } : null;
})
.filter((s): s is { skill: string; proficiency: number; category: string } => s !== null),
};
})
.filter((r): r is NonNullable<typeof r> => r !== null)
.sort((a, b) => a.displayName.localeCompare(b.displayName));
return results;
}),
// ─── Self-service ────────────────────────────────────────────────────────────
/** Get the resource linked to the current user (for self-service pages). */
getMyResource: protectedProcedure.query(async ({ ctx }) => {
const email = ctx.session.user?.email;
if (!email) return null;
const user = await ctx.db.user.findUnique({
where: { email },
select: { resource: { select: { id: true, displayName: true, eid: true, chapter: true } } },
});
return user?.resource ?? null;
}),
// ─── Value Score ─────────────────────────────────────────────────────────────
getValueScores: protectedProcedure
.input(
z.object({
isActive: z.boolean().optional().default(true),
limit: z.number().int().min(1).max(500).default(100),
}),
)
.query(async ({ ctx, input }) => {
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
const visibleRoles = (settings?.scoreVisibleRoles as unknown as string[]) ?? ["ADMIN", "MANAGER"];
const userRole = (ctx.session.user as { role?: string } | undefined)?.role ?? "USER";
if (!visibleRoles.includes(userRole)) return [];
const resources = await ctx.db.resource.findMany({
where: { isActive: input.isActive },
select: {
id: true,
eid: true,
displayName: true,
chapter: true,
lcrCents: true,
valueScore: true,
valueScoreBreakdown: true,
valueScoreUpdatedAt: true,
},
orderBy: [{ valueScore: "desc" }, { displayName: "asc" }],
take: input.limit,
});
return resources;
}),
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 };
}),
listWithUtilization: controllerProcedure
.input(
z.object({
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
chapter: z.string().optional(),
limit: z.number().int().min(1).max(500).default(100),
}),
)
.query(async ({ ctx, input }) => {
const now = new Date();
const start = input.startDate ? new Date(input.startDate) : new Date(now.getFullYear(), now.getMonth(), 1);
const end = input.endDate ? new Date(input.endDate) : new Date(now.getFullYear(), now.getMonth() + 3, 0);
const resources = await ctx.db.resource.findMany({
where: {
isActive: true,
...(input.chapter ? { chapter: input.chapter } : {}),
},
take: input.limit,
orderBy: { displayName: "asc" },
select: {
id: true,
eid: true,
displayName: true,
email: true,
chapter: true,
lcrCents: true,
ucrCents: true,
currency: true,
chargeabilityTarget: true,
availability: true,
skills: true,
dynamicFields: true,
blueprintId: true,
isActive: true,
createdAt: true,
updatedAt: true,
roleId: true,
portfolioUrl: true,
postalCode: true,
federalState: true,
valueScore: true,
valueScoreBreakdown: true,
valueScoreUpdatedAt: true,
userId: true,
},
});
const bookings = await listAssignmentBookings(ctx.db, {
startDate: start,
endDate: end,
resourceIds: resources.map((resource) => resource.id),
});
return resources.map((r) => {
const avail = r.availability as Record<string, number>;
const dailyAvailHours = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5;
const periodDays =
(end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24) + 1;
const availableHours = dailyAvailHours * periodDays * (5 / 7);
let bookedHours = 0;
let isOverbooked = false;
const resourceBookings = bookings.filter((booking) => booking.resourceId === r.id);
for (const a of resourceBookings) {
const days =
(new Date(a.endDate).getTime() - new Date(a.startDate).getTime()) /
(1000 * 60 * 60 * 24) +
1;
bookedHours += a.hoursPerDay * days;
if (a.hoursPerDay > dailyAvailHours) isOverbooked = true;
}
const utilizationPercent =
availableHours > 0 ? Math.round((bookedHours / availableHours) * 100) : 0;
return {
...r,
bookingCount: resourceBookings.length,
bookedHours: Math.round(bookedHours),
availableHours: Math.round(availableHours),
utilizationPercent,
isOverbooked,
};
});
}),
getChargeabilityStats: controllerProcedure
.input(z.object({ resourceId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const resources = await ctx.db.resource.findMany({
where: {
isActive: true,
...(input.resourceId ? { id: input.resourceId } : {}),
},
select: {
id: true,
eid: true,
displayName: true,
chapter: true,
chargeabilityTarget: true,
availability: true,
},
});
const bookings = await listAssignmentBookings(ctx.db, {
startDate: start,
endDate: end,
resourceIds: resources.map((resource) => resource.id),
});
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",
);
// Expected: all non-CANCELLED assignment-like bookings, all project statuses
const expectedAllocs = resourceBookings;
const actual = computeChargeability(avail, actualAllocs, start, end);
const expected = computeChargeability(avail, expectedAllocs, start, end);
return {
id: r.id,
eid: r.eid,
displayName: r.displayName,
chapter: r.chapter,
chargeabilityTarget: r.chargeabilityTarget,
actualChargeability: actual.chargeability,
expectedChargeability: expected.chargeability,
availableHours: actual.availableHours,
};
});
}),
/**
* Bulk-update dynamicFields on a set of resources (merges — does not overwrite other keys).
*/
batchUpdateCustomFields: managerProcedure
.input(z.object({
ids: z.array(z.string()).min(1).max(100),
fields: z.record(z.string(), z.unknown()),
}))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
await ctx.db.$transaction(
input.ids.map((id) =>
ctx.db.$executeRaw`
UPDATE "Resource"
SET "dynamicFields" = "dynamicFields" || ${JSON.stringify(input.fields)}::jsonb
WHERE id = ${id}
`,
),
);
await ctx.db.auditLog.create({
data: {
entityType: "Resource",
entityId: input.ids.join(","),
action: "UPDATE",
changes: { after: { dynamicFields: input.fields, ids: input.ids } } as unknown as import("@planarchy/db").Prisma.InputJsonValue,
},
});
return { updated: input.ids.length };
}),
});
+244
View File
@@ -0,0 +1,244 @@
import { countPlanningEntries } from "@planarchy/application";
import { CreateRoleSchema, PermissionKey, UpdateRoleSchema } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { emitRoleCreated, emitRoleDeleted, emitRoleUpdated } from "../sse/event-bus.js";
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
async function loadRolePlanningEntryCounts(
db: Pick<import("@planarchy/db").PrismaClient, "demandRequirement" | "assignment">,
roleIds: string[],
) {
const { countsByRoleId } = await countPlanningEntries(db, {
roleIds,
});
return countsByRoleId;
}
async function attachPlanningEntryCounts<
TRole extends {
id: string;
_count: { resourceRoles: number };
},
>(
db: Pick<import("@planarchy/db").PrismaClient, "demandRequirement" | "assignment">,
roles: TRole[],
): Promise<Array<TRole & { _count: { resourceRoles: number; allocations: number } }>> {
const countsByRoleId = await loadRolePlanningEntryCounts(
db,
roles.map((role) => role.id),
);
return roles.map((role) => ({
...role,
_count: {
...role._count,
allocations: countsByRoleId.get(role.id) ?? 0,
},
}));
}
async function attachSinglePlanningEntryCount<
TRole extends {
id: string;
_count: { resourceRoles: number };
},
>(
db: Pick<import("@planarchy/db").PrismaClient, "demandRequirement" | "assignment">,
role: TRole,
): Promise<TRole & { _count: { resourceRoles: number; allocations: number } }> {
return (await attachPlanningEntryCounts(db, [role]))[0]!;
}
export const roleRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
isActive: z.boolean().optional(),
search: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
const roles = await ctx.db.role.findMany({
where: {
...(input.isActive !== undefined ? { isActive: input.isActive } : {}),
...(input.search
? { name: { contains: input.search, mode: "insensitive" as const } }
: {}),
},
include: {
_count: {
select: { resourceRoles: true },
},
},
orderBy: { name: "asc" },
});
return attachPlanningEntryCounts(ctx.db, roles);
}),
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 } },
},
},
},
});
if (!role) {
throw new TRPCError({ code: "NOT_FOUND", message: "Role not found" });
}
return attachSinglePlanningEntryCount(ctx.db, role);
}),
create: managerProcedure
.input(CreateRoleSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ROLES);
const existing = await ctx.db.role.findUnique({ where: { name: input.name } });
if (existing) {
throw new TRPCError({ code: "CONFLICT", message: `Role "${input.name}" already exists` });
}
const role = await ctx.db.role.create({
data: {
name: input.name,
description: input.description ?? null,
color: input.color ?? null,
},
include: { _count: { select: { resourceRoles: true } } },
});
await ctx.db.auditLog.create({
data: {
entityType: "Role",
entityId: role.id,
action: "CREATE",
changes: { after: role },
},
});
emitRoleCreated({ id: role.id, name: role.name });
return {
...role,
_count: {
...role._count,
allocations: 0,
},
};
}),
update: managerProcedure
.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" });
}
if (input.data.name && input.data.name !== existing.name) {
const nameConflict = await ctx.db.role.findUnique({ where: { name: input.data.name } });
if (nameConflict) {
throw new TRPCError({ code: "CONFLICT", message: `Role "${input.data.name}" already exists` });
}
}
const updated = await ctx.db.role.update({
where: { id: input.id },
data: {
...(input.data.name !== undefined ? { name: input.data.name } : {}),
...(input.data.description !== undefined ? { description: input.data.description } : {}),
...(input.data.color !== undefined ? { color: input.data.color } : {}),
...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}),
},
include: { _count: { select: { resourceRoles: true } } },
});
await ctx.db.auditLog.create({
data: {
entityType: "Role",
entityId: input.id,
action: "UPDATE",
changes: { before: existing, after: updated },
},
});
emitRoleUpdated({ id: updated.id, name: updated.name });
return attachSinglePlanningEntryCount(ctx.db, updated);
}),
delete: managerProcedure
.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 roleWithCounts = await attachSinglePlanningEntryCount(ctx.db, role);
if (
roleWithCounts._count.resourceRoles > 0 ||
roleWithCounts._count.allocations > 0
) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: `Cannot delete role assigned to ${roleWithCounts._count.resourceRoles} resource(s) and ${roleWithCounts._count.allocations} allocation(s). Deactivate it instead.`,
});
}
await ctx.db.role.delete({ where: { id: input.id } });
await ctx.db.auditLog.create({
data: {
entityType: "Role",
entityId: input.id,
action: "DELETE",
changes: { before: role },
},
});
emitRoleDeleted(input.id);
return { success: true };
}),
deactivate: managerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ROLES);
const role = await ctx.db.role.update({
where: { id: input.id },
data: { isActive: false },
include: { _count: { select: { resourceRoles: true } } },
});
await ctx.db.auditLog.create({
data: {
entityType: "Role",
entityId: input.id,
action: "UPDATE",
changes: { after: { isActive: false } },
},
});
emitRoleUpdated({ id: role.id, isActive: false });
return attachSinglePlanningEntryCount(ctx.db, role);
}),
});
+224
View File
@@ -0,0 +1,224 @@
import { z } from "zod";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
import { createAiClient, isAiConfigured, parseAiError } from "../ai-client.js";
import { DEFAULT_SUMMARY_PROMPT } from "./resource.js";
import { VALUE_SCORE_WEIGHTS } from "@planarchy/shared";
import { testSmtpConnection } from "../lib/email.js";
export const settingsRouter = createTRPCRouter({
getSystemSettings: adminProcedure.query(async ({ ctx }) => {
const settings = await ctx.db.systemSettings.findUnique({
where: { id: "singleton" },
});
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,
};
return {
aiProvider: settings?.aiProvider ?? "openai",
azureOpenAiEndpoint: settings?.azureOpenAiEndpoint ?? null,
azureOpenAiDeployment: settings?.azureOpenAiDeployment ?? null,
azureApiVersion: settings?.azureApiVersion ?? "2025-01-01-preview",
aiMaxCompletionTokens: settings?.aiMaxCompletionTokens ?? 300,
aiTemperature: settings?.aiTemperature ?? 1,
aiSummaryPrompt: settings?.aiSummaryPrompt ?? null,
defaultSummaryPrompt: DEFAULT_SUMMARY_PROMPT,
hasApiKey: !!settings?.azureOpenAiApiKey,
scoreWeights: (settings?.scoreWeights as unknown as typeof defaultWeights) ?? defaultWeights,
scoreVisibleRoles: (settings?.scoreVisibleRoles as unknown as string[]) ?? ["ADMIN", "MANAGER"],
// SMTP
smtpHost: settings?.smtpHost ?? null,
smtpPort: settings?.smtpPort ?? 587,
smtpUser: settings?.smtpUser ?? null,
smtpFrom: settings?.smtpFrom ?? null,
smtpTls: settings?.smtpTls ?? true,
hasSmtpPassword: !!settings?.smtpPassword,
// Vacation defaults
vacationDefaultDays: settings?.vacationDefaultDays ?? 28,
};
}),
updateSystemSettings: adminProcedure
.input(
z.object({
aiProvider: z.enum(["openai", "azure"]).optional(),
azureOpenAiEndpoint: z.string().url().optional().or(z.literal("")),
azureOpenAiDeployment: z.string().optional(),
azureOpenAiApiKey: z.string().optional(),
azureApiVersion: z.string().optional(),
aiMaxCompletionTokens: z.number().int().min(50).max(4000).optional(),
aiTemperature: z.number().min(0).max(2).optional(),
aiSummaryPrompt: z.string().optional(),
scoreWeights: z.object({
skillDepth: z.number().min(0).max(1),
skillBreadth: z.number().min(0).max(1),
costEfficiency: z.number().min(0).max(1),
chargeability: z.number().min(0).max(1),
experience: z.number().min(0).max(1),
}).refine(
(w) => {
const sum = w.skillDepth + w.skillBreadth + w.costEfficiency + w.chargeability + w.experience;
return Math.abs(sum - 1.0) < 0.01;
},
{ message: "Score weights must sum to 1.0" },
).optional(),
scoreVisibleRoles: z.array(z.enum(["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"])).optional(),
// SMTP
smtpHost: z.string().optional(),
smtpPort: z.number().int().min(1).max(65535).optional(),
smtpUser: z.string().optional(),
smtpPassword: z.string().optional(),
smtpFrom: z.string().email().optional().or(z.literal("")),
smtpTls: z.boolean().optional(),
// Vacation
vacationDefaultDays: z.number().int().min(0).max(365).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const data: Record<string, unknown> = {};
if (input.aiProvider !== undefined) data.aiProvider = input.aiProvider;
if (input.azureOpenAiEndpoint !== undefined)
data.azureOpenAiEndpoint = input.azureOpenAiEndpoint || null;
if (input.azureOpenAiDeployment !== undefined)
data.azureOpenAiDeployment = input.azureOpenAiDeployment || null;
if (input.azureOpenAiApiKey !== undefined)
data.azureOpenAiApiKey = input.azureOpenAiApiKey || null;
if (input.azureApiVersion !== undefined)
data.azureApiVersion = input.azureApiVersion || null;
if (input.aiMaxCompletionTokens !== undefined)
data.aiMaxCompletionTokens = input.aiMaxCompletionTokens;
if (input.aiTemperature !== undefined)
data.aiTemperature = input.aiTemperature;
if (input.aiSummaryPrompt !== undefined)
data.aiSummaryPrompt = input.aiSummaryPrompt || null;
if (input.scoreWeights !== undefined)
data.scoreWeights = input.scoreWeights;
if (input.scoreVisibleRoles !== undefined)
data.scoreVisibleRoles = input.scoreVisibleRoles;
// SMTP
if (input.smtpHost !== undefined) data.smtpHost = input.smtpHost || null;
if (input.smtpPort !== undefined) data.smtpPort = input.smtpPort;
if (input.smtpUser !== undefined) data.smtpUser = input.smtpUser || null;
if (input.smtpPassword !== undefined) data.smtpPassword = input.smtpPassword || null;
if (input.smtpFrom !== undefined) data.smtpFrom = input.smtpFrom || null;
if (input.smtpTls !== undefined) data.smtpTls = input.smtpTls;
// Vacation
if (input.vacationDefaultDays !== undefined) data.vacationDefaultDays = input.vacationDefaultDays;
await ctx.db.systemSettings.upsert({
where: { id: "singleton" },
create: { id: "singleton", ...data },
update: data,
});
return { ok: true };
}),
testAiConnection: adminProcedure.mutation(async ({ ctx }) => {
const settings = await ctx.db.systemSettings.findUnique({
where: { id: "singleton" },
});
if (!isAiConfigured(settings)) {
const provider = settings?.aiProvider ?? "openai";
if (provider === "azure") {
return { ok: false, error: "Missing required fields: endpoint, deployment name, and API key are all required for Azure OpenAI." };
}
return { ok: false, error: "Missing required fields: model name and API key are required." };
}
const provider = settings!.aiProvider ?? "openai";
const apiKey = settings!.azureOpenAiApiKey!;
let url: string;
let headers: Record<string, string>;
if (provider === "azure") {
const endpoint = settings!.azureOpenAiEndpoint!.replace(/\/$/, "");
const deployment = settings!.azureOpenAiDeployment!;
const apiVersion = settings!.azureApiVersion ?? "2025-01-01-preview";
url = `${endpoint}/openai/deployments/${deployment}/chat/completions?api-version=${apiVersion}`;
headers = { "Content-Type": "application/json", "api-key": apiKey };
} else {
// Standard OpenAI API — deployment field holds the model name (e.g. "gpt-4o")
const model = settings!.azureOpenAiDeployment ?? "gpt-4o-mini";
url = "https://api.openai.com/v1/chat/completions";
headers = { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` };
// Override body to include model field for OpenAI
try {
const resp = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({
model,
messages: [{ role: "user", content: "ping" }],
max_completion_tokens: 5,
}),
});
const body = await resp.text();
if (resp.ok) return { ok: true, raw: null };
let msg = body;
try {
const parsed = JSON.parse(body) as { error?: { message?: string } };
if (parsed.error?.message) msg = parsed.error.message;
} catch { /* keep raw */ }
const raw = `HTTP ${resp.status}: ${msg}`;
return { ok: false, error: parseAiError(new Error(raw)), raw };
} catch (err) {
const raw = err instanceof Error ? err.message : String(err);
return { ok: false, error: parseAiError(err), raw };
}
}
try {
const resp = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({
messages: [{ role: "user", content: "ping" }],
max_completion_tokens: 5,
}),
});
const body = await resp.text();
if (resp.ok) {
return { ok: true, raw: null };
}
let azureMessage = body;
try {
const parsed = JSON.parse(body) as { error?: { message?: string; code?: string } };
if (parsed.error?.message) azureMessage = parsed.error.message;
} catch { /* leave as raw text */ }
const raw = `HTTP ${resp.status}: ${azureMessage}`;
return { ok: false, error: parseAiError(new Error(raw)), raw };
} catch (err) {
const raw = err instanceof Error ? err.message : String(err);
return { ok: false, error: parseAiError(err), raw };
}
}),
testSmtpConnection: adminProcedure.mutation(async () => {
return testSmtpConnection();
}),
getAiConfigured: protectedProcedure.query(async ({ ctx }) => {
const settings = await ctx.db.systemSettings.findUnique({
where: { id: "singleton" },
select: {
aiProvider: true,
azureOpenAiEndpoint: true,
azureOpenAiDeployment: true,
azureOpenAiApiKey: true,
},
});
return { configured: isAiConfigured(settings) };
}),
});
+200
View File
@@ -0,0 +1,200 @@
import { analyzeUtilization, findCapacityWindows, rankResources } from "@planarchy/staffing";
import { listAssignmentBookings } from "@planarchy/application";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
export const staffingRouter = createTRPCRouter({
/**
* Get ranked resource suggestions for a staffing requirement.
*/
getSuggestions: protectedProcedure
.input(
z.object({
requiredSkills: z.array(z.string()),
preferredSkills: z.array(z.string()).optional(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
hoursPerDay: z.number().min(0).max(24),
budgetLcrCentsPerHour: z.number().optional(),
chapter: z.string().optional(),
skillCategory: z.string().optional(),
mainSkillsOnly: z.boolean().optional(),
minProficiency: z.number().min(1).max(5).optional(),
}),
)
.query(async ({ ctx, input }) => {
const { requiredSkills, preferredSkills, startDate, endDate, hoursPerDay, budgetLcrCentsPerHour, chapter, skillCategory, mainSkillsOnly, minProficiency } = input;
const resources = await ctx.db.resource.findMany({
where: {
isActive: true,
...(chapter ? { chapter } : {}),
},
});
const bookings = await listAssignmentBookings(ctx.db, {
startDate,
endDate,
resourceIds: resources.map((resource) => resource.id),
});
// Compute utilization percent for each resource in the requested period
const enrichedResources = resources.map((resource) => {
const totalAvailableHours =
(resource.availability as { monday?: number; tuesday?: number; wednesday?: number; thursday?: number; friday?: number }).monday ?? 8;
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
const allocatedHoursPerDay = resourceBookings.reduce(
(sum, a) => sum + a.hoursPerDay,
0,
);
const utilizationPercent =
totalAvailableHours > 0
? Math.min(100, (allocatedHoursPerDay / totalAvailableHours) * 100)
: 0;
const wouldExceedCapacity = allocatedHoursPerDay + hoursPerDay > totalAvailableHours;
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
let skills = resource.skills as unknown as SkillRow[];
// Apply skill filters before matching
if (mainSkillsOnly) skills = skills.filter((s) => s.isMainSkill);
if (skillCategory) skills = skills.filter((s) => s.category === skillCategory);
if (minProficiency) skills = skills.filter((s) => s.proficiency >= minProficiency);
return {
id: resource.id,
displayName: resource.displayName,
eid: resource.eid,
skills: skills as unknown as import("@planarchy/shared").SkillEntry[],
lcrCents: resource.lcrCents,
chargeabilityTarget: resource.chargeabilityTarget,
currentUtilizationPercent: utilizationPercent,
hasAvailabilityConflicts: wouldExceedCapacity,
conflictDays: wouldExceedCapacity ? ["(multiple days)"] : [],
valueScore: resource.valueScore ?? 0,
};
});
const ranked = rankResources({
requiredSkills,
preferredSkills: preferredSkills,
resources: enrichedResources,
budgetLcrCentsPerHour,
} as unknown as Parameters<typeof rankResources>[0]);
// Value-score tiebreaker: within 2 points, prefer higher valueScore
return ranked.sort((a, b) => {
if (Math.abs(a.score - b.score) <= 2) {
const aVal = (enrichedResources.find((r) => r.id === a.resourceId)?.valueScore ?? 0);
const bVal = (enrichedResources.find((r) => r.id === b.resourceId)?.valueScore ?? 0);
return bVal - aVal;
}
return 0;
});
}),
/**
* Analyze utilization for a specific resource over a date range.
*/
analyzeUtilization: protectedProcedure
.input(
z.object({
resourceId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
}),
)
.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 resourceBookings = await listAssignmentBookings(ctx.db, {
startDate: input.startDate,
endDate: input.endDate,
resourceIds: [resource.id],
});
return analyzeUtilization({
resource: {
id: resource.id,
displayName: resource.displayName,
chargeabilityTarget: resource.chargeabilityTarget,
availability: resource.availability as unknown as import("@planarchy/shared").WeekdayAvailability,
},
allocations: resourceBookings.map((booking) => ({
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
status: booking.status,
projectName: booking.project.name,
isChargeable: booking.project.orderType === "CHARGEABLE",
})) as unknown as Parameters<typeof analyzeUtilization>[0]["allocations"],
analysisStart: input.startDate,
analysisEnd: input.endDate,
});
}),
/**
* Find capacity windows for a resource.
*/
findCapacity: protectedProcedure
.input(
z.object({
resourceId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
minAvailableHoursPerDay: z.number().optional().default(4),
}),
)
.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 resourceBookings = await listAssignmentBookings(ctx.db, {
startDate: input.startDate,
endDate: input.endDate,
resourceIds: [resource.id],
});
return findCapacityWindows(
{
id: resource.id,
displayName: resource.displayName,
availability: resource.availability as unknown as import("@planarchy/shared").WeekdayAvailability,
},
resourceBookings.map((booking) => ({
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
status: booking.status,
})) as Pick<import("@planarchy/shared").Allocation, "startDate" | "endDate" | "hoursPerDay" | "status">[],
input.startDate,
input.endDate,
input.minAvailableHoursPerDay,
);
}),
});
@@ -0,0 +1,60 @@
import {
buildSplitAllocationReadModel,
type SplitAssignmentRecord,
type SplitDemandRequirementRecord,
} from "@planarchy/application";
import type { ShiftInput } from "@planarchy/engine";
import type { WeekdayAvailability } from "@planarchy/shared";
export interface TimelineShiftWindow {
id: string;
resourceId: string;
projectId: string;
startDate: Date | string;
endDate: Date | string;
hoursPerDay: number;
status: string;
}
export interface BuildTimelineShiftPlanInput {
demandRequirements: SplitDemandRequirementRecord[];
assignments: SplitAssignmentRecord[];
allAssignmentWindows: TimelineShiftWindow[];
}
export interface TimelineShiftPlan {
validationAllocations: ShiftInput["allocations"];
}
export function buildTimelineShiftPlan({
demandRequirements,
assignments,
allAssignmentWindows,
}: BuildTimelineShiftPlanInput): TimelineShiftPlan {
const readModel = buildSplitAllocationReadModel({
demandRequirements,
assignments,
});
const validationAllocations = readModel.assignments
.filter((assignment) => assignment.resourceId !== null && assignment.resource)
.map((assignment) => {
const metadata = (assignment.metadata as Record<string, unknown> | null | undefined) ?? {};
return {
...assignment,
resource: {
...assignment.resource!,
availability: assignment.resource!.availability as WeekdayAvailability,
},
allAllocationsForResource: allAssignmentWindows.filter(
(window) => window.resourceId === assignment.resourceId,
),
includeSaturday: (metadata.includeSaturday as boolean | undefined) ?? false,
};
}) as unknown as ShiftInput["allocations"];
return {
validationAllocations,
};
}
+610
View File
@@ -0,0 +1,610 @@
import {
buildSplitAllocationReadModel,
createAssignment,
findAllocationEntry,
loadAllocationEntry,
listAssignmentBookings,
updateAssignment,
updateDemandRequirement,
updateAllocationEntry,
} from "@planarchy/application";
import type { PrismaClient } from "@planarchy/db";
import { calculateAllocation, computeBudgetStatus, validateShift } from "@planarchy/engine";
import { AllocationStatus, PermissionKey, ShiftProjectSchema, UpdateAllocationHoursSchema } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import {
loadProjectPlanningReadModel,
PROJECT_PLANNING_ASSIGNMENT_INCLUDE,
PROJECT_PLANNING_DEMAND_INCLUDE,
} from "./project-planning-read-model.js";
import {
emitAllocationCreated,
emitAllocationUpdated,
emitProjectShifted,
} from "../sse/event-bus.js";
import { buildTimelineShiftPlan } from "./timeline-shift-planning.js";
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
type ShiftDbClient = Pick<
PrismaClient,
"project" | "demandRequirement" | "assignment"
>;
type TimelineEntriesDbClient = Pick<
PrismaClient,
"demandRequirement" | "assignment"
>;
type TimelineEntriesFilters = {
startDate: Date;
endDate: Date;
resourceIds?: string[] | undefined;
projectIds?: string[] | undefined;
};
function getAssignmentResourceIds(
readModel: ReturnType<typeof buildSplitAllocationReadModel>,
): string[] {
return [
...new Set(
readModel.assignments
.map((assignment) => assignment.resourceId)
.filter((resourceId): resourceId is string => resourceId !== null),
),
];
}
async function loadTimelineEntriesReadModel(
db: TimelineEntriesDbClient,
input: TimelineEntriesFilters,
) {
const { startDate, endDate, resourceIds, projectIds } = input;
const [demandRequirements, assignments] = await Promise.all([
resourceIds && resourceIds.length > 0
? Promise.resolve([])
: db.demandRequirement.findMany({
where: {
status: { not: "CANCELLED" },
startDate: { lte: endDate },
endDate: { gte: startDate },
...(projectIds ? { projectId: { in: projectIds } } : {}),
},
include: PROJECT_PLANNING_DEMAND_INCLUDE,
orderBy: [{ startDate: "asc" }, { projectId: "asc" }],
}),
db.assignment.findMany({
where: {
status: { not: "CANCELLED" },
startDate: { lte: endDate },
endDate: { gte: startDate },
...(resourceIds ? { resourceId: { in: resourceIds } } : {}),
...(projectIds ? { projectId: { in: projectIds } } : {}),
},
include: PROJECT_PLANNING_ASSIGNMENT_INCLUDE,
orderBy: [{ startDate: "asc" }, { resourceId: "asc" }],
}),
]);
return buildSplitAllocationReadModel({ demandRequirements, assignments });
}
async function loadProjectShiftContext(db: ShiftDbClient, projectId: string) {
const [project, planningRead] = await Promise.all([
db.project.findUnique({
where: { id: projectId },
select: {
id: true,
budgetCents: true,
winProbability: true,
startDate: true,
endDate: true,
},
}),
loadProjectPlanningReadModel(db, { projectId, activeOnly: true }),
]);
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
const { demandRequirements, assignments, readModel: projectReadModel } = planningRead;
const resourceIds = getAssignmentResourceIds(projectReadModel);
const allAssignmentWindows =
resourceIds.length === 0
? []
: (
await listAssignmentBookings(db, {
resourceIds,
})
).map((booking) => ({
id: booking.id,
resourceId: booking.resourceId!,
projectId: booking.projectId,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
status: booking.status,
}));
const shiftPlan = buildTimelineShiftPlan({
demandRequirements,
assignments,
allAssignmentWindows,
});
return {
project,
demandRequirements,
assignments,
shiftPlan,
};
}
export const timelineRouter = createTRPCRouter({
/**
* Get all timeline entries (projects + allocations) for a date range.
* Includes project startDate, endDate, staffingReqs for demand overlay.
*/
getEntries: protectedProcedure
.input(
z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
resourceIds: z.array(z.string()).optional(),
projectIds: z.array(z.string()).optional(),
}),
)
.query(async ({ ctx, input }) => {
const readModel = await loadTimelineEntriesReadModel(ctx.db, input);
return readModel.allocations;
}),
getEntriesView: protectedProcedure
.input(
z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
resourceIds: z.array(z.string()).optional(),
projectIds: z.array(z.string()).optional(),
}),
)
.query(async ({ ctx, input }) => loadTimelineEntriesReadModel(ctx.db, input)),
/**
* Get full project context for a project:
* - project with staffingReqs and budget
* - all active planning entries on this project
* - all assignment bookings for the same resources (for cross-project overlap display)
* Used when: drag starts or project panel opens.
*/
getProjectContext: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const [project, planningRead] = await Promise.all([
ctx.db.project.findUnique({
where: { id: input.projectId },
select: {
id: true,
name: true,
shortCode: true,
orderType: true,
budgetCents: true,
winProbability: true,
status: true,
startDate: true,
endDate: true,
staffingReqs: true,
},
}),
loadProjectPlanningReadModel(ctx.db, {
projectId: input.projectId,
activeOnly: true,
}),
]);
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
const resourceIds = getAssignmentResourceIds(planningRead.readModel);
const allResourceAllocations =
resourceIds.length === 0
? []
: await listAssignmentBookings(ctx.db, {
resourceIds,
});
return {
project,
allocations: planningRead.readModel.allocations,
demands: planningRead.readModel.demands,
assignments: planningRead.readModel.assignments,
allResourceAllocations,
resourceIds,
};
}),
/**
* Inline update of an allocation's hours, dates, includeSaturday, or role.
* Recalculates dailyCostCents and emits SSE.
*/
updateAllocationInline: managerProcedure
.input(UpdateAllocationHoursSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const resolved = await loadAllocationEntry(ctx.db, input.allocationId);
const existing = resolved.entry;
const existingResource = resolved.resourceId
? await ctx.db.resource.findUnique({
where: { id: resolved.resourceId },
select: { id: true, lcrCents: true, availability: true },
})
: null;
const newHoursPerDay = input.hoursPerDay ?? existing.hoursPerDay;
const newStartDate = input.startDate ?? existing.startDate;
const newEndDate = input.endDate ?? existing.endDate;
if (newEndDate < newStartDate) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "End date must be after start date",
});
}
// Merge includeSaturday into metadata
const existingMeta = (existing.metadata as Record<string, unknown>) ?? {};
const newMeta: Record<string, unknown> = {
...existingMeta,
...(input.includeSaturday !== undefined
? { includeSaturday: input.includeSaturday }
: {}),
};
const includeSaturday =
input.includeSaturday ?? (existingMeta.includeSaturday as boolean | undefined) ?? false;
// For placeholder allocations (no resource), dailyCostCents stays 0
let newDailyCostCents = 0;
if (resolved.resourceId) {
if (!existingResource) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
const availability =
existingResource.availability as unknown as import("@planarchy/shared").WeekdayAvailability;
// Load recurrence from merged metadata
const recurrence = (newMeta.recurrence as import("@planarchy/shared").RecurrencePattern | undefined);
// Load approved vacations for recalculation (graceful fallback if table not yet migrated)
const vacationDates: Date[] = [];
try {
const vacations = await ctx.db.vacation.findMany({
where: {
resourceId: resolved.resourceId,
status: "APPROVED",
startDate: { lte: newEndDate },
endDate: { gte: newStartDate },
},
select: { startDate: true, endDate: true },
});
for (const v of vacations) {
const cur = new Date(v.startDate);
cur.setHours(0, 0, 0, 0);
const vEnd = new Date(v.endDate);
vEnd.setHours(0, 0, 0, 0);
while (cur <= vEnd) {
vacationDates.push(new Date(cur));
cur.setDate(cur.getDate() + 1);
}
}
} catch {
// vacation table may not exist yet — proceed without vacation adjustment
}
newDailyCostCents = calculateAllocation({
lcrCents: existingResource.lcrCents,
hoursPerDay: newHoursPerDay,
startDate: newStartDate,
endDate: newEndDate,
availability,
includeSaturday,
...(recurrence ? { recurrence } : {}),
vacationDates,
}).dailyCostCents;
}
const updated = await ctx.db.$transaction(async (tx) => {
const { allocation: updatedAllocation } = await updateAllocationEntry(
tx as unknown as Parameters<typeof updateAllocationEntry>[0],
{
id: input.allocationId,
demandRequirementUpdate: {
hoursPerDay: newHoursPerDay,
startDate: newStartDate,
endDate: newEndDate,
metadata: newMeta,
...(input.role !== undefined ? { role: input.role } : {}),
},
assignmentUpdate: {
hoursPerDay: newHoursPerDay,
startDate: newStartDate,
endDate: newEndDate,
dailyCostCents: newDailyCostCents,
metadata: newMeta,
...(input.role !== undefined ? { role: input.role } : {}),
},
},
);
await tx.auditLog.create({
data: {
entityType: "Allocation",
entityId: input.allocationId,
action: "UPDATE",
changes: {
before: {
id: resolved.entry.id,
hoursPerDay: existing.hoursPerDay,
startDate: existing.startDate,
endDate: existing.endDate,
},
after: {
id: updatedAllocation.id,
hoursPerDay: newHoursPerDay,
startDate: newStartDate,
endDate: newEndDate,
includeSaturday,
},
},
},
});
return updatedAllocation;
});
emitAllocationUpdated({
id: updated.id,
projectId: updated.projectId,
resourceId: updated.resourceId,
});
return updated;
}),
/**
* Preview a project shift — validate without committing.
* Returns cost impact, conflicts, warnings.
*/
previewShift: protectedProcedure
.input(ShiftProjectSchema)
.query(async ({ ctx, input }) => {
const { projectId, newStartDate, newEndDate } = input;
const { project, shiftPlan } = await loadProjectShiftContext(ctx.db, projectId);
return validateShift({
project: {
id: project.id,
budgetCents: project.budgetCents,
winProbability: project.winProbability,
startDate: project.startDate,
endDate: project.endDate,
},
newStartDate,
newEndDate,
allocations: shiftPlan.validationAllocations,
});
}),
/**
* Apply a project shift — validate, then commit all allocation date changes.
* Reads includeSaturday from each allocation's metadata.
*/
applyShift: managerProcedure
.input(ShiftProjectSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const { projectId, newStartDate, newEndDate } = input;
const { project, demandRequirements, assignments, shiftPlan } = await loadProjectShiftContext(
ctx.db,
projectId,
);
// Re-validate before committing
const validation = validateShift({
project: {
id: project.id,
budgetCents: project.budgetCents,
winProbability: project.winProbability,
startDate: project.startDate,
endDate: project.endDate,
},
newStartDate,
newEndDate,
allocations: shiftPlan.validationAllocations,
});
if (!validation.valid) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Shift validation failed: ${validation.errors.map((e) => e.message).join(", ")}`,
});
}
// Apply shift in a transaction
const updatedProject = await ctx.db.$transaction(async (tx) => {
// Update project dates
const proj = await tx.project.update({
where: { id: projectId },
data: { startDate: newStartDate, endDate: newEndDate },
});
for (const demandRequirement of demandRequirements) {
await updateDemandRequirement(
tx as unknown as Parameters<typeof updateDemandRequirement>[0],
demandRequirement.id,
{
startDate: newStartDate,
endDate: newEndDate,
},
);
}
for (const assignment of assignments) {
const metadata = (assignment.metadata as Record<string, unknown> | null | undefined) ?? {};
const includeSaturday = (metadata.includeSaturday as boolean | undefined) ?? false;
const newDailyCost = calculateAllocation({
lcrCents: assignment.resource!.lcrCents,
hoursPerDay: assignment.hoursPerDay,
startDate: newStartDate,
endDate: newEndDate,
availability:
assignment.resource!.availability as unknown as import("@planarchy/shared").WeekdayAvailability,
includeSaturday,
}).dailyCostCents;
await updateAssignment(
tx as unknown as Parameters<typeof updateAssignment>[0],
assignment.id,
{
startDate: newStartDate,
endDate: newEndDate,
dailyCostCents: newDailyCost,
},
);
}
// Write audit log
await tx.auditLog.create({
data: {
entityType: "Project",
entityId: projectId,
action: "SHIFT",
changes: {
before: { startDate: project.startDate, endDate: project.endDate },
after: { startDate: newStartDate, endDate: newEndDate },
costImpact: validation.costImpact,
} as unknown as import("@planarchy/db").Prisma.InputJsonValue,
},
});
return proj;
});
// Emit SSE event for live updates
emitProjectShifted({
projectId,
newStartDate: newStartDate.toISOString(),
newEndDate: newEndDate.toISOString(),
costDeltaCents: validation.costImpact.deltaCents,
});
return { project: updatedProject, validation };
}),
/**
* Quick-assign a resource to a project for a date range.
* Overbooking is intentionally allowed — no availability throw.
* For use from the timeline drag-to-assign UI.
*/
quickAssign: managerProcedure
.input(
z.object({
resourceId: z.string(),
projectId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
hoursPerDay: z.number().min(0.5).max(24).default(8),
role: z.string().min(1).max(200).default("Team Member"),
roleId: z.string().optional(),
status: z.nativeEnum(AllocationStatus).default(AllocationStatus.PROPOSED),
}),
)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
if (input.endDate < input.startDate) {
throw new TRPCError({ code: "BAD_REQUEST", message: "End date must be after start date" });
}
const percentage = Math.min(100, Math.round((input.hoursPerDay / 8) * 100));
const metadata = { source: "quickAssign" } satisfies Record<string, unknown>;
const allocation = await ctx.db.$transaction(async (tx) => {
const assignment = await createAssignment(
tx as unknown as Parameters<typeof createAssignment>[0],
{
resourceId: input.resourceId,
projectId: input.projectId,
startDate: input.startDate,
endDate: input.endDate,
hoursPerDay: input.hoursPerDay,
percentage,
role: input.role,
roleId: input.roleId ?? undefined,
status: input.status,
metadata,
},
);
return buildSplitAllocationReadModel({
demandRequirements: [],
assignments: [assignment],
}).allocations[0]!;
});
emitAllocationCreated({
id: allocation.id,
projectId: allocation.projectId,
resourceId: allocation.resourceId,
});
return allocation;
}),
/**
* Get budget status for a project.
*/
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 bookings = await listAssignmentBookings(ctx.db, {
startDate: project.startDate,
endDate: project.endDate,
projectIds: [project.id],
});
return computeBudgetStatus(
project.budgetCents,
project.winProbability,
bookings.map((booking) => ({
status: booking.status,
dailyCostCents: booking.dailyCostCents,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
})) as unknown as Pick<import("@planarchy/shared").Allocation, "status" | "dailyCostCents" | "startDate" | "endDate" | "hoursPerDay">[],
project.startDate,
project.endDate,
);
}),
});
+211
View File
@@ -0,0 +1,211 @@
import {
PermissionOverrides,
SystemRole,
resolvePermissions,
type ColumnPreferences,
} from "@planarchy/shared/types";
import {
dashboardLayoutSchema,
normalizeDashboardLayout,
} from "@planarchy/shared/schemas";
import { Prisma } from "@planarchy/db";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
export const userRouter = createTRPCRouter({
list: protectedProcedure.query(async ({ ctx }) => {
return ctx.db.user.findMany({
select: {
id: true,
name: true,
email: true,
systemRole: true,
createdAt: true,
},
orderBy: { name: "asc" },
});
}),
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" });
}
return user;
}),
create: adminProcedure
.input(
z.object({
email: z.string().email(),
name: z.string().min(1),
systemRole: z.nativeEnum(SystemRole).default(SystemRole.USER),
password: z.string().min(8),
}),
)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.user.findUnique({ where: { email: input.email } });
if (existing) {
throw new TRPCError({ code: "CONFLICT", message: "User with this email already exists" });
}
const { hash } = await import("@node-rs/argon2");
const passwordHash = await hash(input.password);
return ctx.db.user.create({
data: {
email: input.email,
name: input.name,
systemRole: input.systemRole,
passwordHash,
},
select: { id: true, name: true, email: true, systemRole: true },
});
}),
updateRole: adminProcedure
.input(
z.object({
id: z.string(),
systemRole: z.nativeEnum(SystemRole),
}),
)
.mutation(async ({ ctx, input }) => {
return ctx.db.user.update({
where: { id: input.id },
data: { systemRole: input.systemRole },
select: { id: true, name: true, email: true, systemRole: true },
});
}),
getDashboardLayout: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.db.user.findUnique({
where: { email: ctx.session.user?.email ?? "" },
select: { dashboardLayout: true, updatedAt: true },
});
return {
layout: user?.dashboardLayout ? normalizeDashboardLayout(user.dashboardLayout) : null,
updatedAt: user?.updatedAt ?? null,
};
}),
saveDashboardLayout: protectedProcedure
.input(z.object({ layout: dashboardLayoutSchema }))
.mutation(async ({ ctx, input }) => {
const updated = await ctx.db.user.update({
where: { email: ctx.session.user?.email ?? "" },
data: { dashboardLayout: input.layout as unknown as import("@planarchy/db").Prisma.InputJsonValue },
select: { updatedAt: true },
});
return { updatedAt: updated.updatedAt };
}),
setPermissions: adminProcedure
.input(
z.object({
userId: z.string(),
overrides: z
.object({
granted: z.array(z.string()).optional(),
denied: z.array(z.string()).optional(),
chapterIds: z.array(z.string()).optional(),
})
.nullable(),
}),
)
.mutation(async ({ ctx, input }) => {
const user = await ctx.db.user.update({
where: { id: input.userId },
data: { permissionOverrides: input.overrides ?? Prisma.DbNull },
});
return user;
}),
resetPermissions: adminProcedure
.input(z.object({ userId: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.db.user.update({
where: { id: input.userId },
data: { permissionOverrides: Prisma.DbNull },
});
}),
getColumnPreferences: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.db.user.findUnique({
where: { id: ctx.dbUser!.id },
select: { columnPreferences: true },
});
return (user?.columnPreferences ?? {}) as ColumnPreferences;
}),
setColumnPreferences: protectedProcedure
.input(z.object({
view: z.enum(["resources", "projects", "allocations", "vacations", "roles", "users", "blueprints"]),
visible: z.array(z.string()).optional(),
sort: z.object({ field: z.string(), dir: z.enum(["asc", "desc"]) }).nullable().optional(),
rowOrder: z.array(z.string()).nullable().optional(),
}))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.user.findUnique({
where: { id: ctx.dbUser!.id },
select: { columnPreferences: true },
});
const prefs = (existing?.columnPreferences ?? {}) as ColumnPreferences;
const prev = (prefs[input.view] as import("@planarchy/shared").ViewPreferences | undefined) ?? { visible: [] };
// Merge: only overwrite fields that were explicitly provided
const merged: import("@planarchy/shared").ViewPreferences = {
visible: input.visible ?? prev.visible,
};
// sort: null = clear, undefined = keep existing, value = set
if (input.sort !== null && input.sort !== undefined) {
merged.sort = input.sort;
} else if (input.sort === undefined && prev.sort != null) {
merged.sort = prev.sort;
}
// rowOrder: null = clear, undefined = keep existing, value = set
if (input.rowOrder !== null && input.rowOrder !== undefined) {
merged.rowOrder = input.rowOrder;
} else if (input.rowOrder === undefined && prev.rowOrder != null) {
merged.rowOrder = prev.rowOrder;
}
prefs[input.view] = merged;
await ctx.db.user.update({
where: { id: ctx.dbUser!.id },
data: { columnPreferences: prefs as Prisma.InputJsonValue },
});
return { ok: true };
}),
getEffectivePermissions: adminProcedure
.input(z.object({ userId: z.string() }))
.query(async ({ ctx, input }) => {
const user = await ctx.db.user.findUniqueOrThrow({
where: { id: input.userId },
select: { systemRole: true, permissionOverrides: true },
});
const permissions = resolvePermissions(
user.systemRole as SystemRole,
user.permissionOverrides as PermissionOverrides | null,
);
return {
systemRole: user.systemRole,
effectivePermissions: Array.from(permissions),
overrides: user.permissionOverrides as PermissionOverrides | null,
};
}),
});
@@ -0,0 +1,92 @@
import {
CreateUtilizationCategorySchema,
UpdateUtilizationCategorySchema,
} from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
export const utilizationCategoryRouter = createTRPCRouter({
list: protectedProcedure
.input(z.object({ isActive: z.boolean().optional() }).optional())
.query(async ({ ctx, input }) => {
return ctx.db.utilizationCategory.findMany({
where: {
...(input?.isActive !== undefined ? { isActive: input.isActive } : {}),
},
orderBy: { sortOrder: "asc" },
});
}),
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" });
return cat;
}),
create: adminProcedure
.input(CreateUtilizationCategorySchema)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.utilizationCategory.findUnique({ where: { code: input.code } });
if (existing) {
throw new TRPCError({ code: "CONFLICT", message: `Code "${input.code}" already exists` });
}
// If setting as default, unset the current default first
if (input.isDefault) {
await ctx.db.utilizationCategory.updateMany({
where: { isDefault: true },
data: { isDefault: false },
});
}
return ctx.db.utilizationCategory.create({
data: {
code: input.code,
name: input.name,
...(input.description !== undefined ? { description: input.description } : {}),
sortOrder: input.sortOrder,
isDefault: input.isDefault,
},
});
}),
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" });
if (input.data.code && input.data.code !== existing.code) {
const conflict = await ctx.db.utilizationCategory.findUnique({ where: { code: input.data.code } });
if (conflict) {
throw new TRPCError({ code: "CONFLICT", message: `Code "${input.data.code}" already exists` });
}
}
// If setting as default, unset others
if (input.data.isDefault) {
await ctx.db.utilizationCategory.updateMany({
where: { isDefault: true, id: { not: input.id } },
data: { isDefault: false },
});
}
return ctx.db.utilizationCategory.update({
where: { id: input.id },
data: {
...(input.data.code !== undefined ? { code: input.data.code } : {}),
...(input.data.name !== undefined ? { name: input.data.name } : {}),
...(input.data.description !== undefined ? { description: input.data.description } : {}),
...(input.data.sortOrder !== undefined ? { sortOrder: input.data.sortOrder } : {}),
...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}),
...(input.data.isDefault !== undefined ? { isDefault: input.data.isDefault } : {}),
},
});
}),
});
+549
View File
@@ -0,0 +1,549 @@
import { UpdateVacationStatusSchema, getPublicHolidays } from "@planarchy/shared";
import { VacationStatus, VacationType } from "@planarchy/db";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { emitVacationCreated, emitVacationUpdated, emitNotificationCreated } from "../sse/event-bus.js";
import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
import { sendEmail } from "../lib/email.js";
/** Types that consume from annual leave balance */
const BALANCE_TYPES = [VacationType.ANNUAL, VacationType.OTHER];
/** Send in-app notification + optional email when vacation status changes */
async function notifyVacationStatus(
db: Parameters<Parameters<typeof protectedProcedure["query"]>[0]>[0]["ctx"]["db"],
vacationId: string,
resourceId: string,
newStatus: VacationStatus,
rejectionReason?: string | null,
) {
// Find the resource's linked user
const resource = await db.resource.findUnique({
where: { id: resourceId },
select: {
displayName: true,
user: { select: { id: true, email: true, name: true } },
},
});
if (!resource?.user) return;
const statusLabel = newStatus === VacationStatus.APPROVED ? "approved" : "rejected";
const title = `Vacation request ${statusLabel}`;
const body = rejectionReason
? `Your vacation request was ${statusLabel}. Reason: ${rejectionReason}`
: `Your vacation request has been ${statusLabel}.`;
// In-app notification
const notification = await db.notification.create({
data: {
userId: resource.user.id,
type: `VACATION_${newStatus}`,
title,
body,
entityId: vacationId,
entityType: "vacation",
},
});
emitNotificationCreated(resource.user.id, notification.id);
// Email (non-blocking)
if (resource.user.email) {
void sendEmail({
to: resource.user.email,
subject: `Planarchy — ${title}`,
text: body,
});
}
}
export const vacationRouter = createTRPCRouter({
/**
* List vacations with optional filters.
*/
list: protectedProcedure
.input(
z.object({
resourceId: z.string().optional(),
status: z.nativeEnum(VacationStatus).optional(),
type: z.nativeEnum(VacationType).optional(),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
limit: z.number().min(1).max(500).default(100),
}),
)
.query(async ({ ctx, input }) => {
return ctx.db.vacation.findMany({
where: {
...(input.resourceId ? { resourceId: input.resourceId } : {}),
...(input.status ? { status: input.status } : {}),
...(input.type ? { type: input.type } : {}),
...(input.startDate ? { endDate: { gte: input.startDate } } : {}),
...(input.endDate ? { startDate: { lte: input.endDate } } : {}),
},
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 } },
},
orderBy: { startDate: "asc" },
take: input.limit,
});
}),
/**
* Get a single vacation by ID.
*/
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;
}),
/**
* Create a vacation request.
* - MANAGER/ADMIN → auto-approved
* - USER → PENDING
* Adds isHalfDay + halfDayPart support.
*/
create: protectedProcedure
.input(
z.object({
resourceId: z.string(),
type: z.nativeEnum(VacationType),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
note: z.string().max(500).optional(),
isHalfDay: z.boolean().optional(),
halfDayPart: z.enum(["MORNING", "AFTERNOON"]).optional(),
}).refine((d) => d.endDate >= d.startDate, {
message: "End date must be after start date",
path: ["endDate"],
}),
)
.mutation(async ({ ctx, input }) => {
const userRecord = await ctx.db.user.findUnique({
where: { email: ctx.session.user?.email ?? "" },
select: { id: true, systemRole: true },
});
if (!userRecord) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
// Check for overlapping APPROVED or PENDING vacations
const overlapping = await ctx.db.vacation.findFirst({
where: {
resourceId: input.resourceId,
status: { in: ["APPROVED", "PENDING"] },
startDate: { lte: input.endDate },
endDate: { gte: input.startDate },
},
});
if (overlapping) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Overlapping vacation already exists for this resource in the selected period",
});
}
const isManager = userRecord.systemRole === "ADMIN" || userRecord.systemRole === "MANAGER";
const status = isManager ? VacationStatus.APPROVED : VacationStatus.PENDING;
const vacation = await ctx.db.vacation.create({
data: {
resourceId: input.resourceId,
type: input.type,
status,
startDate: input.startDate,
endDate: input.endDate,
...(input.note !== undefined ? { note: input.note } : {}),
isHalfDay: input.isHalfDay ?? false,
...(input.halfDayPart !== undefined ? { halfDayPart: input.halfDayPart } : {}),
requestedById: userRecord.id,
...(isManager
? { approvedById: userRecord.id, approvedAt: new Date() }
: {}),
},
include: {
resource: { select: { id: true, displayName: true, eid: true } },
requestedBy: { select: { id: true, name: true, email: true } },
},
});
emitVacationCreated({ id: vacation.id, resourceId: vacation.resourceId, status: vacation.status });
return vacation;
}),
/**
* Approve a vacation (manager/admin only).
*/
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 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" });
}
const userRecord = await ctx.db.user.findUnique({
where: { email: ctx.session.user?.email ?? "" },
select: { id: true },
});
const updated = await ctx.db.vacation.update({
where: { id: input.id },
data: {
status: VacationStatus.APPROVED,
rejectionReason: null,
...(userRecord?.id ? { approvedById: userRecord.id } : {}),
approvedAt: new Date(),
},
});
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
if (existing.status === VacationStatus.PENDING) {
void notifyVacationStatus(ctx.db, updated.id, updated.resourceId, VacationStatus.APPROVED);
}
return updated;
}),
/**
* Reject a vacation (manager/admin only).
*/
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" });
}
if (existing.status !== VacationStatus.PENDING) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Only PENDING vacations can be rejected" });
}
const updated = await ctx.db.vacation.update({
where: { id: input.id },
data: {
status: VacationStatus.REJECTED,
...(input.rejectionReason !== undefined ? { rejectionReason: input.rejectionReason } : {}),
},
});
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
void notifyVacationStatus(ctx.db, updated.id, updated.resourceId, VacationStatus.REJECTED, input.rejectionReason);
return updated;
}),
/**
* Batch approve multiple pending vacations (manager/admin only).
*/
batchApprove: managerProcedure
.input(z.object({ ids: z.array(z.string()).min(1).max(100) }))
.mutation(async ({ ctx, input }) => {
const userRecord = await ctx.db.user.findUnique({
where: { email: ctx.session.user?.email ?? "" },
select: { id: true },
});
const vacations = await ctx.db.vacation.findMany({
where: { id: { in: input.ids }, status: VacationStatus.PENDING },
select: { id: true, resourceId: true },
});
await ctx.db.vacation.updateMany({
where: { id: { in: vacations.map((v) => v.id) } },
data: {
status: VacationStatus.APPROVED,
rejectionReason: null,
...(userRecord?.id ? { approvedById: userRecord.id } : {}),
approvedAt: new Date(),
},
});
for (const v of vacations) {
emitVacationUpdated({ id: v.id, resourceId: v.resourceId, status: VacationStatus.APPROVED });
void notifyVacationStatus(ctx.db, v.id, v.resourceId, VacationStatus.APPROVED);
}
return { approved: vacations.length };
}),
/**
* Batch reject multiple pending vacations (manager/admin only).
*/
batchReject: managerProcedure
.input(
z.object({
ids: z.array(z.string()).min(1).max(100),
rejectionReason: z.string().max(500).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const vacations = await ctx.db.vacation.findMany({
where: { id: { in: input.ids }, status: VacationStatus.PENDING },
select: { id: true, resourceId: true },
});
await ctx.db.vacation.updateMany({
where: { id: { in: vacations.map((v) => v.id) } },
data: {
status: VacationStatus.REJECTED,
...(input.rejectionReason !== undefined ? { rejectionReason: input.rejectionReason } : {}),
},
});
for (const v of vacations) {
emitVacationUpdated({ id: v.id, resourceId: v.resourceId, status: VacationStatus.REJECTED });
void notifyVacationStatus(ctx.db, v.id, v.resourceId, VacationStatus.REJECTED, input.rejectionReason);
}
return { rejected: vacations.length };
}),
/**
* Cancel a vacation (owner or manager).
*/
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" });
}
if (existing.status === VacationStatus.CANCELLED) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Already cancelled" });
}
const updated = await ctx.db.vacation.update({
where: { id: input.id },
data: { status: VacationStatus.CANCELLED },
});
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
return updated;
}),
/**
* Get all APPROVED vacations for a resource in a date range (used by calculator).
*/
getForResource: protectedProcedure
.input(
z.object({
resourceId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
}),
)
.query(async ({ ctx, input }) => {
return ctx.db.vacation.findMany({
where: {
resourceId: input.resourceId,
status: VacationStatus.APPROVED,
startDate: { lte: input.endDate },
endDate: { gte: input.startDate },
},
select: {
id: true,
startDate: true,
endDate: true,
type: true,
status: true,
},
orderBy: { startDate: "asc" },
});
}),
/**
* Get all PENDING vacations awaiting approval (manager/admin only).
*/
getPendingApprovals: managerProcedure.query(async ({ ctx }) => {
return ctx.db.vacation.findMany({
where: { status: VacationStatus.PENDING },
include: {
resource: { select: { id: true, displayName: true, eid: true } },
requestedBy: { select: { id: true, name: true, email: true } },
},
orderBy: { startDate: "asc" },
});
}),
/**
* Get team overlap: other vacations in the same chapter for a given period.
* Used by the creation modal to warn the requester.
*/
getTeamOverlap: protectedProcedure
.input(
z.object({
resourceId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
}),
)
.query(async ({ ctx, input }) => {
// Find the chapter of the requesting resource
const resource = await ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: { chapter: true },
});
if (!resource?.chapter) return [];
// Find team members in the same chapter who are off in this period
return ctx.db.vacation.findMany({
where: {
resource: { chapter: resource.chapter },
resourceId: { not: input.resourceId },
status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] },
startDate: { lte: input.endDate },
endDate: { gte: input.startDate },
},
include: {
resource: { select: { id: true, displayName: true, eid: true } },
},
orderBy: { startDate: "asc" },
take: 20,
});
}),
/**
* Batch-create public holidays for all resources (or a chapter) for a given year+state.
* Admin-only. Creates as APPROVED automatically.
*/
batchCreatePublicHolidays: adminProcedure
.input(
z.object({
year: z.number().int().min(2000).max(2100),
federalState: z.string().optional(), // e.g. "BY"
chapter: z.string().optional(), // filter to a chapter
replaceExisting: z.boolean().default(false),
}),
)
.mutation(async ({ ctx, input }) => {
const holidays = getPublicHolidays(input.year, input.federalState);
if (holidays.length === 0) {
return { created: 0 };
}
const resources = await ctx.db.resource.findMany({
where: {
isActive: true,
...(input.chapter ? { chapter: input.chapter } : {}),
},
select: { id: true },
});
const adminUser = await ctx.db.user.findUnique({
where: { email: ctx.session.user?.email ?? "" },
select: { id: true },
});
if (!adminUser) throw new TRPCError({ code: "UNAUTHORIZED" });
let created = 0;
for (const resource of resources) {
for (const holiday of holidays) {
const startDate = new Date(holiday.date);
const endDate = new Date(holiday.date);
if (input.replaceExisting) {
// Remove any existing public holiday on this exact date for this resource
await ctx.db.vacation.deleteMany({
where: {
resourceId: resource.id,
type: VacationType.PUBLIC_HOLIDAY,
startDate,
endDate,
},
});
}
// Check if one already exists
const exists = await ctx.db.vacation.findFirst({
where: {
resourceId: resource.id,
type: VacationType.PUBLIC_HOLIDAY,
startDate,
endDate,
},
});
if (exists) continue;
await ctx.db.vacation.create({
data: {
resourceId: resource.id,
type: VacationType.PUBLIC_HOLIDAY,
status: VacationStatus.APPROVED,
startDate,
endDate,
note: holiday.name,
requestedById: adminUser.id,
approvedById: adminUser.id,
approvedAt: new Date(),
},
});
created++;
}
}
return { created, holidays: holidays.length, resources: resources.length };
}),
/**
* Update vacation status (approve/reject/cancel via schema).
*/
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 userRecord = await ctx.db.user.findUnique({
where: { email: ctx.session.user?.email ?? "" },
select: { id: true, systemRole: true },
});
if (!userRecord) throw new TRPCError({ code: "UNAUTHORIZED" });
const isManager = userRecord.systemRole === "ADMIN" || userRecord.systemRole === "MANAGER";
if (input.status !== "CANCELLED" && !isManager) {
throw new TRPCError({ code: "FORBIDDEN", message: "Manager role required to approve/reject" });
}
const data: Record<string, unknown> = { status: input.status };
if (input.status === "APPROVED") {
data.approvedById = userRecord.id;
data.approvedAt = new Date();
data.rejectionReason = null;
}
if (input.note !== undefined) {
data.note = input.note;
}
const updated = await ctx.db.vacation.update({
where: { id: input.id },
data,
});
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
return updated;
}),
});
+133
View File
@@ -0,0 +1,133 @@
import { Redis } from "ioredis";
import { SSE_EVENT_TYPES, type SseEventType } from "@planarchy/shared";
export interface SseEvent {
type: SseEventType;
payload: Record<string, unknown>;
timestamp: string;
}
type Subscriber = (event: SseEvent) => void;
// Module-level subscriber registry (shared between EventBus and publishLocal)
const subscribers = new Set<Subscriber>();
// 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";
let publisher: Redis | null = null;
let subscriber: Redis | null = null;
function getPublisher(): Redis {
if (!publisher) {
publisher = new Redis(REDIS_URL, { lazyConnect: false, enableReadyCheck: false });
publisher.on("error", (e: unknown) => console.error("[Redis publisher]", e));
}
return publisher;
}
function setupSubscriber(): void {
if (subscriber) return;
try {
subscriber = new Redis(REDIS_URL, { lazyConnect: false, enableReadyCheck: false });
subscriber.on("error", (e: unknown) => console.error("[Redis subscriber]", e));
void subscriber.subscribe(CHANNEL).catch((err: unknown) => {
console.error("[Redis subscribe]", err);
});
subscriber.on("message", (_channel: string, message: string) => {
try {
const parsed = JSON.parse(message) as { type: SseEventType; payload: Record<string, unknown>; timestamp: string };
publishLocal({ type: parsed.type, payload: parsed.payload, timestamp: parsed.timestamp });
} catch { /* ignore parse errors */ }
});
} catch (e) {
console.warn("[Redis setupSubscriber] Redis unavailable, SSE will be local-only:", e);
}
}
/**
* SSE Event Bus with Redis Pub/Sub for multi-instance support.
* Gracefully degrades to in-memory delivery when Redis is unavailable.
*/
class EventBus {
subscribe(fn: Subscriber): () => void {
subscribers.add(fn);
return () => subscribers.delete(fn);
}
publish(event: SseEvent): void {
// Broadcast via Redis (all instances receive via subscriber.on("message"))
try {
const pub = getPublisher();
void pub.publish(CHANNEL, JSON.stringify({ type: event.type, payload: event.payload, timestamp: event.timestamp }));
} catch (e) {
console.warn("[Redis emit] fallback to local-only:", e);
// Deliver locally when Redis is unavailable
publishLocal(event);
}
}
emit(type: SseEventType, payload: Record<string, unknown>): void {
this.publish({
type,
payload,
timestamp: new Date().toISOString(),
});
}
get subscriberCount(): number {
return subscribers.size;
}
}
// Local delivery: deliver to subscribers connected to THIS instance (called from Redis subscriber)
function publishLocal(event: SseEvent): void {
for (const fn of subscribers) {
fn(event);
}
}
// Singleton event bus
export const eventBus = new EventBus();
// Start Redis subscriber once at module init (best-effort)
setupSubscriber();
// Helper emitters
export const emitAllocationCreated = (allocation: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_CREATED, allocation);
export const emitAllocationUpdated = (allocation: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_UPDATED, allocation);
export const emitAllocationDeleted = (allocationId: string, projectId: string) =>
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_DELETED, { allocationId, projectId });
export const emitProjectShifted = (project: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.PROJECT_SHIFTED, project);
export const emitBudgetWarning = (projectId: string, payload: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.BUDGET_WARNING, { projectId, ...payload });
export const emitVacationCreated = (vacation: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.VACATION_CREATED, vacation);
export const emitVacationUpdated = (vacation: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.VACATION_UPDATED, vacation);
export const emitVacationDeleted = (vacationId: string, resourceId: string) =>
eventBus.emit(SSE_EVENT_TYPES.VACATION_DELETED, { vacationId, resourceId });
export const emitRoleCreated = (role: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.ROLE_CREATED, role);
export const emitRoleUpdated = (role: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.ROLE_UPDATED, role);
export const emitRoleDeleted = (roleId: string) =>
eventBus.emit(SSE_EVENT_TYPES.ROLE_DELETED, { roleId });
export function emitNotificationCreated(userId: string, notificationId: string): void {
eventBus.emit(SSE_EVENT_TYPES.NOTIFICATION_CREATED, { userId, notificationId });
}
+129
View File
@@ -0,0 +1,129 @@
import { prisma } from "@planarchy/db";
import { resolvePermissions, PermissionKey, SystemRole } from "@planarchy/shared";
import { initTRPC, TRPCError } from "@trpc/server";
import { ZodError } from "zod";
// Minimal Session type to avoid next-auth peer-dep in this package
interface Session {
user?: { email?: string | null; name?: string | null; image?: string | null } | null;
expires: string;
}
// ─── Context ──────────────────────────────────────────────────────────────────
export interface TRPCContext {
session: Session | null;
db: typeof prisma;
dbUser: { id: string; systemRole: string; permissionOverrides: unknown } | null;
}
export function createTRPCContext(opts: {
session: Session | null;
dbUser?: { id: string; systemRole: string; permissionOverrides: unknown } | null;
}): TRPCContext {
return {
session: opts.session,
db: prisma,
dbUser: opts.dbUser ?? null,
};
}
// ─── tRPC Init ───────────────────────────────────────────────────────────────
const t = initTRPC.context<TRPCContext>().create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
// ─── Procedures ──────────────────────────────────────────────────────────────
export const createTRPCRouter = t.router;
export const createCallerFactory = t.createCallerFactory;
/**
* Public procedure — no authentication required.
*/
export const publicProcedure = t.procedure;
/**
* Protected procedure — requires any authenticated session.
*/
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Authentication required" });
}
return next({
ctx: {
...ctx,
session: ctx.session,
user: ctx.session.user,
},
});
});
/**
* Manager procedure — requires MANAGER or ADMIN role.
*/
export const managerProcedure = protectedProcedure.use(({ ctx, next }) => {
const user = ctx.dbUser;
if (!user) throw new TRPCError({ code: "UNAUTHORIZED" });
const allowedRoles: string[] = [SystemRole.ADMIN, SystemRole.MANAGER];
if (!allowedRoles.includes(user.systemRole)) {
throw new TRPCError({ code: "FORBIDDEN", message: "Manager or Admin role required" });
}
const permissions = resolvePermissions(
user.systemRole as SystemRole,
user.permissionOverrides as import("@planarchy/shared").PermissionOverrides | null
);
return next({ ctx: { ...ctx, user, permissions } });
});
/**
* Controller procedure — requires CONTROLLER, MANAGER, or ADMIN role.
* Grants read-only access to financial and export data.
*/
export const controllerProcedure = protectedProcedure.use(({ ctx, next }) => {
const user = ctx.dbUser;
if (!user) throw new TRPCError({ code: "UNAUTHORIZED" });
const allowed: SystemRole[] = [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER];
if (!allowed.includes(user.systemRole as SystemRole)) {
throw new TRPCError({ code: "FORBIDDEN", message: "Controller access required" });
}
const permissions = resolvePermissions(
user.systemRole as SystemRole,
user.permissionOverrides as import("@planarchy/shared").PermissionOverrides | null
);
return next({ ctx: { ...ctx, user, permissions } });
});
/**
* Admin procedure — requires ADMIN role only.
*/
export const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
const user = ctx.dbUser;
if (!user || user.systemRole !== SystemRole.ADMIN) {
throw new TRPCError({ code: "FORBIDDEN", message: "Admin role required" });
}
const permissions = resolvePermissions(SystemRole.ADMIN, null);
return next({ ctx: { ...ctx, user, permissions } });
});
/**
* requirePermission — throws FORBIDDEN if the ctx lacks the given permission.
*/
export function requirePermission(
ctx: { permissions: Set<PermissionKey> },
key: PermissionKey
): void {
if (!ctx.permissions.has(key)) {
throw new TRPCError({ code: "FORBIDDEN", message: `Permission required: ${key}` });
}
}