fix(#1): Address code review findings
- Convert ApiResponse to discriminated union for type-safe error handling - Add HealthStatus type with HealthState literal union - Make BaseEntity fields readonly for immutability - Add GlobalExceptionFilter with structured logging - Add port validation with clear error messages in main.ts - Improve parseDate to log warnings for invalid dates - Add comprehensive Button tests (variants, onClick, disabled) - Add slugify edge case tests (empty, special chars, numbers) - Create ESLint configs for all packages - Remove compiled JS files from src directories - Convert .prettierrc.js to .prettierrc.json Refs #1 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
10
.gitignore
vendored
10
.gitignore
vendored
@@ -7,6 +7,16 @@ dist
|
|||||||
.next
|
.next
|
||||||
.turbo
|
.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
|
# IDE
|
||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
import config from "@mosaic/config/prettier";
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
10
.prettierrc.json
Normal file
10
.prettierrc.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
16
apps/api/eslint.config.js
Normal file
16
apps/api/eslint.config.js
Normal file
@@ -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"],
|
||||||
|
},
|
||||||
|
];
|
||||||
14
apps/api/src/app.controller.d.ts
vendored
14
apps/api/src/app.controller.d.ts
vendored
@@ -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<HealthStatus>;
|
|
||||||
}
|
|
||||||
export {};
|
|
||||||
//# sourceMappingURL=app.controller.d.ts.map
|
|
||||||
@@ -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"}
|
|
||||||
@@ -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
|
|
||||||
@@ -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"}
|
|
||||||
@@ -1,11 +1,7 @@
|
|||||||
import { Controller, Get } from "@nestjs/common";
|
import { Controller, Get } from "@nestjs/common";
|
||||||
import { AppService } from "./app.service";
|
import { AppService } from "./app.service";
|
||||||
import type { ApiResponse } from "@mosaic/shared";
|
import type { ApiResponse, HealthStatus } from "@mosaic/shared";
|
||||||
|
import { successResponse } from "@mosaic/shared";
|
||||||
interface HealthStatus {
|
|
||||||
status: string;
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
@@ -18,12 +14,9 @@ export class AppController {
|
|||||||
|
|
||||||
@Get("health")
|
@Get("health")
|
||||||
getHealth(): ApiResponse<HealthStatus> {
|
getHealth(): ApiResponse<HealthStatus> {
|
||||||
return {
|
return successResponse({
|
||||||
success: true,
|
status: "healthy",
|
||||||
data: {
|
timestamp: new Date().toISOString(),
|
||||||
status: "healthy",
|
});
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
apps/api/src/app.module.d.ts
vendored
3
apps/api/src/app.module.d.ts
vendored
@@ -1,3 +0,0 @@
|
|||||||
export declare class AppModule {
|
|
||||||
}
|
|
||||||
//# sourceMappingURL=app.module.d.ts.map
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"version":3,"file":"app.module.d.ts","sourceRoot":"","sources":["app.module.ts"],"names":[],"mappings":"AAIA,qBAKa,SAAS;CAAG"}
|
|
||||||
@@ -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
|
|
||||||
@@ -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"}
|
|
||||||
4
apps/api/src/app.service.d.ts
vendored
4
apps/api/src/app.service.d.ts
vendored
@@ -1,4 +0,0 @@
|
|||||||
export declare class AppService {
|
|
||||||
getHello(): string;
|
|
||||||
}
|
|
||||||
//# sourceMappingURL=app.service.d.ts.map
|
|
||||||
@@ -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"}
|
|
||||||
@@ -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
|
|
||||||
@@ -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"}
|
|
||||||
69
apps/api/src/filters/global-exception.filter.ts
Normal file
69
apps/api/src/filters/global-exception.filter.ts
Normal file
@@ -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<Response>();
|
||||||
|
const request = ctx.getRequest<Request>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
2
apps/api/src/main.d.ts
vendored
2
apps/api/src/main.d.ts
vendored
@@ -1,2 +0,0 @@
|
|||||||
export {};
|
|
||||||
//# sourceMappingURL=main.d.ts.map
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"version":3,"file":"main.d.ts","sourceRoot":"","sources":["main.ts"],"names":[],"mappings":""}
|
|
||||||
@@ -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
|
|
||||||
@@ -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"}
|
|
||||||
@@ -1,17 +1,61 @@
|
|||||||
import { NestFactory } from "@nestjs/core";
|
import { NestFactory } from "@nestjs/core";
|
||||||
import { AppModule } from "./app.module";
|
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() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
app.useGlobalFilters(new GlobalExceptionFilter());
|
||||||
app.enableCors();
|
app.enableCors();
|
||||||
|
|
||||||
const port = process.env["PORT"] ?? 3001;
|
const port = getPort();
|
||||||
await app.listen(port);
|
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) => {
|
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);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
16
apps/web/eslint.config.js
Normal file
16
apps/web/eslint.config.js
Normal file
@@ -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/**"],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -6,8 +6,8 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"dev": "next dev --turbopack --port 3000",
|
"dev": "next dev --turbopack --port 3000",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "eslint \"src/**/*.{ts,tsx}\"",
|
||||||
"lint:fix": "next lint --fix",
|
"lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"clean": "rm -rf .next",
|
"clean": "rm -rf .next",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ export default function Home() {
|
|||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen flex-col items-center justify-center p-24">
|
<main className="flex min-h-screen flex-col items-center justify-center p-24">
|
||||||
<h1 className="text-4xl font-bold mb-8">Mosaic Stack</h1>
|
<h1 className="text-4xl font-bold mb-8">Mosaic Stack</h1>
|
||||||
<p className="text-lg text-gray-600 mb-8">
|
<p className="text-lg text-gray-600 mb-8">Welcome to the Mosaic Stack monorepo</p>
|
||||||
Welcome to the Mosaic Stack monorepo
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<Button variant="primary">Get Started</Button>
|
<Button variant="primary">Get Started</Button>
|
||||||
<Button variant="secondary">Learn More</Button>
|
<Button variant="secondary">Learn More</Button>
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ export default [
|
|||||||
...baseConfig,
|
...baseConfig,
|
||||||
{
|
{
|
||||||
rules: {
|
rules: {
|
||||||
"@typescript-eslint/interface-name-prefix": "off",
|
|
||||||
"@typescript-eslint/explicit-function-return-type": "off",
|
"@typescript-eslint/explicit-function-return-type": "off",
|
||||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||||
|
"@typescript-eslint/no-extraneous-class": "off",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
16
packages/shared/eslint.config.js
Normal file
16
packages/shared/eslint.config.js
Normal file
@@ -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"],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -1,38 +1,121 @@
|
|||||||
/**
|
/**
|
||||||
* Base entity type with common fields
|
* Base entity type with common fields
|
||||||
|
* @invariant updatedAt >= createdAt
|
||||||
*/
|
*/
|
||||||
export interface BaseEntity {
|
export interface BaseEntity {
|
||||||
id: string;
|
readonly id: string;
|
||||||
createdAt: Date;
|
readonly createdAt: Date;
|
||||||
updatedAt: 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<T> {
|
export type ApiResponse<T> =
|
||||||
data: T;
|
| { success: true; data: T; message?: string }
|
||||||
success: boolean;
|
| { success: false; data?: never; message: string; errorCode?: string };
|
||||||
message?: string;
|
|
||||||
|
/**
|
||||||
|
* Helper to create a successful API response
|
||||||
|
*/
|
||||||
|
export function successResponse<T>(data: T, message?: string): ApiResponse<T> {
|
||||||
|
if (message !== undefined) {
|
||||||
|
return { success: true, data, message };
|
||||||
|
}
|
||||||
|
return { success: true, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create an error API response
|
||||||
|
*/
|
||||||
|
export function errorResponse<T>(message: string, errorCode?: string): ApiResponse<T> {
|
||||||
|
if (errorCode !== undefined) {
|
||||||
|
return { success: false, message, errorCode };
|
||||||
|
}
|
||||||
|
return { success: false, message };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pagination parameters
|
* Pagination parameters
|
||||||
*/
|
*/
|
||||||
export interface PaginationParams {
|
export interface PaginationParams<SortableFields extends string = string> {
|
||||||
|
/** Page number (1-indexed) */
|
||||||
page: number;
|
page: number;
|
||||||
|
/** Items per page */
|
||||||
limit: number;
|
limit: number;
|
||||||
sortBy?: string;
|
/** Field to sort by */
|
||||||
|
sortBy?: SortableFields;
|
||||||
|
/** Sort direction */
|
||||||
sortOrder?: "asc" | "desc";
|
sortOrder?: "asc" | "desc";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Paginated response
|
* Paginated response
|
||||||
|
* @invariant totalPages === Math.ceil(total / limit)
|
||||||
|
* @invariant data.length <= limit
|
||||||
|
* @invariant page >= 1 && page <= totalPages (when total > 0)
|
||||||
*/
|
*/
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
data: T[];
|
readonly data: readonly T[];
|
||||||
total: number;
|
readonly total: number;
|
||||||
page: number;
|
readonly page: number;
|
||||||
limit: number;
|
readonly limit: number;
|
||||||
totalPages: number;
|
readonly totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create a paginated response with calculated totalPages
|
||||||
|
*/
|
||||||
|
export function createPaginatedResponse<T>(
|
||||||
|
data: T[],
|
||||||
|
total: number,
|
||||||
|
page: number,
|
||||||
|
limit: number
|
||||||
|
): PaginatedResponse<T> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,38 @@ describe("slugify", () => {
|
|||||||
it("should trim leading and trailing hyphens", () => {
|
it("should trim leading and trailing hyphens", () => {
|
||||||
expect(slugify(" Hello World ")).toBe("hello-world");
|
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", () => {
|
describe("sleep", () => {
|
||||||
|
|||||||
@@ -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 {
|
export function parseDate(value: string | Date | undefined | null): Date | undefined {
|
||||||
if (!value) return undefined;
|
if (!value) return undefined;
|
||||||
if (value instanceof Date) return value;
|
if (value instanceof Date) return value;
|
||||||
const parsed = new Date(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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
16
packages/ui/eslint.config.js
Normal file
16
packages/ui/eslint.config.js
Normal file
@@ -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"],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it, afterEach } from "vitest";
|
import { describe, expect, it, afterEach, vi } from "vitest";
|
||||||
import { render, screen, cleanup } from "@testing-library/react";
|
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||||
import { Button } from "./Button.js";
|
import { Button } from "./Button.js";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -12,20 +12,75 @@ describe("Button", () => {
|
|||||||
expect(screen.getByRole("button")).toHaveTextContent("Click me");
|
expect(screen.getByRole("button")).toHaveTextContent("Click me");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should apply variant styles", () => {
|
describe("variants", () => {
|
||||||
render(<Button variant="danger">Delete</Button>);
|
it("should apply primary variant styles by default", () => {
|
||||||
const button = screen.getByRole("button");
|
render(<Button>Primary</Button>);
|
||||||
expect(button.className).toContain("bg-red-600");
|
const button = screen.getByRole("button");
|
||||||
|
expect(button.className).toContain("bg-blue-600");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should apply secondary variant styles", () => {
|
||||||
|
render(<Button variant="secondary">Secondary</Button>);
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
expect(button.className).toContain("bg-gray-200");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should apply danger variant styles", () => {
|
||||||
|
render(<Button variant="danger">Delete</Button>);
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
expect(button.className).toContain("bg-red-600");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should apply size styles", () => {
|
describe("sizes", () => {
|
||||||
render(<Button size="lg">Large Button</Button>);
|
it("should apply medium size by default", () => {
|
||||||
const button = screen.getByRole("button");
|
render(<Button>Medium</Button>);
|
||||||
expect(button.className).toContain("px-6");
|
const button = screen.getByRole("button");
|
||||||
|
expect(button.className).toContain("px-4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should apply small size styles", () => {
|
||||||
|
render(<Button size="sm">Small</Button>);
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
expect(button.className).toContain("px-3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should apply large size styles", () => {
|
||||||
|
render(<Button size="lg">Large Button</Button>);
|
||||||
|
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(<Button onClick={handleClick}>Click me</Button>);
|
||||||
|
fireEvent.click(screen.getByRole("button"));
|
||||||
|
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not call onClick when disabled", () => {
|
||||||
|
const handleClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<Button onClick={handleClick} disabled>
|
||||||
|
Disabled
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button"));
|
||||||
|
expect(handleClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should pass through additional props", () => {
|
it("should pass through additional props", () => {
|
||||||
render(<Button disabled>Disabled</Button>);
|
render(<Button disabled>Disabled</Button>);
|
||||||
expect(screen.getByRole("button")).toBeDisabled();
|
expect(screen.getByRole("button")).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should merge custom className with default styles", () => {
|
||||||
|
render(<Button className="custom-class">Custom</Button>);
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
expect(button.className).toContain("custom-class");
|
||||||
|
expect(button.className).toContain("bg-blue-600");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user