@oondemand/oon-core-back 0.3.55 → 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,123 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const GenericError = require("../../../core/errors/GenericError");
|
|
4
|
+
const { getIntegrationModels } = require("../models");
|
|
5
|
+
const { getIntegrationProvider, resolveWebhookHandler } = require("../registry");
|
|
6
|
+
const { sanitizeIntegrationError, sanitizeIntegrationValue } = require("../sanitizer");
|
|
7
|
+
const { acquireNext, finalizeLease, leaseHeartbeat } = require("./lease");
|
|
8
|
+
const { runTrackedOperation } = require("./tracking");
|
|
9
|
+
const { DEFAULT_LEASE_MS, calculateBackoff, classifyError, isEnabled, payloadHash, workerId } = require("./utils");
|
|
10
|
+
|
|
11
|
+
async function receiveWebhook(input) {
|
|
12
|
+
const provider = getIntegrationProvider(input.provider);
|
|
13
|
+
if (typeof provider.verifyWebhook === "function") {
|
|
14
|
+
const accepted = await provider.verifyWebhook({
|
|
15
|
+
token: input.token,
|
|
16
|
+
headers: input.headers || {},
|
|
17
|
+
payload: input.payload,
|
|
18
|
+
});
|
|
19
|
+
if (!accepted) throw new GenericError("Webhook de integração não autorizado.", { statusCode: 401 });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const sanitizedPayload = sanitizeIntegrationValue(input.payload || {});
|
|
23
|
+
const hash = payloadHash(sanitizedPayload);
|
|
24
|
+
const externalEventId = String(input.externalEventId || "").trim();
|
|
25
|
+
const deduplicationKey = `${provider.key}:${externalEventId || hash}`;
|
|
26
|
+
const { Inbox } = getIntegrationModels();
|
|
27
|
+
return Inbox.findOneAndUpdate(
|
|
28
|
+
{ deduplicationKey },
|
|
29
|
+
{
|
|
30
|
+
$setOnInsert: {
|
|
31
|
+
provider: provider.key,
|
|
32
|
+
resource: String(input.resource || ""),
|
|
33
|
+
eventType: String(input.eventType || "unknown"),
|
|
34
|
+
externalEventId,
|
|
35
|
+
deduplicationKey,
|
|
36
|
+
payloadHash: hash,
|
|
37
|
+
payload: sanitizedPayload,
|
|
38
|
+
receivedAt: new Date(),
|
|
39
|
+
status: "Pendente",
|
|
40
|
+
attempts: 0,
|
|
41
|
+
nextAttemptAt: new Date(),
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{ upsert: true, new: true, setDefaultsOnInsert: true },
|
|
45
|
+
).lean();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function processWebhookInbox(options = {}) {
|
|
49
|
+
const { Inbox } = getIntegrationModels();
|
|
50
|
+
const limit = Math.min(500, Math.max(1, Number(options.limit || 20)));
|
|
51
|
+
const worker = options.worker || workerId("inbox");
|
|
52
|
+
const leaseMs = Number(options.leaseMs || DEFAULT_LEASE_MS);
|
|
53
|
+
const results = [];
|
|
54
|
+
|
|
55
|
+
for (let index = 0; index < limit; index += 1) {
|
|
56
|
+
const query = { status: { $in: ["Pendente", "Erro temporário", "Processando"] } };
|
|
57
|
+
if (options.provider) query.provider = String(options.provider).trim().toLowerCase();
|
|
58
|
+
const event = await acquireNext(Inbox, query, { worker, leaseMs });
|
|
59
|
+
if (!event) break;
|
|
60
|
+
const pulse = leaseHeartbeat(Inbox, event, options);
|
|
61
|
+
pulse.start();
|
|
62
|
+
let provider;
|
|
63
|
+
try {
|
|
64
|
+
const resolved = resolveWebhookHandler(event.provider, event.eventType);
|
|
65
|
+
provider = resolved.provider;
|
|
66
|
+
const handler = resolved.handler;
|
|
67
|
+
if (!isEnabled(provider)) {
|
|
68
|
+
throw new GenericError(`A integração ${provider.label} está desativada.`, { statusCode: 503 });
|
|
69
|
+
}
|
|
70
|
+
const tracked = await runTrackedOperation({
|
|
71
|
+
provider: provider.key,
|
|
72
|
+
resource: event.resource || "",
|
|
73
|
+
operation: "webhook",
|
|
74
|
+
title: `${provider.label}: webhook ${event.eventType}`,
|
|
75
|
+
metadata: {
|
|
76
|
+
source: "inbox",
|
|
77
|
+
inboxId: String(event._id),
|
|
78
|
+
externalEventId: event.externalEventId || "",
|
|
79
|
+
},
|
|
80
|
+
runner: (trace) => handler(event, {
|
|
81
|
+
...options,
|
|
82
|
+
...trace,
|
|
83
|
+
provider,
|
|
84
|
+
heartbeat: pulse.beat,
|
|
85
|
+
sanitize: sanitizeIntegrationValue,
|
|
86
|
+
}),
|
|
87
|
+
});
|
|
88
|
+
const committed = await finalizeLease(Inbox, event, {
|
|
89
|
+
status: "Concluído",
|
|
90
|
+
processedAt: new Date(),
|
|
91
|
+
lastError: {},
|
|
92
|
+
nextAttemptAt: null,
|
|
93
|
+
});
|
|
94
|
+
if (!committed) {
|
|
95
|
+
throw new GenericError("Resultado do webhook ignorado porque o lease expirou.", { statusCode: 409 });
|
|
96
|
+
}
|
|
97
|
+
results.push({ id: String(event._id), status: "Concluído", response: tracked.result });
|
|
98
|
+
} catch (error) {
|
|
99
|
+
const effectiveProvider = provider || { maxAttempts: 1, retryPolicy: { jitter: false } };
|
|
100
|
+
const classification = classifyError(effectiveProvider, error, Number(event.attempts || 1));
|
|
101
|
+
const committed = await finalizeLease(Inbox, event, {
|
|
102
|
+
status: classification.definitive ? "Erro definitivo" : "Erro temporário",
|
|
103
|
+
lastError: sanitizeIntegrationError(error),
|
|
104
|
+
nextAttemptAt: classification.definitive
|
|
105
|
+
? null
|
|
106
|
+
: new Date(Date.now() + calculateBackoff(event.attempts, effectiveProvider.retryPolicy)),
|
|
107
|
+
});
|
|
108
|
+
if (!committed) {
|
|
109
|
+
throw new GenericError("Erro do webhook ignorado porque o lease expirou.", { statusCode: 409 });
|
|
110
|
+
}
|
|
111
|
+
results.push({
|
|
112
|
+
id: String(event._id),
|
|
113
|
+
status: classification.definitive ? "Erro definitivo" : "Erro temporário",
|
|
114
|
+
error: sanitizeIntegrationError(error),
|
|
115
|
+
});
|
|
116
|
+
} finally {
|
|
117
|
+
pulse.stop();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { processed: results.filter((item) => item.status === "Concluído").length, results };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { processWebhookInbox, receiveWebhook };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
const GenericError = require("../../../core/errors/GenericError");
|
|
5
|
+
const { DEFAULT_HEARTBEAT_MS, DEFAULT_LEASE_MS } = require("./utils");
|
|
6
|
+
|
|
7
|
+
async function acquireNext(Model, query, { worker, leaseMs }) {
|
|
8
|
+
const now = new Date();
|
|
9
|
+
const leaseId = crypto.randomUUID();
|
|
10
|
+
const document = await Model.findOneAndUpdate(
|
|
11
|
+
{
|
|
12
|
+
...query,
|
|
13
|
+
$and: [
|
|
14
|
+
{
|
|
15
|
+
$or: [
|
|
16
|
+
{ nextAttemptAt: { $lte: now } },
|
|
17
|
+
{ nextAttemptAt: null },
|
|
18
|
+
{ nextAttemptAt: { $exists: false } },
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
$or: [
|
|
23
|
+
{ leaseExpiresAt: { $lte: now } },
|
|
24
|
+
{ leaseExpiresAt: null },
|
|
25
|
+
{ leaseExpiresAt: { $exists: false } },
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
$set: {
|
|
32
|
+
status: "Processando",
|
|
33
|
+
leaseId,
|
|
34
|
+
lockedAt: now,
|
|
35
|
+
lockedBy: worker,
|
|
36
|
+
heartbeatAt: now,
|
|
37
|
+
leaseExpiresAt: new Date(now.getTime() + leaseMs),
|
|
38
|
+
},
|
|
39
|
+
$inc: { attempts: 1 },
|
|
40
|
+
},
|
|
41
|
+
{ sort: { createdAt: 1, receivedAt: 1 }, new: true },
|
|
42
|
+
).lean();
|
|
43
|
+
return document ? { ...document, leaseId } : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function heartbeat(Model, id, leaseId, leaseMs = DEFAULT_LEASE_MS) {
|
|
47
|
+
const now = new Date();
|
|
48
|
+
const result = await Model.updateOne(
|
|
49
|
+
{ _id: id, leaseId, status: "Processando" },
|
|
50
|
+
{ $set: { heartbeatAt: now, leaseExpiresAt: new Date(now.getTime() + leaseMs) } },
|
|
51
|
+
);
|
|
52
|
+
if (result.matchedCount !== 1) {
|
|
53
|
+
throw new GenericError("Lease da integração não pertence mais a este worker.", { statusCode: 409 });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function leaseHeartbeat(Model, document, options = {}) {
|
|
58
|
+
const leaseMs = Number(options.leaseMs || DEFAULT_LEASE_MS);
|
|
59
|
+
const intervalMs = Math.min(leaseMs / 2, Number(options.heartbeatMs || DEFAULT_HEARTBEAT_MS));
|
|
60
|
+
let timer;
|
|
61
|
+
let stopped = false;
|
|
62
|
+
const beat = () => heartbeat(Model, document._id, document.leaseId, leaseMs);
|
|
63
|
+
return {
|
|
64
|
+
start() {
|
|
65
|
+
timer = setInterval(() => {
|
|
66
|
+
beat().catch((error) => {
|
|
67
|
+
if (!stopped) console.error("[integration-heartbeat]", error.message);
|
|
68
|
+
});
|
|
69
|
+
}, intervalMs);
|
|
70
|
+
timer.unref?.();
|
|
71
|
+
},
|
|
72
|
+
async beat() { return beat(); },
|
|
73
|
+
stop() { stopped = true; if (timer) clearInterval(timer); },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function finalizeLease(Model, event, patch) {
|
|
78
|
+
const result = await Model.updateOne(
|
|
79
|
+
{ _id: event._id, leaseId: event.leaseId, status: "Processando" },
|
|
80
|
+
{
|
|
81
|
+
$set: {
|
|
82
|
+
...patch,
|
|
83
|
+
leaseId: "",
|
|
84
|
+
lockedAt: null,
|
|
85
|
+
lockedBy: "",
|
|
86
|
+
leaseExpiresAt: null,
|
|
87
|
+
heartbeatAt: null,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
return result.matchedCount === 1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { acquireNext, finalizeLease, heartbeat, leaseHeartbeat };
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const GenericError = require("../../../core/errors/GenericError");
|
|
4
|
+
const { getIntegrationModels } = require("../models");
|
|
5
|
+
const { getIntegrationProvider, resolveIntegrationHandler } = require("../registry");
|
|
6
|
+
const { sanitizeIntegrationError, sanitizeIntegrationValue } = require("../sanitizer");
|
|
7
|
+
const { acquireNext, finalizeLease, leaseHeartbeat } = require("./lease");
|
|
8
|
+
const { runTrackedOperation } = require("./tracking");
|
|
9
|
+
const { DEFAULT_LEASE_MS, calculateBackoff, classifyError, isEnabled, workerId } = require("./utils");
|
|
10
|
+
|
|
11
|
+
async function enqueueIntegration(input) {
|
|
12
|
+
const provider = getIntegrationProvider(input.provider);
|
|
13
|
+
const handler = String(input.handler || input.type || "").trim();
|
|
14
|
+
const idempotencyKey = String(input.idempotencyKey || "").trim();
|
|
15
|
+
if (!handler) throw new GenericError("Informe o handler da integração.", { statusCode: 422 });
|
|
16
|
+
if (!idempotencyKey) throw new GenericError("Informe a chave de idempotência.", { statusCode: 422 });
|
|
17
|
+
if (!provider.handlers[handler]) {
|
|
18
|
+
throw new GenericError(`Handler ${handler} não registrado para ${provider.label}.`, { statusCode: 422 });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const { Outbox } = getIntegrationModels();
|
|
22
|
+
return Outbox.findOneAndUpdate(
|
|
23
|
+
{ idempotencyKey },
|
|
24
|
+
{
|
|
25
|
+
$setOnInsert: {
|
|
26
|
+
provider: provider.key,
|
|
27
|
+
handler,
|
|
28
|
+
resource: String(input.resource || ""),
|
|
29
|
+
operation: String(input.operation || "execute"),
|
|
30
|
+
aggregateType: String(input.aggregateType || ""),
|
|
31
|
+
aggregateId: String(input.aggregateId || ""),
|
|
32
|
+
idempotencyKey,
|
|
33
|
+
payload: sanitizeIntegrationValue(input.payload || {}),
|
|
34
|
+
status: "Pendente",
|
|
35
|
+
attempts: 0,
|
|
36
|
+
nextAttemptAt: new Date(),
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{ upsert: true, new: true, setDefaultsOnInsert: true },
|
|
40
|
+
).lean();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function processOutboxEvent(event, options = {}) {
|
|
44
|
+
const { Outbox } = getIntegrationModels();
|
|
45
|
+
const pulse = leaseHeartbeat(Outbox, event, options);
|
|
46
|
+
pulse.start();
|
|
47
|
+
let provider;
|
|
48
|
+
let handlerKey = event.handler;
|
|
49
|
+
try {
|
|
50
|
+
const resolved = resolveIntegrationHandler(event.provider, event.handler);
|
|
51
|
+
provider = resolved.provider;
|
|
52
|
+
handlerKey = resolved.handlerKey;
|
|
53
|
+
const handler = resolved.handler;
|
|
54
|
+
if (!isEnabled(provider)) {
|
|
55
|
+
throw new GenericError(`A integração ${provider.label} está desativada.`, { statusCode: 503 });
|
|
56
|
+
}
|
|
57
|
+
const tracked = await runTrackedOperation({
|
|
58
|
+
provider: provider.key,
|
|
59
|
+
resource: event.resource || "",
|
|
60
|
+
operation: event.operation || "execute",
|
|
61
|
+
title: `${provider.label}: ${handlerKey}`,
|
|
62
|
+
metadata: {
|
|
63
|
+
source: "outbox",
|
|
64
|
+
outboxId: String(event._id),
|
|
65
|
+
aggregateType: event.aggregateType || "",
|
|
66
|
+
aggregateId: event.aggregateId || "",
|
|
67
|
+
attempts: event.attempts,
|
|
68
|
+
},
|
|
69
|
+
runner: (trace) => handler(event, {
|
|
70
|
+
...options,
|
|
71
|
+
...trace,
|
|
72
|
+
provider,
|
|
73
|
+
heartbeat: pulse.beat,
|
|
74
|
+
sanitize: sanitizeIntegrationValue,
|
|
75
|
+
}),
|
|
76
|
+
});
|
|
77
|
+
const response = tracked.result;
|
|
78
|
+
const committed = await finalizeLease(Outbox, event, {
|
|
79
|
+
status: "Concluído",
|
|
80
|
+
completedAt: new Date(),
|
|
81
|
+
responseSummary: sanitizeIntegrationValue(tracked.summary || response || {}),
|
|
82
|
+
lastError: {},
|
|
83
|
+
nextAttemptAt: null,
|
|
84
|
+
});
|
|
85
|
+
if (!committed) throw new GenericError("Resultado ignorado porque o lease expirou.", { statusCode: 409 });
|
|
86
|
+
return { id: String(event._id), handler: handlerKey, status: "Concluído", response };
|
|
87
|
+
} catch (error) {
|
|
88
|
+
const attempts = Number(event.attempts || 1);
|
|
89
|
+
const effectiveProvider = provider || { maxAttempts: 1, retryPolicy: { jitter: false } };
|
|
90
|
+
const classification = classifyError(effectiveProvider, error, attempts);
|
|
91
|
+
const retryPolicy = { ...effectiveProvider.retryPolicy, ...(error?.retryPolicy || {}) };
|
|
92
|
+
const committed = await finalizeLease(Outbox, event, {
|
|
93
|
+
status: classification.definitive ? "Erro definitivo" : "Erro temporário",
|
|
94
|
+
lastError: sanitizeIntegrationError(error),
|
|
95
|
+
nextAttemptAt: classification.definitive
|
|
96
|
+
? null
|
|
97
|
+
: new Date(Date.now() + calculateBackoff(attempts, retryPolicy)),
|
|
98
|
+
});
|
|
99
|
+
if (!committed) throw new GenericError("Erro ignorado porque o lease expirou.", { statusCode: 409 });
|
|
100
|
+
return {
|
|
101
|
+
id: String(event._id),
|
|
102
|
+
handler: handlerKey,
|
|
103
|
+
status: classification.definitive ? "Erro definitivo" : "Erro temporário",
|
|
104
|
+
error: sanitizeIntegrationError(error),
|
|
105
|
+
};
|
|
106
|
+
} finally {
|
|
107
|
+
pulse.stop();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function processIntegrationQueue(options = {}) {
|
|
112
|
+
const { Outbox } = getIntegrationModels();
|
|
113
|
+
const limit = Math.min(500, Math.max(1, Number(options.limit || 20)));
|
|
114
|
+
const worker = options.worker || workerId("outbox");
|
|
115
|
+
const leaseMs = Number(options.leaseMs || DEFAULT_LEASE_MS);
|
|
116
|
+
const statuses = ["Pendente", "Erro temporário", "Processando"];
|
|
117
|
+
const results = [];
|
|
118
|
+
|
|
119
|
+
for (let index = 0; index < limit; index += 1) {
|
|
120
|
+
const query = { status: { $in: statuses } };
|
|
121
|
+
if (options.provider) query.provider = String(options.provider).trim().toLowerCase();
|
|
122
|
+
const event = await acquireNext(Outbox, query, { worker, leaseMs });
|
|
123
|
+
if (!event) break;
|
|
124
|
+
try {
|
|
125
|
+
results.push(await processOutboxEvent(event, options));
|
|
126
|
+
} catch (error) {
|
|
127
|
+
results.push({ id: String(event._id), status: "Erro", error: sanitizeIntegrationError(error) });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
processed: results.filter((item) => item.status === "Concluído").length,
|
|
133
|
+
errors: results.filter((item) => item.status !== "Concluído"),
|
|
134
|
+
results,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function archiveIntegrationTicket(id, reason) {
|
|
139
|
+
const { Outbox } = getIntegrationModels();
|
|
140
|
+
const ticket = await Outbox.findById(id).lean();
|
|
141
|
+
if (!ticket) throw new GenericError("Ticket de integração não encontrado.", { statusCode: 404 });
|
|
142
|
+
if (!["Erro temporário", "Erro definitivo"].includes(ticket.status)) {
|
|
143
|
+
throw new GenericError("Somente tickets com erro podem ser arquivados.", { statusCode: 409 });
|
|
144
|
+
}
|
|
145
|
+
return Outbox.findOneAndUpdate(
|
|
146
|
+
{ _id: id, status: ticket.status },
|
|
147
|
+
{
|
|
148
|
+
$set: {
|
|
149
|
+
previousStatus: ticket.status,
|
|
150
|
+
status: "Arquivado",
|
|
151
|
+
archivedAt: new Date(),
|
|
152
|
+
archiveReason: String(reason || "Erro reconhecido e arquivado manualmente.").slice(0, 500),
|
|
153
|
+
nextAttemptAt: null,
|
|
154
|
+
leaseId: "",
|
|
155
|
+
lockedAt: null,
|
|
156
|
+
lockedBy: "",
|
|
157
|
+
leaseExpiresAt: null,
|
|
158
|
+
heartbeatAt: null,
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{ new: true },
|
|
162
|
+
).lean();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function reprocessIntegrationTicket(id) {
|
|
166
|
+
const { Outbox } = getIntegrationModels();
|
|
167
|
+
const ticket = await Outbox.findById(id).lean();
|
|
168
|
+
if (!ticket) throw new GenericError("Ticket de integração não encontrado.", { statusCode: 404 });
|
|
169
|
+
if (!["Erro temporário", "Erro definitivo", "Arquivado"].includes(ticket.status)) {
|
|
170
|
+
throw new GenericError("Este ticket não permite reprocessamento.", { statusCode: 409 });
|
|
171
|
+
}
|
|
172
|
+
return Outbox.findOneAndUpdate(
|
|
173
|
+
{ _id: id, status: ticket.status },
|
|
174
|
+
{
|
|
175
|
+
$set: {
|
|
176
|
+
previousStatus: ticket.status,
|
|
177
|
+
status: "Pendente",
|
|
178
|
+
attempts: 0,
|
|
179
|
+
nextAttemptAt: new Date(),
|
|
180
|
+
lastError: {},
|
|
181
|
+
archivedAt: null,
|
|
182
|
+
archiveReason: "",
|
|
183
|
+
leaseId: "",
|
|
184
|
+
lockedAt: null,
|
|
185
|
+
lockedBy: "",
|
|
186
|
+
leaseExpiresAt: null,
|
|
187
|
+
heartbeatAt: null,
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
{ new: true },
|
|
191
|
+
).lean();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = {
|
|
195
|
+
archiveIntegrationTicket,
|
|
196
|
+
enqueueIntegration,
|
|
197
|
+
processIntegrationQueue,
|
|
198
|
+
reprocessIntegrationTicket,
|
|
199
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const GenericError = require("../../../core/errors/GenericError");
|
|
4
|
+
const { getIntegrationModels } = require("../models");
|
|
5
|
+
const { findResource, getIntegrationProvider, listIntegrationProviders, resolveIntegrationHandler } = require("../registry");
|
|
6
|
+
const { sanitizeIntegrationError, sanitizeIntegrationValue } = require("../sanitizer");
|
|
7
|
+
const { runTrackedOperation } = require("./tracking");
|
|
8
|
+
const { isEnabled } = require("./utils");
|
|
9
|
+
|
|
10
|
+
async function synchronizeResource(providerKey, resourceKey, options = {}) {
|
|
11
|
+
const { provider, resource } = findResource(providerKey, resourceKey);
|
|
12
|
+
if (!isEnabled(provider)) {
|
|
13
|
+
throw new GenericError(`A integração ${provider.label} está desativada.`, { statusCode: 503 });
|
|
14
|
+
}
|
|
15
|
+
const handlerKey = resource.syncHandler || resource.handler;
|
|
16
|
+
const { handler } = resolveIntegrationHandler(provider.key, handlerKey);
|
|
17
|
+
return runTrackedOperation({
|
|
18
|
+
provider: provider.key,
|
|
19
|
+
resource: resource.key,
|
|
20
|
+
operation: "sync",
|
|
21
|
+
title: resource.syncTitle || `Sincronização de ${resource.label}`,
|
|
22
|
+
metadata: options.metadata || {},
|
|
23
|
+
runner: (context) => handler({
|
|
24
|
+
provider: provider.key,
|
|
25
|
+
handler: handlerKey,
|
|
26
|
+
resource: resource.key,
|
|
27
|
+
operation: "sync",
|
|
28
|
+
payload: sanitizeIntegrationValue(options.payload || {}),
|
|
29
|
+
}, { ...options, ...context, provider, resource }),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function synchronizeAll(providerKey, options = {}) {
|
|
34
|
+
const provider = getIntegrationProvider(providerKey);
|
|
35
|
+
const resources = provider.resources
|
|
36
|
+
.filter((resource) => resource.includeInFullSync && (resource.syncHandler || resource.handler))
|
|
37
|
+
.sort((left, right) => left.order - right.order);
|
|
38
|
+
const results = [];
|
|
39
|
+
for (const resource of resources) {
|
|
40
|
+
try {
|
|
41
|
+
results.push({
|
|
42
|
+
resource: resource.key,
|
|
43
|
+
ok: true,
|
|
44
|
+
result: await synchronizeResource(provider.key, resource.key, options),
|
|
45
|
+
});
|
|
46
|
+
} catch (error) {
|
|
47
|
+
results.push({ resource: resource.key, ok: false, error: sanitizeIntegrationError(error) });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
provider: provider.key,
|
|
52
|
+
status: results.some((item) => !item.ok) ? "Concluído com erros" : "Concluído",
|
|
53
|
+
results,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function integrationHealth() {
|
|
58
|
+
const { Outbox, Inbox, Execution } = getIntegrationModels();
|
|
59
|
+
const providers = listIntegrationProviders();
|
|
60
|
+
return Promise.all(providers.map(async (provider) => {
|
|
61
|
+
const [queue, inbox, lastExecution] = await Promise.all([
|
|
62
|
+
Outbox.aggregate([
|
|
63
|
+
{ $match: { provider: provider.key } },
|
|
64
|
+
{ $group: { _id: "$status", count: { $sum: 1 }, oldest: { $min: "$createdAt" } } },
|
|
65
|
+
]),
|
|
66
|
+
Inbox.aggregate([
|
|
67
|
+
{ $match: { provider: provider.key } },
|
|
68
|
+
{ $group: { _id: "$status", count: { $sum: 1 } } },
|
|
69
|
+
]),
|
|
70
|
+
Execution.findOne({ provider: provider.key }).sort({ startedAt: -1 }).lean(),
|
|
71
|
+
]);
|
|
72
|
+
return {
|
|
73
|
+
...provider,
|
|
74
|
+
queue: Object.fromEntries(queue.map((item) => [item._id, item.count])),
|
|
75
|
+
inbox: Object.fromEntries(inbox.map((item) => [item._id, item.count])),
|
|
76
|
+
oldestPendingAt: queue
|
|
77
|
+
.filter((item) => ["Pendente", "Erro temporário", "Processando"].includes(item._id) && item.oldest)
|
|
78
|
+
.map((item) => new Date(item.oldest))
|
|
79
|
+
.sort((left, right) => left.getTime() - right.getTime())[0] || null,
|
|
80
|
+
lastExecution: lastExecution ? sanitizeIntegrationValue(lastExecution) : null,
|
|
81
|
+
};
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { integrationHealth, synchronizeAll, synchronizeResource };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { getIntegrationModels } = require("../models");
|
|
4
|
+
const { sanitizeIntegrationError, sanitizeIntegrationValue } = require("../sanitizer");
|
|
5
|
+
const { DEFAULT_ITEM_LIMIT, DEFAULT_TRACE_LIMIT } = require("./utils");
|
|
6
|
+
|
|
7
|
+
async function runTrackedOperation({ provider, resource = "", operation = "execute", title, metadata = {}, runner }) {
|
|
8
|
+
const { Execution } = getIntegrationModels();
|
|
9
|
+
const startedAt = new Date();
|
|
10
|
+
const execution = await Execution.create({
|
|
11
|
+
provider,
|
|
12
|
+
resource,
|
|
13
|
+
operation,
|
|
14
|
+
title: title || `${provider}:${resource || operation}`,
|
|
15
|
+
status: "Executando",
|
|
16
|
+
startedAt,
|
|
17
|
+
metadata: sanitizeIntegrationValue(metadata),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const requests = [];
|
|
21
|
+
const errors = [];
|
|
22
|
+
const items = [];
|
|
23
|
+
let requestCount = 0;
|
|
24
|
+
let errorCount = 0;
|
|
25
|
+
let itemCount = 0;
|
|
26
|
+
const context = {
|
|
27
|
+
executionId: String(execution._id),
|
|
28
|
+
recordRequest(value) {
|
|
29
|
+
requestCount += 1;
|
|
30
|
+
if (requests.length < DEFAULT_TRACE_LIMIT) requests.push(sanitizeIntegrationValue(value));
|
|
31
|
+
},
|
|
32
|
+
recordError(value) {
|
|
33
|
+
errorCount += 1;
|
|
34
|
+
if (errors.length < DEFAULT_TRACE_LIMIT) errors.push(sanitizeIntegrationValue(value));
|
|
35
|
+
},
|
|
36
|
+
recordItem(value) {
|
|
37
|
+
itemCount += 1;
|
|
38
|
+
if (items.length < DEFAULT_ITEM_LIMIT) items.push(sanitizeIntegrationValue(value));
|
|
39
|
+
},
|
|
40
|
+
sanitize: sanitizeIntegrationValue,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const result = await runner(context);
|
|
45
|
+
const summary = sanitizeIntegrationValue(result?.summary || result || {});
|
|
46
|
+
const status = errors.length ? "Concluído com erros" : "Concluído";
|
|
47
|
+
await Execution.updateOne({ _id: execution._id }, {
|
|
48
|
+
$set: {
|
|
49
|
+
status,
|
|
50
|
+
concludedAt: new Date(),
|
|
51
|
+
durationMs: Date.now() - startedAt.getTime(),
|
|
52
|
+
message: String(result?.message || ""),
|
|
53
|
+
summary,
|
|
54
|
+
requests,
|
|
55
|
+
requestCount,
|
|
56
|
+
requestsLimited: requestCount > requests.length,
|
|
57
|
+
errors,
|
|
58
|
+
errorCount,
|
|
59
|
+
errorsLimited: errorCount > errors.length,
|
|
60
|
+
items,
|
|
61
|
+
itemCount,
|
|
62
|
+
itemsLimited: itemCount > items.length,
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
return { executionId: String(execution._id), status, summary, result };
|
|
66
|
+
} catch (error) {
|
|
67
|
+
const sanitized = sanitizeIntegrationError(error);
|
|
68
|
+
await Execution.updateOne({ _id: execution._id }, {
|
|
69
|
+
$set: {
|
|
70
|
+
status: "Erro",
|
|
71
|
+
concludedAt: new Date(),
|
|
72
|
+
durationMs: Date.now() - startedAt.getTime(),
|
|
73
|
+
error: sanitized,
|
|
74
|
+
requests,
|
|
75
|
+
requestCount,
|
|
76
|
+
requestsLimited: requestCount > requests.length,
|
|
77
|
+
errors: [...errors, sanitized].slice(0, DEFAULT_TRACE_LIMIT),
|
|
78
|
+
errorCount: errorCount + 1,
|
|
79
|
+
errorsLimited: errorCount + 1 > DEFAULT_TRACE_LIMIT,
|
|
80
|
+
items,
|
|
81
|
+
itemCount,
|
|
82
|
+
itemsLimited: itemCount > items.length,
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { runTrackedOperation };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
const os = require("node:os");
|
|
5
|
+
|
|
6
|
+
const DEFAULT_LEASE_MS = 120_000;
|
|
7
|
+
const DEFAULT_HEARTBEAT_MS = 30_000;
|
|
8
|
+
const DEFAULT_ITEM_LIMIT = 100;
|
|
9
|
+
const DEFAULT_TRACE_LIMIT = 100;
|
|
10
|
+
|
|
11
|
+
function workerId(prefix = "integration") {
|
|
12
|
+
return `${prefix}:${os.hostname()}:${process.pid}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function stableStringify(value) {
|
|
16
|
+
if (value == null || typeof value !== "object") return JSON.stringify(value);
|
|
17
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
18
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function payloadHash(payload) {
|
|
22
|
+
return crypto.createHash("sha256").update(stableStringify(payload)).digest("hex");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function calculateBackoff(attempt, policy = {}) {
|
|
26
|
+
const strategy = policy.strategy || "exponential";
|
|
27
|
+
const initialMs = Math.max(1, Number(policy.initialMs || 60_000));
|
|
28
|
+
const maxMs = Math.max(initialMs, Number(policy.maxMs || 21_600_000));
|
|
29
|
+
let delay = strategy === "fixed"
|
|
30
|
+
? initialMs
|
|
31
|
+
: Math.min(maxMs, initialMs * (2 ** Math.max(0, Number(attempt || 1) - 1)));
|
|
32
|
+
if (policy.jitter !== false) {
|
|
33
|
+
const factor = 0.75 + Math.random() * 0.5;
|
|
34
|
+
delay = Math.max(1, Math.round(delay * factor));
|
|
35
|
+
}
|
|
36
|
+
return delay;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function classifyError(provider, error, attempts) {
|
|
40
|
+
const custom = typeof provider.classifyError === "function"
|
|
41
|
+
? provider.classifyError(error, { attempts })
|
|
42
|
+
: null;
|
|
43
|
+
const retryable = custom?.retryable ?? (
|
|
44
|
+
typeof provider.isRetryable === "function"
|
|
45
|
+
? provider.isRetryable(error)
|
|
46
|
+
: error?.retryable !== false && Number(error?.statusCode || error?.response?.status || 500) >= 500
|
|
47
|
+
);
|
|
48
|
+
const maxAttempts = Math.max(1, Number(custom?.maxAttempts || provider.maxAttempts || 5));
|
|
49
|
+
return {
|
|
50
|
+
retryable: Boolean(retryable),
|
|
51
|
+
definitive: !retryable || attempts >= maxAttempts,
|
|
52
|
+
maxAttempts,
|
|
53
|
+
code: custom?.code || error?.code,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isEnabled(provider) {
|
|
58
|
+
return typeof provider.enabled === "function" ? Boolean(provider.enabled()) : provider.enabled !== false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = {
|
|
62
|
+
DEFAULT_HEARTBEAT_MS,
|
|
63
|
+
DEFAULT_ITEM_LIMIT,
|
|
64
|
+
DEFAULT_LEASE_MS,
|
|
65
|
+
DEFAULT_TRACE_LIMIT,
|
|
66
|
+
calculateBackoff,
|
|
67
|
+
classifyError,
|
|
68
|
+
isEnabled,
|
|
69
|
+
payloadHash,
|
|
70
|
+
workerId,
|
|
71
|
+
};
|