This commit is contained in:
David Arranz 2025-01-29 20:02:59 +01:00
parent 5f555bb242
commit f5c9a0a995
21 changed files with 2780 additions and 20 deletions

13
.prettierrc Normal file
View File

@ -0,0 +1,13 @@
{
"bracketSpacing": true,
"useTabs": false,
"printWidth": 100,
"tabWidth": 2,
"semi": true,
"singleQuote": false,
"trailingComma": "es5",
"jsxSingleQuote": true,
"jsxBracketSameLine": false,
"arrowParens": "always",
"rcVerbose": true
}

20
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,20 @@
{
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit",
"source.fixAll.eslint": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.formatOnPaste": false,
"prettier.useEditorConfig": false,
"prettier.useTabs": false,
"prettier.configPath": ".prettierrc",
"asciidoc.antora.enableAntoraSupport": true,
// other vscode settings
"tailwindCSS.rootFontSize": 16,
"[handlebars]": {
"editor.defaultFormatter": "vscode.html-language-features"
} // <- your root font size here
}

View File

@ -0,0 +1,19 @@
import { pathsToModuleNameMapper } from "ts-jest";
import { compilerOptions } from "./tsconfig.json";
export default {
preset: "ts-jest",
testEnvironment: "node",
rootDir: "./",
testMatch: ["**/*.spec.ts"],
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: "<rootDir>/src/" }),
transform: {
"^.+\\.(ts|tsx)$": "ts-jest",
},
collectCoverage: true,
coverageDirectory: "coverage",
coverageReporters: ["text", "lcov"],
clearMocks: true,
setupFilesAfterEnv: ["<rootDir>/tests/setup.ts"],
};

View File

@ -9,7 +9,7 @@
"typecheck": "tsc --noEmit",
"build": "npm run clean && npm run typecheck && esbuild src/index.ts --platform=node --format=cjs --bundle --sourcemap --minify --outdir=dist",
"start": "node dist/index.js",
"test": "echo \"No tests yet\"",
"test": "jest --config=./jest.config.ts --verbose",
"lint": "eslint src --ext .ts"
},
"keywords": [],
@ -18,26 +18,47 @@
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/bcrypt": "^5.0.2",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.8",
"@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1",
"@types/response-time": "^2.3.8",
"@typescript-eslint/eslint-plugin": "^8.22.0",
"@typescript-eslint/parser": "^8.22.0",
"eslint": "^9.19.0",
"jest": "^29.7.0",
"nodemon": "^3.1.9",
"ts-jest": "^29.2.5",
"ts-node-dev": "^2.0.0",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
},
"dependencies": {
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"esbuild": "^0.24.0",
"express": "^4.21.2",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"mariadb": "^3.4.0",
"module-alias": "^2.2.3",
"mysql2": "^3.12.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"response-time": "^2.3.3",
"sequelize": "^6.37.5"
"sequelize": "^6.37.5",
"shallow-equal-object": "^1.1.1",
"uuid": "^11.0.5",
"zod": "^3.24.1"
},
"_moduleAliases": {
"@common": "./dist/common",
"@auth": "./dist/auth",
"@config": "./dist/config",
"@companies": "./dist/companies"
}
}

View File

@ -0,0 +1,47 @@
import { Result } from "@common/domain";
import { EmailAddress, PasswordHash, Username, UserRoles } from "./value-objects";
export class AuthUser {
private constructor(
public readonly id: number | null,
public readonly username: Username,
public readonly email: EmailAddress,
private password: PasswordHash,
public readonly roles: UserRoles,
public readonly isActive: boolean
) {}
static async create(
id: number | null,
username: string,
email: string | null,
plainPassword: string,
roles: string[],
isActive: boolean
): Promise<Result<AuthUser, Error>> {
const usernameResult = Username.create(username);
const emailResult = EmailAddress.create(email);
const passwordResult = await PasswordHash.create(plainPassword);
const rolesResult = UserRoles.create(roles);
const combined = Result.combine([usernameResult, emailResult, passwordResult, rolesResult]);
if (combined.isError()) {
return Result.fail(combined.error);
}
return Result.ok(
new AuthUser(
id,
usernameResult.data,
emailResult.data,
passwordResult.data,
rolesResult.data,
isActive
)
);
}
async validatePassword(plainPassword: string): Promise<boolean> {
return await this.password.compare(plainPassword);
}
}

View File

@ -0,0 +1,22 @@
import { Result, ValueObject } from "@common/domain";
import { z } from "zod";
const RoleSchema = z.enum(["Admin", "User", "Manager", "Editor"]);
export class UserRoles extends ValueObject<string[]> {
static create(roles: string[]): Result<UserRoles, Error> {
const result = UserRoles.validate(roles);
return result.success
? Result.ok(new UserRoles(result.data))
: Result.fail(new Error("Invalid user roles"));
}
private static validate(roles: string[]) {
return z.array(RoleSchema).safeParse(roles);
}
hasRole(role: string): boolean {
return this.value.includes(role);
}
}

View File

@ -0,0 +1,63 @@
import { EmailAddress } from "./email-address";
describe("EmailAddress Value Object", () => {
it("should create a valid email", () => {
const result = EmailAddress.create("user@example.com");
expect(result.isOk()).toBe(true);
expect(result.data.getValue()).toBe("user@example.com");
});
it("should return an error for invalid email format", () => {
const result = EmailAddress.create("invalid-email");
expect(result.isError()).toBe(true);
expect(result.error.message).toBe("Invalid email format");
});
it("should allow null email", () => {
const result = EmailAddress.create(null);
expect(result.isOk()).toBe(true);
expect(result.data.getValue()).toBe(null);
});
it("should convert empty string to null", () => {
const result = EmailAddress.create("");
expect(result.isOk()).toBe(true);
expect(result.data.getValue()).toBe(null);
});
it("should compare two equal email objects correctly", () => {
const email1 = EmailAddress.create("test@example.com");
const email2 = EmailAddress.create("test@example.com");
expect(email1.isOk()).toBe(true);
expect(email2.isOk()).toBe(true);
expect(email1.data.equals(email2.data)).toBe(true);
});
it("should compare two different email objects as not equal", () => {
const email1 = EmailAddress.create("test@example.com");
const email2 = EmailAddress.create("other@example.com");
expect(email1.isOk()).toBe(true);
expect(email2.isOk()).toBe(true);
expect(email1.data.equals(email2.data)).toBe(false);
});
it("should detect empty email correctly", () => {
const email = EmailAddress.create(null);
expect(email.isOk()).toBe(true);
expect(email.data.isEmpty()).toBe(true);
});
it("should detect non-empty email correctly", () => {
const email = EmailAddress.create("test@example.com");
expect(email.isOk()).toBe(true);
expect(email.data.isEmpty()).toBe(false);
});
});

View File

@ -0,0 +1,23 @@
import { Result, ValueObject } from "@common/domain";
import { z } from "zod";
export class EmailAddress extends ValueObject<string | null> {
static create(email: string | null): Result<EmailAddress, Error> {
const normalizedEmail = email?.trim() === "" ? null : email?.toLowerCase() || null;
const result = EmailAddress.validate(normalizedEmail);
return result.success
? Result.ok(new EmailAddress(result.data))
: Result.fail(new Error(result.error.errors[0].message));
}
private static validate(email: string | null) {
const schema = z.string().email({ message: "Invalid email format" }).or(z.null());
return schema.safeParse(email);
}
isEmpty(): boolean {
return this.value === null;
}
}

View File

@ -0,0 +1,4 @@
export * from "./auth-user-roles";
export * from "./email-address";
export * from "./password-hash";
export * from "./username";

View File

@ -0,0 +1,33 @@
import { PasswordHash } from "./password-hash";
describe("PasswordHash Value Object", () => {
it("should hash a valid password", async () => {
const result = await PasswordHash.create("StrongPass123");
expect(result.isOk()).toBe(true);
expect(result.data.getValue()).not.toBe("StrongPass123"); // Should be hashed
});
it("should return an error for short password", async () => {
const result = await PasswordHash.create("12345");
expect(result.isError()).toBe(true);
expect(result.error.message).toBe("Password must be at least 8 characters long");
});
it("should validate password comparison correctly", async () => {
const result = await PasswordHash.create("SecurePass123");
expect(result.isOk()).toBe(true);
const isValid = await result.data.compare("SecurePass123");
expect(isValid).toBe(true);
});
it("should fail password comparison for incorrect passwords", async () => {
const result = await PasswordHash.create("SecurePass123");
expect(result.isOk()).toBe(true);
const isValid = await result.data.compare("WrongPassword");
expect(isValid).toBe(false);
});
});

View File

@ -0,0 +1,35 @@
import { Result, ValueObject } from "@common/domain";
import bcrypt from "bcrypt";
import { z } from "zod";
const PasswordSchema = z
.string()
.min(8, { message: "Password must be at least 8 characters long" });
export class PasswordHash extends ValueObject<string> {
private static readonly SALT_ROUNDS = 10;
static async create(plainPassword: string): Promise<Result<PasswordHash, Error>> {
const result = PasswordHash.validate(plainPassword);
if (!result.success) {
return Result.fail(new Error(result.error.errors[0].message));
}
const hashed = await bcrypt.hash(result.data, this.SALT_ROUNDS);
return Result.ok(new PasswordHash(hashed));
}
private static validate(password: string) {
const schema = z.string().min(8, { message: "Password must be at least 8 characters long" });
return schema.safeParse(password);
}
static fromHash(hash: string): PasswordHash {
return new PasswordHash(hash);
}
async compare(plainPassword: string): Promise<boolean> {
return await bcrypt.compare(plainPassword, this.value);
}
}

View File

@ -0,0 +1,24 @@
import { Result, ValueObject } from "@common/domain";
import { z } from "zod";
export class Username extends ValueObject<string> {
static create(username: string): Result<Username, Error> {
const result = Username.validate(username);
return result.success
? Result.ok(new Username(result.data))
: Result.fail(new Error(result.error.errors[0].message));
}
private static validate(username: string) {
const schema = z
.string()
.min(3, { message: "Username must be at least 3 characters long" })
.max(30, { message: "Username cannot exceed 30 characters" })
.regex(/^[a-zA-Z0-9_]+$/, {
message: "Username can only contain letters, numbers, and underscores",
});
return schema.safeParse(username);
}
}

View File

@ -0,0 +1,2 @@
export * from "./result";
export * from "./value-object";

View File

@ -0,0 +1,78 @@
export class Result<T, E extends Error = Error> {
private readonly isSuccess: boolean;
private readonly _data?: T;
private readonly _error?: E;
private constructor(props: { isSuccess: boolean; error?: E; data?: T }) {
const { isSuccess, error, data } = props;
if (isSuccess && error) {
throw new Error(`InvalidOperation: A result cannot be successful and contain an error`);
}
if (!isSuccess && !error) {
throw new Error(`InvalidOperation: A failing result needs to contain an error message`);
}
this.isSuccess = isSuccess;
this._error = error;
this._data = data;
Object.freeze(this);
}
static ok<T>(data?: T): Result<T, never> {
return new Result<T, never>({ isSuccess: true, data });
}
static fail<E extends Error = Error>(error?: E): Result<never, E> {
return new Result<never, E>({ isSuccess: false, error });
}
static combine(results: Result<any, any>[]): Result<any, any> {
for (const result of results) {
if (result.isError()) {
return result;
}
}
return Result.ok<any>();
}
isOk(): boolean {
return this.isSuccess;
}
isError(): boolean {
return !this.isSuccess;
}
get data(): T {
if (!this.isSuccess) {
throw new Error("Cannot get value data from a failed result.");
}
return this._data as T;
}
get error(): E {
if (this.isSuccess) {
throw new Error("Cannot get error from a successful result.");
}
return this._error as E;
}
/**
* 🔹 `getOrElse(defaultValue: T): T`
* Si el `Result` es un `ok`, devuelve `data`, de lo contrario, devuelve `defaultValue`.
*/
getOrElse(defaultValue: T): T {
return this.isSuccess ? this.data : defaultValue;
}
/**
* 🔹 `match<R>(onOk: (data: T) => R, onError: (error: E) => R): R`
* Evalúa el `Result`: ejecuta `onOk()` si es `ok` o `onError()` si es `fail`.
*/
match<R>(onOk: (data: T) => R, onError: (error: E) => R): R {
return this.isSuccess ? onOk(this.data) : onError(this.error);
}
}

View File

@ -0,0 +1,35 @@
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
import { Result } from "./result";
import { ValueObject } from "./value-object";
const UUIDSchema = z.string().uuid({ message: "Invalid UUID format" });
export class UniqueID extends ValueObject<string> {
static create(id?: string, generateOnEmpty: boolean = false): Result<UniqueID, Error> {
if (!id) {
return generateOnEmpty
? UniqueID.generateNewID()
: Result.fail(new Error("ID is null or empty"));
}
const result = UniqueID.validate(id.trim());
return result.success
? Result.ok(new UniqueID(result.data))
: Result.fail(new Error(result.error.errors[0].message));
}
static generate(): UniqueID {
return new UniqueID(uuidv4());
}
static validate(id: string) {
const schema = z.string().uuid({ message: "Invalid UUID format" });
return schema.safeParse(id.trim());
}
static generateNewID(): Result<UniqueID, never> {
return Result.ok(new UniqueID(uuidv4()));
}
}

View File

@ -0,0 +1,38 @@
import { shallowEqual } from "shallow-equal-object";
export abstract class ValueObject<T> {
protected readonly value: T;
protected constructor(value: T) {
if (value === null || value === undefined) {
throw new Error("ValueObject value cannot be null or undefined");
}
this.value = typeof value === "object" ? Object.freeze(value) : value;
Object.freeze(this);
}
equals(other: ValueObject<T>): boolean {
if (!(other instanceof ValueObject)) {
return false;
}
if (other.value === undefined) {
return false;
}
return shallowEqual(this.value, other.value);
}
getValue(): T {
return this.value;
}
toString(): string {
return String(this.value);
}
clone(): this {
return Object.create(this);
}
}

View File

@ -1,8 +1,18 @@
{
"extends": "@repo/typescript-config/base.json",
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist"
"resolveJsonModule": true,
"esModuleInterop": true,
"module": "CommonJS",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"baseUrl": "src",
"paths": {
"@shared/*": ["shared/*"],
"@common/*": ["common/*"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View File

@ -0,0 +1,12 @@
{
"name": "shared",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}

View File

File diff suppressed because it is too large Load Diff

View File

@ -1,17 +1,17 @@
{
"compilerOptions": {
"target": "ES2018",
"module": "commonjs",
"moduleResolution": "node",
"strict": true,
"importHelpers": false,
"noEmitHelpers": false,
"skipLibCheck": true,
"outDir": "./dist",
"baseUrl": "./packages",
"paths": {
"@vercel/webpack-nft": ["webpack-nmt/src/index.ts"]
}
},
"exclude": ["node_modules", "target"]
}
"compilerOptions": {
"target": "ES6",
"module": "CommonJS",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": "./",
"paths": {
"@shared/*": ["packages/shared/src/*"]
}
},
"include": ["apps", "packages"],
"exclude": ["node_modules", "dist"]
}