@runnerpro/backend 1.31.2 → 1.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,6 +16,19 @@ Connection with the PostgresSQL database
16
16
  - <b>Param</b>: array of values
17
17
  - <b>Return</b>: promise of array of values (each item is a row of the table)
18
18
 
19
+ > ⚠️ **T-219 — un fallo de CONEXIÓN puede rechazar, y se enciende por servicio.** `query()` no
20
+ > lanzaba nunca: tanto «la BD no respondió» como «no hay filas» devolvían `[]`. Desde la **1.32.0**,
21
+ > con `RP_DB_RECHAZA_FALLO_CONEXION=1` en el entorno, un fallo de conexión —agotado el reintento (1,
22
+ > a los 500 ms)— **rechaza la promesa**. Un error de SQL (sintaxis, constraint, columna inexistente)
23
+ > se sigue logueando y devolviendo `[]` en los dos modos, igual que siempre.
24
+ >
25
+ > **Sin esa variable no cambia nada**, y por eso puede salir en una menor: a un `^1.x` le entra en
26
+ > cuanto alguien refresque su lock (`npm install`/`npm update`, sin tocar el rango) y ahí se comporta
27
+ > como la 1.31.x. Con `npm ci` el lock manda, así que hasta ese refresco no llega. **Antes de encenderla en un
28
+ > servicio, lee [docs/T-219-auditoria-consumidores.md](./docs/T-219-auditoria-consumidores.md)**: ahí
29
+ > está, repo por repo y con fichero y línea, dónde aterriza ese rechazo hoy y qué hay que cerrar
30
+ > antes. Encenderla sin cerrar esos puntos deja peticiones sin respuesta.
31
+
19
32
  ### batchQuery
20
33
 
21
34
  Connection with PostgreSQL database in batches. Use for faster query execution when we need to execute many queries.
@@ -27,6 +27,29 @@ for (const meters of Object.keys(catalog_1.PR_DISTANCE_BADGE)) {
27
27
  BADGE_PR_DISTANCE[catalog_1.PR_DISTANCE_BADGE[Number(meters)]] = Number(meters);
28
28
  }
29
29
  const typesList = (types) => types.map((t) => `'${t}'`).join(',');
30
+ /**
31
+ * Traza el fallo de un grupo de logros sin tumbar el resto.
32
+ *
33
+ * Los grupos van aislados a propósito (un fallo no debe impedir evaluar los demás), pero hasta
34
+ * T-219 lo hacían con un `catch (_) {}` mudo: cuando el pool no conectaba, los logros de ese
35
+ * cliente simplemente no se evaluaban y no quedaba ni rastro. Ahora sigue sin tumbar el resto,
36
+ * pero se sabe a quién le pasó y en qué grupo.
37
+ */
38
+ const avisoLogros = (etiqueta, userId, error) => {
39
+ // eslint-disable-next-line no-console
40
+ console.error(`achievements: fallo evaluando ${etiqueta} de idCliente=${userId} (${(error === null || error === void 0 ? void 0 : error.message) || error}). Ese grupo se queda sin evaluar.`);
41
+ };
42
+ /** Igual que `avisoLogros` pero para lo que NO va aislado: deja la traza y vuelve a lanzar. */
43
+ const conTraza = (etiqueta, userId, fn) => __awaiter(void 0, void 0, void 0, function* () {
44
+ try {
45
+ return yield fn();
46
+ }
47
+ catch (error) {
48
+ // eslint-disable-next-line no-console
49
+ console.error(`achievements: fallo leyendo/escribiendo ${etiqueta} de idCliente=${userId} (${(error === null || error === void 0 ? void 0 : error.message) || error}).`);
50
+ throw error;
51
+ }
52
+ });
30
53
  const countScalar = (sql, params) => __awaiter(void 0, void 0, void 0, function* () {
31
54
  const [row] = yield (0, db_1.query)(sql, params);
32
55
  return Number(row === null || row === void 0 ? void 0 : row.c) || 0;
@@ -49,7 +72,9 @@ const getAllAggregates = (userId, sinceDate) => __awaiter(void 0, void 0, void 0
49
72
  params.push(sinceDate);
50
73
  }
51
74
  const runList = typesList(catalog_1.RUNNING_TYPES);
52
- const [row] = yield (0, db_1.query)(`SELECT
75
+ // ⚠️ T-219: 9 de los 176 client_login_timeout de la semana medida caen aquí. No va aislado a
76
+ // propósito: sin agregados no hay nada que evaluar, así que se traza y se propaga.
77
+ const [row] = yield conTraza('los agregados de workouts', userId, () => (0, db_1.query)(`SELECT
53
78
  COALESCE(MAX("DISTANCE") FILTER (WHERE "TYPE" IN (${runList})), 0) AS "RUN MAX",
54
79
  COALESCE(SUM("DISTANCE") FILTER (WHERE "TYPE" IN (${runList})), 0) AS "RUN TOTAL",
55
80
  COUNT(*) FILTER (WHERE "TYPE" = 'FUERZA') AS "STR COUNT",
@@ -62,7 +87,7 @@ const getAllAggregates = (userId, sinceDate) => __awaiter(void 0, void 0, void 0
62
87
  COUNT(*) FILTER (WHERE "TYPE" = 'COMPETICION') AS "RACE COUNT",
63
88
  COUNT(*) FILTER (WHERE "FC MEDIA" IS NOT NULL) AS "HR COUNT"
64
89
  FROM "WORKOUT"
65
- WHERE "ID CLIENTE" = ? AND "DONE" = true${sinceFilter}`, params);
90
+ WHERE "ID CLIENTE" = ? AND "DONE" = true${sinceFilter}`, params));
66
91
  return {
67
92
  runMax: Number(row === null || row === void 0 ? void 0 : row.runMax) || 0,
68
93
  runTotal: Number(row === null || row === void 0 ? void 0 : row.runTotal) || 0,
@@ -311,10 +336,12 @@ const persistLogros = (userId, ids, markSeen = false) => __awaiter(void 0, void
311
336
  const params = [];
312
337
  for (const id of uniq)
313
338
  params.push(userId, id);
314
- const rows = yield (0, db_1.query)(`INSERT INTO "CLIENTE LOGRO" ("ID CLIENTE", "LOGRO ID", "FECHA VISTO")
339
+ // ⚠️ T-219: es el alta de los logros. Si esto se pierde en silencio, el cliente no desbloquea
340
+ // nada y no queda rastro; se traza con el idCliente y se propaga.
341
+ const rows = yield conTraza(`el alta de ${uniq.length} logro(s)`, userId, () => (0, db_1.query)(`INSERT INTO "CLIENTE LOGRO" ("ID CLIENTE", "LOGRO ID", "FECHA VISTO")
315
342
  VALUES ${valuesSql}
316
343
  ON CONFLICT ("ID CLIENTE", "LOGRO ID") DO NOTHING
317
- RETURNING "LOGRO ID" AS "ID", "FECHA DESBLOQUEO"`, params);
344
+ RETURNING "LOGRO ID" AS "ID", "FECHA DESBLOQUEO"`, params));
318
345
  return rows.map((r) => ({ id: r.id, fechaDesbloqueo: r.fechaDesbloqueo }));
319
346
  });
320
347
  // ── Reglas por categoría ────────────────────────────────────────────────────
@@ -366,7 +393,9 @@ const evalCount = (rules, count) => rules.filter((r) => count >= r.count).map((r
366
393
  * los 4 bloques (base/desarrollo/pico/taper). Devuelve null si no hay plan.
367
394
  */
368
395
  const getPlanContext = (userId) => __awaiter(void 0, void 0, void 0, function* () {
369
- const [row] = yield (0, db_1.query)(`SELECT
396
+ // ⚠️ T-219: va dentro de un grupo aislado (`evaluatePlan`), así que el error lo recoge
397
+ // `avisoLogros` arriba; aquí se deja la traza con el idCliente para saber a quién le pasó.
398
+ const [row] = yield conTraza('el contexto del plan', userId, () => (0, db_1.query)(`SELECT
370
399
  "ID PLAN CARRERA" AS "PLAN ID",
371
400
  "START DATE PLAN"::date AS "PLAN START",
372
401
  "GOAL DATE"::date AS "PLAN GOAL",
@@ -380,7 +409,7 @@ const getPlanContext = (userId) => __awaiter(void 0, void 0, void 0, function* (
380
409
  "ALGORITMO BLOQUE 3 FIN"::date AS "PICO FIN",
381
410
  "ALGORITMO BLOQUE 4 INICIO"::date AS "TAPER INI",
382
411
  "ALGORITMO BLOQUE 4 FIN"::date AS "TAPER FIN"
383
- FROM "CLIENTE" WHERE "ID" = ?`, [userId]);
412
+ FROM "CLIENTE" WHERE "ID" = ?`, [userId]));
384
413
  return row || null;
385
414
  });
386
415
  /**
@@ -682,8 +711,8 @@ const evaluateAchievements = (userId, event = 'all', opts = {}) => __awaiter(voi
682
711
  else
683
712
  nuevos.push(...(yield recomputeAllPRs(userId, anchor, markSeen)));
684
713
  }
685
- catch (_) {
686
- // ignore
714
+ catch (error) {
715
+ avisoLogros('los récords personales (PR)', userId, error);
687
716
  }
688
717
  const [streak, agg] = yield Promise.all([
689
718
  (0, streak_1.computeStreakStats)(userId, anchor),
@@ -697,8 +726,8 @@ const evaluateAchievements = (userId, event = 'all', opts = {}) => __awaiter(voi
697
726
  try {
698
727
  yield (0, db_1.query)('UPDATE "CLIENTE" SET "RACHA MAXIMA" = GREATEST(COALESCE("RACHA MAXIMA", 0), ?) WHERE "ID" = ?', [streak.longest, userId]);
699
728
  }
700
- catch (_) {
701
- // ignore
729
+ catch (error) {
730
+ avisoLogros('la racha máxima', userId, error);
702
731
  }
703
732
  eligible.push(...evaluateConsistency(streak.longest), ...evaluateDistance(agg.runMax), ...evaluateVolume(agg.runTotal), ...evaluateStrength(agg.strCount), ...evaluateCycling(agg), ...evaluateSwim(agg), ...evaluateTriSwim(agg), ...evaluateTrail(agg));
704
733
  const raceDates = agg.raceCount >= 3 ? yield getRaceDates(userId, anchor) : [];
@@ -711,31 +740,41 @@ const evaluateAchievements = (userId, event = 'all', opts = {}) => __awaiter(voi
711
740
  try {
712
741
  eligible.push(...(yield evaluatePlan(userId, anchor)));
713
742
  }
714
- catch (_) { /* ignore */ }
743
+ catch (error) {
744
+ avisoLogros('el plan y sus hitos', userId, error);
745
+ }
715
746
  try {
716
747
  const strengthDates = yield getStrengthDates(userId, anchor);
717
748
  eligible.push(...evaluateStrengthFrequency(strengthDates));
718
749
  }
719
- catch (_) { /* ignore */ }
750
+ catch (error) {
751
+ avisoLogros('la frecuencia de fuerza', userId, error);
752
+ }
720
753
  try {
721
754
  eligible.push(...(yield evaluateBrick(userId, anchor)));
722
755
  }
723
- catch (_) { /* ignore */ }
756
+ catch (error) {
757
+ avisoLogros('los brick', userId, error);
758
+ }
724
759
  try {
725
760
  eligible.push(...(yield evaluateRecovery(userId, anchor)));
726
761
  }
727
- catch (_) { /* ignore */ }
762
+ catch (error) {
763
+ avisoLogros('la recuperación', userId, error);
764
+ }
728
765
  try {
729
766
  eligible.push(...(yield evaluateCoachCall(userId)));
730
767
  }
731
- catch (_) { /* ignore */ }
768
+ catch (error) {
769
+ avisoLogros('la llamada con el entrenador', userId, error);
770
+ }
732
771
  }
733
772
  if (want('chat') || want('survey') || want('availability') || want('nutrition') || want('food_photo') || want('wearable')) {
734
773
  try {
735
774
  eligible.push(...(yield evaluateNonWorkout(userId, event)));
736
775
  }
737
- catch (_) {
738
- // ignore
776
+ catch (error) {
777
+ avisoLogros(`los logros de '${event}'`, userId, error);
739
778
  }
740
779
  }
741
780
  // 'goal' (añadir objetivo) re-evalúa los logros de Plan (incl. pc_nuevo_capitulo).
@@ -744,7 +783,9 @@ const evaluateAchievements = (userId, event = 'all', opts = {}) => __awaiter(voi
744
783
  try {
745
784
  eligible.push(...(yield evaluatePlan(userId, anchor)));
746
785
  }
747
- catch (_) { /* ignore */ }
786
+ catch (error) {
787
+ avisoLogros('el plan y sus hitos', userId, error);
788
+ }
748
789
  }
749
790
  nuevos.push(...(yield persistLogros(userId, eligible, markSeen)));
750
791
  // pace_*/pr_* se derivan del mapa de PRs ya persistido en los PARAMS de dist_*.
@@ -753,8 +794,8 @@ const evaluateAchievements = (userId, event = 'all', opts = {}) => __awaiter(voi
753
794
  const prMap = yield getPrMap(userId);
754
795
  nuevos.push(...(yield persistLogros(userId, [...evaluatePace(prMap), ...evaluateTimePR(prMap)])));
755
796
  }
756
- catch (_) {
757
- // ignore
797
+ catch (error) {
798
+ avisoLogros('los logros de ritmo y marca', userId, error);
758
799
  }
759
800
  }
760
801
  // Dedup por id, preservando el PRIMERO de cada id. applyPRs se acumula antes que
@@ -34,31 +34,167 @@ pool.on('error', (err) => {
34
34
  console.error('PG pool error', err);
35
35
  // El pool se encarga automáticamente del manejo
36
36
  });
37
- // FUNCIÓN PRINCIPAL - queryDirect (recomendada para 90% de casos)
37
+ // Reintento de conexión: mismos números que `queryWithClient` (1 reintento, 500 ms).
38
+ const REINTENTOS_CONEXION = 1;
39
+ const ESPERA_REINTENTO_MS = 500;
40
+ // Códigos y mensajes que NO son un error de SQL sino del pool o del socket. La clase 08 de
41
+ // PostgreSQL es "Connection Exception"; 57P01 es "admin_shutdown"; el resto son errno de Node.
42
+ const CODIGOS_CONEXION = [
43
+ 'EPIPE',
44
+ 'ECONNRESET',
45
+ 'ECONNREFUSED',
46
+ 'ETIMEDOUT',
47
+ 'ENOTFOUND',
48
+ 'EHOSTUNREACH',
49
+ '57P01',
50
+ '08000',
51
+ '08001',
52
+ '08003',
53
+ '08004',
54
+ '08006',
55
+ '08P01',
56
+ ];
57
+ // pg y pg-pool no ponen `code` en sus propios timeouts, así que hay que mirar el mensaje.
58
+ // Los cuatro primeros son, literalmente, los que salen en prod-third-party-backend (T-219).
59
+ const MENSAJES_CONEXION = [
60
+ 'client_login_timeout',
61
+ 'timeout exceeded when trying to connect',
62
+ 'connection terminated due to connection timeout',
63
+ 'query read timeout',
64
+ 'connection terminated unexpectedly',
65
+ 'connection ended unexpectedly',
66
+ 'client has encountered a connection error',
67
+ ];
68
+ /**
69
+ * ¿Este error es "no he podido hablar con la BD" y no "tu SQL está mal"?
70
+ *
71
+ * Sirve para decidir dos cosas: si merece la pena reintentar y si hay que devolver la conexión
72
+ * al pool como rota. Un error de sintaxis o una constraint violada NO entran aquí: reintentarlos
73
+ * es tirar el tiempo y el resultado sería el mismo.
74
+ */
75
+ const esErrorDeConexion = (error) => {
76
+ if (!error)
77
+ return false;
78
+ if (error.code && CODIGOS_CONEXION.includes(String(error.code)))
79
+ return true;
80
+ const mensaje = String(error.message || '').toLowerCase();
81
+ return MENSAJES_CONEXION.some((m) => mensaje.includes(m));
82
+ };
83
+ /**
84
+ * Interruptor del contrato de T-219, APAGADO por defecto y a propósito.
85
+ *
86
+ * El rechazo salió primero como 2.0.0 justamente para que `^1.x` no se lo llevara solo. Sale ahora
87
+ * en una menor —que cualquier `^1.x` se lleva en cuanto refresque su lock, y esos repos suben
88
+ * dependencias a menudo— así que la barrera tiene que seguir en pie de otra forma: apagado, la
89
+ * librería se comporta exactamente como la 1.31.x.
90
+ *
91
+ * Cada servicio lo enciende con `RP_DB_RECHAZA_FALLO_CONEXION=1` CUANDO haya cerrado sus puntos
92
+ * de `docs/T-219-auditoria-consumidores.md` (hoy: Cliente-backend 10, Dashboard-backend 6,
93
+ * Cron-backend 3, IA-backend 0, back-office sin auditar).
94
+ *
95
+ * Se lee en cada llamada, no al cargar el módulo: así se puede encender por entorno sin depender
96
+ * del orden de los imports, y las pruebas pueden ir y volver.
97
+ */
98
+ const rechazaFalloDeConexion = () => process.env.RP_DB_RECHAZA_FALLO_CONEXION === '1';
99
+ const mapearFilas = (result) => ((result === null || result === void 0 ? void 0 : result.rows) || []).map((row) => {
100
+ const json = Object.keys(row);
101
+ const aux = {};
102
+ json.forEach((key) => {
103
+ aux[camelize(key)] = row[key];
104
+ });
105
+ return aux;
106
+ });
107
+ /**
108
+ * Coge una conexión del pool reintentando si el fallo es de conexión/login.
109
+ *
110
+ * Se separa del `client.query` a propósito (T-219): aquí todavía no se ha mandado nada a
111
+ * PostgreSQL, así que reintentar es seguro incluso con un INSERT o un UPDATE. Reintentar la
112
+ * query ya enviada no lo sería —podría duplicar una escritura—, por eso `query()` no lo hace.
113
+ */
114
+ const conectarConReintento = () => __awaiter(void 0, void 0, void 0, function* () {
115
+ for (let intento = 0;; intento++) {
116
+ try {
117
+ return yield pool.connect();
118
+ }
119
+ catch (error) {
120
+ if (intento >= REINTENTOS_CONEXION || !esErrorDeConexion(error))
121
+ throw error;
122
+ // eslint-disable-next-line no-console
123
+ console.error(`PG connect error (intento ${intento + 1}/${REINTENTOS_CONEXION + 1}): ${error === null || error === void 0 ? void 0 : error.message}. Reintentando en ${ESPERA_REINTENTO_MS} ms.`);
124
+ yield sleep(ESPERA_REINTENTO_MS);
125
+ }
126
+ }
127
+ });
128
+ /**
129
+ * ✅ FUNCIÓN PRINCIPAL - queryDirect (recomendada para 90% de casos)
130
+ *
131
+ * ⚠️ CONTRATO (T-219): con `RP_DB_RECHAZA_FALLO_CONEXION=1`, un fallo de CONEXIÓN, agotados los
132
+ * reintentos, **rechaza**. Sin esa variable —el caso por defecto— devuelve `[]` como toda la 1.x.
133
+ * Un error de SQL (sintaxis, constraint, columna que no existe) se sigue logueando y
134
+ * devolviendo `[]` en los dos modos, como siempre.
135
+ *
136
+ * Antes todo devolvía `[]`, así que "la BD no ha conectado" y "no hay filas" eran la misma
137
+ * cosa para quien llamaba. En 7 días eso escondió 176 `client_login_timeout` en
138
+ * prod-third-party-backend: unos pocos reventaban con un TypeError y la mayoría se saltaban
139
+ * trabajo en silencio (entrenos que se quedaron sin estructura, notificaciones que no
140
+ * salieron). Ver docs de T-219.
141
+ *
142
+ * Para el que llama: si la fila puede no existir de verdad, sigue comprobándolo. Lo que ya no
143
+ * hace falta es adivinar si el `[]` era un fallo de BD.
144
+ *
145
+ * El cambio sale publicado en **1.32.0** con el interruptor apagado: la versión puede entrar en
146
+ * cualquier servicio sin avisar y aun así no cambia nada hasta que ese servicio lo enciende. Antes de
147
+ * encenderlo, `docs/T-219-auditoria-consumidores.md` dice, con fichero y línea, dónde cae ese
148
+ * rechazo en cada repo y qué queda por cerrar.
149
+ */
38
150
  const query = (queryText, values = []) => __awaiter(void 0, void 0, void 0, function* () {
151
+ let client;
152
+ try {
153
+ client = yield conectarConReintento();
154
+ }
155
+ catch (error) {
156
+ // Agotados los reintentos: se propaga para que el llamante decida, en vez de fingir "0 filas".
157
+ const enhancedError = enhancePostgreSQLError(error, queryText, values);
158
+ // eslint-disable-next-line no-console
159
+ console.error('PG query error', enhancedError);
160
+ if (rechazaFalloDeConexion())
161
+ throw error;
162
+ // Interruptor apagado = contrato de la 1.x: se devuelve []. La traza de arriba es lo único
163
+ // que queda del fallo, y es justo lo que T-219 vino a arreglar: enciéndelo cuando puedas.
164
+ return [];
165
+ }
166
+ let clienteRoto = false;
39
167
  try {
40
168
  const text = getParseQuery(queryText, values);
41
- const result = (yield pool.query({
169
+ const result = (yield client.query({
42
170
  text,
43
- // @ts-ignore
44
171
  values: values.flat(),
45
172
  }));
46
- return (result.rows || []).map((row) => {
47
- const json = Object.keys(row);
48
- const aux = {};
49
- json.forEach((key) => {
50
- aux[camelize(key)] = row[key];
51
- });
52
- return aux;
53
- });
173
+ return mapearFilas(result);
54
174
  }
55
175
  catch (error) {
56
176
  // Logging mejorado con información detallada del error
57
177
  const enhancedError = enhancePostgreSQLError(error, queryText, values);
58
178
  // eslint-disable-next-line no-console
59
179
  console.error('PG query error', enhancedError);
180
+ // Se cayó el socket a mitad de la query: la conexión ya no vale y el llamante tiene que
181
+ // enterarse. Un error de SQL, en cambio, mantiene el contrato de siempre y devuelve [].
182
+ // Marcar el cliente como roto NO depende del interruptor: devolver al pool una conexión
183
+ // muerta es un fallo por sí solo, y arreglarlo no cambia lo que ve quien llama.
184
+ clienteRoto = esErrorDeConexion(error);
185
+ if (clienteRoto && rechazaFalloDeConexion())
186
+ throw error;
60
187
  return [];
61
188
  }
189
+ finally {
190
+ try {
191
+ client.release(clienteRoto);
192
+ }
193
+ catch (releaseError) {
194
+ // eslint-disable-next-line no-console
195
+ console.error('Error releasing client:', releaseError);
196
+ }
197
+ }
62
198
  });
63
199
  exports.query = query;
64
200
  // ✅ Para casos especiales que requieren control manual de conexiones
@@ -72,14 +208,7 @@ const queryWithClient = (queryText, values = [], _retry = false) => __awaiter(vo
72
208
  text,
73
209
  values: values.flat(),
74
210
  }));
75
- return (result.rows || []).map((row) => {
76
- const json = Object.keys(row);
77
- const aux = {};
78
- json.forEach((key) => {
79
- aux[camelize(key)] = row[key];
80
- });
81
- return aux;
82
- });
211
+ return mapearFilas(result);
83
212
  }
84
213
  catch (error) {
85
214
  // Logging mejorado con información detallada del error
@@ -93,7 +222,7 @@ const queryWithClient = (queryText, values = [], _retry = false) => __awaiter(vo
93
222
  released = true;
94
223
  }
95
224
  if (!_retry) {
96
- yield sleep(500);
225
+ yield sleep(ESPERA_REINTENTO_MS);
97
226
  return queryWithClient(queryText, values, true);
98
227
  }
99
228
  }
@@ -144,14 +273,7 @@ const longRunningQuery = (queryText, values = [], timeoutMs = 300000 // 5 minuto
144
273
  // @ts-ignore
145
274
  values: values.flat(),
146
275
  }));
147
- return (result.rows || []).map((row) => {
148
- const json = Object.keys(row);
149
- const aux = {};
150
- json.forEach((key) => {
151
- aux[camelize(key)] = row[key];
152
- });
153
- return aux;
154
- });
276
+ return mapearFilas(result);
155
277
  }
156
278
  finally {
157
279
  client.release();
@@ -41,20 +41,36 @@ canvas_1.GlobalFonts.registerFromPath(path_1.default.join(__dirname, '../../..',
41
41
  */
42
42
  const generateShareMap = (image, idWorkout, options = {}) => __awaiter(void 0, void 0, void 0, function* () {
43
43
  const useDefaultPhoto = (options === null || options === void 0 ? void 0 : options.useDefaultPhoto) || false;
44
- const [workout] = yield (0, index_1.query)('SELECT [ID], [ID CLIENTE], [TYPE], [DISTANCE], [DURATION], [DESNIVEL] FROM [WORKOUT] WHERE [ID] = ?', [idWorkout]);
45
- // ⚠️ El guard va ANTES de usar `workout`: `query` devuelve [] tanto si el entreno
46
- // no existe como si la consulta falló (se traga el error y loguea 'PG query error',
47
- // p.ej. client_login_timeout del pool). Leer `workout.idCliente` sin comprobarlo
48
- // rompía el webhook con TypeError.
44
+ // ⚠️ El guard va ANTES de usar `workout`: `query` devuelve [] cuando el entreno no existe.
45
+ // Leer `workout.idCliente` sin comprobarlo rompía el webhook con TypeError (T-066).
46
+ // Desde T-219 un fallo de CONEXIÓN ya no llega como [] sino como excepción, así que se
47
+ // captura aquí para mantener el contrato de la función (devolver `null`, no lanzar) y se
48
+ // distingue en el log de "el entreno no existe".
49
+ let workout, cliente;
50
+ try {
51
+ [workout] = yield (0, index_1.query)('SELECT [ID], [ID CLIENTE], [TYPE], [DISTANCE], [DURATION], [DESNIVEL] FROM [WORKOUT] WHERE [ID] = ?', [idWorkout]);
52
+ }
53
+ catch (error) {
54
+ // eslint-disable-next-line no-console
55
+ console.error(`generateShareMap: la BD no respondió al leer el idWorkout=${idWorkout} (${error === null || error === void 0 ? void 0 : error.message}). No se genera la imagen de compartir.`);
56
+ return null;
57
+ }
49
58
  if (!workout) {
50
59
  // eslint-disable-next-line no-console
51
- console.error(`generateShareMap: sin fila de WORKOUT para idWorkout=${idWorkout}; el entreno no existe o la consulta falló (ver 'PG query error' justo antes). No se genera la imagen de compartir.`);
60
+ console.error(`generateShareMap: sin fila de WORKOUT para idWorkout=${idWorkout}; el entreno no existe. No se genera la imagen de compartir.`);
61
+ return null;
62
+ }
63
+ try {
64
+ [cliente] = yield (0, index_1.query)('SELECT [PREFERRED LANGUAGE] FROM [CLIENTE] WHERE [ID] = ?', [workout.idCliente]);
65
+ }
66
+ catch (error) {
67
+ // eslint-disable-next-line no-console
68
+ console.error(`generateShareMap: la BD no respondió al leer el idCliente=${workout.idCliente} del idWorkout=${idWorkout} (${error === null || error === void 0 ? void 0 : error.message}). No se genera la imagen de compartir.`);
52
69
  return null;
53
70
  }
54
- const [cliente] = yield (0, index_1.query)('SELECT [PREFERRED LANGUAGE] FROM [CLIENTE] WHERE [ID] = ?', [workout.idCliente]);
55
71
  if (!cliente) {
56
72
  // eslint-disable-next-line no-console
57
- console.error(`generateShareMap: sin fila de CLIENTE idCliente=${workout.idCliente} del idWorkout=${idWorkout}; el cliente no existe o la consulta falló. No se genera la imagen de compartir.`);
73
+ console.error(`generateShareMap: sin fila de CLIENTE idCliente=${workout.idCliente} del idWorkout=${idWorkout}; el cliente no existe. No se genera la imagen de compartir.`);
58
74
  return null;
59
75
  }
60
76
  const width = 1080;
@@ -13,16 +13,30 @@ exports.sendNotification = void 0;
13
13
  const index_1 = require("../db/index");
14
14
  const common_1 = require("@runnerpro/common");
15
15
  const sendNotification = ({ firebaseMessaging, idCliente, title, body, screen = common_1.NOTIFICATION_SCREEN_TYPES.HOME, screenParams = {}, }) => __awaiter(void 0, void 0, void 0, function* () {
16
- const devices = yield (0, index_1.query)('SELECT [SUBSCRIPTION], [TYPE] FROM [PUSH MANAGER] WHERE [ID CLIENTE] = ?', [idCliente]);
17
- const [{ id: idNotification }] = yield (0, index_1.query)('INSERT INTO "CLIENTE NOTIFICACION" ("ID CLIENTE", "TIMESTAMP", "TITLE", "BODY", "PARAMS") VALUES (?, NOW(), ?, ?, ?) RETURNING "ID"', [
18
- idCliente,
19
- title !== null && title !== void 0 ? title : '',
20
- body,
21
- JSON.stringify({
22
- screen,
23
- screenParams,
24
- }),
25
- ]);
16
+ // ⚠️ T-219: el INSERT lleva RETURNING y se destructura directo, así que un [] revienta con
17
+ // "TypeError: Cannot read properties of undefined (reading 'id')" —2 veces en la semana
18
+ // medida—. Se traza con el idCliente y se propaga: sin fila no hay notificación que mandar.
19
+ let devices, idNotification;
20
+ try {
21
+ devices = yield (0, index_1.query)('SELECT [SUBSCRIPTION], [TYPE] FROM [PUSH MANAGER] WHERE [ID CLIENTE] = ?', [idCliente]);
22
+ const [fila] = yield (0, index_1.query)('INSERT INTO "CLIENTE NOTIFICACION" ("ID CLIENTE", "TIMESTAMP", "TITLE", "BODY", "PARAMS") VALUES (?, NOW(), ?, ?, ?) RETURNING "ID"', [
23
+ idCliente,
24
+ title !== null && title !== void 0 ? title : '',
25
+ body,
26
+ JSON.stringify({
27
+ screen,
28
+ screenParams,
29
+ }),
30
+ ]);
31
+ if (!fila)
32
+ throw new Error('el INSERT de CLIENTE NOTIFICACION no ha devuelto fila');
33
+ idNotification = fila.id;
34
+ }
35
+ catch (error) {
36
+ // eslint-disable-next-line no-console
37
+ console.error(`sendNotification: no se ha podido registrar la notificación de idCliente=${idCliente} (${error === null || error === void 0 ? void 0 : error.message}). No se envía.`);
38
+ throw error;
39
+ }
26
40
  const screenParamsString = JSON.stringify(screenParams);
27
41
  for (const device of devices) {
28
42
  yield notificationWEB(firebaseMessaging, {
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/achievements/index.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAC;AAiK9C,qGAAqG;AAErG,eAAO,MAAM,qBAAqB,YAAa,GAAG,UAAU,GAAG,KAAG,GAuBjE,CAAC;AAkjBF;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,oBAAoB,WACvB,GAAG,UACJ,MAAM,SACP,GAAG,KACR,QAAQ,MAAM;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,eAAe,EAAE,GAAG,CAAC;IAAC,MAAM,CAAC,EAAE,GAAG,CAAA;CAAE,CAAC,CAyGnE,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,WAC9B,GAAG,UACJ,MAAM,SACP,GAAG,yBAER,QAAQ,MAAM;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,eAAe,EAAE,GAAG,CAAC;IAAC,MAAM,CAAC,EAAE,GAAG,CAAA;CAAE,CAAC,CAWnE,CAAC;AAEF,uFAAuF;AACvF,eAAO,MAAM,uBAAuB,WAAkB,GAAG,KAAG,QAAQ,MAAM,EAAE,CAO3E,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/achievements/index.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAC;AA6L9C,qGAAqG;AAErG,eAAO,MAAM,qBAAqB,YAAa,GAAG,UAAU,GAAG,KAAG,GAuBjE,CAAC;AA0jBF;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,oBAAoB,WACvB,GAAG,UACJ,MAAM,SACP,GAAG,KACR,QAAQ,MAAM;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,eAAe,EAAE,GAAG,CAAC;IAAC,MAAM,CAAC,EAAE,GAAG,CAAA;CAAE,CAAC,CAyGnE,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,WAC9B,GAAG,UACJ,MAAM,SACP,GAAG,yBAER,QAAQ,MAAM;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,eAAe,EAAE,GAAG,CAAC;IAAC,MAAM,CAAC,EAAE,GAAG,CAAA;CAAE,CAAC,CAWnE,CAAC;AAEF,uFAAuF;AACvF,eAAO,MAAM,uBAAuB,WAAkB,GAAG,KAAG,QAAQ,MAAM,EAAE,CAO3E,CAAC"}
@@ -1,5 +1,27 @@
1
1
  import postgresql from 'pg';
2
2
  declare const pool: postgresql.Pool;
3
+ /**
4
+ * ✅ FUNCIÓN PRINCIPAL - queryDirect (recomendada para 90% de casos)
5
+ *
6
+ * ⚠️ CONTRATO (T-219): con `RP_DB_RECHAZA_FALLO_CONEXION=1`, un fallo de CONEXIÓN, agotados los
7
+ * reintentos, **rechaza**. Sin esa variable —el caso por defecto— devuelve `[]` como toda la 1.x.
8
+ * Un error de SQL (sintaxis, constraint, columna que no existe) se sigue logueando y
9
+ * devolviendo `[]` en los dos modos, como siempre.
10
+ *
11
+ * Antes todo devolvía `[]`, así que "la BD no ha conectado" y "no hay filas" eran la misma
12
+ * cosa para quien llamaba. En 7 días eso escondió 176 `client_login_timeout` en
13
+ * prod-third-party-backend: unos pocos reventaban con un TypeError y la mayoría se saltaban
14
+ * trabajo en silencio (entrenos que se quedaron sin estructura, notificaciones que no
15
+ * salieron). Ver docs de T-219.
16
+ *
17
+ * Para el que llama: si la fila puede no existir de verdad, sigue comprobándolo. Lo que ya no
18
+ * hace falta es adivinar si el `[]` era un fallo de BD.
19
+ *
20
+ * El cambio sale publicado en **1.32.0** con el interruptor apagado: la versión puede entrar en
21
+ * cualquier servicio sin avisar y aun así no cambia nada hasta que ese servicio lo enciende. Antes de
22
+ * encenderlo, `docs/T-219-auditoria-consumidores.md` dice, con fichero y línea, dónde cae ese
23
+ * rechazo en cada repo y qué queda por cerrar.
24
+ */
3
25
  declare const query: (queryText: string, values?: (string | number | boolean)[]) => Promise<any>;
4
26
  declare const queryWithClient: (queryText: string, values?: (string | number | boolean)[], _retry?: boolean) => any;
5
27
  declare const batchQuery: (queries: any[], batchSize?: number) => Promise<any[]>;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/db/index.ts"],"names":[],"mappings":"AAAA,OAAO,UAAU,MAAM,IAAI,CAAC;AAI5B,QAAA,MAAM,IAAI,iBAYR,CAAC;AASH,QAAA,MAAM,KAAK,cAAqB,MAAM,WAAU,CAAC,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,EAAE,iBAyB5E,CAAC;AAGF,QAAA,MAAM,eAAe,cAAqB,MAAM,WAAU,CAAC,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,EAAE,0BAmDtF,CAAC;AAGF,QAAA,MAAM,UAAU,YAAmB,GAAG,EAAE,uCAmBvC,CAAC;AAGF,QAAA,MAAM,gBAAgB,cACT,MAAM,WACT,CAAC,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,EAAE,qCAyBtC,CAAC;AAGF,QAAA,MAAM,SAAS,QAAS,MAAM,EAAE,WAM/B,CAAC;AAoJF,OAAO,EACL,KAAK,EAAE,oCAAoC;AAC3C,eAAe,EAAE,wBAAwB;AACzC,gBAAgB,EAAE,uBAAuB;AACzC,UAAU,EAAE,aAAa;AACzB,SAAS,EAAE,WAAW;AACtB,IAAI,GACL,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/db/index.ts"],"names":[],"mappings":"AAAA,OAAO,UAAU,MAAM,IAAI,CAAC;AAI5B,QAAA,MAAM,IAAI,iBAYR,CAAC;AAyGH;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,QAAA,MAAM,KAAK,cAAqB,MAAM,WAAU,CAAC,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,EAAE,iBA+C5E,CAAC;AAGF,QAAA,MAAM,eAAe,cAAqB,MAAM,WAAU,CAAC,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,EAAE,0BA4CtF,CAAC;AAGF,QAAA,MAAM,UAAU,YAAmB,GAAG,EAAE,uCAmBvC,CAAC;AAGF,QAAA,MAAM,gBAAgB,cACT,MAAM,WACT,CAAC,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,EAAE,qCAkBtC,CAAC;AAGF,QAAA,MAAM,SAAS,QAAS,MAAM,EAAE,WAM/B,CAAC;AAoJF,OAAO,EACL,KAAK,EAAE,oCAAoC;AAC3C,eAAe,EAAE,wBAAwB;AACzC,gBAAgB,EAAE,uBAAuB;AACzC,UAAU,EAAE,aAAa;AACzB,SAAS,EAAE,WAAW;AACtB,IAAI,GACL,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"generateShareMap.d.ts","sourceRoot":"","sources":["../../../../src/image/generateShareMap.ts"],"names":[],"mappings":";AAYA;;;;;;;;;;;;;;;GAeG;AACH,QAAA,MAAM,gBAAgB,+DA+BrB,CAAC;AA0HF,OAAO,EAAE,gBAAgB,EAAE,CAAC"}
1
+ {"version":3,"file":"generateShareMap.d.ts","sourceRoot":"","sources":["../../../../src/image/generateShareMap.ts"],"names":[],"mappings":";AAYA;;;;;;;;;;;;;;;GAeG;AACH,QAAA,MAAM,gBAAgB,+DA6CrB,CAAC;AA0HF,OAAO,EAAE,gBAAgB,EAAE,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/sendNotification/index.ts"],"names":[],"mappings":"AAEA,UAAU,YAAY;IACpB,iBAAiB,EAAE,GAAG,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,YAAY,CAAC,EAAE,GAAG,CAAC;CACpB;AAED,QAAA,MAAM,gBAAgB,yEAOnB,YAAY,kBA8Bd,CAAC;AAuBF,OAAO,EAAE,gBAAgB,EAAE,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/sendNotification/index.ts"],"names":[],"mappings":"AAEA,UAAU,YAAY;IACpB,iBAAiB,EAAE,GAAG,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,YAAY,CAAC,EAAE,GAAG,CAAC;CACpB;AAED,QAAA,MAAM,gBAAgB,yEAOnB,YAAY,kBA0Cd,CAAC;AAuBF,OAAO,EAAE,gBAAgB,EAAE,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"estructuraWorkout.d.ts","sourceRoot":"","sources":["../../../../src/workout/estructuraWorkout.ts"],"names":[],"mappings":"AAsCA,QAAA,MAAM,mBAAmB,YAAa,GAAG,cAAc,GAAG,QAiCzD,CAAC;AAEF;;;;;;;;;;;;;;;;GAgBG;AACH,QAAA,MAAM,wBAAwB,cAAqB,GAAG,kBAkBrD,CAAC;AAkPF,OAAO,EAAE,mBAAmB,EAAE,wBAAwB,EAAE,CAAC"}
1
+ {"version":3,"file":"estructuraWorkout.d.ts","sourceRoot":"","sources":["../../../../src/workout/estructuraWorkout.ts"],"names":[],"mappings":"AAsCA,QAAA,MAAM,mBAAmB,YAAa,GAAG,cAAc,GAAG,QAiCzD,CAAC;AAEF;;;;;;;;;;;;;;;;GAgBG;AACH,QAAA,MAAM,wBAAwB,cAAqB,GAAG,kBAgCrD,CAAC;AAkPF,OAAO,EAAE,mBAAmB,EAAE,wBAAwB,EAAE,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"saveWorkoutAplication.d.ts","sourceRoot":"","sources":["../../../../src/workout/saveWorkoutAplication.ts"],"names":[],"mappings":"AAuTA;;;;;;;;;;GAUG;AACH,QAAA,MAAM,qBAAqB,SACnB,GAAG,UAED,GAAG,KACV,QAAQ,MAAM,GAAG,IAAI,CA6EvB,CAAC;AA+EF,OAAO,EAAE,qBAAqB,EAAE,CAAC"}
1
+ {"version":3,"file":"saveWorkoutAplication.d.ts","sourceRoot":"","sources":["../../../../src/workout/saveWorkoutAplication.ts"],"names":[],"mappings":"AAsTA;;;;;;;;;;GAUG;AACH,QAAA,MAAM,qBAAqB,SACnB,GAAG,UAED,GAAG,KACV,QAAQ,MAAM,GAAG,IAAI,CAqFvB,CAAC;AA0GF,OAAO,EAAE,qBAAqB,EAAE,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"saveWorkoutLaps.d.ts","sourceRoot":"","sources":["../../../../src/workout/saveWorkoutLaps.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,QAAA,MAAM,eAAe,cAAqB,GAAG,QAAQ,GAAG,EAAE,mBAAmB,GAAG,KAAG,QAAQ,IAAI,CAuB9F,CAAC;AAEF,OAAO,EAAE,eAAe,EAAE,CAAC"}
1
+ {"version":3,"file":"saveWorkoutLaps.d.ts","sourceRoot":"","sources":["../../../../src/workout/saveWorkoutLaps.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,QAAA,MAAM,eAAe,cAAqB,GAAG,QAAQ,GAAG,EAAE,mBAAmB,GAAG,KAAG,QAAQ,IAAI,CAyC9F,CAAC;AAEF,OAAO,EAAE,eAAe,EAAE,CAAC"}
@@ -85,12 +85,25 @@ exports.getStructuraWorkout = getStructuraWorkout;
85
85
  * @param {number|string} idWorkout
86
86
  */
87
87
  const saveDoneStructuraWorkout = (idWorkout) => __awaiter(void 0, void 0, void 0, function* () {
88
- const [laps, streamData, [workout], estructura] = yield Promise.all([
89
- (0, db_1.query)('SELECT * FROM "WORKOUT LAP" WHERE "ID WORKOUT" = ? ORDER BY "INDEX"', [idWorkout]),
90
- (0, db_1.query)('SELECT * FROM "WORKOUT STREAM" WHERE "ID WORKOUT" = ?', [idWorkout]),
91
- (0, db_1.query)('SELECT "DURATION", "ID CLIENTE", "TYPE" FROM "WORKOUT" WHERE "ID" = ?', [idWorkout]),
92
- (0, db_1.query)('SELECT * FROM "WORKOUT STRUCTURE" WHERE "ID WORKOUT" = ? ORDER BY "INDEX"', [idWorkout]),
93
- ]);
88
+ // ⚠️ T-219: estas 4 lecturas son el punto donde más veces se cae el pool (135 de los 176
89
+ // client_login_timeout de una semana en prod-third-party-backend). Antes el fallo llegaba
90
+ // como `[]` y el `return` de abajo lo confundía con "este entreno no es por series": el
91
+ // entreno se quedaba sin estructura real y nadie se enteraba. Ahora se traza con el
92
+ // idWorkout y se propaga, para que el webhook que lo llamó lo registre.
93
+ let laps, streamData, workout, estructura;
94
+ try {
95
+ [laps, streamData, [workout], estructura] = yield Promise.all([
96
+ (0, db_1.query)('SELECT * FROM "WORKOUT LAP" WHERE "ID WORKOUT" = ? ORDER BY "INDEX"', [idWorkout]),
97
+ (0, db_1.query)('SELECT * FROM "WORKOUT STREAM" WHERE "ID WORKOUT" = ?', [idWorkout]),
98
+ (0, db_1.query)('SELECT "DURATION", "ID CLIENTE", "TYPE" FROM "WORKOUT" WHERE "ID" = ?', [idWorkout]),
99
+ (0, db_1.query)('SELECT * FROM "WORKOUT STRUCTURE" WHERE "ID WORKOUT" = ? ORDER BY "INDEX"', [idWorkout]),
100
+ ]);
101
+ }
102
+ catch (error) {
103
+ // eslint-disable-next-line no-console
104
+ console.error(`saveDoneStructuraWorkout: no se han podido leer los datos del idWorkout=${idWorkout} (${error === null || error === void 0 ? void 0 : error.message}). El entreno se queda SIN estructura real.`);
105
+ throw error;
106
+ }
94
107
  // Workout sin estructura (no es entrenamiento por series) → nada que hacer.
95
108
  if (!estructura || estructura.length === 0 || !workout)
96
109
  return;
@@ -14,7 +14,6 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.saveWorkoutAplication = void 0;
16
16
  const db_1 = require("../db");
17
- const titleDescriptionTranslated_1 = require("../translation/titleDescriptionTranslated");
18
17
  const storage_1 = require("@google-cloud/storage");
19
18
  const path_1 = __importDefault(require("path"));
20
19
  const axios_1 = __importDefault(require("axios"));
@@ -316,16 +315,24 @@ _data) => __awaiter(void 0, void 0, void 0, function* () {
316
315
  }
317
316
  const dateStr = (0, moment_1.default)(opts.date).format('YYYY-MM-DD');
318
317
  const pt = predicadoTipo(opts);
319
- // Traducción FUERA de la sección crítica. getTitleDescriptionTranslated hace
320
- // una llamada de red a Google Cloud Translation; si se ejecutara dentro de
321
- // `withTx` retendría el advisory lock durante todo el round-trip (causa de los
322
- // picos de varios minutos en pg_advisory_xact_lock cuando la API va lenta).
323
- // Sólo depende de idCliente/title/description —nada de la transacción— así que
324
- // se calcula antes de abrir la tx. Semántica intacta: si la traducción falla,
325
- // se propaga el error y el workout no se guarda, igual que antes. En las rutas
326
- // de merge (1 y 2) el valor se descarta; el único sobrecoste es una traducción
327
- // extra para clientes no-ES con doble proveedor, a cambio de no bloquear el lock.
328
- const translated = yield (0, titleDescriptionTranslated_1.getTitleDescriptionTranslated)(opts.idCliente, opts.title, opts.description);
318
+ // SIN traducción en la ingesta: título y descripción se guardan tal cual llegan
319
+ // del proveedor (Garmin/Strava/Apple). Antes se traducían al español con Google
320
+ // Cloud Translation, lo que metía una llamada de red —y una dependencia de las
321
+ // credenciales de GCP— en el camino crítico del guardado: si Translate fallaba,
322
+ // el entreno NO se guardaba y el webhook devolvía 5xx. Con la migración a Azure
323
+ // eso se rompió (sin metadata server, las ADC resuelven a la service account de
324
+ // Storage, que no tiene permiso de Translate PERMISSION_DENIED) y dos
325
+ // actividades envenenadas bastaron para atascar la cola de reintentos de Garmin
326
+ // y frenar la ingesta del resto.
327
+ // Consecuencia asumida: para clientes no-ES el "TITLE" queda en el idioma del
328
+ // dispositivo en vez de normalizado a español —lo notan entrenadores y la IA, no
329
+ // el cliente: la app cae a "TITLE" cuando "TITLE PREFERRED LANGUAGE" es null.
330
+ const translated = {
331
+ title: opts.title,
332
+ description: opts.description,
333
+ titlePreferredLanguage: null,
334
+ descriptionPreferredLanguage: null,
335
+ };
329
336
  const idWorkout = yield withTx((q) => __awaiter(void 0, void 0, void 0, function* () {
330
337
  var _e;
331
338
  // Lock anti-carrera: serializa ingestas del mismo cliente/día/bucket-tipo
@@ -391,7 +398,21 @@ const postHooks = (idWorkout, opts) => __awaiter(void 0, void 0, void 0, functio
391
398
  // La imagen de compartir es accesoria: si falla (Storage, canvas, BD) no debe
392
399
  // romper la ingesta ni escapar como unhandled rejection (así llegaba a Sentry).
393
400
  }
394
- yield (0, estructuraWorkout_1.saveDoneStructuraWorkout)(idWorkout);
401
+ // ⚠️ T-219: aislado como el resto de hooks, y por un motivo concreto. El WORKOUT ya está
402
+ // commiteado y la estructura real es un DERIVADO idempotente que se vuelve a calcular solo
403
+ // (saveWorkoutLaps la re-dispara al llegar los laps, y cualquier merge posterior también).
404
+ // Si el rechazo escapara de aquí subiría por saveWorkoutAplication hasta el llamante y
405
+ // abortaría lo que este hace DESPUÉS —los laps de Strava y la notificación de sentimientos—,
406
+ // que sí son irrecuperables: esos webhooks ya han respondido 200 y nadie los reintenta.
407
+ // Como aquí es donde más se cae el pool (135 de los 176 client_login_timeout), dejarlo
408
+ // propagar convertía el fallo más frecuente en la pérdida más cara. saveDoneStructuraWorkout
409
+ // ya traza el idWorkout antes de lanzar, así que el fallo no se pierde de vista.
410
+ try {
411
+ yield (0, estructuraWorkout_1.saveDoneStructuraWorkout)(idWorkout);
412
+ }
413
+ catch (_) {
414
+ // ignore — trazado dentro; la estructura se recalcula en cuanto lleguen laps o un merge.
415
+ }
395
416
  // Logros: evaluar al completar el entreno (este es el embudo de los entrenos
396
417
  // sincronizados). Se pasa el idWorkout para calcular el PR solo de ESTE entreno
397
418
  // (incremental). Aislado: un fallo de logros NUNCA debe romper la ingesta.
@@ -429,7 +450,15 @@ const saveMap = (idWorkout, polyline) => __awaiter(void 0, void 0, void 0, funct
429
450
  // @ts-ignore
430
451
  yield storage.bucket(process.env.CLOUD_STORAGE_BUCKET_PUBLIC).file(`Workout/${idWorkout}`).save(imageBuffer);
431
452
  const urlMap = `https://storage.googleapis.com/${process.env.CLOUD_STORAGE_BUCKET_PUBLIC}/Workout/${idWorkout}`;
432
- yield (0, db_1.query)('UPDATE "WORKOUT" SET "HAVE MAP IMAGE" = TRUE, "PHOTO URL" = ? WHERE "ID" = ?', [urlMap, idWorkout]);
453
+ try {
454
+ yield (0, db_1.query)('UPDATE "WORKOUT" SET "HAVE MAP IMAGE" = TRUE, "PHOTO URL" = ? WHERE "ID" = ?', [urlMap, idWorkout]);
455
+ }
456
+ catch (error) {
457
+ // T-219: la imagen ya está en Storage; si esto falla, el entreno se queda sin su mapa en BD.
458
+ // eslint-disable-next-line no-console
459
+ console.error(`saveMap: mapa subido pero NO guardado en BD para idWorkout=${idWorkout} (${error === null || error === void 0 ? void 0 : error.message}).`);
460
+ throw error;
461
+ }
433
462
  });
434
463
  const saveShareWorkoutImage = (id, type) => __awaiter(void 0, void 0, void 0, function* () {
435
464
  // @ts-ignore
@@ -451,5 +480,13 @@ const saveShareWorkoutImage = (id, type) => __awaiter(void 0, void 0, void 0, fu
451
480
  // @ts-ignore
452
481
  yield storage.bucket(process.env.CLOUD_STORAGE_BUCKET_PUBLIC).file(`Workout/${id}-share`).save(shareMap);
453
482
  const urlShare = `https://storage.googleapis.com/${process.env.CLOUD_STORAGE_BUCKET_PUBLIC}/Workout/${id}-share`;
454
- yield (0, db_1.query)('UPDATE "WORKOUT" SET "PHOTO URL SHARE" = ? WHERE "ID" = ?', [urlShare, id]);
483
+ try {
484
+ yield (0, db_1.query)('UPDATE "WORKOUT" SET "PHOTO URL SHARE" = ? WHERE "ID" = ?', [urlShare, id]);
485
+ }
486
+ catch (error) {
487
+ // T-219: la imagen de compartir ya está subida; sin esta URL el usuario no la ve en la app.
488
+ // eslint-disable-next-line no-console
489
+ console.error(`saveShareWorkoutImage: imagen subida pero NO guardada en BD para idWorkout=${id} (${error === null || error === void 0 ? void 0 : error.message}).`);
490
+ throw error;
491
+ }
455
492
  });
@@ -44,16 +44,34 @@ const saveWorkoutLaps = (idWorkout, laps, aplicationType) => __awaiter(void 0, v
44
44
  const nuevos = Array.isArray(laps)
45
45
  ? laps.filter((l) => l && l.index !== null && l.index !== undefined && l.durationStartLap !== null && l.durationStartLap !== undefined)
46
46
  : [];
47
- const [countRow] = yield (0, db_1.query)('SELECT COUNT(*)::int AS "N" FROM "WORKOUT LAP" WHERE "ID WORKOUT" = ?', [idWorkout]);
48
- const existentes = Number((countRow === null || countRow === void 0 ? void 0 : countRow.n) || 0);
49
- if (nuevos.length > existentes) {
50
- yield (0, db_1.query)('DELETE FROM "WORKOUT LAP" WHERE "ID WORKOUT" = ?', [idWorkout]);
51
- for (const lap of nuevos) {
52
- yield (0, db_1.query)('INSERT INTO "WORKOUT LAP" ("ID WORKOUT", "INDEX", "DURATION START LAP") VALUES (?, ?, ?)', [idWorkout, lap.index, lap.durationStartLap]);
47
+ // ⚠️ T-219: si el pool se cae en mitad del DELETE + INSERT, los laps quedan a medias. Se traza
48
+ // con el idWorkout y cuántos laps había que escribir, y se propaga para que quede registrado.
49
+ try {
50
+ const [countRow] = yield (0, db_1.query)('SELECT COUNT(*)::int AS "N" FROM "WORKOUT LAP" WHERE "ID WORKOUT" = ?', [idWorkout]);
51
+ const existentes = Number((countRow === null || countRow === void 0 ? void 0 : countRow.n) || 0);
52
+ if (nuevos.length > existentes) {
53
+ yield (0, db_1.query)('DELETE FROM "WORKOUT LAP" WHERE "ID WORKOUT" = ?', [idWorkout]);
54
+ for (const lap of nuevos) {
55
+ yield (0, db_1.query)('INSERT INTO "WORKOUT LAP" ("ID WORKOUT", "INDEX", "DURATION START LAP") VALUES (?, ?, ?)', [idWorkout, lap.index, lap.durationStartLap]);
56
+ }
53
57
  }
54
58
  }
59
+ catch (error) {
60
+ // eslint-disable-next-line no-console
61
+ console.error(`saveWorkoutLaps: fallo guardando los ${nuevos.length} laps del idWorkout=${idWorkout} (${error === null || error === void 0 ? void 0 : error.message}). Pueden haber quedado a medias.`);
62
+ throw error;
63
+ }
55
64
  // si nuevos.length <= existentes: se conserva el conjunto existente (más detalle gana)
56
65
  // Re-dispara el recálculo de la estructura real ahora que hay laps en BD.
57
- yield (0, estructuraWorkout_1.saveDoneStructuraWorkout)(idWorkout);
66
+ // ⚠️ T-219: misma política que en los postHooks de saveWorkoutAplication — la ESCRITURA de
67
+ // laps de arriba propaga (es dato que se pierde), pero este recálculo es un derivado de lo
68
+ // que ya está en BD: si se deja propagar tumba lo que el llamante haga después (en Strava,
69
+ // la notificación de sentimientos) por un fallo que se puede rehacer. Se traza dentro.
70
+ try {
71
+ yield (0, estructuraWorkout_1.saveDoneStructuraWorkout)(idWorkout);
72
+ }
73
+ catch (_) {
74
+ // ignore — trazado dentro; los laps ya están persistidos y el recálculo es rehacible.
75
+ }
58
76
  });
59
77
  exports.saveWorkoutLaps = saveWorkoutLaps;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runnerpro/backend",
3
- "version": "1.31.2",
3
+ "version": "1.33.0",
4
4
  "description": "A collection of common backend functions",
5
5
  "exports": {
6
6
  ".": "./lib/cjs/index.js"
@@ -18,6 +18,7 @@
18
18
  "lint": "eslint --ext .ts --ignore-path .gitignore .",
19
19
  "test:translate-workout-garmin": "ts-node --transpile-only scripts/translateWorkoutGarmin.ts",
20
20
  "test:generate-share-map-guard": "ts-node --transpile-only -P configs/tsconfig.cjs.json scripts/testGenerateShareMapGuard.ts",
21
+ "test:pg-query-conexion": "ts-node --transpile-only -P configs/tsconfig.cjs.json scripts/testPgQueryConexion.ts",
21
22
  "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\"",
22
23
  "prepare": "husky"
23
24
  },
@@ -31,7 +32,7 @@
31
32
  },
32
33
  "repository": {
33
34
  "type": "git",
34
- "url": "https://gitlab.com/runner-pro/runnerpro-backend.git"
35
+ "url": "https://github.com/david-jimenez-lamas/runnerpro-backend.git"
35
36
  },
36
37
  "author": "Runner Pro",
37
38
  "license": "MIT",
@@ -1,38 +0,0 @@
1
- "use strict";
2
- var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
- function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
- return new (P || (P = Promise))(function (resolve, reject) {
5
- function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
- function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
- function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
- step((generator = generator.apply(thisArg, _arguments || [])).next());
9
- });
10
- };
11
- Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.getTitleDescriptionTranslated = void 0;
13
- const db_1 = require("../db");
14
- const index_1 = require("./index");
15
- const common_1 = require("@runnerpro/common");
16
- const getTitleDescriptionTranslated = (idCliente, titleOriginal, descriptionOriginal) => __awaiter(void 0, void 0, void 0, function* () {
17
- let title;
18
- let description;
19
- let titlePreferredLanguage;
20
- let descriptionPreferredLanguage;
21
- const [cliente] = yield (0, db_1.query)('SELECT [PREFERRED LANGUAGE] FROM [CLIENTE] WHERE [ID] = ?', [idCliente]);
22
- if ((cliente === null || cliente === void 0 ? void 0 : cliente.preferredLanguage) && cliente.preferredLanguage !== common_1.LANGUAGES.ES) {
23
- // @ts-ignore
24
- title = titleOriginal ? yield (0, index_1.translate)(titleOriginal, { fromLanguage: cliente.preferredLanguage, toLanguage: common_1.LANGUAGES.ES }) : null;
25
- description = descriptionOriginal
26
- ? // @ts-ignore
27
- yield (0, index_1.translate)(descriptionOriginal, { fromLanguage: cliente.preferredLanguage, toLanguage: common_1.LANGUAGES.ES })
28
- : null;
29
- titlePreferredLanguage = titleOriginal;
30
- descriptionPreferredLanguage = descriptionOriginal;
31
- }
32
- else {
33
- title = titleOriginal;
34
- description = descriptionOriginal;
35
- }
36
- return { title, description, titlePreferredLanguage, descriptionPreferredLanguage };
37
- });
38
- exports.getTitleDescriptionTranslated = getTitleDescriptionTranslated;
@@ -1,8 +0,0 @@
1
- declare const getTitleDescriptionTranslated: (idCliente: any, titleOriginal: any, descriptionOriginal: any) => Promise<{
2
- title: any;
3
- description: any;
4
- titlePreferredLanguage: any;
5
- descriptionPreferredLanguage: any;
6
- }>;
7
- export { getTitleDescriptionTranslated };
8
- //# sourceMappingURL=titleDescriptionTranslated.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"titleDescriptionTranslated.d.ts","sourceRoot":"","sources":["../../../../src/translation/titleDescriptionTranslated.ts"],"names":[],"mappings":"AAIA,QAAA,MAAM,6BAA6B;;;;;EAsBlC,CAAC;AAEF,OAAO,EAAE,6BAA6B,EAAE,CAAC"}