Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .releases/unreleased/code-quality-improvements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
type: patch
area: api
summary: Improve error handling with structured logging and better type safety in tests
---
14 changes: 13 additions & 1 deletion apps/api/src/common/filters/http-error.filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import {
Catch,
ExceptionFilter,
HttpException,
HttpStatus
HttpStatus,
Logger
} from "@nestjs/common";
import {
DomainInvariantError,
Expand All @@ -14,9 +15,12 @@ import {

@Catch()
export class HttpErrorFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpErrorFilter.name);

catch(exception: unknown, host: ArgumentsHost): void {
const context = host.switchToHttp();
const response = context.getResponse();
const request = context.getRequest();

if (exception instanceof HttpException) {
const status = exception.getStatus();
Expand Down Expand Up @@ -61,6 +65,14 @@ export class HttpErrorFilter implements ExceptionFilter {
return;
}

// Log unhandled exceptions with context
this.logger.error("Unhandled exception", {
url: request?.url,
method: request?.method,
message: exception instanceof Error ? exception.message : String(exception),
stack: exception instanceof Error ? exception.stack : undefined
});
Comment on lines +69 to +74
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The NestJS Logger.error method signature expects a stack trace string as the second argument, not a context object. Consider restructuring this to pass the stack trace directly as the second parameter, or format it as a string. For example: this.logger.error('Unhandled exception at ${request?.method} ${request?.url}: ${message}', stack) where message and stack are extracted from the exception.

Suggested change
this.logger.error("Unhandled exception", {
url: request?.url,
method: request?.method,
message: exception instanceof Error ? exception.message : String(exception),
stack: exception instanceof Error ? exception.stack : undefined
});
const errorMessage = `Unhandled exception at ${request?.method ?? "UNKNOWN"} ${request?.url ?? "UNKNOWN"}: ${
exception instanceof Error ? exception.message : String(exception)
}`;
const stack = exception instanceof Error ? exception.stack : undefined;
this.logger.error(errorMessage, stack);

Copilot uses AI. Check for mistakes.

response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: "Internal server error",
Expand Down
5 changes: 3 additions & 2 deletions apps/api/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import "reflect-metadata";
import { ValidationPipe } from "@nestjs/common";
import { Logger, ValidationPipe } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import { ensureEnvironmentLoaded } from "@corpsim/db";
import { AppModule } from "./app.module";
Expand Down Expand Up @@ -101,7 +101,8 @@ async function bootstrap(): Promise<void> {
}

bootstrap().catch((error: unknown) => {
console.error("API bootstrap failed", error);
const logger = new Logger("Bootstrap");
logger.error("API bootstrap failed", error instanceof Error ? error.stack : error);
process.exitCode = 1;
});

21 changes: 15 additions & 6 deletions apps/api/src/prisma/prisma.service.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
import { PrismaClient } from "@prisma/client";
import { ensureEnvironmentLoaded } from "@corpsim/db";

ensureEnvironmentLoaded();

interface PrismaErrorWithCode extends Error {
errorCode?: string;
}

function isPrismaErrorWithCode(error: unknown): error is PrismaErrorWithCode {
return error instanceof Error && "errorCode" in error;
}
Comment on lines +7 to +13
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Prisma error type guard only checks for 'errorCode', but other parts of the codebase show that Prisma errors can have either 'errorCode' or 'code' properties. The schema-readiness service checks both properties (schema-readiness.service.ts:76), and maintenance service uses 'code' (maintenance.service.ts:279). Consider updating the interface and type guard to handle both properties to ensure all Prisma connection errors are properly detected for retries.

Copilot uses AI. Check for mistakes.

function shouldRetryConnect(error: unknown): boolean {
if (!(error instanceof Error)) {
if (!isPrismaErrorWithCode(error)) {
return false;
}

const prismaCode = (error as { errorCode?: string }).errorCode;
if (prismaCode === "P1001") {
if (error.errorCode === "P1001") {
return true;
}

Expand All @@ -25,6 +32,8 @@ function sleep(ms: number): Promise<void> {

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrismaService.name);

async onModuleInit(): Promise<void> {
const maxAttempts = 30;
const baseDelayMs = 1_000;
Expand All @@ -39,8 +48,8 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
}

const delayMs = Math.min(baseDelayMs * attempt, 5_000);
console.warn(
`[prisma] database not reachable on attempt ${attempt}/${maxAttempts}; retrying in ${delayMs}ms`
this.logger.warn(
`Database not reachable on attempt ${attempt}/${maxAttempts}; retrying in ${delayMs}ms`
);
await sleep(delayMs);
}
Expand Down
11 changes: 10 additions & 1 deletion apps/web/src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,16 @@ export async function fetchJson<T>(
response = await fetch(path, requestInit);
}

const payload = (await response.json().catch(() => null)) as unknown;
let payload: unknown = null;
try {
payload = await response.json();
} catch (parseError) {
// If JSON parsing fails and response is not OK, we'll still throw with status
// If JSON parsing fails but response is OK, payload remains null
if (process.env.NODE_ENV === "development") {
console.warn("Failed to parse API response JSON:", parseError);
}
}

if (!response.ok) {
let message = `Request failed: ${response.status}`;
Expand Down
11 changes: 2 additions & 9 deletions packages/sim/tests/research.test.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,11 @@
import { Prisma, PrismaClient, ResearchJobStatus } from "@prisma/client";
import { Prisma, ResearchJobStatus } from "@prisma/client";
import { describe, expect, it, vi } from "vitest";
import {
DomainInvariantError,
completeDueResearchJobs,
startResearch
} from "../src";

function createPrismaTransactionMock(tx: Prisma.TransactionClient): PrismaClient {
return {
$transaction: async <T>(
callback: (transactionClient: Prisma.TransactionClient) => Promise<T>
): Promise<T> => callback(tx)
} as unknown as PrismaClient;
}
import { createPrismaTransactionMock } from "./test-utils";

describe("research service", () => {
it("enforces prerequisite gating before starting research", async () => {
Expand Down
19 changes: 19 additions & 0 deletions packages/sim/tests/test-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Prisma, PrismaClient } from "@prisma/client";

/**
* Creates a minimal PrismaClient mock that supports $transaction.
*
* Note: This uses `as unknown as PrismaClient` because the actual test
* implementations only use the $transaction method, but the service functions
* expect the full PrismaClient type. This is a deliberate trade-off to keep
* test mocks simple while maintaining type compatibility.
*/
export function createPrismaTransactionMock(
tx: Prisma.TransactionClient
): PrismaClient {
return {
$transaction: async <T>(
callback: (transactionClient: Prisma.TransactionClient) => Promise<T>
): Promise<T> => callback(tx)
} as unknown as PrismaClient;
}
11 changes: 2 additions & 9 deletions packages/sim/tests/workforce.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,12 @@
import { Prisma, PrismaClient } from "@prisma/client";
import { Prisma } from "@prisma/client";
import { describe, expect, it, vi } from "vitest";
import {
assertValidWorkforceAllocation,
DomainInvariantError,
requestCompanyWorkforceCapacityChange,
runWorkforceForTick
} from "../src";

function createPrismaTransactionMock(tx: Prisma.TransactionClient): PrismaClient {
return {
$transaction: async <T>(
callback: (transactionClient: Prisma.TransactionClient) => Promise<T>
): Promise<T> => callback(tx)
} as unknown as PrismaClient;
}
import { createPrismaTransactionMock } from "./test-utils";

describe("workforce service", () => {
it("validates allocation percentages sum to 100", () => {
Expand Down