@oondemand/oon-core-back 0.3.56 → 0.3.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/integration-worker.js +31 -0
- package/package.json +3 -2
- package/src/boot/createApp.js +9 -9
- package/src/boot/database.js +25 -15
- package/src/index.js +6 -1
- package/src/modules/integrations/index.js +26 -0
- package/src/modules/integrations/models.js +111 -0
- package/src/modules/integrations/registry.js +154 -0
- package/src/modules/integrations/routes.js +167 -0
- package/src/modules/integrations/runtime/inbox.js +123 -0
- package/src/modules/integrations/runtime/lease.js +94 -0
- package/src/modules/integrations/runtime/outbox.js +199 -0
- package/src/modules/integrations/runtime/sync.js +85 -0
- package/src/modules/integrations/runtime/tracking.js +89 -0
- package/src/modules/integrations/runtime/utils.js +71 -0
- package/src/modules/integrations/runtime.js +10 -0
- package/src/modules/integrations/sanitizer.js +75 -0
- package/src/modules/integrations/worker.js +56 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const { runIntegrationWorker } = require("../src/modules/integrations");
|
|
5
|
+
|
|
6
|
+
function flag(name) {
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
const index = args.indexOf(name);
|
|
9
|
+
if (index < 0) return undefined;
|
|
10
|
+
const value = args[index + 1];
|
|
11
|
+
return value && !value.startsWith("--") ? value : true;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function positive(value) {
|
|
15
|
+
if (value == null || value === true) return undefined;
|
|
16
|
+
const parsed = Number(value);
|
|
17
|
+
if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`Valor inválido: ${value}.`);
|
|
18
|
+
return parsed;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
runIntegrationWorker({
|
|
22
|
+
cwd: process.cwd(),
|
|
23
|
+
once: flag("--once") === true,
|
|
24
|
+
intervalMs: positive(flag("--interval")),
|
|
25
|
+
batchSize: positive(flag("--batch")),
|
|
26
|
+
webhookBatchSize: positive(flag("--webhook-batch")),
|
|
27
|
+
provider: flag("--provider") === true ? undefined : flag("--provider"),
|
|
28
|
+
}).catch((error) => {
|
|
29
|
+
console.error(error?.message || error);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oondemand/oon-core-back",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.57",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/oondemand/oon-platform.git",
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"description": "Core runtime para Centrais Oon: Model Registry, CRUD/RBAC automáticos, módulos opinativos (Omie, Assistentes, Multi-moedas, Esteiras, Documentos) e deploy embutido.",
|
|
10
10
|
"main": "src/index.js",
|
|
11
11
|
"bin": {
|
|
12
|
-
"oonCore-back": "bin/cli.js"
|
|
12
|
+
"oonCore-back": "bin/cli.js",
|
|
13
|
+
"oonCore-integration-worker": "bin/integration-worker.js"
|
|
13
14
|
},
|
|
14
15
|
"files": [
|
|
15
16
|
"bin",
|
package/src/boot/createApp.js
CHANGED
|
@@ -9,6 +9,7 @@ const correlationId = require("../core/middlewares/correlationId");
|
|
|
9
9
|
const requestLog = require("../core/middlewares/requestLog");
|
|
10
10
|
const errorMiddleware = require("../core/middlewares/error");
|
|
11
11
|
const { buildModelRouter } = require("../core/factories/modelRouter");
|
|
12
|
+
const { buildIntegrationRouter, integrationsEnabled } = require("../modules/integrations");
|
|
12
13
|
|
|
13
14
|
const coreRoutes = require("../api/core.routes");
|
|
14
15
|
const statusRoutes = require("../api/status.routes");
|
|
@@ -16,11 +17,6 @@ const authRoutes = require("../api/auth.routes");
|
|
|
16
17
|
const activationRoutes = require("../api/activation.routes");
|
|
17
18
|
const activationGuard = require("../core/middlewares/activationGuard");
|
|
18
19
|
|
|
19
|
-
/**
|
|
20
|
-
* Cria a app Express do Core: middlewares transversais, rotas públicas,
|
|
21
|
-
* /core/*, routers de CRUD gerados pelo registry e rotas customizadas
|
|
22
|
-
* (defineRoutes). Tudo derivado do que foi registrado — sem domínio fixo.
|
|
23
|
-
*/
|
|
24
20
|
function createApp() {
|
|
25
21
|
const app = express();
|
|
26
22
|
|
|
@@ -31,24 +27,28 @@ function createApp() {
|
|
|
31
27
|
app.use(correlationId);
|
|
32
28
|
if (process.env.NODE_ENV === "development") app.use(morgan("dev"));
|
|
33
29
|
|
|
34
|
-
// Públicas.
|
|
35
30
|
app.use("/", statusRoutes);
|
|
36
31
|
app.use("/ativacao", activationRoutes);
|
|
37
32
|
app.use(activationGuard);
|
|
38
33
|
app.use("/auth", authRoutes);
|
|
39
34
|
app.use("/core", coreRoutes);
|
|
40
35
|
|
|
41
|
-
//
|
|
36
|
+
// A engine é montada antes do log genérico: webhooks podem carregar tokens
|
|
37
|
+
// e payloads sensíveis. A própria engine persiste diagnóstico sanitizado.
|
|
38
|
+
if (integrationsEnabled(ctx.config)) {
|
|
39
|
+
const integrationRouter = buildIntegrationRouter();
|
|
40
|
+
app.use("/integrations", integrationRouter);
|
|
41
|
+
app.use("/integracoes", integrationRouter);
|
|
42
|
+
}
|
|
43
|
+
|
|
42
44
|
app.use(requestLog);
|
|
43
45
|
|
|
44
|
-
// CRUD gerado por model registrada.
|
|
45
46
|
for (const entry of registry.listModels()) {
|
|
46
47
|
if (entry.definition.crud?.enabled === false) continue;
|
|
47
48
|
const basePath = entry.definition.basePath || `/${entry.singular}s`;
|
|
48
49
|
app.use(basePath, buildModelRouter(entry));
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
// Rotas customizadas (defineRoutes).
|
|
52
52
|
for (const { basePath, expressRouter } of registry.listRoutes()) {
|
|
53
53
|
app.use(basePath, expressRouter);
|
|
54
54
|
}
|
package/src/boot/database.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
const mongoose = require("mongoose");
|
|
2
2
|
const registry = require("../core/registry");
|
|
3
|
+
const ctx = require("../core/context");
|
|
4
|
+
const { getIntegrationModels } = require("../modules/integrations/models");
|
|
5
|
+
const { listIntegrationProviders } = require("../modules/integrations/registry");
|
|
3
6
|
|
|
4
|
-
/**
|
|
5
|
-
* Conexão com o MongoDB a partir das envs padrão da Central.
|
|
6
|
-
* Extraído de `config/db.js`, sem acoplamento ao domínio.
|
|
7
|
-
*/
|
|
8
7
|
function buildMongoUri() {
|
|
9
8
|
const cfg = {
|
|
10
9
|
dbServer: process.env.DB_SERVER,
|
|
@@ -24,9 +23,6 @@ function buildMongoUri() {
|
|
|
24
23
|
}
|
|
25
24
|
|
|
26
25
|
function connectionOptions() {
|
|
27
|
-
// MONGO_URI é um contrato completo e pode conter usuário, senha, authSource,
|
|
28
|
-
// TLS e demais opções. Passar user/pass indefinidos ao Mongoose pode
|
|
29
|
-
// sobrescrever a autenticação embutida e abrir uma sessão não autenticada.
|
|
30
26
|
if (process.env.MONGO_URI) return {};
|
|
31
27
|
|
|
32
28
|
const hasUser = Boolean(process.env.DB_USER);
|
|
@@ -56,14 +52,6 @@ function shouldSyncDeclaredIndexes() {
|
|
|
56
52
|
return String(process.env.OON_SYNC_INDEXES ?? "true").toLowerCase() !== "false";
|
|
57
53
|
}
|
|
58
54
|
|
|
59
|
-
/**
|
|
60
|
-
* Mantém os índices físicos do MongoDB alinhados às models registradas.
|
|
61
|
-
*
|
|
62
|
-
* `createIndexes()` apenas cria índices ausentes e deixa índices removidos do
|
|
63
|
-
* domínio ativos indefinidamente. `syncIndexes()` também elimina esses índices
|
|
64
|
-
* obsoletos, evitando restrições antigas como um `codigo_1` único continuar
|
|
65
|
-
* bloqueando novos registros depois que o campo saiu do manifesto.
|
|
66
|
-
*/
|
|
67
55
|
async function syncDeclaredIndexes(entries = registry.listModels()) {
|
|
68
56
|
if (!shouldSyncDeclaredIndexes()) return [];
|
|
69
57
|
|
|
@@ -84,10 +72,31 @@ async function syncDeclaredIndexes(entries = registry.listModels()) {
|
|
|
84
72
|
return results;
|
|
85
73
|
}
|
|
86
74
|
|
|
75
|
+
async function syncIntegrationIndexes() {
|
|
76
|
+
const enabled = Boolean(
|
|
77
|
+
ctx.config.appManifest?.modules?.integrations
|
|
78
|
+
|| ctx.config.modules?.integrations
|
|
79
|
+
|| listIntegrationProviders().length,
|
|
80
|
+
);
|
|
81
|
+
if (!enabled || !shouldSyncDeclaredIndexes()) return [];
|
|
82
|
+
|
|
83
|
+
const results = [];
|
|
84
|
+
for (const model of Object.values(getIntegrationModels())) {
|
|
85
|
+
const dropped = await model.syncIndexes();
|
|
86
|
+
const normalized = Array.isArray(dropped) ? dropped : [];
|
|
87
|
+
results.push({ model: model.modelName, dropped: normalized });
|
|
88
|
+
if (normalized.length) {
|
|
89
|
+
console.log(`[OonCore] Índices técnicos obsoletos removidos de ${model.modelName}: ${normalized.join(", ")}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return results;
|
|
93
|
+
}
|
|
94
|
+
|
|
87
95
|
async function connectDB() {
|
|
88
96
|
const uri = buildMongoUri();
|
|
89
97
|
await mongoose.connect(uri, connectionOptions());
|
|
90
98
|
await syncDeclaredIndexes();
|
|
99
|
+
await syncIntegrationIndexes();
|
|
91
100
|
console.log(`Conectado ao MongoDB (${databaseLabel(uri)})`);
|
|
92
101
|
return mongoose.connection;
|
|
93
102
|
}
|
|
@@ -99,4 +108,5 @@ module.exports = {
|
|
|
99
108
|
databaseLabel,
|
|
100
109
|
shouldSyncDeclaredIndexes,
|
|
101
110
|
syncDeclaredIndexes,
|
|
111
|
+
syncIntegrationIndexes,
|
|
102
112
|
};
|
package/src/index.js
CHANGED
|
@@ -8,6 +8,7 @@ const fields = require("./core/fields");
|
|
|
8
8
|
const registry = require("./core/registry");
|
|
9
9
|
const GenericError = require("./core/errors/GenericError");
|
|
10
10
|
const { omie } = require("./modules/omie");
|
|
11
|
+
const integrations = require("./modules/integrations");
|
|
11
12
|
const appManifest = require("./manifest/appManifest");
|
|
12
13
|
const domainManifest = require("./manifest/domainManifest");
|
|
13
14
|
const domainRules = require("./manifest/domainRules");
|
|
@@ -71,6 +72,10 @@ module.exports = {
|
|
|
71
72
|
|
|
72
73
|
fields,
|
|
73
74
|
omie,
|
|
75
|
+
integrations,
|
|
76
|
+
registerIntegrationProvider: integrations.registerIntegrationProvider,
|
|
77
|
+
enqueueIntegration: integrations.enqueueIntegration,
|
|
78
|
+
receiveIntegrationWebhook: integrations.receiveWebhook,
|
|
74
79
|
GenericError,
|
|
75
80
|
registry,
|
|
76
|
-
};
|
|
81
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const registry = require("./registry");
|
|
4
|
+
const runtime = require("./runtime");
|
|
5
|
+
const sanitizer = require("./sanitizer");
|
|
6
|
+
const models = require("./models");
|
|
7
|
+
const worker = require("./worker");
|
|
8
|
+
const { buildIntegrationRouter } = require("./routes");
|
|
9
|
+
|
|
10
|
+
function integrationsEnabled(config = {}) {
|
|
11
|
+
return Boolean(
|
|
12
|
+
config.appManifest?.modules?.integrations
|
|
13
|
+
|| config.modules?.integrations
|
|
14
|
+
|| registry.listIntegrationProviders().length,
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
module.exports = {
|
|
19
|
+
...registry,
|
|
20
|
+
...runtime,
|
|
21
|
+
...sanitizer,
|
|
22
|
+
...models,
|
|
23
|
+
...worker,
|
|
24
|
+
buildIntegrationRouter,
|
|
25
|
+
integrationsEnabled,
|
|
26
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const mongoose = require("mongoose");
|
|
4
|
+
|
|
5
|
+
const OUTBOX_STATUSES = ["Pendente", "Processando", "Erro temporário", "Concluído", "Erro definitivo", "Arquivado"];
|
|
6
|
+
const INBOX_STATUSES = ["Pendente", "Processando", "Concluído", "Erro temporário", "Erro definitivo"];
|
|
7
|
+
const EXECUTION_STATUSES = ["Executando", "Concluído", "Concluído com erros", "Erro"];
|
|
8
|
+
|
|
9
|
+
function jsonField(defaultValue) {
|
|
10
|
+
return { type: mongoose.Schema.Types.Mixed, default: defaultValue };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function leaseFields() {
|
|
14
|
+
return {
|
|
15
|
+
leaseId: { type: String, default: "", index: true },
|
|
16
|
+
lockedAt: { type: Date, default: null },
|
|
17
|
+
lockedBy: { type: String, default: "" },
|
|
18
|
+
leaseExpiresAt: { type: Date, default: null, index: true },
|
|
19
|
+
heartbeatAt: { type: Date, default: null },
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function model(name, schemaFactory, collection) {
|
|
24
|
+
return mongoose.models[name] || mongoose.model(name, schemaFactory(), collection);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function outboxSchema() {
|
|
28
|
+
const schema = new mongoose.Schema({
|
|
29
|
+
provider: { type: String, required: true, index: true },
|
|
30
|
+
handler: { type: String, required: true },
|
|
31
|
+
resource: { type: String, default: "", index: true },
|
|
32
|
+
operation: { type: String, default: "execute", index: true },
|
|
33
|
+
aggregateType: { type: String, default: "", index: true },
|
|
34
|
+
aggregateId: { type: String, default: "", index: true },
|
|
35
|
+
idempotencyKey: { type: String, required: true, unique: true },
|
|
36
|
+
payload: jsonField({}),
|
|
37
|
+
status: { type: String, enum: OUTBOX_STATUSES, default: "Pendente", index: true },
|
|
38
|
+
previousStatus: { type: String, default: "" },
|
|
39
|
+
attempts: { type: Number, min: 0, default: 0 },
|
|
40
|
+
nextAttemptAt: { type: Date, default: Date.now, index: true },
|
|
41
|
+
lastError: jsonField({}),
|
|
42
|
+
responseSummary: jsonField({}),
|
|
43
|
+
completedAt: { type: Date, default: null },
|
|
44
|
+
archivedAt: { type: Date, default: null },
|
|
45
|
+
archiveReason: { type: String, default: "" },
|
|
46
|
+
...leaseFields(),
|
|
47
|
+
}, { timestamps: true, minimize: false, strict: true });
|
|
48
|
+
schema.index({ provider: 1, status: 1, nextAttemptAt: 1, createdAt: 1 });
|
|
49
|
+
schema.index({ status: 1, leaseExpiresAt: 1 });
|
|
50
|
+
return schema;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function inboxSchema() {
|
|
54
|
+
const schema = new mongoose.Schema({
|
|
55
|
+
provider: { type: String, required: true, index: true },
|
|
56
|
+
resource: { type: String, default: "", index: true },
|
|
57
|
+
eventType: { type: String, required: true, index: true },
|
|
58
|
+
externalEventId: { type: String, default: "" },
|
|
59
|
+
deduplicationKey: { type: String, required: true, unique: true },
|
|
60
|
+
payloadHash: { type: String, required: true, index: true },
|
|
61
|
+
payload: jsonField({}),
|
|
62
|
+
receivedAt: { type: Date, default: Date.now, index: true },
|
|
63
|
+
status: { type: String, enum: INBOX_STATUSES, default: "Pendente", index: true },
|
|
64
|
+
attempts: { type: Number, min: 0, default: 0 },
|
|
65
|
+
nextAttemptAt: { type: Date, default: Date.now, index: true },
|
|
66
|
+
processedAt: { type: Date, default: null },
|
|
67
|
+
lastError: jsonField({}),
|
|
68
|
+
...leaseFields(),
|
|
69
|
+
}, { timestamps: true, minimize: false, strict: true });
|
|
70
|
+
schema.index({ provider: 1, status: 1, nextAttemptAt: 1, receivedAt: 1 });
|
|
71
|
+
return schema;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function executionSchema() {
|
|
75
|
+
const schema = new mongoose.Schema({
|
|
76
|
+
provider: { type: String, required: true, index: true },
|
|
77
|
+
resource: { type: String, default: "", index: true },
|
|
78
|
+
operation: { type: String, default: "execute", index: true },
|
|
79
|
+
title: { type: String, required: true },
|
|
80
|
+
status: { type: String, enum: EXECUTION_STATUSES, default: "Executando", index: true },
|
|
81
|
+
startedAt: { type: Date, default: Date.now, index: true },
|
|
82
|
+
concludedAt: { type: Date, default: null },
|
|
83
|
+
durationMs: { type: Number, default: 0 },
|
|
84
|
+
message: { type: String, default: "" },
|
|
85
|
+
error: jsonField({}),
|
|
86
|
+
summary: jsonField({}),
|
|
87
|
+
metadata: jsonField({}),
|
|
88
|
+
requests: { type: [mongoose.Schema.Types.Mixed], default: [] },
|
|
89
|
+
requestCount: { type: Number, default: 0 },
|
|
90
|
+
requestsLimited: { type: Boolean, default: false },
|
|
91
|
+
errors: { type: [mongoose.Schema.Types.Mixed], default: [] },
|
|
92
|
+
errorCount: { type: Number, default: 0 },
|
|
93
|
+
errorsLimited: { type: Boolean, default: false },
|
|
94
|
+
items: { type: [mongoose.Schema.Types.Mixed], default: [] },
|
|
95
|
+
itemCount: { type: Number, default: 0 },
|
|
96
|
+
itemsLimited: { type: Boolean, default: false },
|
|
97
|
+
}, { timestamps: true, minimize: false, strict: true });
|
|
98
|
+
schema.index({ provider: 1, resource: 1, startedAt: -1 });
|
|
99
|
+
schema.index({ status: 1, startedAt: -1 });
|
|
100
|
+
return schema;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function getIntegrationModels() {
|
|
104
|
+
return {
|
|
105
|
+
Outbox: model("OonIntegrationOutbox", outboxSchema, "oon_integration_outbox"),
|
|
106
|
+
Inbox: model("OonIntegrationInbox", inboxSchema, "oon_integration_inbox"),
|
|
107
|
+
Execution: model("OonIntegrationExecution", executionSchema, "oon_integration_executions"),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { EXECUTION_STATUSES, INBOX_STATUSES, OUTBOX_STATUSES, getIntegrationModels };
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const coreRegistry = require("../../core/registry");
|
|
4
|
+
const GenericError = require("../../core/errors/GenericError");
|
|
5
|
+
|
|
6
|
+
const providers = new Map();
|
|
7
|
+
|
|
8
|
+
function normalizeKey(value, field = "identificador") {
|
|
9
|
+
const key = String(value || "").trim().toLowerCase();
|
|
10
|
+
if (!key) {
|
|
11
|
+
throw new GenericError(`Informe o ${field} da integração.`, { statusCode: 500 });
|
|
12
|
+
}
|
|
13
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/.test(key)) {
|
|
14
|
+
throw new GenericError(`${field} inválido: ${key}.`, { statusCode: 500 });
|
|
15
|
+
}
|
|
16
|
+
return key;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeResources(resources = []) {
|
|
20
|
+
const seen = new Set();
|
|
21
|
+
return resources.map((resource, index) => {
|
|
22
|
+
const key = normalizeKey(resource.key, `identificador do recurso ${index + 1}`);
|
|
23
|
+
if (seen.has(key)) {
|
|
24
|
+
throw new GenericError(`Recurso duplicado no provedor: ${key}.`, { statusCode: 500 });
|
|
25
|
+
}
|
|
26
|
+
seen.add(key);
|
|
27
|
+
return {
|
|
28
|
+
...resource,
|
|
29
|
+
key,
|
|
30
|
+
label: String(resource.label || key),
|
|
31
|
+
order: Number(resource.order ?? (index + 1) * 10),
|
|
32
|
+
includeInFullSync: resource.includeInFullSync !== false,
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function metadataFor(provider) {
|
|
38
|
+
return {
|
|
39
|
+
id: provider.key,
|
|
40
|
+
key: provider.key,
|
|
41
|
+
name: provider.key,
|
|
42
|
+
label: provider.label,
|
|
43
|
+
description: provider.description || "",
|
|
44
|
+
status: typeof provider.enabled === "function"
|
|
45
|
+
? (provider.enabled() ? "ativo" : "inativo")
|
|
46
|
+
: (provider.enabled === false ? "inativo" : "ativo"),
|
|
47
|
+
capabilities: [...(provider.capabilities || [])],
|
|
48
|
+
resources: provider.resources.map((resource) => ({
|
|
49
|
+
key: resource.key,
|
|
50
|
+
label: resource.label,
|
|
51
|
+
description: resource.description || "",
|
|
52
|
+
order: resource.order,
|
|
53
|
+
includeInFullSync: resource.includeInFullSync,
|
|
54
|
+
operations: [...(resource.operations || [resource.syncHandler ? "sync" : "execute"])],
|
|
55
|
+
})),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function registerIntegrationProvider(definition = {}) {
|
|
60
|
+
const key = normalizeKey(definition.key, "identificador do provedor");
|
|
61
|
+
if (providers.has(key) && definition.replace !== true) {
|
|
62
|
+
throw new GenericError(`Provedor de integração já registrado: ${key}.`, { statusCode: 500 });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const provider = {
|
|
66
|
+
...definition,
|
|
67
|
+
key,
|
|
68
|
+
label: String(definition.label || key),
|
|
69
|
+
resources: normalizeResources(definition.resources || []),
|
|
70
|
+
handlers: { ...(definition.handlers || {}) },
|
|
71
|
+
webhooks: { ...(definition.webhooks || {}) },
|
|
72
|
+
maxAttempts: Math.max(1, Number(definition.maxAttempts || 5)),
|
|
73
|
+
retryPolicy: {
|
|
74
|
+
strategy: "exponential",
|
|
75
|
+
initialMs: 60_000,
|
|
76
|
+
maxMs: 21_600_000,
|
|
77
|
+
jitter: true,
|
|
78
|
+
...(definition.retryPolicy || {}),
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
providers.set(key, provider);
|
|
83
|
+
coreRegistry.registerIntegration(key, metadataFor(provider));
|
|
84
|
+
return provider;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function getIntegrationProvider(key) {
|
|
88
|
+
const normalized = normalizeKey(key, "identificador do provedor");
|
|
89
|
+
const provider = providers.get(normalized);
|
|
90
|
+
if (!provider) {
|
|
91
|
+
throw new GenericError(`Provedor de integração não registrado: ${normalized}.`, {
|
|
92
|
+
statusCode: 404,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return provider;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function listIntegrationProviders() {
|
|
99
|
+
return [...providers.values()]
|
|
100
|
+
.map(metadataFor)
|
|
101
|
+
.sort((left, right) => left.label.localeCompare(right.label, "pt-BR"));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function resolveIntegrationHandler(providerKey, handlerKey) {
|
|
105
|
+
const provider = getIntegrationProvider(providerKey);
|
|
106
|
+
const key = String(handlerKey || "").trim();
|
|
107
|
+
const handler = provider.handlers[key];
|
|
108
|
+
if (!key || typeof handler !== "function") {
|
|
109
|
+
throw new GenericError(
|
|
110
|
+
`Operação ${key || "não informada"} não registrada para ${provider.label}.`,
|
|
111
|
+
{ statusCode: 422 },
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return { provider, handler, handlerKey: key };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function resolveWebhookHandler(providerKey, eventType) {
|
|
118
|
+
const provider = getIntegrationProvider(providerKey);
|
|
119
|
+
const type = String(eventType || "").trim();
|
|
120
|
+
const handler = provider.webhooks[type] || provider.webhooks["*"] || provider.handleWebhook;
|
|
121
|
+
if (typeof handler !== "function") {
|
|
122
|
+
throw new GenericError(`Evento ${type || "não informado"} não suportado por ${provider.label}.`, {
|
|
123
|
+
statusCode: 422,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return { provider, handler, eventType: type };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function findResource(providerKey, resourceKey) {
|
|
130
|
+
const provider = getIntegrationProvider(providerKey);
|
|
131
|
+
const key = normalizeKey(resourceKey, "identificador do recurso");
|
|
132
|
+
const resource = provider.resources.find((candidate) => candidate.key === key);
|
|
133
|
+
if (!resource) {
|
|
134
|
+
throw new GenericError(`Recurso ${key} não registrado para ${provider.label}.`, {
|
|
135
|
+
statusCode: 404,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return { provider, resource };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function resetIntegrationProviders() {
|
|
142
|
+
providers.clear();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = {
|
|
146
|
+
findResource,
|
|
147
|
+
getIntegrationProvider,
|
|
148
|
+
listIntegrationProviders,
|
|
149
|
+
normalizeKey,
|
|
150
|
+
registerIntegrationProvider,
|
|
151
|
+
resetIntegrationProviders,
|
|
152
|
+
resolveIntegrationHandler,
|
|
153
|
+
resolveWebhookHandler,
|
|
154
|
+
};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const express = require("express");
|
|
4
|
+
const auth = require("../../core/middlewares/auth");
|
|
5
|
+
const { rbac } = require("../../core/middlewares/rbac");
|
|
6
|
+
const { asyncHandler } = require("../../core/utils/helpers");
|
|
7
|
+
const { getIntegrationModels } = require("./models");
|
|
8
|
+
const { getIntegrationProvider, listIntegrationProviders } = require("./registry");
|
|
9
|
+
const {
|
|
10
|
+
archiveIntegrationTicket,
|
|
11
|
+
integrationHealth,
|
|
12
|
+
processIntegrationQueue,
|
|
13
|
+
processWebhookInbox,
|
|
14
|
+
receiveWebhook,
|
|
15
|
+
reprocessIntegrationTicket,
|
|
16
|
+
synchronizeAll,
|
|
17
|
+
synchronizeResource,
|
|
18
|
+
} = require("./runtime");
|
|
19
|
+
const { sanitizeIntegrationValue } = require("./sanitizer");
|
|
20
|
+
|
|
21
|
+
const READ_ROLES = ["admin", "desenvolvedor"];
|
|
22
|
+
const WRITE_ROLES = ["admin", "desenvolvedor"];
|
|
23
|
+
|
|
24
|
+
function number(value, fallback, max = 100) {
|
|
25
|
+
const parsed = Number(value);
|
|
26
|
+
return Number.isFinite(parsed) ? Math.min(max, Math.max(1, parsed)) : fallback;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function pagination(query = {}) {
|
|
30
|
+
const limit = number(query.limit || query.pageSize, 20, 200);
|
|
31
|
+
const page = number(query.page || query.pageIndex, 1, 100_000);
|
|
32
|
+
return { limit, page, skip: (page - 1) * limit };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildIntegrationRouter() {
|
|
36
|
+
const router = express.Router();
|
|
37
|
+
|
|
38
|
+
router.post("/webhooks/:provider/:token", asyncHandler(async (req, res) => {
|
|
39
|
+
const event = await receiveWebhook({
|
|
40
|
+
provider: req.params.provider,
|
|
41
|
+
token: req.params.token,
|
|
42
|
+
headers: req.headers,
|
|
43
|
+
payload: req.body,
|
|
44
|
+
resource: req.query.resource,
|
|
45
|
+
eventType: req.body?.eventType || req.body?.event_type || req.body?.topic || "unknown",
|
|
46
|
+
externalEventId: req.body?.eventId || req.body?.event_id || req.body?.id,
|
|
47
|
+
});
|
|
48
|
+
res.status(202).json({ accepted: true, id: String(event._id), duplicate: event.status !== "Pendente" });
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
router.use(auth);
|
|
52
|
+
router.get("/providers", rbac(READ_ROLES), (_req, res) => {
|
|
53
|
+
res.json({ results: listIntegrationProviders() });
|
|
54
|
+
});
|
|
55
|
+
router.get("/providers/:provider/catalog", rbac(READ_ROLES), (req, res) => {
|
|
56
|
+
const provider = getIntegrationProvider(req.params.provider);
|
|
57
|
+
const metadata = listIntegrationProviders().find((item) => item.key === provider.key);
|
|
58
|
+
res.json({ provider: provider.key, resources: metadata?.resources || [] });
|
|
59
|
+
});
|
|
60
|
+
router.get("/health", rbac(READ_ROLES), asyncHandler(async (_req, res) => {
|
|
61
|
+
res.json({ results: await integrationHealth() });
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
router.get("/history", rbac(READ_ROLES), asyncHandler(async (req, res) => {
|
|
65
|
+
const { Execution } = getIntegrationModels();
|
|
66
|
+
const { limit, page, skip } = pagination(req.query);
|
|
67
|
+
const filter = {};
|
|
68
|
+
for (const field of ["provider", "resource", "operation", "status"]) {
|
|
69
|
+
if (req.query[field]) filter[field] = String(req.query[field]);
|
|
70
|
+
}
|
|
71
|
+
const [results, totalItems] = await Promise.all([
|
|
72
|
+
Execution.find(filter).sort({ startedAt: -1 }).skip(skip).limit(limit).lean(),
|
|
73
|
+
Execution.countDocuments(filter),
|
|
74
|
+
]);
|
|
75
|
+
res.json({
|
|
76
|
+
results: sanitizeIntegrationValue(results),
|
|
77
|
+
pagination: {
|
|
78
|
+
currentPage: page,
|
|
79
|
+
totalItems,
|
|
80
|
+
totalPages: Math.ceil(totalItems / limit),
|
|
81
|
+
itemsPerPage: limit,
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
router.get("/queue", rbac(READ_ROLES), asyncHandler(async (req, res) => {
|
|
87
|
+
const { Outbox } = getIntegrationModels();
|
|
88
|
+
const { limit, page, skip } = pagination(req.query);
|
|
89
|
+
const filter = {};
|
|
90
|
+
for (const field of ["provider", "resource", "operation", "status", "aggregateType", "aggregateId"]) {
|
|
91
|
+
if (req.query[field]) filter[field] = String(req.query[field]);
|
|
92
|
+
}
|
|
93
|
+
const [results, totalItems] = await Promise.all([
|
|
94
|
+
Outbox.find(filter).sort({ createdAt: -1 }).skip(skip).limit(limit).lean(),
|
|
95
|
+
Outbox.countDocuments(filter),
|
|
96
|
+
]);
|
|
97
|
+
res.json({
|
|
98
|
+
results: sanitizeIntegrationValue(results),
|
|
99
|
+
pagination: {
|
|
100
|
+
currentPage: page,
|
|
101
|
+
totalItems,
|
|
102
|
+
totalPages: Math.ceil(totalItems / limit),
|
|
103
|
+
itemsPerPage: limit,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}));
|
|
107
|
+
|
|
108
|
+
router.get("/events", rbac(READ_ROLES), asyncHandler(async (req, res) => {
|
|
109
|
+
const { Inbox } = getIntegrationModels();
|
|
110
|
+
const { limit, page, skip } = pagination(req.query);
|
|
111
|
+
const filter = {};
|
|
112
|
+
for (const field of ["provider", "resource", "eventType", "status"]) {
|
|
113
|
+
if (req.query[field]) filter[field] = String(req.query[field]);
|
|
114
|
+
}
|
|
115
|
+
const [results, totalItems] = await Promise.all([
|
|
116
|
+
Inbox.find(filter).sort({ receivedAt: -1 }).skip(skip).limit(limit).lean(),
|
|
117
|
+
Inbox.countDocuments(filter),
|
|
118
|
+
]);
|
|
119
|
+
res.json({
|
|
120
|
+
results: sanitizeIntegrationValue(results),
|
|
121
|
+
pagination: {
|
|
122
|
+
currentPage: page,
|
|
123
|
+
totalItems,
|
|
124
|
+
totalPages: Math.ceil(totalItems / limit),
|
|
125
|
+
itemsPerPage: limit,
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
}));
|
|
129
|
+
|
|
130
|
+
router.post("/providers/:provider/resources/:resource/sync", rbac(WRITE_ROLES), asyncHandler(async (req, res) => {
|
|
131
|
+
const result = await synchronizeResource(req.params.provider, req.params.resource, {
|
|
132
|
+
payload: req.body,
|
|
133
|
+
metadata: { source: "api", requestId: req.requestId || "", user: req.usuario?.email || "" },
|
|
134
|
+
});
|
|
135
|
+
res.status(200).json(result);
|
|
136
|
+
}));
|
|
137
|
+
router.post("/providers/:provider/sync-all", rbac(WRITE_ROLES), asyncHandler(async (req, res) => {
|
|
138
|
+
const result = await synchronizeAll(req.params.provider, {
|
|
139
|
+
payload: req.body,
|
|
140
|
+
metadata: { source: "api", requestId: req.requestId || "", user: req.usuario?.email || "" },
|
|
141
|
+
});
|
|
142
|
+
res.status(result.status === "Concluído" ? 200 : 207).json(result);
|
|
143
|
+
}));
|
|
144
|
+
router.post("/queue/process", rbac(WRITE_ROLES), asyncHandler(async (req, res) => {
|
|
145
|
+
res.json(await processIntegrationQueue({ provider: req.body?.provider, limit: req.body?.limit }));
|
|
146
|
+
}));
|
|
147
|
+
router.post("/events/process", rbac(WRITE_ROLES), asyncHandler(async (req, res) => {
|
|
148
|
+
res.json(await processWebhookInbox({ provider: req.body?.provider, limit: req.body?.limit }));
|
|
149
|
+
}));
|
|
150
|
+
router.post("/queue/:id/archive", rbac(WRITE_ROLES), asyncHandler(async (req, res) => {
|
|
151
|
+
res.json(await archiveIntegrationTicket(req.params.id, req.body?.reason));
|
|
152
|
+
}));
|
|
153
|
+
router.post("/queue/:id/reprocess", rbac(WRITE_ROLES), asyncHandler(async (req, res) => {
|
|
154
|
+
res.json(await reprocessIntegrationTicket(req.params.id));
|
|
155
|
+
}));
|
|
156
|
+
router.post("/actions/process-active", rbac(WRITE_ROLES), asyncHandler(async (req, res) => {
|
|
157
|
+
const [queue, events] = await Promise.all([
|
|
158
|
+
processIntegrationQueue({ limit: req.body?.limit }),
|
|
159
|
+
processWebhookInbox({ limit: req.body?.eventLimit }),
|
|
160
|
+
]);
|
|
161
|
+
res.json({ queue, events });
|
|
162
|
+
}));
|
|
163
|
+
|
|
164
|
+
return router;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = { buildIntegrationRouter };
|