Files
Nexus/packages/application/src/use-cases/estimate/version-actions.ts
T
Hartmut cd78f72f33 chore: full technical rename planarchy → capakraken
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>
2026-03-27 13:18:09 +01:00

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);
}