cd78f72f33
Complete rename of all technical identifiers across the codebase: Package names (11 packages): - @planarchy/* → @capakraken/* in all package.json, tsconfig, imports Import statements: 277 files, 548 occurrences replaced Database & Docker: - PostgreSQL user/db: planarchy → capakraken - Docker volumes: planarchy_pgdata → capakraken_pgdata - Connection strings updated in docker-compose, .env, CI CI/CD: - GitHub Actions workflow: all filter commands updated - Test database credentials updated Infrastructure: - Redis channel: planarchy:sse → capakraken:sse - Logger service name: planarchy-api → capakraken-api - Anonymization seed updated - Start/stop/restart scripts updated Test data: - Seed emails: @planarchy.dev → @capakraken.dev - E2E test credentials: all 11 spec files updated - Email defaults: @planarchy.app → @capakraken.app - localStorage keys: planarchy_* → capakraken_* Documentation: 30+ .md files updated Verification: - pnpm install: workspace resolution works - TypeScript: only pre-existing TS2589 (no new errors) - Engine: 310/310 tests pass - Staffing: 37/37 tests pass Co-Authored-By: claude-flow <ruv@ruv.net>
410 lines
13 KiB
TypeScript
410 lines
13 KiB
TypeScript
import type { Prisma } from "@capakraken/db";
|
|
import { serializeEstimateExport } from "@capakraken/engine";
|
|
import {
|
|
EstimateExportFormat,
|
|
EstimateStatus,
|
|
EstimateVersionStatus,
|
|
type ApproveEstimateVersionInput,
|
|
type CreateEstimateExportInput,
|
|
type CreateEstimateRevisionInput,
|
|
type SubmitEstimateVersionInput,
|
|
} from "@capakraken/shared";
|
|
import {
|
|
ESTIMATE_DETAIL_INCLUDE,
|
|
type EstimateDbClient,
|
|
type EstimateVersionDetails,
|
|
type EstimateWithDetails,
|
|
} from "./shared.js";
|
|
|
|
async function getEstimateOrThrow(
|
|
db: EstimateDbClient,
|
|
estimateId: string,
|
|
): Promise<EstimateWithDetails> {
|
|
const estimate = await db.estimate.findUnique({
|
|
where: { id: estimateId },
|
|
include: ESTIMATE_DETAIL_INCLUDE,
|
|
});
|
|
|
|
if (!estimate) {
|
|
throw new Error("Estimate not found");
|
|
}
|
|
|
|
return estimate;
|
|
}
|
|
|
|
function resolveVersion(
|
|
estimate: EstimateWithDetails,
|
|
versionId: string | undefined,
|
|
matcher: (version: EstimateVersionDetails) => boolean,
|
|
errorMessage: string,
|
|
) {
|
|
const version = versionId
|
|
? estimate.versions.find((candidate) => candidate.id === versionId)
|
|
: estimate.versions.find(matcher);
|
|
|
|
if (!version) {
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
return version;
|
|
}
|
|
|
|
function sanitizeFileNameSegment(value: string) {
|
|
const normalized = value
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
|
|
return normalized.length > 0 ? normalized : "estimate";
|
|
}
|
|
|
|
function buildExportFileName(
|
|
estimateName: string,
|
|
versionNumber: number,
|
|
format: EstimateExportFormat,
|
|
) {
|
|
return `${sanitizeFileNameSegment(estimateName)}-v${versionNumber}.${format.toLowerCase()}`;
|
|
}
|
|
|
|
export async function submitEstimateVersion(
|
|
db: EstimateDbClient,
|
|
input: SubmitEstimateVersionInput,
|
|
): Promise<EstimateWithDetails> {
|
|
const estimate = await getEstimateOrThrow(db, input.estimateId);
|
|
const targetVersion = resolveVersion(
|
|
estimate,
|
|
input.versionId,
|
|
(version) => version.status === EstimateVersionStatus.WORKING,
|
|
input.versionId
|
|
? "Estimate version not found"
|
|
: "Estimate has no working version",
|
|
);
|
|
|
|
if (targetVersion.status !== EstimateVersionStatus.WORKING) {
|
|
throw new Error("Only working versions can be submitted");
|
|
}
|
|
|
|
const supersededIds = estimate.versions
|
|
.filter(
|
|
(version) =>
|
|
version.id !== targetVersion.id &&
|
|
version.status === EstimateVersionStatus.SUBMITTED,
|
|
)
|
|
.map((version) => version.id);
|
|
|
|
await db.$transaction(async (tx) => {
|
|
if (supersededIds.length > 0) {
|
|
await tx.estimateVersion.updateMany({
|
|
where: { id: { in: supersededIds } },
|
|
data: { status: EstimateVersionStatus.SUPERSEDED },
|
|
});
|
|
}
|
|
|
|
await tx.estimateVersion.update({
|
|
where: { id: targetVersion.id },
|
|
data: {
|
|
status: EstimateVersionStatus.SUBMITTED,
|
|
lockedAt: targetVersion.lockedAt ?? new Date(),
|
|
},
|
|
});
|
|
|
|
await tx.estimate.update({
|
|
where: { id: estimate.id },
|
|
data: { status: EstimateStatus.IN_REVIEW },
|
|
});
|
|
});
|
|
|
|
return getEstimateOrThrow(db, estimate.id);
|
|
}
|
|
|
|
export async function approveEstimateVersion(
|
|
db: EstimateDbClient,
|
|
input: ApproveEstimateVersionInput,
|
|
): Promise<EstimateWithDetails> {
|
|
const estimate = await getEstimateOrThrow(db, input.estimateId);
|
|
const targetVersion = resolveVersion(
|
|
estimate,
|
|
input.versionId,
|
|
(version) => version.status === EstimateVersionStatus.SUBMITTED,
|
|
input.versionId
|
|
? "Estimate version not found"
|
|
: "Estimate has no submitted version",
|
|
);
|
|
|
|
if (targetVersion.status !== EstimateVersionStatus.SUBMITTED) {
|
|
throw new Error("Only submitted versions can be approved");
|
|
}
|
|
|
|
const supersededIds = estimate.versions
|
|
.filter(
|
|
(version) =>
|
|
version.id !== targetVersion.id &&
|
|
(version.status === EstimateVersionStatus.SUBMITTED ||
|
|
version.status === EstimateVersionStatus.APPROVED),
|
|
)
|
|
.map((version) => version.id);
|
|
|
|
await db.$transaction(async (tx) => {
|
|
if (supersededIds.length > 0) {
|
|
await tx.estimateVersion.updateMany({
|
|
where: { id: { in: supersededIds } },
|
|
data: { status: EstimateVersionStatus.SUPERSEDED },
|
|
});
|
|
}
|
|
|
|
await tx.estimateVersion.update({
|
|
where: { id: targetVersion.id },
|
|
data: {
|
|
status: EstimateVersionStatus.APPROVED,
|
|
lockedAt: targetVersion.lockedAt ?? new Date(),
|
|
},
|
|
});
|
|
|
|
await tx.estimate.update({
|
|
where: { id: estimate.id },
|
|
data: { status: EstimateStatus.APPROVED },
|
|
});
|
|
});
|
|
|
|
return getEstimateOrThrow(db, estimate.id);
|
|
}
|
|
|
|
export async function createEstimateRevision(
|
|
db: EstimateDbClient,
|
|
input: CreateEstimateRevisionInput,
|
|
): Promise<EstimateWithDetails> {
|
|
const estimate = await getEstimateOrThrow(db, input.estimateId);
|
|
const existingWorkingVersion = estimate.versions.find(
|
|
(version) => version.status === EstimateVersionStatus.WORKING,
|
|
);
|
|
|
|
if (existingWorkingVersion) {
|
|
throw new Error("Estimate already has a working version");
|
|
}
|
|
|
|
const sourceVersion = resolveVersion(
|
|
estimate,
|
|
input.sourceVersionId,
|
|
(version) =>
|
|
version.status !== EstimateVersionStatus.WORKING || version.lockedAt != null,
|
|
input.sourceVersionId
|
|
? "Estimate version not found"
|
|
: "Estimate has no locked version to revise",
|
|
);
|
|
|
|
if (
|
|
sourceVersion.status === EstimateVersionStatus.WORKING &&
|
|
sourceVersion.lockedAt == null
|
|
) {
|
|
throw new Error("Source version must be locked before creating a revision");
|
|
}
|
|
|
|
const nextVersionNumber = estimate.latestVersionNumber + 1;
|
|
|
|
await db.$transaction(async (tx) => {
|
|
const newVersion = await tx.estimateVersion.create({
|
|
data: {
|
|
estimateId: estimate.id,
|
|
versionNumber: nextVersionNumber,
|
|
label: input.label ?? `Revision ${nextVersionNumber}`,
|
|
status: EstimateVersionStatus.WORKING,
|
|
notes:
|
|
input.notes ?? `Revision created from v${sourceVersion.versionNumber}`,
|
|
projectSnapshot: sourceVersion.projectSnapshot as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
|
|
if (sourceVersion.assumptions.length > 0) {
|
|
await tx.estimateAssumption.createMany({
|
|
data: sourceVersion.assumptions.map((assumption) => ({
|
|
estimateVersionId: newVersion.id,
|
|
category: assumption.category,
|
|
key: assumption.key,
|
|
label: assumption.label,
|
|
valueType: assumption.valueType,
|
|
value: assumption.value as Prisma.InputJsonValue,
|
|
sortOrder: assumption.sortOrder,
|
|
...(assumption.notes != null ? { notes: assumption.notes } : {}),
|
|
})),
|
|
});
|
|
}
|
|
|
|
const scopeItemIdMap = new Map<string, string>();
|
|
|
|
for (const scopeItem of sourceVersion.scopeItems) {
|
|
const createdScopeItem = await tx.scopeItem.create({
|
|
data: {
|
|
estimateVersionId: newVersion.id,
|
|
sequenceNo: scopeItem.sequenceNo,
|
|
scopeType: scopeItem.scopeType,
|
|
...(scopeItem.packageCode != null
|
|
? { packageCode: scopeItem.packageCode }
|
|
: {}),
|
|
name: scopeItem.name,
|
|
...(scopeItem.description != null
|
|
? { description: scopeItem.description }
|
|
: {}),
|
|
...(scopeItem.scene != null ? { scene: scopeItem.scene } : {}),
|
|
...(scopeItem.page != null ? { page: scopeItem.page } : {}),
|
|
...(scopeItem.location != null ? { location: scopeItem.location } : {}),
|
|
...(scopeItem.assumptionCategory != null
|
|
? { assumptionCategory: scopeItem.assumptionCategory }
|
|
: {}),
|
|
technicalSpec: scopeItem.technicalSpec as Prisma.InputJsonValue,
|
|
...(scopeItem.frameCount != null
|
|
? { frameCount: scopeItem.frameCount }
|
|
: {}),
|
|
...(scopeItem.itemCount != null ? { itemCount: scopeItem.itemCount } : {}),
|
|
...(scopeItem.unitMode != null ? { unitMode: scopeItem.unitMode } : {}),
|
|
...(scopeItem.internalComments != null
|
|
? { internalComments: scopeItem.internalComments }
|
|
: {}),
|
|
...(scopeItem.externalComments != null
|
|
? { externalComments: scopeItem.externalComments }
|
|
: {}),
|
|
sortOrder: scopeItem.sortOrder,
|
|
metadata: scopeItem.metadata as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
|
|
scopeItemIdMap.set(scopeItem.id, createdScopeItem.id);
|
|
}
|
|
|
|
if (sourceVersion.demandLines.length > 0) {
|
|
await tx.estimateDemandLine.createMany({
|
|
data: sourceVersion.demandLines.map((line) => ({
|
|
estimateVersionId: newVersion.id,
|
|
scopeItemId:
|
|
line.scopeItemId != null
|
|
? (scopeItemIdMap.get(line.scopeItemId) ?? null)
|
|
: null,
|
|
roleId: line.roleId ?? null,
|
|
resourceId: line.resourceId ?? null,
|
|
lineType: line.lineType,
|
|
name: line.name,
|
|
chapter: line.chapter ?? null,
|
|
hours: line.hours,
|
|
days: line.days ?? null,
|
|
fte: line.fte ?? null,
|
|
rateSource: line.rateSource ?? null,
|
|
costRateCents: line.costRateCents,
|
|
billRateCents: line.billRateCents,
|
|
currency: line.currency,
|
|
costTotalCents: line.costTotalCents,
|
|
priceTotalCents: line.priceTotalCents,
|
|
monthlySpread: line.monthlySpread as Prisma.InputJsonValue,
|
|
staffingAttributes: line.staffingAttributes as Prisma.InputJsonValue,
|
|
metadata: line.metadata as Prisma.InputJsonValue,
|
|
})),
|
|
});
|
|
}
|
|
|
|
if (sourceVersion.resourceSnapshots.length > 0) {
|
|
await tx.resourceCostSnapshot.createMany({
|
|
data: sourceVersion.resourceSnapshots.map((snapshot) => ({
|
|
estimateVersionId: newVersion.id,
|
|
...(snapshot.resourceId != null ? { resourceId: snapshot.resourceId } : {}),
|
|
...(snapshot.sourceEid != null ? { sourceEid: snapshot.sourceEid } : {}),
|
|
displayName: snapshot.displayName,
|
|
...(snapshot.chapter != null ? { chapter: snapshot.chapter } : {}),
|
|
...(snapshot.roleId != null ? { roleId: snapshot.roleId } : {}),
|
|
currency: snapshot.currency,
|
|
lcrCents: snapshot.lcrCents,
|
|
ucrCents: snapshot.ucrCents,
|
|
...(snapshot.fte != null ? { fte: snapshot.fte } : {}),
|
|
...(snapshot.location != null ? { location: snapshot.location } : {}),
|
|
...(snapshot.country != null ? { country: snapshot.country } : {}),
|
|
...(snapshot.level != null ? { level: snapshot.level } : {}),
|
|
...(snapshot.workType != null ? { workType: snapshot.workType } : {}),
|
|
attributes: snapshot.attributes as Prisma.InputJsonValue,
|
|
})),
|
|
});
|
|
}
|
|
|
|
if (sourceVersion.metrics.length > 0) {
|
|
await tx.estimateMetric.createMany({
|
|
data: sourceVersion.metrics.map((metric) => ({
|
|
estimateVersionId: newVersion.id,
|
|
key: metric.key,
|
|
label: metric.label,
|
|
...(metric.metricGroup != null
|
|
? { metricGroup: metric.metricGroup }
|
|
: {}),
|
|
valueDecimal: metric.valueDecimal,
|
|
...(metric.valueCents != null ? { valueCents: metric.valueCents } : {}),
|
|
...(metric.currency != null ? { currency: metric.currency } : {}),
|
|
metadata: metric.metadata as Prisma.InputJsonValue,
|
|
})),
|
|
});
|
|
}
|
|
|
|
await tx.estimate.update({
|
|
where: { id: estimate.id },
|
|
data: {
|
|
latestVersionNumber: nextVersionNumber,
|
|
status: EstimateStatus.DRAFT,
|
|
},
|
|
});
|
|
});
|
|
|
|
return getEstimateOrThrow(db, estimate.id);
|
|
}
|
|
|
|
export async function createEstimateExport(
|
|
db: EstimateDbClient,
|
|
input: CreateEstimateExportInput,
|
|
): Promise<EstimateWithDetails> {
|
|
const estimate = await getEstimateOrThrow(db, input.estimateId);
|
|
const targetVersion = resolveVersion(
|
|
estimate,
|
|
input.versionId,
|
|
() => true,
|
|
input.versionId
|
|
? "Estimate version not found"
|
|
: "Estimate has no version to export",
|
|
);
|
|
const projectSnapshot =
|
|
typeof targetVersion.projectSnapshot === "object" &&
|
|
targetVersion.projectSnapshot !== null &&
|
|
!Array.isArray(targetVersion.projectSnapshot)
|
|
? (targetVersion.projectSnapshot as Record<string, unknown>)
|
|
: null;
|
|
|
|
const payload = serializeEstimateExport(
|
|
{
|
|
estimate,
|
|
version: targetVersion,
|
|
project: estimate.project
|
|
? {
|
|
...estimate.project,
|
|
startDate:
|
|
typeof projectSnapshot?.startDate === "string"
|
|
? projectSnapshot.startDate
|
|
: null,
|
|
endDate:
|
|
typeof projectSnapshot?.endDate === "string"
|
|
? projectSnapshot.endDate
|
|
: null,
|
|
}
|
|
: null,
|
|
},
|
|
input.format,
|
|
);
|
|
|
|
await db.estimateExport.create({
|
|
data: {
|
|
estimateVersionId: targetVersion.id,
|
|
format: input.format,
|
|
fileName: buildExportFileName(
|
|
estimate.name,
|
|
targetVersion.versionNumber,
|
|
input.format,
|
|
),
|
|
payload: payload as unknown as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
|
|
return getEstimateOrThrow(db, estimate.id);
|
|
}
|