feat: add Warehouses + Integrations modules, /admin/configuracion page

- Schema v0.3: Warehouse + Integration models (db push applied)
- WarehousesModule: CRUD, set-default, multi-warehouse support
- IntegrationsModule: 26 keys across 7 groups (payment, notifications,
  customs, courier, marketplace, compliance, warehouse)
- AuthService: suite address pulled from default Warehouse in DB (env fallback)
- AuthModule: imports WarehousesModule
- Seed: creates default warehouse from WAREHOUSE_ADDRESS_* env vars
- Web: /admin/configuracion page (3 tabs: Bodegas, Integraciones, Estado APIs)
- Web: admin layout adds Configuracion nav link
- api.ts: warehouses + integrations client methods
This commit is contained in:
Lizandro Guarnizo
2026-06-01 16:49:19 -05:00
parent 03b18e7a84
commit b2b292c50a
15 changed files with 822 additions and 18 deletions
@@ -0,0 +1,29 @@
import { Controller, Get, Put, Body, UseGuards } from "@nestjs/common";
import { IntegrationsService } from "./integrations.service";
import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard";
import { CurrentUser } from "../auth/decorators/current-user.decorator";
@Controller("integrations")
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles("ADMIN_EMPRESA", "SUPER_ADMIN")
export class IntegrationsController {
constructor(private svc: IntegrationsService) {}
@Get()
findAll(@CurrentUser() user: any) {
return this.svc.findAll(user.tenantId);
}
@Get("status")
getStatus(@CurrentUser() user: any) {
return this.svc.getStatus(user.tenantId);
}
@Put("batch")
batchUpsert(
@CurrentUser() user: any,
@Body() body: { items: Array<{ key: string; value: string | null; isActive: boolean }> },
) {
return this.svc.batchUpsert(user.tenantId, body.items, user.id);
}
}
@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { IntegrationsService } from "./integrations.service";
import { IntegrationsController } from "./integrations.controller";
import { PrismaModule } from "../prisma/prisma.module";
@Module({
imports: [PrismaModule],
providers: [IntegrationsService],
controllers: [IntegrationsController],
exports: [IntegrationsService],
})
export class IntegrationsModule {}
@@ -0,0 +1,122 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
/** All integration keys defined in the system, grouped by category */
export const INTEGRATION_CATALOG = [
// ── Pasarela de Pagos ──────────────────────────────────────
{ key: "stripe_public_key", label: "Stripe — Clave pública", group: "payment", required: false },
{ key: "stripe_secret_key", label: "Stripe — Clave secreta", group: "payment", required: false },
{ key: "payphone_token", label: "PayPhone — Token de API", group: "payment", required: false },
{ key: "paypal_client_id", label: "PayPal — Client ID", group: "payment", required: false },
// ── Notificaciones ────────────────────────────────────────
{ key: "whatsapp_token", label: "WhatsApp Business — Token", group: "notifications", required: true },
{ key: "whatsapp_phone_id", label: "WhatsApp Business — Phone ID", group: "notifications", required: true },
{ key: "whatsapp_nj_number", label: "WhatsApp NJ (operaciones)", group: "notifications", required: false },
{ key: "whatsapp_ec_number", label: "WhatsApp Ecuador (aduanas)", group: "notifications", required: false },
{ key: "sendgrid_api_key", label: "SendGrid — API Key", group: "notifications", required: false },
{ key: "email_from", label: "Email remitente (from)", group: "notifications", required: false },
{ key: "sms_provider", label: "SMS — Proveedor (ej: Twilio)", group: "notifications", required: false },
{ key: "sms_api_key", label: "SMS — API Key", group: "notifications", required: false },
// ── Aduana / SENAE ────────────────────────────────────────
{ key: "senae_endpoint", label: "SENAE — URL WebService", group: "customs", required: true },
{ key: "senae_api_key", label: "SENAE — API Key / Token", group: "customs", required: true },
{ key: "senae_ruc", label: "RUC declarante SENAE", group: "customs", required: true },
{ key: "senae_agent_code", label: "Código Agente Aduanero SENAE", group: "customs", required: false },
// ── Couriers ──────────────────────────────────────────────
{ key: "fedex_api_key", label: "FedEx — API Key", group: "courier", required: false },
{ key: "fedex_account", label: "FedEx — Account Number", group: "courier", required: false },
{ key: "dhl_api_key", label: "DHL — API Key", group: "courier", required: false },
{ key: "ups_client_id", label: "UPS — Client ID", group: "courier", required: false },
{ key: "ups_client_secret", label: "UPS — Client Secret", group: "courier", required: false },
// ── Amazon SP-API ─────────────────────────────────────────
{ key: "amazon_client_id", label: "Amazon SP-API — Client ID", group: "marketplace", required: false },
{ key: "amazon_client_secret", label: "Amazon SP-API — Client Secret", group: "marketplace", required: false },
{ key: "amazon_refresh_token", label: "Amazon SP-API — Refresh Token", group: "marketplace", required: false },
// ── INEN ──────────────────────────────────────────────────
{ key: "inen_endpoint", label: "INEN — URL de consulta", group: "compliance", required: false },
{ key: "inen_api_key", label: "INEN — API Key", group: "compliance", required: false },
// ── Bodega propia ─────────────────────────────────────────
{ key: "warehouse_api_url", label: "Software Bodega — URL base", group: "warehouse", required: false },
{ key: "warehouse_api_key", label: "Software Bodega — API Key", group: "warehouse", required: false },
{ key: "warehouse_webhook_secret", label: "Software Bodega — Webhook Secret", group: "warehouse", required: false },
];
@Injectable()
export class IntegrationsService {
constructor(private prisma: PrismaService) {}
/** Return all integration keys for the tenant, merged with the catalog */
async findAll(tenantId: string): Promise<any[]> {
const stored = await this.prisma.client.integration.findMany({
where: { tenantId },
});
const storedMap = new Map(stored.map(s => [s.key, s]));
return INTEGRATION_CATALOG.map(cat => {
const stored = storedMap.get(cat.key);
return {
...cat,
id: stored?.id ?? null,
value: stored?.value ? "***" : null, // mask for security
hasValue: !!stored?.value,
isActive: stored?.isActive ?? false,
updatedAt: stored?.updatedAt ?? null,
};
});
}
/** Upsert a single integration key */
async upsert(tenantId: string, key: string, value: string | null, isActive: boolean, updatedBy: string): Promise<any> {
const cat = INTEGRATION_CATALOG.find(c => c.key === key);
const result = await this.prisma.client.integration.upsert({
where: { tenantId_key: { tenantId, key } },
create: {
tenantId,
key,
value: value ?? null,
label: cat?.label,
group: cat?.group,
isActive,
updatedBy,
},
update: {
value: value !== undefined ? value : undefined,
isActive,
updatedBy,
updatedAt: new Date(),
},
});
return { ...result, value: result.value ? "***" : null, hasValue: !!result.value };
}
/** Batch upsert multiple keys at once */
async batchUpsert(tenantId: string, items: Array<{ key: string; value: string | null; isActive: boolean }>, updatedBy: string): Promise<void> {
await Promise.all(
items.map(item => this.upsert(tenantId, item.key, item.value, item.isActive, updatedBy))
);
}
/** Get a single integration value (unmasked — internal use only) */
async getValue(tenantId: string, key: string): Promise<string | null> {
const row = await this.prisma.client.integration.findUnique({
where: { tenantId_key: { tenantId, key } },
});
return row?.isActive ? (row.value ?? null) : null;
}
/** Return connection status summary by group */
async getStatus(tenantId: string): Promise<Record<string, { connected: boolean; missing: string[] }>> {
const stored = await this.prisma.client.integration.findMany({ where: { tenantId, isActive: true } });
const activeKeys = new Set(stored.filter(s => !!s.value).map(s => s.key));
const groups = [...new Set(INTEGRATION_CATALOG.map(c => c.group ?? "other"))];
const status: Record<string, { connected: boolean; missing: string[] }> = {};
for (const group of groups) {
const required = INTEGRATION_CATALOG.filter(c => c.group === group && c.required);
const missing = required.filter(c => !activeKeys.has(c.key)).map(c => c.label);
status[group] = { connected: missing.length === 0 && required.length > 0, missing };
}
return status;
}
}