diff --git a/.gitignore b/.gitignore index c19e572..6420fc4 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,16 @@ dist .next .turbo +# Compiled source (prevent accidental commits) +apps/*/src/**/*.js +apps/*/src/**/*.d.ts +apps/*/src/**/*.js.map +apps/*/src/**/*.d.ts.map +packages/*/src/**/*.js +packages/*/src/**/*.d.ts +packages/*/src/**/*.js.map +packages/*/src/**/*.d.ts.map + # IDE .idea .vscode diff --git a/.prettierrc.js b/.prettierrc.js deleted file mode 100644 index 96bdbc2..0000000 --- a/.prettierrc.js +++ /dev/null @@ -1,3 +0,0 @@ -import config from "@mosaic/config/prettier"; - -export default config; diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..b9ac3df --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,10 @@ +{ + "semi": true, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/apps/api/eslint.config.js b/apps/api/eslint.config.js new file mode 100644 index 0000000..3fb1722 --- /dev/null +++ b/apps/api/eslint.config.js @@ -0,0 +1,16 @@ +import nestjsConfig from "@mosaic/config/eslint/nestjs"; + +export default [ + ...nestjsConfig, + { + languageOptions: { + parserOptions: { + project: ["./tsconfig.json"], + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + ignores: ["dist/**", "node_modules/**", "**/*.test.ts", "**/*.spec.ts"], + }, +]; diff --git a/apps/api/src/app.controller.d.ts b/apps/api/src/app.controller.d.ts deleted file mode 100644 index 01585e2..0000000 --- a/apps/api/src/app.controller.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { AppService } from "./app.service"; -import type { ApiResponse } from "@mosaic/shared/types"; -interface HealthStatus { - status: string; - timestamp: string; -} -export declare class AppController { - private readonly appService; - constructor(appService: AppService); - getHello(): string; - getHealth(): ApiResponse; -} -export {}; -//# sourceMappingURL=app.controller.d.ts.map \ No newline at end of file diff --git a/apps/api/src/app.controller.d.ts.map b/apps/api/src/app.controller.d.ts.map deleted file mode 100644 index 4255444..0000000 --- a/apps/api/src/app.controller.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"app.controller.d.ts","sourceRoot":"","sources":["app.controller.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAC3C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAExD,UAAU,YAAY;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,qBACa,aAAa;IACZ,OAAO,CAAC,QAAQ,CAAC,UAAU;gBAAV,UAAU,EAAE,UAAU;IAGnD,QAAQ,IAAI,MAAM;IAKlB,SAAS,IAAI,WAAW,CAAC,YAAY,CAAC;CASvC"} \ No newline at end of file diff --git a/apps/api/src/app.controller.js b/apps/api/src/app.controller.js deleted file mode 100644 index 2793f59..0000000 --- a/apps/api/src/app.controller.js +++ /dev/null @@ -1,50 +0,0 @@ -"use strict"; -var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { - var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; - if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); - else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; - return c > 3 && r && Object.defineProperty(target, key, r), r; -}; -var __metadata = (this && this.__metadata) || function (k, v) { - if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.AppController = void 0; -const common_1 = require("@nestjs/common"); -const app_service_1 = require("./app.service"); -let AppController = class AppController { - appService; - constructor(appService) { - this.appService = appService; - } - getHello() { - return this.appService.getHello(); - } - getHealth() { - return { - success: true, - data: { - status: "healthy", - timestamp: new Date().toISOString(), - }, - }; - } -}; -exports.AppController = AppController; -__decorate([ - (0, common_1.Get)(), - __metadata("design:type", Function), - __metadata("design:paramtypes", []), - __metadata("design:returntype", String) -], AppController.prototype, "getHello", null); -__decorate([ - (0, common_1.Get)("health"), - __metadata("design:type", Function), - __metadata("design:paramtypes", []), - __metadata("design:returntype", Object) -], AppController.prototype, "getHealth", null); -exports.AppController = AppController = __decorate([ - (0, common_1.Controller)(), - __metadata("design:paramtypes", [app_service_1.AppService]) -], AppController); -//# sourceMappingURL=app.controller.js.map \ No newline at end of file diff --git a/apps/api/src/app.controller.js.map b/apps/api/src/app.controller.js.map deleted file mode 100644 index 6e48297..0000000 --- a/apps/api/src/app.controller.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"app.controller.js","sourceRoot":"","sources":["app.controller.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAAiD;AACjD,+CAA2C;AASpC,IAAM,aAAa,GAAnB,MAAM,aAAa;IACK;IAA7B,YAA6B,UAAsB;QAAtB,eAAU,GAAV,UAAU,CAAY;IAAG,CAAC;IAGvD,QAAQ;QACN,OAAO,IAAI,CAAC,UAAU,CAAC,QAAQ,EAAE,CAAC;IACpC,CAAC;IAGD,SAAS;QACP,OAAO;YACL,OAAO,EAAE,IAAI;YACb,IAAI,EAAE;gBACJ,MAAM,EAAE,SAAS;gBACjB,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;aACpC;SACF,CAAC;IACJ,CAAC;CACF,CAAA;AAlBY,sCAAa;AAIxB;IADC,IAAA,YAAG,GAAE;;;;6CAGL;AAGD;IADC,IAAA,YAAG,EAAC,QAAQ,CAAC;;;;8CASb;wBAjBU,aAAa;IADzB,IAAA,mBAAU,GAAE;qCAE8B,wBAAU;GADxC,aAAa,CAkBzB"} \ No newline at end of file diff --git a/apps/api/src/app.controller.ts b/apps/api/src/app.controller.ts index e16955c..50c538e 100644 --- a/apps/api/src/app.controller.ts +++ b/apps/api/src/app.controller.ts @@ -1,11 +1,7 @@ import { Controller, Get } from "@nestjs/common"; import { AppService } from "./app.service"; -import type { ApiResponse } from "@mosaic/shared"; - -interface HealthStatus { - status: string; - timestamp: string; -} +import type { ApiResponse, HealthStatus } from "@mosaic/shared"; +import { successResponse } from "@mosaic/shared"; @Controller() export class AppController { @@ -18,12 +14,9 @@ export class AppController { @Get("health") getHealth(): ApiResponse { - return { - success: true, - data: { - status: "healthy", - timestamp: new Date().toISOString(), - }, - }; + return successResponse({ + status: "healthy", + timestamp: new Date().toISOString(), + }); } } diff --git a/apps/api/src/app.module.d.ts b/apps/api/src/app.module.d.ts deleted file mode 100644 index 9e8a304..0000000 --- a/apps/api/src/app.module.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -export declare class AppModule { -} -//# sourceMappingURL=app.module.d.ts.map \ No newline at end of file diff --git a/apps/api/src/app.module.d.ts.map b/apps/api/src/app.module.d.ts.map deleted file mode 100644 index 3bf5d7b..0000000 --- a/apps/api/src/app.module.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"app.module.d.ts","sourceRoot":"","sources":["app.module.ts"],"names":[],"mappings":"AAIA,qBAKa,SAAS;CAAG"} \ No newline at end of file diff --git a/apps/api/src/app.module.js b/apps/api/src/app.module.js deleted file mode 100644 index 25985e8..0000000 --- a/apps/api/src/app.module.js +++ /dev/null @@ -1,23 +0,0 @@ -"use strict"; -var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { - var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; - if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); - else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; - return c > 3 && r && Object.defineProperty(target, key, r), r; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.AppModule = void 0; -const common_1 = require("@nestjs/common"); -const app_controller_1 = require("./app.controller"); -const app_service_1 = require("./app.service"); -let AppModule = class AppModule { -}; -exports.AppModule = AppModule; -exports.AppModule = AppModule = __decorate([ - (0, common_1.Module)({ - imports: [], - controllers: [app_controller_1.AppController], - providers: [app_service_1.AppService], - }) -], AppModule); -//# sourceMappingURL=app.module.js.map \ No newline at end of file diff --git a/apps/api/src/app.module.js.map b/apps/api/src/app.module.js.map deleted file mode 100644 index d48cf21..0000000 --- a/apps/api/src/app.module.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"app.module.js","sourceRoot":"","sources":["app.module.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAAwC;AACxC,qDAAiD;AACjD,+CAA2C;AAOpC,IAAM,SAAS,GAAf,MAAM,SAAS;CAAG,CAAA;AAAZ,8BAAS;oBAAT,SAAS;IALrB,IAAA,eAAM,EAAC;QACN,OAAO,EAAE,EAAE;QACX,WAAW,EAAE,CAAC,8BAAa,CAAC;QAC5B,SAAS,EAAE,CAAC,wBAAU,CAAC;KACxB,CAAC;GACW,SAAS,CAAG"} \ No newline at end of file diff --git a/apps/api/src/app.service.d.ts b/apps/api/src/app.service.d.ts deleted file mode 100644 index e367353..0000000 --- a/apps/api/src/app.service.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -export declare class AppService { - getHello(): string; -} -//# sourceMappingURL=app.service.d.ts.map \ No newline at end of file diff --git a/apps/api/src/app.service.d.ts.map b/apps/api/src/app.service.d.ts.map deleted file mode 100644 index 52f4274..0000000 --- a/apps/api/src/app.service.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"app.service.d.ts","sourceRoot":"","sources":["app.service.ts"],"names":[],"mappings":"AAEA,qBACa,UAAU;IACrB,QAAQ,IAAI,MAAM;CAGnB"} \ No newline at end of file diff --git a/apps/api/src/app.service.js b/apps/api/src/app.service.js deleted file mode 100644 index e781f3b..0000000 --- a/apps/api/src/app.service.js +++ /dev/null @@ -1,20 +0,0 @@ -"use strict"; -var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { - var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; - if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); - else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; - return c > 3 && r && Object.defineProperty(target, key, r), r; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.AppService = void 0; -const common_1 = require("@nestjs/common"); -let AppService = class AppService { - getHello() { - return "Mosaic Stack API"; - } -}; -exports.AppService = AppService; -exports.AppService = AppService = __decorate([ - (0, common_1.Injectable)() -], AppService); -//# sourceMappingURL=app.service.js.map \ No newline at end of file diff --git a/apps/api/src/app.service.js.map b/apps/api/src/app.service.js.map deleted file mode 100644 index 8664b0f..0000000 --- a/apps/api/src/app.service.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"app.service.js","sourceRoot":"","sources":["app.service.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAA4C;AAGrC,IAAM,UAAU,GAAhB,MAAM,UAAU;IACrB,QAAQ;QACN,OAAO,kBAAkB,CAAC;IAC5B,CAAC;CACF,CAAA;AAJY,gCAAU;qBAAV,UAAU;IADtB,IAAA,mBAAU,GAAE;GACA,UAAU,CAItB"} \ No newline at end of file diff --git a/apps/api/src/filters/global-exception.filter.ts b/apps/api/src/filters/global-exception.filter.ts new file mode 100644 index 0000000..e1ae17d --- /dev/null +++ b/apps/api/src/filters/global-exception.filter.ts @@ -0,0 +1,69 @@ +import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from "@nestjs/common"; +import type { Request, Response } from "express"; +import { randomUUID } from "crypto"; + +interface ErrorResponse { + success: false; + message: string; + errorId: string; + timestamp: string; + path: string; + statusCode: number; +} + +@Catch() +export class GlobalExceptionFilter implements ExceptionFilter { + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + const errorId = randomUUID(); + const timestamp = new Date().toISOString(); + + let status = HttpStatus.INTERNAL_SERVER_ERROR; + let message = "An unexpected error occurred"; + + if (exception instanceof HttpException) { + status = exception.getStatus(); + const exceptionResponse = exception.getResponse(); + message = + typeof exceptionResponse === "string" + ? exceptionResponse + : ((exceptionResponse as { message?: string }).message ?? message); + } else if (exception instanceof Error) { + message = exception.message; + } + + const isProduction = process.env.NODE_ENV === "production"; + + // Structured error logging + const logPayload = { + level: "error", + errorId, + timestamp, + method: request.method, + url: request.url, + statusCode: status, + message: exception instanceof Error ? exception.message : String(exception), + stack: !isProduction && exception instanceof Error ? exception.stack : undefined, + }; + + console.error(isProduction ? JSON.stringify(logPayload) : logPayload); + + // Sanitized client response + const errorResponse: ErrorResponse = { + success: false, + message: + isProduction && status === HttpStatus.INTERNAL_SERVER_ERROR + ? "An unexpected error occurred" + : message, + errorId, + timestamp, + path: request.url, + statusCode: status, + }; + + response.status(status).json(errorResponse); + } +} diff --git a/apps/api/src/main.d.ts b/apps/api/src/main.d.ts deleted file mode 100644 index 371115b..0000000 --- a/apps/api/src/main.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export {}; -//# sourceMappingURL=main.d.ts.map \ No newline at end of file diff --git a/apps/api/src/main.d.ts.map b/apps/api/src/main.d.ts.map deleted file mode 100644 index 04152c4..0000000 --- a/apps/api/src/main.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"main.d.ts","sourceRoot":"","sources":["main.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/apps/api/src/main.js b/apps/api/src/main.js deleted file mode 100644 index 8ed61f9..0000000 --- a/apps/api/src/main.js +++ /dev/null @@ -1,16 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -const core_1 = require("@nestjs/core"); -const app_module_1 = require("./app.module"); -async function bootstrap() { - const app = await core_1.NestFactory.create(app_module_1.AppModule); - app.enableCors(); - const port = process.env["PORT"] ?? 3001; - await app.listen(port); - console.log(`API running on http://localhost:${port}`); -} -bootstrap().catch((err) => { - console.error("Failed to start application:", err); - process.exit(1); -}); -//# sourceMappingURL=main.js.map \ No newline at end of file diff --git a/apps/api/src/main.js.map b/apps/api/src/main.js.map deleted file mode 100644 index 784d529..0000000 --- a/apps/api/src/main.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"main.js","sourceRoot":"","sources":["main.ts"],"names":[],"mappings":";;AAAA,uCAA2C;AAC3C,6CAAyC;AAEzC,KAAK,UAAU,SAAS;IACtB,MAAM,GAAG,GAAG,MAAM,kBAAW,CAAC,MAAM,CAAC,sBAAS,CAAC,CAAC;IAChD,GAAG,CAAC,UAAU,EAAE,CAAC;IAEjB,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,IAAI,CAAC;IACzC,MAAM,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IAEvB,OAAO,CAAC,GAAG,CAAC,mCAAmC,IAAI,EAAE,CAAC,CAAC;AACzD,CAAC;AAED,SAAS,EAAE,CAAC,KAAK,CAAC,CAAC,GAAY,EAAE,EAAE;IACjC,OAAO,CAAC,KAAK,CAAC,8BAA8B,EAAE,GAAG,CAAC,CAAC;IACnD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"} \ No newline at end of file diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 0b4aeda..a8a8881 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,17 +1,61 @@ import { NestFactory } from "@nestjs/core"; import { AppModule } from "./app.module"; +import { GlobalExceptionFilter } from "./filters/global-exception.filter"; + +function getPort(): number { + const portEnv = process.env.PORT; + + if (portEnv === undefined || portEnv === "") { + return 3001; + } + + const port = parseInt(portEnv, 10); + + if (isNaN(port)) { + throw new Error(`Invalid PORT environment variable: "${portEnv}". PORT must be a number.`); + } + + if (port < 1 || port > 65535) { + throw new Error( + `Invalid PORT environment variable: ${String(port)}. PORT must be between 1 and 65535.` + ); + } + + return port; +} async function bootstrap() { const app = await NestFactory.create(AppModule); + + app.useGlobalFilters(new GlobalExceptionFilter()); app.enableCors(); - const port = process.env["PORT"] ?? 3001; + const port = getPort(); await app.listen(port); - console.log(`API running on http://localhost:${port}`); + console.log(`API running on http://localhost:${String(port)}`); } bootstrap().catch((err: unknown) => { - console.error("Failed to start application:", err); + const isProduction = process.env.NODE_ENV === "production"; + const errorMessage = err instanceof Error ? err.message : String(err); + const errorStack = err instanceof Error ? err.stack : undefined; + + if (isProduction) { + console.error( + JSON.stringify({ + level: "error", + message: "Failed to start application", + error: errorMessage, + timestamp: new Date().toISOString(), + }) + ); + } else { + console.error("Failed to start application:", errorMessage); + if (errorStack) { + console.error(errorStack); + } + } + process.exit(1); }); diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js new file mode 100644 index 0000000..2b5f888 --- /dev/null +++ b/apps/web/eslint.config.js @@ -0,0 +1,16 @@ +import nextjsConfig from "@mosaic/config/eslint/nextjs"; + +export default [ + ...nextjsConfig, + { + languageOptions: { + parserOptions: { + project: ["./tsconfig.json"], + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + ignores: [".next/**", "node_modules/**"], + }, +]; diff --git a/apps/web/package.json b/apps/web/package.json index 1b227ad..16b3e4c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,8 +6,8 @@ "build": "next build", "dev": "next dev --turbopack --port 3000", "start": "next start", - "lint": "next lint", - "lint:fix": "next lint --fix", + "lint": "eslint \"src/**/*.{ts,tsx}\"", + "lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix", "typecheck": "tsc --noEmit", "clean": "rm -rf .next", "test": "vitest run", diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index e2dab1c..7e5f7e1 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -4,9 +4,7 @@ export default function Home() { return (

Mosaic Stack

-

- Welcome to the Mosaic Stack monorepo -

+

Welcome to the Mosaic Stack monorepo

diff --git a/packages/config/eslint/nestjs.js b/packages/config/eslint/nestjs.js index 377fff5..5b1a027 100644 --- a/packages/config/eslint/nestjs.js +++ b/packages/config/eslint/nestjs.js @@ -4,9 +4,9 @@ export default [ ...baseConfig, { rules: { - "@typescript-eslint/interface-name-prefix": "off", "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/no-extraneous-class": "off", }, }, ]; diff --git a/packages/shared/eslint.config.js b/packages/shared/eslint.config.js new file mode 100644 index 0000000..0eeb9f2 --- /dev/null +++ b/packages/shared/eslint.config.js @@ -0,0 +1,16 @@ +import baseConfig from "@mosaic/config/eslint/base"; + +export default [ + ...baseConfig, + { + languageOptions: { + parserOptions: { + project: ["./tsconfig.json"], + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + ignores: ["dist/**", "**/*.test.ts", "**/*.spec.ts"], + }, +]; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index cfd93c5..229c695 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -1,38 +1,121 @@ /** * Base entity type with common fields + * @invariant updatedAt >= createdAt */ export interface BaseEntity { - id: string; - createdAt: Date; + readonly id: string; + readonly createdAt: Date; updatedAt: Date; } /** - * API response wrapper + * API response wrapper - discriminated union for type-safe error handling + * + * Usage: + * ```typescript + * if (response.success) { + * // TypeScript knows response.data exists + * console.log(response.data); + * } else { + * // TypeScript knows response.message exists + * console.error(response.message); + * } + * ``` */ -export interface ApiResponse { - data: T; - success: boolean; - message?: string; +export type ApiResponse = + | { success: true; data: T; message?: string } + | { success: false; data?: never; message: string; errorCode?: string }; + +/** + * Helper to create a successful API response + */ +export function successResponse(data: T, message?: string): ApiResponse { + if (message !== undefined) { + return { success: true, data, message }; + } + return { success: true, data }; +} + +/** + * Helper to create an error API response + */ +export function errorResponse(message: string, errorCode?: string): ApiResponse { + if (errorCode !== undefined) { + return { success: false, message, errorCode }; + } + return { success: false, message }; } /** * Pagination parameters */ -export interface PaginationParams { +export interface PaginationParams { + /** Page number (1-indexed) */ page: number; + /** Items per page */ limit: number; - sortBy?: string; + /** Field to sort by */ + sortBy?: SortableFields; + /** Sort direction */ sortOrder?: "asc" | "desc"; } /** * Paginated response + * @invariant totalPages === Math.ceil(total / limit) + * @invariant data.length <= limit + * @invariant page >= 1 && page <= totalPages (when total > 0) */ export interface PaginatedResponse { - data: T[]; - total: number; - page: number; - limit: number; - totalPages: number; + readonly data: readonly T[]; + readonly total: number; + readonly page: number; + readonly limit: number; + readonly totalPages: number; +} + +/** + * Helper to create a paginated response with calculated totalPages + */ +export function createPaginatedResponse( + data: T[], + total: number, + page: number, + limit: number +): PaginatedResponse { + if (limit <= 0) { + throw new Error("limit must be positive"); + } + if (page < 1) { + throw new Error("page must be >= 1"); + } + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit) || 1, + }; +} + +/** + * Health check status values + */ +export type HealthState = "healthy" | "degraded" | "unhealthy"; + +/** + * Health check response + */ +export interface HealthStatus { + status: HealthState; + timestamp: string; + version?: string; + checks?: Record< + string, + { + status: HealthState; + message?: string; + } + >; } diff --git a/packages/shared/src/utils/index.test.ts b/packages/shared/src/utils/index.test.ts index 778571b..ef418a8 100644 --- a/packages/shared/src/utils/index.test.ts +++ b/packages/shared/src/utils/index.test.ts @@ -35,6 +35,38 @@ describe("slugify", () => { it("should trim leading and trailing hyphens", () => { expect(slugify(" Hello World ")).toBe("hello-world"); }); + + it("should handle empty string", () => { + expect(slugify("")).toBe(""); + }); + + it("should handle string with only whitespace", () => { + expect(slugify(" ")).toBe(""); + }); + + it("should handle consecutive special characters", () => { + expect(slugify("Hello---World")).toBe("hello-world"); + expect(slugify("Hello___World")).toBe("hello-world"); + expect(slugify("Hello World")).toBe("hello-world"); + }); + + it("should handle numbers", () => { + expect(slugify("Product 123")).toBe("product-123"); + expect(slugify("2024 New Year")).toBe("2024-new-year"); + }); + + it("should handle mixed case", () => { + expect(slugify("HeLLo WoRLd")).toBe("hello-world"); + }); + + it("should remove leading/trailing special chars", () => { + expect(slugify("---Hello World---")).toBe("hello-world"); + expect(slugify("!@#Hello World!@#")).toBe("hello-world"); + }); + + it("should handle string with only special characters", () => { + expect(slugify("!@#$%^&*()")).toBe(""); + }); }); describe("sleep", () => { diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index 62bd374..5bcb52f 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -1,11 +1,16 @@ /** - * Safely parse a date string or return undefined + * Safely parse a date string or return undefined. + * Logs a warning if an invalid date string is passed. */ export function parseDate(value: string | Date | undefined | null): Date | undefined { if (!value) return undefined; if (value instanceof Date) return value; const parsed = new Date(value); - return isNaN(parsed.getTime()) ? undefined : parsed; + if (isNaN(parsed.getTime())) { + console.warn(`parseDate: Invalid date string received: "${value}"`); + return undefined; + } + return parsed; } /** diff --git a/packages/ui/eslint.config.js b/packages/ui/eslint.config.js new file mode 100644 index 0000000..d53f276 --- /dev/null +++ b/packages/ui/eslint.config.js @@ -0,0 +1,16 @@ +import baseConfig from "@mosaic/config/eslint/base"; + +export default [ + ...baseConfig, + { + languageOptions: { + parserOptions: { + project: ["./tsconfig.json"], + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + ignores: ["dist/**", "**/*.test.tsx", "**/*.test.ts", "**/*.spec.tsx", "**/*.spec.ts"], + }, +]; diff --git a/packages/ui/src/components/Button.test.tsx b/packages/ui/src/components/Button.test.tsx index 710b36b..2d2d9da 100644 --- a/packages/ui/src/components/Button.test.tsx +++ b/packages/ui/src/components/Button.test.tsx @@ -1,5 +1,5 @@ -import { describe, expect, it, afterEach } from "vitest"; -import { render, screen, cleanup } from "@testing-library/react"; +import { describe, expect, it, afterEach, vi } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; import { Button } from "./Button.js"; afterEach(() => { @@ -12,20 +12,75 @@ describe("Button", () => { expect(screen.getByRole("button")).toHaveTextContent("Click me"); }); - it("should apply variant styles", () => { - render(); - const button = screen.getByRole("button"); - expect(button.className).toContain("bg-red-600"); + describe("variants", () => { + it("should apply primary variant styles by default", () => { + render(); + const button = screen.getByRole("button"); + expect(button.className).toContain("bg-blue-600"); + }); + + it("should apply secondary variant styles", () => { + render(); + const button = screen.getByRole("button"); + expect(button.className).toContain("bg-gray-200"); + }); + + it("should apply danger variant styles", () => { + render(); + const button = screen.getByRole("button"); + expect(button.className).toContain("bg-red-600"); + }); }); - it("should apply size styles", () => { - render(); - const button = screen.getByRole("button"); - expect(button.className).toContain("px-6"); + describe("sizes", () => { + it("should apply medium size by default", () => { + render(); + const button = screen.getByRole("button"); + expect(button.className).toContain("px-4"); + }); + + it("should apply small size styles", () => { + render(); + const button = screen.getByRole("button"); + expect(button.className).toContain("px-3"); + }); + + it("should apply large size styles", () => { + render(); + const button = screen.getByRole("button"); + expect(button.className).toContain("px-6"); + }); + }); + + describe("onClick", () => { + it("should call onClick handler when clicked", () => { + const handleClick = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button")); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it("should not call onClick when disabled", () => { + const handleClick = vi.fn(); + render( + + ); + fireEvent.click(screen.getByRole("button")); + expect(handleClick).not.toHaveBeenCalled(); + }); }); it("should pass through additional props", () => { render(); expect(screen.getByRole("button")).toBeDisabled(); }); + + it("should merge custom className with default styles", () => { + render(); + const button = screen.getByRole("button"); + expect(button.className).toContain("custom-class"); + expect(button.className).toContain("bg-blue-600"); + }); });