v0.3.6
This commit is contained in:
parent
2b3dfce72c
commit
3a1c7e0844
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "0.2.7",
|
"version": "0.3.6",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "WEB: Vite (Chrome)",
|
"name": "WEB: Vite (Chrome)",
|
||||||
|
|||||||
@ -1,53 +0,0 @@
|
|||||||
# ───────────────────────────────
|
|
||||||
# Identidad de la compañía
|
|
||||||
# ───────────────────────────────
|
|
||||||
COMPANY=rodax
|
|
||||||
CTE_COMPANY_ID=5e4dc5b3-96b9-4968-9490-14bd032fec5f
|
|
||||||
|
|
||||||
# Dominios
|
|
||||||
DOMAIN=factuges.rodax-software.local
|
|
||||||
|
|
||||||
# ───────────────────────────────
|
|
||||||
# Base de datos (Sequelize / MySQL-MariaDB)
|
|
||||||
# ───────────────────────────────
|
|
||||||
DB_ROOT_PASS=verysecret
|
|
||||||
DB_USER=rodax_usr
|
|
||||||
DB_PASS=supersecret
|
|
||||||
DB_NAME=rodax_db
|
|
||||||
DB_PORT=3306
|
|
||||||
|
|
||||||
# Log de Sequelize (true|false)
|
|
||||||
DB_LOGGING=false
|
|
||||||
|
|
||||||
# Alterar estructura BD
|
|
||||||
DB_SYNC_MODE=none # none | alter | force
|
|
||||||
|
|
||||||
|
|
||||||
# ───────────────────────────────
|
|
||||||
# API
|
|
||||||
# ───────────────────────────────
|
|
||||||
API_PORT=3002
|
|
||||||
API_IMAGE=factuges-server:rodax-latest
|
|
||||||
|
|
||||||
# Plantillas
|
|
||||||
TEMPLATES_PATH=/repo/apps/server/templates
|
|
||||||
|
|
||||||
# Documentos generados
|
|
||||||
DOCUMENTS_PATH=/home/rodax/Documentos/uecko-erp/out/rodax/documents
|
|
||||||
|
|
||||||
# Firma de documentos
|
|
||||||
SIGN_DOCUMENTS=false
|
|
||||||
SIGNED_DOCUMENTS_PATH=/home/rodax/Documentos/uecko-erp/out/rodax/signed-documents
|
|
||||||
|
|
||||||
|
|
||||||
# Chrome executable path (Puppeteer)
|
|
||||||
# PUPPETEER_EXECUTABLE_PATH=/usr/bin/google-chrome
|
|
||||||
|
|
||||||
# URL pública del frontend (CORS)
|
|
||||||
FRONTEND_URL=factuges.rodax-software.local
|
|
||||||
|
|
||||||
# Tiempo máximo para cada warmup() de un módulo, en milisegundos.
|
|
||||||
WARMUP_TIMEOUT_MS=10000
|
|
||||||
|
|
||||||
# Si es true, un fallo de warmup aborta el arranque. Si es false, continúa con warning.
|
|
||||||
WARMUP_STRICT=false
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@erp/factuges-server",
|
"name": "@erp/factuges-server",
|
||||||
"version": "0.2.7",
|
"version": "0.3.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup src/index.ts --config tsup.config.ts",
|
"build": "tsup src/index.ts --config tsup.config.ts",
|
||||||
|
|||||||
@ -52,6 +52,14 @@ const DOCUMENTS_PATH = process.env.DOCUMENTS_PATH ?? "./documents";
|
|||||||
// Proxy (no usáis ahora, pero dejamos la variable por si se activa en el futuro)
|
// Proxy (no usáis ahora, pero dejamos la variable por si se activa en el futuro)
|
||||||
const TRUST_PROXY = asNumber(process.env.TRUST_PROXY, 0);
|
const TRUST_PROXY = asNumber(process.env.TRUST_PROXY, 0);
|
||||||
|
|
||||||
|
const SIGNING_SERVICE_URL = process.env.SIGNING_SERVICE_URL;
|
||||||
|
const SIGNING_SERVICE_METHOD = process.env.SIGNING_SERVICE_METHOD ?? "POST";
|
||||||
|
const SIGNING_SERVICE_TIMEOUT_MS = asNumber(process.env.SIGNING_SERVICE_TIMEOUT_MS, 15_000);
|
||||||
|
const SIGNING_SERVICE_MAX_RETRIES = asNumber(process.env.SIGNING_SERVICE_MAX_RETRIES, 2);
|
||||||
|
|
||||||
|
const COMPANY_SLUG = process.env.COMPANY_SLUG ?? "acme";
|
||||||
|
const COMPANY_CERTIFICATES_JSON = process.env.COMPANY_CERTIFICATES_JSON ?? {};
|
||||||
|
|
||||||
export const ENV = {
|
export const ENV = {
|
||||||
NODE_ENV,
|
NODE_ENV,
|
||||||
API_PORT,
|
API_PORT,
|
||||||
@ -67,9 +75,18 @@ export const ENV = {
|
|||||||
DB_SYNC_MODE,
|
DB_SYNC_MODE,
|
||||||
APP_TIMEZONE,
|
APP_TIMEZONE,
|
||||||
TRUST_PROXY,
|
TRUST_PROXY,
|
||||||
|
|
||||||
TEMPLATES_PATH,
|
TEMPLATES_PATH,
|
||||||
DOCUMENTS_PATH,
|
DOCUMENTS_PATH,
|
||||||
FASTREPORT_BIN,
|
FASTREPORT_BIN,
|
||||||
|
|
||||||
|
SIGNING_SERVICE_URL,
|
||||||
|
SIGNING_SERVICE_METHOD,
|
||||||
|
SIGNING_SERVICE_TIMEOUT_MS,
|
||||||
|
SIGNING_SERVICE_MAX_RETRIES,
|
||||||
|
|
||||||
|
COMPANY_SLUG,
|
||||||
|
COMPANY_CERTIFICATES_JSON,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const Flags = {
|
export const Flags = {
|
||||||
|
|||||||
@ -215,6 +215,7 @@ process.on("uncaughtException", async (error: Error) => {
|
|||||||
|
|
||||||
logger.info(`FRONTEND_URL: ${ENV.FRONTEND_URL}`);
|
logger.info(`FRONTEND_URL: ${ENV.FRONTEND_URL}`);
|
||||||
|
|
||||||
|
logger.info(`DB_DIALECT: ${ENV.DB_DIALECT}`);
|
||||||
logger.info(`DB_HOST: ${ENV.DB_HOST}`);
|
logger.info(`DB_HOST: ${ENV.DB_HOST}`);
|
||||||
logger.info(`DB_PORT: ${ENV.DB_PORT}`);
|
logger.info(`DB_PORT: ${ENV.DB_PORT}`);
|
||||||
logger.info(`DB_NAME: ${ENV.DB_NAME}`);
|
logger.info(`DB_NAME: ${ENV.DB_NAME}`);
|
||||||
@ -225,6 +226,9 @@ process.on("uncaughtException", async (error: Error) => {
|
|||||||
logger.info(`FASTREPORT_BIN: ${ENV.FASTREPORT_BIN}`);
|
logger.info(`FASTREPORT_BIN: ${ENV.FASTREPORT_BIN}`);
|
||||||
logger.info(`TEMPLATES_PATH: ${ENV.TEMPLATES_PATH}`);
|
logger.info(`TEMPLATES_PATH: ${ENV.TEMPLATES_PATH}`);
|
||||||
|
|
||||||
|
logger.info(`SIGNING_SERVICE_URL: ${ENV.SIGNING_SERVICE_URL}`);
|
||||||
|
logger.info(`SIGNING_SERVICE_METHOD: ${ENV.SIGNING_SERVICE_METHOD}`);
|
||||||
|
|
||||||
const database = await tryConnectToDatabase();
|
const database = await tryConnectToDatabase();
|
||||||
|
|
||||||
// Lógica de inicialización de DB, si procede:
|
// Lógica de inicialización de DB, si procede:
|
||||||
|
|||||||
Binary file not shown.
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"mimeType": "application/pdf",
|
||||||
|
"filename": "issued-invoice.pdf",
|
||||||
|
"metadata": {
|
||||||
|
"documentType": "issued-invoice",
|
||||||
|
"documentId": "019c1ef1-7c3d-79ed-a12f-995cdc40252f",
|
||||||
|
"companyId": "5e4dc5b3-96b9-4968-9490-14bd032fec5f",
|
||||||
|
"companySlug": "rodax",
|
||||||
|
"format": "PDF",
|
||||||
|
"languageCode": "es",
|
||||||
|
"filename": "factura-021.pdf",
|
||||||
|
"cacheKey": "issued-invoice:5e4dc5b3-96b9-4968-9490-14bd032fec5f:021:v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@erp/factuges-web",
|
"name": "@erp/factuges-web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.7",
|
"version": "0.3.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host --clearScreen false",
|
"dev": "vite --host --clearScreen false",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@erp/auth",
|
"name": "@erp/auth",
|
||||||
"version": "0.1.7",
|
"version": "0.3.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@erp/core",
|
"name": "@erp/core",
|
||||||
"version": "0.1.7",
|
"version": "0.3.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -2,6 +2,7 @@ export interface IDocumentMetadata {
|
|||||||
readonly documentType: string;
|
readonly documentType: string;
|
||||||
readonly documentId: string;
|
readonly documentId: string;
|
||||||
readonly companyId: string;
|
readonly companyId: string;
|
||||||
|
readonly companySlug: string;
|
||||||
readonly format: "PDF" | "HTML";
|
readonly format: "PDF" | "HTML";
|
||||||
readonly languageCode: string;
|
readonly languageCode: string;
|
||||||
readonly filename: string;
|
readonly filename: string;
|
||||||
|
|||||||
@ -76,6 +76,7 @@ export class DocumentGenerationService<TSnapshot> {
|
|||||||
try {
|
try {
|
||||||
document = await this.postProcessor.process(document, metadata);
|
document = await this.postProcessor.process(document, metadata);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
return Result.fail(DocumentGenerationError.postProcess(error));
|
return Result.fail(DocumentGenerationError.postProcess(error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
export * from "./documents";
|
export * from "./documents";
|
||||||
|
export * from "./presenters";
|
||||||
export * from "./renderers";
|
export * from "./renderers";
|
||||||
export * from "./snapshot-builders";
|
export * from "./snapshot-builders";
|
||||||
|
|||||||
4
modules/core/src/api/application/presenters/index.ts
Normal file
4
modules/core/src/api/application/presenters/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from "./presenter";
|
||||||
|
export * from "./presenter.interface";
|
||||||
|
export * from "./presenter-registry";
|
||||||
|
export * from "./presenter-registry.interface";
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import type { DTO } from "@erp/core/common";
|
import type { DTO } from "@erp/core/common";
|
||||||
|
|
||||||
import type { ISnapshotBuilder } from "./snapshot-builder.interface";
|
import type { IPresenter } from "./presenter.interface";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔑 Claves de proyección comunes para seleccionar presenters
|
* 🔑 Claves de proyección comunes para seleccionar presenters
|
||||||
@ -25,17 +25,17 @@ export interface IPresenterRegistry {
|
|||||||
*/
|
*/
|
||||||
getPresenter<TSource, F extends PresenterFormat = "DTO">(
|
getPresenter<TSource, F extends PresenterFormat = "DTO">(
|
||||||
key: Omit<PresenterKey, "format"> & { format?: F }
|
key: Omit<PresenterKey, "format"> & { format?: F }
|
||||||
): ISnapshotBuilder<TSource, PresenterFormatOutputMap[F]>;
|
): IPresenter<TSource, PresenterFormatOutputMap[F]>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registra un mapper de dominio bajo una clave de proyección.
|
* Registra un mapper de dominio bajo una clave de proyección.
|
||||||
*/
|
*/
|
||||||
registerPresenter<TSource, TOutput>(
|
registerPresenter<TSource, TOutput>(
|
||||||
key: PresenterKey,
|
key: PresenterKey,
|
||||||
presenter: ISnapshotBuilder<TSource, TOutput>
|
presenter: IPresenter<TSource, TOutput>
|
||||||
): this;
|
): this;
|
||||||
|
|
||||||
registerPresenters(
|
registerPresenters(
|
||||||
presenters: Array<{ key: PresenterKey; presenter: ISnapshotBuilder<unknown, unknown> }>
|
presenters: Array<{ key: PresenterKey; presenter: IPresenter<unknown, unknown> }>
|
||||||
): this;
|
): this;
|
||||||
}
|
}
|
||||||
@ -1,10 +1,10 @@
|
|||||||
import { ApplicationError } from "@repo/rdx-ddd";
|
import { ApplicationError } from "@repo/rdx-ddd";
|
||||||
|
|
||||||
|
import type { IPresenter } from "./presenter.interface";
|
||||||
import type { IPresenterRegistry, PresenterKey } from "./presenter-registry.interface";
|
import type { IPresenterRegistry, PresenterKey } from "./presenter-registry.interface";
|
||||||
import type { ISnapshotBuilder } from "./snapshot-builder.interface";
|
|
||||||
|
|
||||||
export class InMemoryPresenterRegistry implements IPresenterRegistry {
|
export class InMemoryPresenterRegistry implements IPresenterRegistry {
|
||||||
private registry: Map<string, ISnapshotBuilder<any, any>> = new Map();
|
private registry: Map<string, IPresenter<any, any>> = new Map();
|
||||||
|
|
||||||
private _normalizeKey(key: PresenterKey): PresenterKey {
|
private _normalizeKey(key: PresenterKey): PresenterKey {
|
||||||
return {
|
return {
|
||||||
@ -29,13 +29,13 @@ export class InMemoryPresenterRegistry implements IPresenterRegistry {
|
|||||||
|
|
||||||
private _registerPresenter<TSource, TOutput>(
|
private _registerPresenter<TSource, TOutput>(
|
||||||
key: PresenterKey,
|
key: PresenterKey,
|
||||||
presenter: ISnapshotBuilder<TSource, TOutput>
|
presenter: IPresenter<TSource, TOutput>
|
||||||
): void {
|
): void {
|
||||||
const exactKey = this._buildKey(key);
|
const exactKey = this._buildKey(key);
|
||||||
this.registry.set(exactKey, presenter);
|
this.registry.set(exactKey, presenter);
|
||||||
}
|
}
|
||||||
|
|
||||||
getPresenter<TSource, TOutput>(key: PresenterKey): ISnapshotBuilder<TSource, TOutput> {
|
getPresenter<TSource, TOutput>(key: PresenterKey): IPresenter<TSource, TOutput> {
|
||||||
const exactKey = this._buildKey(key);
|
const exactKey = this._buildKey(key);
|
||||||
|
|
||||||
// 1) Intentar clave exacta
|
// 1) Intentar clave exacta
|
||||||
@ -76,7 +76,7 @@ export class InMemoryPresenterRegistry implements IPresenterRegistry {
|
|||||||
|
|
||||||
registerPresenter<TSource, TOutput>(
|
registerPresenter<TSource, TOutput>(
|
||||||
key: PresenterKey,
|
key: PresenterKey,
|
||||||
presenter: ISnapshotBuilder<TSource, TOutput>
|
presenter: IPresenter<TSource, TOutput>
|
||||||
): this {
|
): this {
|
||||||
this._registerPresenter(key, presenter);
|
this._registerPresenter(key, presenter);
|
||||||
return this;
|
return this;
|
||||||
@ -85,7 +85,7 @@ export class InMemoryPresenterRegistry implements IPresenterRegistry {
|
|||||||
* ✅ Registro en lote de presentadores.
|
* ✅ Registro en lote de presentadores.
|
||||||
*/
|
*/
|
||||||
registerPresenters(
|
registerPresenters(
|
||||||
presenters: Array<{ key: PresenterKey; presenter: ISnapshotBuilder<any, any> }>
|
presenters: Array<{ key: PresenterKey; presenter: IPresenter<any, any> }>
|
||||||
): this {
|
): this {
|
||||||
for (const { key, presenter } of presenters) {
|
for (const { key, presenter } of presenters) {
|
||||||
this._registerPresenter(key, presenter);
|
this._registerPresenter(key, presenter);
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
import type { DTO } from "../../../common/types";
|
||||||
|
|
||||||
|
export type IPresenterOutputParams = Record<string, unknown>;
|
||||||
|
|
||||||
|
export interface IPresenter<TSource, TOutput = DTO> {
|
||||||
|
toOutput(source: TSource, params?: IPresenterOutputParams): TOutput | Promise<TOutput>;
|
||||||
|
}
|
||||||
11
modules/core/src/api/application/presenters/presenter.ts
Normal file
11
modules/core/src/api/application/presenters/presenter.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import type { IPresenter, IPresenterOutputParams } from "./presenter.interface";
|
||||||
|
import type { IPresenterRegistry } from "./presenter-registry.interface";
|
||||||
|
|
||||||
|
export abstract class Presenter<TSource = unknown, TOutput = unknown>
|
||||||
|
implements IPresenter<TSource, TOutput>
|
||||||
|
{
|
||||||
|
constructor(protected presenterRegistry: IPresenterRegistry) {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
abstract toOutput(source: TSource, params?: IPresenterOutputParams): TOutput;
|
||||||
|
}
|
||||||
@ -1,4 +1,2 @@
|
|||||||
export * from "./presenter-registry";
|
|
||||||
export * from "./presenter-registry.interface";
|
|
||||||
export * from "./snapshot-builder";
|
export * from "./snapshot-builder";
|
||||||
export * from "./snapshot-builder.interface";
|
export * from "./snapshot-builder.interface";
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { IPresenterRegistry } from "./presenter-registry.interface";
|
import type { IPresenterRegistry } from "../presenters";
|
||||||
|
|
||||||
import type { ISnapshotBuilder, ISnapshotBuilderParams } from "./snapshot-builder.interface";
|
import type { ISnapshotBuilder, ISnapshotBuilderParams } from "./snapshot-builder.interface";
|
||||||
|
|
||||||
export abstract class SnapshotBuilder<TSource = unknown, TOutput = unknown>
|
export abstract class SnapshotBuilder<TSource = unknown, TOutput = unknown>
|
||||||
|
|||||||
@ -22,9 +22,14 @@ export const buildCoreDocumentsDI = (env: NodeJS.ProcessEnv) => {
|
|||||||
const signingContextResolver = new EnvCompanySigningContextResolver(env);
|
const signingContextResolver = new EnvCompanySigningContextResolver(env);
|
||||||
|
|
||||||
const signingService = new RestDocumentSigningService({
|
const signingService = new RestDocumentSigningService({
|
||||||
signUrl: String(env.SIGNING_BASE_URL),
|
signUrl: String(env.SIGNING_SERVICE_URL),
|
||||||
timeoutMs: env.SIGNING_TIMEOUT_MS ? Number.parseInt(env.SIGNING_TIMEOUT_MS, 10) : 15_000,
|
method: String(env.SIGNING_SERVICE_METHOD),
|
||||||
maxRetries: env.SIGNING_MAX_RETRIES ? Number.parseInt(env.SIGNING_MAX_RETRIES, 10) : 2,
|
timeoutMs: env.SIGNING_SERVICE_TIMEOUT_MS
|
||||||
|
? Number.parseInt(env.SIGNING_SERVICE_TIMEOUT_MS, 10)
|
||||||
|
: 15_000,
|
||||||
|
maxRetries: env.SIGNING_SERVICE_MAX_RETRIES
|
||||||
|
? Number.parseInt(env.SIGNING_SERVICE_MAX_RETRIES, 10)
|
||||||
|
: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cache para documentos firmados
|
// Cache para documentos firmados
|
||||||
|
|||||||
@ -52,20 +52,15 @@ export class EnvCompanySigningContextResolver implements ISigningContextResolver
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async resolveForCompany(companyId: string): Promise<ICompanyCertificateContext | null> {
|
async resolveForCompany(companySlug: string): Promise<ICompanyCertificateContext | null> {
|
||||||
/**
|
const record = this.records[companySlug];
|
||||||
* En esta implementación:
|
|
||||||
* - companyId === companySlug
|
|
||||||
* - No hay lookup adicional
|
|
||||||
*/
|
|
||||||
const record = this.records[companyId];
|
|
||||||
|
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
companySlug: companyId,
|
companySlug,
|
||||||
certificateSecretName: record.certificateSecretName,
|
certificateSecretName: record.certificateSecretName,
|
||||||
certificatePasswordSecretName: record.certificatePasswordSecretName,
|
certificatePasswordSecretName: record.certificatePasswordSecretName,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
|
|
||||||
import { Result, buildSafePath } from "@repo/rdx-utils";
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import { FastReportExecutionError } from "./fastreport-errors";
|
import { FastReportExecutionError } from "./fastreport-errors";
|
||||||
|
|
||||||
@ -18,7 +18,7 @@ export type FastReportProcessRunnerArgs = {
|
|||||||
templatePath: string; // Path to FRX template (required)
|
templatePath: string; // Path to FRX template (required)
|
||||||
data: string; // JSON data as string
|
data: string; // JSON data as string
|
||||||
format: "PDF" | "HTML";
|
format: "PDF" | "HTML";
|
||||||
workdir: string; // Directorio de trabajo temporal
|
output: string; // Directorio de trabajo temporal
|
||||||
};
|
};
|
||||||
|
|
||||||
export class FastReportProcessRunner {
|
export class FastReportProcessRunner {
|
||||||
@ -26,40 +26,40 @@ export class FastReportProcessRunner {
|
|||||||
executablePath: string,
|
executablePath: string,
|
||||||
executableArgs: FastReportProcessRunnerArgs
|
executableArgs: FastReportProcessRunnerArgs
|
||||||
): Promise<Result<Buffer | string, FastReportExecutionError>> {
|
): Promise<Result<Buffer | string, FastReportExecutionError>> {
|
||||||
const { templatePath, data, format, workdir } = executableArgs;
|
const { templatePath, data, format, output } = executableArgs;
|
||||||
|
|
||||||
// Guardar datos de entrada en JSON
|
// Guardar datos de entrada en JSON
|
||||||
const dataPath = buildSafePath({ basePath: workdir, segments: [], filename: "data.json" });
|
//const dataPath = buildSafePath({ basePath: workdir, segments: [], filename: "data.json" });
|
||||||
|
|
||||||
// Path de output según formato y con
|
// Path de output según formato y con
|
||||||
const outputPath = buildSafePath({
|
/*const outputPath = buildSafePath({
|
||||||
basePath: workdir,
|
basePath: workdir,
|
||||||
segments: [],
|
segments: [],
|
||||||
filename: format === "PDF" ? "output.pdf" : "output.html",
|
filename: format === "PDF" ? "output.pdf" : "output.html",
|
||||||
});
|
});
|
||||||
|
|
||||||
await fs.writeFile(dataPath, data, "utf-8");
|
await fs.writeFile(dataPath, data, "utf-8");*/
|
||||||
|
|
||||||
const args = this.buildArgs({
|
const args = this.buildArgs({
|
||||||
templatePath,
|
templatePath,
|
||||||
dataPath,
|
data,
|
||||||
outputPath,
|
output,
|
||||||
format,
|
format,
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.executeProcess(executablePath, args, outputPath, executableArgs.format);
|
return this.executeProcess(executablePath, args, output, executableArgs.format);
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildArgs(params: {
|
private buildArgs(params: {
|
||||||
templatePath: string;
|
templatePath: string;
|
||||||
dataPath: string;
|
data: string;
|
||||||
outputPath: string;
|
output: string;
|
||||||
format: "PDF" | "HTML";
|
format: "PDF" | "HTML";
|
||||||
}): string[] {
|
}): string[] {
|
||||||
return [
|
return [
|
||||||
`--template=${params.templatePath}`,
|
`--template=${params.templatePath}`,
|
||||||
`--data=${params.dataPath}`,
|
`--data=${params.data}`,
|
||||||
`--output=${params.outputPath}`,
|
`--output=${params.output}`,
|
||||||
`--format=${params.format}`,
|
`--format=${params.format}`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,7 +38,7 @@ export class FastReportRenderer extends Renderer<unknown, FastReportRenderOutput
|
|||||||
await this.processRunner.run(executablePath, {
|
await this.processRunner.run(executablePath, {
|
||||||
templatePath: options.templatePath,
|
templatePath: options.templatePath,
|
||||||
data: inputPath,
|
data: inputPath,
|
||||||
workdir: outputPath,
|
output: outputPath,
|
||||||
format: options.format,
|
format: options.format,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -31,6 +31,7 @@ export class RestDocumentSigningService implements IDocumentSigningService {
|
|||||||
|
|
||||||
async sign(payload: Buffer, context: ICompanyCertificateContext): Promise<Buffer> {
|
async sign(payload: Buffer, context: ICompanyCertificateContext): Promise<Buffer> {
|
||||||
if (!this.circuitBreaker.canExecute()) {
|
if (!this.circuitBreaker.canExecute()) {
|
||||||
|
console.error(`Document signing service unavailable (${this.signUrl})`);
|
||||||
throw new Error("Document signing service unavailable (circuit open)");
|
throw new Error("Document signing service unavailable (circuit open)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -169,11 +169,13 @@ const defaultRules: ReadonlyArray<ErrorToApiRule> = [
|
|||||||
matches: (e) => isDocumentGenerationError(e),
|
matches: (e) => isDocumentGenerationError(e),
|
||||||
build: (e) => {
|
build: (e) => {
|
||||||
const error = e as DocumentGenerationError;
|
const error = e as DocumentGenerationError;
|
||||||
|
const cause = error.cause as Error;
|
||||||
|
console.error(cause.message, cause);
|
||||||
const title =
|
const title =
|
||||||
error.documentErrorType === "METADATA"
|
error.documentErrorType === "METADATA"
|
||||||
? "Invalid document render error"
|
? "Invalid document render error"
|
||||||
: "Unexcepted document render error";
|
: "Unexcepted document render error";
|
||||||
return new InternalApiError(error.message, title);
|
return new InternalApiError(cause.message, title);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@erp/customer-invoices",
|
"name": "@erp/customer-invoices",
|
||||||
"version": "0.1.7",
|
"version": "0.3.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import type { IssuedInvoiceReportTaxSnapshot } from "./issued-invoice-report-tax
|
|||||||
export interface IssuedInvoiceReportSnapshot {
|
export interface IssuedInvoiceReportSnapshot {
|
||||||
id: string;
|
id: string;
|
||||||
company_id: string;
|
company_id: string;
|
||||||
|
company_slug: string;
|
||||||
invoice_number: string;
|
invoice_number: string;
|
||||||
series: string;
|
series: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
|||||||
@ -1,25 +0,0 @@
|
|||||||
import type { IDocumentSigningService } from "../../services";
|
|
||||||
import {
|
|
||||||
type IIssuedInvoiceDocumentRenderer,
|
|
||||||
IssuedInvoiceDocumentReportService,
|
|
||||||
} from "../services";
|
|
||||||
|
|
||||||
export const buildIssuedInvoiceDocumentService = (
|
|
||||||
renderer: IIssuedInvoiceDocumentRenderer,
|
|
||||||
signingService: IDocumentSigningService,
|
|
||||||
certificateResolver: ICompanyCertificateResolver
|
|
||||||
) => {
|
|
||||||
const { fastReport } = renderers;
|
|
||||||
const issuedInvoiceReportRenderer = new IssuedInvoiceFastReportRenderer(
|
|
||||||
fastReport.executableResolver,
|
|
||||||
fastReport.processRunner,
|
|
||||||
fastReport.templateResolver,
|
|
||||||
fastReport.reportStorage
|
|
||||||
);
|
|
||||||
|
|
||||||
const issuedInvoiceDocumentRenderer = new IssuedInvoiceDocumentRenderer(
|
|
||||||
issuedInvoiceReportRenderer
|
|
||||||
);
|
|
||||||
|
|
||||||
return new IssuedInvoiceDocumentReportService(renderer, signingService, certificateResolver);
|
|
||||||
};
|
|
||||||
@ -1,4 +1,3 @@
|
|||||||
export * from "./documents.di";
|
|
||||||
export * from "./finder.di";
|
export * from "./finder.di";
|
||||||
export * from "./snapshot-builders.di";
|
export * from "./snapshot-builders.di";
|
||||||
export * from "./use-cases.di";
|
export * from "./use-cases.di";
|
||||||
|
|||||||
@ -25,6 +25,7 @@ export class IssuedInvoiceDocumentMetadataFactory
|
|||||||
documentType: "issued-invoice",
|
documentType: "issued-invoice",
|
||||||
documentId: snapshot.id,
|
documentId: snapshot.id,
|
||||||
companyId: snapshot.company_id,
|
companyId: snapshot.company_id,
|
||||||
|
companySlug: snapshot.company_slug,
|
||||||
format: "PDF",
|
format: "PDF",
|
||||||
languageCode: snapshot.language_code ?? "es",
|
languageCode: snapshot.language_code ?? "es",
|
||||||
filename: this.buildFilename(snapshot),
|
filename: this.buildFilename(snapshot),
|
||||||
|
|||||||
@ -37,6 +37,7 @@ export class IssuedInvoiceReportSnapshotBuilder implements IIssuedInvoiceReportS
|
|||||||
return {
|
return {
|
||||||
id: snapshot.id,
|
id: snapshot.id,
|
||||||
company_id: snapshot.company_id,
|
company_id: snapshot.company_id,
|
||||||
|
company_slug: "rodax",
|
||||||
invoice_number: snapshot.invoice_number,
|
invoice_number: snapshot.invoice_number,
|
||||||
series: snapshot.series,
|
series: snapshot.series,
|
||||||
status: snapshot.status,
|
status: snapshot.status,
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import type {
|
import type {
|
||||||
ICertificateResolver,
|
|
||||||
IDocument,
|
IDocument,
|
||||||
IDocumentMetadata,
|
IDocumentMetadata,
|
||||||
IDocumentPostProcessor,
|
IDocumentPostProcessor,
|
||||||
IDocumentSigningService,
|
IDocumentSigningService,
|
||||||
|
ISigningContextResolver,
|
||||||
} from "@erp/core/api";
|
} from "@erp/core/api";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -15,18 +15,21 @@ import type {
|
|||||||
*/
|
*/
|
||||||
export class DigitalSignaturePostProcessor implements IDocumentPostProcessor {
|
export class DigitalSignaturePostProcessor implements IDocumentPostProcessor {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly certificateResolver: ICertificateResolver,
|
private readonly certificateResolver: ISigningContextResolver,
|
||||||
private readonly signingService: IDocumentSigningService
|
private readonly signingService: IDocumentSigningService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async process(document: IDocument, metadata: IDocumentMetadata): Promise<IDocument> {
|
async process(document: IDocument, metadata: IDocumentMetadata): Promise<IDocument> {
|
||||||
// Validación defensiva mínima
|
// Validación defensiva mínima
|
||||||
if (document.mimeType !== "application/pdf") {
|
if (document.mimeType !== "application/pdf") {
|
||||||
throw new Error("DigitalSignaturePostProcessor can only sign PDF documents");
|
throw new Error("[DigitalSignaturePostProcessor] can only sign PDF documents");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Resolver certificado de la empresa
|
// 1. Resolver certificado de la empresa
|
||||||
const certificate = await this.certificateResolver.resolveForCompany(metadata.companyId);
|
const certificate = await this.certificateResolver.resolveForCompany(metadata.companySlug);
|
||||||
|
if (!certificate) {
|
||||||
|
throw new Error("[DigitalSignaturePostProcessor] Compny certificate is undefined");
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Firmar payload
|
// 2. Firmar payload
|
||||||
const signedPayload = await this.signingService.sign(document.payload, certificate);
|
const signedPayload = await this.signingService.sign(document.payload, certificate);
|
||||||
|
|||||||
@ -41,15 +41,15 @@ export class ReportIssuedInvoiceController extends ExpressController {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return result.match(
|
return result.match(
|
||||||
({ data, filename }) => {
|
({ payload, filename }) => {
|
||||||
if (format === "PDF") {
|
if (format === "PDF") {
|
||||||
return this.downloadPDF(data as Buffer<ArrayBuffer>, String(filename));
|
return this.downloadPDF(payload as Buffer<ArrayBuffer>, String(filename));
|
||||||
}
|
}
|
||||||
if (format === "HTML") {
|
if (format === "HTML") {
|
||||||
return this.downloadHTML(data as string);
|
return this.downloadHTML(payload as unknown as string);
|
||||||
}
|
}
|
||||||
// JSON
|
// JSON
|
||||||
return this.json(data);
|
return this.json(payload);
|
||||||
},
|
},
|
||||||
(err) => this.handleError(err)
|
(err) => this.handleError(err)
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@erp/customers",
|
"name": "@erp/customers",
|
||||||
"version": "0.1.7",
|
"version": "0.3.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { Presenter } from "@erp/core/api";
|
import { Presenter } from "@erp/core/api";
|
||||||
import { toEmptyString } from "@repo/rdx-ddd";
|
import { toEmptyString } from "@repo/rdx-ddd";
|
||||||
import { GetCustomerByIdResponseDTO } from "../../../../common/dto";
|
|
||||||
import { Customer } from "../../../domain";
|
import type { GetCustomerByIdResponseDTO } from "../../../../common/dto";
|
||||||
|
import type { Customer } from "../../../domain";
|
||||||
|
|
||||||
export class CustomerFullPresenter extends Presenter<Customer, GetCustomerByIdResponseDTO> {
|
export class CustomerFullPresenter extends Presenter<Customer, GetCustomerByIdResponseDTO> {
|
||||||
toOutput(customer: Customer): GetCustomerByIdResponseDTO {
|
toOutput(customer: Customer): GetCustomerByIdResponseDTO {
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import { CriteriaDTO } from "@erp/core";
|
import type { CriteriaDTO } from "@erp/core";
|
||||||
import { Presenter } from "@erp/core/api";
|
import { Presenter } from "@erp/core/api";
|
||||||
import { Criteria } from "@repo/rdx-criteria/server";
|
import type { Criteria } from "@repo/rdx-criteria/server";
|
||||||
import { toEmptyString } from "@repo/rdx-ddd";
|
import { toEmptyString } from "@repo/rdx-ddd";
|
||||||
import { Collection } from "@repo/rdx-utils";
|
import type { Collection } from "@repo/rdx-utils";
|
||||||
import { ListCustomersResponseDTO } from "../../../../common/dto";
|
|
||||||
import { CustomerListDTO } from "../../../infrastructure/mappers";
|
import type { ListCustomersResponseDTO } from "../../../../common/dto";
|
||||||
|
import type { CustomerListDTO } from "../../../infrastructure/mappers";
|
||||||
|
|
||||||
export class ListCustomersPresenter extends Presenter {
|
export class ListCustomersPresenter extends Presenter {
|
||||||
protected _mapCustomer(customer: CustomerListDTO) {
|
protected _mapCustomer(customer: CustomerListDTO) {
|
||||||
|
|||||||
@ -4,15 +4,16 @@ import {
|
|||||||
InMemoryPresenterRegistry,
|
InMemoryPresenterRegistry,
|
||||||
SequelizeTransactionManager,
|
SequelizeTransactionManager,
|
||||||
} from "@erp/core/api";
|
} from "@erp/core/api";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CreateCustomerUseCase,
|
CreateCustomerUseCase,
|
||||||
CustomerApplicationService,
|
|
||||||
CustomerFullPresenter,
|
|
||||||
GetCustomerUseCase,
|
GetCustomerUseCase,
|
||||||
ListCustomersPresenter,
|
|
||||||
ListCustomersUseCase,
|
ListCustomersUseCase,
|
||||||
UpdateCustomerUseCase,
|
UpdateCustomerUseCase,
|
||||||
} from "../application";
|
} from "../application";
|
||||||
|
import { CustomerApplicationService } from "../application/customer-application.service";
|
||||||
|
import { CustomerFullPresenter, ListCustomersPresenter } from "../application/presenters";
|
||||||
|
|
||||||
import { CustomerDomainMapper, CustomerListMapper } from "./mappers";
|
import { CustomerDomainMapper, CustomerListMapper } from "./mappers";
|
||||||
import { CustomerRepository } from "./sequelize";
|
import { CustomerRepository } from "./sequelize";
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@erp/doc-numbering",
|
"name": "@erp/doc-numbering",
|
||||||
"version": "0.1.7",
|
"version": "0.3.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@repo/rdx-criteria",
|
"name": "@repo/rdx-criteria",
|
||||||
"version": "0.2.7",
|
"version": "0.3.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@repo/rdx-ddd",
|
"name": "@repo/rdx-ddd",
|
||||||
"version": "0.2.7",
|
"version": "0.3.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@repo/rdx-logger",
|
"name": "@repo/rdx-logger",
|
||||||
"version": "0.2.7",
|
"version": "0.3.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@repo/rdx-utils",
|
"name": "@repo/rdx-utils",
|
||||||
"version": "0.2.7",
|
"version": "0.3.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -66,7 +66,7 @@ services:
|
|||||||
traefik.http.routers.factuges_rodax_phpmyadmin.middlewares: "factuges_rodax_phpmyadmin_strip"
|
traefik.http.routers.factuges_rodax_phpmyadmin.middlewares: "factuges_rodax_phpmyadmin_strip"
|
||||||
|
|
||||||
|
|
||||||
# --- API (imagen versionada generada por build-api.sh) ---
|
# --- API ---
|
||||||
api:
|
api:
|
||||||
image: ${API_IMAGE}
|
image: ${API_IMAGE}
|
||||||
container_name: factuges_rodax_api
|
container_name: factuges_rodax_api
|
||||||
@ -76,18 +76,32 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
COMPANY: rodax
|
COMPANY_SLUG: ${COMPANY_SLUG}
|
||||||
PORT: ${SERVER_PORT:-3002}
|
FRONTEND_URL: ${FRONTEND_URL}
|
||||||
DB_DIALECT: "mysql"
|
API_PORT: ${API_PORT}
|
||||||
|
|
||||||
DB_HOST: "db"
|
DB_HOST: "db"
|
||||||
DB_PORT: ${DB_PORT}
|
DB_PORT: ${DB_PORT}
|
||||||
|
DB_DIALECT: ${DB_DIALECT}
|
||||||
DB_NAME: ${DB_NAME}
|
DB_NAME: ${DB_NAME}
|
||||||
DB_USER: ${DB_USER}
|
DB_USER: ${DB_USER}
|
||||||
DB_PASS: ${DB_PASS}
|
DB_PASS: ${DB_PASS}
|
||||||
FRONTEND_URL: ${FRONTEND_URL}
|
|
||||||
TEMPLATES_PATH: ${TEMPLATES_PATH}
|
TEMPLATES_PATH: ${TEMPLATES_PATH}
|
||||||
DOCUMENTS_PATH: ${DOCUMENTS_PATH}
|
|
||||||
FASTREPORT_BIN: ${FASTREPORT_BIN}
|
FASTREPORT_BIN: ${FASTREPORT_BIN}
|
||||||
|
DOCUMENTS_PATH: ${DOCUMENTS_PATH}
|
||||||
|
|
||||||
|
SIGNED_DOCUMENTS_PATH: ${SIGNED_DOCUMENTS_PATH}
|
||||||
|
SIGNED_DOCUMENTS_CACHE_PATH: ${SIGNED_DOCUMENTS_CACHE_PATH}
|
||||||
|
|
||||||
|
SIGNING_SERVICE_URL: ${SIGNING_SERVICE_URL}
|
||||||
|
SIGNING_SERVICE_METHOD: ${SIGNING_SERVICE_METHOD}
|
||||||
|
SIGNING_SERVICE_TIMEOUT_MS: ${SIGNING_SERVICE_TIMEOUT_MS}
|
||||||
|
SIGNING_SERVICE_MAX_RETRIES: ${SIGNING_SERVICE_MAX_RETRIES}
|
||||||
|
|
||||||
|
COMPANY_CERTIFICATES_JSON: ${COMPANY_CERTIFICATES_JSON}
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./volumes/templates:/shared/templates:ro
|
- ./volumes/templates:/shared/templates:ro
|
||||||
- ./volumes/certificates:/shared/certificates:ro
|
- ./volumes/certificates:/shared/certificates:ro
|
||||||
|
|||||||
@ -1,15 +1,18 @@
|
|||||||
# Identidad de la compañía
|
# Identidad de la compañía
|
||||||
COMPANY=rodax
|
COMPANY_SLUG=rodax
|
||||||
|
|
||||||
# Dominios
|
# Dominios
|
||||||
DOMAIN=factuges.rodax-software.local
|
DOMAIN=factuges.rodax-software.local
|
||||||
|
|
||||||
# MariaDB
|
# MariaDB
|
||||||
DB_ROOT_PASS=verysecret
|
DB_HOST=db
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DIALECT=mysql
|
||||||
|
DB_NAME=rodax_db
|
||||||
DB_USER=rodax_usr
|
DB_USER=rodax_usr
|
||||||
DB_PASS=supersecret
|
DB_PASS=supersecret
|
||||||
DB_NAME=rodax_db
|
DB_ROOT_PASS=verysecret
|
||||||
DB_PORT=3306
|
|
||||||
|
|
||||||
# API
|
# API
|
||||||
API_PORT=3002
|
API_PORT=3002
|
||||||
@ -23,6 +26,22 @@ TEMPLATES_PATH=/shared/templates
|
|||||||
FASTREPORT_BIN=/repo/tools/FastReportCliGenerator
|
FASTREPORT_BIN=/repo/tools/FastReportCliGenerator
|
||||||
DOCUMENTS_PATH=/shared/documents
|
DOCUMENTS_PATH=/shared/documents
|
||||||
|
|
||||||
|
# Firma de documentos
|
||||||
|
SIGNED_DOCUMENTS_PATH=/shared/documents
|
||||||
|
SIGNED_DOCUMENTS_CACHE_PATH=/shared/cache
|
||||||
|
|
||||||
|
SIGNING_SERVICE_URL=http://signing-service:8000/documents/sign
|
||||||
|
SIGNING_SERVICE_METHOD=POST
|
||||||
|
SIGNING_SERVICE_TIMEOUT_MS=15_000
|
||||||
|
SIGNING_SERVICE_MAX_RETRIES=2
|
||||||
|
|
||||||
|
COMPANY_CERTIFICATES_JSON='{
|
||||||
|
"rodax": {
|
||||||
|
"certificateId": "no se que poner aqui",
|
||||||
|
"certificateSecretName": "certificate_secret_name",
|
||||||
|
"certificatePasswordSecretName": "certificate_password_secret_name"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
|
||||||
# SYNC
|
# SYNC
|
||||||
ENV = development
|
ENV = development
|
||||||
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue
Block a user