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,35 @@
import type { Prisma, PrismaClient } from "@planarchy/db";
import { AllocationStatus } from "@planarchy/shared";
type DbClient = PrismaClient | Prisma.TransactionClient;
export interface DemandRequirementFillProgressInput {
demandRequirementId: string;
headcount: number;
}
export const UPDATED_DEMAND_REQUIREMENT_SELECT = {
id: true,
projectId: true,
headcount: true,
status: true,
} as const;
export async function applyDemandRequirementFillProgress(
db: DbClient,
input: DemandRequirementFillProgressInput,
) {
const updatedDemandRequirement = await db.demandRequirement.update({
where: { id: input.demandRequirementId },
data:
input.headcount > 1
? { headcount: input.headcount - 1 }
: { status: AllocationStatus.COMPLETED },
select: UPDATED_DEMAND_REQUIREMENT_SELECT,
});
return {
...updatedDemandRequirement,
status: updatedDemandRequirement.status as AllocationStatus,
};
}
@@ -0,0 +1,54 @@
import type {
AllocationLike,
AllocationReadModel,
Assignment,
DemandRequirement,
} from "@planarchy/shared";
function toDemandRequirement<TAllocation extends AllocationLike>(
allocation: TAllocation,
): DemandRequirement<TAllocation> {
return {
...allocation,
kind: "demand",
sourceAllocationId: allocation.entityId ?? allocation.id,
resourceId: null,
isPlaceholder: true,
requestedHeadcount: allocation.headcount,
unfilledHeadcount: allocation.headcount,
};
}
function toAssignment<TAllocation extends AllocationLike>(
allocation: TAllocation,
): Assignment<TAllocation> {
return {
...allocation,
kind: "assignment",
sourceAllocationId: allocation.entityId ?? allocation.id,
resourceId: allocation.resourceId as string,
isPlaceholder: false,
};
}
export function buildAllocationReadModel<TAllocation extends AllocationLike>(
allocations: TAllocation[],
): AllocationReadModel<TAllocation> {
const demands: DemandRequirement<TAllocation>[] = [];
const assignments: Assignment<TAllocation>[] = [];
for (const allocation of allocations) {
if (allocation.isPlaceholder || allocation.resourceId === null) {
demands.push(toDemandRequirement(allocation));
continue;
}
assignments.push(toAssignment(allocation));
}
return {
allocations,
demands,
assignments,
};
}
@@ -0,0 +1,175 @@
import type {
AllocationLike,
AllocationReadModel,
Assignment,
DemandRequirement,
} from "@planarchy/shared";
type SplitAllocationEntry = AllocationLike;
type SplitProjectSummary = NonNullable<SplitAllocationEntry["project"]>;
type SplitResourceSummary = NonNullable<SplitAllocationEntry["resource"]>;
type SplitRoleSummary = NonNullable<SplitAllocationEntry["roleEntity"]>;
type SplitDemandAllocationEntry = SplitAllocationEntry & {
resourceId: null;
isPlaceholder: true;
};
type SplitAssignmentAllocationEntry = SplitAllocationEntry & {
resourceId: string;
isPlaceholder: false;
};
export interface SplitDemandRequirementRecord {
id: string;
projectId: string;
startDate: Date | string;
endDate: Date | string;
hoursPerDay: number;
percentage: number;
role: string | null;
roleId: string | null;
headcount: number;
status: string;
metadata: unknown;
createdAt: Date | string;
updatedAt: Date | string;
project?: SplitProjectSummary;
roleEntity?: SplitRoleSummary | null;
}
export interface SplitAssignmentRecord {
id: string;
demandRequirementId?: string | null;
resourceId: string;
projectId: string;
startDate: Date | string;
endDate: Date | string;
hoursPerDay: number;
percentage: number;
role: string | null;
roleId: string | null;
dailyCostCents: number;
status: string;
metadata: unknown;
createdAt: Date | string;
updatedAt: Date | string;
resource?: SplitResourceSummary | null;
project?: SplitProjectSummary;
roleEntity?: SplitRoleSummary | null;
}
export interface BuildSplitAllocationReadModelInput {
demandRequirements: SplitDemandRequirementRecord[];
assignments: SplitAssignmentRecord[];
}
function compareEntries(
left: Pick<SplitAllocationEntry, "startDate" | "resourceId" | "id">,
right: Pick<SplitAllocationEntry, "startDate" | "resourceId" | "id">,
): number {
const startDelta =
new Date(left.startDate).getTime() - new Date(right.startDate).getTime();
if (startDelta !== 0) {
return startDelta;
}
const resourceDelta = (left.resourceId ?? "").localeCompare(right.resourceId ?? "");
if (resourceDelta !== 0) {
return resourceDelta;
}
return left.id.localeCompare(right.id);
}
function toDemandAllocationEntry(
demandRequirement: SplitDemandRequirementRecord,
): SplitDemandAllocationEntry {
return {
id: demandRequirement.id,
entityId: demandRequirement.id,
resourceId: null,
projectId: demandRequirement.projectId,
startDate: demandRequirement.startDate,
endDate: demandRequirement.endDate,
hoursPerDay: demandRequirement.hoursPerDay,
percentage: demandRequirement.percentage,
role: demandRequirement.role,
roleId: demandRequirement.roleId,
isPlaceholder: true,
headcount: demandRequirement.headcount,
dailyCostCents: 0,
status: demandRequirement.status,
metadata: demandRequirement.metadata,
createdAt: demandRequirement.createdAt,
updatedAt: demandRequirement.updatedAt,
...(demandRequirement.project ? { project: demandRequirement.project } : {}),
...(demandRequirement.roleEntity !== undefined
? { roleEntity: demandRequirement.roleEntity ?? null }
: {}),
};
}
function toAssignmentAllocationEntry(
assignment: SplitAssignmentRecord,
): SplitAssignmentAllocationEntry {
return {
id: assignment.id,
entityId: assignment.id,
resourceId: assignment.resourceId,
projectId: assignment.projectId,
startDate: assignment.startDate,
endDate: assignment.endDate,
hoursPerDay: assignment.hoursPerDay,
percentage: assignment.percentage,
role: assignment.role,
roleId: assignment.roleId,
isPlaceholder: false,
headcount: 1,
dailyCostCents: assignment.dailyCostCents,
status: assignment.status,
metadata: assignment.metadata,
createdAt: assignment.createdAt,
updatedAt: assignment.updatedAt,
...(assignment.resource !== undefined ? { resource: assignment.resource ?? null } : {}),
...(assignment.project ? { project: assignment.project } : {}),
...(assignment.roleEntity !== undefined ? { roleEntity: assignment.roleEntity ?? null } : {}),
};
}
function toDemandReadModelEntry(
demandRequirement: SplitDemandRequirementRecord,
): DemandRequirement<SplitAllocationEntry> {
const entry = toDemandAllocationEntry(demandRequirement);
return {
...entry,
kind: "demand",
sourceAllocationId: demandRequirement.id,
requestedHeadcount: demandRequirement.headcount,
unfilledHeadcount: demandRequirement.headcount,
};
}
function toAssignmentReadModelEntry(
assignment: SplitAssignmentRecord,
): Assignment<SplitAllocationEntry> {
const entry = toAssignmentAllocationEntry(assignment);
return {
...entry,
kind: "assignment",
sourceAllocationId: assignment.id,
};
}
export function buildSplitAllocationReadModel({
demandRequirements,
assignments,
}: BuildSplitAllocationReadModelInput): AllocationReadModel<SplitAllocationEntry> {
return {
allocations: [
...demandRequirements.map(toDemandAllocationEntry),
...assignments.map(toAssignmentAllocationEntry),
].sort(compareEntries),
demands: demandRequirements.map(toDemandReadModelEntry).sort(compareEntries),
assignments: assignments.map(toAssignmentReadModelEntry).sort(compareEntries),
};
}
@@ -0,0 +1,72 @@
import type { Prisma, PrismaClient } from "@planarchy/db";
import { buildSplitAllocationReadModel } from "./build-split-allocation-read-model.js";
type DbClient =
| Pick<PrismaClient, "demandRequirement" | "assignment">
| Pick<Prisma.TransactionClient, "demandRequirement" | "assignment">;
export interface CountEstimateHandoffPlanningEntriesInput {
projectId: string;
estimateVersionId: string;
}
export async function countEstimateHandoffPlanningEntries(
db: DbClient,
input: CountEstimateHandoffPlanningEntriesInput,
): Promise<number> {
const handoffWhere = {
projectId: input.projectId,
metadata: {
path: ["estimateHandoff", "estimateVersionId"],
equals: input.estimateVersionId,
},
} as const;
const [demandRequirements, assignments] = await Promise.all([
db.demandRequirement.findMany({
where: handoffWhere,
select: {
id: true,
projectId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
percentage: true,
role: true,
roleId: true,
headcount: true,
status: true,
metadata: true,
createdAt: true,
updatedAt: true,
},
}),
db.assignment.findMany({
where: handoffWhere,
select: {
id: true,
demandRequirementId: true,
resourceId: true,
projectId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
percentage: true,
role: true,
roleId: true,
dailyCostCents: true,
status: true,
metadata: true,
createdAt: true,
updatedAt: true,
},
}),
]);
return buildSplitAllocationReadModel({
demandRequirements,
assignments,
}).allocations.length;
}
@@ -0,0 +1,129 @@
import type { Prisma, PrismaClient } from "@planarchy/db";
import { buildSplitAllocationReadModel } from "./build-split-allocation-read-model.js";
type DbClient =
| Pick<PrismaClient, "demandRequirement" | "assignment">
| Pick<Prisma.TransactionClient, "demandRequirement" | "assignment">;
export interface CountPlanningEntriesInput {
projectIds?: string[];
roleIds?: string[];
}
export interface CountPlanningEntriesResult {
countsByProjectId: Map<string, number>;
countsByRoleId: Map<string, number>;
totalCount: number;
}
function normalizeIds(ids?: string[]): string[] | undefined {
if (!ids) {
return undefined;
}
const normalized = [...new Set(ids.filter(Boolean))];
return normalized.length > 0 ? normalized : undefined;
}
function buildScopedWhere(input: CountPlanningEntriesInput) {
const projectIds = normalizeIds(input.projectIds);
const roleIds = normalizeIds(input.roleIds);
if (input.projectIds && !projectIds) {
return null;
}
if (input.roleIds && !roleIds) {
return null;
}
return {
...(projectIds ? { projectId: { in: projectIds } } : {}),
...(roleIds ? { roleId: { in: roleIds } } : {}),
};
}
export async function countPlanningEntries(
db: DbClient,
input: CountPlanningEntriesInput = {},
): Promise<CountPlanningEntriesResult> {
const scopedWhere = buildScopedWhere(input);
if (scopedWhere === null) {
return {
countsByProjectId: new Map(),
countsByRoleId: new Map(),
totalCount: 0,
};
}
const [demandRequirements, assignments] = await Promise.all([
db.demandRequirement.findMany({
where: scopedWhere,
select: {
id: true,
projectId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
percentage: true,
role: true,
roleId: true,
headcount: true,
status: true,
metadata: true,
createdAt: true,
updatedAt: true,
},
}),
db.assignment.findMany({
where: scopedWhere,
select: {
id: true,
demandRequirementId: true,
resourceId: true,
projectId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
percentage: true,
role: true,
roleId: true,
dailyCostCents: true,
status: true,
metadata: true,
createdAt: true,
updatedAt: true,
},
}),
]);
const readModel = buildSplitAllocationReadModel({
demandRequirements,
assignments,
});
const countsByProjectId = new Map<string, number>();
const countsByRoleId = new Map<string, number>();
for (const allocation of readModel.allocations) {
countsByProjectId.set(
allocation.projectId,
(countsByProjectId.get(allocation.projectId) ?? 0) + 1,
);
if (allocation.roleId) {
countsByRoleId.set(
allocation.roleId,
(countsByRoleId.get(allocation.roleId) ?? 0) + 1,
);
}
}
return {
countsByProjectId,
countsByRoleId,
totalCount: readModel.allocations.length,
};
}
@@ -0,0 +1,174 @@
import { calculateAllocation, validateAvailability } from "@planarchy/engine";
import type { PrismaClient, Prisma } from "@planarchy/db";
import {
type Allocation,
type CreateAssignmentInput,
type WeekdayAvailability,
} from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { listAssignmentBookings } from "./list-assignment-bookings.js";
type DbClient = PrismaClient | Prisma.TransactionClient;
export const ASSIGNMENT_RELATIONS_INCLUDE = {
resource: { select: { id: true, displayName: true, eid: true, lcrCents: true } },
project: { select: { id: true, name: true, shortCode: 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;
export type AssignmentWithRelations = Prisma.AssignmentGetPayload<{
include: typeof ASSIGNMENT_RELATIONS_INCLUDE;
}>;
async function getVacationDates(db: DbClient, resourceId: string, startDate: Date, endDate: Date) {
const vacationDates: Date[] = [];
try {
const vacations = await db.vacation.findMany({
where: {
resourceId,
status: "APPROVED",
startDate: { lte: endDate },
endDate: { gte: startDate },
},
select: { startDate: true, endDate: true },
});
for (const vacation of vacations) {
const current = new Date(vacation.startDate);
current.setHours(0, 0, 0, 0);
const vacationEnd = new Date(vacation.endDate);
vacationEnd.setHours(0, 0, 0, 0);
while (current <= vacationEnd) {
vacationDates.push(new Date(current));
current.setDate(current.getDate() + 1);
}
}
} catch {
// Vacation persistence may not be available in all environments yet.
}
return vacationDates;
}
export async function createAssignment(
db: DbClient,
input: CreateAssignmentInput,
): Promise<AssignmentWithRelations> {
const project = await db.project.findUnique({ where: { id: input.projectId } });
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
const resource = await db.resource.findUnique({ where: { id: input.resourceId } });
if (!resource) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
if (input.demandRequirementId) {
const demandRequirement = await db.demandRequirement.findUnique({
where: { id: input.demandRequirementId },
select: { id: true, projectId: true },
});
if (!demandRequirement) {
throw new TRPCError({ code: "NOT_FOUND", message: "Demand requirement not found" });
}
if (demandRequirement.projectId !== input.projectId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Demand requirement belongs to a different project",
});
}
}
const existingBookings = await listAssignmentBookings(
db as unknown as Parameters<typeof listAssignmentBookings>[0],
{
resourceIds: [input.resourceId],
},
);
const availability = resource.availability as unknown as WeekdayAvailability;
const availabilityWindows = existingBookings.map((booking) => ({
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
status: booking.status,
})) as Pick<Allocation, "startDate" | "endDate" | "hoursPerDay" | "status">[];
const availabilityResult = validateAvailability(
input.startDate,
input.endDate,
input.hoursPerDay,
availability,
availabilityWindows,
);
if (!availabilityResult.valid && availabilityResult.totalConflictDays > 5) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Resource has availability conflicts on ${availabilityResult.totalConflictDays} days`,
});
}
const vacationDates = await getVacationDates(
db,
input.resourceId,
input.startDate,
input.endDate,
);
const calculation = calculateAllocation({
lcrCents: resource.lcrCents,
hoursPerDay: input.hoursPerDay,
startDate: input.startDate,
endDate: input.endDate,
availability,
vacationDates,
});
const assignment = await db.assignment.create({
data: {
demandRequirementId: input.demandRequirementId ?? null,
resourceId: input.resourceId,
projectId: input.projectId,
startDate: input.startDate,
endDate: input.endDate,
hoursPerDay: input.hoursPerDay,
percentage: input.percentage,
role: input.role ?? null,
roleId: input.roleId ?? null,
dailyCostCents: input.dailyCostCents ?? calculation.dailyCostCents,
status: input.status,
metadata: input.metadata as unknown as Prisma.InputJsonValue,
},
include: ASSIGNMENT_RELATIONS_INCLUDE,
});
await db.auditLog.create({
data: {
entityType: "Assignment",
entityId: assignment.id,
action: "CREATE",
changes: { after: assignment } as unknown as Prisma.InputJsonValue,
},
});
return assignment;
}
@@ -0,0 +1,51 @@
import type { PrismaClient, Prisma } from "@planarchy/db";
import { type CreateDemandRequirementInput } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
type DbClient = PrismaClient | Prisma.TransactionClient;
export const DEMAND_REQUIREMENT_RELATIONS_INCLUDE = {
project: { select: { id: true, name: true, shortCode: true } },
roleEntity: { select: { id: true, name: true, color: true } },
} as const;
export type DemandRequirementWithRelations = Prisma.DemandRequirementGetPayload<{
include: typeof DEMAND_REQUIREMENT_RELATIONS_INCLUDE;
}>;
export async function createDemandRequirement(
db: DbClient,
input: CreateDemandRequirementInput,
): Promise<DemandRequirementWithRelations> {
const project = await db.project.findUnique({ where: { id: input.projectId } });
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
const demandRequirement = await db.demandRequirement.create({
data: {
projectId: input.projectId,
startDate: input.startDate,
endDate: input.endDate,
hoursPerDay: input.hoursPerDay,
percentage: input.percentage,
role: input.role ?? null,
roleId: input.roleId ?? null,
headcount: input.headcount ?? 1,
status: input.status,
metadata: input.metadata as unknown as Prisma.InputJsonValue,
},
include: DEMAND_REQUIREMENT_RELATIONS_INCLUDE,
});
await db.auditLog.create({
data: {
entityType: "DemandRequirement",
entityId: demandRequirement.id,
action: "CREATE",
changes: { after: demandRequirement } as unknown as Prisma.InputJsonValue,
},
});
return demandRequirement;
}
@@ -0,0 +1,51 @@
import type { Prisma, PrismaClient } from "@planarchy/db";
import { TRPCError } from "@trpc/server";
import type { AllocationEntryResolution } from "./load-allocation-entry.js";
import { deleteAssignment } from "./delete-assignment.js";
import { deleteDemandRequirement } from "./delete-demand-requirement.js";
type DbClient =
| Pick<PrismaClient, "demandRequirement" | "assignment">
| Pick<Prisma.TransactionClient, "demandRequirement" | "assignment">;
export interface DeleteAllocationEntryResult {
deletedId: string;
projectId: string;
resourceId: string | null;
strategy: "explicit_demand" | "explicit_assignment";
}
export async function deleteAllocationEntry(
db: DbClient,
resolved: AllocationEntryResolution,
): Promise<DeleteAllocationEntryResult> {
if (resolved.kind === "demand") {
const result = await deleteDemandRequirement(
db as Parameters<typeof deleteDemandRequirement>[0],
resolved.demandRequirement.id,
);
return {
deletedId: result.deletedId,
projectId: result.projectId,
resourceId: null,
strategy: "explicit_demand",
};
}
if (resolved.kind === "assignment") {
const result = await deleteAssignment(
db as Parameters<typeof deleteAssignment>[0],
resolved.assignment.id,
);
return {
deletedId: result.deletedId,
projectId: result.projectId,
resourceId: result.resourceId,
strategy: "explicit_assignment",
};
}
throw new TRPCError({ code: "NOT_FOUND", message: "Allocation not found" });
}
@@ -0,0 +1,39 @@
import type { Prisma, PrismaClient } from "@planarchy/db";
type DbClient =
| Pick<PrismaClient, "assignment">
| Pick<Prisma.TransactionClient, "assignment">;
export interface DeleteAssignmentResult {
deletedId: string;
projectId: string;
resourceId: string;
}
export async function deleteAssignment(
db: DbClient,
id: string,
): Promise<DeleteAssignmentResult> {
const assignment = await db.assignment.findUnique({
where: { id },
select: {
id: true,
projectId: true,
resourceId: true,
},
});
if (!assignment) {
throw new Error("Assignment not found");
}
await db.assignment.delete({
where: { id: assignment.id },
});
return {
deletedId: assignment.id,
projectId: assignment.projectId,
resourceId: assignment.resourceId,
};
}
@@ -0,0 +1,40 @@
import type { Prisma, PrismaClient } from "@planarchy/db";
type DbClient =
| Pick<PrismaClient, "demandRequirement" | "assignment">
| Pick<Prisma.TransactionClient, "demandRequirement" | "assignment">;
export interface DeleteDemandRequirementResult {
deletedId: string;
projectId: string;
}
export async function deleteDemandRequirement(
db: DbClient,
id: string,
): Promise<DeleteDemandRequirementResult> {
const demandRequirement = await db.demandRequirement.findUnique({
where: { id },
select: {
id: true,
projectId: true,
},
});
if (!demandRequirement) {
throw new Error("Demand requirement not found");
}
await db.assignment.updateMany({
where: { demandRequirementId: demandRequirement.id },
data: { demandRequirementId: null },
});
await db.demandRequirement.delete({
where: { id: demandRequirement.id },
});
return {
deletedId: demandRequirement.id,
projectId: demandRequirement.projectId,
};
}
@@ -0,0 +1,64 @@
import type { PrismaClient } from "@planarchy/db";
import {
AllocationStatus,
type FillDemandRequirementInput,
} from "@planarchy/shared";
import {
createAssignment,
type AssignmentWithRelations,
} from "./create-assignment.js";
import { applyDemandRequirementFillProgress } from "./apply-demand-requirement-fill-progress.js";
export interface DemandRequirementFillTarget {
id: string;
projectId: string;
startDate: Date;
endDate: Date;
hoursPerDay: number;
role: string | null;
roleId: string | null;
headcount: number;
metadata: unknown;
}
export interface FillDemandRequirementWithLegacySyncResult {
assignment: AssignmentWithRelations;
updatedDemandRequirement: Awaited<
ReturnType<typeof applyDemandRequirementFillProgress>
>;
}
export async function fillDemandRequirementWithLegacySync(
db: PrismaClient,
demandRequirement: DemandRequirementFillTarget,
input: FillDemandRequirementInput,
): Promise<FillDemandRequirementWithLegacySyncResult> {
const hoursPerDay = input.hoursPerDay ?? demandRequirement.hoursPerDay;
const percentage = Math.max(1, Math.min(100, Math.round((hoursPerDay / 8) * 100)));
return db.$transaction(async (tx) => {
const createdAssignment = await createAssignment(tx, {
demandRequirementId: demandRequirement.id,
resourceId: input.resourceId,
projectId: demandRequirement.projectId,
startDate: demandRequirement.startDate,
endDate: demandRequirement.endDate,
hoursPerDay,
percentage,
role: demandRequirement.role ?? undefined,
roleId: demandRequirement.roleId ?? undefined,
status: input.status ?? AllocationStatus.PROPOSED,
metadata: (demandRequirement.metadata as Record<string, unknown> | null) ?? {},
});
const updatedDemandRequirement = await applyDemandRequirementFillProgress(tx, {
demandRequirementId: demandRequirement.id,
headcount: demandRequirement.headcount,
});
return {
assignment: createdAssignment,
updatedDemandRequirement,
};
});
}
@@ -0,0 +1,58 @@
import type { PrismaClient } from "@planarchy/db";
import { AllocationStatus, type FillDemandRequirementInput } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { type AssignmentWithRelations } from "./create-assignment.js";
import { fillDemandRequirementWithLegacySync } from "./fill-demand-requirement-with-legacy-sync.js";
export interface FillDemandRequirementResult {
assignment: AssignmentWithRelations;
updatedDemandRequirement: {
id: string;
projectId: string;
headcount: number;
status: AllocationStatus;
};
}
export interface FillDemandRequirementOptions {
}
export async function fillDemandRequirement(
db: PrismaClient,
input: FillDemandRequirementInput,
): Promise<FillDemandRequirementResult> {
const demandRequirement = await db.demandRequirement.findUnique({
where: { id: input.demandRequirementId },
select: {
id: true,
projectId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
role: true,
roleId: true,
headcount: true,
status: true,
metadata: true,
},
});
if (!demandRequirement) {
throw new TRPCError({ code: "NOT_FOUND", message: "Demand requirement not found" });
}
if (demandRequirement.status === AllocationStatus.CANCELLED) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Demand requirement is cancelled",
});
}
if (demandRequirement.status === AllocationStatus.COMPLETED) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Demand requirement is already completed",
});
}
return fillDemandRequirementWithLegacySync(db, demandRequirement, input);
}
@@ -0,0 +1,67 @@
import type { PrismaClient } from "@planarchy/db";
import type { FillOpenDemandByAllocationInput } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { fillDemandRequirement } from "./fill-demand-requirement.js";
import { loadAllocationEntry } from "./load-allocation-entry.js";
export interface FillOpenDemandResult {
strategy: "demand_requirement" | "placeholder";
createdAllocation: {
id: string;
projectId: string;
resourceId: string | null;
};
updatedAllocation: {
id: string;
projectId: string;
resourceId: string | null;
} | null;
}
function toDemandRequirementFillResult(
result: Awaited<ReturnType<typeof fillDemandRequirement>>,
): FillOpenDemandResult {
return {
strategy: "demand_requirement",
createdAllocation: {
id: result.assignment.id,
projectId: result.assignment.projectId,
resourceId: result.assignment.resourceId,
},
updatedAllocation: {
id: result.updatedDemandRequirement.id,
projectId: result.updatedDemandRequirement.projectId,
resourceId: null,
},
};
}
export async function fillOpenDemand(
db: PrismaClient,
input: FillOpenDemandByAllocationInput,
): Promise<FillOpenDemandResult> {
const allocation = await loadAllocationEntry(db, input.allocationId);
if (allocation.kind === "assignment") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Allocation is already filled",
});
}
if (allocation.kind === "demand") {
const result = await fillDemandRequirement(db, {
demandRequirementId: allocation.demandRequirement.id,
resourceId: input.resourceId,
...(input.hoursPerDay !== undefined ? { hoursPerDay: input.hoursPerDay } : {}),
...(input.status !== undefined ? { status: input.status } : {}),
});
return toDemandRequirementFillResult(result);
}
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Unsupported allocation entry resolution",
});
}
@@ -0,0 +1,94 @@
import type { Prisma, PrismaClient } from "@planarchy/db";
type AssignmentBookingsDbClient = Pick<PrismaClient, "assignment">;
export interface ListAssignmentBookingsInput {
startDate?: Date | undefined;
endDate?: Date | undefined;
resourceIds?: string[] | undefined;
projectIds?: string[] | undefined;
excludeAssignmentIds?: string[] | undefined;
}
export interface AssignmentBookingWithFallback {
id: string;
projectId: string;
resourceId: string | null;
startDate: Date;
endDate: Date;
hoursPerDay: number;
dailyCostCents: number;
status: string;
project: {
id: string;
name: string;
shortCode: string;
status: string;
orderType: string;
};
resource: {
id: string;
displayName: string;
chapter: string | null;
} | null;
}
export async function listAssignmentBookings(
db: AssignmentBookingsDbClient,
input: ListAssignmentBookingsInput,
): Promise<AssignmentBookingWithFallback[]> {
const hasDateBounds = input.startDate !== undefined && input.endDate !== undefined;
if (!hasDateBounds && (input.startDate !== undefined || input.endDate !== undefined)) {
throw new Error("startDate and endDate must be provided together");
}
const excludeAssignmentIds = input.excludeAssignmentIds?.filter(Boolean) ?? [];
const assignmentWhere = {
status: { not: "CANCELLED" as const },
...(hasDateBounds
? {
startDate: { lte: input.endDate! },
endDate: { gte: input.startDate! },
}
: {}),
...(input.resourceIds?.length ? { resourceId: { in: input.resourceIds } } : {}),
...(input.projectIds?.length ? { projectId: { in: input.projectIds } } : {}),
...(excludeAssignmentIds.length ? { id: { notIn: excludeAssignmentIds } } : {}),
} satisfies Prisma.AssignmentWhereInput;
const assignmentSelect = {
id: true,
projectId: true,
resourceId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
dailyCostCents: true,
status: true,
project: {
select: { id: true, name: true, shortCode: true, status: true, orderType: true },
},
resource: {
select: { id: true, displayName: true, chapter: true },
},
} satisfies Prisma.AssignmentSelect;
const assignments = await db.assignment.findMany({
where: assignmentWhere,
select: assignmentSelect,
});
return assignments.map((assignment) => ({
id: assignment.id,
projectId: assignment.projectId,
resourceId: assignment.resourceId,
startDate: assignment.startDate,
endDate: assignment.endDate,
hoursPerDay: assignment.hoursPerDay,
dailyCostCents: assignment.dailyCostCents,
status: assignment.status,
project: assignment.project,
resource: assignment.resource,
}));
}
@@ -0,0 +1,117 @@
import type { Prisma, PrismaClient } from "@planarchy/db";
import type { AllocationWithDetails } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { buildSplitAllocationReadModel } from "./build-split-allocation-read-model.js";
import {
ASSIGNMENT_RELATIONS_INCLUDE,
type AssignmentWithRelations,
} from "./create-assignment.js";
import {
DEMAND_REQUIREMENT_RELATIONS_INCLUDE,
type DemandRequirementWithRelations,
} from "./create-demand-requirement.js";
type DbClient =
| Pick<PrismaClient, "demandRequirement" | "assignment">
| Pick<Prisma.TransactionClient, "demandRequirement" | "assignment">;
export type AllocationEntryResolution =
| {
kind: "demand";
entry: AllocationWithDetails;
demandRequirement: DemandRequirementWithRelations;
projectId: string;
resourceId: null;
}
| {
kind: "assignment";
entry: AllocationWithDetails;
assignment: AssignmentWithRelations;
projectId: string;
resourceId: string;
};
function toDemandAllocationEntry(
demandRequirement: DemandRequirementWithRelations,
): AllocationWithDetails {
return buildSplitAllocationReadModel({
demandRequirements: [demandRequirement],
assignments: [],
}).allocations[0] as AllocationWithDetails;
}
function toAssignmentAllocationEntry(
assignment: AssignmentWithRelations,
): AllocationWithDetails {
return buildSplitAllocationReadModel({
demandRequirements: [],
assignments: [assignment],
}).allocations[0] as AllocationWithDetails;
}
async function loadDemandRequirementById(
db: DbClient,
id: string,
): Promise<DemandRequirementWithRelations | null> {
return db.demandRequirement.findUnique({
where: { id },
include: DEMAND_REQUIREMENT_RELATIONS_INCLUDE,
});
}
async function loadAssignmentById(
db: DbClient,
id: string,
): Promise<AssignmentWithRelations | null> {
return db.assignment.findUnique({
where: { id },
include: ASSIGNMENT_RELATIONS_INCLUDE,
});
}
/**
* Resolves an id to a demand requirement or assignment.
*/
export async function findAllocationEntry(
db: DbClient,
id: string,
): Promise<AllocationEntryResolution | null> {
const [demandRequirement, assignment] = await Promise.all([
loadDemandRequirementById(db, id),
loadAssignmentById(db, id),
]);
if (demandRequirement) {
return {
kind: "demand",
entry: toDemandAllocationEntry(demandRequirement),
demandRequirement,
projectId: demandRequirement.projectId,
resourceId: null,
};
}
if (assignment) {
return {
kind: "assignment",
entry: toAssignmentAllocationEntry(assignment),
assignment,
projectId: assignment.projectId,
resourceId: assignment.resourceId,
};
}
return null;
}
export async function loadAllocationEntry(
db: DbClient,
id: string,
): Promise<AllocationEntryResolution> {
const resolved = await findAllocationEntry(db, id);
if (!resolved) {
throw new TRPCError({ code: "NOT_FOUND", message: "Allocation not found" });
}
return resolved;
}
@@ -0,0 +1,82 @@
import type { PrismaClient, Prisma } from "@planarchy/db";
import type {
AllocationWithDetails,
UpdateAssignmentInput,
UpdateDemandRequirementInput,
} from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { findAllocationEntry } from "./load-allocation-entry.js";
import { updateAssignment } from "./update-assignment.js";
import { updateDemandRequirement } from "./update-demand-requirement.js";
type DbClient =
| Pick<PrismaClient, "demandRequirement" | "assignment">
| Pick<Prisma.TransactionClient, "demandRequirement" | "assignment">;
export interface UpdateAllocationEntryInput {
id: string;
demandRequirementUpdate: UpdateDemandRequirementInput;
assignmentUpdate: UpdateAssignmentInput;
}
export interface UpdateAllocationEntryResult {
allocation: AllocationWithDetails;
strategy: "explicit_demand" | "explicit_assignment";
}
export async function updateAllocationEntry(
db: DbClient,
input: UpdateAllocationEntryInput,
): Promise<UpdateAllocationEntryResult> {
const resolved = await findAllocationEntry(db, input.id);
if (!resolved) {
throw new TRPCError({ code: "NOT_FOUND", message: "Allocation not found" });
}
if (resolved.kind === "demand") {
const updatedDemandRequirement = await updateDemandRequirement(
db as Parameters<typeof updateDemandRequirement>[0],
resolved.demandRequirement.id,
input.demandRequirementUpdate,
);
return {
allocation: {
...resolved.entry,
...updatedDemandRequirement,
id: resolved.entry.id,
resourceId: null,
isPlaceholder: true,
headcount: updatedDemandRequirement.headcount,
dailyCostCents: 0,
project: updatedDemandRequirement.project,
roleEntity: updatedDemandRequirement.roleEntity ?? null,
updatedAt: updatedDemandRequirement.updatedAt,
} as AllocationWithDetails,
strategy: "explicit_demand",
};
}
const updatedAssignment = await updateAssignment(
db as Parameters<typeof updateAssignment>[0],
resolved.assignment.id,
input.assignmentUpdate,
);
return {
allocation: {
...resolved.entry,
...updatedAssignment,
id: resolved.entry.id,
resourceId: updatedAssignment.resourceId,
isPlaceholder: false,
headcount: 1,
project: updatedAssignment.project,
resource: updatedAssignment.resource ?? null,
roleEntity: updatedAssignment.roleEntity ?? null,
updatedAt: updatedAssignment.updatedAt,
} as AllocationWithDetails,
strategy: "explicit_assignment",
};
}
@@ -0,0 +1,85 @@
import type { PrismaClient, Prisma } from "@planarchy/db";
import { type UpdateAssignmentInput } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import {
ASSIGNMENT_RELATIONS_INCLUDE,
type AssignmentWithRelations,
} from "./create-assignment.js";
type DbClient = PrismaClient | Prisma.TransactionClient;
export async function updateAssignment(
db: DbClient,
id: string,
input: UpdateAssignmentInput,
): Promise<AssignmentWithRelations> {
const existing = await db.assignment.findUnique({
where: { id },
include: ASSIGNMENT_RELATIONS_INCLUDE,
});
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Assignment not found" });
}
const updatedAssignment = await db.assignment.update({
where: { id },
data: {
...(input.resourceId !== undefined ? { resourceId: input.resourceId } : {}),
...(input.projectId !== undefined ? { projectId: input.projectId } : {}),
...(input.demandRequirementId !== undefined
? { demandRequirementId: input.demandRequirementId }
: {}),
...(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.dailyCostCents !== undefined ? { dailyCostCents: input.dailyCostCents } : {}),
...(input.status !== undefined ? { status: input.status } : {}),
...(input.metadata !== undefined
? { metadata: input.metadata as unknown as Prisma.InputJsonValue }
: {}),
},
include: ASSIGNMENT_RELATIONS_INCLUDE,
});
await db.auditLog.create({
data: {
entityType: "Assignment",
entityId: id,
action: "UPDATE",
changes: {
before: {
resourceId: existing.resourceId,
projectId: existing.projectId,
demandRequirementId: existing.demandRequirementId,
startDate: existing.startDate,
endDate: existing.endDate,
hoursPerDay: existing.hoursPerDay,
percentage: existing.percentage,
role: existing.role,
roleId: existing.roleId,
dailyCostCents: existing.dailyCostCents,
status: existing.status,
},
after: {
resourceId: updatedAssignment.resourceId,
projectId: updatedAssignment.projectId,
demandRequirementId: updatedAssignment.demandRequirementId,
startDate: updatedAssignment.startDate,
endDate: updatedAssignment.endDate,
hoursPerDay: updatedAssignment.hoursPerDay,
percentage: updatedAssignment.percentage,
role: updatedAssignment.role,
roleId: updatedAssignment.roleId,
dailyCostCents: updatedAssignment.dailyCostCents,
status: updatedAssignment.status,
},
} as unknown as Prisma.InputJsonValue,
},
});
return updatedAssignment;
}
@@ -0,0 +1,77 @@
import type { PrismaClient, Prisma } from "@planarchy/db";
import { type UpdateDemandRequirementInput } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import {
DEMAND_REQUIREMENT_RELATIONS_INCLUDE,
type DemandRequirementWithRelations,
} from "./create-demand-requirement.js";
type DbClient = PrismaClient | Prisma.TransactionClient;
export async function updateDemandRequirement(
db: DbClient,
id: string,
input: UpdateDemandRequirementInput,
): Promise<DemandRequirementWithRelations> {
const existing = await db.demandRequirement.findUnique({
where: { id },
include: DEMAND_REQUIREMENT_RELATIONS_INCLUDE,
});
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Demand requirement not found" });
}
const updatedDemandRequirement = await db.demandRequirement.update({
where: { id },
data: {
...(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 as unknown as Prisma.InputJsonValue }
: {}),
},
include: DEMAND_REQUIREMENT_RELATIONS_INCLUDE,
});
await db.auditLog.create({
data: {
entityType: "DemandRequirement",
entityId: id,
action: "UPDATE",
changes: {
before: {
projectId: existing.projectId,
startDate: existing.startDate,
endDate: existing.endDate,
hoursPerDay: existing.hoursPerDay,
percentage: existing.percentage,
role: existing.role,
roleId: existing.roleId,
headcount: existing.headcount,
status: existing.status,
},
after: {
projectId: updatedDemandRequirement.projectId,
startDate: updatedDemandRequirement.startDate,
endDate: updatedDemandRequirement.endDate,
hoursPerDay: updatedDemandRequirement.hoursPerDay,
percentage: updatedDemandRequirement.percentage,
role: updatedDemandRequirement.role,
roleId: updatedDemandRequirement.roleId,
headcount: updatedDemandRequirement.headcount,
status: updatedDemandRequirement.status,
},
} as unknown as Prisma.InputJsonValue,
},
});
return updatedDemandRequirement;
}