@oondemand/oon-core-back 0.3.56 → 0.3.58

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.
@@ -0,0 +1,215 @@
1
+ "use strict";
2
+
3
+ const express = require("express");
4
+ const GenericError = require("../../core/errors/GenericError");
5
+ const auth = require("../../core/middlewares/auth");
6
+ const { rbac } = require("../../core/middlewares/rbac");
7
+ const { asyncHandler } = require("../../core/utils/helpers");
8
+ const { getIntegrationModels } = require("./models");
9
+ const { getIntegrationProvider, listIntegrationProviders } = require("./registry");
10
+ const {
11
+ archiveIntegrationTicket,
12
+ integrationHealth,
13
+ processIntegrationQueue,
14
+ processWebhookInbox,
15
+ receiveWebhook,
16
+ reprocessIntegrationTicket,
17
+ synchronizeAll,
18
+ synchronizeResource,
19
+ } = require("./runtime");
20
+ const { sanitizeIntegrationValue } = require("./sanitizer");
21
+
22
+ const READ_ROLES = ["admin", "desenvolvedor"];
23
+ const WRITE_ROLES = ["admin", "desenvolvedor"];
24
+
25
+ function number(value, fallback, max = 100) {
26
+ const parsed = Number(value);
27
+ return Number.isFinite(parsed) ? Math.min(max, Math.max(1, parsed)) : fallback;
28
+ }
29
+
30
+ function pagination(query = {}) {
31
+ const limit = number(query.limit || query.pageSize, 20, 200);
32
+ const page = number(query.page || query.pageIndex, 1, 100_000);
33
+ return { limit, page, skip: (page - 1) * limit };
34
+ }
35
+
36
+ function requireProviderExtension(provider, method, message) {
37
+ if (typeof provider[method] !== "function") {
38
+ throw new GenericError(message || `O provedor ${provider.label} não oferece ${method}.`, {
39
+ statusCode: 404,
40
+ });
41
+ }
42
+ return provider[method].bind(provider);
43
+ }
44
+
45
+ function buildIntegrationRouter() {
46
+ const router = express.Router();
47
+
48
+ router.post("/webhooks/:provider/:token", asyncHandler(async (req, res) => {
49
+ const event = await receiveWebhook({
50
+ provider: req.params.provider,
51
+ token: req.params.token,
52
+ headers: req.headers,
53
+ payload: req.body,
54
+ resource: req.query.resource,
55
+ eventType: req.body?.eventType || req.body?.event_type || req.body?.topic || "unknown",
56
+ externalEventId: req.body?.eventId || req.body?.event_id || req.body?.id,
57
+ });
58
+ res.status(202).json({ accepted: true, id: String(event._id), duplicate: event.status !== "Pendente" });
59
+ }));
60
+
61
+ router.use(auth);
62
+ router.get("/providers", rbac(READ_ROLES), (_req, res) => {
63
+ res.json({ results: listIntegrationProviders() });
64
+ });
65
+ router.get("/providers/:provider/catalog", rbac(READ_ROLES), (req, res) => {
66
+ const provider = getIntegrationProvider(req.params.provider);
67
+ const metadata = listIntegrationProviders().find((item) => item.key === provider.key);
68
+ res.json({ provider: provider.key, resources: metadata?.resources || [] });
69
+ });
70
+ router.get("/providers/:provider/configuration", rbac(READ_ROLES), asyncHandler(async (req, res) => {
71
+ const provider = getIntegrationProvider(req.params.provider);
72
+ const getConfiguration = requireProviderExtension(
73
+ provider,
74
+ "getConfiguration",
75
+ `O provedor ${provider.label} não possui configuração gerenciada pelo Core.`,
76
+ );
77
+ res.json({ provider: provider.key, instances: sanitizeIntegrationValue(await getConfiguration()) });
78
+ }));
79
+ router.put("/providers/:provider/configuration/:instanceId", rbac(WRITE_ROLES), asyncHandler(async (req, res) => {
80
+ const provider = getIntegrationProvider(req.params.provider);
81
+ const saveConfiguration = requireProviderExtension(provider, "saveConfiguration");
82
+ const saved = await saveConfiguration(req.params.instanceId, req.body || {});
83
+ res.json({ provider: provider.key, configuration: sanitizeIntegrationValue(saved) });
84
+ }));
85
+ router.post("/providers/:provider/configuration/:instanceId/test", rbac(WRITE_ROLES), asyncHandler(async (req, res) => {
86
+ const provider = getIntegrationProvider(req.params.provider);
87
+ const testConnection = requireProviderExtension(provider, "testConnection");
88
+ res.json(await testConnection(req.params.instanceId, {
89
+ input: req.body || {},
90
+ source: "api",
91
+ requestId: req.requestId || "",
92
+ }));
93
+ }));
94
+ router.get("/providers/:provider/mappings", rbac(READ_ROLES), asyncHandler(async (req, res) => {
95
+ const provider = getIntegrationProvider(req.params.provider);
96
+ const describe = requireProviderExtension(provider, "describe");
97
+ res.json({ provider: provider.key, ...sanitizeIntegrationValue(await describe()) });
98
+ }));
99
+ router.post("/providers/:provider/calls/:call", rbac(WRITE_ROLES), asyncHandler(async (req, res) => {
100
+ const provider = getIntegrationProvider(req.params.provider);
101
+ const executeCall = requireProviderExtension(provider, "executeCall");
102
+ res.json(await executeCall(req.params.call, req.body?.input || req.body || {}, {
103
+ instanceId: req.body?.instanceId || "default",
104
+ source: "api",
105
+ requestId: req.requestId || "",
106
+ }));
107
+ }));
108
+ router.get("/health", rbac(READ_ROLES), asyncHandler(async (_req, res) => {
109
+ res.json({ results: await integrationHealth() });
110
+ }));
111
+
112
+ router.get("/history", rbac(READ_ROLES), asyncHandler(async (req, res) => {
113
+ const { Execution } = getIntegrationModels();
114
+ const { limit, page, skip } = pagination(req.query);
115
+ const filter = {};
116
+ for (const field of ["provider", "resource", "operation", "status"]) {
117
+ if (req.query[field]) filter[field] = String(req.query[field]);
118
+ }
119
+ const [results, totalItems] = await Promise.all([
120
+ Execution.find(filter).sort({ startedAt: -1 }).skip(skip).limit(limit).lean(),
121
+ Execution.countDocuments(filter),
122
+ ]);
123
+ res.json({
124
+ results: sanitizeIntegrationValue(results),
125
+ pagination: {
126
+ currentPage: page,
127
+ totalItems,
128
+ totalPages: Math.ceil(totalItems / limit),
129
+ itemsPerPage: limit,
130
+ },
131
+ });
132
+ }));
133
+
134
+ router.get("/queue", rbac(READ_ROLES), asyncHandler(async (req, res) => {
135
+ const { Outbox } = getIntegrationModels();
136
+ const { limit, page, skip } = pagination(req.query);
137
+ const filter = {};
138
+ for (const field of ["provider", "resource", "operation", "status", "aggregateType", "aggregateId"]) {
139
+ if (req.query[field]) filter[field] = String(req.query[field]);
140
+ }
141
+ const [results, totalItems] = await Promise.all([
142
+ Outbox.find(filter).sort({ createdAt: -1 }).skip(skip).limit(limit).lean(),
143
+ Outbox.countDocuments(filter),
144
+ ]);
145
+ res.json({
146
+ results: sanitizeIntegrationValue(results),
147
+ pagination: {
148
+ currentPage: page,
149
+ totalItems,
150
+ totalPages: Math.ceil(totalItems / limit),
151
+ itemsPerPage: limit,
152
+ },
153
+ });
154
+ }));
155
+
156
+ router.get("/events", rbac(READ_ROLES), asyncHandler(async (req, res) => {
157
+ const { Inbox } = getIntegrationModels();
158
+ const { limit, page, skip } = pagination(req.query);
159
+ const filter = {};
160
+ for (const field of ["provider", "resource", "eventType", "status"]) {
161
+ if (req.query[field]) filter[field] = String(req.query[field]);
162
+ }
163
+ const [results, totalItems] = await Promise.all([
164
+ Inbox.find(filter).sort({ receivedAt: -1 }).skip(skip).limit(limit).lean(),
165
+ Inbox.countDocuments(filter),
166
+ ]);
167
+ res.json({
168
+ results: sanitizeIntegrationValue(results),
169
+ pagination: {
170
+ currentPage: page,
171
+ totalItems,
172
+ totalPages: Math.ceil(totalItems / limit),
173
+ itemsPerPage: limit,
174
+ },
175
+ });
176
+ }));
177
+
178
+ router.post("/providers/:provider/resources/:resource/sync", rbac(WRITE_ROLES), asyncHandler(async (req, res) => {
179
+ const result = await synchronizeResource(req.params.provider, req.params.resource, {
180
+ payload: req.body,
181
+ metadata: { source: "api", requestId: req.requestId || "", user: req.usuario?.email || "" },
182
+ });
183
+ res.status(200).json(result);
184
+ }));
185
+ router.post("/providers/:provider/sync-all", rbac(WRITE_ROLES), asyncHandler(async (req, res) => {
186
+ const result = await synchronizeAll(req.params.provider, {
187
+ payload: req.body,
188
+ metadata: { source: "api", requestId: req.requestId || "", user: req.usuario?.email || "" },
189
+ });
190
+ res.status(result.status === "Concluído" ? 200 : 207).json(result);
191
+ }));
192
+ router.post("/queue/process", rbac(WRITE_ROLES), asyncHandler(async (req, res) => {
193
+ res.json(await processIntegrationQueue({ provider: req.body?.provider, limit: req.body?.limit }));
194
+ }));
195
+ router.post("/events/process", rbac(WRITE_ROLES), asyncHandler(async (req, res) => {
196
+ res.json(await processWebhookInbox({ provider: req.body?.provider, limit: req.body?.limit }));
197
+ }));
198
+ router.post("/queue/:id/archive", rbac(WRITE_ROLES), asyncHandler(async (req, res) => {
199
+ res.json(await archiveIntegrationTicket(req.params.id, req.body?.reason));
200
+ }));
201
+ router.post("/queue/:id/reprocess", rbac(WRITE_ROLES), asyncHandler(async (req, res) => {
202
+ res.json(await reprocessIntegrationTicket(req.params.id));
203
+ }));
204
+ router.post("/actions/process-active", rbac(WRITE_ROLES), asyncHandler(async (req, res) => {
205
+ const [queue, events] = await Promise.all([
206
+ processIntegrationQueue({ limit: req.body?.limit }),
207
+ processWebhookInbox({ limit: req.body?.eventLimit }),
208
+ ]);
209
+ res.json({ queue, events });
210
+ }));
211
+
212
+ return router;
213
+ }
214
+
215
+ module.exports = { buildIntegrationRouter };
@@ -0,0 +1,134 @@
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
+ let verificationMetadata = {};
14
+ if (typeof provider.verifyWebhook === "function") {
15
+ const verification = await provider.verifyWebhook({
16
+ token: input.token,
17
+ headers: input.headers || {},
18
+ payload: input.payload,
19
+ });
20
+ const accepted = verification === true || verification?.accepted === true;
21
+ if (!accepted) throw new GenericError("Webhook de integração não autorizado.", { statusCode: 401 });
22
+ verificationMetadata = sanitizeIntegrationValue(verification?.metadata || {});
23
+ }
24
+
25
+ const normalized = typeof provider.normalizeWebhook === "function"
26
+ ? await provider.normalizeWebhook({ ...input, metadata: verificationMetadata })
27
+ : input;
28
+ const sanitizedPayload = sanitizeIntegrationValue(normalized.payload || {});
29
+ const hash = payloadHash(sanitizedPayload);
30
+ const externalEventId = String(normalized.externalEventId || "").trim();
31
+ const deduplicationKey = `${provider.key}:${externalEventId || hash}`;
32
+ const { Inbox } = getIntegrationModels();
33
+ return Inbox.findOneAndUpdate(
34
+ { deduplicationKey },
35
+ {
36
+ $setOnInsert: {
37
+ provider: provider.key,
38
+ resource: String(normalized.resource || ""),
39
+ eventType: String(normalized.eventType || "unknown"),
40
+ externalEventId,
41
+ deduplicationKey,
42
+ payloadHash: hash,
43
+ payload: sanitizedPayload,
44
+ metadata: sanitizeIntegrationValue({
45
+ ...verificationMetadata,
46
+ ...(normalized.metadata || {}),
47
+ }),
48
+ receivedAt: new Date(),
49
+ status: "Pendente",
50
+ attempts: 0,
51
+ nextAttemptAt: new Date(),
52
+ },
53
+ },
54
+ { upsert: true, new: true, setDefaultsOnInsert: true },
55
+ ).lean();
56
+ }
57
+
58
+ async function processWebhookInbox(options = {}) {
59
+ const { Inbox } = getIntegrationModels();
60
+ const limit = Math.min(500, Math.max(1, Number(options.limit || 20)));
61
+ const worker = options.worker || workerId("inbox");
62
+ const leaseMs = Number(options.leaseMs || DEFAULT_LEASE_MS);
63
+ const results = [];
64
+
65
+ for (let index = 0; index < limit; index += 1) {
66
+ const query = { status: { $in: ["Pendente", "Erro temporário", "Processando"] } };
67
+ if (options.provider) query.provider = String(options.provider).trim().toLowerCase();
68
+ const event = await acquireNext(Inbox, query, { worker, leaseMs });
69
+ if (!event) break;
70
+ const pulse = leaseHeartbeat(Inbox, event, options);
71
+ pulse.start();
72
+ let provider;
73
+ try {
74
+ const resolved = resolveWebhookHandler(event.provider, event.eventType);
75
+ provider = resolved.provider;
76
+ const handler = resolved.handler;
77
+ if (!isEnabled(provider)) {
78
+ throw new GenericError(`A integração ${provider.label} está desativada.`, { statusCode: 503 });
79
+ }
80
+ const tracked = await runTrackedOperation({
81
+ provider: provider.key,
82
+ resource: event.resource || "",
83
+ operation: "webhook",
84
+ title: `${provider.label}: webhook ${event.eventType}`,
85
+ metadata: {
86
+ source: "inbox",
87
+ inboxId: String(event._id),
88
+ externalEventId: event.externalEventId || "",
89
+ ...sanitizeIntegrationValue(event.metadata || {}),
90
+ },
91
+ runner: (trace) => handler(event, {
92
+ ...options,
93
+ ...trace,
94
+ provider,
95
+ heartbeat: pulse.beat,
96
+ sanitize: sanitizeIntegrationValue,
97
+ }),
98
+ });
99
+ const committed = await finalizeLease(Inbox, event, {
100
+ status: "Concluído",
101
+ processedAt: new Date(),
102
+ lastError: {},
103
+ nextAttemptAt: null,
104
+ });
105
+ if (!committed) {
106
+ throw new GenericError("Resultado do webhook ignorado porque o lease expirou.", { statusCode: 409 });
107
+ }
108
+ results.push({ id: String(event._id), status: "Concluído", response: tracked.result });
109
+ } catch (error) {
110
+ const effectiveProvider = provider || { maxAttempts: 1, retryPolicy: { jitter: false } };
111
+ const classification = classifyError(effectiveProvider, error, Number(event.attempts || 1));
112
+ const committed = await finalizeLease(Inbox, event, {
113
+ status: classification.definitive ? "Erro definitivo" : "Erro temporário",
114
+ lastError: sanitizeIntegrationError(error),
115
+ nextAttemptAt: classification.definitive
116
+ ? null
117
+ : new Date(Date.now() + calculateBackoff(event.attempts, effectiveProvider.retryPolicy)),
118
+ });
119
+ if (!committed) {
120
+ throw new GenericError("Erro do webhook ignorado porque o lease expirou.", { statusCode: 409 });
121
+ }
122
+ results.push({
123
+ id: String(event._id),
124
+ status: classification.definitive ? "Erro definitivo" : "Erro temporário",
125
+ error: sanitizeIntegrationError(error),
126
+ });
127
+ } finally {
128
+ pulse.stop();
129
+ }
130
+ }
131
+ return { processed: results.filter((item) => item.status === "Concluído").length, results };
132
+ }
133
+
134
+ 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
+ };