.
This commit is contained in:
parent
5f555bb242
commit
f5c9a0a995
13
.prettierrc
Normal file
13
.prettierrc
Normal 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
20
.vscode/settings.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
19
apps/server/jest.config.ts
Normal file
19
apps/server/jest.config.ts
Normal 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"],
|
||||||
|
};
|
||||||
@ -9,7 +9,7 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"build": "npm run clean && npm run typecheck && esbuild src/index.ts --platform=node --format=cjs --bundle --sourcemap --minify --outdir=dist",
|
"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",
|
"start": "node dist/index.js",
|
||||||
"test": "echo \"No tests yet\"",
|
"test": "jest --config=./jest.config.ts --verbose",
|
||||||
"lint": "eslint src --ext .ts"
|
"lint": "eslint src --ext .ts"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
@ -18,26 +18,47 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/eslint-config": "workspace:*",
|
"@repo/eslint-config": "workspace:*",
|
||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/jsonwebtoken": "^9.0.8",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/response-time": "^2.3.8",
|
"@types/response-time": "^2.3.8",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.22.0",
|
"@typescript-eslint/eslint-plugin": "^8.22.0",
|
||||||
"@typescript-eslint/parser": "^8.22.0",
|
"@typescript-eslint/parser": "^8.22.0",
|
||||||
"eslint": "^9.19.0",
|
"eslint": "^9.19.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
"nodemon": "^3.1.9",
|
"nodemon": "^3.1.9",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
"ts-node-dev": "^2.0.0",
|
"ts-node-dev": "^2.0.0",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"esbuild": "^0.24.0",
|
"esbuild": "^0.24.0",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mariadb": "^3.4.0",
|
"mariadb": "^3.4.0",
|
||||||
|
"module-alias": "^2.2.3",
|
||||||
"mysql2": "^3.12.0",
|
"mysql2": "^3.12.0",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"response-time": "^2.3.3",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
47
apps/server/src/auth/domain/auth-user.model.ts
Normal file
47
apps/server/src/auth/domain/auth-user.model.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
apps/server/src/auth/domain/value-objects/auth-user-roles.ts
Normal file
22
apps/server/src/auth/domain/value-objects/auth-user-roles.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
23
apps/server/src/auth/domain/value-objects/email-address.ts
Normal file
23
apps/server/src/auth/domain/value-objects/email-address.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
4
apps/server/src/auth/domain/value-objects/index.ts
Normal file
4
apps/server/src/auth/domain/value-objects/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from "./auth-user-roles";
|
||||||
|
export * from "./email-address";
|
||||||
|
export * from "./password-hash";
|
||||||
|
export * from "./username";
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
35
apps/server/src/auth/domain/value-objects/password-hash.ts
Normal file
35
apps/server/src/auth/domain/value-objects/password-hash.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
apps/server/src/auth/domain/value-objects/username.ts
Normal file
24
apps/server/src/auth/domain/value-objects/username.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
2
apps/server/src/common/domain/index.ts
Normal file
2
apps/server/src/common/domain/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./result";
|
||||||
|
export * from "./value-object";
|
||||||
78
apps/server/src/common/domain/result.ts
Normal file
78
apps/server/src/common/domain/result.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
apps/server/src/common/domain/unique-id.ts
Normal file
35
apps/server/src/common/domain/unique-id.ts
Normal 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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
38
apps/server/src/common/domain/value-object.ts
Normal file
38
apps/server/src/common/domain/value-object.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,18 @@
|
|||||||
{
|
{
|
||||||
"extends": "@repo/typescript-config/base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "dist"
|
"resolveJsonModule": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"baseUrl": "src",
|
||||||
|
"paths": {
|
||||||
|
"@shared/*": ["shared/*"],
|
||||||
|
"@common/*": ["common/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
12
packages/shared/package.json
Normal file
12
packages/shared/package.json
Normal 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"
|
||||||
|
}
|
||||||
0
packages/shared/tsconfig.json
Normal file
0
packages/shared/tsconfig.json
Normal file
2261
pnpm-lock.yaml
2261
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2018",
|
"target": "ES6",
|
||||||
"module": "commonjs",
|
"module": "CommonJS",
|
||||||
"moduleResolution": "node",
|
"outDir": "./dist",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"importHelpers": false,
|
"esModuleInterop": true,
|
||||||
"noEmitHelpers": false,
|
"skipLibCheck": true,
|
||||||
"skipLibCheck": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"outDir": "./dist",
|
"baseUrl": "./",
|
||||||
"baseUrl": "./packages",
|
"paths": {
|
||||||
"paths": {
|
"@shared/*": ["packages/shared/src/*"]
|
||||||
"@vercel/webpack-nft": ["webpack-nmt/src/index.ts"]
|
}
|
||||||
}
|
},
|
||||||
},
|
"include": ["apps", "packages"],
|
||||||
"exclude": ["node_modules", "target"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user