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:
GeorgeandClaude Sonnet 5 committed 2026-09-16 07:51:03 -05:00
commit 2e92523641
25 files changed
+4707

No files matched your search

+8
View File
@@ -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"
+4
View File
@@ -0,0 +1,4 @@
node_modules
.env
/src/generated/prisma
+71
View File
@@ -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`.
+3712
View File
File diff suppressed because it is too large. Load diff
+33
View File
@@ -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"
}
}
+180
View File
@@ -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")
}
+9
View File
@@ -0,0 +1,9 @@
import { Controller, Get } from '@nestjs/common';
@Controller()
export class AppController {
@Get()
health() {
return { status: 'ok', service: 'sap-intercompany-api' };
}
}
+13
View File
@@ -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 {}
+12
View File
@@ -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();
}
}
+9
View File
@@ -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 {}
+33
View File
@@ -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 };
}
}
+31
View File
@@ -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);
}
}
+9
View File
@@ -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 {}
+61
View File
@@ -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 });
}
}
+38
View File
@@ -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);
}
}
+9
View File
@@ -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 {}
+79
View File
@@ -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
View File
@@ -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();
+9
View File
@@ -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 {}
+23
View File
@@ -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();
}
}
+77
View File
@@ -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') };
}
+75
View File
@@ -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 });
}
}
+9
View File
@@ -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 {}
+168
View File
@@ -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;
}
}
+19
View File
@@ -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"]
}