This commit is contained in:
David Arranz 2024-05-16 20:16:00 +02:00
parent 173ec9e8d8
commit 38ead39cb3
52 changed files with 918 additions and 214 deletions

View File

@ -22,6 +22,7 @@
"@types/express": "^4.17.21",
"@types/express-session": "^1.18.0",
"@types/glob": "^8.1.0",
"@types/http-status": "^1.1.2",
"@types/jest": "^29.5.6",
"@types/jsonwebtoken": "^9.0.6",
"@types/luxon": "^3.3.1",
@ -64,6 +65,7 @@
"express": "^4.18.2",
"express-openapi-validator": "^5.0.4",
"helmet": "^7.0.0",
"http-status": "^1.7.4",
"joi": "^17.12.3",
"joi-phone-number": "^5.1.1",
"jsonwebtoken": "^9.0.2",

View File

@ -4,7 +4,7 @@ module.exports = {
"9d6c903873c341816995a8be0355c6f0d6d471fc6aedacf50790e9b1e49c45b3",
refresh_secret_key:
"3972dc40c69327b65352ed097419213b0b75561169dba562410b85660bb1f305",
token_expiration: "15m",
token_expiration: "5m",
refresh_token_expiration: "7d",
},

View File

@ -9,7 +9,7 @@ import { IRepositoryManager } from "@/contexts/common/domain";
import { IInfrastructureError } from "@/contexts/common/infrastructure";
import { ISequelizeAdapter } from "@/contexts/common/infrastructure/sequelize";
import { Result, ensureUserEmailIsValid } from "@shared/contexts";
import { User } from "../domain";
import { AuthUser } from "../domain";
import { findUserByEmail } from "./authServices";
export interface FindUserByEmailRequest extends IUseCaseRequest {
@ -18,7 +18,7 @@ export interface FindUserByEmailRequest extends IUseCaseRequest {
export type FindUserByEmailResponseOrError =
| Result<never, IUseCaseError>
| Result<User, never>;
| Result<AuthUser, never>;
export class FindUserByEmailUseCase
implements
@ -73,7 +73,7 @@ export class FindUserByEmailUseCase
),
);
}
return Result.ok<User>(user);
return Result.ok<AuthUser>(user);
} catch (error: unknown) {
const _error = error as IInfrastructureError;
return Result.fail(

View File

@ -8,12 +8,12 @@ import { IRepositoryManager } from "@/contexts/common/domain";
import { IInfrastructureError } from "@/contexts/common/infrastructure";
import { ISequelizeAdapter } from "@/contexts/common/infrastructure/sequelize";
import { ILogin_DTO, Result, ensureUserEmailIsValid } from "@shared/contexts";
import { User } from "../domain";
import { AuthUser } from "../domain";
import { findUserByEmail } from "./authServices";
export type LoginResponseOrError =
| Result<never, IUseCaseError>
| Result<User, never>;
| Result<AuthUser, never>;
export class LoginUseCase
implements IUseCase<ILogin_DTO, Promise<LoginResponseOrError>>
@ -64,7 +64,7 @@ export class LoginUseCase
),
);
}
return Result.ok<User>(user);
return Result.ok<AuthUser>(user);
} catch (error: unknown) {
const _error = error as IInfrastructureError;
return Result.fail(

View File

@ -1,13 +1,13 @@
import { IAdapter, RepositoryBuilder } from "@/contexts/common/domain";
import { Email } from "@shared/contexts";
import { User } from "../domain";
import { AuthUser } from "../domain";
import { IAuthRepository } from "../domain/repository";
export const findUserByEmail = async (
email: Email,
adapter: IAdapter,
repository: RepositoryBuilder<IAuthRepository>,
): Promise<User | null> => {
): Promise<AuthUser | null> => {
const user = await adapter
.startTransaction()
.complete(async (t) =>

View File

@ -0,0 +1,115 @@
import bCrypt from "bcryptjs";
import {
AggregateRoot,
Email,
IDomainError,
Name,
Result,
UniqueID,
} from "@shared/contexts";
export interface IAuthUserProps {
name: Name;
email: Email;
password?: string;
hashed_password?: string;
}
export interface IAuthUser {
id: UniqueID;
name: Name;
email: Email;
hashed_password: string;
isUser: boolean;
isAdmin: boolean;
verifyPassword: (candidatePassword: string) => boolean;
}
export class AuthUser
extends AggregateRoot<IAuthUserProps>
implements IAuthUser
{
public static create(
props: IAuthUserProps,
id?: UniqueID,
): Result<AuthUser, IDomainError> {
//const isNew = !!id === false;
// Se hace en el constructor de la Entidad
/* if (isNew) {
id = UniqueEntityID.create();
}*/
const user = new AuthUser(props, id);
return Result.ok<AuthUser>(user);
}
public static async hashPassword(password): Promise<string> {
return hashPassword(password, await genSalt());
}
private _hashed_password: string;
private constructor(props: IAuthUserProps, id?: UniqueID) {
super({ ...props, password: "", hashed_password: "" }, id);
this._protectPassword(props);
}
get name(): Name {
return this.props.name;
}
get email(): Email {
return this.props.email;
}
get hashed_password(): string {
return this._hashed_password;
}
get isUser(): boolean {
return true;
}
get isAdmin(): boolean {
return true;
}
public verifyPassword(candidatePassword: string): boolean {
return bCrypt.compareSync(candidatePassword, this._hashed_password!);
}
private async _protectPassword(props: IAuthUserProps) {
const { password, hashed_password } = props;
if (password) {
this._hashed_password = await AuthUser.hashPassword(password);
} else {
this._hashed_password = hashed_password!;
}
}
}
async function genSalt(rounds = 10): Promise<string> {
return new Promise((resolve, reject) => {
bCrypt.genSalt(rounds, function (err, salt) {
if (err) return reject(err);
return resolve(salt);
});
});
}
async function hashPassword(password: string, salt: string): Promise<string> {
return new Promise((resolve, reject) => {
bCrypt.hash(password, salt, function (err, hash) {
if (err) return reject(err);
return resolve(hash);
});
});
}
AuthUser.hashPassword("123456").then((value) => console.log(value));

View File

@ -1 +1 @@
export * from "./User";
export * from "./AuthUser";

View File

@ -1,7 +1,7 @@
import { IRepository } from "@/contexts/common/domain";
import { Email } from "@shared/contexts";
import { User } from "../entities";
import { AuthUser } from "../entities";
export interface IAuthRepository extends IRepository<any> {
findUserByEmail(email: Email): Promise<User | null>;
findUserByEmail(email: Email): Promise<AuthUser | null>;
}

View File

@ -6,7 +6,7 @@ import {
} from "@/contexts/common/infrastructure/sequelize";
import { Email, ICollection, IQueryCriteria, UniqueID } from "@shared/contexts";
import { Transaction } from "sequelize";
import { User } from "../domain/entities";
import { AuthUser } from "../domain/entities";
import { IAuthRepository } from "../domain/repository/AuthRepository.interface";
import { IUserMapper, createUserMapper } from "./mappers/user.mapper";
@ -16,7 +16,7 @@ export type QueryParams = {
};
export class AuthRepository
extends SequelizeRepository<User>
extends SequelizeRepository<AuthUser>
implements IAuthRepository
{
protected mapper: IUserMapper;
@ -31,8 +31,8 @@ export class AuthRepository
this.mapper = mapper;
}
public async getById(id: UniqueID): Promise<User | null> {
const rawUser: any = await this._getById("User_Model", id);
public async getById(id: UniqueID): Promise<AuthUser | null> {
const rawUser: any = await this._getById("AuthUser_Model", id);
if (!rawUser === true) {
return null;
@ -41,9 +41,9 @@ export class AuthRepository
return this.mapper.mapToDomain(rawUser);
}
public async findUserByEmail(email: Email): Promise<User | null> {
public async findUserByEmail(email: Email): Promise<AuthUser | null> {
const rawUser: any = await this._getBy(
"User_Model",
"AuthUser_Model",
"email",
email.toPrimitive(),
);
@ -59,7 +59,7 @@ export class AuthRepository
queryCriteria?: IQueryCriteria,
): Promise<ICollection<any>> {
const { rows, count } = await this._findAll(
"User_Model",
"AuthUser_Model",
queryCriteria,
/*{
include: [], // esto es para quitar las asociaciones al hacer la consulta

View File

@ -1,5 +1,5 @@
// Import the necessary packages and modules
import { User } from "@/contexts/auth/domain";
import { AuthUser } from "@/contexts/auth/domain";
import { IServerError } from "@/contexts/common/domain/errors";
import { ExpressController } from "@/contexts/common/infrastructure/express";
import passport from "passport";
@ -42,7 +42,7 @@ export class AuthenticateController extends ExpressController {
{ session: false },
(
err: any,
user?: User | false | null,
user?: AuthUser | false | null,
info?: object | string | Array<string | undefined>,
status?: number | Array<number | undefined>,
) => {

View File

@ -1,5 +1,5 @@
import { config } from "@/config";
import { User } from "@/contexts/auth/domain";
import { AuthUser } from "@/contexts/auth/domain";
import { IServerError } from "@/contexts/common/domain/errors";
import {
InfrastructureError,
@ -30,7 +30,7 @@ export class LoginController extends ExpressController {
async executeImpl() {
try {
const user = <User>this.req.user;
const user = <AuthUser>this.req.user;
if (!user) {
const errorMessage = "Unexpected missing user data";
@ -55,13 +55,13 @@ export class LoginController extends ExpressController {
}
}
private _generateUserToken(user: User) {
private _generateUserToken(user: AuthUser) {
return JWT.sign({ email: user.email.toString() }, config.jwt.secret_key, {
expiresIn: config.jwt.token_expiration,
});
}
private _generateUserRefreshToken(user: User) {
private _generateUserRefreshToken(user: AuthUser) {
return JWT.sign(
{ email: user.email.toString() },
config.jwt.refresh_secret_key,

View File

@ -1,9 +1,9 @@
import { IUser } from "@/contexts/auth/domain";
import { IAuthUser } from "@/contexts/auth/domain";
import { IAuthContext } from "@/contexts/auth/infrastructure/Auth.context";
import { ILogin_Response_DTO } from "@shared/contexts";
export interface ILoginUser {
user: IUser;
user: IAuthUser;
token: string;
refreshToken: string;
}

View File

@ -1,40 +1,55 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { AuthUser } from "@/contexts/auth/domain";
import { generateExpressErrorResponse } from "@/contexts/common/infrastructure/express/ExpressErrorResponse";
import Express from "express";
import httpStatus from "http-status";
import passport from "passport";
export const isLoggedUser = passport.authenticate("local-jwt", {
session: false,
});
/*export const authenticate = (
req: Express.Request,
res: Express.Response,
next: Express.NextFunction,
) => {
// Use Passport to authenticate the request using the "jwt" strategy
passport.authenticate(
"local-jwt",
{ session: false },
(
err: any,
user?: User | false | null,
info?: object | string | Array<string | undefined>,
status?: number | Array<number | undefined>,
) => {
console.log(user);
if (err) next(err); // If there's an error, pass it on to the next middleware
if (!user) {
// If the user is not authenticated, send a 401 Unauthorized response
return res.status(401).json({
message: "Unauthorized access. No token provided.",
});
}
// If the user is authenticated, attach the user object to the request and move on to the next middleware
req.user = user;
function compose(middlewareArray: any[]) {
if (!middlewareArray.length) {
return function (
req: Express.Request,
res: Express.Response,
next: Express.NextFunction,
) {
next();
},
)(req, res, next);
};
};
}
const head = middlewareArray[0];
const tail = middlewareArray.slice(1);
*/
return function (
req: Express.Request,
res: Express.Response,
next: Express.NextFunction,
) {
head(req, res, function (err: unknown) {
if (err) return next(err);
compose(tail)(req, res, next);
});
};
}
export const isLoggedUser = compose([
passport.authenticate("local-jwt", {
session: false,
}),
(req: Express.Request, res: Express.Response, next: Express.NextFunction) => {
const user = <AuthUser>req.user;
if (user.isUser) {
return generateExpressErrorResponse(req, res, httpStatus.UNAUTHORIZED);
}
next();
},
]);
export const isAdminUser = compose([
isLoggedUser,
(req: Express.Request, res: Express.Response, next: Express.NextFunction) => {
const user = <AuthUser>req.user;
if (!user.isAdmin) {
return generateExpressErrorResponse(req, res, httpStatus.UNAUTHORIZED);
}
next();
},
]);

View File

@ -4,7 +4,7 @@ import { ensureLogin_DTOIsValid } from "@shared/contexts";
import { Strategy as EmailStrategy, IVerifyOptions } from "passport-local";
import { LoginUseCase } from "@/contexts/auth/application";
import { User } from "@/contexts/auth/domain";
import { AuthUser } from "@/contexts/auth/domain";
import { IAuthContext } from "../../Auth.context";
import { registerAuthRepository } from "../../Auth.repository";
@ -33,7 +33,11 @@ class EmailStrategyController extends PassportStrategyController {
public async verifyStrategy(
email: string,
password: string,
done: (error: any, user?: User | false, options?: IVerifyOptions) => void,
done: (
error: any,
user?: AuthUser | false,
options?: IVerifyOptions,
) => void,
) {
const loginDTOOrError = ensureLogin_DTOIsValid({ email, password });

View File

@ -49,5 +49,20 @@ export const AuthRouter = (appRouter: Express.Router) => {
createLoginController(res.locals["context"]).execute(req, res, next),
);
authRoutes.post(
"/logout",
isLoggedUser,
(
req: Express.Request,
res: Express.Response,
next: Express.NextFunction,
) => {
//req.logout(); <-- ??
return res.status(200).json();
},
);
authRoutes.post("/register");
appRouter.use("/auth", authRoutes);
};

View File

@ -3,30 +3,37 @@ import {
SequelizeMapper,
} from "@/contexts/common/infrastructure";
import { Email, Name, UniqueID } from "@shared/contexts";
import { IUserProps, User } from "../../domain/entities";
import { AuthUser, IAuthUserProps } from "../../domain/entities";
import { IAuthContext } from "../Auth.context";
import { TCreationUser_Attributes, User_Model } from "../sequelize/user.model";
import {
AuthUser_Model,
TCreationUser_Attributes,
} from "../sequelize/authUser.model";
export interface IUserMapper
extends ISequelizeMapper<User_Model, TCreationUser_Attributes, User> {}
extends ISequelizeMapper<
AuthUser_Model,
TCreationUser_Attributes,
AuthUser
> {}
class UserMapper
extends SequelizeMapper<User_Model, TCreationUser_Attributes, User>
extends SequelizeMapper<AuthUser_Model, TCreationUser_Attributes, AuthUser>
implements IUserMapper
{
public constructor(props: { context: IAuthContext }) {
super(props);
}
protected toDomainMappingImpl(source: User_Model, params: any): User {
const props: IUserProps = {
protected toDomainMappingImpl(source: AuthUser_Model, params: any): AuthUser {
const props: IAuthUserProps = {
name: this.mapsValue(source, "name", Name.create),
email: this.mapsValue(source, "email", Email.create),
hashed_password: source.password,
};
const id = this.mapsValue(source, "id", UniqueID.create);
const userOrError = User.create(props, id);
const userOrError = AuthUser.create(props, id);
if (userOrError.isFailure) {
throw userOrError.error;
@ -36,7 +43,7 @@ class UserMapper
}
protected toPersistenceMappingImpl(
source: User,
source: AuthUser,
params?: Record<string, any> | undefined,
) {
return {

View File

@ -6,11 +6,11 @@ import {
Sequelize,
} from "sequelize";
export type TCreationUser_Attributes = InferCreationAttributes<User_Model>;
export type TCreationUser_Attributes = InferCreationAttributes<AuthUser_Model>;
export class User_Model extends Model<
InferAttributes<User_Model>,
InferCreationAttributes<User_Model>
export class AuthUser_Model extends Model<
InferAttributes<AuthUser_Model>,
InferCreationAttributes<AuthUser_Model>
> {
// To avoid table creation
/*static async sync(): Promise<any> {
@ -26,7 +26,7 @@ export class User_Model extends Model<
}
export default (sequelize: Sequelize) => {
User_Model.init(
AuthUser_Model.init(
{
id: {
type: new DataTypes.UUID(),
@ -63,5 +63,5 @@ export default (sequelize: Sequelize) => {
},
);
return User_Model;
return AuthUser_Model;
};

View File

@ -1 +1 @@
export * from "./user.model";
export * from "./authUser.model";

View File

@ -5,8 +5,8 @@ import {
import { ICollection, IQueryCriteria, UniqueID } from "@shared/contexts";
import { Transaction } from "sequelize";
import { ICatalogContext } from ".";
import { ICatalogRepository } from "../domain";
import { Article } from "../domain/entities";
import { ICatalogRepository } from "../domain/repository/CatalogRepository.interface";
import { IArticleMapper, createArticleMapper } from "./mappers/article.mapper";
export type QueryParams = {

View File

@ -5,12 +5,11 @@ import { ListArticlesController } from "./ListArticlesController";
import { listArticlesPresenter } from "./presenter";
export const createListArticlesController = (context: ICatalogContext) => {
const listArticlesUseCase = new ListArticlesUseCase(context);
registerCatalogRepository(context);
return new ListArticlesController(
{
useCase: listArticlesUseCase,
useCase: new ListArticlesUseCase(context),
presenter: listArticlesPresenter,
},
context,

View File

@ -9,7 +9,7 @@ import {
export interface IListArticlesPresenter {
map: (
article: Article,
context: ICatalogContext
context: ICatalogContext,
) => IListArticles_Response_DTO;
mapArray: (
@ -18,17 +18,15 @@ export interface IListArticlesPresenter {
params: {
page: number;
limit: number;
}
},
) => IListResponse_DTO<IListArticles_Response_DTO>;
}
export const listArticlesPresenter: IListArticlesPresenter = {
map: (
article: Article,
context: ICatalogContext
context: ICatalogContext,
): IListArticles_Response_DTO => {
console.time("listArticlesPresenter.map");
const result: IListArticles_Response_DTO = {
id: article.id.toString(),
catalog_name: article.catalog_name.toString(),
@ -40,9 +38,6 @@ export const listArticlesPresenter: IListArticlesPresenter = {
points: article.points.toNumber(),
retail_price: article.retail_price.toObject(),
};
console.timeEnd("listArticlesPresenter.map");
return result;
},
@ -52,15 +47,13 @@ export const listArticlesPresenter: IListArticlesPresenter = {
params: {
page: number;
limit: number;
}
},
): IListResponse_DTO<IListArticles_Response_DTO> => {
console.time("listArticlesPresenter.mapArray");
const { page, limit } = params;
const totalCount = articles.totalCount ?? 0;
const items = articles.items.map((article: Article) =>
listArticlesPresenter.map(article, context)
listArticlesPresenter.map(article, context),
);
const result = {
@ -71,8 +64,6 @@ export const listArticlesPresenter: IListArticlesPresenter = {
items,
};
console.timeEnd("listArticlesPresenter.mapArray");
return result;
},
};

View File

@ -1,15 +1,11 @@
import { IError_Response_DTO } from "@shared/contexts";
import * as express from "express";
import httpStatus from "http-status";
import { URL } from "url";
import {
IErrorExtra_Response_DTO,
IError_Response_DTO,
} from "@shared/contexts";
import { UseCaseError } from "../../application";
import { IServerError } from "../../domain/errors";
import { IController } from "../Controller.interface";
import { InfrastructureError } from "../InfrastructureError";
import { ProblemDocument, ProblemDocumentExtension } from "./ProblemDocument";
import { generateExpressErrorResponse } from "./ExpressErrorResponse";
export abstract class ExpressController implements IController {
protected req: express.Request;
@ -55,19 +51,22 @@ export abstract class ExpressController implements IController {
console.trace("Show me");
console.groupEnd();
return this._errorResponse(500, error ? error.toString() : "Fail");
return this._errorResponse(
httpStatus.INTERNAL_SERVER_ERROR,
error ? error.toString() : "Fail",
);
}
public created<T>(dto?: T) {
if (dto) {
return this.res.status(201).json(dto).send();
return this.res.status(httpStatus.CREATED).json(dto).send();
}
return this.res.status(201).send();
return this.res.status(httpStatus.CREATED).send();
}
public noContent() {
return this.res.status(204).send();
return this.res.status(httpStatus.NO_CONTENT).send();
}
public download(filepath: string, filename: string, done?: any) {
@ -75,47 +74,51 @@ export abstract class ExpressController implements IController {
}
public clientError(message?: string) {
return this._errorResponse(400, message);
return this._errorResponse(httpStatus.BAD_REQUEST, message);
}
public unauthorizedError(message?: string) {
return this._errorResponse(401, message);
return this._errorResponse(httpStatus.UNAUTHORIZED, message);
}
public paymentRequiredError(message?: string) {
return this._errorResponse(402, message);
return this._errorResponse(httpStatus.PAYMENT_REQUIRED, message);
}
public forbiddenError(message?: string) {
return this._errorResponse(403, message);
return this._errorResponse(httpStatus.FORBIDDEN, message);
}
public notFoundError(message: string, error?: IServerError) {
return this._errorResponse(404, message, error);
return this._errorResponse(httpStatus.NOT_FOUND, message, error);
}
public conflictError(message: string, error?: IServerError) {
return this._errorResponse(409, message, error);
return this._errorResponse(httpStatus.CONFLICT, message, error);
}
public invalidInputError(message?: string, error?: InfrastructureError) {
return this._errorResponse(422, message, error);
return this._errorResponse(httpStatus.UNPROCESSABLE_ENTITY, message, error);
}
public tooManyError(message: string, error?: Error) {
return this._errorResponse(429, message, error);
return this._errorResponse(httpStatus.TOO_MANY_REQUESTS, message, error);
}
public internalServerError(message?: string, error?: IServerError) {
return this._errorResponse(500, message, error);
return this._errorResponse(
httpStatus.INTERNAL_SERVER_ERROR,
message,
error,
);
}
public todoError(message?: string) {
return this._errorResponse(501, message);
return this._errorResponse(httpStatus.NOT_IMPLEMENTED, message);
}
public unavailableError(message?: string) {
return this._errorResponse(503, message);
return this._errorResponse(httpStatus.SERVICE_UNAVAILABLE, message);
}
private _jsonResponse(
@ -130,102 +133,12 @@ export abstract class ExpressController implements IController {
message?: string,
error?: Error | InfrastructureError,
): express.Response<IError_Response_DTO> {
const context = {};
if (Object.keys(this.res.locals).length) {
if ("user" in this.res.locals) {
context["user"] = this.res.locals.user;
}
}
if (Object.keys(this.req.params).length) {
context["params"] = this.req.params;
}
if (Object.keys(this.req.query).length) {
context["query"] = this.req.query;
}
if (Object.keys(this.req.body).length) {
context["body"] = this.req.body;
}
const extension = new ProblemDocumentExtension({
context,
extra: error ? { ...this._processError(error) } : {},
});
return this._jsonResponse(
return generateExpressErrorResponse(
this.req,
this.res,
statusCode,
new ProblemDocument(
{
status: statusCode,
detail: message,
instance: this.req.baseUrl,
},
extension,
),
message,
error,
);
}
private _processError(
error: Error | InfrastructureError,
): IErrorExtra_Response_DTO {
/**
*
*
*
{
code: "INVALID_INPUT_DATA",
payload: {
label: "tin",
path: "tin", // [{path: "first_name"}, {path: "last_name"}]
},
name: "UseCaseError",
}
{
code: "INVALID_INPUT_DATA",
payload: [
{
tin: "{tin} is not allowed to be empty",
},
{
first_name: "{first_name} is not allowed to be empty",
},
{
last_name: "{last_name} is not allowed to be empty",
},
{
company_name: "{company_name} is not allowed to be empty",
},
],
name: "InfrastructureError",
}
*/
const useCaseError = <UseCaseError>error;
const payload = !Array.isArray(useCaseError.payload)
? Array(useCaseError.payload)
: useCaseError.payload;
const errors = payload.map((item) => {
if (item.path) {
return item.path
? {
[String(item.path)]: useCaseError.message,
}
: {};
} else {
return item;
}
});
return {
errors,
};
}
}

View File

@ -0,0 +1,78 @@
import {
IErrorExtra_Response_DTO,
IError_Response_DTO,
} from "@shared/contexts";
import Express from "express";
import { UseCaseError } from "../../application";
import { InfrastructureError } from "../InfrastructureError";
import { ProblemDocument, ProblemDocumentExtension } from "./ProblemDocument";
export const generateExpressErrorResponse = (
req: Express.Request,
res: Express.Response,
statusCode: number,
message?: string,
error?: Error | InfrastructureError,
): Express.Response<IError_Response_DTO> => {
const context = {};
if (Object.keys(res.locals).length) {
if ("user" in res.locals) {
context["user"] = res.locals.user;
}
}
if (Object.keys(req.params).length) {
context["params"] = req.params;
}
if (Object.keys(req.query).length) {
context["query"] = req.query;
}
if (Object.keys(req.body).length) {
context["body"] = req.body;
}
const extension = new ProblemDocumentExtension({
context,
extra: error ? { ...generateExpressError(error) } : {},
});
const jsonPayload = new ProblemDocument(
{
status: statusCode,
detail: message,
instance: req.baseUrl,
},
extension,
);
return res.status(statusCode).json(jsonPayload).send();
};
function generateExpressError(
error: Error | InfrastructureError,
): IErrorExtra_Response_DTO {
const useCaseError = <UseCaseError>error;
const payload = !Array.isArray(useCaseError.payload)
? Array(useCaseError.payload)
: useCaseError.payload;
const errors = payload.map((item) => {
if (item.path) {
return item.path
? {
[String(item.path)]: useCaseError.message,
}
: {};
} else {
return item;
}
});
return {
errors,
};
}

View File

@ -0,0 +1,76 @@
import {
IUseCase,
IUseCaseError,
UseCaseError,
handleUseCaseError,
} from "@/contexts/common/application/useCases";
import { IRepositoryManager } from "@/contexts/common/domain";
import {
Collection,
ICollection,
IQueryCriteria,
Result,
} from "@shared/contexts";
import { IInfrastructureError } from "@/contexts/common/infrastructure";
import { ISequelizeAdapter } from "@/contexts/common/infrastructure/sequelize";
import { User } from "../domain";
import { IUserRepository } from "../domain/repository";
export interface IListUsersParams {
queryCriteria: IQueryCriteria;
}
export type ListUsersResult =
| Result<never, IUseCaseError> // Misc errors (value objects)
| Result<ICollection<User>, never>; // Success!
export class ListUsersUseCase
implements IUseCase<IListUsersParams, Promise<ListUsersResult>>
{
private _adapter: ISequelizeAdapter;
private _repositoryManager: IRepositoryManager;
constructor(props: {
adapter: ISequelizeAdapter;
repositoryManager: IRepositoryManager;
}) {
this._adapter = props.adapter;
this._repositoryManager = props.repositoryManager;
}
private getRepositoryByName<T>(name: string) {
return this._repositoryManager.getRepository<T>(name);
}
async execute(params: Partial<IListUsersParams>): Promise<ListUsersResult> {
const { queryCriteria } = params;
return this.findUsers(queryCriteria);
}
private async findUsers(queryCriteria) {
const transaction = this._adapter.startTransaction();
const userRepoBuilder = this.getRepositoryByName<IUserRepository>("User");
let users: ICollection<User> = new Collection();
try {
await transaction.complete(async (t) => {
users = await userRepoBuilder({ transaction: t }).findAll(
queryCriteria,
);
});
return Result.ok(users);
} catch (error: unknown) {
const _error = error as IInfrastructureError;
return Result.fail(
handleUseCaseError(
UseCaseError.REPOSITORY_ERROR,
"Error al listar los usurios",
_error,
),
);
}
}
}

View File

@ -0,0 +1 @@
export * from "./ListUsersUseCase";

View File

@ -21,6 +21,8 @@ export interface IUser {
name: Name;
email: Email;
hashed_password: string;
isUser: boolean;
isAdmin: boolean;
verifyPassword: (candidatePassword: string) => boolean;
}
@ -66,6 +68,14 @@ export class User extends AggregateRoot<IUserProps> implements IUser {
return this._hashed_password;
}
get isUser(): boolean {
return true;
}
get isAdmin(): boolean {
return true;
}
public verifyPassword(candidatePassword: string): boolean {
return bCrypt.compareSync(candidatePassword, this._hashed_password!);
}

View File

@ -0,0 +1 @@
export * from "./User";

View File

@ -0,0 +1,2 @@
export * from "./entities";
export * from "./repository";

View File

@ -0,0 +1,8 @@
import { IRepository } from "@/contexts/common/domain";
import { ICollection, IQueryCriteria, UniqueID } from "@shared/contexts";
import { User } from "../entities";
export interface IUserRepository extends IRepository<any> {
getById(id: UniqueID): Promise<User | null>;
findAll(queryCriteria?: IQueryCriteria): Promise<ICollection<User>>;
}

View File

@ -0,0 +1 @@
export * from "./UserRepository.interface";

View File

@ -0,0 +1 @@
export * from "./infrastructure";

View File

@ -0,0 +1,35 @@
import {
IRepositoryManager,
RepositoryManager,
} from "@/contexts/common/domain";
import {
ISequelizeAdapter,
createSequelizeAdapter,
} from "@/contexts/common/infrastructure/sequelize";
export interface IUserContext {
adapter: ISequelizeAdapter;
repositoryManager: IRepositoryManager;
//services: IApplicationService;
}
export class UserContext {
private static instance: UserContext | null = null;
public static getInstance(): IUserContext {
if (!UserContext.instance) {
UserContext.instance = new UserContext({
adapter: createSequelizeAdapter(),
repositoryManager: RepositoryManager.getInstance(),
});
}
return UserContext.instance.context;
}
private context: IUserContext;
private constructor(context: IUserContext) {
this.context = context;
}
}

View File

@ -0,0 +1,85 @@
import {
ISequelizeAdapter,
SequelizeRepository,
} from "@/contexts/common/infrastructure/sequelize";
import { Email, ICollection, IQueryCriteria, UniqueID } from "@shared/contexts";
import { Transaction } from "sequelize";
import { User } from "../domain";
import { IUserRepository } from "../domain/repository";
import { IUserContext } from "./User.context";
import { IUserMapper, createUserMapper } from "./mappers";
export type QueryParams = {
pagination: Record<string, any>;
filters: Record<string, any>;
};
export class UserRepository
extends SequelizeRepository<User>
implements IUserRepository
{
protected mapper: IUserMapper;
public constructor(props: {
mapper: IUserMapper;
adapter: ISequelizeAdapter;
transaction: Transaction;
}) {
const { adapter, mapper, transaction } = props;
super({ adapter, transaction });
this.mapper = mapper;
}
public async getById(id: UniqueID): Promise<User | null> {
const rawUser: any = await this._getById("User_Model", id);
if (!rawUser === true) {
return null;
}
return this.mapper.mapToDomain(rawUser);
}
public async findUserByEmail(email: Email): Promise<User | null> {
const rawUser: any = await this._getBy(
"User_Model",
"email",
email.toPrimitive(),
);
if (!rawUser === true) {
return null;
}
return this.mapper.mapToDomain(rawUser);
}
public async findAll(
queryCriteria?: IQueryCriteria,
): Promise<ICollection<any>> {
const { rows, count } = await this._findAll(
"User_Model",
queryCriteria,
/*{
include: [], // esto es para quitar las asociaciones al hacer la consulta
}*/
);
return this.mapper.mapArrayAndCountToDomain(rows, count);
}
}
export const registerUserRepository = (context: IUserContext) => {
const adapter = context.adapter;
const repoManager = context.repositoryManager;
repoManager.registerRepository("User", (params = { transaction: null }) => {
const { transaction } = params;
return new UserRepository({
transaction,
adapter,
mapper: createUserMapper(context),
});
});
};

View File

@ -0,0 +1 @@
export * from "./listUsers";

View File

@ -0,0 +1,88 @@
import Joi from "joi";
import { QueryCriteriaService } from "@/contexts/common/application/services";
import { IServerError } from "@/contexts/common/domain/errors";
import { ExpressController } from "@/contexts/common/infrastructure/express";
import {
ListUsersResult,
ListUsersUseCase,
} from "@/contexts/users/application";
import { User } from "@/contexts/users/domain";
import {
ICollection,
IListResponse_DTO,
IListUsers_Response_DTO,
IQueryCriteria,
Result,
RuleValidator,
} from "@shared/contexts";
import { IUserContext } from "../../../User.context";
import { IListUsersPresenter } from "./presenter";
export class ListUsersController extends ExpressController {
private useCase: ListUsersUseCase;
private presenter: IListUsersPresenter;
private context: IUserContext;
constructor(
props: {
useCase: ListUsersUseCase;
presenter: IListUsersPresenter;
},
context: IUserContext,
) {
super();
const { useCase, presenter } = props;
this.useCase = useCase;
this.presenter = presenter;
this.context = context;
}
protected validateQuery(query): Result<any> {
const schema = Joi.object({
page: Joi.number().optional(),
limit: Joi.number().optional(),
$sort_by: Joi.string().optional(),
$filters: Joi.string().optional(),
q: Joi.string().optional(),
}).optional();
return RuleValidator.validate(schema, query);
}
async executeImpl() {
const queryOrError = this.validateQuery(this.req.query);
if (queryOrError.isFailure) {
return this.clientError(queryOrError.error.message);
}
const queryParams = queryOrError.object;
try {
const queryCriteria: IQueryCriteria =
QueryCriteriaService.parse(queryParams);
console.log(queryCriteria);
const result: ListUsersResult = await this.useCase.execute({
queryCriteria,
});
if (result.isFailure) {
return this.clientError(result.error.message);
}
const customers = <ICollection<User>>result.object;
return this.ok<IListResponse_DTO<IListUsers_Response_DTO>>(
this.presenter.mapArray(customers, this.context, {
page: queryCriteria.pagination.offset,
limit: queryCriteria.pagination.limit,
}),
);
} catch (e: unknown) {
return this.fail(e as IServerError);
}
}
}

View File

@ -0,0 +1,16 @@
import { ListUsersUseCase } from "@/contexts/users/application";
import { IUserContext } from "../../../User.context";
import { registerUserRepository } from "../../../User.repository";
import { ListUsersController } from "./ListUsers.controller";
import { listUsersPresenter } from "./presenter";
export const createListUsersController = (context: IUserContext) => {
registerUserRepository(context);
return new ListUsersController(
{
useCase: new ListUsersUseCase(context),
presenter: listUsersPresenter,
},
context,
);
};

View File

@ -0,0 +1,57 @@
import { User } from "@/contexts/users/domain";
import { IUserContext } from "@/contexts/users/infrastructure/User.context";
import {
ICollection,
IListResponse_DTO,
IListUsers_Response_DTO,
} from "@shared/contexts";
export interface IListUsersPresenter {
map: (user: User, context: IUserContext) => IListUsers_Response_DTO;
mapArray: (
users: ICollection<User>,
context: IUserContext,
params: {
page: number;
limit: number;
},
) => IListResponse_DTO<IListUsers_Response_DTO>;
}
export const listUsersPresenter: IListUsersPresenter = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
map: (user: User, context: IUserContext): IListUsers_Response_DTO => {
return {
id: user.id.toString(),
name: user.name.toString(),
email: user.email.toString(),
};
},
mapArray: (
users: ICollection<User>,
context: IUserContext,
params: {
page: number;
limit: number;
},
): IListResponse_DTO<IListUsers_Response_DTO> => {
const { page, limit } = params;
const totalCount = users.totalCount ?? 0;
const items = users.items.map((user: User) =>
listUsersPresenter.map(user, context),
);
const result = {
page,
per_page: limit,
total_pages: Math.ceil(totalCount / limit),
total_items: totalCount,
items,
};
return result;
},
};

View File

@ -0,0 +1 @@
export * from "./ListUsers.presenter";

View File

@ -0,0 +1,2 @@
export * from "./controllers";
export * from "./routes";

View File

@ -0,0 +1,23 @@
import { applyMiddleware } from "@/contexts/common/infrastructure/express";
import Express from "express";
import { createListUsersController } from "./controllers";
export const UserRouter = (appRouter: Express.Router) => {
const userRoutes: Express.Router = Express.Router({ mergeParams: true });
userRoutes.get(
"/",
applyMiddleware("isAdminUser"),
(req: Express.Request, res: Express.Response, next: Express.NextFunction) =>
createListUsersController(res.locals["context"]).execute(req, res, next),
);
/*userRoutes.get(
"/:id",
applyMiddleware("isAdminUser"),
(req: Express.Request, res: Express.Response, next: Express.NextFunction) =>
createGettUserController(res.locals["context"]).execute(req, res, next),
);*/
appRouter.use("/users", userRoutes);
};

View File

@ -0,0 +1,2 @@
export * from "./express";
export * from "./sequelize";

View File

@ -0,0 +1 @@
export * from "./user.mapper";

View File

@ -0,0 +1,54 @@
import {
ISequelizeMapper,
SequelizeMapper,
} from "@/contexts/common/infrastructure";
import { Email, Name, UniqueID } from "@shared/contexts";
import { IUserProps, User } from "../../domain";
import { IUserContext } from "../User.context";
import { TCreationUser_Attributes, User_Model } from "../sequelize/user.model";
export interface IUserMapper
extends ISequelizeMapper<User_Model, TCreationUser_Attributes, User> {}
class UserMapper
extends SequelizeMapper<User_Model, TCreationUser_Attributes, User>
implements IUserMapper
{
public constructor(props: { context: IUserContext }) {
super(props);
}
protected toDomainMappingImpl(source: User_Model, params: any): User {
const props: IUserProps = {
name: this.mapsValue(source, "name", Name.create),
email: this.mapsValue(source, "email", Email.create),
hashed_password: source.password,
};
const id = this.mapsValue(source, "id", UniqueID.create);
const userOrError = User.create(props, id);
if (userOrError.isFailure) {
throw userOrError.error;
}
return userOrError.object;
}
protected toPersistenceMappingImpl(
source: User,
params?: Record<string, any> | undefined,
) {
return {
id: source.id.toPrimitive(),
name: source.name.toPrimitive(),
email: source.email.toPrimitive(),
password: source.hashed_password,
};
}
}
export const createUserMapper = (context: IUserContext): IUserMapper =>
new UserMapper({
context,
});

View File

@ -0,0 +1 @@
export * from "./user.model";

View File

@ -0,0 +1,86 @@
import {
DataTypes,
InferAttributes,
InferCreationAttributes,
Model,
Op,
Sequelize,
} from "sequelize";
export type TCreationUser_Attributes = InferCreationAttributes<User_Model>;
export class User_Model extends Model<
InferAttributes<User_Model>,
InferCreationAttributes<User_Model>
> {
// To avoid table creation
/*static async sync(): Promise<any> {
return Promise.resolve();
}*/
static associate(connection: Sequelize) {}
declare id: string;
declare name: string;
declare email: string;
declare password: string;
}
export default (sequelize: Sequelize) => {
User_Model.init(
{
id: {
type: new DataTypes.UUID(),
primaryKey: true,
},
name: {
type: DataTypes.STRING,
},
email: {
type: DataTypes.STRING,
allowNull: false,
},
password: {
type: DataTypes.STRING,
allowNull: false,
},
},
{
sequelize,
tableName: "users",
paranoid: true, // softs deletes
timestamps: true,
//version: true,
createdAt: "created_at",
updatedAt: "updated_at",
deletedAt: "deleted_at",
indexes: [{ name: "email_idx", fields: ["email"] }],
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope
scopes: {
quickSearch(value) {
return {
where: {
[Op.or]: {
name: {
[Op.like]: `%${value}%`,
},
email: {
[Op.like]: `%${value}%`,
},
},
},
};
},
},
},
);
return User_Model;
};

View File

@ -2,6 +2,7 @@ import { AuthRouter } from "@/contexts/auth";
import { CatalogRouter } from "@/contexts/catalog";
import { RepositoryManager } from "@/contexts/common/domain";
import { createSequelizeAdapter } from "@/contexts/common/infrastructure/sequelize";
import { UserRouter } from "@/contexts/users";
import Express from "express";
export const v1Routes = () => {
@ -39,6 +40,7 @@ export const v1Routes = () => {
});
AuthRouter(routes);
UserRouter(routes);
CatalogRouter(routes);
return routes;

View File

@ -1,3 +1,5 @@
export * from "./common";
export * from "./auth";
export * from "./catalog";
export * from "./common";
export * from "./users";

View File

@ -0,0 +1,5 @@
export interface IListUsers_Response_DTO {
id: string;
name: string;
email: string;
}

View File

@ -0,0 +1 @@
export * from "./IListUsers_Response.dto";

View File

@ -0,0 +1 @@
export * from "./IListUsers.dto";

View File

@ -0,0 +1 @@
export * from "./dto";

View File

@ -0,0 +1 @@
export * from "./application";