Initial commit

This commit is contained in:
Lance
2026-01-16 15:33:40 +08:00
committed by GitHub
commit 037e36f4f4
67 changed files with 5589 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
import type { IConfigService } from "@/shared/application/ports/IConfigService.js";
export class EnvConfigService implements IConfigService {
get(key: string): string {
return process.env[key] ?? "";
}
isDevelopment(): boolean {
return (
process.env.ENVIRONMENT !== "production" ||
process.env.NODE_ENV === "development"
);
}
}

View File

@@ -0,0 +1,15 @@
import bcrypt from "bcrypt";
import { uuidv7 } from "uuidv7";
import type { ICryptoService } from "@/shared/application/ports/ICryptoService.js";
export class BcryptService implements ICryptoService {
hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 10);
}
comparePassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
randomId(): string {
return uuidv7();
}
}

View File

@@ -0,0 +1,142 @@
import { inject, injectable } from "inversify";
import jwt from "jsonwebtoken";
import z from "zod";
import type { UserEntity } from "@/modules/users/domain/users.entity.js";
import type { IConfigService } from "@/shared/application/ports/IConfigService.js";
import type { ILogger } from "@/shared/application/ports/ILogger.js";
import type {
IRefreshData,
ISession,
ITokenService,
} from "@/shared/application/ports/ITokenService.js";
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
import { appContainer } from "../di/Container.js";
const JWTSessionSchema = z.object({
sub: z.string(),
email: z.string(),
isVerified: z.boolean(),
iat: z.number(),
exp: z.number(),
});
const JWTRefreshSchema = z.object({
sub: z.string(),
iat: z.number(),
exp: z.number(),
});
@injectable()
export class JwtService implements ITokenService {
constructor(
@inject(SharedDomain.IConfigService)
private readonly configService: IConfigService,
) {}
generateToken(
user: UserEntity,
additionalClaims?: Record<string, string | boolean | number>,
): string {
const duration = Number.parseInt(
this.configService.get("JWT_DURATION"),
10,
);
const secret = this.configService.get("JWT_SECRET");
const claims = {
iat: Math.ceil(Date.now() / 1000),
exp: Math.ceil(Date.now() / 1000) + duration,
sub: user.id,
email: user.email,
isVerified: user.isVerified,
...additionalClaims,
};
const jwtClaims = JWTSessionSchema.parse(claims);
const token = jwt.sign(jwtClaims, secret, {
algorithm: "HS256",
});
return token;
}
generateRefreshToken(user: UserEntity): string {
const duration = Number.parseInt(
this.configService.get("JWT_REFRESH_DURATION"),
10,
);
const secret = this.configService.get("JWT_REFRESH_SECRET");
const claims = {
iat: Math.ceil(Date.now() / 1000),
exp: Math.ceil(Date.now() / 1000) + duration,
sub: user.id,
};
const jwtClaims = JWTRefreshSchema.parse(claims);
const token = jwt.sign(jwtClaims, secret, {
algorithm: "HS256",
});
return token;
}
getSession(token: string): ISession | null {
const secret = this.configService.get("JWT_SECRET");
const logger = appContainer.get<ILogger>(SharedDomain.ILogger);
try {
const decoded = jwt.verify(token, secret);
const decodedToken = JWTSessionSchema.parse(decoded);
if (decodedToken.exp < Date.now() / 1000) {
logger.error({
message: "Token expired",
module: "JwtService",
error: new Error("Token expired"),
});
return null;
}
const session = {
userId: decodedToken.sub,
email: decodedToken.email,
isVerified: decodedToken.isVerified,
loginDate: new Date(decodedToken.iat * 1000),
};
return session;
} catch (error) {
let errorInstance: Error | undefined;
if (error instanceof Error) {
errorInstance = error;
}
logger.error({
message: "Invalid token",
module: "JwtService",
error: errorInstance,
});
return null;
}
}
validateRefreshToken(refreshToken: string): IRefreshData | null {
const secret = this.configService.get("JWT_REFRESH_SECRET");
const logger = appContainer.get<ILogger>(SharedDomain.ILogger);
try {
const decoded = jwt.verify(refreshToken, secret);
const decodedToken = JWTRefreshSchema.parse(decoded);
if (decodedToken.exp < Date.now() / 1000) {
logger.error({
message: "Token expired",
module: "JwtService",
error: new Error("Token expired"),
});
return null;
}
const refreshData = {
userId: decodedToken.sub,
};
return refreshData;
} catch (error) {
let errorInstance: Error | undefined;
if (error instanceof Error) {
errorInstance = error;
}
logger.error({
message: "Invalid refresh token",
module: "JwtService",
error: errorInstance,
});
return null;
}
}
}

View File

@@ -0,0 +1,12 @@
import { Container } from "inversify";
import { AuthDIModule } from "@/modules/auth/infrastructure/di/auth.di.js";
import { UsersDIModule } from "@/modules/users/infrastructure/di/users.di.js";
import { SharedDIModule } from "./shared.di.js";
const appContainer = new Container();
appContainer.load(SharedDIModule);
appContainer.load(AuthDIModule);
appContainer.load(UsersDIModule);
export { appContainer };

View File

@@ -0,0 +1,14 @@
import { ContainerModule } from "inversify";
import type { IConfigService } from "@/shared/application/ports/IConfigService.js";
import type { ICryptoService } from "@/shared/application/ports/ICryptoService.js";
import type { ITokenService } from "@/shared/application/ports/ITokenService.js";
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
import { EnvConfigService } from "../config/EnvConfigService.js";
import { BcryptService } from "../crypto/BcryptService.js";
import { JwtService } from "../crypto/JwtService.js";
export const SharedDIModule = new ContainerModule(({ bind }) => {
bind<ICryptoService>(SharedDomain.ICryptoService).to(BcryptService);
bind<ITokenService>(SharedDomain.ITokenService).to(JwtService);
bind<IConfigService>(SharedDomain.IConfigService).to(EnvConfigService);
});

View File

@@ -0,0 +1,44 @@
import type { ErrorRequestHandler } from "express";
import type { ILogger } from "@/shared/application/ports/ILogger.js";
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
import { ValidationError } from "@/shared/core/errors/ValidationError.js";
import { appContainer } from "../../di/Container.js";
import { respondWithGenericError } from "../responses/respondWithGenericError.js";
import { respondWithValidationError } from "../responses/respondWithValidationError.js";
export const errorHandler: ErrorRequestHandler = (err, req, res, _next) => {
const logger = appContainer.get<ILogger>(SharedDomain.ILogger, {
optional: true,
});
logger?.error({
message: err.message,
error: err,
module: "errorHandler",
context: {
route: req.originalUrl,
method: req.method,
userAgent: req.headers["user-agent"] ?? "n/a",
ip: req.ip ?? "n/a",
},
});
if (err instanceof ValidationError) {
respondWithValidationError({
res,
response: {
message: err.message,
issues: err.issues,
},
});
return;
}
respondWithGenericError({
res,
response: {
message: err.message,
},
statusCode: 500,
});
};

View File

@@ -0,0 +1,38 @@
import type { NextFunction, Request, Response } from "express";
import { ZodError } from "zod";
import type { ILogger } from "@/shared/application/ports/ILogger.js";
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
import { ValidationError } from "@/shared/core/errors/ValidationError.js";
import { appContainer } from "../../di/Container.js";
export const zodValidationHandler = (
err: ZodError,
req: Request,
_res: Response,
next: NextFunction,
) => {
const logger = appContainer.get<ILogger>(SharedDomain.ILogger, {
optional: true,
});
if (err instanceof ZodError) {
const issues = err.issues.map((issue) => ({
field: issue.path[0]?.toString() ?? "root",
message: issue.message,
}));
const validationError = new ValidationError("Validation error", issues);
logger?.error({
message: "ZodError caught!",
module: "zodValidationHandler",
context: {
route: req.originalUrl,
method: req.method,
userAgent: req.headers["user-agent"] ?? "n/a",
ip: req.ip ?? "n/a",
},
error: validationError,
});
next(validationError);
}
next(err);
};

View File

@@ -0,0 +1,18 @@
import type { NextFunction, Request, Response } from "express";
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
import { appContainer } from "@/shared/infrastructure/di/Container.js";
export const attachRequestContext = (
req: Request,
_res: Response,
next: NextFunction,
) => {
appContainer.bind(SharedDomain.IRequestContext).toConstantValue({
route: req.originalUrl,
method: req.method,
userAgent: req.headers["user-agent"],
ip: req.ip,
});
next();
appContainer.unbind(SharedDomain.IRequestContext);
};

View File

@@ -0,0 +1,37 @@
import type { NextFunction, Request, Response } from "express";
import type { IUsersRepository } from "@/modules/users/domain/users.repo.js";
import { UsersDomain } from "@/modules/users/domain/users.symbols.js";
import type { ITokenService } from "@/shared/application/ports/ITokenService.js";
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
import { appContainer } from "../../di/Container.js";
export const attachSession = async (
req: Request,
_res: Response,
next: NextFunction,
) => {
const tokenService = appContainer.get<ITokenService>(
SharedDomain.ITokenService,
);
const userRepo = appContainer.get<IUsersRepository>(
UsersDomain.IUserRepository,
);
const token = req.cookies.token;
if (!token) {
next();
return;
}
const session = tokenService.getSession(token);
if (!session) {
next();
return;
}
const currentUser = await userRepo.findOne({ id: session.userId });
if (!currentUser) {
next();
return;
}
req.session = session;
req.currentUser = currentUser;
next();
};

View File

@@ -0,0 +1,26 @@
import type { NextFunction, Request, Response } from "express";
import type { ILogger } from "@/shared/application/ports/ILogger.js";
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
import { appContainer } from "../../di/Container.js";
export const requestLogger = (
req: Request,
res: Response,
next: NextFunction,
) => {
res.on("finish", () => {
const logger = appContainer.get<ILogger>(SharedDomain.ILogger);
logger.info({
message: res.statusCode.toString(),
module: "requestLogger",
context: {
route: req.originalUrl,
method: req.method,
userAgent: req.headers["user-agent"] ?? "n/a",
ip: req.ip ?? "n/a",
},
});
});
next();
};

View File

@@ -0,0 +1,19 @@
import type { NextFunction, Request, Response } from "express";
import { respondWithGenericError } from "../responses/respondWithGenericError.js";
export const requireAuth = (
req: Request,
res: Response,
next: NextFunction,
) => {
if (!req.session) {
return respondWithGenericError({
res,
response: {
message: "Unauthorized",
},
statusCode: 401,
});
}
next();
};

View File

@@ -0,0 +1,14 @@
import type { NextFunction, Request, Response } from "express";
const HEADERS_TO_STRIP = ["x-powered-by"];
export const stripHeaders = (
_req: Request,
res: Response,
next: NextFunction,
) => {
HEADERS_TO_STRIP.forEach((header) => {
res.removeHeader(header);
});
next();
};

View File

@@ -0,0 +1,9 @@
import type { UserEntity } from "@/modules/users/domain/users.entity.js";
import type { ISession } from "@/shared/application/ports/ITokenService.js";
declare module "express-serve-static-core" {
interface Request {
session?: ISession;
currentUser?: UserEntity;
}
}

View File

@@ -0,0 +1,18 @@
import type { Response } from "express";
export type GenericErrorResponse = {
message: string;
};
type RespondWithGenericErrorParams = {
res: Response;
response: GenericErrorResponse;
statusCode: number;
};
export function respondWithGenericError({
res,
response,
statusCode,
}: RespondWithGenericErrorParams) {
res.status(statusCode).json(response);
}

View File

@@ -0,0 +1,19 @@
import type { Response } from "express";
export type ValidationErrorResponse = {
message: string;
issues: {
field: string;
message: string;
}[];
};
type RespondWithValidationErrorParams = {
res: Response;
response: ValidationErrorResponse;
};
export function respondWithValidationError({
res,
response,
}: RespondWithValidationErrorParams) {
res.status(400).json(response);
}

View File

@@ -0,0 +1,15 @@
import express from "express";
import authRoutes from "@/modules/auth/infrastructure/http/auth.routes.js";
import helloWorldRoutes from "@/modules/hello-world/infrastructure/http/hello-world.routes.js";
import { attachRequestContext } from "./middlewares/attachRequestContext.js";
import { attachSession } from "./middlewares/attachSession.js";
const routes = express.Router();
routes.use(attachRequestContext);
routes.use(attachSession);
routes.use("/auth", authRoutes);
routes.use("/hello-world", helloWorldRoutes);
export default routes;

View File

@@ -0,0 +1,30 @@
import type {
ILogger,
LogMessage,
} from "@/shared/application/ports/ILogger.js";
function messageBuilder({ message, module, context }: LogMessage): string {
const fullMessage = [message];
const localDateTime = new Date().toLocaleString();
if (context) {
fullMessage.push(`(${context.ip}) [${context.method} ${context.route}]`);
}
if (module) {
fullMessage.push(`[${module}]`);
}
return `[${localDateTime}] ${fullMessage.reverse().join(" ")}`;
}
export class ConsoleLogger implements ILogger {
info(message: LogMessage): void {
console.log(messageBuilder(message));
}
error(message: LogMessage): void {
console.error(messageBuilder(message));
}
warn(message: LogMessage): void {
console.warn(messageBuilder(message));
}
}

View File

@@ -0,0 +1,402 @@
import { beforeEach, describe, expect, it } from "vitest";
import { InMemoryRepository } from "./InMemoryRepository.js";
type TestEntity = {
id: string;
name: string;
age: number;
isActive: boolean;
createdAt: Date;
};
class TestRepository extends InMemoryRepository<TestEntity> {}
describe("InMemoryRepository (Generic) - Comprehensive Tests", () => {
let repo: TestRepository;
beforeEach(() => {
repo = new TestRepository();
});
describe("Persistence Operations", () => {
it("should save and find an entity by ID", async () => {
const id = repo.generateId();
const entity: TestEntity = {
id,
name: "Test",
age: 25,
isActive: true,
createdAt: new Date(),
};
await repo.save(entity);
const found = await repo.findById(id);
expect(found).toEqual(entity);
});
it("should update an existing entity with the same ID", async () => {
const id = repo.generateId();
const entity: TestEntity = {
id,
name: "Old Name",
age: 25,
isActive: true,
createdAt: new Date(),
};
await repo.save(entity);
// Modify and save again
entity.name = "New Name";
await repo.save(entity);
const found = await repo.findById(id);
expect(found).toBeDefined();
expect(found?.name).toBe("New Name");
// Ensure no duplicate records
const all = await repo.findAll();
expect(all.total).toBe(1);
});
it("should return null if finding by non-existent ID", async () => {
const found = await repo.findById("non-existent-id");
expect(found).toBeNull();
});
});
describe("Query Operations", () => {
it("should find one entity by filter criteria", async () => {
await repo.save({
id: "1",
name: "Unique",
age: 25,
isActive: true,
createdAt: new Date(),
});
await repo.save({
id: "2",
name: "Other",
age: 30,
isActive: false,
createdAt: new Date(),
});
const found = await repo.findOne({ name: "Unique" });
expect(found).toBeDefined();
expect(found?.id).toBe("1");
});
it("should return null if findOne matches nothing", async () => {
const found = await repo.findOne({ name: "NonExistent" });
expect(found).toBeNull();
});
});
describe("Exact Match Filtering", () => {
it("should filter by string (exact match)", async () => {
await repo.save({
id: "1",
name: "Alice",
age: 25,
isActive: true,
createdAt: new Date(),
});
await repo.save({
id: "2",
name: "Bob",
age: 30,
isActive: false,
createdAt: new Date(),
});
const result = await repo.findAll({ name: "Alice" });
expect(result.data).toHaveLength(1);
expect(result.data.at(0)?.name).toBe("Alice");
});
it("should filter by number (exact match)", async () => {
await repo.save({
id: "1",
name: "Alice",
age: 25,
isActive: true,
createdAt: new Date(),
});
await repo.save({
id: "2",
name: "Bob",
age: 30,
isActive: false,
createdAt: new Date(),
});
const result = await repo.findAll({ age: 25 });
expect(result.data).toHaveLength(1);
expect(result.data.at(0)?.age).toBe(25);
});
it("should filter by boolean (true)", async () => {
await repo.save({
id: "1",
name: "Alice",
age: 25,
isActive: true,
createdAt: new Date(),
});
await repo.save({
id: "2",
name: "Bob",
age: 30,
isActive: false,
createdAt: new Date(),
});
const result = await repo.findAll({ isActive: true });
expect(result.data).toHaveLength(1);
expect(result.data.at(0)?.isActive).toBe(true);
});
it("should filter by boolean (false)", async () => {
await repo.save({
id: "1",
name: "Alice",
age: 25,
isActive: true,
createdAt: new Date(),
});
await repo.save({
id: "2",
name: "Bob",
age: 30,
isActive: false,
createdAt: new Date(),
});
const result = await repo.findAll({ isActive: false });
expect(result.data).toHaveLength(1);
expect(result.data.at(0)?.isActive).toBe(false);
});
it("should filter by Date (exact match)", async () => {
const date1 = new Date("2023-01-01T00:00:00Z");
const date2 = new Date("2023-01-02T00:00:00Z");
await repo.save({
id: "1",
name: "Alice",
age: 25,
isActive: true,
createdAt: date1,
});
await repo.save({
id: "2",
name: "Bob",
age: 30,
isActive: false,
createdAt: date2,
});
const result = await repo.findAll({ createdAt: date1 });
expect(result.data).toHaveLength(1);
expect(result.data.at(0)?.createdAt).toEqual(date1);
});
it("should ignore undefined filter properties", async () => {
await repo.save({
id: "1",
name: "Alice",
age: 25,
isActive: true,
createdAt: new Date(),
});
await repo.save({
id: "2",
name: "Bob",
age: 30,
isActive: false,
createdAt: new Date(),
});
const result = await repo.findAll({ name: undefined });
expect(result.data).toHaveLength(2);
});
});
describe("Range Filtering - Number", () => {
beforeEach(async () => {
await repo.save({
id: "1",
name: "Kid",
age: 10,
isActive: true,
createdAt: new Date(),
});
await repo.save({
id: "2",
name: "Teen",
age: 15,
isActive: true,
createdAt: new Date(),
});
await repo.save({
id: "3",
name: "Adult",
age: 25,
isActive: true,
createdAt: new Date(),
});
});
it("should filter by number range (from only - tail case)", async () => {
// age >= 15
const result = await repo.findAll({ age: { from: 15 } });
expect(result.data).toHaveLength(2); // Teen, Adult
expect(result.data.map((i) => i.age).sort()).toEqual([15, 25]);
});
it("should filter by number range (to only - head case)", async () => {
// age <= 15
const result = await repo.findAll({ age: { to: 15 } });
expect(result.data).toHaveLength(2); // Kid, Teen
expect(result.data.map((i) => i.age).sort()).toEqual([10, 15]);
});
it("should filter by number range (from and to - middle case)", async () => {
// 12 <= age <= 20
const result = await repo.findAll({ age: { from: 12, to: 20 } });
expect(result.data).toHaveLength(1); // Teen
expect(result.data.at(0)?.age).toBe(15);
});
});
describe("Range Filtering - Date", () => {
const d1 = new Date("2023-01-01T10:00:00Z");
const d2 = new Date("2023-01-02T10:00:00Z");
const d3 = new Date("2023-01-03T10:00:00Z");
beforeEach(async () => {
await repo.save({
id: "1",
name: "First",
age: 20,
isActive: true,
createdAt: d1,
});
await repo.save({
id: "2",
name: "Second",
age: 20,
isActive: true,
createdAt: d2,
});
await repo.save({
id: "3",
name: "Third",
age: 20,
isActive: true,
createdAt: d3,
});
});
it("should filter by date range (from only - tail case)", async () => {
// date >= d2
const result = await repo.findAll({ createdAt: { from: d2 } });
expect(result.data).toHaveLength(2); // Second, Third
expect(result.data.map((i) => i.createdAt.getTime()).sort()).toEqual([
d2.getTime(),
d3.getTime(),
]);
});
it("should filter by date range (to only - head case)", async () => {
// date <= d2
const result = await repo.findAll({ createdAt: { to: d2 } });
expect(result.data).toHaveLength(2); // First, Second
expect(result.data.map((i) => i.createdAt.getTime()).sort()).toEqual([
d1.getTime(),
d2.getTime(),
]);
});
it("should filter by date range (from and to - middle case)", async () => {
// d1 <= date <= d2
const result = await repo.findAll({ createdAt: { from: d1, to: d2 } });
expect(result.data).toHaveLength(2); // First, Second
});
});
describe("Pagination with Filtering", () => {
beforeEach(async () => {
// Create 10 items
for (let i = 1; i <= 10; i++) {
await repo.save({
id: i.toString(),
name: i % 2 === 0 ? "Even" : "Odd",
age: i * 10, // 10, 20, ..., 100
isActive: true,
createdAt: new Date(`2023-01-${i.toString().padStart(2, "0")}`),
});
}
});
it("should paginate exact match results", async () => {
// Filter: name = "Even" (5 items: 2, 4, 6, 8, 10)
// Page 1: limit 2 -> [2, 4]
const page1 = await repo.findAll(
{ name: "Even" },
{ offset: 0, limit: 2 },
);
expect(page1.total).toBe(5);
expect(page1.data).toHaveLength(2);
expect(page1.data.at(0)?.id).toBe("2");
expect(page1.data.at(1)?.id).toBe("4");
// Page 2: offset 2, limit 2 -> [6, 8]
const page2 = await repo.findAll(
{ name: "Even" },
{ offset: 2, limit: 2 },
);
expect(page2.data).toHaveLength(2);
expect(page2.data.at(0)?.id).toBe("6");
expect(page2.data.at(1)?.id).toBe("8");
});
it("should paginate number range results", async () => {
// Filter: age >= 50 (6 items: 50, 60, 70, 80, 90, 100)
// Page 1: limit 3 -> [50, 60, 70]
const result = await repo.findAll(
{ age: { from: 50 } },
{ offset: 0, limit: 3 },
);
expect(result.total).toBe(6);
expect(result.data).toHaveLength(3);
expect(result.data.map((i) => i.age)).toEqual([50, 60, 70]);
});
it("should paginate date range results", async () => {
// Filter: date <= 2023-01-05 (5 items: 1, 2, 3, 4, 5)
const targetDate = new Date("2023-01-05");
// Page 2: offset 2, limit 2 -> [3, 4]
const result = await repo.findAll(
{ createdAt: { to: targetDate } },
{ offset: 2, limit: 2 },
);
expect(result.total).toBe(5);
expect(result.data).toHaveLength(2);
expect(result.data.at(0)?.id).toBe("3");
expect(result.data.at(1)?.id).toBe("4");
});
});
describe("Utility Operations", () => {
it("should generate unique IDs", () => {
const ids = new Set<string>();
for (let i = 0; i < 1000; i++) {
ids.add(repo.generateId());
}
expect(ids.size).toBe(1000);
});
});
});

View File

@@ -0,0 +1,102 @@
import { randomUUID } from "node:crypto";
import type {
FilterCriteria,
FilterRange,
IBaseRepository,
PaginationOptions,
WithPagination,
} from "@/shared/core/IBaseRepository.js";
export class InMemoryRepository<T extends { id: string }>
implements IBaseRepository<T>
{
protected items: T[] = [];
async save(entity: T): Promise<T | null> {
const index = this.items.findIndex((item) => item.id === entity.id);
if (index !== -1) {
this.items[index] = entity;
} else {
this.items.push(entity);
}
return entity;
}
async findById(id: string): Promise<T | null> {
return this.items.find((item) => item.id === id) || null;
}
async findOne(criteria: FilterCriteria<T>): Promise<T | null> {
const filtered = this.applyFilters(this.items, criteria);
return filtered[0] || null;
}
async findAll(
criteria?: FilterCriteria<T>,
paginationOptions?: PaginationOptions,
): Promise<WithPagination<T>> {
let filtered = this.items;
if (criteria) {
filtered = this.applyFilters(filtered, criteria);
}
const total = filtered.length;
if (paginationOptions) {
const { offset, limit } = paginationOptions;
filtered = filtered.slice(offset, offset + limit);
}
return {
data: filtered,
total,
};
}
generateId(): string {
return randomUUID();
}
protected applyFilters(items: T[], criteria: FilterCriteria<T>): T[] {
return items.filter((item) => {
return Object.entries(criteria).every(([key, value]) => {
const itemValue = item[key as keyof T];
if (value === undefined) return true;
// Handle Date Range
if (
value &&
typeof value === "object" &&
("from" in value || "to" in value) &&
((value as FilterRange<Date>).from instanceof Date ||
(value as FilterRange<Date>).to instanceof Date) &&
itemValue instanceof Date
) {
const range = value as FilterRange<Date>;
if (range.from && itemValue < range.from) return false;
if (range.to && itemValue > range.to) return false;
return true;
}
// Handle Number Range
if (
value &&
typeof value === "object" &&
(typeof (value as FilterRange<number>).from === "number" ||
typeof (value as FilterRange<number>).to === "number") &&
typeof itemValue === "number"
) {
const range = value as FilterRange<number>;
if (range.from !== undefined && itemValue < range.from) return false;
if (range.to !== undefined && itemValue > range.to) return false;
return true;
}
// Handle Exact Match
return itemValue === value;
});
});
}
}

View File

@@ -0,0 +1,36 @@
import { PrismaPg } from "@prisma/adapter-pg";
import { inject, injectable } from "inversify";
import { uuidv7 } from "uuidv7";
import { PrismaClient as PrismaClientLib } from "@/generated/prisma/client.js";
import type { IConfigService } from "@/shared/application/ports/IConfigService.js";
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
export type PrismaClient = PrismaClientLib;
@injectable()
export class PrismaClientWrapper {
private readonly client: PrismaClientLib;
constructor(
@inject(SharedDomain.IConfigService)
private readonly configService: IConfigService,
) {
const connectionString = this.configService.get("POSTGRES_URL");
const adapter = new PrismaPg({
connectionString,
});
this.client = new PrismaClientLib({
adapter,
});
}
getClient(): PrismaClientLib {
return this.client;
}
generateId(): string {
return uuidv7();
}
}