@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,210 @@
1
+ "use strict";
2
+ /**
3
+ * Dónde guarda un nodo la referencia a una credencial — TODAS las formas.
4
+ *
5
+ * ## Por qué existe
6
+ *
7
+ * `findConsumers` buscaba en dos campos, `credentialId` y `memoryCredentialId`,
8
+ * y con eso decidía si una credencial estaba en uso. Los nodos que pueden usar
9
+ * VARIAS credenciales no las guardan así:
10
+ *
11
+ * - `socialMediaAction.credentialIds` — un mapa `plataforma → id | id[]`;
12
+ * - `socialMediaAction.extraCredentialIds` — un array;
13
+ * - `trigger.serviceConfig.social_media.credentialIds` — el mismo mapa,
14
+ * pero anidado dentro de `serviceConfig`.
15
+ *
16
+ * Así que borrar una credencial usada SÓLO por esas vías encontraba cero
17
+ * consumidores: ni 409, ni desatacado, ni aviso. La credencial desaparecía y el
18
+ * nodo se quedaba con un id que ya no resuelve — que es como Ariel perdió la de
19
+ * Mastodon el 2026-08-12, y el trigger siguió mostrando `…c0fac6` sin decir
20
+ * nada.
21
+ *
22
+ * No es la primera vez: la cabecera de `remove()` cuenta que esta misma clase
23
+ * de fallo dejó «19 nodos vivos en 5 workspaces apuntando a tres credenciales
24
+ * que no existían». Aquel arreglo cubrió los dos campos planos y se olvidó de
25
+ * los múltiples, porque la lista de campos estaba escrita a mano en la consulta.
26
+ * Ahora está declarada aquí, y `credential-references.spec.ts` la prueba con la
27
+ * forma real de cada documento.
28
+ *
29
+ * ## Cómo se compara
30
+ *
31
+ * Siempre por `String(...)`. Estos campos son `Mixed` en Mongoose, así que el
32
+ * mismo id vive como `ObjectId` en unos documentos y como `string` en otros —
33
+ * ver `reference-id.ts`. Comparar por valor fallaría en la mitad.
34
+ */
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.UNSET_MARCADOR_DETACH = exports.CAMPOS_MARCADOR_DETACH = exports.CREDENTIAL_REF_SHAPES = void 0;
37
+ exports.leerCamino = leerCamino;
38
+ exports.valorReferencia = valorReferencia;
39
+ exports.formasQueReferencian = formasQueReferencian;
40
+ exports.valorTrasDesatacar = valorTrasDesatacar;
41
+ exports.valorTieneCredencial = valorTieneCredencial;
42
+ exports.tieneAlgunaCredencial = tieneAlgunaCredencial;
43
+ exports.marcadorEsFosil = marcadorEsFosil;
44
+ exports.dtoTocaCredencial = dtoTocaCredencial;
45
+ /**
46
+ * Las cinco formas que existen hoy.
47
+ *
48
+ * Se barren en TODAS las colecciones de nodos, no sólo en las que declaran el
49
+ * campo: una consulta por un campo que la colección no tiene simplemente no
50
+ * devuelve nada, y así añadir el campo a otro nodo no obliga a tocar esta lista.
51
+ */
52
+ exports.CREDENTIAL_REF_SHAPES = [
53
+ { kind: 'scalar', path: 'credentialId' },
54
+ { kind: 'scalar', path: 'memoryCredentialId' },
55
+ { kind: 'array', path: 'extraCredentialIds' },
56
+ { kind: 'map', path: 'credentialIds' },
57
+ { kind: 'map', path: 'serviceConfig.social_media.credentialIds' },
58
+ ];
59
+ const mismoId = (v, id) => v !== null && v !== undefined && String(v) === id;
60
+ /** Lee un camino con puntos. Devuelve `undefined` si algo del medio falta. */
61
+ function leerCamino(doc, path) {
62
+ let actual = doc;
63
+ for (const parte of path.split('.')) {
64
+ if (actual === null || typeof actual !== 'object')
65
+ return undefined;
66
+ actual = actual[parte];
67
+ }
68
+ return actual;
69
+ }
70
+ /**
71
+ * ¿El valor de este campo referencia la credencial?
72
+ *
73
+ * Un mapa puede tener el id como string suelto o dentro de un array: las dos
74
+ * formas conviven en datos reales (`credentialIds` está declarado como
75
+ * `Record<string, string[] | string>` justamente por eso).
76
+ */
77
+ function valorReferencia(valor, id, kind) {
78
+ if (valor === null || valor === undefined)
79
+ return false;
80
+ if (kind === 'scalar')
81
+ return mismoId(valor, id);
82
+ if (kind === 'array')
83
+ return Array.isArray(valor) && valor.some((v) => mismoId(v, id));
84
+ // map
85
+ if (typeof valor !== 'object' || Array.isArray(valor))
86
+ return false;
87
+ return Object.values(valor).some((v) => Array.isArray(v) ? v.some((x) => mismoId(x, id)) : mismoId(v, id));
88
+ }
89
+ /** Las formas por las que ESTE documento referencia la credencial. */
90
+ function formasQueReferencian(doc, id) {
91
+ return exports.CREDENTIAL_REF_SHAPES.filter((forma) => valorReferencia(leerCamino(doc, forma.path), id, forma.kind));
92
+ }
93
+ /**
94
+ * El valor que hay que dejar en el campo al quitar la credencial.
95
+ *
96
+ * Devuelve `null` cuando el campo se queda sin nada, para que el documento no
97
+ * conserve un `{}` o un `[]` que se lee como «configurado, pero vacío».
98
+ *
99
+ * En el mapa, una plataforma que se queda sin cuentas **desaparece**: dejarla
100
+ * con `[]` haría que el trigger siguiera anunciando que vigila Mastodon sin
101
+ * ninguna cuenta con la que hacerlo.
102
+ */
103
+ function valorTrasDesatacar(valor, id, kind) {
104
+ if (kind === 'scalar')
105
+ return null;
106
+ if (kind === 'array') {
107
+ const resto = Array.isArray(valor)
108
+ ? valor.filter((v) => !mismoId(v, id))
109
+ : [];
110
+ return resto.length > 0 ? resto : null;
111
+ }
112
+ // map
113
+ if (valor === null || typeof valor !== 'object' || Array.isArray(valor))
114
+ return null;
115
+ const salida = {};
116
+ for (const [clave, v] of Object.entries(valor)) {
117
+ if (Array.isArray(v)) {
118
+ const resto = v.filter((x) => !mismoId(x, id));
119
+ if (resto.length > 0)
120
+ salida[clave] = resto;
121
+ }
122
+ else if (!mismoId(v, id)) {
123
+ salida[clave] = v;
124
+ }
125
+ }
126
+ return Object.keys(salida).length > 0 ? salida : null;
127
+ }
128
+ /* ── El marcador de «tu credencial se borró» ────────────────────── */
129
+ /**
130
+ * Los dos campos que `credentials.service.remove` escribe en cada nodo que se
131
+ * queda huérfano, para que la pantalla pueda decir QUÉ credencial era y
132
+ * cuándo desapareció (después de desatacar ya no queda id al que preguntar).
133
+ */
134
+ exports.CAMPOS_MARCADOR_DETACH = [
135
+ 'credentialDetachedAt',
136
+ 'credentialDetachedName',
137
+ ];
138
+ /** El `$unset` que los quita. */
139
+ exports.UNSET_MARCADOR_DETACH = {
140
+ credentialDetachedAt: '',
141
+ credentialDetachedName: '',
142
+ };
143
+ /** ¿Este valor trae al menos una credencial puesta? */
144
+ function valorTieneCredencial(valor, kind) {
145
+ if (valor === null || valor === undefined || valor === '')
146
+ return false;
147
+ if (kind === 'scalar')
148
+ return true;
149
+ if (kind === 'array')
150
+ return Array.isArray(valor) && valor.length > 0;
151
+ // map
152
+ if (typeof valor !== 'object' || Array.isArray(valor))
153
+ return false;
154
+ return Object.values(valor).some((v) => Array.isArray(v) ? v.length > 0 : v !== null && v !== undefined && v !== '');
155
+ }
156
+ /**
157
+ * ¿El nodo tiene AHORA alguna credencial, por cualquiera de las cinco formas?
158
+ *
159
+ * Es la pregunta que decide si el aviso «se borró tu credencial» sigue
160
+ * teniendo sentido. El dashboard hace este mismo cálculo para ocultarlo
161
+ * (`hooks/useNodeErrors.ts`); aquí sirve para BORRAR el marcador, que es lo
162
+ * que faltaba: ocultarlo lo dejaba vivo, y volvía a salir en cuanto el nodo
163
+ * se quedaba sin credencial otra vez — aunque fuera por un «Disconnect» a
164
+ * mano, y enseñando la fecha de un borrado de días atrás.
165
+ */
166
+ function tieneAlgunaCredencial(doc) {
167
+ if (!doc || typeof doc !== 'object')
168
+ return false;
169
+ return exports.CREDENTIAL_REF_SHAPES.some((forma) => valorTieneCredencial(leerCamino(doc, forma.path), forma.kind));
170
+ }
171
+ /**
172
+ * ¿Hay que quitarle el marcador a este documento?
173
+ *
174
+ * Sólo cuando el marcador está Y el nodo volvió a tener credencial. Si sigue
175
+ * huérfano el aviso es legítimo y se queda: alguien le borró la credencial de
176
+ * verdad y todavía no ha elegido otra.
177
+ */
178
+ function marcadorEsFosil(doc, tocoCredencial = false) {
179
+ if (!doc || typeof doc !== 'object')
180
+ return false;
181
+ const nombre = doc.credentialDetachedName;
182
+ if (typeof nombre !== 'string' || !nombre)
183
+ return false;
184
+ return tieneAlgunaCredencial(doc) || tocoCredencial;
185
+ }
186
+ /**
187
+ * ¿Este dto toca el campo de la credencial, sea para poner una o para
188
+ * quitarla?
189
+ *
190
+ * ⚠️ Ésta es la mitad que faltaba, y se vio en cuanto llegó a producción.
191
+ *
192
+ * «Es fósil si el nodo VOLVIÓ a tener credencial» arregla el caso de elegir
193
+ * otra, pero deja fuera el que lo destapó: Ariel pulsó «Disconnect» y el nodo
194
+ * se quedó SIN credencial, así que seguía contando como huérfano legítimo y
195
+ * el aviso del día 20 seguía en pantalla. Su queja fue literal: «y sigue».
196
+ *
197
+ * La distinción buena no es «tiene credencial» sino «¿sigue siendo verdad que
198
+ * te la quitaron sin que tú hicieras nada?». En cuanto la persona toca ese
199
+ * campo —ponga una o la vacíe— ya vio el aviso y actuó: a partir de ahí el
200
+ * estado lo eligió ella, y seguir diciéndole que se lo borraron el día 20 es
201
+ * hablarle de otra cosa.
202
+ *
203
+ * `!== undefined` y no un truthy: `credentialId: null` es exactamente el caso
204
+ * de desconectar, y es el que hay que reconocer.
205
+ */
206
+ function dtoTocaCredencial(dto) {
207
+ if (!dto || typeof dto !== 'object')
208
+ return false;
209
+ return exports.CREDENTIAL_REF_SHAPES.some((forma) => leerCamino(dto, forma.path) !== undefined);
210
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Filters out undefined values from an object.
3
+ * Useful for DTO → Mongoose update where undefined own properties
4
+ * would overwrite existing values.
5
+ */
6
+ export declare function definedProps<T extends object>(obj: T): Partial<T>;
7
+ /**
8
+ * Throws ForbiddenException if the current count has reached the plan limit.
9
+ * A limit of -1 means unlimited.
10
+ */
11
+ export declare function checkPlanLimit(count: number, limit: number, resource: string): void;
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.definedProps = definedProps;
4
+ exports.checkPlanLimit = checkPlanLimit;
5
+ const common_1 = require("@nestjs/common");
6
+ /**
7
+ * Filters out undefined values from an object.
8
+ * Useful for DTO → Mongoose update where undefined own properties
9
+ * would overwrite existing values.
10
+ */
11
+ function definedProps(obj) {
12
+ return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
13
+ }
14
+ /**
15
+ * Throws ForbiddenException if the current count has reached the plan limit.
16
+ * A limit of -1 means unlimited.
17
+ */
18
+ function checkPlanLimit(count, limit, resource) {
19
+ if (limit !== -1 && count >= limit) {
20
+ throw new common_1.ForbiddenException(`${resource} limit reached (${count}/${limit}). Upgrade your plan.`);
21
+ }
22
+ }
package/dist/index.d.ts CHANGED
@@ -50,3 +50,12 @@ export * from "./swallow";
50
50
  export * from "./template-render";
51
51
  export * from "./try-parse";
52
52
  export * from "./workspace-payloads";
53
+ export * from "./base-node.service";
54
+ export * from "./cascade-cleanup";
55
+ export * from "./dto-utils";
56
+ export * from "./sync-connections";
57
+ export * from "./unique-name";
58
+ export * from "./credential-references";
59
+ export * from "./limpiar-marcador-detach";
60
+ export * from "./olvidar-la-colocacion";
61
+ export * from "./acceso-al-workspace";
package/dist/index.js CHANGED
@@ -72,3 +72,12 @@ __exportStar(require("./swallow"), exports);
72
72
  __exportStar(require("./template-render"), exports);
73
73
  __exportStar(require("./try-parse"), exports);
74
74
  __exportStar(require("./workspace-payloads"), exports);
75
+ __exportStar(require("./base-node.service"), exports);
76
+ __exportStar(require("./cascade-cleanup"), exports);
77
+ __exportStar(require("./dto-utils"), exports);
78
+ __exportStar(require("./sync-connections"), exports);
79
+ __exportStar(require("./unique-name"), exports);
80
+ __exportStar(require("./credential-references"), exports);
81
+ __exportStar(require("./limpiar-marcador-detach"), exports);
82
+ __exportStar(require("./olvidar-la-colocacion"), exports);
83
+ __exportStar(require("./acceso-al-workspace"), exports);
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Quita el aviso «se borró tu credencial» cuando el nodo ya tiene otra.
3
+ *
4
+ * ## El defecto que arregla
5
+ *
6
+ * `credentials.service.remove` escribe dos marcadores en cada nodo que deja
7
+ * huérfano —`credentialDetachedName` y `credentialDetachedAt`— para que la
8
+ * pantalla pueda decir qué credencial era y cuándo desapareció. Hasta ahora
9
+ * eran un `$set` sin pareja: **ningún sitio de la api los borraba**.
10
+ *
11
+ * El dashboard sólo los OCULTA mientras el nodo tenga alguna credencial
12
+ * (`hooks/useNodeErrors.ts`). Ocultar no es borrar: el marcador seguía vivo
13
+ * debajo y volvía a salir en cuanto el nodo se quedaba sin credencial otra
14
+ * vez — incluso pulsando «Disconnect», que sólo hace `onChange(null, null)`
15
+ * y no borra ninguna credencial.
16
+ *
17
+ * Lo reportó Ariel el 2026-08-23: desconectó una credencial de Drive y la
18
+ * pantalla le dijo *«The credential "Google - …" was deleted on Aug 20, 2026»*.
19
+ * La fecha es la prueba de que el marcador era viejo: si lo hubiera escrito su
20
+ * clic, diría el 23. Un fósil de otro día conviviendo con el estado de hoy, y
21
+ * ganando él.
22
+ *
23
+ * ## Por qué esta condición y no otra
24
+ *
25
+ * Se miran las DOS cosas, y hicieron falta las dos:
26
+ *
27
+ * - el DOCUMENTO tiene credencial → el aviso ya no describe nada; cubre
28
+ * también al nodo que sólo cambió de nombre y arrastra el fósil;
29
+ * - el DTO tocaba el campo de la credencial → la persona acaba de actuar
30
+ * sobre él, ponga una o la vacíe.
31
+ *
32
+ * ⚠️ La segunda llegó tarde. Con sólo la primera, pulsar «Disconnect» dejaba
33
+ * el nodo sin credencial, o sea contando como huérfano legítimo, y el aviso
34
+ * del día 20 seguía en pantalla — que es exactamente el caso que abrió todo
35
+ * esto. Ariel lo dijo en dos palabras al probarlo: «y sigue».
36
+ *
37
+ * Y sólo se limpia si el nodo VOLVIÓ a tener credencial: si sigue huérfano el
38
+ * aviso es legítimo —alguien le borró la credencial de verdad y todavía no ha
39
+ * elegido otra— y tiene que seguir viéndose.
40
+ *
41
+ * ## Coste
42
+ *
43
+ * Cero en el caso normal. `marcadorEsFosil` mira dos campos en memoria y sale;
44
+ * sólo hay escritura cuando de verdad hay un fósil que quitar, que es raro.
45
+ *
46
+ * El fallo no aborta el PATCH: el usuario venía a guardar su nodo, y que no se
47
+ * pueda limpiar un aviso cosmético no es motivo para devolverle un 500. Pero
48
+ * se registra con nombre — ver `common/swallow`.
49
+ */
50
+ export declare function limpiarMarcadorDetachSiEsFosil(model: {
51
+ updateOne(filtro: unknown, update: unknown): {
52
+ exec?: () => Promise<unknown>;
53
+ } | Promise<unknown>;
54
+ }, doc: unknown, opts?: {
55
+ /**
56
+ * ¿El PATCH que trajo hasta aquí tocaba el campo de la credencial?
57
+ *
58
+ * Cuando sí, el aviso se retira aunque el nodo se quede SIN credencial:
59
+ * desconectar a mano es un acto de la persona, y el marcador sólo tiene
60
+ * sentido mientras describa algo que le pasó SIN que ella hiciera nada.
61
+ */
62
+ tocoCredencial?: boolean;
63
+ }): Promise<void>;
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.limpiarMarcadorDetachSiEsFosil = limpiarMarcadorDetachSiEsFosil;
4
+ const common_1 = require("@nestjs/common");
5
+ const swallow_1 = require("./swallow");
6
+ const credential_references_1 = require("./credential-references");
7
+ const LOGGER = new common_1.Logger('LimpiarMarcadorDetach');
8
+ /**
9
+ * Quita el aviso «se borró tu credencial» cuando el nodo ya tiene otra.
10
+ *
11
+ * ## El defecto que arregla
12
+ *
13
+ * `credentials.service.remove` escribe dos marcadores en cada nodo que deja
14
+ * huérfano —`credentialDetachedName` y `credentialDetachedAt`— para que la
15
+ * pantalla pueda decir qué credencial era y cuándo desapareció. Hasta ahora
16
+ * eran un `$set` sin pareja: **ningún sitio de la api los borraba**.
17
+ *
18
+ * El dashboard sólo los OCULTA mientras el nodo tenga alguna credencial
19
+ * (`hooks/useNodeErrors.ts`). Ocultar no es borrar: el marcador seguía vivo
20
+ * debajo y volvía a salir en cuanto el nodo se quedaba sin credencial otra
21
+ * vez — incluso pulsando «Disconnect», que sólo hace `onChange(null, null)`
22
+ * y no borra ninguna credencial.
23
+ *
24
+ * Lo reportó Ariel el 2026-08-23: desconectó una credencial de Drive y la
25
+ * pantalla le dijo *«The credential "Google - …" was deleted on Aug 20, 2026»*.
26
+ * La fecha es la prueba de que el marcador era viejo: si lo hubiera escrito su
27
+ * clic, diría el 23. Un fósil de otro día conviviendo con el estado de hoy, y
28
+ * ganando él.
29
+ *
30
+ * ## Por qué esta condición y no otra
31
+ *
32
+ * Se miran las DOS cosas, y hicieron falta las dos:
33
+ *
34
+ * - el DOCUMENTO tiene credencial → el aviso ya no describe nada; cubre
35
+ * también al nodo que sólo cambió de nombre y arrastra el fósil;
36
+ * - el DTO tocaba el campo de la credencial → la persona acaba de actuar
37
+ * sobre él, ponga una o la vacíe.
38
+ *
39
+ * ⚠️ La segunda llegó tarde. Con sólo la primera, pulsar «Disconnect» dejaba
40
+ * el nodo sin credencial, o sea contando como huérfano legítimo, y el aviso
41
+ * del día 20 seguía en pantalla — que es exactamente el caso que abrió todo
42
+ * esto. Ariel lo dijo en dos palabras al probarlo: «y sigue».
43
+ *
44
+ * Y sólo se limpia si el nodo VOLVIÓ a tener credencial: si sigue huérfano el
45
+ * aviso es legítimo —alguien le borró la credencial de verdad y todavía no ha
46
+ * elegido otra— y tiene que seguir viéndose.
47
+ *
48
+ * ## Coste
49
+ *
50
+ * Cero en el caso normal. `marcadorEsFosil` mira dos campos en memoria y sale;
51
+ * sólo hay escritura cuando de verdad hay un fósil que quitar, que es raro.
52
+ *
53
+ * El fallo no aborta el PATCH: el usuario venía a guardar su nodo, y que no se
54
+ * pueda limpiar un aviso cosmético no es motivo para devolverle un 500. Pero
55
+ * se registra con nombre — ver `common/swallow`.
56
+ */
57
+ async function limpiarMarcadorDetachSiEsFosil(
58
+ /*
59
+ * ⚠️ `updateOne` va como MÉTODO abreviado y no como propiedad con tipo
60
+ * función, y la diferencia no es de estilo.
61
+ *
62
+ * Con `strictFunctionTypes`, una propiedad cuyo tipo es una función se
63
+ * comprueba de forma CONTRAVARIANTE en los parámetros: un `updateOne` real
64
+ * de Mongoose, que declara `filter: _QueryFilter<T>`, no encaja en uno que
65
+ * declara `filtro: unknown`. Un método abreviado se comprueba de forma
66
+ * bivariante y sí encaja.
67
+ *
68
+ * Salió al mudar esto al paquete: en la api compilaba con mongoose 9.2.1 y
69
+ * aquí falla con 9.9.4, que estrechó `_QueryFilter`. Como el `peerDependency`
70
+ * dice `mongoose >=8`, la firma tiene que aguantar las dos — y la bivarianza
71
+ * es segura aquí porque esta función sólo LLAMA al método, con valores que
72
+ * construye ella misma.
73
+ */
74
+ model, doc, opts) {
75
+ if (!(0, credential_references_1.marcadorEsFosil)(doc, opts?.tocoCredencial))
76
+ return;
77
+ const id = doc._id;
78
+ if (!id)
79
+ return;
80
+ const resultado = model.updateOne({ _id: id }, { $unset: credential_references_1.UNSET_MARCADOR_DETACH });
81
+ const promesa = typeof resultado.exec === 'function'
82
+ ? resultado.exec()
83
+ : resultado;
84
+ await Promise.resolve(promesa).catch((0, swallow_1.onFailure)(LOGGER, `limpiar el marcador de credencial borrada del nodo ${String(id)}`));
85
+ /* También en el documento que se va a devolver.
86
+ Sin esto el PATCH responde con el marcador todavía puesto, la pantalla lo
87
+ pinta una última vez y sólo desaparece al recargar — que es exactamente la
88
+ clase de «se arregló pero no se ve» que hace dudar de si se arregló. */
89
+ const d = doc;
90
+ delete d.credentialDetachedAt;
91
+ delete d.credentialDetachedName;
92
+ }
@@ -0,0 +1,23 @@
1
+ import type { Connection } from 'mongoose';
2
+ /**
3
+ * Borrar un nodo le quita también su sitio en el lienzo.
4
+ *
5
+ * Sin esto, la clave se queda para siempre. Es literalmente de donde salió el
6
+ * mapa viejo: `users.flowLayout` tenía **1 003 de 1 284 nodos referidos que ya
7
+ * no existían** —el 78 %— porque no había ningún camino que quitara la entrada
8
+ * al borrar el nodo. Sólo crecía.
9
+ *
10
+ * Y ya estaba volviendo a pasar: dos horas después de migrar, `flow_layouts`
11
+ * tenía 55 posiciones para 53 nodos. Las dos de más eran un Chat Trigger y el
12
+ * nodo AI que nace con él, creados y borrados por el camino.
13
+ *
14
+ * Va por la conexión y no por el servicio a propósito: quien borra un nodo es
15
+ * `BaseNodeService`, del que heredan los 45 tipos, y meterle una dependencia
16
+ * nueva al constructor obliga a tocar los 45 módulos. `cascadeCleanupNodeReferences`
17
+ * —que limpia las conexiones que apuntaban al nodo— resuelve lo mismo por el
18
+ * mismo sitio.
19
+ *
20
+ * No lanza. Que un nodo no se pueda borrar porque su posición se resistió sería
21
+ * peor que la posición huérfana que esto viene a evitar.
22
+ */
23
+ export declare function olvidarLaColocacion(connection: Connection, nodeType: string, nodeId: string, workspaceId: string | undefined): Promise<number>;
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.olvidarLaColocacion = olvidarLaColocacion;
4
+ const common_1 = require("@nestjs/common");
5
+ const mongoose_1 = require("mongoose");
6
+ const node_types_1 = require("@hostwebhook/node-types");
7
+ const logger = new common_1.Logger('FlowLayouts');
8
+ /**
9
+ * Borrar un nodo le quita también su sitio en el lienzo.
10
+ *
11
+ * Sin esto, la clave se queda para siempre. Es literalmente de donde salió el
12
+ * mapa viejo: `users.flowLayout` tenía **1 003 de 1 284 nodos referidos que ya
13
+ * no existían** —el 78 %— porque no había ningún camino que quitara la entrada
14
+ * al borrar el nodo. Sólo crecía.
15
+ *
16
+ * Y ya estaba volviendo a pasar: dos horas después de migrar, `flow_layouts`
17
+ * tenía 55 posiciones para 53 nodos. Las dos de más eran un Chat Trigger y el
18
+ * nodo AI que nace con él, creados y borrados por el camino.
19
+ *
20
+ * Va por la conexión y no por el servicio a propósito: quien borra un nodo es
21
+ * `BaseNodeService`, del que heredan los 45 tipos, y meterle una dependencia
22
+ * nueva al constructor obliga a tocar los 45 módulos. `cascadeCleanupNodeReferences`
23
+ * —que limpia las conexiones que apuntaban al nodo— resuelve lo mismo por el
24
+ * mismo sitio.
25
+ *
26
+ * No lanza. Que un nodo no se pueda borrar porque su posición se resistió sería
27
+ * peor que la posición huérfana que esto viene a evitar.
28
+ */
29
+ async function olvidarLaColocacion(connection, nodeType, nodeId, workspaceId) {
30
+ if (!workspaceId || !mongoose_1.Types.ObjectId.isValid(workspaceId)) {
31
+ /* El nodo se borró igual; sólo queda su posición. Decirlo, porque el rastro
32
+ es lo único que distingue «no había workspace» de «no se limpió». */
33
+ logger.warn(`olvidarLaColocacion: ${nodeType}/${nodeId} sin workspaceId; su posición se queda`);
34
+ return 0;
35
+ }
36
+ const prefijo = node_types_1.NODE_REGISTRY[nodeType]?.prefix;
37
+ if (!prefijo) {
38
+ logger.warn(`olvidarLaColocacion: '${nodeType}' no está en el registro; no sé cómo se llama en el lienzo`);
39
+ return 0;
40
+ }
41
+ /* La clave del lienzo, que es `<prefijo>-<ObjectId>`. Se comprueba en vez de
42
+ interpolar a ciegas: va dentro de una ruta de `$unset`, donde un punto baja
43
+ un nivel y un `$` es un operador. */
44
+ if (!/^[0-9a-fA-F]{24}$/.test(nodeId)) {
45
+ logger.warn(`olvidarLaColocacion: '${nodeId}' no es un id de nodo`);
46
+ return 0;
47
+ }
48
+ try {
49
+ /* `updateMany` y sin `userId`: la colocación es de cada persona, y quien
50
+ borra el nodo no sabe —ni tiene por qué— quién lo había puesto dónde. */
51
+ const r = await connection
52
+ .collection('flow_layouts')
53
+ .updateMany({ workspaceId: new mongoose_1.Types.ObjectId(workspaceId) }, { $unset: { [`positions.${prefijo}-${nodeId}`]: '' } });
54
+ return r.modifiedCount ?? 0;
55
+ }
56
+ catch (err) {
57
+ logger.error(`olvidarLaColocacion: no pude quitar ${prefijo}-${nodeId} del workspace ${workspaceId}:`, err);
58
+ return 0;
59
+ }
60
+ }
@@ -0,0 +1,73 @@
1
+ import { Connection, Types } from 'mongoose';
2
+ /**
3
+ * Reject a node wired to itself.
4
+ *
5
+ * A canvas drag from a node's own output dot to its own input dot produced a
6
+ * real, persisted edge: source and target were the same id. Nothing stopped it
7
+ * — the dashboard assumed the registry prevented invalid connections, and the
8
+ * registry answers a different question. `canReceiveFrom('transform',
9
+ * 'transform')` is TRUE, and correctly so: chaining two different Transform
10
+ * nodes is ordinary. What the type pair cannot express is "not this same
11
+ * instance", because ids are not types.
12
+ *
13
+ * The one legitimate self-edge is the Loop node's loop-back, and the registry
14
+ * already declares it as `special.hasLoopBack`. So the exception is read from
15
+ * there rather than hardcoded here — a future node that needs to re-enter
16
+ * itself declares the flag and both the canvas and this check follow.
17
+ */
18
+ export declare function assertNoSelfConnection(nodeType: string, nodeId: Types.ObjectId | string, nodes: Array<{
19
+ nodeType: string;
20
+ nodeId: any;
21
+ }>): void;
22
+ /**
23
+ * Rechaza una conexión entre tipos que no pueden conectarse.
24
+ *
25
+ * Hasta ahora la compatibilidad de tipos era cosa del lienzo, y estaba escrito
26
+ * aquí al lado: *"Type compatibility is the registry's job and is checked on
27
+ * the canvas"*. Eso valía mientras el lienzo fuera el único que escribe, y no
28
+ * lo es: `/agent-context` le explica a un agente, con ejemplos, que meta
29
+ * `{ nodeType, nodeId }` a mano en `inputNodes`/`outputNodes`, y cualquier
30
+ * cliente con la clave y el scope puede hacer lo mismo.
31
+ *
32
+ * Lo que se colaba por ahí no revienta: **desaparece**. `dispatchOutputNodes`
33
+ * cae en su rama por defecto, `getNodeHandler('trigger')` no devuelve nada y
34
+ * suelta un `logger.warn` —sin evento de telemetría, porque el `telemetry.emit`
35
+ * está en el `catch` y esto no lanza—. O sea una arista dibujada en el lienzo
36
+ * que no dispara nunca y que no aparece en ningún sitio donde mirarla.
37
+ *
38
+ * ⚠️ **La regla es `fromNodes`/`toNodes` de `NODE_UI`, y no las listas de
39
+ * `NODE_CONNECTIONS`.** La tentación es usar `acceptsInputFrom`/`canOutputTo`,
40
+ * que están ahí mismo, las declaran los 43 tipos y parecen más precisas. Están
41
+ * podridas: medidas contra producción el 2026-08-19 rechazaban **111 de 364**
42
+ * conexiones que funcionan hoy — `trigger → ai`, `ai → telegramAction`,
43
+ * `sheetsAction → filter`. `fromNodes`/`toNodes` rechazaba **0**, porque es el
44
+ * mismo dato que decide qué puntos pinta la tarjeta. Endurecer con eso no
45
+ * puede romper nada que el usuario haya podido crear desde el lienzo, que es
46
+ * justo la propiedad que se busca: la api no debe ser más estricta que la
47
+ * pantalla, sólo igual de estricta en los caminos que la pantalla no cubre.
48
+ *
49
+ * De un tipo que el registro no conoce no se opina. Un alias viejo se
50
+ * normaliza primero (`voiceCall` → `voiceAgent`); lo que ni así aparezca se
51
+ * deja pasar, porque un 400 sobre datos heredados con autoguardado deja la
52
+ * página sin poder guardar nunca más.
53
+ */
54
+ export declare function assertConnectableTypes(ownType: string, refs: {
55
+ inputNodes?: Array<{
56
+ nodeType: string;
57
+ }>;
58
+ outputNodes?: Array<{
59
+ nodeType: string;
60
+ }>;
61
+ }): void;
62
+ /**
63
+ * Validate that all target nodes belong to the same workspace as the source node.
64
+ * Throws BadRequestException if any cross-workspace connection is detected.
65
+ *
66
+ * This is the ONLY backend connection logic. The frontend owns connection
67
+ * creation/deletion — it updates both sides (source.outputNodes + target.inputNodes).
68
+ * The backend just saves what it receives and validates workspace boundaries.
69
+ */
70
+ export declare function validateConnectionWorkspaces(connection: Connection, sourceWorkspaceId: Types.ObjectId | string | null | undefined, nodes: Array<{
71
+ nodeType: string;
72
+ nodeId: any;
73
+ }>): Promise<void>;