@runnerpro/backend 1.21.3 → 1.21.5

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.
@@ -18,10 +18,19 @@ const common_1 = require("@runnerpro/common");
18
18
  Object.defineProperty(exports, "LANGUAGES", { enumerable: true, get: function () { return common_1.LANGUAGES; } });
19
19
  const fs_1 = __importDefault(require("fs"));
20
20
  const index_1 = require("../db/index");
21
+ // Cliente singleton: instanciar TranslationServiceClient en cada llamada paga el
22
+ // coste de init (descubrimiento de credenciales/auth) por traducción. Se reutiliza
23
+ // un único cliente por proceso; es thread-safe para llamadas concurrentes.
24
+ let translationClientSingleton = null;
25
+ const getTranslationClient = () => {
26
+ if (!translationClientSingleton)
27
+ translationClientSingleton = new translate_1.TranslationServiceClient();
28
+ return translationClientSingleton;
29
+ };
21
30
  const translate = (text, { fromLanguage, toLanguage }) => __awaiter(void 0, void 0, void 0, function* () {
22
31
  const projectId = process.env.GCP_PROYECT_ID;
23
32
  const location = 'global';
24
- const translationClient = new translate_1.TranslationServiceClient();
33
+ const translationClient = getTranslationClient();
25
34
  const request = {
26
35
  parent: `projects/${projectId}/locations/${location}`,
27
36
  contents: [text],
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/translation/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAI9C,KAAK,cAAc,GAAG,MAAM,OAAO,SAAS,CAAC;AAE7C,QAAA,MAAM,SAAS,SACP,MAAM;kBACkC,cAAc;gBAAc,cAAc;MACvF,QAAQ,MAAM,CAwBhB,CAAC;AAEF,QAAA,MAAM,cAAc,aAAc,MAAM,QAAQ,MAAM;cAExC,MAAM,iBAAiB,GAAG;CAkBvC,CAAC;AAEF,QAAA,MAAM,gCAAgC;;;;;;EA4BrC,CAAC;AAEF,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,cAAc,EAAE,gCAAgC,EAAE,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/translation/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAI9C,KAAK,cAAc,GAAG,MAAM,OAAO,SAAS,CAAC;AAW7C,QAAA,MAAM,SAAS,SACP,MAAM;kBACkC,cAAc;gBAAc,cAAc;MACvF,QAAQ,MAAM,CAwBhB,CAAC;AAEF,QAAA,MAAM,cAAc,aAAc,MAAM,QAAQ,MAAM;cAExC,MAAM,iBAAiB,GAAG;CAkBvC,CAAC;AAEF,QAAA,MAAM,gCAAgC;;;;;;EA4BrC,CAAC;AAEF,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,cAAc,EAAE,gCAAgC,EAAE,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"estructuraWorkout.d.ts","sourceRoot":"","sources":["../../../../src/workout/estructuraWorkout.ts"],"names":[],"mappings":"AAuCA,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,kBAkBrD,CAAC;AAkPF,OAAO,EAAE,mBAAmB,EAAE,wBAAwB,EAAE,CAAC"}
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * @param opts {{
3
3
  * date, type, title, description, distance, duration, power, desnivel, polyline,
4
+ * startAt, // ISO/Date de inicio (UTC) — clave del dedup cross-proveedor
4
5
  * idCliente, aplicationType, aplicationId,
5
6
  * queryConditionExist?, // LEGACY string SQL — deprecated, fallback
6
7
  * tiposEquivalentes?, // string[] de TYPEs equivalentes
@@ -1 +1 @@
1
- {"version":3,"file":"saveWorkoutAplication.d.ts","sourceRoot":"","sources":["../../../../src/workout/saveWorkoutAplication.ts"],"names":[],"mappings":"AAwPA;;;;;;;;;GASG;AACH,QAAA,MAAM,qBAAqB,SACnB,GAAG,UAED,GAAG,KACV,QAAQ,MAAM,GAAG,IAAI,CA0DvB,CAAC;AA+DF,OAAO,EAAE,qBAAqB,EAAE,CAAC"}
1
+ {"version":3,"file":"saveWorkoutAplication.d.ts","sourceRoot":"","sources":["../../../../src/workout/saveWorkoutAplication.ts"],"names":[],"mappings":"AAuRA;;;;;;;;;;GAUG;AACH,QAAA,MAAM,qBAAqB,SACnB,GAAG,UAED,GAAG,KACV,QAAQ,MAAM,GAAG,IAAI,CAsEvB,CAAC;AA+DF,OAAO,EAAE,qBAAqB,EAAE,CAAC"}
@@ -147,12 +147,42 @@ const similar = (a, b, tol) => {
147
147
  return na === nb;
148
148
  return Math.abs(na - nb) / Math.max(na, nb) <= tol;
149
149
  };
150
+ // Ventana de solape de hora de inicio para el dedup cross-proveedor. Las
151
+ // grabaciones de la MISMA actividad (típicamente un mismo reloj que sincroniza a
152
+ // Strava/Apple/Garmin) empiezan en el mismo instante; 5 min absorben cualquier
153
+ // desfase de redondeo/zona horaria sin abrir falsos positivos (no se inician dos
154
+ // actividades del mismo grupo de tipo en 5 min).
155
+ const VENTANA_INICIO_MS = 5 * 60 * 1000;
156
+ const inicioSolapa = (a, b) => {
157
+ if (!a || !b)
158
+ return false;
159
+ const ta = new Date(a).getTime();
160
+ const tb = new Date(b).getTime();
161
+ if (Number.isNaN(ta) || Number.isNaN(tb))
162
+ return false;
163
+ return Math.abs(ta - tb) <= VENTANA_INICIO_MS;
164
+ };
150
165
  const buscarWorkoutSimilar = (q, opts, dateStr, pt) => __awaiter(void 0, void 0, void 0, function* () {
151
- const rows = yield q(`SELECT "ID", "DURATION", "DISTANCE", "TIPO APLICATION"
166
+ // Pre-filtro por ventana de día ±1: el match real es por hora de inicio (no por
167
+ // DATE exacto). Strava deriva DATE de la hora LOCAL y Apple/Garmin de UTC, así
168
+ // que la misma actividad puede caer en días distintos cerca de medianoche; ±1
169
+ // día cubre ese borde sin ensanchar el rango de más.
170
+ const dayPrev = (0, moment_1.default)(dateStr, 'YYYY-MM-DD').subtract(1, 'day').format('YYYY-MM-DD');
171
+ const dayNext = (0, moment_1.default)(dateStr, 'YYYY-MM-DD').add(1, 'day').format('YYYY-MM-DD');
172
+ const rows = yield q(`SELECT "ID", "DURATION", "DISTANCE", "TIPO APLICATION", "START AT"
152
173
  FROM "WORKOUT"
153
- WHERE "ID CLIENTE" = ? AND "DATE" = ? AND ${pt.sql}
154
- AND "DONE" = TRUE AND "TIPO APLICATION" IS NOT NULL AND "TIPO APLICATION" <> ?`, [opts.idCliente, dateStr, ...pt.params, opts.aplicationType]);
155
- const cand = rows.filter((r) => similar(r.duration, opts.duration, 0.1) && similar(r.distance, opts.distance, 0.1));
174
+ WHERE "ID CLIENTE" = ? AND "DATE" BETWEEN ? AND ? AND ${pt.sql}
175
+ AND "DONE" = TRUE AND "TIPO APLICATION" IS NOT NULL AND "TIPO APLICATION" <> ?`, [opts.idCliente, dayPrev, dayNext, ...pt.params, opts.aplicationType]);
176
+ const cand = rows.filter((r) => {
177
+ // Señal robusta: solape de hora de inicio. Inmune a las unidades de distancia
178
+ // (Apple en millas) y a elapsed-vs-moving en la duración (webhook Strava).
179
+ // Sólo aplica si AMBAS filas tienen "START AT".
180
+ if (opts.startAt && r.startAt)
181
+ return inicioSolapa(r.startAt, opts.startAt);
182
+ // Fallback legacy: filas sin "START AT" (anteriores a esta feature, sin
183
+ // backfill) → se mantiene el heurístico distancia+duración para no regresar.
184
+ return similar(r.duration, opts.duration, 0.1) && similar(r.distance, opts.distance, 0.1);
185
+ });
156
186
  cand.sort((a, b) => prioridadFuente(b.tipoAplication) - prioridadFuente(a.tipoAplication));
157
187
  return cand[0] || null;
158
188
  });
@@ -193,9 +223,9 @@ const registrarFuente = (q, idWorkout, tipo, idApp) => __awaiter(void 0, void 0,
193
223
  DO UPDATE SET "ID APLICATION" = EXCLUDED."ID APLICATION", "INGESTED AT" = NOW()`, [idWorkout, String(tipo), idApp !== null && idApp !== undefined ? String(idApp) : null]);
194
224
  });
195
225
  const aplicarMerge = (q, idWorkout, opts, esFilaNueva) => __awaiter(void 0, void 0, void 0, function* () {
196
- var _a, _b;
226
+ var _a, _b, _c;
197
227
  const [row] = yield q(`SELECT "DISTANCE", "DURATION", "POTENCIA", "DESNIVEL", "CALORIES", "FC MEDIA",
198
- "ZONE", "POLYLINE", "TIPO APLICATION", "ID APLICATION"
228
+ "ZONE", "POLYLINE", "TIPO APLICATION", "ID APLICATION", "START AT"
199
229
  FROM "WORKOUT" WHERE "ID" = ? FOR UPDATE`, [idWorkout]);
200
230
  const prioNueva = prioridadFuente(opts.aplicationType);
201
231
  const prioActual = esFilaNueva ? -1 : prioridadFuente(row === null || row === void 0 ? void 0 : row.tipoAplication);
@@ -210,6 +240,9 @@ const aplicarMerge = (q, idWorkout, opts, esFilaNueva) => __awaiter(void 0, void
210
240
  const zone = (0, paceZone_1.getZone)({ duration, distance, zones: cli });
211
241
  // POLYLINE: la primera fuente que la puso manda; nunca se pisa.
212
242
  const polyline = row && row.polyline ? row.polyline : getPolyline(opts.polyline);
243
+ // START AT: la primera fuente que lo puso manda; nunca se pisa (igual que
244
+ // POLYLINE). Es la clave del dedup cross-proveedor (solape de hora de inicio).
245
+ const startAt = row && row.startAt ? row.startAt : (_c = opts.startAt) !== null && _c !== void 0 ? _c : null;
213
246
  // TIPO/ID APLICATION: sólo se setean en INSERT o si la fila no tenía fuente;
214
247
  // nunca se reemplazan (la primera fuente se queda).
215
248
  let tipoFinal;
@@ -225,9 +258,9 @@ const aplicarMerge = (q, idWorkout, opts, esFilaNueva) => __awaiter(void 0, void
225
258
  const feelingsAsked = (0, moment_1.default)(opts.date).format('YYYY-MM-DD') !== (0, moment_1.default)().format('YYYY-MM-DD');
226
259
  yield q(`UPDATE "WORKOUT" SET
227
260
  "DISTANCE" = ?, "DURATION" = ?, "POTENCIA" = ?, "DESNIVEL" = ?, "CALORIES" = ?,
228
- "FC MEDIA" = ?, "ZONE" = ?, "POLYLINE" = ?, "TIPO APLICATION" = ?, "ID APLICATION" = ?,
261
+ "FC MEDIA" = ?, "ZONE" = ?, "POLYLINE" = ?, "START AT" = ?, "TIPO APLICATION" = ?, "ID APLICATION" = ?,
229
262
  "FEELINGS ASKED" = ?, "DONE" = TRUE
230
- WHERE "ID" = ?`, [distance, duration, potencia, desnivel, calories, fcMedia, zone, polyline, tipoFinal, idFinal, feelingsAsked, idWorkout]);
263
+ WHERE "ID" = ?`, [distance, duration, potencia, desnivel, calories, fcMedia, zone, polyline, startAt, tipoFinal, idFinal, feelingsAsked, idWorkout]);
231
264
  yield mergeStreams(q, idWorkout, opts.streams);
232
265
  });
233
266
  // =============================================================================
@@ -236,6 +269,7 @@ const aplicarMerge = (q, idWorkout, opts, esFilaNueva) => __awaiter(void 0, void
236
269
  /**
237
270
  * @param opts {{
238
271
  * date, type, title, description, distance, duration, power, desnivel, polyline,
272
+ * startAt, // ISO/Date de inicio (UTC) — clave del dedup cross-proveedor
239
273
  * idCliente, aplicationType, aplicationId,
240
274
  * queryConditionExist?, // LEGACY string SQL — deprecated, fallback
241
275
  * tiposEquivalentes?, // string[] de TYPEs equivalentes
@@ -248,7 +282,18 @@ const saveWorkoutAplication = (opts,
248
282
  _data) => __awaiter(void 0, void 0, void 0, function* () {
249
283
  const dateStr = (0, moment_1.default)(opts.date).format('YYYY-MM-DD');
250
284
  const pt = predicadoTipo(opts);
285
+ // Traducción FUERA de la sección crítica. getTitleDescriptionTranslated hace
286
+ // una llamada de red a Google Cloud Translation; si se ejecutara dentro de
287
+ // `withTx` retendría el advisory lock durante todo el round-trip (causa de los
288
+ // picos de varios minutos en pg_advisory_xact_lock cuando la API va lenta).
289
+ // Sólo depende de idCliente/title/description —nada de la transacción— así que
290
+ // se calcula antes de abrir la tx. Semántica intacta: si la traducción falla,
291
+ // se propaga el error y el workout no se guarda, igual que antes. En las rutas
292
+ // de merge (1 y 2) el valor se descarta; el único sobrecoste es una traducción
293
+ // extra para clientes no-ES con doble proveedor, a cambio de no bloquear el lock.
294
+ const translated = yield (0, titleDescriptionTranslated_1.getTitleDescriptionTranslated)(opts.idCliente, opts.title, opts.description);
251
295
  const idWorkout = yield withTx((q) => __awaiter(void 0, void 0, void 0, function* () {
296
+ var _d;
252
297
  // Lock anti-carrera: serializa ingestas del mismo cliente/día/bucket-tipo
253
298
  // (Strava-webhook y Garmin-push pueden entrar casi a la vez).
254
299
  yield q('SELECT pg_advisory_xact_lock(?)', [hashInt(`${opts.idCliente}|${dateStr}|${bucketTipo(opts.type)}`)]);
@@ -276,11 +321,12 @@ _data) => __awaiter(void 0, void 0, void 0, function* () {
276
321
  yield registrarFuente(q, sim.id, opts.aplicationType, opts.aplicationId);
277
322
  return sim.id;
278
323
  }
279
- // 3) INSERT nuevo (comportamiento actual).
280
- const { title: titleNoTranslate, description: descriptionNoTranslate, titlePreferredLanguage, descriptionPreferredLanguage } = yield (0, titleDescriptionTranslated_1.getTitleDescriptionTranslated)(opts.idCliente, opts.title, opts.description);
324
+ // 3) INSERT nuevo (comportamiento actual). La traducción ya se calculó antes
325
+ // de abrir la tx (ver `translated` arriba) para no retener el advisory lock.
326
+ const { title: titleNoTranslate, description: descriptionNoTranslate, titlePreferredLanguage, descriptionPreferredLanguage } = translated;
281
327
  const inserted = yield q(`INSERT INTO "WORKOUT"
282
- ("ID CLIENTE", "DATE", "TITLE", "DESCRIPTION", "TITLE PREFERRED LANGUAGE", "DESCRIPTION PREFERRED LANGUAGE", "TYPE", "DONE", "SHOW CLIENT")
283
- VALUES (?, ?, ?, ?, ?, ?, ?, TRUE, TRUE) RETURNING "ID"`, [opts.idCliente, dateStr, titleNoTranslate, descriptionNoTranslate, titlePreferredLanguage, descriptionPreferredLanguage, opts.type]);
328
+ ("ID CLIENTE", "DATE", "START AT", "TITLE", "DESCRIPTION", "TITLE PREFERRED LANGUAGE", "DESCRIPTION PREFERRED LANGUAGE", "TYPE", "DONE", "SHOW CLIENT")
329
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, TRUE, TRUE) RETURNING "ID"`, [opts.idCliente, dateStr, (_d = opts.startAt) !== null && _d !== void 0 ? _d : null, titleNoTranslate, descriptionNoTranslate, titlePreferredLanguage, descriptionPreferredLanguage, opts.type]);
284
330
  if (!inserted || !inserted[0])
285
331
  return null;
286
332
  const id = inserted[0].id;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runnerpro/backend",
3
- "version": "1.21.3",
3
+ "version": "1.21.5",
4
4
  "description": "A collection of common backend functions",
5
5
  "exports": {
6
6
  ".": "./lib/cjs/index.js"