Initial commit: sap-intercompany-api
Backend NestJS + Prisma, dueno de la configuracion del worker (databases, scenarios, scenario_destinations, value_mappings) y lectura de webhook_events / replication_attempts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
2e92523641
25 files changed
+4707
No files matched your search
@@ -0,0 +1,8 @@
|
||||
DATABASE_URL="postgresql://intercompany:intercompany@localhost:5432/sap_intercompany?schema=public"
|
||||
|
||||
PORT=3001
|
||||
|
||||
# Habilita GET /scenarios/:code/source: ruta absoluta a la raíz del proyecto
|
||||
# sap-intercompany-worker (asume que ambos proyectos corren en la misma
|
||||
# máquina). De ahí se arma <WORKER_ROOT_PATH>/src/scenarios/<slug>.ts.
|
||||
WORKER_ROOT_PATH="../sap-intercompany-worker"
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
.env
|
||||
|
||||
/src/generated/prisma
|
||||
@@ -0,0 +1,71 @@
|
||||
# sap-intercompany-api
|
||||
|
||||
Backend HTTP (NestJS) dueño de toda la configuración de
|
||||
[`sap-intercompany-worker`](../sap-intercompany-worker) — bases de datos,
|
||||
escenarios, destinos, mapeos de valor — y del log de replicación
|
||||
(`webhook_events` + `replication_attempts`), sobre la misma Postgres.
|
||||
Reemplaza a las rutas `/replication/*` que tenía `sap-webhook-api`.
|
||||
|
||||
`sap-intercompany-worker` no expone HTTP a propósito (sigue siendo un
|
||||
script plano por cron); esta API es la única superficie HTTP para
|
||||
administrar su configuración, pensada para ser consumida por
|
||||
`sap-intercompany-viewer` (frontend, aún no implementado).
|
||||
|
||||
## Qué NO hace
|
||||
|
||||
- **No corre migraciones.** `prisma/schema.prisma` es una copia de la del
|
||||
worker (mismo `DATABASE_URL`); acá solo se corre `prisma generate` para
|
||||
tener un cliente tipado. El worker sigue siendo el dueño del schema.
|
||||
- **No genera la lógica de `filter`/`transform` de un escenario.**
|
||||
`POST /scenarios` crea la fila en Postgres y, si no existe, escribe el
|
||||
scaffold de 3 secciones en
|
||||
`sap-intercompany-worker/src/scenarios/<slug>.ts` (mismo scaffold que
|
||||
genera `scenario:add` del CLI del worker) — el cuerpo real de `filter`/
|
||||
`transform` siempre se edita a mano, en el editor del usuario.
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `GET/POST /databases`, `GET /databases/:code`, `PATCH /databases/:code`
|
||||
(incluye desactivar con `{"active": false}`). Las respuestas nunca
|
||||
incluyen `password`.
|
||||
- `GET /scenarios`, `GET /scenarios/:code`, `POST /scenarios`,
|
||||
`PATCH /scenarios/:code`, `POST /scenarios/:code/rename`.
|
||||
- `GET /scenarios/:code/source` — código fuente crudo del `.ts` (solo
|
||||
lectura; requiere `WORKER_ROOT_PATH`).
|
||||
- `GET/POST /scenarios/:code/destinations`,
|
||||
`DELETE /scenarios/:code/destinations/:databaseCode`.
|
||||
- `GET/POST /scenarios/:code/mappings` (`GET` acepta `?fieldName=`).
|
||||
- `GET /events` (filtros `from`, `to`, `businessObject`, `scenarioCode`,
|
||||
`status`), `GET /events/:id` (incluye `replication_attempts`).
|
||||
- `POST /events/:id/reprocess` — pone el evento en `status='pending'` de
|
||||
nuevo; el worker lo va a reprocesar contra **todos** los escenarios y
|
||||
destinos aplicables en su próximo ciclo, no solo el que falló.
|
||||
|
||||
`POST /databases` y `POST /scenarios` fallan con `409` si el `code` ya
|
||||
existe (a diferencia del CLI del worker, que hace upsert) — para
|
||||
actualizar un existente se usa `PATCH`.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env # completar DATABASE_URL (la misma del worker) y WORKER_ROOT_PATH
|
||||
npx prisma generate # cliente tipado propio; NUNCA correr `prisma migrate` acá
|
||||
npm start # http://localhost:3001
|
||||
```
|
||||
|
||||
## Stack
|
||||
|
||||
- NestJS (mismo framework/estilo que `sap-webhook-api`), corrido con `tsx`
|
||||
en vez de `nest build` — mismo enfoque ESM + TypeScript nativo que ya usa
|
||||
`sap-intercompany-worker`, para poder importar directo el cliente Prisma
|
||||
generado (`prisma-client`, salida `.ts`) sin fricción de compilación.
|
||||
- Prisma 7 + `@prisma/adapter-pg`, contra la Postgres compartida con el
|
||||
worker.
|
||||
|
||||
Nota de implementación: la inyección de dependencias de Nest usa
|
||||
`@Inject(Token)` explícito en cada constructor (en vez de depender solo del
|
||||
tipo declarado) porque bajo `tsx`/esbuild la emisión de
|
||||
`design:paramtypes` (`emitDecoratorMetadata`) no es confiable — sin esto,
|
||||
Nest instancia los controllers/servicios sin fallar pero con las
|
||||
dependencias en `undefined`.
|
||||
Generated
+3712
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "sap-intercompany-api",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend HTTP (NestJS) dueño de la configuración de sap-intercompany-worker (bases, escenarios, destinos, mapeos) y del log de replicación — consumido por sap-intercompany-viewer.",
|
||||
"type": "module",
|
||||
"main": "src/main.ts",
|
||||
"scripts": {
|
||||
"start": "tsx src/main.ts",
|
||||
"start:dev": "tsx watch src/main.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prisma:generate": "prisma generate"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@prisma/adapter-pg": "^7.9.1",
|
||||
"@prisma/client": "^7.9.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"prisma": "^7.9.1",
|
||||
"tsx": "^4.23.12",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// Copia de sap-intercompany-worker/prisma/schema.prisma — mismo DATABASE_URL,
|
||||
// misma Postgres. Esta API solo corre `prisma generate` para tener un
|
||||
// cliente tipado propio; las migraciones (`prisma migrate`) corren SIEMPRE
|
||||
// desde sap-intercompany-worker, que es el dueño del schema. Si el schema
|
||||
// del worker cambia, copiar los cambios acá y volver a generar.
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../src/generated/prisma"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
enum EventStatus {
|
||||
pending
|
||||
processing
|
||||
done
|
||||
partial
|
||||
failed
|
||||
filtered // ningún escenario activo para el objeto aceptó el mensaje: no se reintenta
|
||||
}
|
||||
|
||||
enum ScenarioDestinationMode {
|
||||
fixed // datos maestros: la lista de destinos está fija en ScenarioDestination
|
||||
payload // transacciones: el destino se resuelve leyendo routingField del payload (no implementado aún, ver docs)
|
||||
}
|
||||
|
||||
// Bandeja de entrada: un registro por evento recibido del webhook de SAP.
|
||||
// sap-webhook-api inserta acá (además del .json local) apenas recibe la notificación.
|
||||
model WebhookEvent {
|
||||
id String @id // el "id" del CloudEvent que manda SAP
|
||||
businessObject String @map("business_object") // 'BusinessPartners', 'Orders', etc.
|
||||
operation String // 'Created' | 'Updated'
|
||||
subject String // CardCode / DocEntry, según el objeto
|
||||
payload Json
|
||||
receivedAt DateTime @default(now()) @map("received_at")
|
||||
status EventStatus @default(pending)
|
||||
|
||||
attempts ReplicationAttempt[]
|
||||
|
||||
@@map("webhook_events")
|
||||
}
|
||||
|
||||
// Registro único de toda base de datos SAP alcanzable por Service Layer.
|
||||
// Ya no existe distinción de HQ vs. subsidiaria: cualquier fila puede ser
|
||||
// origen o destino, según lo que declare cada escenario.
|
||||
model Database {
|
||||
id Int @id @default(autoincrement())
|
||||
code String @unique // mnemónico corto y estable, ej. 'HQ', 'SUB-NORTE'; asignado una sola vez al crear, NO autoincremental
|
||||
name String
|
||||
baseUrl String @map("base_url")
|
||||
companyDb String @map("company_db")
|
||||
username String
|
||||
password String
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
originOfScenarios Scenario[] @relation("ScenarioOrigin")
|
||||
destinations ScenarioDestination[]
|
||||
valueMappings ScenarioValueMapping[]
|
||||
|
||||
@@map("databases")
|
||||
}
|
||||
|
||||
enum ScenarioKeyStrategy {
|
||||
portable // el keyField vale lo mismo en origen y destino (ej. CardCode, ItemCode): GET directo por clave
|
||||
udfLink // SAP asigna la clave por base (ej. SalesEmployeeCode): se busca por un UDF en el destino que guarda la clave de origen
|
||||
}
|
||||
|
||||
enum ScenarioKeyType {
|
||||
string // la clave va entre comillas en la URL OData: BusinessPartners('C23900')
|
||||
number // la clave va sin comillas: SalesPersons(5) — mandar comillas en un campo numérico da HTTP 400
|
||||
}
|
||||
|
||||
// Un escenario es una forma completa e independiente de procesar un business
|
||||
// object: su propio filtro, base origen, base(s) destino y transformación de
|
||||
// payload. Puede haber varios escenarios activos para el mismo business
|
||||
// object a la vez (ej. "clientes" y "proveedores" sobre BusinessPartners), y
|
||||
// un mismo evento puede matchear y ser procesado por más de uno.
|
||||
//
|
||||
// El filtro y la transformación del payload NO viven en esta tabla: cada
|
||||
// escenario tiene un archivo de código en src/scenarios/<code>.ts con
|
||||
// SIEMPRE las mismas dos partes — filter(record) -> boolean y
|
||||
// transform(record, ctx) -> record — porque ambos necesitan lógica
|
||||
// arbitraria (condiciones compuestas, elegir campos) que una tabla no puede
|
||||
// expresar. Lo que SÍ vive en tabla son los mapeos de valor puntuales que
|
||||
// transform consulta vía ctx.mapValue(...) dentro de su lógica — ver
|
||||
// ScenarioValueMapping más abajo y docs/ESCENARIOS.md — pensado para que a
|
||||
// futuro una UI permita cargarlos sin editar código, aunque el archivo siga
|
||||
// siendo quien decide cómo usarlos.
|
||||
model Scenario {
|
||||
id Int @id @default(autoincrement())
|
||||
code String @unique // 'BP_CLIENTES', 'BP_PROVEEDORES' — también el nombre del archivo en src/scenarios/
|
||||
name String
|
||||
businessObject String @map("business_object") // objeto que dispara el escenario y que se lee del origen (con qué se matchea webhook_events y con qué se hace el GET a la base origen)
|
||||
destinationBusinessObject String? @map("destination_business_object") // objeto a usar en el GET/POST/PATCH del DESTINO, si es distinto al de origen (ej. origen PurchaseOrders -> destino Orders). null = mismo que businessObject
|
||||
originDatabaseCode String @map("origin_database_code")
|
||||
originDatabase Database @relation("ScenarioOrigin", fields: [originDatabaseCode], references: [code])
|
||||
destinationMode ScenarioDestinationMode @map("destination_mode")
|
||||
keyField String @map("key_field") // clave del registro para GET/POST/PATCH, ej. 'CardCode', 'SalesEmployeeCode'
|
||||
keyType ScenarioKeyType @default(string) @map("key_type") // tipo de dato del keyField en el Service Layer (define si va entre comillas en la URL)
|
||||
keyStrategy ScenarioKeyStrategy @default(portable) @map("key_strategy")
|
||||
matchField String? @map("match_field") // solo si keyStrategy = udfLink: UDF en el destino que guarda el valor de keyField de origen, ej. 'U_HQ_SlpCode'
|
||||
routingField String? @map("routing_field") // solo si destinationMode = payload
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
destinations ScenarioDestination[]
|
||||
valueMappings ScenarioValueMapping[]
|
||||
attempts ReplicationAttempt[]
|
||||
|
||||
@@map("scenarios")
|
||||
}
|
||||
|
||||
// Destinos fijos de un escenario en modo 'fixed' (datos maestros). En modo
|
||||
// 'payload' (transacciones) no se usa: el destino se resuelve en runtime.
|
||||
model ScenarioDestination {
|
||||
id Int @id @default(autoincrement())
|
||||
scenarioId Int @map("scenario_id")
|
||||
scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
|
||||
databaseCode String @map("database_code")
|
||||
database Database @relation(fields: [databaseCode], references: [code])
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@unique([scenarioId, databaseCode])
|
||||
@@map("scenario_destinations")
|
||||
}
|
||||
|
||||
// Tabla de equivalencias que el archivo de transformación del escenario
|
||||
// consulta vía ctx.mapValue(fieldName, sourceValue) — el código decide QUÉ
|
||||
// campo mapear y QUÉ hacer si falta (fallar, dejar sin traducir, etc.); acá
|
||||
// solo vive el dato. Escopada por escenario, no global por objeto+campo,
|
||||
// porque el mismo campo puede tener dominios de valores distintos según el
|
||||
// escenario (ej. GroupCode de clientes vs. de proveedores). Pensada para
|
||||
// que a futuro una UI la administre sin tocar código.
|
||||
model ScenarioValueMapping {
|
||||
id Int @id @default(autoincrement())
|
||||
scenarioId Int @map("scenario_id")
|
||||
scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
|
||||
fieldName String @map("field_name")
|
||||
databaseCode String @map("database_code")
|
||||
database Database @relation(fields: [databaseCode], references: [code])
|
||||
sourceValue String @map("source_value")
|
||||
targetValue String @map("target_value")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@unique([scenarioId, fieldName, databaseCode, sourceValue])
|
||||
@@map("scenario_value_mappings")
|
||||
}
|
||||
|
||||
// Un registro por cada intento de réplica de un evento, dentro de UN
|
||||
// escenario puntual, hacia UNA base destino. origin_database y
|
||||
// destination_database son texto libre (sin FK) a propósito: es un log de
|
||||
// auditoría inmutable que no debe verse afectado si la base o el escenario
|
||||
// se borran o renombran después.
|
||||
model ReplicationAttempt {
|
||||
id Int @id @default(autoincrement())
|
||||
event WebhookEvent @relation(fields: [eventId], references: [id])
|
||||
eventId String @map("event_id")
|
||||
scenario Scenario @relation(fields: [scenarioId], references: [id])
|
||||
scenarioId Int @map("scenario_id")
|
||||
originDatabase String @map("origin_database")
|
||||
destinationDatabase String @map("destination_database") // 'N/A' si falló antes de resolver destino (ej. error leyendo el origen)
|
||||
verbUsed String @map("verb_used") // 'POST' | 'PATCH' | 'SKIPPED' | 'N/A'
|
||||
httpStatus Int? @map("http_status")
|
||||
errorMessage String? @map("error_message")
|
||||
payloadSent Json? @map("payload_sent") // payload ya transformado enviado al destino; se guarda en éxito Y en falla
|
||||
resultKey String? @map("result_key") // clave propia del destino cuando difiere de la de origen (keyStrategy=udfLink) o DocEntry (transacciones); null si no aplica
|
||||
attemptedAt DateTime @default(now()) @map("attempted_at")
|
||||
|
||||
@@index([eventId])
|
||||
@@index([scenarioId])
|
||||
@@index([attemptedAt])
|
||||
@@map("replication_attempts")
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
@Get()
|
||||
health() {
|
||||
return { status: 'ok', service: 'sap-intercompany-api' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AppController } from './app.controller.ts';
|
||||
import { DashboardModule } from './dashboard/dashboard.module.ts';
|
||||
import { DatabasesModule } from './databases/databases.module.ts';
|
||||
import { EventsModule } from './events/events.module.ts';
|
||||
import { PrismaModule } from './prisma/prisma.module.ts';
|
||||
import { ScenariosModule } from './scenarios/scenarios.module.ts';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, DatabasesModule, ScenariosModule, EventsModule, DashboardModule],
|
||||
controllers: [AppController],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get, Inject } from '@nestjs/common';
|
||||
import { DashboardService } from './dashboard.service.ts';
|
||||
|
||||
@Controller('dashboard')
|
||||
export class DashboardController {
|
||||
constructor(@Inject(DashboardService) private readonly dashboard: DashboardService) {}
|
||||
|
||||
@Get('stats')
|
||||
getStats() {
|
||||
return this.dashboard.getStats();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DashboardController } from './dashboard.controller.ts';
|
||||
import { DashboardService } from './dashboard.service.ts';
|
||||
|
||||
@Module({
|
||||
controllers: [DashboardController],
|
||||
providers: [DashboardService],
|
||||
})
|
||||
export class DashboardModule {}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service.ts';
|
||||
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function startOfToday(): Date {
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DashboardService {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
|
||||
async getStats() {
|
||||
const today = startOfToday();
|
||||
const sevenDaysAgo = new Date(Date.now() - SEVEN_DAYS_MS);
|
||||
|
||||
const [activeScenarios, pendingEvents, doneToday, failedToday, doneLast7Days, failedLast7Days, totalDatabases] =
|
||||
await Promise.all([
|
||||
this.prisma.scenario.count({ where: { active: true } }),
|
||||
this.prisma.webhookEvent.count({ where: { status: 'pending' } }),
|
||||
this.prisma.webhookEvent.count({ where: { status: 'done', receivedAt: { gte: today } } }),
|
||||
this.prisma.webhookEvent.count({ where: { status: 'failed', receivedAt: { gte: today } } }),
|
||||
this.prisma.webhookEvent.count({ where: { status: 'done', receivedAt: { gte: sevenDaysAgo } } }),
|
||||
this.prisma.webhookEvent.count({ where: { status: 'failed', receivedAt: { gte: sevenDaysAgo } } }),
|
||||
this.prisma.database.count(),
|
||||
]);
|
||||
|
||||
return { activeScenarios, pendingEvents, doneToday, failedToday, doneLast7Days, failedLast7Days, totalDatabases };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { BadRequestException, Body, Controller, Get, Inject, Param, Patch, Post } from '@nestjs/common';
|
||||
import { CreateDatabaseInput, DatabasesService, UpdateDatabaseInput } from './databases.service.ts';
|
||||
|
||||
@Controller('databases')
|
||||
export class DatabasesController {
|
||||
constructor(@Inject(DatabasesService) private readonly databases: DatabasesService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.databases.list();
|
||||
}
|
||||
|
||||
@Get(':code')
|
||||
get(@Param('code') code: string) {
|
||||
return this.databases.get(code);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() body: Partial<CreateDatabaseInput>) {
|
||||
const { code, name, baseUrl, companyDb, username, password } = body;
|
||||
if (!code || !name || !baseUrl || !companyDb || !username || !password) {
|
||||
throw new BadRequestException('code, name, baseUrl, companyDb, username y password son obligatorios');
|
||||
}
|
||||
return this.databases.create({ code, name, baseUrl, companyDb, username, password });
|
||||
}
|
||||
|
||||
@Patch(':code')
|
||||
update(@Param('code') code: string, @Body() body: UpdateDatabaseInput) {
|
||||
return this.databases.update(code, body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DatabasesController } from './databases.controller.ts';
|
||||
import { DatabasesService } from './databases.service.ts';
|
||||
|
||||
@Module({
|
||||
controllers: [DatabasesController],
|
||||
providers: [DatabasesService],
|
||||
})
|
||||
export class DatabasesModule {}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service.ts';
|
||||
|
||||
// Nunca se devuelve `password` en las respuestas — este endpoint lo recibe
|
||||
// en el body para guardarlo, pero no hay motivo para que cualquier consumidor
|
||||
// del listado (el visor, o quien sea) pueda leerlo de vuelta.
|
||||
const PUBLIC_SELECT = {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
baseUrl: true,
|
||||
companyDb: true,
|
||||
username: true,
|
||||
active: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
};
|
||||
|
||||
export interface CreateDatabaseInput {
|
||||
code: string;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
companyDb: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface UpdateDatabaseInput {
|
||||
name?: string;
|
||||
baseUrl?: string;
|
||||
companyDb?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DatabasesService {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
|
||||
list() {
|
||||
return this.prisma.database.findMany({ orderBy: { code: 'asc' }, select: PUBLIC_SELECT });
|
||||
}
|
||||
|
||||
async get(code: string) {
|
||||
const db = await this.prisma.database.findUnique({ where: { code }, select: PUBLIC_SELECT });
|
||||
if (!db) throw new NotFoundException(`No existe la base de datos '${code}'`);
|
||||
return db;
|
||||
}
|
||||
|
||||
async create(input: CreateDatabaseInput) {
|
||||
const existing = await this.prisma.database.findUnique({ where: { code: input.code } });
|
||||
if (existing) throw new ConflictException(`Ya existe una base de datos con code '${input.code}'`);
|
||||
return this.prisma.database.create({ data: input, select: PUBLIC_SELECT });
|
||||
}
|
||||
|
||||
async update(code: string, input: UpdateDatabaseInput) {
|
||||
await this.get(code);
|
||||
return this.prisma.database.update({ where: { code }, data: input, select: PUBLIC_SELECT });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Controller, Get, Inject, Param, Post, Query } from '@nestjs/common';
|
||||
import { EventsService } from './events.service.ts';
|
||||
|
||||
@Controller('events')
|
||||
export class EventsController {
|
||||
constructor(@Inject(EventsService) private readonly events: EventsService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
@Query('businessObject') businessObject?: string,
|
||||
@Query('scenarioCode') scenarioCode?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.events.list({
|
||||
from,
|
||||
to,
|
||||
businessObject,
|
||||
scenarioCode,
|
||||
status,
|
||||
page: page ? Number(page) : undefined,
|
||||
pageSize: pageSize ? Number(pageSize) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@Param('id') id: string) {
|
||||
return this.events.get(id);
|
||||
}
|
||||
|
||||
@Post(':id/reprocess')
|
||||
reprocess(@Param('id') id: string) {
|
||||
return this.events.reprocess(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EventsController } from './events.controller.ts';
|
||||
import { EventsService } from './events.service.ts';
|
||||
|
||||
@Module({
|
||||
controllers: [EventsController],
|
||||
providers: [EventsService],
|
||||
})
|
||||
export class EventsModule {}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service.ts';
|
||||
|
||||
const VALID_STATUSES = ['pending', 'processing', 'done', 'partial', 'failed', 'filtered'];
|
||||
|
||||
export interface ListEventsFilters {
|
||||
from?: string;
|
||||
to?: string;
|
||||
businessObject?: string;
|
||||
scenarioCode?: string;
|
||||
status?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
const MAX_PAGE_SIZE = 200;
|
||||
|
||||
@Injectable()
|
||||
export class EventsService {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(filters: ListEventsFilters) {
|
||||
const statuses = filters.status ? filters.status.split(',').filter(Boolean) : [];
|
||||
const invalidStatus = statuses.find((s) => !VALID_STATUSES.includes(s));
|
||||
if (invalidStatus) {
|
||||
throw new BadRequestException(`status inválido: '${invalidStatus}' (valores válidos: ${VALID_STATUSES.join(', ')})`);
|
||||
}
|
||||
|
||||
const page = filters.page && filters.page > 0 ? filters.page : 1;
|
||||
const pageSize = filters.pageSize && filters.pageSize > 0 ? Math.min(filters.pageSize, MAX_PAGE_SIZE) : DEFAULT_PAGE_SIZE;
|
||||
|
||||
const where = {
|
||||
...(filters.businessObject ? { businessObject: filters.businessObject } : {}),
|
||||
...(statuses.length ? { status: { in: statuses as any } } : {}),
|
||||
...(filters.scenarioCode ? { attempts: { some: { scenario: { code: filters.scenarioCode } } } } : {}),
|
||||
...(filters.from || filters.to
|
||||
? {
|
||||
receivedAt: {
|
||||
...(filters.from ? { gte: new Date(filters.from) } : {}),
|
||||
...(filters.to ? { lte: new Date(filters.to) } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.webhookEvent.findMany({
|
||||
where,
|
||||
orderBy: { receivedAt: 'desc' },
|
||||
include: { _count: { select: { attempts: true } } },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.webhookEvent.count({ where }),
|
||||
]);
|
||||
|
||||
return { data, total, page, pageSize };
|
||||
}
|
||||
|
||||
async get(id: string) {
|
||||
const event = await this.prisma.webhookEvent.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
attempts: {
|
||||
orderBy: { attemptedAt: 'asc' },
|
||||
include: { scenario: { select: { code: true, name: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!event) throw new NotFoundException(`No existe el evento '${id}'`);
|
||||
return event;
|
||||
}
|
||||
|
||||
async reprocess(id: string) {
|
||||
await this.get(id);
|
||||
return this.prisma.webhookEvent.update({ where: { id }, data: { status: 'pending' } });
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import 'dotenv/config';
|
||||
import 'reflect-metadata';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module.ts';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('Bootstrap');
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.enableCors(); // consumido por sap-intercompany-viewer, otro origen
|
||||
|
||||
const port = process.env.PORT ?? 3001;
|
||||
await app.listen(port);
|
||||
logger.log(`sap-intercompany-api escuchando en el puerto ${port}`);
|
||||
}
|
||||
bootstrap();
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service.ts';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { PrismaClient } from '../generated/prisma/client.ts';
|
||||
|
||||
// Mismo cliente/adapter que sap-intercompany-worker (Prisma 7 + @prisma/adapter-pg),
|
||||
// sobre la misma Postgres. Esta API nunca corre `prisma migrate` — ver nota en prisma/schema.prisma.
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(PrismaService.name);
|
||||
|
||||
constructor() {
|
||||
super({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) });
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
this.logger.log('Conexión a Postgres OK');
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Helpers de filesystem para el código fuente (filter/transform) de cada
|
||||
// escenario, que vive en sap-intercompany-worker/src/scenarios/<slug>.ts —
|
||||
// esta API nunca genera esa lógica, solo el scaffold de 3 secciones (import,
|
||||
// filter, transform) la primera vez que se crea un escenario, y lo puede
|
||||
// leer en modo solo lectura (GET /scenarios/:code/source). Editar el cuerpo
|
||||
// de filter/transform queda siempre a mano, en el editor del usuario.
|
||||
//
|
||||
// IMPORTANTE: el scaffold de abajo es una copia intencional de
|
||||
// `scaffoldTransformFile` en sap-intercompany-worker/scripts/manage-config.ts
|
||||
// (no se importa desde ahí para no acoplar esta API al resto del código del
|
||||
// worker, que trae su propio cliente Prisma generado). Si ese scaffold
|
||||
// cambia, actualizar los dos lugares.
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
export function scenarioFileSlug(scenarioCode: string): string {
|
||||
return scenarioCode.toLowerCase();
|
||||
}
|
||||
|
||||
function scenariosDir(): string {
|
||||
const workerRootPath = process.env.WORKER_ROOT_PATH;
|
||||
if (!workerRootPath) {
|
||||
throw new Error('WORKER_ROOT_PATH no está configurada (ver .env.example)');
|
||||
}
|
||||
return resolve(process.cwd(), workerRootPath, 'src', 'scenarios');
|
||||
}
|
||||
|
||||
export function scenarioFilePath(code: string): string {
|
||||
return resolve(scenariosDir(), `${scenarioFileSlug(code)}.ts`);
|
||||
}
|
||||
|
||||
// Devuelve la ruta creada, o null si el archivo ya existía (no lo pisa).
|
||||
export function scaffoldScenarioFile(code: string): string | null {
|
||||
const path = scenarioFilePath(code);
|
||||
if (existsSync(path)) return null;
|
||||
mkdirSync(scenariosDir(), { recursive: true });
|
||||
writeFileSync(
|
||||
path,
|
||||
`// Escenario ${code} — completar según corresponda (ver docs/ESCENARIOS.md).
|
||||
import type { ScenarioFilterContext, ScenarioTransformContext } from '../scenarioTransform.ts';
|
||||
|
||||
// --- Filtro: decide si este escenario aplica al registro ---
|
||||
// Puede ser async: ctx.get(path) permite consultar la base ORIGEN (ej. si
|
||||
// el filtro depende de un dato que no viene en el payload del evento).
|
||||
export function filter(record: Record<string, unknown>, ctx: ScenarioFilterContext): boolean | Promise<boolean> {
|
||||
return true; // reemplazar con la condición real, o dejar así para "sin filtro"
|
||||
}
|
||||
|
||||
// --- Transformación: arma el payload para un destino puntual ---
|
||||
// Se llama una vez por destino: ctx.destinationCode permite transformar
|
||||
// distinto según a cuál le toque. Puede lanzar una excepción para marcar
|
||||
// el intento como SKIPPED (ej. un valor sin mapeo cargado).
|
||||
export function transform(record: Record<string, unknown>, ctx: ScenarioTransformContext): Record<string, unknown> {
|
||||
// --- Mapeo: ctx.mapValue(fieldName, sourceValue) consulta scenario_value_mappings
|
||||
// (cargados desde /scenarios/:code/mappings) para ESTE escenario y destino ---
|
||||
|
||||
return { ...record };
|
||||
}
|
||||
`,
|
||||
);
|
||||
return path;
|
||||
}
|
||||
|
||||
export function renameScenarioFile(oldCode: string, newCode: string): { renamed: boolean; from: string; to: string } {
|
||||
const from = scenarioFilePath(oldCode);
|
||||
const to = scenarioFilePath(newCode);
|
||||
if (existsSync(to)) throw new Error(`ya existe un archivo en ${to}`);
|
||||
if (!existsSync(from)) return { renamed: false, from, to };
|
||||
renameSync(from, to);
|
||||
return { renamed: true, from, to };
|
||||
}
|
||||
|
||||
export function readScenarioSource(code: string): { path: string; content: string } | null {
|
||||
const path = scenarioFilePath(code);
|
||||
if (!existsSync(path)) return null;
|
||||
return { path, content: readFileSync(path, 'utf-8') };
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { BadRequestException, Body, Controller, Delete, Get, Inject, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { CreateScenarioInput, ScenariosService, UpdateScenarioInput } from './scenarios.service.ts';
|
||||
|
||||
@Controller('scenarios')
|
||||
export class ScenariosController {
|
||||
constructor(@Inject(ScenariosService) private readonly scenarios: ScenariosService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.scenarios.list();
|
||||
}
|
||||
|
||||
@Get(':code')
|
||||
get(@Param('code') code: string) {
|
||||
return this.scenarios.get(code);
|
||||
}
|
||||
|
||||
@Get(':code/source')
|
||||
getSource(@Param('code') code: string) {
|
||||
return this.scenarios.getSource(code);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() body: Partial<CreateScenarioInput>) {
|
||||
const { code, name, businessObject, originDatabaseCode, keyField } = body;
|
||||
if (!code || !name || !businessObject || !originDatabaseCode || !keyField) {
|
||||
throw new BadRequestException('code, name, businessObject, originDatabaseCode y keyField son obligatorios');
|
||||
}
|
||||
return this.scenarios.create({ ...body, code, name, businessObject, originDatabaseCode, keyField });
|
||||
}
|
||||
|
||||
@Patch(':code')
|
||||
update(@Param('code') code: string, @Body() body: UpdateScenarioInput) {
|
||||
return this.scenarios.update(code, body);
|
||||
}
|
||||
|
||||
@Post(':code/rename')
|
||||
rename(@Param('code') code: string, @Body() body: { newCode?: string }) {
|
||||
if (!body.newCode) throw new BadRequestException('newCode es obligatorio');
|
||||
return this.scenarios.rename(code, body.newCode);
|
||||
}
|
||||
|
||||
@Get(':code/destinations')
|
||||
listDestinations(@Param('code') code: string) {
|
||||
return this.scenarios.listDestinations(code);
|
||||
}
|
||||
|
||||
@Post(':code/destinations')
|
||||
addDestination(@Param('code') code: string, @Body() body: { databaseCode?: string }) {
|
||||
if (!body.databaseCode) throw new BadRequestException('databaseCode es obligatorio');
|
||||
return this.scenarios.addDestination(code, body.databaseCode);
|
||||
}
|
||||
|
||||
@Delete(':code/destinations/:databaseCode')
|
||||
removeDestination(@Param('code') code: string, @Param('databaseCode') databaseCode: string) {
|
||||
return this.scenarios.removeDestination(code, databaseCode);
|
||||
}
|
||||
|
||||
@Get(':code/mappings')
|
||||
listMappings(@Param('code') code: string, @Query('fieldName') fieldName?: string) {
|
||||
return this.scenarios.listMappings(code, fieldName);
|
||||
}
|
||||
|
||||
@Post(':code/mappings')
|
||||
addMapping(
|
||||
@Param('code') code: string,
|
||||
@Body() body: { fieldName?: string; databaseCode?: string; sourceValue?: string; targetValue?: string },
|
||||
) {
|
||||
const { fieldName, databaseCode, sourceValue, targetValue } = body;
|
||||
if (!fieldName || !databaseCode || !sourceValue || !targetValue) {
|
||||
throw new BadRequestException('fieldName, databaseCode, sourceValue y targetValue son obligatorios');
|
||||
}
|
||||
return this.scenarios.addMapping(code, { fieldName, databaseCode, sourceValue, targetValue });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ScenariosController } from './scenarios.controller.ts';
|
||||
import { ScenariosService } from './scenarios.service.ts';
|
||||
|
||||
@Module({
|
||||
controllers: [ScenariosController],
|
||||
providers: [ScenariosService],
|
||||
})
|
||||
export class ScenariosModule {}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { BadRequestException, ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service.ts';
|
||||
import { readScenarioSource, renameScenarioFile, scaffoldScenarioFile, scenarioFileSlug } from './scenario-file.ts';
|
||||
|
||||
type KeyStrategy = 'portable' | 'udfLink';
|
||||
type KeyType = 'string' | 'number';
|
||||
type DestinationMode = 'fixed' | 'payload';
|
||||
|
||||
export interface CreateScenarioInput {
|
||||
code: string;
|
||||
name: string;
|
||||
businessObject: string;
|
||||
destinationBusinessObject?: string | null;
|
||||
originDatabaseCode: string;
|
||||
keyField: string;
|
||||
keyType?: KeyType;
|
||||
keyStrategy?: KeyStrategy;
|
||||
matchField?: string | null;
|
||||
destinationMode?: DestinationMode;
|
||||
routingField?: string | null;
|
||||
}
|
||||
|
||||
export type UpdateScenarioInput = Partial<Omit<CreateScenarioInput, 'code'>> & { active?: boolean };
|
||||
|
||||
// Misma validación de combinaciones que `scenario:add` en
|
||||
// sap-intercompany-worker/scripts/manage-config.ts — mantener sincronizadas.
|
||||
function validateCombination(input: {
|
||||
keyStrategy: KeyStrategy;
|
||||
matchField?: string | null;
|
||||
destinationMode: DestinationMode;
|
||||
routingField?: string | null;
|
||||
}) {
|
||||
if (input.keyStrategy === 'udfLink' && !input.matchField) {
|
||||
throw new BadRequestException("keyStrategy 'udfLink' necesita matchField");
|
||||
}
|
||||
if (input.destinationMode === 'payload' && !input.routingField) {
|
||||
throw new BadRequestException("destinationMode 'payload' necesita routingField (UDF con el code de la base destino)");
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ScenariosService {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
|
||||
list() {
|
||||
return this.prisma.scenario.findMany({ orderBy: { code: 'asc' }, include: { destinations: true } });
|
||||
}
|
||||
|
||||
async get(code: string) {
|
||||
const scenario = await this.prisma.scenario.findUnique({ where: { code }, include: { destinations: true } });
|
||||
if (!scenario) throw new NotFoundException(`No existe el escenario '${code}'`);
|
||||
return scenario;
|
||||
}
|
||||
|
||||
async create(input: CreateScenarioInput) {
|
||||
const existing = await this.prisma.scenario.findUnique({ where: { code: input.code } });
|
||||
if (existing) throw new ConflictException(`Ya existe un escenario con code '${input.code}'`);
|
||||
|
||||
const keyStrategy = input.keyStrategy ?? 'portable';
|
||||
const keyType = input.keyType ?? 'string';
|
||||
const destinationMode = input.destinationMode ?? 'fixed';
|
||||
validateCombination({ keyStrategy, matchField: input.matchField, destinationMode, routingField: input.routingField });
|
||||
|
||||
const scenario = await this.prisma.scenario.create({
|
||||
data: {
|
||||
code: input.code,
|
||||
name: input.name,
|
||||
businessObject: input.businessObject,
|
||||
destinationBusinessObject: input.destinationBusinessObject ?? null,
|
||||
originDatabaseCode: input.originDatabaseCode,
|
||||
destinationMode,
|
||||
routingField: input.routingField ?? null,
|
||||
keyField: input.keyField,
|
||||
keyType,
|
||||
keyStrategy,
|
||||
matchField: input.matchField ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
const scaffoldedFilePath = scaffoldScenarioFile(scenario.code);
|
||||
return { scenario, scaffoldedFilePath };
|
||||
}
|
||||
|
||||
async update(code: string, input: UpdateScenarioInput) {
|
||||
const current = await this.get(code);
|
||||
const keyStrategy = input.keyStrategy ?? (current.keyStrategy as KeyStrategy);
|
||||
const destinationMode = input.destinationMode ?? (current.destinationMode as DestinationMode);
|
||||
const matchField = input.matchField !== undefined ? input.matchField : current.matchField;
|
||||
const routingField = input.routingField !== undefined ? input.routingField : current.routingField;
|
||||
validateCombination({ keyStrategy, matchField, destinationMode, routingField });
|
||||
|
||||
return this.prisma.scenario.update({ where: { code }, data: { ...input } });
|
||||
}
|
||||
|
||||
async rename(oldCode: string, newCode: string) {
|
||||
const scenario = await this.get(oldCode);
|
||||
const existingWithNewCode = await this.prisma.scenario.findUnique({ where: { code: newCode } });
|
||||
if (existingWithNewCode) throw new ConflictException(`Ya existe un escenario con code '${newCode}'`);
|
||||
|
||||
const fileResult = renameScenarioFile(oldCode, newCode);
|
||||
const updated = await this.prisma.scenario.update({ where: { id: scenario.id }, data: { code: newCode } });
|
||||
return { scenario: updated, file: fileResult };
|
||||
}
|
||||
|
||||
async listDestinations(scenarioCode: string) {
|
||||
const scenario = await this.get(scenarioCode);
|
||||
return this.prisma.scenarioDestination.findMany({
|
||||
where: { scenarioId: scenario.id },
|
||||
include: { database: { select: { code: true, name: true, active: true } } },
|
||||
orderBy: { databaseCode: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async addDestination(scenarioCode: string, databaseCode: string) {
|
||||
const scenario = await this.get(scenarioCode);
|
||||
return this.prisma.scenarioDestination.upsert({
|
||||
where: { scenarioId_databaseCode: { scenarioId: scenario.id, databaseCode } },
|
||||
create: { scenarioId: scenario.id, databaseCode },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
async removeDestination(scenarioCode: string, databaseCode: string) {
|
||||
const scenario = await this.get(scenarioCode);
|
||||
try {
|
||||
await this.prisma.scenarioDestination.delete({
|
||||
where: { scenarioId_databaseCode: { scenarioId: scenario.id, databaseCode } },
|
||||
});
|
||||
} catch {
|
||||
throw new NotFoundException(`El escenario '${scenarioCode}' no tiene destino '${databaseCode}'`);
|
||||
}
|
||||
}
|
||||
|
||||
async listMappings(scenarioCode: string, fieldName?: string) {
|
||||
const scenario = await this.get(scenarioCode);
|
||||
return this.prisma.scenarioValueMapping.findMany({
|
||||
where: { scenarioId: scenario.id, ...(fieldName ? { fieldName } : {}) },
|
||||
orderBy: [{ fieldName: 'asc' }, { databaseCode: 'asc' }, { sourceValue: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
async addMapping(scenarioCode: string, input: { fieldName: string; databaseCode: string; sourceValue: string; targetValue: string }) {
|
||||
const scenario = await this.get(scenarioCode);
|
||||
return this.prisma.scenarioValueMapping.upsert({
|
||||
where: {
|
||||
scenarioId_fieldName_databaseCode_sourceValue: {
|
||||
scenarioId: scenario.id,
|
||||
fieldName: input.fieldName,
|
||||
databaseCode: input.databaseCode,
|
||||
sourceValue: input.sourceValue,
|
||||
},
|
||||
},
|
||||
create: { scenarioId: scenario.id, ...input },
|
||||
update: { targetValue: input.targetValue },
|
||||
});
|
||||
}
|
||||
|
||||
async getSource(scenarioCode: string) {
|
||||
await this.get(scenarioCode); // 404 consistente si el escenario no existe en Postgres
|
||||
const source = readScenarioSource(scenarioCode);
|
||||
if (!source) {
|
||||
throw new NotFoundException(
|
||||
`No se encontró src/scenarios/${scenarioFileSlug(scenarioCode)}.ts en sap-intercompany-worker`,
|
||||
);
|
||||
}
|
||||
return source;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"],
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "src/generated"]
|
||||
}
|
||||
Reference in new issue
Block a user