@runnerpro/backend 1.21.16 → 1.22.1

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.
@@ -24,12 +24,12 @@ const transporter = nodemailer_1.default.createTransport({
24
24
  greetingTimeout: 10000,
25
25
  socketTimeout: 20000,
26
26
  });
27
- const sendMail = ({ subject, title, body, to, link, attachments, bcc }) => __awaiter(void 0, void 0, void 0, function* () {
27
+ const sendMail = ({ subject, title, body, to, link, attachments, bcc, signoff }) => __awaiter(void 0, void 0, void 0, function* () {
28
28
  var _a;
29
29
  const toMapped = process.env.NODE_ENV === 'PROD' ? to.map((correo) => correo).join(',') : process.env.GMAIL_EMAIL_USER;
30
30
  if ((toMapped === null || toMapped === void 0 ? void 0 : toMapped.length) === 0)
31
31
  return;
32
- const bodyHTML = getBodyHTML(title, body, link);
32
+ const bodyHTML = getBodyHTML(title, body, link, signoff);
33
33
  const sendPromise = transporter.sendMail({
34
34
  from: `"RunnerPro" <${process.env.GMAIL_EMAIL_USER}>`,
35
35
  to: toMapped,
@@ -37,14 +37,7 @@ const sendMail = ({ subject, title, body, to, link, attachments, bcc }) => __awa
37
37
  subject,
38
38
  text: body + (link ? link.url : ''),
39
39
  html: bodyHTML,
40
- attachments: [
41
- ...(attachments || []),
42
- {
43
- filename: 'logo-large.png',
44
- path: 'https://storage.googleapis.com/assets_runnerpro/logo-large-nuevo.png',
45
- cid: 'logo',
46
- },
47
- ],
40
+ attachments: attachments || [],
48
41
  });
49
42
  const transport = (_a = transporter.transporter) !== null && _a !== void 0 ? _a : transporter;
50
43
  const errorPromise = new Promise((_, reject) => {
@@ -71,63 +64,47 @@ const sendMail = ({ subject, title, body, to, link, attachments, bcc }) => __awa
71
64
  return Promise.race([sendPromise, errorPromise, timeoutPromise]);
72
65
  });
73
66
  exports.sendMail = sendMail;
74
- function getBodyHTML(title, body, link) {
67
+ function getBodyHTML(title, body, link, signoff) {
68
+ // Estilo: correo personal, de founder a runner. Fondo blanco, sin cabecera
69
+ // negra ni tarjeta; Helvetica, texto a la izquierda, mucho aire, CTA como
70
+ // enlace de texto y firma personal al pie (como las newsletters de Cristóbal).
71
+ const FONT = 'Helvetica, Arial, sans-serif';
72
+ const INK = '#2c2c2b';
73
+ const MUTED = '#8a8a86';
74
+ const firma = signoff || '';
75
+ const greetingHTML = title
76
+ ? `<p style="margin:0 0 20px 0;font-family:${FONT};font-size:16px;line-height:26px;color:${INK};">${title}</p>`
77
+ : '';
78
+ const bodyHTML = `<div style="font-family:${FONT};font-size:16px;line-height:26px;color:${INK};">${body
79
+ .split('\n')
80
+ .join('<br>')}</div>`;
81
+ const linkHTML = link
82
+ ? `<p style="margin:26px 0 0 0;font-family:${FONT};font-size:16px;line-height:26px;">
83
+ <a href="${link.external ? '' : process.env.FRONTEND_URL}${link.url}" style="color:${INK};font-weight:bold;text-decoration:underline;">${link.text} &rarr;</a>
84
+ </p>`
85
+ : '';
86
+ const signoffHTML = firma
87
+ ? `<p style="margin:26px 0 0 0;font-family:${FONT};font-size:16px;line-height:26px;font-style:italic;color:${MUTED};">${firma}</p>`
88
+ : '';
75
89
  return `
76
90
  <html lang="es">
77
91
  <head>
78
92
  <meta charset="UTF-8">
79
93
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
80
- <title>Correo con estilo</title>
81
- <style>
82
- body {
83
- margin: 0;
84
- padding: 0;
85
- }
86
- .button {
87
- display: inline-block;
88
- padding: 12px 26px;
89
- font-size: 17px;
90
- text-align: center;
91
- text-decoration: none;
92
- background-color: #000000;
93
- color: #ffffff !important;
94
- margin: 40px 0 20px 0;
95
- font-family: 'Sofia Sans', 'Roboto', sans-serif;
96
- font-weight: bold;
97
- border-radius: 4px;
98
- border: 2px solid #000000;
99
- }
100
- </style>
94
+ <title>RunnerPro</title>
101
95
  </head>
102
- <body style="background: #f0f0f0; padding: 0 0 40px 0">
103
- <table role="presentation" style="width:100%;border-collapse:collapse;border:0;border-spacing:0;background:#f0f0f0">
96
+ <body style="margin:0;padding:0;background:#ffffff;">
97
+ <table role="presentation" width="100%" style="border-collapse:collapse;border:0;border-spacing:0;background:#ffffff;">
104
98
  <tr>
105
- <td align="center" style="padding:0;">
106
- <table role="presentation" style="width:100%;border-collapse:collapse;border-spacing:0;text-align:left;">
99
+ <td align="center" style="padding:32px 16px 48px 16px;">
100
+ <table role="presentation" width="560" style="width:560px;max-width:100%;border-collapse:collapse;border:0;border-spacing:0;text-align:left;">
107
101
  <tr>
108
- <td align="center" style="padding:5px 0 0 0;background:#000000;line-height:0">
109
- <img src="cid:logo" alt="" width="240" style="height:auto;display:block;">
110
- </td>
111
- </tr>
112
- <tr>
113
- <td style="padding:20px 24px 0px 24px; max-width: 600px" align="center">
114
- <h1 style="font-size:22px;text-align:center;margin:16px 0 6px 0;font-family:'Sofia Sans', 'Roboto', sans-serif;font-style: italic">
115
- ${title || ''}
116
- </h1>
117
- <p style="text-align:left;margin:0 0 12px 0;font-size:15px;line-height:24px;font-family:'Sofia Sans', 'Roboto', sans-serif;">
118
- ${body.split('\n').join('<br>')}
119
- </p>
120
- ${link
121
- ? `
122
- <table role="presentation" style="width:100%;border-collapse:collapse;border:0;border-spacing:0;background:#f0f0f0;">
123
- <tr>
124
- <td align="center" style="padding:0;">
125
- <a href="${link.external ? '' : process.env.FRONTEND_URL}${link.url}" class="button" style="color:white">${link.text}</a>
126
- </td>
127
- </tr>
128
- </table>
129
- `
130
- : ''}
102
+ <td style="padding:0;">
103
+ ${greetingHTML}
104
+ ${bodyHTML}
105
+ ${linkHTML}
106
+ ${signoffHTML}
107
+ <p style="margin:40px 0 0 0;font-family:${FONT};font-size:14px;color:${MUTED};">RunnerPro</p>
131
108
  </td>
132
109
  </tr>
133
110
  </table>
@@ -6,6 +6,7 @@ interface Mail {
6
6
  link?: {
7
7
  url: string;
8
8
  text: string;
9
+ external?: boolean;
9
10
  };
10
11
  attachments?: {
11
12
  filename: string;
@@ -13,7 +14,13 @@ interface Mail {
13
14
  cid?: string;
14
15
  }[];
15
16
  bcc?: string[];
17
+ /**
18
+ * Firma personal al pie (estilo newsletter de Cristóbal), p. ej.
19
+ * "Cristóbal, CEO de RunnerPro". Por defecto no se firma. Solo se muestra
20
+ * si el correo pasa explícitamente este campo (correos de cliente).
21
+ */
22
+ signoff?: string;
16
23
  }
17
- declare const sendMail: ({ subject, title, body, to, link, attachments, bcc }: Mail) => Promise<import("nodemailer/lib/smtp-transport").SentMessageInfo>;
24
+ declare const sendMail: ({ subject, title, body, to, link, attachments, bcc, signoff }: Mail) => Promise<import("nodemailer/lib/smtp-transport").SentMessageInfo>;
18
25
  export { sendMail };
19
26
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/sendMail/index.ts"],"names":[],"mappings":"AAaA,UAAU,IAAI;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,EAAE,CAAC;IACb,IAAI,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;IACrC,WAAW,CAAC,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACjE,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;CAChB;AAED,QAAA,MAAM,QAAQ,yDAAgE,IAAI,qEA6CjF,CAAC;AAuEF,OAAO,EAAE,QAAQ,EAAE,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/sendMail/index.ts"],"names":[],"mappings":"AAaA,UAAU,IAAI;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,EAAE,CAAC;IACb,IAAI,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;IACzD,WAAW,CAAC,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACjE,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;IACf;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,QAAA,MAAM,QAAQ,kEAAyE,IAAI,qEAsC1F,CAAC;AAiEF,OAAO,EAAE,QAAQ,EAAE,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"getPlanificacionPrueba7diasEmpezarCorrer.d.ts","sourceRoot":"","sources":["../../../../../../src/workout/planificacionPrueba7dias/empezarCorrer/getPlanificacionPrueba7diasEmpezarCorrer.ts"],"names":[],"mappings":"AAIA,QAAA,MAAM,wCAAwC;;;;;;;;;;;;;GAK7C,CAAC;AAEF,QAAA,MAAM,4CAA4C,kBAQjD,CAAC;AAEF,OAAO,EAAE,wCAAwC,EAAE,4CAA4C,EAAE,CAAC"}
1
+ {"version":3,"file":"getPlanificacionPrueba7diasEmpezarCorrer.d.ts","sourceRoot":"","sources":["../../../../../../src/workout/planificacionPrueba7dias/empezarCorrer/getPlanificacionPrueba7diasEmpezarCorrer.ts"],"names":[],"mappings":"AAIA,QAAA,MAAM,wCAAwC;;;;;;;;;;;;;GAK7C,CAAC;AAEF,QAAA,MAAM,4CAA4C,kBAgCjD,CAAC;AAEF,OAAO,EAAE,wCAAwC,EAAE,4CAA4C,EAAE,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/workout/planificacionPrueba7dias/index.ts"],"names":[],"mappings":"AAaA;;;;;;;;;;;;;;;;;;GAkBG;AACH,QAAA,MAAM,2BAA2B,qBAAgB,GAAG,UAkEnD,CAAC;AAmIF,OAAO,EAAE,2BAA2B,EAAE,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/workout/planificacionPrueba7dias/index.ts"],"names":[],"mappings":"AAaA;;;;;;;;;;;;;;;;;;GAkBG;AACH,QAAA,MAAM,2BAA2B,qBAAgB,GAAG,UA+EnD,CAAC;AAmIF,OAAO,EAAE,2BAA2B,EAAE,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"saveWorkoutAplication.d.ts","sourceRoot":"","sources":["../../../../src/workout/saveWorkoutAplication.ts"],"names":[],"mappings":"AA4SA;;;;;;;;;;GAUG;AACH,QAAA,MAAM,qBAAqB,SACnB,GAAG,UAED,GAAG,KACV,QAAQ,MAAM,GAAG,IAAI,CAsEvB,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;AAuEF,OAAO,EAAE,qBAAqB,EAAE,CAAC"}
@@ -15,14 +15,33 @@ const getPlanificacionPrueba7diasEmpezarCorrer = (c) => {
15
15
  };
16
16
  exports.getPlanificacionPrueba7diasEmpezarCorrer = getPlanificacionPrueba7diasEmpezarCorrer;
17
17
  const changeGoalDistanceByNivelActualEmpezarCorrer = (c) => {
18
- if (c.objetivoPrincipal === 'Correr después de lesión' || Number(c.goalDistance) === -2) {
19
- c.type = '3';
18
+ // A5 El mapeo estaba invertido y con un typo: la vuelta de lesión (el perfil más frágil)
19
+ // caía en el nivel MÁS duro (2x8'), porque se comparaba contra 'Correr después de lesión'
20
+ // (sin "una", valor que nunca casaba con el del formulario) y, vía -2, caía en type '3'.
21
+ // Además el nivel más suave (3x1') era inalcanzable.
22
+ //
23
+ // Capa 3 — Si el formulario web aportó el AGUANTE corriendo (F3), la progresión CaCos se
24
+ // gradúa con él; y el ESTADO de la lesión (F7) pone techo (con dolor nunca por encima del
25
+ // nivel suave). Sin esos datos (cohorte app): el frágil y el principiante absoluto arrancan
26
+ // en el nivel suave (type '1'); el resto en el intermedio (type '2').
27
+ const NIVEL_POR_AGUANTE = { lt5: 1, '5-20': 2, '20-45': 2, gt45: 3 };
28
+ const TECHO_POR_LESION = { duele: 1, leve: 2, prudencia: 2, recuperado: 3 };
29
+ let nivel;
30
+ if (NIVEL_POR_AGUANTE[c.aguanteCorriendo] !== undefined) {
31
+ nivel = NIVEL_POR_AGUANTE[c.aguanteCorriendo];
20
32
  }
21
- else if (Number(c.goalDistance) === -1) {
22
- c.type = '1';
33
+ else if (c.objetivoPrincipal === 'Correr después de una lesión' ||
34
+ c.objetivoPrincipal === 'Comenzar a correr' ||
35
+ Number(c.goalDistance) === -2) {
36
+ nivel = 1;
23
37
  }
24
38
  else {
25
- c.type = '2';
39
+ nivel = 2;
26
40
  }
41
+ if (c.objetivoPrincipal === 'Correr después de una lesión') {
42
+ const techo = TECHO_POR_LESION[c.estadoLesion] !== undefined ? TECHO_POR_LESION[c.estadoLesion] : 1;
43
+ nivel = Math.min(nivel, techo);
44
+ }
45
+ c.type = String(nivel);
27
46
  };
28
47
  exports.changeGoalDistanceByNivelActualEmpezarCorrer = changeGoalDistanceByNivelActualEmpezarCorrer;
@@ -40,14 +40,28 @@ const getPlanificacionPrueba7dias = (c, options = {}) => {
40
40
  // @ts-ignore
41
41
  workoutsBase = getWorkoutsByGoalDistance(c);
42
42
  if (!workoutsBase || workoutsBase.length === 0) {
43
- workoutsBase = [];
43
+ // El aviso se mantiene como señal de que un objetivo se ha quedado sin plantilla propia.
44
44
  (0, sendMail_1.sendMail)({
45
45
  to: ['david.jimenez@runnerpro.app'],
46
46
  subject: 'Planificación de prueba 7 días - No hay entrenamientos',
47
47
  body: JSON.stringify(c),
48
48
  title: '',
49
49
  });
50
+ // Fallback — nunca dejar al cliente sin plantilla base ('Correr una distancia/carrera'
51
+ // sin distancia elegida, 'Ciclismo', textos de objetivo futuros…): vuelta de lesión (o
52
+ // similar) → progresión CaCos; resto → plan 10k por nivel, el mismo default que
53
+ // 'Entrenamiento general'.
54
+ c.objetivoPrincipal = /lesi/i.test(String(c.objetivoPrincipal || '')) ? 'Correr después de una lesión' : 'Entrenamiento general';
55
+ c.goalDistance = -1;
56
+ changeGoalDistanceByNivelActual(c);
57
+ // @ts-ignore
58
+ workoutsBase = getWorkoutsByGoalDistance(c) || [];
50
59
  }
60
+ // Sin plantilla no hay nada que repartir: mejor semana vacía explícita que rellenar los
61
+ // días disponibles con stubs { date } sin contenido (aguas abajo acababan siendo filas
62
+ // WORKOUT sin título ni duración) o solo con la sesión de fuerza genérica.
63
+ if (workoutsBase.length === 0)
64
+ return [];
51
65
  if ((Number(c.nivelEntrenamientoFuerza) === 1 || Number(c.incluirEntrenamientoFuerza) === 1) && nDiasDisponibles >= 4) {
52
66
  // @ts-ignore
53
67
  workoutsBase.splice(1, 0, addWorkoutFuerza());
@@ -98,7 +112,7 @@ const getPlanificacionPrueba7dias = (c, options = {}) => {
98
112
  };
99
113
  exports.getPlanificacionPrueba7dias = getPlanificacionPrueba7dias;
100
114
  const changeGoalDistanceByNivelActual = (c) => {
101
- if (['Comenzar a correr', 'Correr después de lesión'].includes(c.objetivoPrincipal) || [-2].includes(Number(c.goalDistance)))
115
+ if (['Comenzar a correr', 'Correr después de una lesión'].includes(c.objetivoPrincipal) || [-2].includes(Number(c.goalDistance)))
102
116
  (0, getPlanificacionPrueba7diasEmpezarCorrer_1.changeGoalDistanceByNivelActualEmpezarCorrer)(c);
103
117
  else if (Number(c.goalDistance) > 1 && Number(c.goalDistance) < 8000)
104
118
  (0, getPlanificacionPrueba7dias5k_1.changeGoalDistanceByNivelActual5k)(c);
@@ -113,7 +127,7 @@ const changeGoalDistanceByNivelActual = (c) => {
113
127
  (0, getPlanificacionPrueba7dias42k_1.changeGoalDistanceByNivelActual42k)(c);
114
128
  };
115
129
  const getWorkoutsByGoalDistance = (c) => {
116
- if (['Comenzar a correr', 'Correr después de lesión'].includes(c.objetivoPrincipal) || Number(c.goalDistance) === -2)
130
+ if (['Comenzar a correr', 'Correr después de una lesión'].includes(c.objetivoPrincipal) || Number(c.goalDistance) === -2)
117
131
  return (0, getPlanificacionPrueba7diasEmpezarCorrer_1.getPlanificacionPrueba7diasEmpezarCorrer)(c);
118
132
  if (Number(c.goalDistance) === 5000)
119
133
  return (0, getPlanificacionPrueba7dias5k_1.getPlanificacionPrueba7dias5k)(c);
@@ -160,6 +160,16 @@ const VENTANA_INICIO_MS = 30 * 60 * 1000;
160
160
  // terminar, o redondea la duración). 2h la absorben; si aun así no solapan, son
161
161
  // dos entrenos distintos y se respetan como dobles.
162
162
  const VENTANA_INICIO_MANUAL_MS = 2 * 60 * 60 * 1000;
163
+ // Duración mínima de un entreno importado (anti "entrenos residuales"). Un
164
+ // arranque accidental del reloj/app (p.ej. re-lanzar la rutina al acabar y
165
+ // pararla al minuto) genera en el proveedor una actividad REAL de segundos con
166
+ // UUID propio: el dedup no puede fusionarla (es otra actividad, no solapa) y
167
+ // acababa como "hecho" duplicado del día con el mismo título de rutina, o
168
+ // completando un planificado con 60 s. Por debajo de 2 min no existe una
169
+ // sesión de entrenamiento legítima; se descarta en este embudo común para
170
+ // cubrir TODAS las fuentes (Apple/Strava/Garmin). El completado MANUAL de la
171
+ // app NO pasa por aquí, así que no se ve afectado.
172
+ const DURACION_MINIMA_INGESTA_S = 120;
163
173
  const inicioSolapa = (a, b, ventanaMs = VENTANA_INICIO_MS) => {
164
174
  if (!a || !b)
165
175
  return false;
@@ -298,6 +308,12 @@ const aplicarMerge = (q, idWorkout, opts, esFilaNueva) => __awaiter(void 0, void
298
308
  const saveWorkoutAplication = (opts,
299
309
  // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
300
310
  _data) => __awaiter(void 0, void 0, void 0, function* () {
311
+ // Entreno residual: se ignora ANTES de traducir y de abrir la tx. Sólo aplica
312
+ // cuando la fuente informa duración; sin ella no se puede juzgar y se deja pasar.
313
+ const duracionIngesta = Number(opts.duration);
314
+ if (Number.isFinite(duracionIngesta) && duracionIngesta > 0 && duracionIngesta < DURACION_MINIMA_INGESTA_S) {
315
+ return null;
316
+ }
301
317
  const dateStr = (0, moment_1.default)(opts.date).format('YYYY-MM-DD');
302
318
  const pt = predicadoTipo(opts);
303
319
  // Traducción FUERA de la sección crítica. getTitleDescriptionTranslated hace
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runnerpro/backend",
3
- "version": "1.21.16",
3
+ "version": "1.22.1",
4
4
  "description": "A collection of common backend functions",
5
5
  "exports": {
6
6
  ".": "./lib/cjs/index.js"