@runnerpro/backend 1.31.0 → 1.31.2

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.
@@ -476,47 +476,61 @@ const sendFile = (req, res, { sendNotification, firebaseMessaging, isClient, buc
476
476
  let thumbnail;
477
477
  const { userid } = req.session;
478
478
  const filePath = path_1.default.join('./uploads', req.file.filename);
479
- if (req.file.mimetype.includes('video')) {
480
- duration = yield getVideoDuration(filePath);
481
- thumbnail = yield getThumbnailFromVideo(filePath, duration);
482
- }
483
- const [{ id: idFile }] = yield (0, index_1.query)('INSERT INTO [CHAT MESSAGE] ([ID CLIENTE], [ID SENDER], [TEXT], [MIMETYPE], [DURATION], [TYPE]) VALUES (?, ?, ?, ?, ?, ?) RETURNING [ID]', [isClient ? userid : idCliente, userid, req.file.originalname, req.file.mimetype, duration || null, type || 2]);
484
- // Logros: adjunto enviado por el cliente también cuenta como mensaje. Await
485
- // acotado para devolver los recién desbloqueados al FE en la respuesta (push).
479
+ // El adjunto de multer vive en ./uploads, que en Cloud Run es tmpfs: disco que es RAM.
480
+ // El unlink va en un finally porque antes solo se alcanzaba si TODO lo anterior salía
481
+ // bien: una subida a Storage o una query que fallara dejaban la foto, el audio o el
482
+ // vídeo —decenas de MB— ocupando memoria hasta el reinicio (la fuga de T-211).
483
+ let idFile;
486
484
  let logrosDesbloqueados = [];
487
- if (isClient) {
488
- logrosDesbloqueados = yield (0, index_1.evaluateAchievementsBounded)(userid, 'chat', {}, 1500);
485
+ try {
486
+ if (req.file.mimetype.includes('video')) {
487
+ duration = yield getVideoDuration(filePath);
488
+ thumbnail = yield getThumbnailFromVideo(filePath, duration);
489
+ }
490
+ [{ id: idFile }] = yield (0, index_1.query)('INSERT INTO [CHAT MESSAGE] ([ID CLIENTE], [ID SENDER], [TEXT], [MIMETYPE], [DURATION], [TYPE]) VALUES (?, ?, ?, ?, ?, ?) RETURNING [ID]', [isClient ? userid : idCliente, userid, req.file.originalname, req.file.mimetype, duration || null, type || 2]);
491
+ // Logros: adjunto enviado por el cliente también cuenta como mensaje. Await
492
+ // acotado para devolver los recién desbloqueados al FE en la respuesta (push).
493
+ if (isClient) {
494
+ logrosDesbloqueados = yield (0, index_1.evaluateAchievementsBounded)(userid, 'chat', {}, 1500);
495
+ }
496
+ const fileData = fs_1.default.readFileSync(filePath);
497
+ const files = [];
498
+ if (req.file.mimetype.includes('image')) {
499
+ try {
500
+ const fileDataResize = yield resizeImage(fileData, filePath, req.file.mimetype);
501
+ const fileDataOriented = yield rotateOrientationImage(fileData, filePath, null, req.file.mimetype);
502
+ files.push({ data: fileDataResize, id: idFile });
503
+ files.push({ data: fileDataOriented, id: `${idFile}-original` });
504
+ }
505
+ catch (error) {
506
+ (0, index_1.err)(null, null, error, null);
507
+ }
508
+ }
509
+ else if (req.file.mimetype.includes('video')) {
510
+ files.push({ data: fileData, id: idFile });
511
+ files.push({ data: thumbnail, id: `${idFile}-thumbnail` });
512
+ }
513
+ else {
514
+ files.push({ data: fileData, id: idFile });
515
+ }
516
+ res.send({ idFile, logrosDesbloqueados });
517
+ // Procesar el adjunto en background, sea del tipo que sea: transcripción, descripción, resumen
518
+ // o ficha del archivo. El router de mediaProcessing decide qué toca. Va ANTES de subir a Storage
519
+ // a propósito: si la subida falla, el mensaje se quedaría sin FILE TEXT de forma permanente y
520
+ // eso bloquea al entrenador, que no distingue "sin texto" de "aún sin procesar".
521
+ (0, mediaProcessing_1.processMediaFile)(idFile, fileData, req.file.mimetype, req.file.originalname);
522
+ for (const file of files) {
523
+ yield bucket.file(`Chat/${file.id}`).save(file.data);
524
+ }
489
525
  }
490
- const fileData = fs_1.default.readFileSync(filePath);
491
- const files = [];
492
- if (req.file.mimetype.includes('image')) {
526
+ finally {
493
527
  try {
494
- const fileDataResize = yield resizeImage(fileData, filePath, req.file.mimetype);
495
- const fileDataOriented = yield rotateOrientationImage(fileData, filePath, null, req.file.mimetype);
496
- files.push({ data: fileDataResize, id: idFile });
497
- files.push({ data: fileDataOriented, id: `${idFile}-original` });
528
+ fs_1.default.unlinkSync(filePath);
498
529
  }
499
- catch (error) {
500
- (0, index_1.err)(null, null, error, null);
530
+ catch (cleanupError) {
531
+ // Ya no está (o nunca llegó a escribirse): no hay nada que limpiar.
501
532
  }
502
533
  }
503
- else if (req.file.mimetype.includes('video')) {
504
- files.push({ data: fileData, id: idFile });
505
- files.push({ data: thumbnail, id: `${idFile}-thumbnail` });
506
- }
507
- else {
508
- files.push({ data: fileData, id: idFile });
509
- }
510
- res.send({ idFile, logrosDesbloqueados });
511
- // Procesar el adjunto en background, sea del tipo que sea: transcripción, descripción, resumen
512
- // o ficha del archivo. El router de mediaProcessing decide qué toca. Va ANTES de subir a Storage
513
- // a propósito: si la subida falla, el mensaje se quedaría sin FILE TEXT de forma permanente y
514
- // eso bloquea al entrenador, que no distingue "sin texto" de "aún sin procesar".
515
- (0, mediaProcessing_1.processMediaFile)(idFile, fileData, req.file.mimetype, req.file.originalname);
516
- for (const file of files) {
517
- yield bucket.file(`Chat/${file.id}`).save(file.data);
518
- }
519
- fs_1.default.unlinkSync(filePath);
520
534
  if (!isClient) {
521
535
  let textFile = 'Archivo adjunto';
522
536
  if (Number(type) === 4)
@@ -20,17 +20,43 @@ const common_1 = require("@runnerpro/common");
20
20
  const translation_1 = require("../translation");
21
21
  const path_1 = __importDefault(require("path"));
22
22
  const jimp_1 = __importDefault(require("jimp"));
23
- const uuidv4_1 = require("uuidv4");
24
- const fs_1 = __importDefault(require("fs"));
25
23
  // install fonts
26
24
  canvas_1.GlobalFonts.registerFromPath(path_1.default.join(__dirname, '../../..', 'static/fonts', 'SofiaSans-Bold.ttf'), 'SofiaSansBold');
27
25
  canvas_1.GlobalFonts.registerFromPath(path_1.default.join(__dirname, '../../..', 'static/fonts', 'SofiaSans-Regular.ttf'), 'SofiaSans');
26
+ /**
27
+ * Genera la imagen cuadrada (1080x1080) para compartir un entreno: la foto/mapa
28
+ * de fondo con los datos del entreno (distancia, tiempo, ritmo o desnivel) superpuestos.
29
+ *
30
+ * @param image - Buffer de la imagen de fondo (mapa del entreno o foto por defecto).
31
+ * @param idWorkout - ID del entreno del que se leen los datos y el idioma de su cliente.
32
+ * @param options - `useDefaultPhoto: true` cuando el fondo es la foto genérica (invierte el degradado superior).
33
+ * @returns Buffer JPEG con la imagen, o `null` si no se pudieron leer el entreno o su cliente.
34
+ *
35
+ * @example
36
+ * ```typescript
37
+ * const shareMap = await generateShareMap(image, idWorkout, { useDefaultPhoto: true });
38
+ * if (!shareMap) return; // ⚠️ SIEMPRE comprobar el null antes de subirla
39
+ * await bucket.file(`Workout/${idWorkout}-share`).save(shareMap);
40
+ * ```
41
+ */
28
42
  const generateShareMap = (image, idWorkout, options = {}) => __awaiter(void 0, void 0, void 0, function* () {
29
43
  const useDefaultPhoto = (options === null || options === void 0 ? void 0 : options.useDefaultPhoto) || false;
30
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.
49
+ if (!workout) {
50
+ // 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.`);
52
+ return null;
53
+ }
31
54
  const [cliente] = yield (0, index_1.query)('SELECT [PREFERRED LANGUAGE] FROM [CLIENTE] WHERE [ID] = ?', [workout.idCliente]);
32
- if (!workout || !cliente)
55
+ if (!cliente) {
56
+ // 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.`);
33
58
  return null;
59
+ }
34
60
  const width = 1080;
35
61
  const height = 1080;
36
62
  const canvas = (0, canvas_1.createCanvas)(width, height);
@@ -131,13 +157,13 @@ const addShadowShareWorkoutmap = (context, useDefaultPhoto) => __awaiter(void 0,
131
157
  context.fillRect(0, 1080 - shadowHeight, 1080, shadowHeight);
132
158
  });
133
159
  const addImageShareWorkoutmap = (context, data) => __awaiter(void 0, void 0, void 0, function* () {
160
+ // Sin fichero temporal: antes el JPEG intermedio pasaba por ./uploads y el unlink
161
+ // solo llegaba si el render entero salía bien — un fallo por medio lo dejaba
162
+ // huérfano, y en Cloud Run ./uploads es tmpfs, o sea RAM que no se recupera hasta
163
+ // el reinicio (la fuga de T-211). loadImage acepta el Buffer directamente.
134
164
  const image = yield jimp_1.default.read(data);
135
- const filename2 = `share-${(0, uuidv4_1.uuid)()}.jpeg`;
136
- const uploadsDir = path_1.default.resolve(process.cwd(), 'uploads');
137
- yield image.cover(1080, 1080).writeAsync(path_1.default.join(uploadsDir, filename2));
138
- const imagePath2 = path_1.default.join(uploadsDir, filename2);
139
- const image2 = yield (0, canvas_1.loadImage)(imagePath2);
165
+ const buffer = yield image.cover(1080, 1080).getBufferAsync(jimp_1.default.MIME_JPEG);
166
+ const image2 = yield (0, canvas_1.loadImage)(buffer);
140
167
  context.drawImage(image2, 0, 0, 1080, 1080);
141
168
  context.fillStyle = 'rgba(0, 0, 0, 0.5)';
142
- yield fs_1.default.promises.unlink(imagePath2);
143
169
  });
@@ -13,40 +13,30 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
13
13
  };
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.reduceSizeImage = void 0;
16
- const util_1 = require("util");
17
16
  const jimp_1 = __importDefault(require("jimp"));
18
17
  const fs_1 = __importDefault(require("fs"));
19
- const uuid_1 = require("uuid");
20
- const path_1 = __importDefault(require("path"));
21
18
  const exifr_1 = __importDefault(require("exifr"));
19
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
20
+ const sizeOf = require('image-size');
22
21
  const reduceSizeImage = (file, newWidth = 100, quality = 80) => __awaiter(void 0, void 0, void 0, function* () {
23
22
  try {
24
- let filePath;
25
- if (typeof file === 'object') {
26
- // Write this image to a file in uploads
27
- const uid = (0, uuid_1.v4)();
28
- filePath = path_1.default.join(process.cwd(), 'uploads', `${uid}.webp`);
29
- fs_1.default.writeFileSync(filePath, file);
30
- }
31
- else {
32
- // If it's not an object, it's already a file path
33
- filePath = file;
34
- }
35
- // eslint-disable-next-line @typescript-eslint/no-var-requires
36
- const sizeOf = (0, util_1.promisify)(require('image-size'));
37
- const dimensions = yield sizeOf(filePath);
23
+ // Todo en memoria, sin fichero temporal. Antes esto escribía el buffer en ./uploads
24
+ // para medirlo y leerlo desde disco, y solo lo borraba si TODO salía bien: con un
25
+ // formato que Jimp no lee (el HEIC de los iPhone, sin ir más lejos) se saltaba al
26
+ // catch y el fichero quedaba huérfano. En Cloud Run ./uploads es tmpfs —disco que
27
+ // es RAM— así que cada foto fallida eran MiB de memoria perdidos hasta el reinicio
28
+ // (la fuga de T-211). Jimp e image-size aceptan el Buffer directamente.
29
+ const source = typeof file === 'object' ? file : fs_1.default.readFileSync(file);
30
+ const dimensions = sizeOf(source);
38
31
  const imageWidth = dimensions.width;
39
32
  const imageHeight = dimensions.height;
40
33
  const newHeight = Math.round((imageHeight * newWidth) / imageWidth);
41
- let image = yield jimp_1.default.read(filePath);
34
+ let image = yield jimp_1.default.read(source);
42
35
  image = yield rotateOrientationImage(image, file);
43
36
  image = yield image.resize(newWidth, newHeight);
44
37
  image = yield image.quality(quality);
45
38
  // @ts-ignore
46
- image = yield image.getBufferAsync(jimp_1.default.MIME_JPEG);
47
- if (typeof file === 'object')
48
- fs_1.default.unlinkSync(filePath);
49
- return image;
39
+ return yield image.getBufferAsync(jimp_1.default.MIME_JPEG);
50
40
  }
51
41
  catch (error) {
52
42
  if (typeof file === 'object')
@@ -1 +1 @@
1
- {"version":3,"file":"conversation.d.ts","sourceRoot":"","sources":["../../../../../src/chat/api/conversation.ts"],"names":[],"mappings":"AAmBA,QAAA,MAAM,iBAAiB,0BAA2B,GAAG,SA0BpD,CAAC;AAoGF;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,QAAA,MAAM,uBAAuB,qCAAkC,GAAG,iBAwCjE,CAAC;AAmGF;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,QAAA,MAAM,WAAW,0EAAuE,GAAG,kBA0E1F,CAAC;AAEF,QAAA,MAAM,gBAAgB;;;;mBAqBrB,CAAC;AA2TF,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,WAAW,EAAE,uBAAuB,EAAE,CAAC"}
1
+ {"version":3,"file":"conversation.d.ts","sourceRoot":"","sources":["../../../../../src/chat/api/conversation.ts"],"names":[],"mappings":"AAmBA,QAAA,MAAM,iBAAiB,0BAA2B,GAAG,SA0BpD,CAAC;AAoGF;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,QAAA,MAAM,uBAAuB,qCAAkC,GAAG,iBAwCjE,CAAC;AAmGF;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,QAAA,MAAM,WAAW,0EAAuE,GAAG,kBA0E1F,CAAC;AAEF,QAAA,MAAM,gBAAgB;;;;mBAqBrB,CAAC;AAuUF,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,WAAW,EAAE,uBAAuB,EAAE,CAAC"}
@@ -1,4 +1,20 @@
1
1
  /// <reference types="node" />
2
+ /**
3
+ * Genera la imagen cuadrada (1080x1080) para compartir un entreno: la foto/mapa
4
+ * de fondo con los datos del entreno (distancia, tiempo, ritmo o desnivel) superpuestos.
5
+ *
6
+ * @param image - Buffer de la imagen de fondo (mapa del entreno o foto por defecto).
7
+ * @param idWorkout - ID del entreno del que se leen los datos y el idioma de su cliente.
8
+ * @param options - `useDefaultPhoto: true` cuando el fondo es la foto genérica (invierte el degradado superior).
9
+ * @returns Buffer JPEG con la imagen, o `null` si no se pudieron leer el entreno o su cliente.
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * const shareMap = await generateShareMap(image, idWorkout, { useDefaultPhoto: true });
14
+ * if (!shareMap) return; // ⚠️ SIEMPRE comprobar el null antes de subirla
15
+ * await bucket.file(`Workout/${idWorkout}-share`).save(shareMap);
16
+ * ```
17
+ */
2
18
  declare const generateShareMap: (image: any, idWorkout: any, options?: {}) => Promise<Buffer>;
3
19
  export { generateShareMap };
4
20
  //# sourceMappingURL=generateShareMap.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"generateShareMap.d.ts","sourceRoot":"","sources":["../../../../src/image/generateShareMap.ts"],"names":[],"mappings":";AAcA,QAAA,MAAM,gBAAgB,+DAkBrB,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,+DA+BrB,CAAC;AA0HF,OAAO,EAAE,gBAAgB,EAAE,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"reduceSizeImage.d.ts","sourceRoot":"","sources":["../../../../src/image/reduceSizeImage.ts"],"names":[],"mappings":"AAOA,QAAA,MAAM,eAAe,kEAqCpB,CAAC;AAaF,OAAO,EAAE,eAAe,EAAE,CAAC"}
1
+ {"version":3,"file":"reduceSizeImage.d.ts","sourceRoot":"","sources":["../../../../src/image/reduceSizeImage.ts"],"names":[],"mappings":"AAOA,QAAA,MAAM,eAAe,kEA4BpB,CAAC;AAaF,OAAO,EAAE,eAAe,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;AAuEF,OAAO,EAAE,qBAAqB,EAAE,CAAC"}
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"}
@@ -384,7 +384,13 @@ const postHooks = (idWorkout, opts) => __awaiter(void 0, void 0, void 0, functio
384
384
  catch (_) {
385
385
  // Un fallo de Mapbox no debe romper la ingesta (estado ya persistido).
386
386
  }
387
- saveShareWorkoutImage(idWorkout, opts.type);
387
+ try {
388
+ yield saveShareWorkoutImage(idWorkout, opts.type);
389
+ }
390
+ catch (_) {
391
+ // La imagen de compartir es accesoria: si falla (Storage, canvas, BD) no debe
392
+ // romper la ingesta ni escapar como unhandled rejection (así llegaba a Sentry).
393
+ }
388
394
  yield (0, estructuraWorkout_1.saveDoneStructuraWorkout)(idWorkout);
389
395
  // Logros: evaluar al completar el entreno (este es el embudo de los entrenos
390
396
  // sincronizados). Se pasa el idWorkout para calcular el PR solo de ESTE entreno
@@ -438,6 +444,10 @@ const saveShareWorkoutImage = (id, type) => __awaiter(void 0, void 0, void 0, fu
438
444
  else
439
445
  [image] = yield storage.bucket(process.env.CLOUD_STORAGE_BUCKET_PUBLIC).file(`Workout/${id}`).download();
440
446
  const shareMap = yield (0, generateShareMap_1.generateShareMap)(image, id);
447
+ // generateShareMap devuelve null si no pudo leer el entreno o su cliente (ya deja
448
+ // traza del motivo): sin imagen no hay nada que subir ni URL que guardar.
449
+ if (!shareMap)
450
+ return;
441
451
  // @ts-ignore
442
452
  yield storage.bucket(process.env.CLOUD_STORAGE_BUCKET_PUBLIC).file(`Workout/${id}-share`).save(shareMap);
443
453
  const urlShare = `https://storage.googleapis.com/${process.env.CLOUD_STORAGE_BUCKET_PUBLIC}/Workout/${id}-share`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runnerpro/backend",
3
- "version": "1.31.0",
3
+ "version": "1.31.2",
4
4
  "description": "A collection of common backend functions",
5
5
  "exports": {
6
6
  ".": "./lib/cjs/index.js"
@@ -17,6 +17,7 @@
17
17
  "semantic-release": "semantic-release",
18
18
  "lint": "eslint --ext .ts --ignore-path .gitignore .",
19
19
  "test:translate-workout-garmin": "ts-node --transpile-only scripts/translateWorkoutGarmin.ts",
20
+ "test:generate-share-map-guard": "ts-node --transpile-only -P configs/tsconfig.cjs.json scripts/testGenerateShareMapGuard.ts",
20
21
  "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\"",
21
22
  "prepare": "husky"
22
23
  },