@hostwebhook/node-sdk 0.2.0 → 0.4.0
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/dist/acceso-al-workspace.d.ts +51 -0
- package/dist/acceso-al-workspace.js +99 -0
- package/dist/base-node.service.d.ts +120 -0
- package/dist/base-node.service.js +409 -0
- package/dist/cascade-cleanup.d.ts +13 -0
- package/dist/cascade-cleanup.js +93 -0
- package/dist/contratos.d.ts +19 -9
- package/dist/contratos.js +21 -45
- package/dist/credential-references.d.ts +131 -0
- package/dist/credential-references.js +210 -0
- package/dist/dto-utils.d.ts +11 -0
- package/dist/dto-utils.js +22 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/limpiar-marcador-detach.d.ts +63 -0
- package/dist/limpiar-marcador-detach.js +92 -0
- package/dist/olvidar-la-colocacion.d.ts +23 -0
- package/dist/olvidar-la-colocacion.js +60 -0
- package/dist/sync-connections.d.ts +73 -0
- package/dist/sync-connections.js +120 -0
- package/dist/unique-name.d.ts +6 -0
- package/dist/unique-name.js +32 -0
- package/package.json +5 -3
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BaseNodeService = void 0;
|
|
4
|
+
const olvidar_la_colocacion_1 = require("./olvidar-la-colocacion");
|
|
5
|
+
const common_1 = require("@nestjs/common");
|
|
6
|
+
const mongoose_1 = require("mongoose");
|
|
7
|
+
const cascade_cleanup_1 = require("./cascade-cleanup");
|
|
8
|
+
const sync_connections_1 = require("./sync-connections");
|
|
9
|
+
const normalize_nodes_1 = require("./normalize-nodes");
|
|
10
|
+
const unique_name_1 = require("./unique-name");
|
|
11
|
+
const dto_utils_1 = require("./dto-utils");
|
|
12
|
+
const workspace_payloads_1 = require("./workspace-payloads");
|
|
13
|
+
const limpiar_marcador_detach_1 = require("./limpiar-marcador-detach");
|
|
14
|
+
const credential_references_1 = require("./credential-references");
|
|
15
|
+
const swallow_1 = require("./swallow");
|
|
16
|
+
const acceso_al_workspace_1 = require("./acceso-al-workspace");
|
|
17
|
+
/**
|
|
18
|
+
* File-level logger so the diagnostic line in `saveLastPayload` doesn't
|
|
19
|
+
* require declaring `protected readonly logger` on the abstract class —
|
|
20
|
+
* which would collide with the `private readonly logger` already
|
|
21
|
+
* declared on ~22 subclasses (TS2415: stricter visibility on derived).
|
|
22
|
+
*/
|
|
23
|
+
const BASE_NODE_SAVE_PAYLOAD_LOGGER = new common_1.Logger('BaseNodeService.saveLastPayload');
|
|
24
|
+
/** Mismo motivo de visibilidad que el de arriba: a nivel de módulo. */
|
|
25
|
+
const BASE_NODE_STALE_PAYLOAD_LOGGER = new common_1.Logger('BaseNodeService.staleConnection');
|
|
26
|
+
/**
|
|
27
|
+
* Base service for node entities (HTTP Actions, Transform Nodes, Filter Nodes, Sheets Actions, etc.).
|
|
28
|
+
* Provides standard CRUD, saveLastPayload, findActiveByInput, and connection validation.
|
|
29
|
+
*
|
|
30
|
+
* Subclasses must define:
|
|
31
|
+
* - `entityName`: human-readable name for error messages (e.g. 'HTTP action')
|
|
32
|
+
* - `cascadeNodeType`: type key for cascadeCleanupNodeReferences (e.g. 'httpAction')
|
|
33
|
+
*/
|
|
34
|
+
class BaseNodeService {
|
|
35
|
+
model;
|
|
36
|
+
connection;
|
|
37
|
+
constructor(model, connection) {
|
|
38
|
+
this.model = model;
|
|
39
|
+
this.connection = connection;
|
|
40
|
+
}
|
|
41
|
+
async findAll(orgId, workspaceId, visibles) {
|
|
42
|
+
const filter = {
|
|
43
|
+
organizationId: new mongoose_1.Types.ObjectId(orgId),
|
|
44
|
+
};
|
|
45
|
+
if (workspaceId)
|
|
46
|
+
filter.workspaceId = new mongoose_1.Types.ObjectId(workspaceId);
|
|
47
|
+
/* Sin `workspaceId` esto listaba TODOS los nodos de la organizacion,
|
|
48
|
+
incluidos los de workspaces restringidos. Es el mismo agujero que tenia
|
|
49
|
+
el lienzo y se arregla igual: `null` significa «no hay nada que
|
|
50
|
+
recortar» y entonces la consulta sale identica a antes; el `null` de la
|
|
51
|
+
lista deja pasar los nodos sin workspace, que no estan en ninguno
|
|
52
|
+
restringido. */ else if (visibles)
|
|
53
|
+
filter.workspaceId = { $in: [...visibles, null] };
|
|
54
|
+
return this.model.find(filter).sort({ createdAt: -1 });
|
|
55
|
+
}
|
|
56
|
+
async findOne(id, orgId) {
|
|
57
|
+
const doc = await this.model.findOne({
|
|
58
|
+
_id: new mongoose_1.Types.ObjectId(id),
|
|
59
|
+
organizationId: new mongoose_1.Types.ObjectId(orgId),
|
|
60
|
+
});
|
|
61
|
+
if (!doc)
|
|
62
|
+
throw new common_1.NotFoundException(`${this.entityName} not found`);
|
|
63
|
+
return doc;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Devuelve el `organizationId` del nodo, que ya se busca aquí dentro.
|
|
67
|
+
*
|
|
68
|
+
* Lo necesita quien quiera avisar por socket de que el payload cambió: sin
|
|
69
|
+
* esto tendría que repetir la misma consulta justo después. Los llamantes
|
|
70
|
+
* que no lo usen no se enteran — antes devolvía `void`.
|
|
71
|
+
*/
|
|
72
|
+
async saveLastPayload(id, payload,
|
|
73
|
+
/**
|
|
74
|
+
* De dónde sale este payload.
|
|
75
|
+
*
|
|
76
|
+
* `example: true` sólo cuando el usuario promueve una PLANTILLA desde el
|
|
77
|
+
* Test push: datos inventados, con la forma exacta que el nodo va a
|
|
78
|
+
* despachar, para poder montar el flujo antes del primer evento real.
|
|
79
|
+
*
|
|
80
|
+
* ⚠️ Va FUERA del payload a propósito. Meter la marca dentro cambiaría la
|
|
81
|
+
* forma —y el sprint entero fue sobre que la plantilla sea la forma
|
|
82
|
+
* EXACTA—, así que quien copiara `{{payload.x}}` desde ahí acabaría con
|
|
83
|
+
* una ruta que no existe en la entrega de verdad.
|
|
84
|
+
*/
|
|
85
|
+
meta) {
|
|
86
|
+
if (payload == null)
|
|
87
|
+
return undefined;
|
|
88
|
+
const entity = (await this.model
|
|
89
|
+
.findById(id, { organizationId: 1, workspaceId: 1, name: 1 })
|
|
90
|
+
.lean());
|
|
91
|
+
if (!entity) {
|
|
92
|
+
// Used to be silent — the upsert would proceed with `organizationId:
|
|
93
|
+
// undefined` and the schema's `required: true` validation would
|
|
94
|
+
// bounce the write, leaving the calling .catch() to log a warn that
|
|
95
|
+
// got buried among other warns. Throwing here surfaces the race
|
|
96
|
+
// explicitly so the calling trigger handler's enhanced
|
|
97
|
+
// .catch() can emit a proper telemetry event with this exact reason.
|
|
98
|
+
throw new Error(`saveLastPayload: ${this.cascadeNodeType} entity ${id} not found at write time (race? was the trigger deleted between push and persist?)`);
|
|
99
|
+
}
|
|
100
|
+
const result = await this.connection.db
|
|
101
|
+
?.collection('nodepayloads')
|
|
102
|
+
.updateOne({ nodeId: new mongoose_1.Types.ObjectId(id) }, {
|
|
103
|
+
$set: {
|
|
104
|
+
nodeType: this.cascadeNodeType,
|
|
105
|
+
organizationId: entity.organizationId,
|
|
106
|
+
workspaceId: entity.workspaceId,
|
|
107
|
+
name: entity.name,
|
|
108
|
+
payload,
|
|
109
|
+
/**
|
|
110
|
+
* ⚠️ SIEMPRE se escribe, aunque sea `false`.
|
|
111
|
+
*
|
|
112
|
+
* Si sólo se pusiera al promover un ejemplo, la marca se quedaría
|
|
113
|
+
* pegada: el siguiente evento REAL sobreescribiría el payload y el
|
|
114
|
+
* panel seguiría diciendo "Example data" sobre datos de verdad —
|
|
115
|
+
* que es peor que no marcarlo nunca.
|
|
116
|
+
*/
|
|
117
|
+
example: meta?.example === true,
|
|
118
|
+
/**
|
|
119
|
+
* ⚠️ Y por lo mismo, SIEMPRE `false`.
|
|
120
|
+
*
|
|
121
|
+
* Éste es el segundo escritor de payloads —el otro es
|
|
122
|
+
* `NodePayloadsService.save()`— y si sólo limpiara aquel, el aviso
|
|
123
|
+
* de «esta salida es de otra conexión» se quedaría pegado en todos
|
|
124
|
+
* los nodos que pasan por aquí: la ejecución nueva sobreescribiría
|
|
125
|
+
* el payload y el panel seguiría avisando sobre datos recién
|
|
126
|
+
* traídos. Mismo error que documenta `example`, dos líneas arriba.
|
|
127
|
+
*/
|
|
128
|
+
staleConnection: false,
|
|
129
|
+
updatedAt: new Date(),
|
|
130
|
+
},
|
|
131
|
+
$setOnInsert: {
|
|
132
|
+
nodeId: new mongoose_1.Types.ObjectId(id),
|
|
133
|
+
createdAt: new Date(),
|
|
134
|
+
},
|
|
135
|
+
}, { upsert: true });
|
|
136
|
+
// Confirm the upsert actually landed. Diagnostic line — temporary,
|
|
137
|
+
// remove once the slack-trigger output-payload-empty bug is resolved.
|
|
138
|
+
// matched=1 → existing doc updated. upserted=yes → new doc created.
|
|
139
|
+
// matched=0 + upserted=no → write was a no-op (shouldn't happen with
|
|
140
|
+
// upsert: true, but worth catching). Local Logger so we don't collide
|
|
141
|
+
// with the `private readonly logger` declared on every subclass.
|
|
142
|
+
BASE_NODE_SAVE_PAYLOAD_LOGGER.log(`saveLastPayload — type=${this.cascadeNodeType} id=${id} matched=${result?.matchedCount ?? 0} modified=${result?.modifiedCount ?? 0} upserted=${result?.upsertedId ? 'yes' : 'no'}`);
|
|
143
|
+
// Keep Redis cache in sync so downstream $() reads get fresh data
|
|
144
|
+
if (entity.workspaceId && entity.name) {
|
|
145
|
+
(0, workspace_payloads_1.cacheNodePayload)(entity.workspaceId.toString(), entity.name, payload);
|
|
146
|
+
}
|
|
147
|
+
return { organizationId: entity.organizationId.toString() };
|
|
148
|
+
}
|
|
149
|
+
async remove(id, orgId) {
|
|
150
|
+
/* `findOneAndDelete` y no `deleteOne` porque hace falta el `workspaceId`
|
|
151
|
+
del nodo, y una vez borrado no queda de dónde sacarlo.
|
|
152
|
+
|
|
153
|
+
Sin él esto invalidaba sólo `canvas:{org}:all`, que es una clave que el
|
|
154
|
+
dashboard no pide nunca: siempre lee `canvas:{org}:{workspace}`. El nodo
|
|
155
|
+
borrado seguía en el lienzo hasta que vencía el TTL de 30 s, y volver a
|
|
156
|
+
borrarlo respondía «not found» —el de aquí abajo— porque en Mongo ya no
|
|
157
|
+
estaba. */
|
|
158
|
+
const deleted = await this.model.findOneAndDelete({
|
|
159
|
+
_id: new mongoose_1.Types.ObjectId(id),
|
|
160
|
+
organizationId: new mongoose_1.Types.ObjectId(orgId),
|
|
161
|
+
});
|
|
162
|
+
if (!deleted)
|
|
163
|
+
throw new common_1.NotFoundException(`${this.entityName} not found`);
|
|
164
|
+
await this.onCascadeDelete(id);
|
|
165
|
+
await (0, cascade_cleanup_1.cascadeCleanupNodeReferences)(this.connection, this.cascadeNodeType, id);
|
|
166
|
+
/* Al final y no justo tras el borrado: la limpieza en cascada desconecta
|
|
167
|
+
este nodo de los demás, y esas aristas también salen en el lienzo. Tirar
|
|
168
|
+
la clave antes dejaba una ventana en la que una lectura volvía a cachear
|
|
169
|
+
el grafo con las conexiones muertas todavía puestas. */
|
|
170
|
+
/* Y su sitio en el lienzo. Sin esto la clave se queda para siempre: es de
|
|
171
|
+
donde salió el 78 % de posiciones muertas del mapa viejo, y ya estaba
|
|
172
|
+
volviendo a pasar en la colección nueva. */
|
|
173
|
+
await (0, olvidar_la_colocacion_1.olvidarLaColocacion)(this.connection, this.cascadeNodeType, id, deleted.workspaceId?.toString());
|
|
174
|
+
(0, workspace_payloads_1.invalidateCanvasCache)(orgId, deleted.workspaceId?.toString());
|
|
175
|
+
}
|
|
176
|
+
/** Override for custom cascade cleanup (e.g. router rules). Default is no-op. */
|
|
177
|
+
async onCascadeDelete(_id) { }
|
|
178
|
+
/** Sugar for the one lookup event-pipeline does often enough to name. */
|
|
179
|
+
findActiveByInputWebhook(webhookId) {
|
|
180
|
+
return this.findActiveByInput('webhook', webhookId);
|
|
181
|
+
}
|
|
182
|
+
/** Find active nodes where the given node is an input source */
|
|
183
|
+
async findActiveByInput(nodeType, nodeId) {
|
|
184
|
+
return this.model
|
|
185
|
+
.find({
|
|
186
|
+
inputNodes: {
|
|
187
|
+
$elemMatch: { nodeType, nodeId: new mongoose_1.Types.ObjectId(nodeId) },
|
|
188
|
+
},
|
|
189
|
+
isActive: true,
|
|
190
|
+
})
|
|
191
|
+
.lean();
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Standard create: assertUniqueNodeName → model.create → normalizeAndValidateConnections.
|
|
195
|
+
* Pass node-specific fields via `fields` — userId, orgId, workspaceId, inputNodes,
|
|
196
|
+
* outputNodes, isActive, and name are handled automatically.
|
|
197
|
+
*/
|
|
198
|
+
async createNode(dto, userId, orgId, extraFields = {}) {
|
|
199
|
+
/* `workspaceId` llega del cuerpo y se escribía tal cual. Un id de OTRA
|
|
200
|
+
organización tampoco lo frenaba el guardia: no aparece en su consulta
|
|
201
|
+
—que filtra por organización— y su regla para «no encontrado» es dejar
|
|
202
|
+
contestar al manejador, para que un id equivocado siga dando 404 en vez
|
|
203
|
+
de delatar su existencia con un 403. Sólo que el manejador tampoco
|
|
204
|
+
miraba. Ver `workspaces/acceso-al-workspace.ts`. */
|
|
205
|
+
await (0, acceso_al_workspace_1.asegurarWorkspaceDeLaOrganizacion)(this.connection, dto.workspaceId, orgId);
|
|
206
|
+
await (0, unique_name_1.assertUniqueNodeName)(this.connection, dto.workspaceId, dto.name);
|
|
207
|
+
/* ANTES del `create`, no después. La comprobación de workspace corría con
|
|
208
|
+
el nodo ya guardado y sin transacción alrededor: el 400 salía, pero la
|
|
209
|
+
arista rechazada se quedaba escrita y el motor la seguía en el siguiente
|
|
210
|
+
evento. El workspace es el del DTO —el mismo con el que va a nacer el
|
|
211
|
+
nodo— y todavía no hay `_id`, así que no cabe autoconexión. */
|
|
212
|
+
await this.normalizeAndValidateConnections(dto, dto.workspaceId);
|
|
213
|
+
const saved = await this.model.create({
|
|
214
|
+
name: dto.name,
|
|
215
|
+
isActive: dto.isActive ?? true,
|
|
216
|
+
inputNodes: (0, normalize_nodes_1.normalizeNodes)(dto.inputNodes),
|
|
217
|
+
outputNodes: (0, normalize_nodes_1.normalizeNodes)(dto.outputNodes),
|
|
218
|
+
userId: new mongoose_1.Types.ObjectId(userId),
|
|
219
|
+
organizationId: new mongoose_1.Types.ObjectId(orgId),
|
|
220
|
+
workspaceId: dto.workspaceId ? new mongoose_1.Types.ObjectId(dto.workspaceId) : null,
|
|
221
|
+
...extraFields,
|
|
222
|
+
});
|
|
223
|
+
(0, workspace_payloads_1.invalidateCanvasCache)(orgId, saved.workspaceId?.toString());
|
|
224
|
+
return saved;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Normalize inputNodes/outputNodes and validate the structural rules.
|
|
228
|
+
*
|
|
229
|
+
* Tres reglas. Dos son sobre la forma del grafo y necesitan ids —no se puede
|
|
230
|
+
* cruzar de workspace, y un nodo no se conecta a sí mismo—, y por eso viven
|
|
231
|
+
* aquí y no en el registro.
|
|
232
|
+
*
|
|
233
|
+
* La tercera sí es de tipos. Aquí ponía que la compatibilidad «es cosa del
|
|
234
|
+
* registro y se comprueba en el lienzo», y eso sólo vale si el lienzo es el
|
|
235
|
+
* único que escribe. No lo es: `/agent-context` le explica a un agente que
|
|
236
|
+
* meta las referencias a mano, y cualquier cliente con la clave puede. Lo
|
|
237
|
+
* que entra por ahí no falla, desaparece — ver `assertConnectableTypes`.
|
|
238
|
+
*/
|
|
239
|
+
async normalizeAndValidateConnections(dto, workspaceId, selfId) {
|
|
240
|
+
if (dto.inputNodes === undefined && dto.outputNodes === undefined)
|
|
241
|
+
return;
|
|
242
|
+
const nodes = [...(dto.inputNodes ?? []), ...(dto.outputNodes ?? [])];
|
|
243
|
+
(0, sync_connections_1.assertConnectableTypes)(this.cascadeNodeType, dto);
|
|
244
|
+
if (selfId)
|
|
245
|
+
(0, sync_connections_1.assertNoSelfConnection)(this.cascadeNodeType, selfId, nodes);
|
|
246
|
+
await (0, sync_connections_1.validateConnectionWorkspaces)(this.connection, workspaceId, nodes);
|
|
247
|
+
}
|
|
248
|
+
/** Build update payload with normalized nodes */
|
|
249
|
+
buildUpdatePayload(dto) {
|
|
250
|
+
const update = (0, dto_utils_1.definedProps)({ ...dto });
|
|
251
|
+
if (dto.inputNodes !== undefined)
|
|
252
|
+
update.inputNodes = (0, normalize_nodes_1.normalizeNodes)(dto.inputNodes);
|
|
253
|
+
if (dto.outputNodes !== undefined)
|
|
254
|
+
update.outputNodes = (0, normalize_nodes_1.normalizeNodes)(dto.outputNodes);
|
|
255
|
+
this.castSchemaObjectIdFields(update);
|
|
256
|
+
return update;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Walk the entity's schema and convert any field declared as
|
|
260
|
+
* `Types.ObjectId` (or `[Types.ObjectId]`) from a string back to a
|
|
261
|
+
* proper ObjectId before the value enters a `findOneAndUpdate` $set.
|
|
262
|
+
*
|
|
263
|
+
* **Why this exists:** Mongoose does NOT auto-cast nested $set paths to
|
|
264
|
+
* the schema's declared types — the cast only fires for full-document
|
|
265
|
+
* `save()` and `Document.set()`. So a dashboard PATCH that lands a
|
|
266
|
+
* `credentialId: "69fe70…"` string in the dto would persist a String
|
|
267
|
+
* even though the schema says `Types.ObjectId`. That's invisible to
|
|
268
|
+
* `_id`-keyed lookups (Mongoose casts query filters), but breaks any
|
|
269
|
+
* downstream query using `field: { $in: [ObjectId(…)] }` because
|
|
270
|
+
* MongoDB is strict-typed: String !== ObjectId.
|
|
271
|
+
*
|
|
272
|
+
* The Slack-trigger `processSlackCommand` was the canary: it routes
|
|
273
|
+
* inbound pushes by `team_id → credentials → triggers $in [ObjectId]`
|
|
274
|
+
* and silently dropped every `/joke` invocation as `no-trigger-opted-in`
|
|
275
|
+
* even though the toggle was correctly true on the entity. Fix landed
|
|
276
|
+
* 2026-05-09 — see `feedback_basenode_objectid_cast.md` for the full
|
|
277
|
+
* forensics. Lifting the cast here means every node service inherits
|
|
278
|
+
* the protection without repeating per-subclass logic.
|
|
279
|
+
*
|
|
280
|
+
* Schema-driven so adding a new ObjectId field on any entity
|
|
281
|
+
* (including Array<ObjectId> like `[{type: ObjectId, ref: 'Foo'}]`)
|
|
282
|
+
* gets covered automatically — no maintenance needed.
|
|
283
|
+
*
|
|
284
|
+
* Skips `_id` (Mongoose handles it explicitly in query casting),
|
|
285
|
+
* skips `null`/`undefined` (preserves "clear the relationship"
|
|
286
|
+
* semantics), skips non-hex strings (throwing here would mask
|
|
287
|
+
* real validation errors).
|
|
288
|
+
*/
|
|
289
|
+
castSchemaObjectIdFields(payload) {
|
|
290
|
+
const schema = this.model.schema;
|
|
291
|
+
if (!schema?.eachPath)
|
|
292
|
+
return;
|
|
293
|
+
schema.eachPath((path, schemaType) => {
|
|
294
|
+
if (path === '_id')
|
|
295
|
+
return;
|
|
296
|
+
if (!(path in payload))
|
|
297
|
+
return;
|
|
298
|
+
const value = payload[path];
|
|
299
|
+
if (value == null)
|
|
300
|
+
return;
|
|
301
|
+
// Plain `field: ObjectId`
|
|
302
|
+
//
|
|
303
|
+
// 'ObjectId', not 'ObjectID'. Mongoose reported the capitalised form up
|
|
304
|
+
// to v6; this comparison was written against that and silently stopped
|
|
305
|
+
// matching on the upgrade — half of why this helper was a no-op for so
|
|
306
|
+
// long. The other half was that every path compiled to Mixed until the
|
|
307
|
+
// declarations were corrected, so there was nothing to match anyway.
|
|
308
|
+
if (schemaType?.instance === 'ObjectId' && typeof value === 'string') {
|
|
309
|
+
if (mongoose_1.Types.ObjectId.isValid(value)) {
|
|
310
|
+
payload[path] = new mongoose_1.Types.ObjectId(value);
|
|
311
|
+
}
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
// `field: [ObjectId]`
|
|
315
|
+
if (schemaType?.instance === 'Array' &&
|
|
316
|
+
schemaType.caster?.instance === 'ObjectId' &&
|
|
317
|
+
Array.isArray(value)) {
|
|
318
|
+
payload[path] = value.map((v) => typeof v === 'string' && mongoose_1.Types.ObjectId.isValid(v)
|
|
319
|
+
? new mongoose_1.Types.ObjectId(v)
|
|
320
|
+
: v);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
/** Standard update with node normalization and workspace validation */
|
|
326
|
+
async update(id, dto, orgId) {
|
|
327
|
+
let previousName;
|
|
328
|
+
/* La credencial de ANTES, para saber si el nodo cambió de servidor. Se lee
|
|
329
|
+
aquí porque `findOneAndUpdate` de abajo devuelve el documento ya
|
|
330
|
+
actualizado, y para comparar hace falta el de antes. */
|
|
331
|
+
let previousCredentialId = null;
|
|
332
|
+
const tocaConexiones = dto.inputNodes !== undefined || dto.outputNodes !== undefined;
|
|
333
|
+
if (dto.name !== undefined ||
|
|
334
|
+
dto.credentialId !== undefined ||
|
|
335
|
+
tocaConexiones) {
|
|
336
|
+
/* Filtrado por organización, igual que el `findOneAndUpdate` de abajo:
|
|
337
|
+
este documento ya no sirve sólo para comparar nombres, ahora decide si
|
|
338
|
+
la petición sigue. Leerlo sin el filtro dejaba que un nodo de otra
|
|
339
|
+
organización pusiera el workspace con el que se toma esa decisión. */
|
|
340
|
+
const existing = await this.model.findOne({
|
|
341
|
+
_id: new mongoose_1.Types.ObjectId(id),
|
|
342
|
+
organizationId: new mongoose_1.Types.ObjectId(orgId),
|
|
343
|
+
});
|
|
344
|
+
if (!existing)
|
|
345
|
+
throw new common_1.NotFoundException(`${this.entityName} not found`);
|
|
346
|
+
previousName = existing.name;
|
|
347
|
+
previousCredentialId = existing.credentialId?.toString() ?? null;
|
|
348
|
+
if (dto.name !== undefined) {
|
|
349
|
+
await (0, unique_name_1.assertUniqueNodeName)(this.connection, existing.workspaceId, dto.name, id);
|
|
350
|
+
}
|
|
351
|
+
/* ANTES del `findOneAndUpdate`. Corría después y sin transacción, así
|
|
352
|
+
que el 400 era cosmético: la arista rechazada ya estaba guardada y el
|
|
353
|
+
motor la recorría igual. El workspace no se puede cambiar por PATCH,
|
|
354
|
+
así que el de antes de escribir es el mismo que el de después. */
|
|
355
|
+
if (tocaConexiones) {
|
|
356
|
+
await this.normalizeAndValidateConnections(dto, existing.workspaceId, id);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const update = this.buildUpdatePayload(dto);
|
|
360
|
+
const updated = await this.model.findOneAndUpdate({
|
|
361
|
+
_id: new mongoose_1.Types.ObjectId(id),
|
|
362
|
+
organizationId: new mongoose_1.Types.ObjectId(orgId),
|
|
363
|
+
}, { $set: update }, { returnDocument: 'after' });
|
|
364
|
+
if (!updated)
|
|
365
|
+
throw new common_1.NotFoundException(`${this.entityName} not found`);
|
|
366
|
+
if (dto.name !== undefined) {
|
|
367
|
+
await (0, workspace_payloads_1.propagateNodeRename)(this.connection, id, previousName, dto.name, updated.workspaceId?.toString());
|
|
368
|
+
}
|
|
369
|
+
/* Cambiar de credencial es cambiar de servidor, y la última salida
|
|
370
|
+
guardada se queda enseñando lo que devolvió el anterior. Se marca para
|
|
371
|
+
que la UI pueda decir de dónde viene, en vez de pintarla como si fuera
|
|
372
|
+
de la conexión de ahora. No se borra: esa salida sigue siendo útil para
|
|
373
|
+
mapear campos aguas abajo — lo que sobra es el silencio, no el dato.
|
|
374
|
+
|
|
375
|
+
Vive aquí, en la base, y no en los dos nodos de base de datos: cualquier
|
|
376
|
+
nodo con credencial tiene el mismo problema en cuanto la cambia. */
|
|
377
|
+
if (dto.credentialId !== undefined) {
|
|
378
|
+
const nuevaCredencial = dto.credentialId
|
|
379
|
+
? String(dto.credentialId)
|
|
380
|
+
: null;
|
|
381
|
+
if (nuevaCredencial !== previousCredentialId) {
|
|
382
|
+
await this.connection
|
|
383
|
+
.collection('nodepayloads')
|
|
384
|
+
.updateOne({ nodeId: new mongoose_1.Types.ObjectId(id) }, { $set: { staleConnection: true } })
|
|
385
|
+
.catch((0, swallow_1.onFailure)(BASE_NODE_STALE_PAYLOAD_LOGGER, `marcar la salida de ${id} como de otra conexión`));
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
/* El aviso «se borró tu credencial» se apaga cuando vuelve a haber una.
|
|
389
|
+
|
|
390
|
+
El marcador lo escribe `credentials.service.remove` y hasta ahora no lo
|
|
391
|
+
borraba NADIE: un solo `$set` en toda la api y ningún `$unset`. El
|
|
392
|
+
dashboard se limitaba a ocultarlo mientras el nodo tuviera credencial,
|
|
393
|
+
así que seguía vivo debajo — y resucitaba en cuanto el nodo se quedaba
|
|
394
|
+
sin ella otra vez, aunque fuera por pulsar «Disconnect», que sólo pone
|
|
395
|
+
el campo a null y no borra nada. El usuario leía «fue eliminada el 20 de
|
|
396
|
+
agosto» tres días después de desconectar a mano.
|
|
397
|
+
|
|
398
|
+
Va aquí y no en el `$set` de arriba porque la condición no es «el dto
|
|
399
|
+
trae credencial» sino «el nodo TIENE credencial»: así también se limpia
|
|
400
|
+
el fósil de un nodo que sólo cambió de nombre. En el caso normal no hay
|
|
401
|
+
marcador y la función vuelve sin tocar la base. */
|
|
402
|
+
await (0, limpiar_marcador_detach_1.limpiarMarcadorDetachSiEsFosil)(this.model, updated, {
|
|
403
|
+
tocoCredencial: (0, credential_references_1.dtoTocaCredencial)(dto),
|
|
404
|
+
});
|
|
405
|
+
(0, workspace_payloads_1.invalidateCanvasCache)(orgId, updated.workspaceId?.toString());
|
|
406
|
+
return updated;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
exports.BaseNodeService = BaseNodeService;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Connection } from 'mongoose';
|
|
2
|
+
/**
|
|
3
|
+
* Remove everything a deleted node leaves behind: the references other
|
|
4
|
+
* nodes hold to it, and the payload row it owned.
|
|
5
|
+
*
|
|
6
|
+
* Called by every node service's remove() — the 39 that inherit
|
|
7
|
+
* `BaseNodeService.remove` plus webhooks and scheduled-workflows, which
|
|
8
|
+
* call it directly. That makes this the one funnel all 42 node types pass
|
|
9
|
+
* through on delete, which is why the payload cleanup lives here instead of
|
|
10
|
+
* in `BaseNodeService` (where the two outliers would have been missed) or
|
|
11
|
+
* in a per-type `onCascadeDelete` override (42 chances to forget one).
|
|
12
|
+
*/
|
|
13
|
+
export declare function cascadeCleanupNodeReferences(db: Connection, deletedNodeType: string, deletedNodeId: string): Promise<void>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.cascadeCleanupNodeReferences = cascadeCleanupNodeReferences;
|
|
4
|
+
const common_1 = require("@nestjs/common");
|
|
5
|
+
const mongoose_1 = require("mongoose");
|
|
6
|
+
const node_dispatch_registry_1 = require("./node-dispatch.registry");
|
|
7
|
+
const output_node_dto_1 = require("./dto/output-node.dto");
|
|
8
|
+
const workspace_payloads_1 = require("./workspace-payloads");
|
|
9
|
+
const logger = new common_1.Logger('CascadeCleanup');
|
|
10
|
+
/**
|
|
11
|
+
* Build all known aliases for a nodeType (e.g. 'scheduledWorkflow' also matches 'scheduled-workflow').
|
|
12
|
+
*/
|
|
13
|
+
function getNodeTypeVariants(nodeType) {
|
|
14
|
+
const variants = new Set([nodeType]);
|
|
15
|
+
// Check reverse: find aliases that normalize to this type
|
|
16
|
+
for (const [alias, canonical] of Object.entries(output_node_dto_1.NODE_TYPE_ALIASES)) {
|
|
17
|
+
if (canonical === nodeType)
|
|
18
|
+
variants.add(alias);
|
|
19
|
+
}
|
|
20
|
+
// Check forward: if the input is an alias, add the canonical
|
|
21
|
+
if (output_node_dto_1.NODE_TYPE_ALIASES[nodeType])
|
|
22
|
+
variants.add(output_node_dto_1.NODE_TYPE_ALIASES[nodeType]);
|
|
23
|
+
return [...variants];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Every array on a node entity that stores {nodeType, nodeId} refs.
|
|
27
|
+
*
|
|
28
|
+
* This list used to have seven entries and was the bug's own habitat: a
|
|
29
|
+
* node that invented an eighth field name and forgot to add it here kept
|
|
30
|
+
* orphan references forever — which is exactly what happened to
|
|
31
|
+
* `loopBackInputNodes`, and to `conditional.branches[].outputNodes`, whose
|
|
32
|
+
* dead targets needed a second cleanup layer with arrayFilters.
|
|
33
|
+
*
|
|
34
|
+
* Phase 1 collapsed the outbound side into `outputNodes` + a port, so
|
|
35
|
+
* there is nothing left to forget. `loopBackInputNodes` stays because it
|
|
36
|
+
* is a genuinely different edge — inbound, with a mirror on the target.
|
|
37
|
+
*/
|
|
38
|
+
const TOP_LEVEL_CONNECTION_FIELDS = [
|
|
39
|
+
'inputNodes',
|
|
40
|
+
'outputNodes',
|
|
41
|
+
'loopBackInputNodes', // loop — inbound, mirrored by loopBackTargetId
|
|
42
|
+
];
|
|
43
|
+
/**
|
|
44
|
+
* Remove everything a deleted node leaves behind: the references other
|
|
45
|
+
* nodes hold to it, and the payload row it owned.
|
|
46
|
+
*
|
|
47
|
+
* Called by every node service's remove() — the 39 that inherit
|
|
48
|
+
* `BaseNodeService.remove` plus webhooks and scheduled-workflows, which
|
|
49
|
+
* call it directly. That makes this the one funnel all 42 node types pass
|
|
50
|
+
* through on delete, which is why the payload cleanup lives here instead of
|
|
51
|
+
* in `BaseNodeService` (where the two outliers would have been missed) or
|
|
52
|
+
* in a per-type `onCascadeDelete` override (42 chances to forget one).
|
|
53
|
+
*/
|
|
54
|
+
async function cascadeCleanupNodeReferences(db, deletedNodeType, deletedNodeId) {
|
|
55
|
+
const nativeDb = db.db;
|
|
56
|
+
if (!nativeDb)
|
|
57
|
+
return;
|
|
58
|
+
const collections = (0, node_dispatch_registry_1.getAllNodeCollections)();
|
|
59
|
+
const typeVariants = getNodeTypeVariants(deletedNodeType);
|
|
60
|
+
const oid = new mongoose_1.Types.ObjectId(deletedNodeId);
|
|
61
|
+
const pullConditions = typeVariants.flatMap((t) => [
|
|
62
|
+
{ nodeType: t, nodeId: deletedNodeId },
|
|
63
|
+
{ nodeType: t, nodeId: oid },
|
|
64
|
+
]);
|
|
65
|
+
// Layer 1: top-level array fields. Single updateMany per collection
|
|
66
|
+
// covers every TOP_LEVEL_CONNECTION_FIELDS entry in one round-trip.
|
|
67
|
+
const topLevelPull = Object.fromEntries(TOP_LEVEL_CONNECTION_FIELDS.map((field) => [
|
|
68
|
+
field,
|
|
69
|
+
{ $or: pullConditions },
|
|
70
|
+
]));
|
|
71
|
+
const topLevelFilterOr = TOP_LEVEL_CONNECTION_FIELDS.flatMap((field) => typeVariants.flatMap((t) => [
|
|
72
|
+
{ [`${field}.nodeType`]: t, [`${field}.nodeId`]: deletedNodeId },
|
|
73
|
+
{ [`${field}.nodeType`]: t, [`${field}.nodeId`]: oid },
|
|
74
|
+
]));
|
|
75
|
+
await Promise.all(collections.map((col) => nativeDb
|
|
76
|
+
.collection(col)
|
|
77
|
+
.updateMany({ $or: topLevelFilterOr }, { $pull: topLevelPull })
|
|
78
|
+
.catch((err) => {
|
|
79
|
+
logger.error(`Failed to cleanup top-level refs in ${col} for ${deletedNodeType}:${deletedNodeId}: ${err?.message ?? err}`);
|
|
80
|
+
})));
|
|
81
|
+
// The node's own payload row. Runs after the reference scrub so a failure
|
|
82
|
+
// here cannot leave dangling edges pointing at a node that is already gone.
|
|
83
|
+
await (0, workspace_payloads_1.deleteNodePayload)(db, deletedNodeId);
|
|
84
|
+
// There used to be two more layers here: an arrayFilters pull over
|
|
85
|
+
// conditional.branches[].outputNodes, and a $set nulling the router's
|
|
86
|
+
// rules[].targetNodeId / targetNodeType. Both existed only because a
|
|
87
|
+
// destination could live nested inside a container or as a loose string.
|
|
88
|
+
// Since Phase 1 every outbound edge is a `outputNodes` entry, so the
|
|
89
|
+
// single $pull above reaches all of them — including branches, split
|
|
90
|
+
// outputs, classifier categories and router rules. The rule itself
|
|
91
|
+
// survives untouched, which is what the old $set was carefully
|
|
92
|
+
// preserving by hand.
|
|
93
|
+
}
|
package/dist/contratos.d.ts
CHANGED
|
@@ -21,19 +21,29 @@
|
|
|
21
21
|
* conoce las entidades de nadie.
|
|
22
22
|
*/
|
|
23
23
|
/**
|
|
24
|
-
* Los operadores que
|
|
24
|
+
* Los operadores que este SDK sabe EVALUAR. Se reexportan; se definen en
|
|
25
|
+
* `@hostwebhook/platform-contracts`.
|
|
25
26
|
*
|
|
26
|
-
* ⚠️
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
27
|
+
* ⚠️ **La lista ya no se escribe aquí, y el motivo importa.** El dashboard
|
|
28
|
+
* también las necesita —para pintar los desplegables— y este paquete arrastra
|
|
29
|
+
* `re2` y medio runtime de nodos. Importarlo desde el navegador para leer
|
|
30
|
+
* treinta cadenas sería pagar un bundle entero por una constante.
|
|
31
|
+
*
|
|
32
|
+
* Lo que NO se mudó es la garantía: `una-sola-lista-de-operadores.test.ts`
|
|
33
|
+
* sigue aquí, atando esta lista a los `case` de `filter-utils.ts` en los dos
|
|
34
|
+
* sentidos. La definición puede vivir en otro paquete mientras algo la ate a
|
|
35
|
+
* lo que la resuelve; una lista sin ese test es una lista que vuelve a
|
|
36
|
+
* divergir.
|
|
37
|
+
*
|
|
38
|
+
* Se reexporta —en vez de que quien la use la importe de contratos— para que
|
|
39
|
+
* la api no tenga que cambiar ni un import.
|
|
31
40
|
*/
|
|
32
|
-
export
|
|
33
|
-
export type FilterOperator
|
|
41
|
+
export { FILTER_OPERATORS, ROUTER_OPERATORS, OPERADORES_SIN_VALOR, llevaValor, } from '@hostwebhook/platform-contracts';
|
|
42
|
+
export type { FilterOperator, RouterOperator, } from '@hostwebhook/platform-contracts';
|
|
43
|
+
import type { FilterOperator as OperadorDeFiltro } from '@hostwebhook/platform-contracts';
|
|
34
44
|
export interface PayloadFilter {
|
|
35
45
|
field: string;
|
|
36
|
-
operator:
|
|
46
|
+
operator: OperadorDeFiltro;
|
|
37
47
|
value?: string;
|
|
38
48
|
enabled?: boolean;
|
|
39
49
|
}
|
package/dist/contratos.js
CHANGED
|
@@ -22,52 +22,28 @@
|
|
|
22
22
|
* conoce las entidades de nadie.
|
|
23
23
|
*/
|
|
24
24
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
-
exports.FILTER_OPERATORS = void 0;
|
|
25
|
+
exports.llevaValor = exports.OPERADORES_SIN_VALOR = exports.ROUTER_OPERATORS = exports.FILTER_OPERATORS = void 0;
|
|
26
26
|
/* ── Filtros ─────────────────────────────────────────────────────── */
|
|
27
27
|
/**
|
|
28
|
-
* Los operadores que
|
|
28
|
+
* Los operadores que este SDK sabe EVALUAR. Se reexportan; se definen en
|
|
29
|
+
* `@hostwebhook/platform-contracts`.
|
|
29
30
|
*
|
|
30
|
-
* ⚠️
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
31
|
+
* ⚠️ **La lista ya no se escribe aquí, y el motivo importa.** El dashboard
|
|
32
|
+
* también las necesita —para pintar los desplegables— y este paquete arrastra
|
|
33
|
+
* `re2` y medio runtime de nodos. Importarlo desde el navegador para leer
|
|
34
|
+
* treinta cadenas sería pagar un bundle entero por una constante.
|
|
35
|
+
*
|
|
36
|
+
* Lo que NO se mudó es la garantía: `una-sola-lista-de-operadores.test.ts`
|
|
37
|
+
* sigue aquí, atando esta lista a los `case` de `filter-utils.ts` en los dos
|
|
38
|
+
* sentidos. La definición puede vivir en otro paquete mientras algo la ate a
|
|
39
|
+
* lo que la resuelve; una lista sin ese test es una lista que vuelve a
|
|
40
|
+
* divergir.
|
|
41
|
+
*
|
|
42
|
+
* Se reexporta —en vez de que quien la use la importe de contratos— para que
|
|
43
|
+
* la api no tenga que cambiar ni un import.
|
|
35
44
|
*/
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
'gte',
|
|
42
|
-
'lt',
|
|
43
|
-
'lte',
|
|
44
|
-
/* Texto */
|
|
45
|
-
'contains',
|
|
46
|
-
'not_contains',
|
|
47
|
-
'starts_with',
|
|
48
|
-
'not_starts_with',
|
|
49
|
-
'ends_with',
|
|
50
|
-
'not_ends_with',
|
|
51
|
-
'matches_regex',
|
|
52
|
-
'is_empty',
|
|
53
|
-
'is_not_empty',
|
|
54
|
-
/* Presencia */
|
|
55
|
-
'exists',
|
|
56
|
-
'not_exists',
|
|
57
|
-
'has_key',
|
|
58
|
-
/* Booleanos */
|
|
59
|
-
'is_true',
|
|
60
|
-
'is_false',
|
|
61
|
-
/* Listas */
|
|
62
|
-
'array_contains',
|
|
63
|
-
'array_not_contains',
|
|
64
|
-
'array_empty',
|
|
65
|
-
'array_not_empty',
|
|
66
|
-
'array_length_eq',
|
|
67
|
-
'array_length_gt',
|
|
68
|
-
'array_length_lt',
|
|
69
|
-
/* Fechas */
|
|
70
|
-
'date_eq',
|
|
71
|
-
'date_before',
|
|
72
|
-
'date_after',
|
|
73
|
-
];
|
|
45
|
+
var platform_contracts_1 = require("@hostwebhook/platform-contracts");
|
|
46
|
+
Object.defineProperty(exports, "FILTER_OPERATORS", { enumerable: true, get: function () { return platform_contracts_1.FILTER_OPERATORS; } });
|
|
47
|
+
Object.defineProperty(exports, "ROUTER_OPERATORS", { enumerable: true, get: function () { return platform_contracts_1.ROUTER_OPERATORS; } });
|
|
48
|
+
Object.defineProperty(exports, "OPERADORES_SIN_VALOR", { enumerable: true, get: function () { return platform_contracts_1.OPERADORES_SIN_VALOR; } });
|
|
49
|
+
Object.defineProperty(exports, "llevaValor", { enumerable: true, get: function () { return platform_contracts_1.llevaValor; } });
|