@hostwebhook/node-sdk 0.3.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
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Dónde guarda un nodo la referencia a una credencial — TODAS las formas.
3
+ *
4
+ * ## Por qué existe
5
+ *
6
+ * `findConsumers` buscaba en dos campos, `credentialId` y `memoryCredentialId`,
7
+ * y con eso decidía si una credencial estaba en uso. Los nodos que pueden usar
8
+ * VARIAS credenciales no las guardan así:
9
+ *
10
+ * - `socialMediaAction.credentialIds` — un mapa `plataforma → id | id[]`;
11
+ * - `socialMediaAction.extraCredentialIds` — un array;
12
+ * - `trigger.serviceConfig.social_media.credentialIds` — el mismo mapa,
13
+ * pero anidado dentro de `serviceConfig`.
14
+ *
15
+ * Así que borrar una credencial usada SÓLO por esas vías encontraba cero
16
+ * consumidores: ni 409, ni desatacado, ni aviso. La credencial desaparecía y el
17
+ * nodo se quedaba con un id que ya no resuelve — que es como Ariel perdió la de
18
+ * Mastodon el 2026-08-12, y el trigger siguió mostrando `…c0fac6` sin decir
19
+ * nada.
20
+ *
21
+ * No es la primera vez: la cabecera de `remove()` cuenta que esta misma clase
22
+ * de fallo dejó «19 nodos vivos en 5 workspaces apuntando a tres credenciales
23
+ * que no existían». Aquel arreglo cubrió los dos campos planos y se olvidó de
24
+ * los múltiples, porque la lista de campos estaba escrita a mano en la consulta.
25
+ * Ahora está declarada aquí, y `credential-references.spec.ts` la prueba con la
26
+ * forma real de cada documento.
27
+ *
28
+ * ## Cómo se compara
29
+ *
30
+ * Siempre por `String(...)`. Estos campos son `Mixed` en Mongoose, así que el
31
+ * mismo id vive como `ObjectId` en unos documentos y como `string` en otros —
32
+ * ver `reference-id.ts`. Comparar por valor fallaría en la mitad.
33
+ */
34
+ /** Una forma de guardar referencias. `path` admite puntos. */
35
+ export type CredRefShape =
36
+ /** `credentialId: ObjectId | string | null` */
37
+ {
38
+ kind: 'scalar';
39
+ path: string;
40
+ }
41
+ /** `extraCredentialIds: ObjectId[]` */
42
+ | {
43
+ kind: 'array';
44
+ path: string;
45
+ }
46
+ /** `credentialIds: { plataforma: id | id[] }` */
47
+ | {
48
+ kind: 'map';
49
+ path: string;
50
+ };
51
+ /**
52
+ * Las cinco formas que existen hoy.
53
+ *
54
+ * Se barren en TODAS las colecciones de nodos, no sólo en las que declaran el
55
+ * campo: una consulta por un campo que la colección no tiene simplemente no
56
+ * devuelve nada, y así añadir el campo a otro nodo no obliga a tocar esta lista.
57
+ */
58
+ export declare const CREDENTIAL_REF_SHAPES: readonly CredRefShape[];
59
+ /** Lee un camino con puntos. Devuelve `undefined` si algo del medio falta. */
60
+ export declare function leerCamino(doc: unknown, path: string): unknown;
61
+ /**
62
+ * ¿El valor de este campo referencia la credencial?
63
+ *
64
+ * Un mapa puede tener el id como string suelto o dentro de un array: las dos
65
+ * formas conviven en datos reales (`credentialIds` está declarado como
66
+ * `Record<string, string[] | string>` justamente por eso).
67
+ */
68
+ export declare function valorReferencia(valor: unknown, id: string, kind: CredRefShape['kind']): boolean;
69
+ /** Las formas por las que ESTE documento referencia la credencial. */
70
+ export declare function formasQueReferencian(doc: Record<string, unknown>, id: string): CredRefShape[];
71
+ /**
72
+ * El valor que hay que dejar en el campo al quitar la credencial.
73
+ *
74
+ * Devuelve `null` cuando el campo se queda sin nada, para que el documento no
75
+ * conserve un `{}` o un `[]` que se lee como «configurado, pero vacío».
76
+ *
77
+ * En el mapa, una plataforma que se queda sin cuentas **desaparece**: dejarla
78
+ * con `[]` haría que el trigger siguiera anunciando que vigila Mastodon sin
79
+ * ninguna cuenta con la que hacerlo.
80
+ */
81
+ export declare function valorTrasDesatacar(valor: unknown, id: string, kind: CredRefShape['kind']): unknown;
82
+ /**
83
+ * Los dos campos que `credentials.service.remove` escribe en cada nodo que se
84
+ * queda huérfano, para que la pantalla pueda decir QUÉ credencial era y
85
+ * cuándo desapareció (después de desatacar ya no queda id al que preguntar).
86
+ */
87
+ export declare const CAMPOS_MARCADOR_DETACH: readonly ["credentialDetachedAt", "credentialDetachedName"];
88
+ /** El `$unset` que los quita. */
89
+ export declare const UNSET_MARCADOR_DETACH: Record<string, ''>;
90
+ /** ¿Este valor trae al menos una credencial puesta? */
91
+ export declare function valorTieneCredencial(valor: unknown, kind: CredRefShape['kind']): boolean;
92
+ /**
93
+ * ¿El nodo tiene AHORA alguna credencial, por cualquiera de las cinco formas?
94
+ *
95
+ * Es la pregunta que decide si el aviso «se borró tu credencial» sigue
96
+ * teniendo sentido. El dashboard hace este mismo cálculo para ocultarlo
97
+ * (`hooks/useNodeErrors.ts`); aquí sirve para BORRAR el marcador, que es lo
98
+ * que faltaba: ocultarlo lo dejaba vivo, y volvía a salir en cuanto el nodo
99
+ * se quedaba sin credencial otra vez — aunque fuera por un «Disconnect» a
100
+ * mano, y enseñando la fecha de un borrado de días atrás.
101
+ */
102
+ export declare function tieneAlgunaCredencial(doc: unknown): boolean;
103
+ /**
104
+ * ¿Hay que quitarle el marcador a este documento?
105
+ *
106
+ * Sólo cuando el marcador está Y el nodo volvió a tener credencial. Si sigue
107
+ * huérfano el aviso es legítimo y se queda: alguien le borró la credencial de
108
+ * verdad y todavía no ha elegido otra.
109
+ */
110
+ export declare function marcadorEsFosil(doc: unknown, tocoCredencial?: boolean): boolean;
111
+ /**
112
+ * ¿Este dto toca el campo de la credencial, sea para poner una o para
113
+ * quitarla?
114
+ *
115
+ * ⚠️ Ésta es la mitad que faltaba, y se vio en cuanto llegó a producción.
116
+ *
117
+ * «Es fósil si el nodo VOLVIÓ a tener credencial» arregla el caso de elegir
118
+ * otra, pero deja fuera el que lo destapó: Ariel pulsó «Disconnect» y el nodo
119
+ * se quedó SIN credencial, así que seguía contando como huérfano legítimo y
120
+ * el aviso del día 20 seguía en pantalla. Su queja fue literal: «y sigue».
121
+ *
122
+ * La distinción buena no es «tiene credencial» sino «¿sigue siendo verdad que
123
+ * te la quitaron sin que tú hicieras nada?». En cuanto la persona toca ese
124
+ * campo —ponga una o la vacíe— ya vio el aviso y actuó: a partir de ahí el
125
+ * estado lo eligió ella, y seguir diciéndole que se lo borraron el día 20 es
126
+ * hablarle de otra cosa.
127
+ *
128
+ * `!== undefined` y no un truthy: `credentialId: null` es exactamente el caso
129
+ * de desconectar, y es el que hay que reconocer.
130
+ */
131
+ export declare function dtoTocaCredencial(dto: unknown): boolean;