chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -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 };
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user