Enviar una incidencia

This commit is contained in:
David Arranz 2024-10-01 12:21:08 +02:00
parent ab9f7ea401
commit d69c8b3831
91 changed files with 1063 additions and 603 deletions

View File

@ -1,4 +1,4 @@
<!doctype html>
<!DOCTYPE html>
<html>
<head>

View File

@ -68,7 +68,6 @@
"react-resizable-panels": "^2.0.23",
"react-router-dom": "^6.26.0",
"react-secure-storage": "^1.3.2",
"react-toastify": "^10.0.5",
"react-wrap-balancer": "^1.1.1",
"recharts": "^2.12.7",
"slugify": "^1.6.6",

View File

@ -3,7 +3,6 @@ import { Toaster, TooltipProvider } from "@/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { Suspense } from "react";
import "react-toastify/dist/ReactToastify.css";
import { Routes } from "./Routes";
import { LoadingOverlay, TailwindIndicator } from "./components";
import { createAxiosAuthActions, createAxiosDataProvider } from "./lib/axios";
@ -20,25 +19,26 @@ function App() {
});
return (
<QueryClientProvider client={queryClient}>
<DataSourceProvider dataSource={createAxiosDataProvider(import.meta.env.VITE_API_URL)}>
<AuthProvider authActions={createAxiosAuthActions(import.meta.env.VITE_API_URL)}>
<ThemeProvider defaultTheme='light' storageKey='vite-ui-theme'>
<TooltipProvider delayDuration={0}>
<UnsavedWarnProvider>
<Suspense fallback={<LoadingOverlay />}>
<Routes />
<Toaster />
</Suspense>
</UnsavedWarnProvider>
</TooltipProvider>
<TailwindIndicator />
<ReactQueryDevtools initialIsOpen={false} />
</ThemeProvider>
</AuthProvider>
</DataSourceProvider>
</QueryClientProvider>
<>
<QueryClientProvider client={queryClient}>
<DataSourceProvider dataSource={createAxiosDataProvider(import.meta.env.VITE_API_URL)}>
<AuthProvider authActions={createAxiosAuthActions(import.meta.env.VITE_API_URL)}>
<ThemeProvider defaultTheme='light' storageKey='vite-ui-theme'>
<TooltipProvider delayDuration={0}>
<UnsavedWarnProvider>
<Suspense fallback={<LoadingOverlay />}>
<Routes />
</Suspense>
</UnsavedWarnProvider>
</TooltipProvider>
<Toaster />
<TailwindIndicator />
<ReactQueryDevtools initialIsOpen={false} />
</ThemeProvider>
</AuthProvider>
</DataSourceProvider>
</QueryClientProvider>
</>
);
}

View File

@ -13,7 +13,7 @@ import {
} from "./app";
import { CatalogLayout, CatalogList } from "./app/catalog";
import { DashboardPage } from "./app/dashboard";
import { QuotesLayout } from "./app/quotes/layout";
import { QuotesLayout } from "./app/quotes";
import { QuotesList } from "./app/quotes/list";
import { ProtectedRoute } from "./components";
@ -46,11 +46,9 @@ export const Routes = () => {
{
path: "/catalog",
element: (
<ProtectedRoute>
<CatalogLayout>
<Outlet />
</CatalogLayout>
</ProtectedRoute>
<CatalogLayout>
<Outlet />
</CatalogLayout>
),
children: [
{
@ -62,11 +60,9 @@ export const Routes = () => {
{
path: "/dealers",
element: (
<ProtectedRoute>
<DealerLayout>
<Outlet />
</DealerLayout>
</ProtectedRoute>
<DealerLayout>
<Outlet />
</DealerLayout>
),
children: [
{
@ -77,7 +73,11 @@ export const Routes = () => {
},
{
path: "/quotes",
element: <QuotesLayout />,
element: (
<QuotesLayout>
<Outlet />
</QuotesLayout>
),
children: [
{
index: true,
@ -96,11 +96,9 @@ export const Routes = () => {
{
path: "/settings",
element: (
<ProtectedRoute>
<SettingsLayout>
<Outlet />
</SettingsLayout>
</ProtectedRoute>
<SettingsLayout>
<Outlet />
</SettingsLayout>
),
children: [
{

View File

@ -1,14 +1,16 @@
import { Layout, LayoutContent, LayoutHeader } from "@/components";
import { Layout, LayoutContent, LayoutHeader, ProtectedRoute } from "@/components";
import { PropsWithChildren } from "react";
import { CatalogProvider } from "./CatalogContext";
export const CatalogLayout = ({ children }: PropsWithChildren) => {
return (
<CatalogProvider>
<Layout>
<LayoutHeader />
<LayoutContent>{children}</LayoutContent>
</Layout>
</CatalogProvider>
<ProtectedRoute>
<CatalogProvider>
<Layout className='catalog-layout'>
<LayoutHeader />
<LayoutContent>{children}</LayoutContent>
</Layout>
</CatalogProvider>
</ProtectedRoute>
);
};

View File

@ -1,11 +1,13 @@
import { Layout, LayoutContent, LayoutHeader } from "@/components";
import { Layout, LayoutContent, LayoutHeader, ProtectedRoute } from "@/components";
import { PropsWithChildren } from "react";
export const DealerLayout = ({ children }: PropsWithChildren) => {
return (
<Layout>
<LayoutHeader />
<LayoutContent>{children}</LayoutContent>
</Layout>
<ProtectedRoute>
<Layout className='dealers-layout'>
<LayoutHeader />
<LayoutContent>{children}</LayoutContent>
</Layout>
</ProtectedRoute>
);
};

View File

@ -11,13 +11,13 @@ import { t } from "i18next";
import { SubmitButton } from "@/components";
import { useUnsavedChangesNotifier } from "@/lib/hooks";
import { Button, Form, Separator } from "@/ui";
import { useToast } from "@/ui/use-toast";
import { joiResolver } from "@hookform/resolvers/joi";
import { ICreateQuote_Request_DTO } from "@shared/contexts";
import Joi from "joi";
import { useMemo } from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import SpanishJoiMessages from "../../spanish-joi-messages.json";
import { useQuotes } from "./hooks";
@ -25,6 +25,7 @@ interface QuoteDataForm extends ICreateQuote_Request_DTO {}
export const QuoteCreate = () => {
const navigate = useNavigate();
const { toast } = useToast();
const { useCreate } = useQuotes();
const { mutate, isPending } = useCreate();
@ -67,11 +68,18 @@ export const QuoteCreate = () => {
mutate(formData, {
onError: (error) => {
console.debug(error);
toast.error(error.message);
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
},
onSuccess: (data) => {
reset(getValues());
toast.success("Cotización guardada");
toast({
title: "Cotización guardada",
className: "bg-green-300",
});
navigate(`/quotes/edit/${data.id}`, { relative: "path" });
},
});

View File

@ -1,3 +1,4 @@
export * from "./create";
export * from "./edit";
export * from "./layout";
export * from "./list";

View File

@ -1,16 +1,14 @@
import { Layout, LayoutContent, LayoutHeader, ProtectedRoute } from "@/components";
import { Outlet } from "react-router-dom";
import { PropsWithChildren } from "react";
import { QuotesProvider } from "./QuotesContext";
export const QuotesLayout = () => {
export const QuotesLayout = ({ children }: PropsWithChildren) => {
return (
<ProtectedRoute>
<QuotesProvider>
<Layout>
<Layout className='quotes-layout'>
<LayoutHeader />
<LayoutContent>
<Outlet />
</LayoutContent>
<LayoutContent>{children}</LayoutContent>
</Layout>
</QuotesProvider>
</ProtectedRoute>

View File

@ -1,22 +1,24 @@
import { Layout, LayoutContent, LayoutHeader } from "@/components";
import { Layout, LayoutContent, LayoutHeader, ProtectedRoute } from "@/components";
import { PropsWithChildren } from "react";
import { Trans } from "react-i18next";
import { SettingsProvider } from "./SettingsContext";
export const SettingsLayout = ({ children }: PropsWithChildren) => {
return (
<SettingsProvider>
<Layout>
<LayoutHeader />
<LayoutContent>
<div className='grid w-full max-w-6xl gap-2 mx-auto'>
<h1 className='text-2xl font-semibold md:text-3xl'>
<Trans i18nKey='settings.edit.title' />
</h1>
</div>
{children}
</LayoutContent>
</Layout>
</SettingsProvider>
<ProtectedRoute>
<SettingsProvider>
<Layout className='settings-layout'>
<LayoutHeader />
<LayoutContent>
<div className='grid w-full max-w-6xl gap-2 mx-auto'>
<h1 className='text-2xl font-semibold md:text-3xl'>
<Trans i18nKey='settings.edit.title' />
</h1>
</div>
{children}
</LayoutContent>
</Layout>
</SettingsProvider>
</ProtectedRoute>
);
};

View File

@ -0,0 +1,160 @@
import { FormTextAreaField } from "@/components";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Form,
} from "@/ui";
import { useState } from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import { useToast } from "@/ui/use-toast";
import { joiResolver } from "@hookform/resolvers/joi";
import { ISendIncidence_Request_DTO } from "@shared/contexts";
import { t } from "i18next";
import Joi from "joi";
import { HelpCircleIcon } from "lucide-react";
import { useSupport } from "../hooks";
const formSchema = Joi.object({
incidence: Joi.string().min(10).required().messages({
"string.empty": "Debe escribir algo antes de enviar",
"string.min": "El texto es demasiado corto. Debe tener al menos 10 caracteres",
"string.max": "El texto es demasiado largo.",
"any.required": "La descripción es requerida",
}),
});
type SupportDataForm = ISendIncidence_Request_DTO;
export default function SupportModal() {
const [isOpen, setIsOpen] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const { toast } = useToast();
const { useSubmitIncidence } = useSupport();
const form = useForm<SupportDataForm>({
mode: "onBlur",
resolver: joiResolver(formSchema),
defaultValues: {
incidence: "",
},
});
const { handleSubmit, watch, reset } = form;
const incidenceValue = watch("incidence");
const { mutate } = useSubmitIncidence({
mutateOptions: {
onSuccess: () => {
toast({
title: "Incidencia enviada",
description: "La incidencia se ha enviado correctamente",
variant: "success",
});
setIsOpen(false);
reset();
},
onError: () => {
toast({
title: "Error en el envío",
description:
"No se ha podido enviar la incidencia correctamente. Por favor, inténtalo de nuevo.",
variant: "destructive",
});
},
},
});
const onSubmit: SubmitHandler<SupportDataForm> = async (data) => {
mutate(data);
};
const handleClose = () => {
console.log("handleClose", incidenceValue.trim());
if (incidenceValue.trim()) {
setShowConfirmDialog(true);
} else {
setIsOpen(false);
reset();
}
};
const confirmClose = () => {
setShowConfirmDialog(false);
setIsOpen(false);
reset();
};
return (
<>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant='outline'
size='icon'
className='overflow-hidden rounded-full bg-primary text-primary-foreground'
onClick={() => setIsOpen(true)}
>
<HelpCircleIcon className='w-5 h-5' />
<span className='sr-only'>Abrir ventana de soporte</span>
</Button>
</DialogTrigger>
<DialogContent className='sm:max-w-xl'>
<DialogHeader className='mb-2'>
<DialogTitle>{t("support.modal.title")}</DialogTitle>
<DialogDescription>{t("support.modal.subtitle")}</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={handleSubmit(onSubmit)} className='space-y-4'>
<FormTextAreaField
name='incidence'
placeholder='Describe la incidencia aquí...'
className='min-h-96'
/>
<DialogFooter>
<Button type='button' variant='outline' onClick={handleClose}>
Cancelar
</Button>
<Button type='submit'>Enviar incidencia</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>¿Estás seguro de que quieres cancelar?</AlertDialogTitle>
<AlertDialogDescription>
Has escrito texto en el campo de descripción. Si cierras la ventana, perderás los
cambios no guardados.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setShowConfirmDialog(false)}>
Volver al formulario
</AlertDialogCancel>
<AlertDialogAction onClick={confirmClose}>, cerrar</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

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

View File

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

View File

@ -0,0 +1,28 @@
import { TDataSourceError } from "@/lib/hooks/useDataSource/types";
import { useDataSource } from "@/lib/hooks/useDataSource/useDataSource";
import { ISendIncidence_Request_DTO } from "@shared/contexts";
import { useMutation, UseMutationOptions } from "@tanstack/react-query";
export type UseSupportGetParamsType = {
mutateOptions?: UseMutationOptions<void, TDataSourceError, ISendIncidence_Request_DTO>;
};
export const useSupport = () => {
const dataSource = useDataSource();
return {
useSubmitIncidence: (params?: UseSupportGetParamsType) => {
const { mutateOptions = {} } = params || {};
return useMutation<void, TDataSourceError, ISendIncidence_Request_DTO>({
mutationFn: (data) => {
return dataSource.createOne({
resource: "support",
data,
});
},
...mutateOptions,
});
},
};
};

View File

@ -0,0 +1,2 @@
export * from "./components";
export * from "./hooks";

View File

@ -1,9 +1,15 @@
import { UnsavedWarnProvider } from "@/lib/hooks";
import { cn } from "@/lib/utils";
import { PropsWithChildren } from "react";
export const Layout = ({ children }: PropsWithChildren) => (
export const Layout = ({
className,
children,
}: PropsWithChildren<{
className?: string;
}>) => (
<UnsavedWarnProvider>
<div className='flex flex-col w-full min-h-screen'>{children}</div>
<div className={cn("flex flex-col w-full min-h-screen", className)}>{children}</div>
</UnsavedWarnProvider>
);

View File

@ -1,5 +1,6 @@
import { Button, Sheet, SheetContent, SheetTrigger } from "@/ui";
import SupportModal from "@/app/support/components/SupportModal";
import { cn } from "@/lib/utils";
import { MenuIcon, Package2Icon } from "lucide-react";
import { useCallback } from "react";
@ -105,6 +106,7 @@ export const LayoutHeader = () => {
</Link>
<div className='flex items-center justify-end w-full gap-4 md:ml-auto md:gap-2 lg:gap-4'>
<UserButton />
<SupportModal />
</div>
</header>
);

View File

@ -23,16 +23,16 @@ export const ProtectedRoute = ({ children }: ProctectRouteProps) => {
}
}, [profile, profileStatus, i18n]);*/
if (isPending) {
/*if (isPending) {
return null;
}
}*/
if (isSuccess && !authenticated) {
// Redirect them to the /login page, but save the current location they were
// trying to go to when they were redirected. This allows us to send them
// along to that page after they login, which is a nicer user experience
// than dropping them off on the home page.
return <Navigate to={redirectTo ?? "/login"} state={{ from: location }} />;
return <Navigate to={redirectTo ?? "/login"} />;
}
return <>{children ?? null}</>;

View File

@ -15,7 +15,7 @@ import {
IUpdateOneDataProviderParams,
IUploadFileDataProviderParam,
} from "../hooks/useDataSource/DataSource";
import { createAxiosInstance } from "./axiosInstance";
import { createAxiosInstance, defaultAxiosRequestConfig } from "./axiosInstance";
export const createAxiosDataProvider = (
apiUrl: string,
@ -170,62 +170,54 @@ export const createAxiosDataProvider = (
},
custom: async <R>(params: ICustomDataProviderParam): Promise<R> => {
const { url, method, responseType, headers, signal, data, ...payload } = params;
const requestUrl = `${url}?`;
const { url, path, method, responseType, headers, signal, data, ...payload } = params;
let requestUrl: string;
/*if (sort) {
const generatedSort = extractSortParams(sort);
if (generatedSort) {
const { _sort, _order } = generatedSort;
const sortQuery = {
_sort: _sort.join(","),
_order: _order.join(","),
};
requestUrl = `${requestUrl}&${queryString.stringify(sortQuery)}`;
}
if (path) {
requestUrl = `${apiUrl}/${path}`;
} else if (url) {
requestUrl = url;
} else {
throw new Error('"url" or "path" param is missing');
}
if (filters) {
const filterQuery = extractFilterParams(filters);
requestUrl = `${requestUrl}&${queryString.stringify(filterQuery)}`;
}*/
/*if (query) {
requestUrl = `${requestUrl}&${queryString.stringify(query)}`;
}*/
if (headers) {
httpClient.defaults.headers = {
...httpClient.defaults.headers,
...headers,
};
}
console.log(apiUrl, path, url, requestUrl.toString());
// Preparar la respuesta personalizada
let customResponse;
// Configurar opciones comunes para la petición
const config = {
url: requestUrl.toString(),
method,
responseType,
signal,
...payload,
...defaultAxiosRequestConfig,
};
switch (method) {
case "put":
case "post":
case "patch":
customResponse = await httpClient.request<R>({
url,
method,
responseType,
headers,
...config,
data,
...payload,
});
break;
case "delete":
customResponse = await httpClient.delete<R>(url, {
customResponse = await httpClient.delete<R>(requestUrl.toString(), {
responseType,
headers,
...payload,
});
break;
default:
customResponse = await httpClient.get<R>(requestUrl, {
customResponse = await httpClient.get<R>(requestUrl.toString(), {
responseType,
signal,
headers,
...payload,
});
break;
}

View File

@ -1,8 +1,8 @@
import { AuthActionResponse, useAuth } from "@/lib/hooks";
import { UseMutationOptions, useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { useToast } from "@/ui/use-toast";
import { useQueryKey } from "../useQueryKey";
export const useLogout = (params?: UseMutationOptions<AuthActionResponse, Error>) => {
@ -11,6 +11,7 @@ export const useLogout = (params?: UseMutationOptions<AuthActionResponse, Error>
const keys = useQueryKey();
const { logout } = useAuth();
const navigate = useNavigate();
const { toast } = useToast();
return useMutation({
mutationKey: keys().auth().action("logout").get(),
@ -29,7 +30,11 @@ export const useLogout = (params?: UseMutationOptions<AuthActionResponse, Error>
},
onError: (error, variables, context) => {
const { message } = error;
toast.error(message);
toast({
title: "Error",
description: message,
variant: "destructive",
});
if (onError) {
onError(error, variables, context);

View File

@ -74,7 +74,8 @@ export interface IUploadFileDataProviderParam {
}
export interface ICustomDataProviderParam {
url: string;
url?: string;
path?: string;
method: "get" | "delete" | "head" | "options" | "post" | "put" | "patch";
signal?: AbortSignal;
responseType?: ResponseType;

View File

@ -433,6 +433,13 @@
"desc": "Texto para indicar el tiempo de validez de la cotización"
}
}
},
"support": {
"modal": {
"title": "Enviar una incidencia",
"subtitle": "Utiliza este formulario para informar sobre cualquier problema que hayas encontrado mientras usabas la aplicación. Nuestro equipo de desarrollo revisará tu incidencia y tratará de resolverla."
},
"form_fields": {}
}
}
}

View File

@ -1,3 +1,5 @@
"use client";
import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
@ -14,7 +16,7 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px] space-y-6",
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
@ -30,6 +32,7 @@ const toastVariants = cva(
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
success: "group border-green-400 bg-green-300 text-foreground",
},
},
defaultVariants: {

View File

@ -1,3 +1,5 @@
"use client";
import {
Toast,
ToastClose,
@ -12,7 +14,7 @@ export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider swipeDirection='right'>
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
@ -25,7 +27,7 @@ export function Toaster() {
</Toast>
);
})}
<ToastViewport />
<ToastViewport className='sm:mx-auto sm:right-0 sm:left-0' />
</ToastProvider>
);
}

View File

@ -1,10 +1,12 @@
"use client";
// Inspired by react-hot-toast library
import * as React from "react";
import type { ToastActionElement, ToastProps } from "./toast";
const TOAST_LIMIT = 5;
const TOAST_REMOVE_DELAY = 10000;
const TOAST_LIMIT = 3;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;

View File

@ -2593,7 +2593,7 @@ clsx@2.0.0:
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.0.0.tgz#12658f3fd98fafe62075595a5c30e43d18f3d00b"
integrity sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==
clsx@^2.0.0, clsx@^2.1.0, clsx@^2.1.1:
clsx@^2.0.0, clsx@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
@ -5094,13 +5094,6 @@ react-style-singleton@^2.2.1:
invariant "^2.2.4"
tslib "^2.0.0"
react-toastify@^10.0.5:
version "10.0.5"
resolved "https://registry.yarnpkg.com/react-toastify/-/react-toastify-10.0.5.tgz#6b8f8386060c5c856239f3036d1e76874ce3bd1e"
integrity sha512-mNKt2jBXJg4O7pSdbNUfDdTsK9FIdikfsIE/yUCxbAEXl4HMyJaivrVFcn3Elvt5xvCQYhUZm+hqTIu1UXM3Pw==
dependencies:
clsx "^2.1.0"
react-transition-group@^4.4.5:
version "4.4.5"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1"

View File

@ -58,6 +58,7 @@
"@joi/date": "^2.1.0",
"@reis/joi-luxon": "^3.0.0",
"@types/mime-types": "^2.1.4",
"@types/nodemailer": "^6.4.16",
"bcrypt": "^5.1.1",
"cls-rtracer": "^2.6.3",
"cors": "^2.8.5",
@ -79,6 +80,7 @@
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.6.0",
"nodemailer": "^6.9.15",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",

View File

@ -41,6 +41,11 @@ module.exports = {
"/home/rodax/Documentos/uecko-presupuestos/server/uploads/dealer-logos",
dealer_logo_placeholder:
"/home/rodax/Documentos/uecko-presupuestos/server/uploads/images/logo-placeholder-200x100.png",
support: {
from: "noreply@presupuestos.uecko.com",
subject: "Nueva incidencia Presupuestador Uecko",
},
},
admin: {
@ -56,4 +61,6 @@ module.exports = {
password: "123456",
language: "en",
},
nodemailer: {},
};

View File

@ -24,6 +24,10 @@ module.exports = {
defaults: {
dealer_logos_upload_path: "/api/uploads/dealer-logos",
dealer_logo_placeholder: "/api/uploads/images/logo-placeholder-200x100.png",
support: {
from: "noreply@presupuestos.uecko.com",
},
},
admin: {
@ -39,4 +43,16 @@ module.exports = {
password: "123456",
language: "en",
},
nodemailer: {
brevo: {
host: "smtp-relay.brevo.com",
port: 587,
secure: false,
auth: {
user: "7d0c4e001@smtp-brevo.com",
pass: "xsmtpsib-42ff61d359e148710fce8376854330891677a38172fd4217a0dc220551cce210-Wxm4DQwItYgTUcF6",
},
},
},
};

View File

@ -10,11 +10,10 @@ const extension = isProduction ? ".js" : ".ts";
const environmentConfig = require(path.resolve(__dirname, "environments", environment + extension));
export const config = Object.assign(
{
environment,
isProduction,
isDevelopment,
},
environmentConfig
);
export const config = {
environment,
isProduction,
isDevelopment,
...environmentConfig,
};

View File

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

View File

@ -1,13 +1,9 @@
import { IAuthContext } from "./Auth.context";
import {
ISequelizeAdapter,
SequelizeRepository,
} from "@/contexts/common/infrastructure/sequelize";
import { ISequelizeAdapter, SequelizeRepository } from "@/contexts/common/infrastructure/sequelize";
import { Email, ICollection, IQueryCriteria, UniqueID } from "@shared/contexts";
import { Transaction } from "sequelize";
import { AuthUser } from "../domain/entities";
import { IAuthRepository } from "../domain/repository/AuthRepository.interface";
import { IAuthContext } from "./Auth.context";
import { IUserMapper, createUserMapper } from "./mappers/authuser.mapper";
export type QueryParams = {
@ -15,10 +11,7 @@ export type QueryParams = {
filters: Record<string, any>;
};
export class AuthRepository
extends SequelizeRepository<AuthUser>
implements IAuthRepository
{
export class AuthRepository extends SequelizeRepository<AuthUser> implements IAuthRepository {
protected mapper: IUserMapper;
public constructor(props: {
@ -42,11 +35,7 @@ export class AuthRepository
}
public async findUserByEmail(email: Email): Promise<AuthUser | null> {
const rawUser: any = await this._getBy(
"AuthUser_Model",
"email",
email.toPrimitive(),
);
const rawUser: any = await this._getBy("AuthUser_Model", "email", email.toPrimitive());
if (!rawUser === true) {
return null;
@ -55,12 +44,10 @@ export class AuthRepository
return this.mapper.mapToDomain(rawUser);
}
public async findAll(
queryCriteria?: IQueryCriteria,
): Promise<ICollection<any>> {
public async findAll(queryCriteria?: IQueryCriteria): Promise<ICollection<any>> {
const { rows, count } = await this._findAll(
"AuthUser_Model",
queryCriteria,
queryCriteria
/*{
include: [], // esto es para quitar las asociaciones al hacer la consulta
}*/

View File

@ -1,36 +0,0 @@
// Import the necessary packages and modules
import { AuthUser } from "@/contexts/auth/domain";
import { IServerError } from "@/contexts/common/domain/errors";
import { ExpressController } from "@/contexts/common/infrastructure/express";
import passport from "passport";
export class AuthenticateController extends ExpressController {
async executeImpl() {
try {
return passport.authenticate(
"local-jwt",
{ session: false },
(
err: any,
user?: AuthUser | false | null,
info?: object | string | Array<string | undefined>,
status?: number | Array<number | undefined>
) => {
if (err) {
return this.next(err);
}
if (!user) {
return this.unauthorizedError("Unauthorized access. No token provided.");
}
// If the user is authenticated, attach the user object to the request and move on to the next middleware
this.req["user"] = user;
return this.next();
}
);
} catch (e: unknown) {
return this.fail(e as IServerError);
}
}
}

View File

@ -1,5 +1,5 @@
import { IAuthUser } from "@/contexts/auth/domain";
import { IAuthContext } from "@/contexts/auth/infrastructure/Auth.context";
import { IAuthContext } from "@/contexts/auth/infrastructure";
import { IIdentity_Response_DTO } from "@shared/contexts";
export interface IIdentityUser extends IAuthUser {}

View File

@ -1,5 +1,5 @@
import { IAuthUser } from "@/contexts/auth/domain";
import { IAuthContext } from "@/contexts/auth/infrastructure/Auth.context";
import { IAuthContext } from "@/contexts/auth/infrastructure";
import { ILogin_Response_DTO } from "@shared/contexts";
export interface ILoginUser {

View File

@ -1,18 +0,0 @@
import { AuthUser } from "@/contexts/auth/domain";
import { generateExpressError } from "@/contexts/common/infrastructure/express";
import { NextFunction, Request, Response } from "express";
import httpStatus from "http-status";
interface AuthenticatedRequest extends Request {
user?: AuthUser;
}
const profileMiddleware = (req: Request, res: Response, next: NextFunction) => {
const _req = req as AuthenticatedRequest;
const user = <AuthUser>_req.user;
if (!user || !user.isAdmin) {
generateExpressError(req, res, httpStatus.UNAUTHORIZED);
}
next();
};

View File

@ -0,0 +1,69 @@
import { AuthUser } from "@/contexts/auth/domain";
import { ICommonContext } from "@/contexts/common/infrastructure";
import { generateExpressError } from "@/contexts/common/infrastructure/express";
import { ensureIdIsValid } from "@shared/contexts";
import { NextFunction, Request, Response } from "express";
import httpStatus from "http-status";
import passport from "passport";
// Extender el Request de Express para incluir el usuario autenticado optionalmente
interface AuthenticatedRequest extends Request {
user?: AuthUser;
}
// Middleware para autenticar usando passport con el local-jwt strategy
const authenticateJwt = passport.authenticate("local-jwt", { session: false });
// Para establecer el contexto de autenticación
const setAuthContext = (req: AuthenticatedRequest, res: Response, user: AuthUser) => {
const { context } = res.locals || {};
res.locals.context = {
...context,
user,
} as ICommonContext;
};
// Comprueba el rol del usuario
const authorizeUser = (condition: (user: AuthUser) => boolean) => {
return (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
const user = req.user as AuthUser;
if (!user || !condition(user)) {
return generateExpressError(req, res, httpStatus.UNAUTHORIZED);
}
setAuthContext(req, res, user);
next();
};
};
// Verifica que el usuario esté autenticado
export const checkUser = [authenticateJwt, authorizeUser((user) => user.isUser)];
// Verifica que el usuario sea administrador
export const checkIsAdmin = [authenticateJwt, authorizeUser((user) => user.isAdmin)];
// Middleware para verificar que el usuario sea administrador o el dueño de los datos (self)
export const checkAdminOrSelf = [
authenticateJwt,
(req: AuthenticatedRequest, res: Response, next: NextFunction) => {
const user = req.user as AuthUser;
const { userId } = req.params;
// Si el usuario es admin, está autorizado
if (user.isAdmin) {
setAuthContext(req, res, user);
next();
}
// Si el usuario es sí mismo
if (user.isUser && userId) {
const paramIdOrError = ensureIdIsValid(userId);
if (paramIdOrError.isSuccess && user.id.equals(paramIdOrError.object)) {
setAuthContext(req, res, user);
next();
}
}
return generateExpressError(req, res, httpStatus.UNAUTHORIZED);
},
];

View File

@ -1,65 +0,0 @@
import { AuthUser } from "@/contexts/auth/domain";
import { composeMiddleware, generateExpressError } from "@/contexts/common/infrastructure/express";
import { ensureIdIsValid } from "@shared/contexts";
import { NextFunction, Request, Response } from "express";
import httpStatus from "http-status";
import passport from "passport";
interface AuthenticatedRequest extends Request {
user?: AuthUser;
}
export const checkUser = composeMiddleware([
passport.authenticate("local-jwt", {
session: false,
}),
(req: Request, res: Response, next: NextFunction) => {
const _req = req as AuthenticatedRequest;
const user = <AuthUser>_req.user;
if (!user || !user.isUser) {
return generateExpressError(req, res, httpStatus.UNAUTHORIZED);
}
return next();
},
]);
export const checkisAdmin = composeMiddleware([
passport.authenticate("local-jwt", {
session: false,
}),
(req: Request, res: Response, next: NextFunction) => {
const _req = req as AuthenticatedRequest;
const user = <AuthUser>_req.user;
if (!user || !user.isAdmin) {
generateExpressError(req, res, httpStatus.UNAUTHORIZED);
}
return next();
},
]);
export const checkAdminOrSelf = composeMiddleware([
passport.authenticate("local-jwt", {
session: false,
}),
(req: Request, res: Response, next: NextFunction) => {
const _req = req as AuthenticatedRequest;
const user = <AuthUser>_req.user;
const { userId } = req.params;
if (user && user.isAdmin) {
return next();
}
if (user && user.isUser && userId) {
const paramIdOrError = ensureIdIsValid(userId);
if (paramIdOrError.isSuccess && user.id.equals(paramIdOrError.object)) {
return next();
}
}
return generateExpressError(req, res, httpStatus.UNAUTHORIZED);
},
]);

View File

@ -1,10 +1,11 @@
import { createCommonContext } from "@/contexts/common/infrastructure";
import { PassportStatic } from "passport";
import { AuthContext } from "../../Auth.context";
import { initEmailStrategy } from "./emailStrategy";
import { initJWTStrategy } from "./jwtStrategy";
// Export a function that will be used to configure Passport authentication
export const configurePassportAuth = (passport: PassportStatic) => {
passport.use("local-email", initEmailStrategy(AuthContext.getInstance()));
passport.use("local-jwt", initJWTStrategy(AuthContext.getInstance()));
const context = createCommonContext();
passport.use("local-email", initEmailStrategy(context));
passport.use("local-jwt", initJWTStrategy(context));
};

View File

@ -5,6 +5,7 @@ import { Strategy as EmailStrategy, IVerifyOptions } from "passport-local";
import { LoginUseCase } from "@/contexts/auth/application";
import { AuthUser } from "@/contexts/auth/domain";
import { ICommonContext } from "@/contexts/common/infrastructure";
import { IAuthContext } from "../../Auth.context";
import { registerAuthRepository } from "../../Auth.repository";
@ -54,9 +55,9 @@ class EmailStrategyController extends PassportStrategyController {
}
}
export const initEmailStrategy = (context: IAuthContext) =>
export const initEmailStrategy = (context: ICommonContext) =>
new EmailStrategy(strategyOpts, async (username, password, done) => {
registerAuthRepository(context);
registerAuthRepository(context as IAuthContext);
return new EmailStrategyController(
{
useCase: new LoginUseCase(context),

View File

@ -1,2 +1,2 @@
export * from "./authMiddleware";
export * from "./Auth.middleware";
export * from "./configurePassportAuth";

View File

@ -1,6 +1,7 @@
import { config } from "@/config";
import { FindUserByEmailUseCase } from "@/contexts/auth/application/FindUserByEmail.useCase";
import { IServerError } from "@/contexts/common/domain/errors";
import { createCommonContext, ICommonContext } from "@/contexts/common/infrastructure";
import { PassportStrategyController } from "@/contexts/common/infrastructure/express";
import { ExtractJwt, Strategy as JWTStrategy, VerifiedCallback } from "passport-jwt";
import { IAuthContext } from "../../Auth.context";
@ -44,9 +45,10 @@ class JWTStrategyController extends PassportStrategyController {
}
}
export const initJWTStrategy = (context: IAuthContext) =>
export const initJWTStrategy = (context: ICommonContext) =>
new JWTStrategy(strategyOpts, async (payload, done) => {
registerAuthRepository(context);
const context = createCommonContext();
registerAuthRepository(context as IAuthContext);
return new JWTStrategyController(
{
useCase: new FindUserByEmailUseCase(context),

View File

@ -1,2 +1,4 @@
export * from "./Auth.context";
export * from "./Auth.repository";
export * from "./express";
export * from "./sequelize";

View File

@ -13,10 +13,6 @@ class AuthUserMapper
extends SequelizeMapper<AuthUser_Model, AuthUserCreationAttributes, AuthUser>
implements IUserMapper
{
public constructor(props: { context: IAuthContext }) {
super(props);
}
protected toDomainMappingImpl(source: AuthUser_Model, params: any): AuthUser {
const props: IAuthUserProps = {
name: this.mapsValue(source, "name", Name.create),

View File

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

View File

@ -0,0 +1,20 @@
export interface ISendEmailAddress {
name: string;
address: string;
}
export interface ISendEmailOptions {
from: string | ISendEmailAddress | undefined;
to: string | ISendEmailAddress;
subject?: string;
text?: string;
html?: string;
cc?: string | ISendEmailAddress | Array<string | ISendEmailAddress> | undefined;
bcc?: string | ISendEmailAddress | Array<string | ISendEmailAddress> | undefined;
replyTo?: string | ISendEmailAddress | Array<string | ISendEmailAddress> | undefined;
attachments?: Array<{ filename: string; path: string }>;
}
export interface IEmailService {
sendMail(mailOptions: ISendEmailOptions, dry?: boolean): Promise<void>;
}

View File

@ -1,2 +1,3 @@
export * from "./ApplicationService";
export * from "./Email.service";
export * from "./QueryCriteriaService";

View File

@ -0,0 +1,22 @@
import { config } from "@/config";
import { RepositoryManager } from "@/contexts/common/domain";
import { createSequelizeAdapter } from "@/contexts/common/infrastructure/sequelize";
import { AuthUser } from "@/contexts/auth/domain";
import { IRepositoryManager } from "../domain";
import { ISequelizeAdapter } from "./sequelize";
export interface IContext {}
11111111;
export interface ICommonContext extends IContext {
adapter: ISequelizeAdapter;
repositoryManager: IRepositoryManager;
defaults: Record<string, any>;
user?: AuthUser;
}
export const createCommonContext = () => ({
defaults: config.defaults,
adapter: createSequelizeAdapter(),
repositoryManager: RepositoryManager.getInstance(),
});

View File

@ -1,31 +0,0 @@
import { config } from "../../../config";
import { IRepositoryManager, RepositoryManager } from "../domain";
import { ISequelizeAdapter, createSequelizeAdapter } from "./sequelize";
export interface IContext {
adapter: ISequelizeAdapter;
repositoryManager: IRepositoryManager;
defaults: Record<string, any>;
}
export class ContextFactory {
protected static instance: ContextFactory | null = null;
public static getInstance(): IContext {
if (!ContextFactory.instance) {
ContextFactory.instance = new ContextFactory({
defaults: config.defaults, // Agregamos los valores específicos de ProfileContext
adapter: createSequelizeAdapter(),
repositoryManager: RepositoryManager.getInstance(),
});
}
return ContextFactory.instance.context;
}
protected context: IContext;
protected constructor(context: IContext) {
this.context = context;
}
}

View File

@ -4,21 +4,18 @@ import { IServerError, ServerError } from "../domain/errors";
export interface IInfrastructureError extends IServerError {}
export class InfrastructureError
extends ServerError
implements IInfrastructureError
{
export class InfrastructureError extends ServerError implements IInfrastructureError {
public static readonly BAD_REQUEST = "BAD_REQUEST";
public static readonly UNEXCEPTED_ERROR = "UNEXCEPTED_ERROR";
public static readonly INVALID_INPUT_DATA = "INVALID_INPUT_DATA";
public static readonly RESOURCE_NOT_READY = "RESOURCE_NOT_READY";
public static readonly RESOURCE_NOT_FOUND_ERROR = "RESOURCE_NOT_FOUND_ERROR";
public static readonly RESOURCE_ALREADY_REGISTERED =
"RESOURCE_ALREADY_REGISTERED";
public static readonly RESOURCE_ALREADY_REGISTERED = "RESOURCE_ALREADY_REGISTERED";
public static create(
code: string,
message: string,
error?: UseCaseError | ValidationError,
error?: UseCaseError | ValidationError
): InfrastructureError {
let payload = {};
@ -29,9 +26,7 @@ export class InfrastructureError
} else {
// UseCaseError
const _error = <UseCaseError>error;
const _payload = Array.isArray(_error.payload)
? _error.payload
: [_error.payload];
const _payload = Array.isArray(_error.payload) ? _error.payload : [_error.payload];
payload = _payload.map((item: Record<string, any>) => ({
path: item.path,

View File

@ -1,12 +1,7 @@
import { NextFunction, Request, Response } from "express";
import { IContext } from "../ContextFactory";
import { ExpressController } from "./ExpressController";
export type ControllerFactory<T extends IContext> = (context: T) => ExpressController;
export const handleRequest =
<T extends IContext>(controllerFactory: ControllerFactory<T>) =>
(req: Request, res: Response, next: NextFunction) => {
const context: T = res.locals["context"];
(controllerFactory: any) => (req: Request, res: Response, next: NextFunction) => {
const context = res.locals["context"];
return controllerFactory(context).execute(req, res, next);
};

View File

@ -40,7 +40,7 @@ function composeMiddleware(middlewareArray: any[]) {
return function (req: Request, res: Response, next: NextFunction) {
head(req, res, function (err: unknown) {
if (err) return next(err);
if (err) next(err);
composeMiddleware(tail)(req, res, next);
});
};

View File

@ -1,4 +1,4 @@
export * from "./ContextFactory";
export * from "./Common.context";
export * from "./Controller.interface";
export * from "./InfrastructureError";
export * from "./mappers";

View File

@ -0,0 +1,29 @@
import { config } from "@/config";
import nodemailer from "nodemailer";
import { ApplicationService, IEmailService, ISendEmailOptions } from "../../application";
import { LoggerMailService } from "./LoggerMailService";
export class BrevoMailService extends ApplicationService implements IEmailService {
private transporter: any;
constructor() {
super();
this.transporter = nodemailer.createTransport(config.nodemailer.brevo);
}
async sendMail(mailOptions: ISendEmailOptions, dry?: boolean): Promise<void> {
if (dry) {
// No enviar el email
new LoggerMailService().sendMail(mailOptions);
return;
}
await this.transporter.sendMail({
...mailOptions,
attachments: mailOptions.attachments?.map((att) => ({
filename: att.filename,
path: att.path,
})),
});
}
}

View File

@ -0,0 +1,10 @@
import { logger } from "@/infrastructure/logger";
import { ApplicationService, IEmailService, ISendEmailOptions } from "../../application";
export class LoggerMailService extends ApplicationService implements IEmailService {
async sendMail(mailOptions: ISendEmailOptions): Promise<void> {
await logger().debug(
`Email no enviado (modo desarrollo):\n${JSON.stringify(mailOptions, null, 2)}\n\n`
);
}
}

View File

@ -0,0 +1,2 @@
export * from "./BrevoMailService";
export * from "./LoggerMailService";

View File

@ -1,25 +1,3 @@
import { ContextFactory, IContext } from "@/contexts/common/infrastructure";
import { ICommonContext } from "@/contexts/common/infrastructure";
export interface IProfileContext extends IContext {}
export class ProfileContext extends ContextFactory {
protected static instance: ProfileContext | null = null;
public static getInstance(): IProfileContext {
if (!ProfileContext.instance) {
try {
ProfileContext.instance = new ProfileContext({
...ContextFactory.getInstance(), // Reutilizamos el contexto de la clase base
});
} catch (error: unknown) {
throw new Error(`Error initializing ProfileContext: ${(error as Error).message}`);
}
}
return ProfileContext.instance.context as IProfileContext;
}
private constructor(context: IProfileContext) {
super(context); // Llamamos al constructor de la clase base
}
}
export interface IProfileContext extends ICommonContext {}

View File

@ -1,31 +1,9 @@
import { ContextFactory, IContext } from "@/contexts/common/infrastructure";
import { ICommonContext } from "@/contexts/common/infrastructure";
import { Dealer, IQuoteReferenceGeneratorService } from "../domain";
export interface ISalesContext extends IContext {
export interface ISalesContext extends ICommonContext {
services?: {
QuoteReferenceGeneratorService: IQuoteReferenceGeneratorService;
};
dealer?: Dealer;
}
export class SalesContext extends ContextFactory {
protected static instance: SalesContext | null = null;
public static getInstance(): ISalesContext {
if (!SalesContext.instance) {
try {
SalesContext.instance = new SalesContext({
...ContextFactory.getInstance(), // Reutilizamos el contexto de la clase base
});
} catch (error: unknown) {
throw new Error(`Error initializing SalesContext: ${(error as Error).message}`);
}
}
return SalesContext.instance.context;
}
private constructor(context: ISalesContext) {
super(context); // Llamamos al constructor de la clase base
}
}

View File

@ -82,53 +82,33 @@ export class UpdateQuoteController extends ExpressController {
}
private _handleExecuteError(error: IUseCaseError) {
const createInfraError = (infrastructureCode: string, message: string) => {
return InfrastructureError.create(infrastructureCode, message, error);
};
let errorMessage: string;
let infraError: IInfrastructureError;
switch (error.code) {
case UseCaseError.NOT_FOUND_ERROR:
errorMessage = "Quote not found";
infraError = InfrastructureError.create(
InfrastructureError.RESOURCE_NOT_FOUND_ERROR,
errorMessage,
error
);
infraError = createInfraError(InfrastructureError.RESOURCE_NOT_FOUND_ERROR, errorMessage);
return this.notFoundError(errorMessage, infraError);
break;
case UseCaseError.INVALID_INPUT_DATA:
errorMessage = "Quote data not valid";
infraError = InfrastructureError.create(
InfrastructureError.INVALID_INPUT_DATA,
"Datos del cliente a actulizar erróneos",
error
);
infraError = createInfraError(InfrastructureError.INVALID_INPUT_DATA, errorMessage);
return this.invalidInputError(errorMessage, infraError);
break;
case UseCaseError.REPOSITORY_ERROR:
errorMessage = "Error updating quote";
infraError = InfrastructureError.create(
InfrastructureError.UNEXCEPTED_ERROR,
errorMessage,
error
);
infraError = createInfraError(InfrastructureError.UNEXCEPTED_ERROR, errorMessage);
return this.conflictError(errorMessage, infraError);
break;
case UseCaseError.UNEXCEPTED_ERROR:
errorMessage = error.message;
infraError = InfrastructureError.create(
InfrastructureError.UNEXCEPTED_ERROR,
errorMessage,
error
);
infraError = createInfraError(InfrastructureError.UNEXCEPTED_ERROR, errorMessage);
return this.internalServerError(errorMessage, infraError);
break;
default:
errorMessage = error.message;

View File

@ -0,0 +1,94 @@
import { AuthUser } from "@/contexts/auth/domain";
import { IUseCaseError, UseCaseError } from "@/contexts/common/application";
import { ICommonContext, InfrastructureError } from "@/contexts/common/infrastructure";
import { generateExpressError } from "@/contexts/common/infrastructure/express";
import { GetDealerByUserUseCase } from "@/contexts/sales/application";
import * as express from "express";
import httpStatus from "http-status";
import { registerDealerRepository } from "../../Dealer.repository";
interface AuthenticatedRequest extends express.Request {
user?: AuthUser;
}
export const getDealerMiddleware = async (
req: express.Request,
res: express.Response,
next: express.NextFunction
) => {
const _req = req as AuthenticatedRequest;
const user = _req.user as AuthUser;
const { context } = res.locals as { context: ICommonContext };
if (!user || !user.id) {
return handleError(
req,
res,
UseCaseError.create(UseCaseError.NOT_FOUND_ERROR, "User not found")
);
}
registerDealerRepository(context);
try {
const dealerOrError = await new GetDealerByUserUseCase({
adapter: context.adapter,
repositoryManager: context.repositoryManager,
}).execute({
userId: user.id,
});
if (dealerOrError.isFailure) {
return handleError(req, res, dealerOrError.error);
}
res.locals.context = {
...context,
dealer: dealerOrError.object,
};
next();
} catch (e: unknown) {
console.error("Error in getDealerMiddleware:", e as Error);
return handleError(req, res, e as UseCaseError);
}
};
function handleError(req: express.Request, res: express.Response, error?: IUseCaseError) {
const errorMappings: {
[key: string]: { message: string; status: number; infraErrorCode: string };
} = {
[UseCaseError.NOT_FOUND_ERROR]: {
message: "Dealer not found",
status: httpStatus.NOT_FOUND,
infraErrorCode: InfrastructureError.RESOURCE_NOT_FOUND_ERROR,
},
[UseCaseError.INVALID_INPUT_DATA]: {
message: "Dealer data not valid",
status: httpStatus.UNPROCESSABLE_ENTITY,
infraErrorCode: InfrastructureError.INVALID_INPUT_DATA,
},
[UseCaseError.REPOSITORY_ERROR]: {
message: "Unexpected error",
status: httpStatus.CONFLICT,
infraErrorCode: InfrastructureError.UNEXCEPTED_ERROR,
},
[UseCaseError.UNEXCEPTED_ERROR]: {
message: error?.message ?? "Unexcepted error",
status: httpStatus.INTERNAL_SERVER_ERROR,
infraErrorCode: InfrastructureError.UNEXCEPTED_ERROR,
},
};
const { message, status, infraErrorCode } = error
? errorMappings[error.code]
: {
message: "Bad request",
status: httpStatus.BAD_REQUEST,
infraErrorCode: InfrastructureError.BAD_REQUEST,
};
const infraError = InfrastructureError.create(infraErrorCode, message, error);
return generateExpressError(req, res, status, message, infraError);
}

View File

@ -1,39 +0,0 @@
import { AuthUser } from "@/contexts/auth/domain";
import { GetDealerByUserUseCase } from "@/contexts/sales/application";
import * as express from "express";
import { registerDealerRepository } from "../../Dealer.repository";
import { ISalesContext } from "../../Sales.context";
interface AuthenticatedRequest extends express.Request {
user?: AuthUser;
}
export const getDealerMiddleware = async (
req: express.Request,
res: express.Response,
next: express.NextFunction
) => {
const _req = req as AuthenticatedRequest;
const user = <AuthUser>_req.user;
const context: ISalesContext = res.locals.context;
registerDealerRepository(context);
try {
const dealerOrError = await new GetDealerByUserUseCase(context).execute({
userId: user.id,
});
if (dealerOrError.isFailure) {
return res.status(500).json().send();
//return this._handleExecuteError(result.error);
}
context.dealer = dealerOrError.object;
return next();
} catch (e: unknown) {
//return this.fail(e as IServerError);
return res.status(500).json().send();
}
};

View File

@ -0,0 +1,119 @@
import {
IUseCase,
IUseCaseError,
IUseCaseRequest,
UseCaseError,
} from "@/contexts/common/application";
import {
DomainError,
IDomainError,
ISendIncidence_Request_DTO,
Result,
TextValueObject,
UTCDateValue,
UniqueID,
} from "@shared/contexts";
import { Dealer } from "@/contexts/sales/domain";
import { Incidence } from "../domain";
import { ISupportContext } from "../infrastructure";
export interface ISendIncidenceUseCaseRequest extends IUseCaseRequest {
incidenceDTO: ISendIncidence_Request_DTO;
}
export type SendIncidenceResponseOrError =
| Result<never, IUseCaseError> // Misc errors (value objects)
| Result<void, never>; // Success!
export class SendIncidenceUseCase
implements IUseCase<ISendIncidenceUseCaseRequest, Promise<SendIncidenceResponseOrError>>
{
private _context: ISupportContext;
constructor(context: ISupportContext) {
this._context = context;
}
async execute(request: ISendIncidenceUseCaseRequest): Promise<SendIncidenceResponseOrError> {
const { incidenceDTO } = request;
//const QuoteRepository = this._getQuoteRepository();
// Validaciones de datos
if (!this._context.user) {
const message = "Error. Missing User";
return Result.fail(UseCaseError.create(UseCaseError.INVALID_INPUT_DATA, message));
}
// Validaciones de datos
if (!this._context.dealer) {
const message = "Error. Missing Dealer";
return Result.fail(UseCaseError.create(UseCaseError.INVALID_INPUT_DATA, message));
}
const dealer = this._context.dealer;
const user = this._context.user;
// Send incidence
const incidenceOrError = this._tryIncidenceInstance(incidenceDTO, dealer);
if (incidenceOrError.isFailure) {
const { error: domainError } = incidenceOrError;
let errorCode = "";
let message = "";
switch (domainError.code) {
// Errores manuales
case DomainError.INVALID_INPUT_DATA:
errorCode = UseCaseError.INVALID_INPUT_DATA;
message = "The issue has some erroneous data.";
break;
default:
errorCode = UseCaseError.UNEXCEPTED_ERROR;
message = domainError.message;
break;
}
return Result.fail(UseCaseError.create(errorCode, message, domainError));
}
const incidence = incidenceOrError.object;
this._context.services?.emailService.sendMail({
from: {
name: user.name.toString(),
address: user.email.toString(),
},
to: this._context.defaults.support.from,
subject: this._context.defaults.support.subject,
html: incidence.description.toString(),
});
return Result.ok<void>();
}
private _tryIncidenceInstance(
incidenceDTO: ISendIncidence_Request_DTO,
dealer: Dealer
): Result<Incidence, IDomainError> {
const dateOrError = UTCDateValue.createCurrentDate();
if (dateOrError.isFailure) {
return Result.fail(dateOrError.error);
}
const descriptionOrError = TextValueObject.create(incidenceDTO.incidence);
if (descriptionOrError.isFailure) {
return Result.fail(descriptionOrError.error);
}
return Incidence.create(
{
date: dateOrError.object,
description: descriptionOrError.object,
dealerId: dealer.id,
},
UniqueID.generateNewID().object
);
}
}

View File

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

View File

@ -0,0 +1,45 @@
import {
AggregateRoot,
IDomainError,
Note,
Result,
UTCDateValue,
UniqueID,
} from "@shared/contexts";
export interface IIncidenceProps {
dealerId: UniqueID;
date: UTCDateValue;
description: Note;
}
export interface IIncidence {
id: UniqueID;
dealerId: UniqueID;
date: UTCDateValue;
description: Note;
}
export class Incidence extends AggregateRoot<IIncidenceProps> implements IIncidence {
public static create(props: IIncidenceProps, id?: UniqueID): Result<Incidence, IDomainError> {
const incidence = new Incidence(props, id);
return Result.ok<Incidence>(incidence);
}
get id(): UniqueID {
return this._id;
}
get dealerId() {
return this.props.dealerId;
}
get date() {
return this.props.date;
}
get description() {
return this.props.description;
}
}

View File

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

View File

@ -0,0 +1,10 @@
import { IEmailService } from "@/contexts/common/application";
import { ICommonContext } from "@/contexts/common/infrastructure";
import { Dealer } from "@/contexts/sales/domain";
export interface ISupportContext extends ICommonContext {
services?: {
emailService: IEmailService;
};
dealer?: Dealer;
}

View File

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

View File

@ -0,0 +1,76 @@
import { IUseCaseError, UseCaseError } from "@/contexts/common/application/useCases";
import { IServerError } from "@/contexts/common/domain/errors";
import { IInfrastructureError, InfrastructureError } from "@/contexts/common/infrastructure";
import { ExpressController } from "@/contexts/common/infrastructure/express";
import { SendIncidenceUseCase } from "@/contexts/support/application/SendIncidence.useCase";
import {
ensureSendIncidence_Request_DTOIsValid,
ISendIncidence_Request_DTO,
} from "@shared/contexts";
import { ISupportContext } from "../../../Support.context";
export class SendIncidenceController extends ExpressController {
private useCase: SendIncidenceUseCase;
private context: ISupportContext;
constructor(props: { useCase: SendIncidenceUseCase }, context: ISupportContext) {
super();
const { useCase } = props;
this.useCase = useCase;
this.context = context;
}
async executeImpl(): Promise<any> {
try {
const incidenceDTO: ISendIncidence_Request_DTO = this.req.body;
// Validar DTO de datos
const incidenceDTOOrError = ensureSendIncidence_Request_DTOIsValid(incidenceDTO);
if (incidenceDTOOrError.isFailure) {
const errorMessage = "Incidence data not valid";
const infraError = InfrastructureError.create(
InfrastructureError.INVALID_INPUT_DATA,
errorMessage,
incidenceDTOOrError.error
);
return this.invalidInputError(errorMessage, infraError);
}
// Llamar al caso de uso
const result = await this.useCase.execute({
incidenceDTO: incidenceDTOOrError.object,
});
if (result.isFailure) {
return this._handleExecuteError(result.error);
}
return this.noContent();
} catch (e: unknown) {
return this.fail(e as IServerError);
}
}
private _handleExecuteError(error: IUseCaseError) {
let errorMessage: string;
let infraError: IInfrastructureError;
switch (error.code) {
case UseCaseError.UNEXCEPTED_ERROR:
errorMessage = error.message;
infraError = InfrastructureError.create(
InfrastructureError.UNEXCEPTED_ERROR,
errorMessage,
error
);
return this.internalServerError(errorMessage, infraError);
break;
default:
errorMessage = error.message;
return this.clientError(errorMessage);
}
}
}

View File

@ -0,0 +1,16 @@
import { SendIncidenceUseCase } from "@/contexts/support/application/SendIncidence.useCase";
import { ISupportContext } from "../../../Support.context";
import { SendIncidenceController } from "./SendIncidence.controller";
export const createSendIncidenceController = (context: ISupportContext) => {
if (!context) {
throw new Error("Support context is required");
}
return new SendIncidenceController(
{
useCase: new SendIncidenceUseCase(context),
},
context
);
};

View File

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

View File

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

View File

@ -1,35 +1,3 @@
import {
IRepositoryManager,
RepositoryManager,
} from "@/contexts/common/domain";
import {
ISequelizeAdapter,
createSequelizeAdapter,
} from "@/contexts/common/infrastructure/sequelize";
import { ICommonContext } from "@/contexts/common/infrastructure";
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;
}
}
export interface IUserContext extends ICommonContext {}

View File

@ -0,0 +1,8 @@
import { createCommonContext } from "@/contexts/common/infrastructure";
import { NextFunction, Request, Response } from "express";
export const commonContextMiddleware = (req: Request, res: Response, next: NextFunction) => {
// Almacenar el contexto en res.locals
res.locals.context = createCommonContext();
next();
};

View File

@ -1,3 +0,0 @@
import { ContextFactory } from "@/contexts/common/infrastructure";
export const createContextMiddleware = () => ContextFactory.getInstance();

View File

@ -1,6 +1,6 @@
import { checkUser } from "@/contexts/auth";
import { listArticlesController } from "@/contexts/catalog";
import { NextFunction, Request, Response, Router } from "express";
import { listArticlesController } from "../../../../contexts/catalog/infrastructure/express/controllers";
export const catalogRouter = (appRouter: Router) => {
const catalogRoutes: Router = Router({ mergeParams: true });

View File

@ -1,15 +1,15 @@
import { checkUser, checkisAdmin } from "@/contexts/auth";
import { checkIsAdmin, checkUser } from "@/contexts/auth";
import {
getDealerController,
listDealersController,
} from "@/contexts/sales/infrastructure/express/controllers/dealers";
import { getDealerMiddleware } from "@/contexts/sales/infrastructure/express/middlewares/dealerMiddleware";
import { getDealerMiddleware } from "@/contexts/sales/infrastructure/express/middlewares/Dealer.middleware";
import { Router } from "express";
export const DealerRouter = (appRouter: Router) => {
export const dealerRouter = (appRouter: Router) => {
const dealerRoutes: Router = Router({ mergeParams: true });
dealerRoutes.get("/", checkisAdmin, listDealersController);
dealerRoutes.get("/", checkIsAdmin, listDealersController);
dealerRoutes.get("/:dealerId", checkUser, getDealerMiddleware, getDealerController);
///dealerRoutes.post("/", checkisAdmin, createDealerController);
//dealerRoutes.put("/:dealerId", checkisAdmin, updateDealerController);

View File

@ -3,12 +3,11 @@ import { handleRequest } from "@/contexts/common/infrastructure/express";
import {
createGetProfileController,
createUpdateProfileController,
ProfileContext,
} from "@/contexts/profile/infrastructure";
import { createGetProfileLogoController } from "@/contexts/profile/infrastructure/express/controllers/getProfileLogo";
import { createUploadProfileLogoController } from "@/contexts/profile/infrastructure/express/controllers/uploadProfileLogo";
import { NextFunction, Request, Response, Router } from "express";
import { createMulterMiddleware } from "../upload.middleware";
import { Router } from "express";
import { createMulterMiddleware } from "../Upload.middleware";
const uploadProfileLogo = createMulterMiddleware({
uploadFolder: "uploads/dealer-logos",
@ -19,13 +18,6 @@ const uploadProfileLogo = createMulterMiddleware({
export const profileRouter = (appRouter: Router) => {
const profileRoutes: Router = Router({ mergeParams: true });
const profileContextMiddleware = (req: Request, res: Response, next: NextFunction) => {
res.locals["context"] = ProfileContext.getInstance();
next();
};
profileRoutes.use(profileContextMiddleware);
profileRoutes.get("/", checkUser, handleRequest(createGetProfileController));
profileRoutes.put("/", checkUser, handleRequest(createUpdateProfileController));
profileRoutes.get("/logo", checkUser, handleRequest(createGetProfileLogoController));

View File

@ -7,10 +7,10 @@ import {
setStatusQuoteController,
updateQuoteController,
} from "@/contexts/sales/infrastructure/express/controllers";
import { getDealerMiddleware } from "@/contexts/sales/infrastructure/express/middlewares/dealerMiddleware";
import { getDealerMiddleware } from "@/contexts/sales/infrastructure/express/middlewares/Dealer.middleware";
import { Router } from "express";
export const QuoteRouter = (appRouter: Router) => {
export const quoteRouter = (appRouter: Router): void => {
const quoteRoutes: Router = Router({ mergeParams: true });
// Users CRUD

View File

@ -0,0 +1,32 @@
import { NextFunction, Request, Response, Router } from "express";
import { config } from "@/config";
import { checkUser } from "@/contexts/auth";
import { handleRequest } from "@/contexts/common/infrastructure/express";
import { BrevoMailService, LoggerMailService } from "@/contexts/common/infrastructure/nodemailer";
import { getDealerMiddleware } from "@/contexts/sales/infrastructure/express/middlewares/Dealer.middleware";
import { createSendIncidenceController, ISupportContext } from "@/contexts/support/infrastructure";
export const supportRouter = (appRouter: Router): void => {
const supportRoutes: Router = Router({ mergeParams: true });
supportRoutes.use((req: Request, res: Response, next: NextFunction) => {
const context = res.locals["context"];
res.locals["context"] = {
...context,
services: {
emailService: config.isProduction ? new BrevoMailService() : new LoggerMailService(),
},
} as ISupportContext;
next();
});
supportRoutes.post(
"/",
checkUser,
getDealerMiddleware,
handleRequest(createSendIncidenceController)
);
appRouter.use("/support", supportRoutes);
};

View File

@ -1,4 +1,4 @@
import { checkAdminOrSelf, checkisAdmin } from "@/contexts/auth";
import { checkAdminOrSelf, checkIsAdmin } from "@/contexts/auth";
import { NextFunction, Request, Response, Router } from "express";
import {
createCreateUserController,
@ -11,7 +11,7 @@ import {
export const usersRouter = (appRouter: Router) => {
const userRoutes: Router = Router({ mergeParams: true });
userRoutes.get("/", checkisAdmin, (req: Request, res: Response, next: NextFunction) =>
userRoutes.get("/", checkIsAdmin, (req: Request, res: Response, next: NextFunction) =>
createListUsersController(res.locals["context"]).execute(req, res, next)
);
@ -19,15 +19,15 @@ export const usersRouter = (appRouter: Router) => {
createGetUserController(res.locals["context"]).execute(req, res, next)
);
userRoutes.post("/", checkisAdmin, (req: Request, res: Response, next: NextFunction) =>
userRoutes.post("/", checkIsAdmin, (req: Request, res: Response, next: NextFunction) =>
createCreateUserController(res.locals["context"]).execute(req, res, next)
);
userRoutes.put("/:userId", checkisAdmin, (req: Request, res: Response, next: NextFunction) =>
userRoutes.put("/:userId", checkIsAdmin, (req: Request, res: Response, next: NextFunction) =>
createUpdateUserController(res.locals["context"]).execute(req, res, next)
);
userRoutes.delete("/:userId", checkisAdmin, (req: Request, res: Response, next: NextFunction) =>
userRoutes.delete("/:userId", checkIsAdmin, (req: Request, res: Response, next: NextFunction) =>
createDeleteUserController(res.locals["context"]).execute(req, res, next)
);

View File

@ -1,13 +1,14 @@
import { NextFunction, Request, Response, Router } from "express";
import { createContextMiddleware } from "./context.middleware";
import { Router } from "express";
import { commonContextMiddleware } from "./CommonContext.middleware";
import {
DealerRouter,
QuoteRouter,
authRouter,
catalogRouter,
dealerRouter,
profileRouter,
quoteRouter,
usersRouter,
} from "./routes";
import { supportRouter } from "./routes/support.routes";
export const v1Routes = () => {
const routes = Router({ mergeParams: true });
@ -16,24 +17,22 @@ export const v1Routes = () => {
res.send("Hello world!");
});
routes.use((req: Request, res: Response, next: NextFunction) => {
res.locals["context"] = createContextMiddleware();
//res.locals["middlewares"] = createMiddlewareMap();
return next();
});
routes.use((req, res, next) => {
console.log(`[${new Date().toLocaleTimeString()}] Incoming request to ${req.path}`);
console.log(
`[${new Date().toLocaleTimeString()}] Incoming request ${req.method} to ${req.path}`
);
next();
});
routes.use(commonContextMiddleware);
authRouter(routes);
profileRouter(routes);
usersRouter(routes);
catalogRouter(routes);
DealerRouter(routes);
QuoteRouter(routes);
dealerRouter(routes);
quoteRouter(routes);
supportRouter(routes);
return routes;
};

View File

@ -63,10 +63,6 @@ app.use(helmet());
//app.use(morgan('common'));
app.use(morgan("dev"));
// Autentication
app.use(passport.initialize());
configurePassportAuth(passport);
// Express configuration
app.set("port", process.env.PORT ?? 3001);
@ -85,6 +81,10 @@ app.use((err: any, req: any, res: any, next: any) => {
next();
});
// Autentication
app.use(passport.initialize());
configurePassportAuth(passport);
// API
app.use("/api/v1", v1Routes());

View File

@ -5,7 +5,7 @@ import { createLogger, format, transports } from "winston";
import DailyRotateFile from "winston-daily-rotate-file";
import { config } from "../../config";
function initLogger(rTracer) {
export const initLogger = (rTracer) => {
// a custom format that outputs request id
const consoleFormat = format.combine(
@ -83,9 +83,7 @@ function initLogger(rTracer) {
//}
return logger;
}
export { initLogger };
};
export const logger = () => {
return initLogger(rTracer);

View File

@ -1,4 +1,7 @@
import { SalesContext } from "@/contexts/sales/infrastructure";
import { config } from "@/config";
import { RepositoryManager } from "@/contexts/common/domain";
import { createSequelizeAdapter } from "@/contexts/common/infrastructure/sequelize";
import { registerDealerRepository } from "@/contexts/sales/infrastructure/Dealer.repository";
import {
initializeAdmin,
@ -8,7 +11,11 @@ import {
import { registerUserRepository } from "@/contexts/users/infrastructure/User.repository";
export const insertUsers = async () => {
const context = SalesContext.getInstance();
const context = {
defaults: config.defaults,
adapter: createSequelizeAdapter(),
repositoryManager: RepositoryManager.getInstance(),
} as any;
registerUserRepository(context);
registerDealerRepository(context);

View File

@ -1038,6 +1038,13 @@
dependencies:
undici-types "~6.19.2"
"@types/nodemailer@^6.4.16":
version "6.4.16"
resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.16.tgz#db006abcb1e1c8e6ea2fb53b27fefec3c03eaa6c"
integrity sha512-uz6hN6Pp0upXMcilM61CoKyjT7sskBoOWpptkjjJp8jIMlTdc3xG01U7proKkXzruMS4hS0zqtHNkNPFB20rKQ==
dependencies:
"@types/node" "*"
"@types/passport-jwt@^4.0.1":
version "4.0.1"
resolved "https://registry.yarnpkg.com/@types/passport-jwt/-/passport-jwt-4.0.1.tgz#080fbe934fb9f6954fb88ec4cdf4bb2cc7c4d435"
@ -4598,6 +4605,11 @@ node-releases@^2.0.14:
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.14.tgz#2ffb053bceb8b2be8495ece1ab6ce600c4461b0b"
integrity sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==
nodemailer@^6.9.15:
version "6.9.15"
resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.9.15.tgz#57b79dc522be27e0e47ac16cc860aa0673e62e04"
integrity sha512-AHf04ySLC6CIfuRtRiEYtGEXgRfa6INgWGluDhnxTZhHSKvrBu7lc1VVchQ0d8nPc4cFaZoPq8vkyNoZr0TpGQ==
nopt@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88"

View File

@ -3,4 +3,5 @@ export * from "./catalog";
export * from "./common";
export * from "./profile";
export * from "./sales";
export * from "./support";
export * from "./users";

View File

@ -0,0 +1,20 @@
import Joi from "joi";
import { Result, RuleValidator } from "../../../common";
export interface ISendIncidence_Request_DTO {
incidence: string;
}
export function ensureSendIncidence_Request_DTOIsValid(SupportDTO: ISendIncidence_Request_DTO) {
const schema = Joi.object({
incidence: Joi.string().min(10).max(1000).required(),
});
const result = RuleValidator.validate<ISendIncidence_Request_DTO>(schema, SupportDTO);
if (result.isFailure) {
return Result.fail(result.error);
}
return Result.ok(result.object);
}

View File

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

View File

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

View File

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