@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.
@@ -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
+ }
@@ -21,19 +21,29 @@
21
21
  * conoce las entidades de nadie.
22
22
  */
23
23
  /**
24
- * Los operadores que el SDK sabe EVALUAR, sacados de `filter-utils`.
24
+ * Los operadores que este SDK sabe EVALUAR. Se reexportan; se definen en
25
+ * `@hostwebhook/platform-contracts`.
25
26
  *
26
- * ⚠️ No coinciden con los `enum` de las entidades de la api, y esa es la
27
- * lista buena: aquí vive el evaluador. Los enum de allá van de 7 a 32 segun
28
- * la entidad —`webhook` admite 7 de estos 30— asi que hay filtros
29
- * que el motor sabe resolver y la base de datos rechaza al guardar. Es un
30
- * fallo de la api, anterior a este paquete, y se arregla aparte.
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 declare const FILTER_OPERATORS: readonly ["eq", "neq", "gt", "gte", "lt", "lte", "contains", "not_contains", "starts_with", "not_starts_with", "ends_with", "not_ends_with", "matches_regex", "is_empty", "is_not_empty", "exists", "not_exists", "has_key", "is_true", "is_false", "array_contains", "array_not_contains", "array_empty", "array_not_empty", "array_length_eq", "array_length_gt", "array_length_lt", "date_eq", "date_before", "date_after"];
33
- export type FilterOperator = (typeof FILTER_OPERATORS)[number];
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: FilterOperator;
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 el SDK sabe EVALUAR, sacados de `filter-utils`.
28
+ * Los operadores que este SDK sabe EVALUAR. Se reexportan; se definen en
29
+ * `@hostwebhook/platform-contracts`.
29
30
  *
30
- * ⚠️ No coinciden con los `enum` de las entidades de la api, y esa es la
31
- * lista buena: aquí vive el evaluador. Los enum de allá van de 7 a 32 segun
32
- * la entidad —`webhook` admite 7 de estos 30— asi que hay filtros
33
- * que el motor sabe resolver y la base de datos rechaza al guardar. Es un
34
- * fallo de la api, anterior a este paquete, y se arregla aparte.
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
- exports.FILTER_OPERATORS = [
37
- /* Comparación */
38
- 'eq',
39
- 'neq',
40
- 'gt',
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; } });