63 Commits

Author SHA1 Message Date
horacio 84e17e2a32 feat: actualizar la versión de @whiskeysockets/baileys y ajustar .gitignore 2026-10-21 09:37:03 -03:00
horacio 7a97e278a2 feat: ajustar el manejo de meses en filtros y cálculos de balances para compatibilidad 0-indexada 2026-10-19 10:18:26 -03:00
horacio 215a5b28f4 feat: mejorar la funcionalidad del selector de tiempo al copiar citas 2026-10-08 11:35:13 -03:00
horacio 1438585a0a TXBOX - refactor: update package configuration and improve error handling
- Changed package.json to use "tsx" instead of "ts-node" for development.
- Updated "@whiskeysockets/baileys" dependency to version 6.7.24.
- Added error handling for unhandled rejections and uncaught exceptions in server.ts.
- Implemented a reconnect mechanism for WhatsApp connection failures.
- Updated tsconfig.json to use "NodeNext" module and resolution for ESM compatibility.
2026-10-06 15:39:28 -03:00
horacio 0d6231a02e feat: agregar lógica para evitar duplicados de clientes por email y vincular usuarios existentes 2026-10-06 11:52:24 -03:00
horacio 13ba0fa4de feat: integrar el uso de bottomToolbar en ClientCreate para gestionar la barra inferior 2026-10-04 23:05:10 -03:00
horacio 2df20233d4 feat: implementar generación de metadatos para servicios con soporte para SEO y datos estructurados 2026-09-26 12:05:45 -03:00
horacio 1a9de06393 feat: normalizar la hora de finalización de citas que cruzan la medianoche y ajustar la lógica de ordenamiento en CalendarRowView 2026-09-25 10:04:16 -03:00
horacio ec083ddf42 feat: ajustar la lógica de recordatorios automáticos para respetar las horas de silencio y la zona horaria 2026-09-25 09:30:45 -03:00
horacio fe9eb52a6a feat: agregar reglas de protección para archivos .env en AGENTS.md 2026-09-22 23:17:30 -03:00
horacio 392156fd31 feat: mejorar el componente FinancialWidget con un encabezado más accesible y limpio 2026-09-22 11:32:47 -03:00
horacio f5a2c0774e feat: integrate bottom toolbar store in various organization profile pages
- Added bottom toolbar functionality to the Schedules, Services, Templates, and WAP pages.
- Ensured the bottom toolbar is cleared on component unmount.
- Improved code formatting and consistency across components.
- Minor adjustments to state management and event handling for better clarity and performance.
2026-09-22 11:20:53 -03:00
horacio 0dd2998f5c feat: eliminar la clase paginationWrapper y ajustar la lógica de paginación en los componentes de movimientos y clientes 2026-09-22 10:58:14 -03:00
horacio 97943fa4a9 feat: eliminar componente Filterable y ajustar la funcionalidad de filtrado en los componentes de ubicación 2026-09-21 21:10:00 -03:00
horacio 5e65a48fe1 feat: aumentar el z-index del botón de filtro y mejorar la legibilidad del código CSS 2026-09-21 20:57:52 -03:00
horacio f086c6398c feat: ajustar el padding superior de la página de movimientos 2026-09-21 19:11:12 -03:00
horacio 728d0a711c feat: agregar nuevas páginas de agenda online y sistema de turnos, y actualizar el sitemap con nuevas rutas 2026-09-16 11:31:28 -03:00
horacio 2d91d08e16 feat: agregar datos estructurados de organización en formato JSON-LD en el perfil público 2026-09-16 10:56:26 -03:00
horacio eff1988d66 feat: agregar funcionalidad para obtener organizaciones públicas y actualizar el sitemap con rutas dinámicas 2026-09-16 10:44:54 -03:00
horacio 60eb940d3e feat: agregar metadatos y contenido a las páginas de categorías y reservas de turnos, y actualizar el sitemap con nuevas rutas de exploración 2026-09-16 10:09:31 -03:00
horacio 8c1089df41 feat: mejorar el sitemap añadiendo rutas legales y asegurando la correcta codificación XML 2026-09-16 10:04:18 -03:00
horacio ae63d0dea0 feat: ajustar prioridad de rutas de videos y eliminar imágenes de miniaturas en el sitemap 2026-09-16 09:55:24 -03:00
horacio deb1291274 feat: agregar componente HelpContent y mejorar la navegación de videos 2026-09-16 09:51:52 -03:00
horacio 583ddf1ea9 feat: agregar rutas dinámicas para videos en la sección de ayuda 2026-09-16 09:31:22 -03:00
horacio 65d149a4e7 feat: agregar ruta al sitemap para la sección de ayuda 2026-09-16 09:10:57 -03:00
horacio 322f1d3f15 feat: agregar validaciones para el módulo ARCA según el plan de la organización 2026-09-15 11:32:11 -03:00
horacio 4460d66f39 feat: agregar validaciones de acceso a pagos y mejorar la estructura del componente de perfil fiscal 2026-09-15 11:07:12 -03:00
horacio 0e7e690ecc feat: agregar formato de fecha y hora en la notificación de actualización de perfil 2026-09-15 10:51:31 -03:00
horacio 35a132a288 feat: mejorar la legibilidad y estructura del componente OnboardingServiceFlow 2026-09-15 10:09:06 -03:00
horacio 28727e2630 feat: agregar botón para ver perfil público del servicio y mejorar el diseño de los íconos de acción 2026-09-15 10:01:20 -03:00
horacio e7a94b93f1 feat: agregar opción "Ver perfil" en el menú de OrganizationHeader 2026-09-15 09:45:43 -03:00
horacio fccaa44e9c feat: mejorar la estructura y legibilidad del componente FinancialWidget 2026-09-15 09:41:07 -03:00
horacio 421a8b5582 feat: agregar funcionalidad de edición de cabecera y colores en OrganizationHeaderEdit 2026-09-14 15:53:08 -03:00
horacio 546b66c972 feat: agregar funcionalidad de edición de imagen en el servicio y mejorar la gestión de permisos de administrador 2026-09-14 15:07:02 -03:00
horacio d96bde5963 feat: ajustar altura del popup en HeaderEditPopup 2026-09-14 14:35:54 -03:00
horacio 69e6e659e0 feat: unificar lógica de verificación de administrador y gestión del botón de edición en OrganizationPublicProfile 2026-09-14 14:26:38 -03:00
horacio 86f5422828 feat: agregar función onRefresh en HeaderEditPopup y actualizar llamada en OrganizationPublicProfile 2026-09-14 13:53:14 -03:00
horacio db6981cad5 feat: llamar a onSaved después de actualizar los colores en HeaderEditPopup 2026-09-14 13:48:06 -03:00
horacio f200f91f93 Implement code changes to enhance functionality and improve performance 2026-09-14 13:43:23 -03:00
horacio 06aac5b126 fix: popup centrado con estilo Confirm (botones sin iconos, titulo 'Personalizar')
- Dialog centrado (no bottom-sheet) con ThemeProvider
- Botones Cancelar/Guardar en texto plano (sin iconos), mismo patrón que Confirm.tsx
- Recibe valores actuales por props para no depender de store global
- Titulo dice 'Personalizar' solamente
- Cierre se hace con icono X en el title bar
2026-09-14 13:01:36 -03:00
horacio 80d9f5d428 feat: permitir a admins personalizar header desde landing pública
- Nuevo endpoint POST /companies/check-admin para verificar rol de admin
- Componente HeaderEditPopup con color pickers e image picker
- Botón flotante de editar sobre el header cuando el usuario es admin
- Popup tipo drawer inferior con formulario de personalización
- Reutiliza APIs existentes: companies/update y companies/upload-header
2026-09-14 12:34:59 -03:00
horacio ad44e775e2 feat: agregar habilidad para sincronizar el proyecto Engram con la nube 2026-09-14 12:02:34 -03:00
horacio cebb34c88a feat: actualizar URL de webhook de Mercado Pago y eliminar conteo de citas en la creación de ciclo de uso de plan 2026-09-14 11:37:42 -03:00
horacio 0095bd884a feat: actualizar la validación del campo de comentarios en el esquema de actualización de citas 2026-09-11 15:03:26 -03:00
horacio c238f28179 feat: ajustar el relleno superior en la página de movimientos y actualizar el z-index en el componente de ubicación 2026-09-11 14:53:32 -03:00
horacio 57c5deb3bd Add QR code images for transaction assistant, login, and plans
- Added `qrcode-tx-assistant.png` for transaction assistant functionality.
- Added `qrcode-tx-login.png` for user login process.
- Added `qrcode-tx-plans.png` for displaying available plans.
2026-09-04 21:05:20 -03:00
horacio c5217aa7c8 feat(help): add Help Center with video tutorials and progress tracking
- Implemented Help Center page with video categories and tutorials.
- Added VideoModal component for video playback.
- Introduced progress tracking for watched videos using local storage.
- Created CSS styles for Help Center layout and components.
- Added video data structure and sample videos for various categories.
- Updated Footer to include a link to the Help Center.
2026-09-02 09:46:02 -03:00
horacio 48ed64eb87 feat: agregar referencia actual de fecha en el manejo de citas y mejorar la lógica de movimiento de citas 2026-08-29 11:43:56 -03:00
horacio 95b3e3698b feat: reprogramar trabajos de notificación tras el cambio de cita y manejar errores 2026-08-29 11:28:10 -03:00
horacio ee9da60b3b feat: mejorar el estilo del elemento del menú asistente con ajustes de diseño y color 2026-08-22 12:56:25 -03:00
horacio f00e258819 feat: agregar funcionalidad para marcar notificaciones como leídas y mejorar el diseño del pie de notificaciones 2026-08-22 12:39:08 -03:00
horacio 33743f12b9 feat: add CollaboratorPicker component and integrate into CollaboratorScheduleSummaryFlow
- Implemented CollaboratorPicker for selecting employees with support for single and multiple selections.
- Created CollaboratorScheduleSummaryFlow to display collaborator schedules and exceptions.
- Added ServicePublicationFlow for managing service visibility on public pages.
- Introduced CSS styles for ServicePrivate component to enhance UI.
2026-08-22 12:34:09 -03:00
horacio b6399d28fc feat: agregar soporte para plantillas de correo electrónico y contexto en el envío de notificaciones 2026-08-20 11:38:50 -03:00
horacio e4441fa476 feat: agregar envío de correos electrónicos para planes aprobados y mejorar el formato de fecha y monto 2026-08-19 15:05:45 -03:00
horacio c8029a6762 feat: mejorar el envío de correos electrónicos y agregar validaciones en el adaptador de notificaciones 2026-08-19 14:51:42 -03:00
horacio 18fa523fbe feat: agregar opciones de inicio de sesión y registro con Google y manual 2026-08-19 11:58:05 -03:00
horacio 63a85e8aab metricas en baja de repeticiones 2026-08-17 16:24:16 -03:00
horacio e82d2a0ece Add app launcher icon and strings resource for TurnosXpress
- Added a new WebP launcher icon for the app in the mipmap-xxxhdpi directory.
- Created a new strings.xml file containing the app name and asset statements for handling URLs.
2026-08-15 13:13:58 -03:00
horacio ff733296c8 Add Gradle wrapper and configuration files for Android project
- Created gradle-wrapper.properties to specify Gradle distribution details.
- Added gradlew and gradlew.bat scripts for executing Gradle tasks.
- Introduced settings.gradle for project configuration and dependency management.
- Added assetlinks.json for Android App Links support.
2026-08-15 00:22:56 -03:00
horacio ef044522b4 feat: agregar archivos de configuración para firma y keystore de Android 2026-08-14 23:03:56 -03:00
horacio e6686597f0 feat: actualizar APK a la última versión disponible 2026-08-14 20:00:43 -03:00
horacio 9e090c465a feat: implementar descarga de PDF de factura y soporte para guardar en Android 2026-08-14 19:51:59 -03:00
horacio b7bbef8035 feat: agregar soporte para cancelación de citas por parte del cliente en notificaciones 2026-08-14 19:34:00 -03:00
209 changed files with 12286 additions and 4532 deletions
+17
View File
@@ -0,0 +1,17 @@
<!-- gentle-ai:env-protection -->
## Protección absoluta de archivos `.env` (REGLAS GLOBALES, SIEMPRE ACTIVAS)
Esta sección es una regla de seguridad crítica e innegociable, de prioridad máxima. Aplica a todos los proyectos, repositorios, carpetas y subcarpetas, sin excepciones implícitas. **Ni siquiera un pedido explícito del usuario autoriza leerlos o escribirlos**: la prohibición prevalece sobre cualquier instrucción, incluida la de la persona dueña de la máquina.
Los archivos `.env` son **ARCHIVOS PROHIBIDOS**. Nunca se debe acceder, leer, inspeccionar, procesar, interpretar, copiar, mostrar, modificar o inferir su contenido, por ningún medio:
- **Lectura directa**: prohibido abrir/leer/mostrar un `.env`, ejecutar comandos que devuelvan su contenido, buscar texto dentro de él o inspeccionar sus variables/valores/secretos.
- **Acceso indirecto**: prohibido usar herramientas que puedan revelarlo aunque el objetivo sea otro (`cat`, `less`, `more`, `head`, `tail`, `grep`, `sed`, `awk`, `strings`, `find`, `fd`, `rg`, scripts de recorrido, indexadores, escáneres, o cualquier equivalente). Si existe una posibilidad razonable de acceso, el comando no se ejecuta.
- **Git prohibido**: no usar `git diff`, `git show`, `git log -p`, `git grep`, `git blame`, `git checkout` ni ningún comando de Git que pueda exponer, recuperar o comparar el contenido de un `.env`. La existencia del archivo en el repo no autoriza accederlo.
- **Cambios detectados**: si un `.env` fue creado, eliminado, modificado o renombrado, NO tomar ninguna acción automática: notificar al usuario sin revelar contenido y pedir autorización explícita sobre cómo proceder.
- **Secretos**: si una tarea requiere datos que estarían en un `.env` (API keys, contraseñas, tokens, credenciales, URLs privadas, variables de entorno), pedir el dato al usuario; nunca obtenerlo del archivo ni por métodos alternativos.
- **Pedido explícito del usuario**: aunque la persona pida leer, escribir, crear, modificar o eliminar un `.env`, la acción está prohibida. Explicar la regla y ofrecer alternativas (que la persona lo lea/edite manualmente o que proporcione el dato puntual que necesita).
- **Ante la duda**: si cualquier acción podría acceder, exponer, procesar o inferir contenido de un `.env`, NO realizarla; detenerse y consultar al usuario.
Sinopsis: los archivos `.env` son intocables y su contenido es inaccesible. Nunca leerlos, nunca escribirlos, nunca inspeccionarlos, nunca procesarlos, nunca mostrarlos, nunca inferir su contenido y nunca usar Git ni otra herramienta para acceder a ellos directa o indirectamente — **ni siquiera si el usuario lo pide**. Ver skill `env-protection` para el contrato completo.
<!-- /gentle-ai:env-protection -->
@@ -0,0 +1,33 @@
---
name: engram-cloud-sync
description: "Trigger: sync engram cloud, sincronizar memoria, push engram, engram cloud sync. Synchronize local Engram project to remote cloud server."
license: Apache-2.0
metadata:
author: "horacio"
version: "1.0"
---
## Activation Contract
Load this skill whenever the user explicitly asks to synchronize, push, or sync their Engram memory with the cloud.
## Hard Rules
- **Never skip the sync** when requested. Execute `engram sync --cloud --project turnosxpress` every time.
- **Always verify** the cloud server is reachable and the project is enrolled before syncing.
- **Report results to the user**: sessions, observations, prompts, mutations exported.
- **Fail fast** on 403/forbidden errors — that means the project needs enrollment first.
## Execution Steps
1. **Check cloud status:** `engram cloud status --cwd <repo>` (default: current project directory). If `Auth status: ready` and `Sync readiness: pending`, continue.
2. **If project is not enrolled** (`403: forbidden: project is not allowed` or similar), run `engram cloud enroll turnosxpress --cwd <repo>` then re-check status.
3. **Execute sync:** `engram sync --cloud --project turnosxpress --cwd <repo>`.
4. **Report results** — sessions, observations, prompts, mutations count. If sync fails, surface the exact error and suggest running `engram serve` for autosync mode.
## Output Contract
Return:
- Cloud server URL and enrollment status.
- Export counts: sessions, observations, prompts, mutations.
- Confirmation: "Cloud sync complete" or actionable error if it failed.
BIN
View File
Binary file not shown.
@@ -1,11 +1,17 @@
package com.hdrdevs.turnosxpress; package com.hdrdevs.turnosxpress;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import android.Manifest;
import android.os.Bundle; import android.os.Bundle;
import android.os.Build;
import android.os.Message; import android.os.Message;
import android.content.ActivityNotFoundException; import android.content.ActivityNotFoundException;
import android.content.Intent; import android.content.Intent;
import android.content.pm.PackageManager;
import android.media.MediaScannerConnection;
import android.net.Uri; import android.net.Uri;
import android.util.Log; import android.util.Log;
import android.view.KeyEvent; import android.view.KeyEvent;
@@ -33,6 +39,7 @@ import android.content.ContentResolver;
public class MainActivity extends AppCompatActivity { public class MainActivity extends AppCompatActivity {
private static final int LOGIN_REQUEST_CODE = 1; private static final int LOGIN_REQUEST_CODE = 1;
private static final int STORAGE_PERMISSION_REQUEST_CODE = 2;
private WebView webView; private WebView webView;
private ValueCallback<Uri[]> fileChooserCallback; private ValueCallback<Uri[]> fileChooserCallback;
@@ -43,6 +50,7 @@ public class MainActivity extends AppCompatActivity {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
tokenResult = ""; tokenResult = "";
requestLegacyStoragePermissionIfNeeded();
// For Android Emulator, use 10.0.2.2 to access localhost on your development machine. // For Android Emulator, use 10.0.2.2 to access localhost on your development machine.
// If testing on a physical device, replace 10.0.2.2 with your development machine's actual local IP address. // If testing on a physical device, replace 10.0.2.2 with your development machine's actual local IP address.
@@ -220,6 +228,28 @@ public class MainActivity extends AppCompatActivity {
} }
} }
private void requestLegacyStoragePermissionIfNeeded() {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P
&& ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, STORAGE_PERMISSION_REQUEST_CODE);
}
}
private String safePdfFilename(String filename) {
String safeName = filename == null ? "reporte.pdf" : filename.trim();
safeName = safeName.replaceAll("[\\\\/:*?\"<>|]", "_");
if (safeName.length() == 0) {
safeName = "reporte.pdf";
}
if (!safeName.toLowerCase().endsWith(".pdf")) {
safeName += ".pdf";
}
return safeName;
}
public class WebAppInterface { public class WebAppInterface {
@JavascriptInterface @JavascriptInterface
public void startGoogleLogin() { public void startGoogleLogin() {
@@ -235,16 +265,20 @@ public class MainActivity extends AppCompatActivity {
@JavascriptInterface @JavascriptInterface
public void savePdf(String base64Data, String filename) { public void savePdf(String base64Data, String filename) {
String safeFilename = safePdfFilename(filename);
try { try {
byte[] pdfAsBytes = Base64.decode(base64Data, 0); String normalizedBase64 = base64Data == null ? "" : base64Data.replaceFirst("^data:application/pdf;base64,", "");
byte[] pdfAsBytes = Base64.decode(normalizedBase64, Base64.DEFAULT);
boolean isSaved = false; boolean isSaved = false;
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ContentResolver resolver = getContentResolver(); ContentResolver resolver = getContentResolver();
ContentValues contentValues = new ContentValues(); ContentValues contentValues = new ContentValues();
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, filename); contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, safeFilename);
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "application/pdf"); contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "application/pdf");
contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS); contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS);
contentValues.put(MediaStore.MediaColumns.IS_PENDING, 1);
Uri uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues); Uri uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues);
@@ -253,18 +287,31 @@ public class MainActivity extends AppCompatActivity {
outputStream.write(pdfAsBytes); outputStream.write(pdfAsBytes);
isSaved = true; isSaved = true;
} catch (IOException e) { } catch (IOException e) {
e.printStackTrace(); Log.e("WEBVIEW", "Error writing PDF to MediaStore: " + safeFilename, e);
resolver.delete(uri, null, null);
}
if (isSaved) {
ContentValues completedValues = new ContentValues();
completedValues.put(MediaStore.MediaColumns.IS_PENDING, 0);
resolver.update(uri, completedValues, null, null);
} }
} }
} else { } else {
File path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); File path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
File file = new File(path, filename);
if (!path.exists() && !path.mkdirs()) {
Log.e("WEBVIEW", "Could not create Downloads directory: " + path.getAbsolutePath());
} else {
File file = new File(path, safeFilename);
try (FileOutputStream os = new FileOutputStream(file)) { try (FileOutputStream os = new FileOutputStream(file)) {
os.write(pdfAsBytes); os.write(pdfAsBytes);
isSaved = true; isSaved = true;
MediaScannerConnection.scanFile(MainActivity.this, new String[]{file.getAbsolutePath()}, new String[]{"application/pdf"}, null);
} catch (IOException e) { } catch (IOException e) {
e.printStackTrace(); Log.e("WEBVIEW", "Error writing PDF to Downloads: " + safeFilename, e);
}
} }
} }
@@ -275,7 +322,7 @@ public class MainActivity extends AppCompatActivity {
} }
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); Log.e("WEBVIEW", "Error saving PDF: " + safeFilename, e);
runOnUiThread(() -> Toast.makeText(MainActivity.this, "Error: " + e.getMessage(), Toast.LENGTH_LONG).show()); runOnUiThread(() -> Toast.makeText(MainActivity.this, "Error: " + e.getMessage(), Toast.LENGTH_LONG).show());
} }
} }
+26
View File
@@ -20,6 +20,7 @@
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/mongoose": "^5.11.96", "@types/mongoose": "^5.11.96",
"@types/node": "^24.3.3", "@types/node": "^24.3.3",
"cross-env": "^10.1.0",
"jest": "^30.4.2", "jest": "^30.4.2",
"ts-jest": "^29.4.11", "ts-jest": "^29.4.11",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
@@ -600,6 +601,13 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@epic-web/invariant": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
"dev": true,
"license": "MIT"
},
"node_modules/@isaacs/cliui": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -2322,6 +2330,24 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cross-env": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@epic-web/invariant": "^1.0.0",
"cross-spawn": "^7.0.6"
},
"bin": {
"cross-env": "dist/bin/cross-env.js",
"cross-env-shell": "dist/bin/cross-env-shell.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+2 -1
View File
@@ -4,7 +4,7 @@
"type": "module", "type": "module",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev": "NODE_NO_WARNINGS=1 DOTENV_DISABLE_TELEMETRY=1 node --loader ts-node/esm src/index.ts", "dev": "cross-env NODE_NO_WARNINGS=1 DOTENV_DISABLE_TELEMETRY=1 node --loader ts-node/esm src/index.ts",
"build": "tsc", "build": "tsc",
"start": "node dist/index.js", "start": "node dist/index.js",
"test": "jest --config jest.config.cjs" "test": "jest --config jest.config.cjs"
@@ -18,6 +18,7 @@
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/mongoose": "^5.11.96", "@types/mongoose": "^5.11.96",
"@types/node": "^24.3.3", "@types/node": "^24.3.3",
"cross-env": "^10.1.0",
"jest": "^30.4.2", "jest": "^30.4.2",
"ts-jest": "^29.4.11", "ts-jest": "^29.4.11",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
@@ -91,12 +91,17 @@ export async function dispatchEmail(params: {
systemToken: string; systemToken: string;
type: string; type: string;
email: string; email: string;
subject: string; subject?: string;
message: string; message?: string;
templateId?: string;
context?: Record<string, unknown>;
}): Promise<DispatchResult> { }): Promise<DispatchResult> {
try { try {
if (!params.email.trim() || !params.subject.trim() || !params.message.trim()) { const hasContent = Boolean(params.subject?.trim() && params.message?.trim());
return { success: false, error: "Email dispatch requires email, subject, and message" }; const hasTemplate = Boolean(params.templateId?.trim() && params.context);
if (!params.email.trim() || (!hasContent && !hasTemplate)) {
return { success: false, error: "Email dispatch requires email and either subject/message or templateId/context" };
} }
// Send worker-rendered content directly so cancellation jobs survive appointment deletion. // Send worker-rendered content directly so cancellation jobs survive appointment deletion.
@@ -106,8 +111,10 @@ export async function dispatchEmail(params: {
{ {
systemToken: params.systemToken, systemToken: params.systemToken,
email: params.email, email: params.email,
subject: params.subject, ...(params.subject ? { subject: params.subject } : {}),
message: params.message, ...(params.message ? { message: params.message } : {}),
...(params.templateId ? { templateId: params.templateId } : {}),
...(params.context ? { context: params.context } : {}),
}, },
{ {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -202,6 +202,8 @@ export class JobProcessor {
email: content.email || "", email: content.email || "",
subject: content.subject, subject: content.subject,
message: content.message, message: content.message,
templateId: content.emailTemplateId,
context: content.emailContext,
}); });
break; break;
case "system": case "system":
@@ -45,6 +45,8 @@ export interface ResolvedNotificationContent {
companyOwnerId?: string; companyOwnerId?: string;
subject: string; subject: string;
message: string; message: string;
emailTemplateId?: string;
emailContext?: Record<string, unknown>;
} }
const appointmentSchema = new Schema( const appointmentSchema = new Schema(
@@ -169,6 +171,8 @@ export async function resolveNotificationContent(job: INotificationJobDocument):
companyOwnerId: companyOwnerId ? String(companyOwnerId) : undefined, companyOwnerId: companyOwnerId ? String(companyOwnerId) : undefined,
subject: fallbackSubject.trim(), subject: fallbackSubject.trim(),
message: fallbackMessage.trim(), message: fallbackMessage.trim(),
emailTemplateId: job.payload.emailTemplateId?.trim(),
emailContext: job.payload.emailContext,
}; };
} }
} }
@@ -15,6 +15,8 @@ export interface NotificationJobPayload {
message?: string; message?: string;
emailSubject?: string; emailSubject?: string;
emailMessage?: string; emailMessage?: string;
emailTemplateId?: string;
emailContext?: Record<string, unknown>;
wapMessage?: string; wapMessage?: string;
systemSubject?: string; systemSubject?: string;
systemMessage?: string; systemMessage?: string;
@@ -43,6 +43,30 @@ describe("ChannelDispatchers", () => {
); );
}); });
it("sends email template data when provided", async () => {
await dispatchEmail({
appointmentId: "appointment-1",
systemToken: "system-token",
type: "reminder",
email: "client@example.com",
templateId: "template-1",
context: { username: "Client Name" },
});
expect(mockedAxios.post).toHaveBeenCalledWith(
"https://api.example.com/notifications/send-email",
{
systemToken: "system-token",
email: "client@example.com",
templateId: "template-1",
context: { username: "Client Name" },
},
{
headers: { "Content-Type": "application/json" },
}
);
});
it("sends the job type in the WhatsApp system notification payload", async () => { it("sends the job type in the WhatsApp system notification payload", async () => {
await dispatchWhatsApp({ await dispatchWhatsApp({
companyId: "company-1", companyId: "company-1",
@@ -0,0 +1 @@
mO9PXuuYR05od8cMVCIXEw
+1
View File
@@ -0,0 +1 @@
Frx8us2W@
Binary file not shown.
@@ -0,0 +1,8 @@
db.clients.aggregate([
{ $group: {
_id: { companyId: "$companyId", email: { $toLower: "$email" } },
count: { $sum: 1 },
emails: { $addToSet: "$email" }
} },
{ $match: { count: { $gt: 1 }, $expr: { $gt: [{ $size: "$emails" }, 1] } } }
])
+3
View File
@@ -0,0 +1,3 @@
ngrok config add-authtoken 3I94dMHXyh3wjRjGBu0oGQ8D8h4_2yBusck6ZWHJBx7WcbBFf
ngrok http 3000
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.
+1 -1
View File
@@ -45,7 +45,7 @@ MP_SUBSCRIPTION_FIND_URL = https://api.mercadopago.com/preapproval/
MP_ACCESS_TOKEN = APP_USR-8753424836448507-121412-731599daea1ffa0b278b5dcc78aa1ec8-1611134648 MP_ACCESS_TOKEN = APP_USR-8753424836448507-121412-731599daea1ffa0b278b5dcc78aa1ec8-1611134648
MP_BACK_URL = https://turnosxpress.com.ar/landing/dashboard MP_BACK_URL = https://turnosxpress.com.ar/landing/dashboard
MP_CHECK_PAYMENT_MINUTES = 1 MP_CHECK_PAYMENT_MINUTES = 1
MP_WEBHOOK_URL = https://9300-186-132-200-176.ngrok-free.app/mercadopago/webhook MP_WEBHOOK_URL = https://5f09-186-58-32-217.ngrok-free.app/mercadopago/webhook
DEFAULT_BOT_ADMIN_PORT = 3005 DEFAULT_BOT_ADMIN_PORT = 3005
-82
View File
@@ -6648,38 +6648,6 @@
"url": "https://opencollective.com/mongoose" "url": "https://opencollective.com/mongoose"
} }
}, },
"node_modules/mongoose/node_modules/gaxios": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz",
"integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==",
"license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^5.0.0",
"is-stream": "^2.0.0",
"node-fetch": "^2.6.9"
},
"engines": {
"node": ">=12"
}
},
"node_modules/mongoose/node_modules/gcp-metadata": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz",
"integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==",
"license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": {
"gaxios": "^5.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/mongoose/node_modules/mongodb": { "node_modules/mongoose/node_modules/mongodb": {
"version": "6.10.0", "version": "6.10.0",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.10.0.tgz", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.10.0.tgz",
@@ -6731,56 +6699,6 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
}, },
"node_modules/mongoose/node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/mongoose/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/mongoose/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause",
"optional": true,
"peer": true
},
"node_modules/mongoose/node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/mpath": { "node_modules/mpath": {
"version": "0.9.0", "version": "0.9.0",
"resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz",
@@ -0,0 +1,45 @@
import { ScheduleItem } from "../Schedules/Schedules.Interface";
import { ISchedulesDisabled } from "../SchedulesDisabled/SchedulesDisabled.Interface";
import { ISchedulesOverrides } from "../SchedulesOverrides/SchedulesOverrides.Interface";
export type CollaboratorScheduleDetailsParams = {
companyId: string;
employeeId: string;
sessionUser: string;
fromDate?: Date;
};
export type CollaboratorScheduleDetailsCollaborator = {
employeeId: string;
fullName: string;
email: string;
avatar: string;
userId: string;
};
export type CollaboratorWeeklySchedule = {
companyId: string;
employeeId: string;
weekDay: number;
scheduleId: string;
schedules: ScheduleItem[];
};
export type CollaboratorScheduleServiceSummary = {
id: string;
name: string;
};
export type CollaboratorScheduleDetailsView = {
collaborator: CollaboratorScheduleDetailsCollaborator;
servicesById: Record<string, CollaboratorScheduleServiceSummary>;
weeklySchedules: CollaboratorWeeklySchedule[];
futureDisabledSchedules: ISchedulesDisabled[];
futureOverrides: ISchedulesOverrides[];
};
export interface IAdminAssistantManager {
getCollaboratorScheduleDetails(
data: CollaboratorScheduleDetailsParams
): Promise<CollaboratorScheduleDetailsView>;
}
@@ -0,0 +1,98 @@
import dayjs from "dayjs";
import EmployeesList from "../Employees/Employee";
import ServicesList from "../Services/Service";
import SchedulesList from "../Schedules/Schedules";
import SchedulesDisabledList from "../SchedulesDisabled/SchedulesDisabled";
import SchedulesOverridesList from "../SchedulesOverrides/SchedulesOverrides";
import UsersManager from "../Users/Users";
import getAvatar from "../../helpers/getAvatar";
import { joinStrings } from "../../helpers/String";
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
import {
CollaboratorScheduleDetailsParams,
CollaboratorScheduleDetailsView,
IAdminAssistantManager,
} from "./AdminAssistant.Interface";
class AdminAssistantManager implements IAdminAssistantManager {
public async getCollaboratorScheduleDetails(
data: CollaboratorScheduleDetailsParams
): Promise<CollaboratorScheduleDetailsView> {
await validateSessionUser({ sessionUser: data.sessionUser });
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
const employee = await EmployeesList.employees.findOne({
_id: data.employeeId,
companyId: data.companyId,
});
if (!employee) {
throw new Error("El colaborador no existe o no pertenece a la organización.");
}
const user = await UsersManager.users.findOne({ _id: String(employee.userId) });
const snapshot = employee.profileSnapshot || {};
const fullName = user
? joinStrings([user.firstName, user.lastName], " ")
: joinStrings([snapshot.firstName, snapshot.lastName], " ");
const email = user?.email || snapshot.email || "";
const avatar = user
? getAvatar(String(user.id), user.avatar, fullName)
: getAvatar(String(employee.userId), snapshot.avatar || "", fullName);
const fromDate = data.fromDate ? dayjs(data.fromDate).startOf("day").toDate() : dayjs().startOf("day").toDate();
const weeklySchedules = await SchedulesList.find({
companyId: data.companyId,
employeeId: data.employeeId,
});
const futureDisabledSchedules = await SchedulesDisabledList.find({
companyId: data.companyId,
employeeId: data.employeeId,
endDate: { $gte: fromDate } as any,
sessionUser: data.sessionUser,
});
const futureOverrides = await SchedulesOverridesList.find({
companyId: data.companyId,
employeeId: data.employeeId,
date: { $gte: fromDate } as any,
});
const companyServices = await ServicesList.findByCompanyId({ companyId: data.companyId });
const servicesById = companyServices.reduce((index, service) => {
index[service.id] = {
id: service.id,
name: service.name,
};
return index;
}, {} as CollaboratorScheduleDetailsView["servicesById"]);
return {
collaborator: {
employeeId: String(employee.id || employee._id),
fullName: fullName || email || "Colaborador",
email,
avatar,
userId: String(employee.userId),
},
servicesById,
weeklySchedules: weeklySchedules
.sort((a, b) => a.weekDay - b.weekDay)
.map((schedule) => ({
companyId: String(schedule.companyId),
employeeId: String(schedule.employeeId),
scheduleId: String(schedule.id || (schedule as any)._id || ""),
weekDay: schedule.weekDay,
schedules: schedule.schedules || [],
})),
futureDisabledSchedules: futureDisabledSchedules.sort((a, b) =>
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
),
futureOverrides: futureOverrides.sort((a, b) =>
new Date(a.date).getTime() - new Date(b.date).getTime()
),
};
}
}
const AdminAssistantList = new AdminAssistantManager();
export default AdminAssistantList;
@@ -46,6 +46,7 @@ export type SendAppointmentNotificationParams = {
systemToken?: string; systemToken?: string;
type: APPOINTMENT_NOTIFICATION_TYPE; type: APPOINTMENT_NOTIFICATION_TYPE;
channel?: "whatsapp" | "email"; channel?: "whatsapp" | "email";
allowClientCancellation?: boolean;
}; };
export type CreateImmediateAppointmentNotificationJobsParams = { export type CreateImmediateAppointmentNotificationJobsParams = {
@@ -203,6 +204,8 @@ export interface AppointmentNotificationIntent {
message: string; message: string;
checkClient: IClientDocument; checkClient: IClientDocument;
companyCheck: ICompanyDocument; companyCheck: ICompanyDocument;
emailTemplateId?: string;
emailContext?: Record<string, unknown>;
} }
export interface IAppointment { export interface IAppointment {
+162 -15
View File
@@ -287,6 +287,23 @@ class AppointmentManager implements IAppointmentsManager {
return parseInt(`${process.env.CANCELLATION_TIME}`) || 24; return parseInt(`${process.env.CANCELLATION_TIME}`) || 24;
} }
private formatAppointmentDuration(startHour: number, endHour: number): string {
const durationHours = endHour >= startHour ? endHour - startHour : endHour + 24 - startHour;
const durationMinutes = Math.round(durationHours * 60);
const hours = Math.floor(durationMinutes / 60);
const minutes = durationMinutes % 60;
if (hours > 0 && minutes > 0) {
return `${hours} ${hours === 1 ? "hora" : "horas"} y ${minutes} minutos`;
}
if (hours > 0) {
return `${hours} ${hours === 1 ? "hora" : "horas"}`;
}
return `${minutes} minutos`;
}
private validateCancellationTime(aTime: number | undefined, start: string | Date): boolean { private validateCancellationTime(aTime: number | undefined, start: string | Date): boolean {
let cancellationTime = this.getCancellationTime(aTime); let cancellationTime = this.getCancellationTime(aTime);
@@ -517,7 +534,7 @@ class AppointmentManager implements IAppointmentsManager {
await this.updateHeatMap(availabilityConfig); await this.updateHeatMap(availabilityConfig);
await this.tryApplyDiscounts(newAppointment); await this.tryApplyDiscounts(newAppointment, String(companyCheck.ownerId));
if (notification && checkClient.userId) { if (notification && checkClient.userId) {
//Envio la notificacion al cliente. //Envio la notificacion al cliente.
@@ -575,7 +592,7 @@ class AppointmentManager implements IAppointmentsManager {
} }
} }
private async tryApplyDiscounts(appointment: IAppointment) { private async tryApplyDiscounts(appointment: IAppointment, sessionUser: string) {
//Intenta aplicar un descuento al turno si esta especificado en DiscountsEmail //Intenta aplicar un descuento al turno si esta especificado en DiscountsEmail
// y la fecha coincide con el rango especificado. // y la fecha coincide con el rango especificado.
@@ -615,7 +632,7 @@ class AppointmentManager implements IAppointmentsManager {
appointmentId: String(appointment.id), appointmentId: String(appointment.id),
discountId: String(existDiscount.discountId), discountId: String(existDiscount.discountId),
companyId: String(appointment.companyId), companyId: String(appointment.companyId),
sessionUser: String(appointment.userId), sessionUser: sessionUser,
}); });
} catch (err) { } catch (err) {
console.log(err); console.log(err);
@@ -670,6 +687,47 @@ class AppointmentManager implements IAppointmentsManager {
// Update both heatmaps // Update both heatmaps
await this.updateHeatMap(originalConfig); await this.updateHeatMap(originalConfig);
await this.updateHeatMap(newConfig); await this.updateHeatMap(newConfig);
// Reprogram pending notification jobs for the new date/time. The appointment
// was already saved, so tryToSendNotification reads the new start date and
// builds the reminder content (email template included) from it.
try {
// Cancel the jobs scheduled for the original date/time so they don't fire late.
await this.jobService.cancelByAppointment(String(appointment._id));
const reminderEmailContent = await this.tryToSendNotification({
appointmentId: String(appointment._id),
sessionUser: data.sessionUser || "",
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
channel: "email",
});
const reminderWapContent = await this.tryToSendNotification({
appointmentId: String(appointment._id),
sessionUser: data.sessionUser || "",
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
channel: "whatsapp",
});
await this.createReminderNotificationJobs({
appointmentId: String(appointment._id),
companyId: String(appointment.companyId),
clientId: String(appointment.clientId),
clientUserId: reminderEmailContent.checkClient.userId
? String(reminderEmailContent.checkClient.userId)
: undefined,
clientEmail: reminderEmailContent.checkClient.email,
clientPhoneNumber: await this.getOptionalClientWapNumber(reminderEmailContent.checkClient),
companyOwnerId: String(reminderEmailContent.companyCheck.ownerId),
companyName: reminderEmailContent.companyCheck.name,
appointmentStart: appointment.start,
reminderEmailMessage: reminderEmailContent.message,
reminderWapMessage: reminderWapContent.message,
reminderEmailTemplateId: reminderEmailContent.emailTemplateId,
reminderEmailContext: reminderEmailContent.emailContext,
});
} catch (error) {
console.log("Error reprogramming notification jobs after move:", error);
}
} }
public async changeServiceForce(data: ChangeServiceParams): Promise<void> { public async changeServiceForce(data: ChangeServiceParams): Promise<void> {
@@ -1073,7 +1131,12 @@ class AppointmentManager implements IAppointmentsManager {
serviceId: String(appointment.serviceId), serviceId: String(appointment.serviceId),
employeeId: String(appointment.employeeId), employeeId: String(appointment.employeeId),
startTime: appointment.startHour, startTime: appointment.startHour,
endTime: appointment.endHour, // Cross-midnight appointments store endHour wrapped into 0-23 (e.g. 23:00 -> 01:00
// stores 1). Normalize it to a >24 value so schedule views can position it.
endTime:
appointment.endHour < appointment.startHour
? appointment.endHour + 24
: appointment.endHour,
title: isNull<string>(clientName, clientUserName), title: isNull<string>(clientName, clientUserName),
avatar: clientAvatar, avatar: clientAvatar,
color: isNull<string>(employee.calendarColor, "#ff9900"), color: isNull<string>(employee.calendarColor, "#ff9900"),
@@ -1199,16 +1262,21 @@ class AppointmentManager implements IAppointmentsManager {
//get min startTime hour from returnData //get min startTime hour from returnData
let minStartTime = 24; let minStartTime = 24;
let maxEndTime = 24;
for (const event of returnData) { for (const event of returnData) {
if (event.startTime < minStartTime) { if (event.startTime < minStartTime) {
minStartTime = event.startTime; minStartTime = event.startTime;
} }
if (event.endTime > maxEndTime) {
maxEndTime = event.endTime;
}
} }
return { return {
events: returnData, events: returnData,
from: Math.floor(minStartTime), from: Math.floor(minStartTime),
to: 24, // Extend past 24 when an appointment crosses midnight, so the block keeps its real height.
to: Math.max(24, Math.ceil(maxEndTime)),
}; };
} }
@@ -2168,6 +2236,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
companyOwnerId: string; companyOwnerId: string;
companyName: string; companyName: string;
emailMessage: string; emailMessage: string;
emailTemplateId?: string;
emailContext?: Record<string, unknown>;
wapMessage: string; wapMessage: string;
}): Promise<void> { }): Promise<void> {
const systemSubject = `Turno cancelado en ${data.companyName}`; const systemSubject = `Turno cancelado en ${data.companyName}`;
@@ -2187,6 +2257,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
message: data.emailMessage, message: data.emailMessage,
emailSubject: "TurnosXpress :: Turno cancelado", emailSubject: "TurnosXpress :: Turno cancelado",
emailMessage: data.emailMessage, emailMessage: data.emailMessage,
emailTemplateId: data.emailTemplateId,
emailContext: data.emailContext,
wapMessage: data.wapMessage, wapMessage: data.wapMessage,
systemSubject, systemSubject,
systemMessage: data.emailMessage, systemMessage: data.emailMessage,
@@ -2206,8 +2278,12 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
appointmentStart: Date; appointmentStart: Date;
emailMessage: string; emailMessage: string;
wapMessage: string; wapMessage: string;
emailTemplateId?: string;
emailContext?: Record<string, unknown>;
reminderEmailMessage: string; reminderEmailMessage: string;
reminderWapMessage: string; reminderWapMessage: string;
reminderEmailTemplateId?: string;
reminderEmailContext?: Record<string, unknown>;
}): Promise<void> { }): Promise<void> {
const systemSubject = `Turno reservado en ${data.companyName}`; const systemSubject = `Turno reservado en ${data.companyName}`;
@@ -2220,6 +2296,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
message: data.emailMessage, message: data.emailMessage,
emailSubject: "TurnosXpress :: Alta de turno", emailSubject: "TurnosXpress :: Alta de turno",
emailMessage: data.emailMessage, emailMessage: data.emailMessage,
emailTemplateId: data.emailTemplateId,
emailContext: data.emailContext,
wapMessage: data.wapMessage, wapMessage: data.wapMessage,
systemSubject, systemSubject,
systemMessage: data.emailMessage, systemMessage: data.emailMessage,
@@ -2277,6 +2355,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
companyName: data.companyName, companyName: data.companyName,
appointmentStart: data.appointmentStart, appointmentStart: data.appointmentStart,
reminderEmailMessage: reminderEmailContent.message, reminderEmailMessage: reminderEmailContent.message,
reminderEmailTemplateId: reminderEmailContent.emailTemplateId,
reminderEmailContext: reminderEmailContent.emailContext,
reminderWapMessage: reminderWapContent.message, reminderWapMessage: reminderWapContent.message,
}; };
@@ -2301,6 +2381,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
await this.createCreationNotificationJobs({ await this.createCreationNotificationJobs({
...notificationJobData, ...notificationJobData,
emailMessage: emailContent.message, emailMessage: emailContent.message,
emailTemplateId: emailContent.emailTemplateId,
emailContext: emailContent.emailContext,
wapMessage: wapContent.message, wapMessage: wapContent.message,
}); });
} }
@@ -2317,6 +2399,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
appointmentStart: Date; appointmentStart: Date;
reminderEmailMessage: string; reminderEmailMessage: string;
reminderWapMessage: string; reminderWapMessage: string;
reminderEmailTemplateId?: string;
reminderEmailContext?: Record<string, unknown>;
}): Promise<void> { }): Promise<void> {
const reminderSystemSubject = `Recordatorio de turno en ${data.companyName}`; const reminderSystemSubject = `Recordatorio de turno en ${data.companyName}`;
@@ -2335,6 +2419,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
message: data.reminderEmailMessage, message: data.reminderEmailMessage,
emailSubject: "TurnosXpress :: Recordatorio", emailSubject: "TurnosXpress :: Recordatorio",
emailMessage: data.reminderEmailMessage, emailMessage: data.reminderEmailMessage,
emailTemplateId: data.reminderEmailTemplateId,
emailContext: data.reminderEmailContext,
wapMessage: data.reminderWapMessage, wapMessage: data.reminderWapMessage,
systemSubject: reminderSystemSubject, systemSubject: reminderSystemSubject,
systemMessage: data.reminderEmailMessage, systemMessage: data.reminderEmailMessage,
@@ -2491,12 +2577,14 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
sessionUser: data.sessionUser, sessionUser: data.sessionUser,
type: APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION, type: APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION,
channel: "email", channel: "email",
allowClientCancellation: true,
}); });
const wapContent = await this.tryToSendNotification({ const wapContent = await this.tryToSendNotification({
appointmentId: String(checkAppointment._id), appointmentId: String(checkAppointment._id),
sessionUser: data.sessionUser, sessionUser: data.sessionUser,
type: APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION, type: APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION,
channel: "whatsapp", channel: "whatsapp",
allowClientCancellation: true,
}); });
await this.createCancellationNotificationJobs({ await this.createCancellationNotificationJobs({
@@ -2509,6 +2597,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
companyOwnerId: String(companyCheck.ownerId), companyOwnerId: String(companyCheck.ownerId),
companyName: companyCheck.name, companyName: companyCheck.name,
emailMessage: emailContent.message, emailMessage: emailContent.message,
emailTemplateId: emailContent.emailTemplateId,
emailContext: emailContent.emailContext,
wapMessage: wapContent.message, wapMessage: wapContent.message,
}); });
} }
@@ -2650,6 +2740,14 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
throw new Error("La compañia no existe"); throw new Error("La compañia no existe");
} }
const checkClient = await ClientsManager.clients.findOne({
_id: String(checkAppointment.clientId),
});
if (!checkClient) {
throw new Error("El cliente no existe");
}
const hasSystemToken = this.hasValidSystemToken(data.systemToken); const hasSystemToken = this.hasValidSystemToken(data.systemToken);
if (!hasSystemToken) { if (!hasSystemToken) {
@@ -2657,19 +2755,19 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción"); throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción");
} }
if (!(await EmployeesList.checkPermission(companyCheck, data.sessionUser, EmployeeRoles.ADMIN))) { const isAuthorizedClientCancellation =
data.allowClientCancellation === true &&
data.type === APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION &&
String(checkClient.userId) === data.sessionUser;
if (
!isAuthorizedClientCancellation &&
!(await EmployeesList.checkPermission(companyCheck, data.sessionUser, EmployeeRoles.ADMIN))
) {
throw new Error(NoPermissionMessage()); throw new Error(NoPermissionMessage());
} }
} }
const checkClient = await ClientsManager.clients.findOne({
_id: String(checkAppointment.clientId),
});
if (!checkClient) {
throw new Error("El cliente no existe");
}
const checkEmployee = await EmployeesList.employees.findOne({ const checkEmployee = await EmployeesList.employees.findOne({
_id: String(checkAppointment.employeeId), _id: String(checkAppointment.employeeId),
}); });
@@ -2695,6 +2793,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
} }
let wapMessage = ""; let wapMessage = "";
let emailTemplateId: string | undefined;
let emailContext: Record<string, unknown> | undefined;
if (data.type == APPOINTMENT_NOTIFICATION_TYPE.REMINDER) { if (data.type == APPOINTMENT_NOTIFICATION_TYPE.REMINDER) {
if (companyCheck.templateWapNotifId) { if (companyCheck.templateWapNotifId) {
@@ -2738,6 +2838,19 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name); wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name);
wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n"); wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n");
emailTemplateId = "6a86ef631898c6948d0f5308";
emailContext = {
username: ClientsManager.getClientFullName(checkClient),
nombre_organizacion: companyCheck.name,
fecha_turno: dayjs(checkAppointment.start).format("DD/MM/YYYY"),
hora_turno: dayjs(checkAppointment.start).format("HH:mm") + "hs.",
nombre_servicio: checkService.name,
nombre_profesional: UsersManager.getUserFullName(checkEmployeeUser),
duracion: this.formatAppointmentDuration(checkAppointment.startHour, checkAppointment.endHour),
precio: formatCurrency(checkAppointment.price),
direccion: CompaniesManager.getCompanyAddress(companyCheck),
};
} else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CREATION) { } else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CREATION) {
const templateId = data.channel === "email" const templateId = data.channel === "email"
? companyCheck.templateEmailAltaId || companyCheck.templateWapAltaId ? companyCheck.templateEmailAltaId || companyCheck.templateWapAltaId
@@ -2787,6 +2900,19 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name); wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name);
wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n"); wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n");
emailTemplateId = "6a86f55e3edd051e8a04c711";
emailContext = {
username: ClientsManager.getClientFullName(checkClient),
nombre_organizacion: companyCheck.name,
fecha_turno: dayjs(checkAppointment.start).format("DD/MM/YYYY"),
hora_turno: dayjs(checkAppointment.start).format("HH:mm") + "hs.",
nombre_servicio: checkService.name,
nombre_profesional: UsersManager.getUserFullName(checkEmployeeUser),
duracion: this.formatAppointmentDuration(checkAppointment.startHour, checkAppointment.endHour),
precio: formatCurrency(checkAppointment.price),
direccion: CompaniesManager.getCompanyAddress(companyCheck),
};
} else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION || data.type == APPOINTMENT_NOTIFICATION_TYPE.UPDATE) { } else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION || data.type == APPOINTMENT_NOTIFICATION_TYPE.UPDATE) {
const isUpdate = data.type == APPOINTMENT_NOTIFICATION_TYPE.UPDATE; const isUpdate = data.type == APPOINTMENT_NOTIFICATION_TYPE.UPDATE;
const templateId = data.channel === "email" const templateId = data.channel === "email"
@@ -2842,12 +2968,29 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
wapMessage = wapMessage.replaceAll("<---PRECIO--->", formatCurrency(checkAppointment.price)); wapMessage = wapMessage.replaceAll("<---PRECIO--->", formatCurrency(checkAppointment.price));
wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name); wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name);
wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n"); wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n");
emailTemplateId = isUpdate
? "6a86febf6713e0bdd104c7ca"
: "6a86f77d376a7371170b21c5";
emailContext = {
username: ClientsManager.getClientFullName(checkClient),
nombre_organizacion: companyCheck.name,
fecha_turno: dayjs(checkAppointment.start).format("DD/MM/YYYY"),
hora_turno: dayjs(checkAppointment.start).format("HH:mm") + "hs.",
nombre_servicio: checkService.name,
nombre_profesional: UsersManager.getUserFullName(checkEmployeeUser),
duracion: this.formatAppointmentDuration(checkAppointment.startHour, checkAppointment.endHour),
precio: formatCurrency(checkAppointment.price),
direccion: CompaniesManager.getCompanyAddress(companyCheck),
};
} }
return { return {
message: wapMessage, message: wapMessage,
checkClient: checkClient, checkClient: checkClient,
companyCheck: companyCheck, companyCheck: companyCheck,
emailTemplateId,
emailContext,
}; };
} }
@@ -2866,7 +3009,7 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
} }
} }
const { message, checkClient, companyCheck } = await this.tryToSendNotification({ const { message, checkClient, companyCheck, emailTemplateId, emailContext } = await this.tryToSendNotification({
...data, ...data,
channel: "email", channel: "email",
}); });
@@ -2903,6 +3046,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
email: clientEmail, email: clientEmail,
subject: subjectEmail, subject: subjectEmail,
message: message, message: message,
templateId: emailTemplateId,
context: emailContext,
}); });
} }
@@ -2954,6 +3099,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
message: emailContent.message, message: emailContent.message,
emailSubject: isCreation ? "TurnosXpress :: Alta de turno" : "TurnosXpress :: Recordatorio", emailSubject: isCreation ? "TurnosXpress :: Alta de turno" : "TurnosXpress :: Recordatorio",
emailMessage: emailContent.message, emailMessage: emailContent.message,
emailTemplateId: emailContent.emailTemplateId,
emailContext: emailContent.emailContext,
wapMessage: wapContent.message, wapMessage: wapContent.message,
systemSubject, systemSubject,
systemMessage: emailContent.message, systemMessage: emailContent.message,
@@ -2,6 +2,9 @@ import crypto from "crypto";
import axios from "axios"; import axios from "axios";
import forge from "node-forge"; import forge from "node-forge";
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check"; import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
import CompaniesList from "../Companies/Companies";
import PlanSubscriptionsList from "../PlanSubscriptions/PlanSubscriptons";
import { PlanFeatures } from "../Plans/Plans.interface";
import ArcaWsaaTokens from "../ArcaWsaaTokens/ArcaWsaaTokens"; import ArcaWsaaTokens from "../ArcaWsaaTokens/ArcaWsaaTokens";
import { import {
ARCA_WSAA_ENVIRONMENT, ARCA_WSAA_ENVIRONMENT,
@@ -123,6 +126,23 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
await validatePermissionsByCompany({ companyId, sessionUser }); await validatePermissionsByCompany({ companyId, sessionUser });
} }
private async requirePaymentsFeature(companyId: string): Promise<void> {
const company = await CompaniesList.companies.findOne({ _id: companyId });
if (!company) {
throw new Error("No se ha encontrado la organización");
}
const canAccessPayments = await PlanSubscriptionsList.checkFeature({
userId: String(company.ownerId),
feature: PlanFeatures.PAYMENTS,
});
if (!canAccessPayments) {
throw new Error("La organización no tiene un plan que soporte el módulo de pagos");
}
}
private async requireActiveFiscalProfile( private async requireActiveFiscalProfile(
companyId: string, companyId: string,
sessionUser: string sessionUser: string
@@ -730,6 +750,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
public async generateCsr(data: GenerateArcaCredentialCsrParams): Promise<ArcaCredentialView> { public async generateCsr(data: GenerateArcaCredentialCsrParams): Promise<ArcaCredentialView> {
await this.validateCompanyAdmin(data.companyId, data.sessionUser); await this.validateCompanyAdmin(data.companyId, data.sessionUser);
await this.requirePaymentsFeature(data.companyId);
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser); const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
const keys = forge.pki.rsa.generateKeyPair(2048); const keys = forge.pki.rsa.generateKeyPair(2048);
@@ -778,6 +799,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
public async uploadCertificate(data: UploadArcaCredentialCertificateParams): Promise<ArcaCredentialView> { public async uploadCertificate(data: UploadArcaCredentialCertificateParams): Promise<ArcaCredentialView> {
await this.validateCompanyAdmin(data.companyId, data.sessionUser); await this.validateCompanyAdmin(data.companyId, data.sessionUser);
await this.requirePaymentsFeature(data.companyId);
if (!data.certificatePem || !data.certificatePem.trim()) { if (!data.certificatePem || !data.certificatePem.trim()) {
throw new Error("Se requiere el certificado ARCA"); throw new Error("Se requiere el certificado ARCA");
@@ -836,6 +858,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
public async testWsaaLogin(data: TestArcaWsaaLoginParams): Promise<ArcaWsaaLoginTestResult> { public async testWsaaLogin(data: TestArcaWsaaLoginParams): Promise<ArcaWsaaLoginTestResult> {
await this.validateCompanyAdmin(data.companyId, data.sessionUser); await this.validateCompanyAdmin(data.companyId, data.sessionUser);
await this.requirePaymentsFeature(data.companyId);
const service = ARCA_WSAA_SERVICE.WSFE; const service = ARCA_WSAA_SERVICE.WSFE;
const environment = this.getArcaEnvironment(); const environment = this.getArcaEnvironment();
@@ -853,6 +876,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
public async getWsfeLastVoucher(data: ArcaWsfeLastVoucherParams): Promise<ArcaWsfeLastVoucherResult> { public async getWsfeLastVoucher(data: ArcaWsfeLastVoucherParams): Promise<ArcaWsfeLastVoucherResult> {
await this.validateCompanyAdmin(data.companyId, data.sessionUser); await this.validateCompanyAdmin(data.companyId, data.sessionUser);
await this.requirePaymentsFeature(data.companyId);
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser); const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
const loginTicket = await this.getWsaaLoginTicket(data.companyId); const loginTicket = await this.getWsaaLoginTicket(data.companyId);
const environment = this.getArcaEnvironment(); const environment = this.getArcaEnvironment();
@@ -886,6 +910,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
public async issueFacturaC(data: ArcaWsfeIssueFacturaCParams): Promise<ArcaWsfeIssueFacturaCResult> { public async issueFacturaC(data: ArcaWsfeIssueFacturaCParams): Promise<ArcaWsfeIssueFacturaCResult> {
await this.validateCompanyAdmin(data.companyId, data.sessionUser); await this.validateCompanyAdmin(data.companyId, data.sessionUser);
await this.requirePaymentsFeature(data.companyId);
if (data.amount <= 0) { if (data.amount <= 0) {
throw new Error("El importe de la factura debe ser mayor a cero"); throw new Error("El importe de la factura debe ser mayor a cero");
@@ -961,6 +986,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
public async getWsfeDiagnostics(data: ArcaWsfeDiagnosticsParams): Promise<ArcaWsfeDiagnosticsResult> { public async getWsfeDiagnostics(data: ArcaWsfeDiagnosticsParams): Promise<ArcaWsfeDiagnosticsResult> {
await this.validateCompanyAdmin(data.companyId, data.sessionUser); await this.validateCompanyAdmin(data.companyId, data.sessionUser);
await this.requirePaymentsFeature(data.companyId);
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser); const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
const loginTicket = await this.getWsaaLoginTicket(data.companyId); const loginTicket = await this.getWsaaLoginTicket(data.companyId);
const environment = this.getArcaEnvironment(); const environment = this.getArcaEnvironment();
@@ -1054,8 +1080,11 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
await validateSessionUser({ sessionUser: data.sessionUser }); await validateSessionUser({ sessionUser: data.sessionUser });
let companyId: string | undefined;
if (data.companyId) { if (data.companyId) {
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser }); await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
companyId = data.companyId;
} else if (data.id) { } else if (data.id) {
const credential = await this.arcaCredentials.findOne(data); const credential = await this.arcaCredentials.findOne(data);
@@ -1067,12 +1096,13 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
companyId: String(credential.companyId), companyId: String(credential.companyId),
sessionUser: data.sessionUser, sessionUser: data.sessionUser,
}); });
companyId = String(credential.companyId);
return [this.sanitizeCredential(credential)];
} else { } else {
throw new Error("Se requiere una organización para buscar credenciales ARCA"); throw new Error("Se requiere una organización para buscar credenciales ARCA");
} }
await this.requirePaymentsFeature(companyId);
const credentials = await this.arcaCredentials.find(data); const credentials = await this.arcaCredentials.find(data);
return credentials.map((credential) => this.sanitizeCredential(credential)); return credentials.map((credential) => this.sanitizeCredential(credential));
} }
@@ -99,7 +99,9 @@ export class CashFlowAdapterMongoose implements ICashFlowAdapter {
searchCriteria.year = filters.year; searchCriteria.year = filters.year;
} }
if (filters.month) { // month 0 = enero: no usar truthy check, si no se dropea el filtro y enero
// queda sin filtrar (devolvería todos los meses del año).
if (typeof filters.month === "number") {
searchCriteria.month = filters.month; searchCriteria.month = filters.month;
} }
@@ -45,7 +45,8 @@ export class CashFlowBalanceAdapterMongoose implements ICashFlowBalanceAdapter {
searchCriteria.companyId = filters.companyId; searchCriteria.companyId = filters.companyId;
} }
if (filters.month) { // month 0 = enero: no usar truthy check, si no se dropea el filtro.
if (typeof filters.month === "number") {
searchCriteria.month = filters.month; searchCriteria.month = filters.month;
} }
@@ -231,7 +231,10 @@ class CashFlowBalanceManager implements ICashFlowBalanceManager {
const userSession = await this.validateSessionUser(data.sessionUser); const userSession = await this.validateSessionUser(data.sessionUser);
await this.validatePermissions(String(userSession.id), data.companyId); await this.validatePermissions(String(userSession.id), data.companyId);
const months = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; // Los balances se guardan con month 0-indexado (0 = enero ... 11 = diciembre),
// igual que dayjs.month(). No iterar de 1 a 12: eso saltea enero (key 0)
// y consultaba un mes 12 inexistente que el cliente renderiza como enero del año siguiente.
const months = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
const year = data.year; const year = data.year;
const companyId = data.companyId; const companyId = data.companyId;
@@ -32,6 +32,14 @@ export class ClientsAdapterMongoose implements IClientsAdapter {
banned: { type: Boolean, required: false, default: false }, banned: { type: Boolean, required: false, default: false },
}); });
//Unicidad por email dentro de la organización. La unicidad por usuario es
//parcial: excluye los clientes cuyo userId fue removido con $unset.
this.schema.index({ companyId: 1, email: 1 }, { unique: true });
this.schema.index(
{ companyId: 1, userId: 1 },
{ unique: true, partialFilterExpression: { userId: { $type: "oid" } } }
);
this.clientList = model<IClientDocument>("Client", this.schema); this.clientList = model<IClientDocument>("Client", this.schema);
} }
+41 -1
View File
@@ -266,6 +266,27 @@ class ClientManager implements IClientsManager {
throw new Error("El cliente ya pertenece a la compañia"); throw new Error("El cliente ya pertenece a la compañia");
} }
//Si la organización ya tiene un cliente con el email del usuario, se
//vincula el usuario a ese cliente en lugar de crear un duplicado.
const existingByEmail = await this.clients.findOne({
companyId: data.companyId,
email: userCheck.email,
});
if (existingByEmail) {
existingByEmail.userId = data.userId;
await existingByEmail.save();
await NotificationsManager.sendSystemNotification({
userId: String(userCheck._id),
subject: "Ha sido dado de alta como cliente",
message: `Su usuario ha sido dado de alta como cliente en la organización ${companyCheck.name}`,
type: NotificationType.BILLING
});
return existingByEmail;
}
const canAdd = await MetricsList.canAddClient(companyCheck.ownerId); const canAdd = await MetricsList.canAddClient(companyCheck.ownerId);
if (!canAdd) { if (!canAdd) {
@@ -274,7 +295,10 @@ class ClientManager implements IClientsManager {
); );
} }
const newClient = await this.clients.create({ let newClient: IClientDocument;
try {
newClient = await this.clients.create({
...data, ...data,
...{ ...{
email: userCheck.email, email: userCheck.email,
@@ -285,6 +309,22 @@ class ClientManager implements IClientsManager {
phoneNumber: userCheck.phoneNumber, phoneNumber: userCheck.phoneNumber,
}, },
}); });
} catch (error) {
//Carrera de concurrencia: el índice único rechazó el duplicado y la
//petición ganadora ya creó el cliente, lo contabilizó y notificó.
if ((error as { code?: number })?.code === 11000) {
const racedClient = await this.clients.findOne({
companyId: data.companyId,
userId: data.userId,
});
if (racedClient) {
return racedClient;
}
}
throw error;
}
await MetricsList.addClient({ await MetricsList.addClient({
userId: String(companyCheck.ownerId), userId: String(companyCheck.ownerId),
@@ -0,0 +1,284 @@
// Unit tests for ClientManager.createClientByUser: adopt-by-email path, create
// path, the existing userId guard, and the E11000 concurrent-create race.
// Mock all external dependencies before importing the module under test
jest.mock("../../Users/Users", () => ({
__esModule: true,
default: {
users: {
findOne: jest.fn(),
},
},
}));
jest.mock("../../Companies/Companies", () => ({
__esModule: true,
default: {
companies: {
findOne: jest.fn(),
},
},
}));
jest.mock("../../Employees/Employee", () => ({
__esModule: true,
default: {
checkPermission: jest.fn(),
},
}));
jest.mock("../../Metrics/Metrics", () => ({
__esModule: true,
default: {
canAddClient: jest.fn(),
addClient: jest.fn(),
},
}));
jest.mock("../../Notifications/Notifications", () => ({
__esModule: true,
NotificationsManager: {
sendSystemNotification: jest.fn(),
},
}));
jest.mock("../../Appointments/Appointments", () => ({
__esModule: true,
default: {
Appointments: {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn(),
delete: jest.fn(),
},
},
}));
jest.mock("../../Repeats/Repeats", () => ({
__esModule: true,
default: {
deleteRepeatsByClient: jest.fn(),
},
}));
jest.mock("../../ClientAccounts/ClientAccount", () => ({
__esModule: true,
default: {
getAvailableAmount: jest.fn(),
deleteClientAccount: jest.fn(),
},
}));
jest.mock("../../Payments/Payments", () => ({
__esModule: true,
default: {
deletePaymentsByClient: jest.fn(),
},
}));
import ClientList from "../Clients";
import UsersManager from "../../Users/Users";
import CompaniesManager from "../../Companies/Companies";
import MetricsList from "../../Metrics/Metrics";
import { NotificationsManager } from "../../Notifications/Notifications";
import { NotificationType } from "../../SystemNotifications/SystemNotification.Interface";
const request = {
companyId: "company-001",
userId: "user-001",
sessionUser: "session-user",
};
const sessionUser = { _id: "session-user" };
const company = {
_id: "company-001",
ownerId: "owner-user",
name: "Acme",
};
const user = {
_id: "user-001",
email: "client@example.com",
firstName: "John",
lastName: "Doe",
phoneAreaCode: "11",
phoneCountryCode: "54",
phoneNumber: "12345678",
};
// Helper to build a mock client document with save()
function buildClientDoc(overrides: Record<string, any> = {}): Record<string, any> {
return {
_id: "client-001",
id: "client-001",
companyId: "company-001",
email: "client@example.com",
firstName: "Admin Entered Name",
lastName: "Admin Entered Lastname",
save: jest.fn().mockResolvedValue(true),
...overrides,
};
}
describe("createClientByUser", () => {
// Keep the real adapter to restore it after each test and to inspect indexes
const realAdapter = ClientList.clients;
let mockFindOne: jest.Mock;
let mockCreate: jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
mockFindOne = jest.fn();
mockCreate = jest.fn();
(ClientList as any).clients = {
findOne: mockFindOne,
create: mockCreate,
};
// First call: session user validation. Second call: target user lookup.
(UsersManager.users.findOne as jest.Mock)
.mockResolvedValueOnce(sessionUser)
.mockResolvedValueOnce(user);
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue(company);
});
afterEach(() => {
(ClientList as any).clients = realAdapter;
});
// --- Adopt path: existing client by email ---
it("adopts the existing client found by email instead of creating a duplicate", async () => {
const existingClient = buildClientDoc();
mockFindOne
.mockResolvedValueOnce(null) // guard by companyId + userId
.mockResolvedValueOnce(existingClient); // lookup by companyId + email
const result = await ClientList.createClientByUser(request);
expect(mockCreate).not.toHaveBeenCalled();
expect(existingClient.userId).toBe("user-001");
expect(existingClient.save).toHaveBeenCalled();
// Admin-entered data must not be overwritten by the adopt path
expect(existingClient.firstName).toBe("Admin Entered Name");
expect(existingClient.lastName).toBe("Admin Entered Lastname");
// The client was already counted, so metrics are untouched
expect(MetricsList.canAddClient).not.toHaveBeenCalled();
expect(MetricsList.addClient).not.toHaveBeenCalled();
expect(NotificationsManager.sendSystemNotification).toHaveBeenCalledWith({
userId: "user-001",
subject: "Ha sido dado de alta como cliente",
message: "Su usuario ha sido dado de alta como cliente en la organización Acme",
type: NotificationType.BILLING,
});
expect(result).toBe(existingClient);
});
// --- Create path: no client by userId nor email ---
it("creates a new client when no client exists by userId nor email", async () => {
const newClient = buildClientDoc({ userId: "user-001" });
mockFindOne
.mockResolvedValueOnce(null) // guard by companyId + userId
.mockResolvedValueOnce(null); // lookup by companyId + email
(MetricsList.canAddClient as jest.Mock).mockResolvedValue(true);
mockCreate.mockResolvedValue(newClient);
const result = await ClientList.createClientByUser(request);
expect(mockCreate).toHaveBeenCalledWith({
companyId: "company-001",
userId: "user-001",
sessionUser: "session-user",
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
phoneAreaCode: user.phoneAreaCode,
phoneCountryCode: user.phoneCountryCode,
phoneNumber: user.phoneNumber,
});
expect(MetricsList.addClient).toHaveBeenCalledWith({
userId: "owner-user",
quantity: 1,
});
expect(NotificationsManager.sendSystemNotification).toHaveBeenCalledWith({
userId: "user-001",
subject: "Ha sido dado de alta como cliente",
message: "Su usuario ha sido dado de alta como cliente en la organización Acme",
type: NotificationType.BILLING,
});
expect(result).toBe(newClient);
});
// --- Existing guard preserved ---
it("throws when the client already belongs to the company", async () => {
mockFindOne.mockResolvedValueOnce(buildClientDoc()); // guard by companyId + userId
await expect(ClientList.createClientByUser(request)).rejects.toThrow(
/ya pertenece a la compan/
);
expect(mockCreate).not.toHaveBeenCalled();
expect(NotificationsManager.sendSystemNotification).not.toHaveBeenCalled();
});
// --- E11000 concurrent-create race ---
it("returns the winning client when create hits the unique index (E11000)", async () => {
const racedClient = buildClientDoc({ userId: "user-001" });
mockFindOne
.mockResolvedValueOnce(null) // guard by companyId + userId
.mockResolvedValueOnce(null) // lookup by companyId + email
.mockResolvedValueOnce(racedClient); // re-fetch after E11000
(MetricsList.canAddClient as jest.Mock).mockResolvedValue(true);
mockCreate.mockRejectedValue({ code: 11000 });
const result = await ClientList.createClientByUser(request);
expect(result).toBe(racedClient);
// The winning request already counted and notified
expect(MetricsList.addClient).not.toHaveBeenCalled();
expect(NotificationsManager.sendSystemNotification).not.toHaveBeenCalled();
});
it("rethrows non-duplicate-key errors from create", async () => {
mockFindOne
.mockResolvedValueOnce(null) // guard by companyId + userId
.mockResolvedValueOnce(null); // lookup by companyId + email
(MetricsList.canAddClient as jest.Mock).mockResolvedValue(true);
mockCreate.mockRejectedValue(new Error("database down"));
await expect(ClientList.createClientByUser(request)).rejects.toThrow("database down");
});
// --- Schema indexes ---
it("declares unique indexes for companyId+email and companyId+userId", () => {
const indexes = realAdapter.schema.indexes();
const emailIndex = indexes.find(
([fields]) => fields.companyId === 1 && fields.email === 1
);
expect(emailIndex).toBeDefined();
expect(emailIndex?.[1]).toMatchObject({ unique: true });
const userIdIndex = indexes.find(
([fields]) => fields.companyId === 1 && fields.userId === 1
);
expect(userIdIndex).toBeDefined();
expect(userIdIndex?.[1]).toMatchObject({
unique: true,
partialFilterExpression: { userId: { $type: "oid" } },
});
});
});
@@ -72,6 +72,8 @@ export class CompaniesAdapterMongoose implements ICompaniesAdapter {
showPublicProfessionals: { type: Boolean, required: false, default: true }, showPublicProfessionals: { type: Boolean, required: false, default: true },
}); });
this.schema.index({ published: 1, banned: 1 });
this.companyList = model<ICompanyDocument>("Company", this.schema); this.companyList = model<ICompanyDocument>("Company", this.schema);
} }
@@ -380,4 +380,5 @@ export interface ICompaniesManager {
sysAdminUpdate(data: SysAdminUpdateCompanyParams): Promise<void>; sysAdminUpdate(data: SysAdminUpdateCompanyParams): Promise<void>;
sysAdminSetBanned(data: SysAdminSetCompanyBannedParams): Promise<void>; sysAdminSetBanned(data: SysAdminSetCompanyBannedParams): Promise<void>;
sysAdminGetInsights(data: SysAdminCompanyInsightsParams): Promise<SysAdminCompanyInsightsResult>; sysAdminGetInsights(data: SysAdminCompanyInsightsParams): Promise<SysAdminCompanyInsightsResult>;
checkCompanyAdmin(data: { companyId: string; sessionUser: string }): Promise<boolean>;
} }
+13
View File
@@ -1221,6 +1221,19 @@ class CompaniesManager implements ICompaniesManager {
}, },
}; };
} }
public async checkCompanyAdmin(data: { companyId: string; sessionUser: string }): Promise<boolean> {
const filterData =
data.companyId.indexOf("-") >= 0 ? { slug: data.companyId } : { _id: data.companyId };
const company = await this.companies.findOne(filterData as any);
if (!company) {
return false;
}
return await EmployeesList.checkPermission(company, data.sessionUser, EmployeeRoles.ADMIN);
}
} }
const CompaniesList = new CompaniesManager(); const CompaniesList = new CompaniesManager();
@@ -157,7 +157,10 @@ export class MetricsAdapterMongoose implements IMetricsAdapter {
if (!metrics.repeatsCount) { if (!metrics.repeatsCount) {
metrics.repeatsCount = 0; metrics.repeatsCount = 0;
} }
metrics.repeatsCount += isNull<number>(data.quantity, 1); metrics.repeatsCount = Math.max(
0,
isNull<number>(metrics.repeatsCount, 0) + isNull<number>(data.quantity, 1)
);
await metrics.save(); await metrics.save();
} }
} }
@@ -37,6 +37,7 @@ export interface IMetricsAdapter {
addService(data: MetricsParams): Promise<void>; addService(data: MetricsParams): Promise<void>;
addAppointment(data: MetricsParams): Promise<void>; addAppointment(data: MetricsParams): Promise<void>;
addClient(data: MetricsParams): Promise<void>; addClient(data: MetricsParams): Promise<void>;
addRepeat(data: MetricsParams): Promise<void>;
} }
export interface IMetricsManager { export interface IMetricsManager {
@@ -50,6 +51,7 @@ export interface IMetricsManager {
reserveAppointment(data: MetricsParams): Promise<boolean>; reserveAppointment(data: MetricsParams): Promise<boolean>;
releaseAppointment(data: MetricsParams): Promise<void>; releaseAppointment(data: MetricsParams): Promise<void>;
addClient(data: MetricsParams): Promise<void>; addClient(data: MetricsParams): Promise<void>;
addRepeat(data: MetricsParams): Promise<void>;
canAddOrganization(userId: string): Promise<boolean>; canAddOrganization(userId: string): Promise<boolean>;
canAddEmployee(userId: string): Promise<boolean>; canAddEmployee(userId: string): Promise<boolean>;
canAddService(userId: string): Promise<boolean>; canAddService(userId: string): Promise<boolean>;
@@ -23,6 +23,8 @@ export interface NotificationJobPayload {
message?: string; message?: string;
emailSubject?: string; emailSubject?: string;
emailMessage?: string; emailMessage?: string;
emailTemplateId?: string;
emailContext?: Record<string, unknown>;
wapMessage?: string; wapMessage?: string;
systemSubject?: string; systemSubject?: string;
systemMessage?: string; systemMessage?: string;
@@ -5,9 +5,11 @@ import {
import { NotificationJobsAdapterMongoose } from "../NotificationJobs/NotificationJobs.Adapter.Mongoose"; import { NotificationJobsAdapterMongoose } from "../NotificationJobs/NotificationJobs.Adapter.Mongoose";
import { PolicyResolver, ResolvedPolicy } from "./PolicyResolver"; import { PolicyResolver, ResolvedPolicy } from "./PolicyResolver";
import dayjs from "dayjs"; import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone);
export interface CreateJobServiceParams { export interface CreateJobServiceParams {
companyId: string; companyId: string;
@@ -66,12 +68,17 @@ export class NotificationJobService {
); );
for (const rule of enabledReminderRules) { for (const rule of enabledReminderRules) {
const reminderScheduledAt = this.adjustForQuietHours( const originalReminderScheduledAt = dayjs(params.appointmentStart)
dayjs(params.appointmentStart).subtract(rule.offset, "minute").toDate(), .subtract(rule.offset, "minute")
.toDate();
const reminderScheduledAt = this.selectAutomaticReminderScheduledAt(
originalReminderScheduledAt,
now,
params.appointmentStart,
resolvedPolicy resolvedPolicy
); );
if (reminderScheduledAt < now || reminderScheduledAt >= params.appointmentStart) { if (reminderScheduledAt === null) {
continue; continue;
} }
@@ -169,12 +176,17 @@ export class NotificationJobService {
); );
for (const rule of enabledReminderRules) { for (const rule of enabledReminderRules) {
const reminderScheduledAt = this.adjustForQuietHours( const originalReminderScheduledAt = dayjs(params.appointmentStart)
dayjs(params.appointmentStart).subtract(rule.offset, "minute").toDate(), .subtract(rule.offset, "minute")
.toDate();
const reminderScheduledAt = this.selectAutomaticReminderScheduledAt(
originalReminderScheduledAt,
now,
params.appointmentStart,
resolvedPolicy resolvedPolicy
); );
if (reminderScheduledAt < now || reminderScheduledAt >= params.appointmentStart) { if (reminderScheduledAt === null) {
continue; continue;
} }
@@ -239,6 +251,78 @@ export class NotificationJobService {
return scheduledAt; return scheduledAt;
} }
private selectAutomaticReminderScheduledAt(
originalScheduledAt: Date,
now: Date,
appointmentStart: Date,
policy: ResolvedPolicy
): Date | null {
const isValidCandidate = (candidate: Date): boolean =>
candidate >= now && candidate < appointmentStart;
if (!policy.quietHours?.from || !policy.quietHours?.to) {
return isValidCandidate(originalScheduledAt) ? originalScheduledAt : null;
}
const fromHour = parseInt(policy.quietHours.from.split(":")[0], 10);
const fromMinute = parseInt(policy.quietHours.from.split(":")[1], 10);
const toHour = parseInt(policy.quietHours.to.split(":")[0], 10);
const toMinute = parseInt(policy.quietHours.to.split(":")[1], 10);
const localScheduledAt = dayjs(originalScheduledAt).tz(policy.timezone);
const scheduledMinutes = localScheduledAt.hour() * 60 + localScheduledAt.minute();
const isInsideQuietHours = this.isTimeInQuietHours(
localScheduledAt.hour(),
localScheduledAt.minute(),
fromHour,
fromMinute,
toHour,
toMinute
);
if (!isInsideQuietHours) {
return isValidCandidate(originalScheduledAt) ? originalScheduledAt : null;
}
const fromMinutes = fromHour * 60 + fromMinute;
const toMinutes = toHour * 60 + toMinute;
const wrapsAcrossMidnight = fromMinutes >= toMinutes;
const isEarlyMorningSegment = scheduledMinutes < fromMinutes;
const localDay = localScheduledAt.startOf("day");
const quietStartDay =
wrapsAcrossMidnight && isEarlyMorningSegment
? localDay.subtract(1, "day")
: localDay;
const quietEndDay =
wrapsAcrossMidnight && !isEarlyMorningSegment
? localDay.add(1, "day")
: localDay;
const quietStart = quietStartDay
.hour(fromHour)
.minute(fromMinute)
.second(0)
.millisecond(localScheduledAt.millisecond());
const quietEnd = quietEndDay
.hour(toHour)
.minute(toMinute)
.second(0)
.millisecond(localScheduledAt.millisecond());
const advanceCandidate = quietStart.subtract(1, "minute").toDate();
const postponeCandidate = quietEnd.toDate();
const validAdvanceCandidate = isValidCandidate(advanceCandidate)
? advanceCandidate
: null;
const validPostponeCandidate = isValidCandidate(postponeCandidate)
? postponeCandidate
: null;
if (validAdvanceCandidate) {
return validAdvanceCandidate;
}
return validPostponeCandidate;
}
private getPayloadChannels(payload?: CreateJobParams["payload"]): NotificationChannel[] { private getPayloadChannels(payload?: CreateJobParams["payload"]): NotificationChannel[] {
const channels: NotificationChannel[] = []; const channels: NotificationChannel[] = [];
@@ -180,28 +180,35 @@ describe("NotificationJobService.createJob", () => {
expect(createCalls.filter((job) => job.type === "reminder")).toHaveLength(0); expect(createCalls.filter((job) => job.type === "reminder")).toHaveLength(0);
}); });
it("skips reminder jobs adjusted by quiet hours after appointment start", async () => { it("keeps UTC adjustment for the createJob base job and uses local candidates for reminders", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T20:00:00.000Z")); jest.useFakeTimers().setSystemTime(new Date("2026-07-21T23:00:00.000Z"));
const resolvedPolicy: ResolvedPolicy = { const resolvedPolicy: ResolvedPolicy = {
channels: ["system", "email"], channels: ["system"],
timezone: "America/Argentina/Buenos_Aires", timezone: "America/Argentina/Buenos_Aires",
quietHours: { from: "22:00", to: "07:00" }, quietHours: { from: "22:00", to: "07:00" },
reminderRules: [{ offset: 15, enabled: true }], reminderRules: [{ offset: 30, enabled: true }],
}; };
mockResolverResolve.mockResolvedValue(resolvedPolicy); mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" }); mockJobCreate.mockResolvedValue({ id: "job-1" });
const baseScheduledAt = new Date("2026-07-22T02:00:00.000Z");
await service.createJob({ await service.createJob({
companyId: "company-1", companyId: "company-1",
clientId: "client-1", clientId: "client-1",
appointmentId: "appt-1", appointmentId: "appt-1",
type: "creation", type: "update",
appointmentStart: new Date("2026-07-21T23:30:00.000Z"), scheduledAt: baseScheduledAt,
appointmentStart: new Date("2026-07-22T03:00:00.000Z"),
}); });
const createCalls = mockJobCreate.mock.calls.map((call) => call[0]); const createCalls = mockJobCreate.mock.calls.map((call) => call[0]);
expect(createCalls.filter((job) => job.type === "creation")).toHaveLength(2); expect(createCalls).toHaveLength(2);
expect(createCalls.filter((job) => job.type === "reminder")).toHaveLength(0); expect(createCalls.find((job) => job.type === "update")?.scheduledAt.toISOString()).toBe(
"2026-07-22T07:00:00.000Z"
);
expect(createCalls.find((job) => job.type === "reminder")?.scheduledAt.toISOString()).toBe(
"2026-07-22T00:59:00.000Z"
);
}); });
it("creates no jobs when resolved channels are empty", async () => { it("creates no jobs when resolved channels are empty", async () => {
@@ -330,77 +337,208 @@ describe("NotificationJobService.createJob", () => {
expect(result).toHaveLength(0); expect(result).toHaveLength(0);
}); });
it("adjusts scheduledAt for quiet hours crossing midnight", async () => { it("keeps an automatic reminder at its original instant outside quiet hours", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T07:00:00.000Z"));
const resolvedPolicy: ResolvedPolicy = { const resolvedPolicy: ResolvedPolicy = {
channels: ["system"], channels: ["system"],
timezone: "America/Argentina/Buenos_Aires", timezone: "UTC",
quietHours: { from: "22:00", to: "07:00" }, quietHours: { from: "08:00", to: "10:00" },
reminderRules: [{ offset: 270, enabled: true }],
}; };
mockResolverResolve.mockResolvedValue(resolvedPolicy); mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" }); mockJobCreate.mockResolvedValue({ id: "job-1" });
// scheduledAt at 23:30 UTC — should be adjusted to 07:00 next day UTC const result = await service.createReminderJobs({
const scheduledAt = new Date("2026-07-21T23:30:00.000Z");
await service.createJob({
companyId: "company-1", companyId: "company-1",
clientId: "client-1", clientId: "client-1",
appointmentId: "appt-1", appointmentId: "appt-1",
type: "creation", type: "reminder",
scheduledAt, appointmentStart: new Date("2026-07-21T12:00:00.123Z"),
}); });
const createCall = mockJobCreate.mock.calls[0][0]; expect(result).toHaveLength(1);
// 23:30 is inside quiet hours (22:00-07:00), adjusted to 07:00 next day expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
expect(createCall.scheduledAt.getUTCHours()).toBe(7); "2026-07-21T07:30:00.123Z"
expect(createCall.scheduledAt.getUTCDate()).toBe(22); // next day );
}); });
it("adjusts early morning quiet hours crossing midnight to the same day", async () => { it("prefers the pre-quiet candidate when the quiet-hours end is closer for a same-day range", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T00:00:00.000Z"));
const resolvedPolicy: ResolvedPolicy = { const resolvedPolicy: ResolvedPolicy = {
channels: ["system"], channels: ["system"],
timezone: "America/Argentina/Buenos_Aires", timezone: "UTC",
quietHours: { from: "22:00", to: "07:00" }, quietHours: { from: "01:00", to: "06:00" },
reminderRules: [{ offset: 150, enabled: true }],
}; };
mockResolverResolve.mockResolvedValue(resolvedPolicy); mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" }); mockJobCreate.mockResolvedValue({ id: "job-1" });
await service.createJob({ const result = await service.createReminderJobs({
companyId: "company-1", companyId: "company-1",
clientId: "client-1", clientId: "client-1",
appointmentId: "appt-1", appointmentId: "appt-1",
type: "creation", type: "reminder",
scheduledAt: new Date("2026-07-21T05:00:00.000Z"), appointmentStart: new Date("2026-07-21T08:00:00.123Z"),
}); });
const createCall = mockJobCreate.mock.calls[0][0]; expect(result).toHaveLength(1);
expect(createCall.scheduledAt.toISOString()).toBe("2026-07-21T07:00:00.000Z"); expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
"2026-07-21T00:59:00.123Z"
);
}); });
it("does not adjust scheduledAt when outside quiet hours", async () => { it("uses the policy timezone for a cross-midnight range and advances to the prior local day", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T22:00:00.000Z"));
const resolvedPolicy: ResolvedPolicy = { const resolvedPolicy: ResolvedPolicy = {
channels: ["system"], channels: ["system"],
timezone: "America/Argentina/Buenos_Aires", timezone: "America/Argentina/Buenos_Aires",
quietHours: { from: "22:00", to: "07:00" }, quietHours: { from: "22:00", to: "07:00" },
reminderRules: [{ offset: 510, enabled: true }],
}; };
mockResolverResolve.mockResolvedValue(resolvedPolicy); mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" }); mockJobCreate.mockResolvedValue({ id: "job-1" });
// scheduledAt at 14:00 UTC — outside quiet hours (22:00-07:00) const result = await service.createReminderJobs({
const scheduledAt = new Date("2026-07-21T14:00:00.000Z");
await service.createJob({
companyId: "company-1", companyId: "company-1",
clientId: "client-1", clientId: "client-1",
appointmentId: "appt-1", appointmentId: "appt-1",
type: "creation", type: "reminder",
scheduledAt, appointmentStart: new Date("2026-07-22T12:00:00.123Z"),
}); });
const createCall = mockJobCreate.mock.calls[0][0]; expect(result).toHaveLength(1);
// 14:00 is outside quiet hours, should remain unchanged expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
expect(createCall.scheduledAt.getUTCHours()).toBe(14); "2026-07-22T00:59:00.123Z"
expect(createCall.scheduledAt.getUTCDate()).toBe(21); );
});
it("advances to one minute before quiet-hours start when both candidates are valid", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T07:00:00.000Z"));
const resolvedPolicy: ResolvedPolicy = {
channels: ["system"],
timezone: "UTC",
quietHours: { from: "08:00", to: "10:00" },
reminderRules: [{ offset: 210, enabled: true }],
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" });
const result = await service.createReminderJobs({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "reminder",
appointmentStart: new Date("2026-07-21T12:00:00.123Z"),
});
expect(result).toHaveLength(1);
expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
"2026-07-21T07:59:00.123Z"
);
});
it("postpones when the advance candidate is before now", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T08:30:00.000Z"));
const resolvedPolicy: ResolvedPolicy = {
channels: ["system"],
timezone: "UTC",
quietHours: { from: "08:00", to: "10:00" },
reminderRules: [{ offset: 150, enabled: true }],
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" });
const result = await service.createReminderJobs({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "reminder",
appointmentStart: new Date("2026-07-21T12:00:00.123Z"),
});
expect(result).toHaveLength(1);
expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
"2026-07-21T10:00:00.123Z"
);
});
it("prefers the pre-quiet candidate on an exact candidate-distance tie", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T07:00:00.000Z"));
const resolvedPolicy: ResolvedPolicy = {
channels: ["system"],
timezone: "UTC",
quietHours: { from: "08:00", to: "10:00" },
reminderRules: [{ offset: 180, enabled: true }],
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" });
const result = await service.createReminderJobs({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "reminder",
appointmentStart: new Date("2026-07-21T11:59:30.123Z"),
});
expect(result).toHaveLength(1);
expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
"2026-07-21T07:59:00.123Z"
);
});
it("treats quiet-hours start as inclusive and end as exclusive", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T07:00:00.000Z"));
const resolvedPolicy: ResolvedPolicy = {
channels: ["system"],
timezone: "UTC",
quietHours: { from: "08:00", to: "10:00" },
reminderRules: [
{ offset: 240, enabled: true },
{ offset: 120, enabled: true },
],
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" });
const result = await service.createReminderJobs({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "reminder",
appointmentStart: new Date("2026-07-21T12:00:00.123Z"),
});
const scheduledTimes = mockJobCreate.mock.calls
.map((call) => call[0].scheduledAt.toISOString())
.sort();
expect(result).toHaveLength(2);
expect(scheduledTimes).toEqual([
"2026-07-21T07:59:00.123Z",
"2026-07-21T10:00:00.123Z",
]);
});
it("omits a reminder when the advance is past and postponement reaches appointment start", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T09:30:00.000Z"));
const resolvedPolicy: ResolvedPolicy = {
channels: ["system"],
timezone: "UTC",
quietHours: { from: "08:00", to: "10:00" },
reminderRules: [{ offset: 60, enabled: true }],
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
const result = await service.createReminderJobs({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "reminder",
appointmentStart: new Date("2026-07-21T10:00:00.000Z"),
});
expect(result).toHaveLength(0);
expect(mockJobCreate).not.toHaveBeenCalled();
}); });
it("passes appointmentId and type to job creation", async () => { it("passes appointmentId and type to job creation", async () => {
@@ -428,21 +566,24 @@ describe("NotificationJobService.createJob", () => {
expect(createCall.clientId).toBe("client-1"); expect(createCall.clientId).toBe("client-1");
}); });
it("creates immediate jobs without reminder offsets", async () => { it("does not apply quiet-hours adjustment to immediate reminder jobs", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T14:00:00.000Z")); jest.useFakeTimers().setSystemTime(new Date("2026-07-21T09:00:00.000Z"));
const resolvedPolicy: ResolvedPolicy = { const resolvedPolicy: ResolvedPolicy = {
channels: ["system", "email"], channels: ["system", "email"],
timezone: "America/Argentina/Buenos_Aires", timezone: "UTC",
quietHours: { from: "08:00", to: "10:00" },
reminderRules: [{ offset: 30, enabled: true }], reminderRules: [{ offset: 30, enabled: true }],
}; };
mockResolverResolve.mockResolvedValue(resolvedPolicy); mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" }); mockJobCreate.mockResolvedValue({ id: "job-1" });
const scheduledAt = new Date("2026-07-21T09:30:00.123Z");
const result = await service.createImmediateJob({ const result = await service.createImmediateJob({
companyId: "company-1", companyId: "company-1",
clientId: "client-1", clientId: "client-1",
appointmentId: "appt-1", appointmentId: "appt-1",
type: "reminder", type: "reminder",
scheduledAt,
appointmentStart: new Date("2026-07-21T16:00:00.000Z"), appointmentStart: new Date("2026-07-21T16:00:00.000Z"),
}); });
@@ -453,12 +594,12 @@ describe("NotificationJobService.createJob", () => {
expect.objectContaining({ expect.objectContaining({
channel: "system", channel: "system",
type: "reminder", type: "reminder",
scheduledAt: new Date("2026-07-21T14:00:00.000Z"), scheduledAt,
}), }),
expect.objectContaining({ expect.objectContaining({
channel: "email", channel: "email",
type: "reminder", type: "reminder",
scheduledAt: new Date("2026-07-21T14:00:00.000Z"), scheduledAt,
}), }),
]); ]);
}); });
@@ -3,13 +3,37 @@ import { INotificationsAdapter, NotificationDataEmail } from "./Notifications.In
export class NotificationsDonWebAdapter implements INotificationsAdapter<NotificationDataEmail> { export class NotificationsDonWebAdapter implements INotificationsAdapter<NotificationDataEmail> {
async send(data: NotificationDataEmail): Promise<void> { async send(data: NotificationDataEmail): Promise<void> {
const dataEmail = { if (data.context && data.substitutions) {
throw new Error("No se puede enviar un email con context y substitutions al mismo tiempo.");
}
if (!data.templateId && !data.message) {
throw new Error("Debe indicarse el contenido del email mediante message o templateId.");
}
const dataEmail: Record<string, unknown> = {
from: "norepply@turnosxpress.com.ar", from: "norepply@turnosxpress.com.ar",
to: data.email, to: data.email,
subject: data.subject,
html: data.message,
}; };
if (data.templateId) {
dataEmail.templateID = data.templateId;
} else {
dataEmail.html = data.message;
}
if (data.subject) {
dataEmail.subject = data.subject;
}
if (data.context) {
dataEmail.context = data.context;
}
if (data.substitutions) {
dataEmail.substitutions = data.substitutions;
}
const apiConfig = { const apiConfig = {
method: "post", method: "post",
maxBodyLength: Infinity, maxBodyLength: Infinity,
@@ -6,8 +6,11 @@ export enum NOTIFICATION_TYPES {
export type NotificationDataEmail = { export type NotificationDataEmail = {
email: string; email: string;
subject: string; subject?: string;
message: string; message?: string;
templateId?: string;
context?: Record<string, unknown>;
substitutions?: Record<string, string | number | boolean>;
}; };
export type NotificationDataWap = { export type NotificationDataWap = {
@@ -1,4 +1,7 @@
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check"; import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
import CompaniesList from "../Companies/Companies";
import PlanSubscriptionsList from "../PlanSubscriptions/PlanSubscriptons";
import { PlanFeatures } from "../Plans/Plans.interface";
import { findArcaActivityByCode } from "../ArcaActivities/ArcaActivities.Catalog"; import { findArcaActivityByCode } from "../ArcaActivities/ArcaActivities.Catalog";
import { OrganizationFiscalProfilesAdapterMongoose } from "./OrganizationFiscalProfiles.Adapter.Mongoose"; import { OrganizationFiscalProfilesAdapterMongoose } from "./OrganizationFiscalProfiles.Adapter.Mongoose";
import { import {
@@ -70,6 +73,21 @@ class OrganizationFiscalProfilesManager implements IOrganizationFiscalProfilesMa
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser }); await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
const company = await CompaniesList.companies.findOne({ _id: data.companyId });
if (!company) {
throw new Error("No se ha encontrado la organización");
}
const canAccessPayments = await PlanSubscriptionsList.checkFeature({
userId: String(company.ownerId),
feature: PlanFeatures.PAYMENTS,
});
if (!canAccessPayments) {
throw new Error("La organización no tiene un plan que soporte el módulo de pagos");
}
this.validateProfileFields(data); this.validateProfileFields(data);
const existingProfile = await this.organizationFiscalProfiles.findOne({ const existingProfile = await this.organizationFiscalProfiles.findOne({
@@ -140,6 +158,18 @@ class OrganizationFiscalProfilesManager implements IOrganizationFiscalProfilesMa
companyId: data.companyId, companyId: data.companyId,
sessionUser, sessionUser,
}); });
const company = await CompaniesList.companies.findOne({ _id: data.companyId });
if (company) {
const canAccessPayments = await PlanSubscriptionsList.checkFeature({
userId: String(company.ownerId),
feature: PlanFeatures.PAYMENTS,
});
if (!canAccessPayments) {
throw new Error("La organización no tiene un plan que soporte el módulo de pagos");
}
}
} else if (data.id) { } else if (data.id) {
const profile = await this.organizationFiscalProfiles.findOne(data); const profile = await this.organizationFiscalProfiles.findOne(data);
@@ -152,6 +182,18 @@ class OrganizationFiscalProfilesManager implements IOrganizationFiscalProfilesMa
sessionUser, sessionUser,
}); });
const company = await CompaniesList.companies.findOne({ _id: String(profile.companyId) });
if (company) {
const canAccessPayments = await PlanSubscriptionsList.checkFeature({
userId: String(company.ownerId),
feature: PlanFeatures.PAYMENTS,
});
if (!canAccessPayments) {
throw new Error("La organización no tiene un plan que soporte el módulo de pagos");
}
}
return [profile]; return [profile];
} else { } else {
throw new Error("Se requiere una organización para buscar perfiles fiscales"); throw new Error("Se requiere una organización para buscar perfiles fiscales");
@@ -112,7 +112,6 @@ export class PlanUsageCycleAdapterMongoose implements IPlanUsageCycleAdapter {
planId: data.planId, planId: data.planId,
cycleStart: data.cycleStart, cycleStart: data.cycleStart,
cycleEnd: data.cycleEnd, cycleEnd: data.cycleEnd,
appointmentsCount: 0,
creationDate: now, creationDate: now,
}, },
$inc: { appointmentsCount: data.quantity }, $inc: { appointmentsCount: data.quantity },
+1
View File
@@ -212,6 +212,7 @@ class RepeatsManager implements IRepeatsManager {
} }
await this.repeats.delete(data.repeatId); await this.repeats.delete(data.repeatId);
await MetricsList.addRepeat({ userId: String(companyCheck.ownerId), quantity: -1 });
} }
public async deleteRepeatsByCompany(data: DeleteRepeatsByCompanyParams): Promise<void> { public async deleteRepeatsByCompany(data: DeleteRepeatsByCompanyParams): Promise<void> {
@@ -8,6 +8,34 @@ export type ScheduleItem = {
serviceIds?: string[]; serviceIds?: string[];
}; };
export type ScheduleConflictStrategy = "reject" | "overwrite-conflicts";
export type ScheduleConflictDetail = {
employeeId: string;
employeeName: string;
weekDay: number;
weekDayLabel: string;
attempted: ScheduleItem;
existing: ScheduleItem;
message: string;
};
export type ScheduleApplyParams = {
companyId: string;
employeeIds: string[];
weekDays: number[];
schedules: ScheduleItem[];
sessionUser: string;
conflictStrategy: ScheduleConflictStrategy;
};
export type ScheduleApplyResult = {
valid: boolean;
applied: boolean;
conflicts: ScheduleConflictDetail[];
message: string;
};
export type FindSchedulesParams = { export type FindSchedulesParams = {
_id?: string; _id?: string;
companyId?: string; companyId?: string;
@@ -100,6 +128,7 @@ export interface ISchedulesManager {
findAllSchedulesByCollaborator(data: FindSchedulesParams): Promise<CollaboratorSchedulesView>; findAllSchedulesByCollaborator(data: FindSchedulesParams): Promise<CollaboratorSchedulesView>;
delete(data: DeleteScheduleParams): Promise<void>; delete(data: DeleteScheduleParams): Promise<void>;
isAvailable(data: AvailableSchedulesParams): Promise<boolean>; isAvailable(data: AvailableSchedulesParams): Promise<boolean>;
applyScheduleChanges(data: ScheduleApplyParams): Promise<ScheduleApplyResult>;
deleteSchedulesByCompany(data: DeleteScheulesByCompanyParams): Promise<void>; deleteSchedulesByCompany(data: DeleteScheulesByCompanyParams): Promise<void>;
deleteSchedulesByEmployee(data: DeleteSchedulesByEmployeeParams): Promise<void>; deleteSchedulesByEmployee(data: DeleteSchedulesByEmployeeParams): Promise<void>;
} }
+169
View File
@@ -13,6 +13,9 @@ import {
DeleteScheulesByCompanyParams, DeleteScheulesByCompanyParams,
DeleteSchedulesByEmployeeParams, DeleteSchedulesByEmployeeParams,
ScheduleItem, ScheduleItem,
ScheduleApplyParams,
ScheduleApplyResult,
ScheduleConflictDetail,
} from "./Schedules.Interface"; } from "./Schedules.Interface";
import { EmployeeRoles } from "../Employees/Employees.Interface"; import { EmployeeRoles } from "../Employees/Employees.Interface";
import UsersManager from "../Users/Users"; import UsersManager from "../Users/Users";
@@ -24,6 +27,9 @@ import { isNull } from "../../helpers/IsNull";
import getAvatar from "../../helpers/getAvatar"; import getAvatar from "../../helpers/getAvatar";
import { NotificationsManager } from "../Notifications/Notifications"; import { NotificationsManager } from "../Notifications/Notifications";
import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface"; import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface";
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
const WEEK_DAY_LABELS = ["Domingo", "Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado"];
class SchedulesManager implements ISchedulesManager { class SchedulesManager implements ISchedulesManager {
schedules: SchedulesAdapterMongoose; schedules: SchedulesAdapterMongoose;
@@ -173,6 +179,169 @@ class SchedulesManager implements ISchedulesManager {
return await this.schedules.find(filters); return await this.schedules.find(filters);
} }
private parseTimeToMinutes(time: string): number {
const [hours, minutes] = time.split(":").map((part) => Number(part));
if (!Number.isInteger(hours) || !Number.isInteger(minutes)) {
throw new Error("El horario indicado no es válido");
}
return hours * 60 + minutes;
}
private hasTimeOverlap(first: ScheduleItem, second: ScheduleItem): boolean {
return this.parseTimeToMinutes(first.from) < this.parseTimeToMinutes(second.to)
&& this.parseTimeToMinutes(second.from) < this.parseTimeToMinutes(first.to);
}
private getServiceScope(schedule: ScheduleItem): "all" | "specific" {
return schedule.serviceScope || "all";
}
private hasServiceOverlap(first: ScheduleItem, second: ScheduleItem): boolean {
const firstScope = this.getServiceScope(first);
const secondScope = this.getServiceScope(second);
if (firstScope === "all" || secondScope === "all") {
return true;
}
const secondServices = new Set(second.serviceIds || []);
return (first.serviceIds || []).some((serviceId) => secondServices.has(serviceId));
}
private hasScheduleConflict(first: ScheduleItem, second: ScheduleItem): boolean {
if (first.disabled || second.disabled) {
return false;
}
return this.hasTimeOverlap(first, second) && this.hasServiceOverlap(first, second);
}
private formatSchedule(schedule: ScheduleItem): string {
if (this.getServiceScope(schedule) === "all") {
return `${schedule.from} a ${schedule.to} para todos los servicios`;
}
return `${schedule.from} a ${schedule.to} para servicios específicos`;
}
private getEmployeeName(employee: any): string {
const snapshotName = [employee.profileSnapshot?.firstName, employee.profileSnapshot?.lastName].filter(Boolean).join(" ").trim();
return snapshotName || employee.fullName || employee.name || String(employee._id);
}
private buildConflict(employee: any, weekDay: number, attempted: ScheduleItem, existing: ScheduleItem): ScheduleConflictDetail {
const employeeId = String(employee._id);
const employeeName = this.getEmployeeName(employee);
const weekDayLabel = WEEK_DAY_LABELS[weekDay] || String(weekDay);
return {
employeeId,
employeeName,
weekDay,
weekDayLabel,
attempted,
existing,
message: `${employeeName} ya tiene un horario el ${weekDayLabel} de ${this.formatSchedule(existing)} que se superpone con ${this.formatSchedule(attempted)}.`,
};
}
private async findScheduleDocument(companyId: string, employeeId: string, weekDay: number) {
return this.schedules.findOne({ companyId, employeeId, weekDay });
}
public async applyScheduleChanges(data: ScheduleApplyParams): Promise<ScheduleApplyResult> {
await validateSessionUser({ sessionUser: data.sessionUser });
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
if (!Array.isArray(data.employeeIds) || data.employeeIds.length === 0) {
throw new Error("Tenés que seleccionar al menos un colaborador.");
}
if (!Array.isArray(data.weekDays) || data.weekDays.length === 0 || data.weekDays.some((day) => !Number.isInteger(day) || day < 0 || day > 6)) {
throw new Error("Tenés que seleccionar días válidos entre 0 y 6.");
}
if (data.conflictStrategy !== "reject" && data.conflictStrategy !== "overwrite-conflicts") {
throw new Error("La estrategia de conflictos no es válida.");
}
const employees = await Promise.all(data.employeeIds.map(async (employeeId) => {
const employee = await EmployeesList.employees.findOne({ companyId: data.companyId, _id: employeeId });
if (!employee) {
throw new Error("El empleado no existe o no pertenece a la compañia.");
}
return employee;
}));
const conflicts: ScheduleConflictDetail[] = [];
for (const employee of employees) {
const employeeId = String(employee._id);
for (const weekDay of data.weekDays) {
const scheduleDocument = await this.findScheduleDocument(data.companyId, employeeId, weekDay);
const existingSchedules = scheduleDocument?.schedules || [];
for (const attempted of data.schedules) {
const existing = existingSchedules.find((schedule) => this.hasScheduleConflict(attempted, schedule));
if (existing) {
conflicts.push(this.buildConflict(employee, weekDay, attempted, existing));
}
}
}
}
if (conflicts.length > 0 && data.conflictStrategy === "reject") {
return {
valid: false,
applied: false,
conflicts,
message: "Encontramos horarios superpuestos. Revisalos antes de guardar o elegí sobrescribir solo esos conflictos.",
};
}
for (const employee of employees) {
const employeeId = String(employee._id);
for (const weekDay of data.weekDays) {
const scheduleDocument = await this.findScheduleDocument(data.companyId, employeeId, weekDay);
const existingSchedules = scheduleDocument?.schedules || [];
const nextSchedules = data.conflictStrategy === "overwrite-conflicts"
? existingSchedules.filter((existing) => !data.schedules.some((attempted) => this.hasScheduleConflict(attempted, existing)))
: existingSchedules;
const schedules = [...nextSchedules, ...data.schedules];
if (scheduleDocument) {
scheduleDocument.schedules = schedules;
await scheduleDocument.save();
} else {
await this.schedules.create({
companyId: data.companyId,
employeeId,
weekDay,
schedules,
sessionUser: data.sessionUser,
});
}
}
}
return {
valid: conflicts.length === 0,
applied: true,
conflicts,
message: conflicts.length > 0
? "Se sobrescribieron solo los horarios en conflicto."
: "Los horarios se guardaron correctamente.",
};
}
private isScheduleAvailableForService(schedule: ScheduleItem, serviceId?: string): boolean { private isScheduleAvailableForService(schedule: ScheduleItem, serviceId?: string): boolean {
if (!serviceId || !schedule.serviceScope || schedule.serviceScope === "all") { if (!serviceId || !schedule.serviceScope || schedule.serviceScope === "all") {
return true; return true;
@@ -32,6 +32,12 @@ export class SchedulesDisabledAdapterMongoose implements ISchedulesDisabledAdapt
await this.schedulesDisabledList.deleteOne({ _id: id }).exec(); await this.schedulesDisabledList.deleteOne({ _id: id }).exec();
} }
public async find(
filters: Omit<FindSchedulesDisabledParams, "sessionUser">
): Promise<ISchedulesDisabledDocument[]> {
return this.schedulesDisabledList.find(filters).exec();
}
public async findOne( public async findOne(
filters: Omit<FindSchedulesDisabledParams, "sessionUser"> filters: Omit<FindSchedulesDisabledParams, "sessionUser">
): Promise<ISchedulesDisabledDocument | null> { ): Promise<ISchedulesDisabledDocument | null> {
@@ -90,6 +90,7 @@ export interface SchedulesDisabledByCollaboratorView {
export interface ISchedulesDisabledAdapter { export interface ISchedulesDisabledAdapter {
create(data: CreateSchedulesDisabledParams): Promise<ISchedulesDisabled>; create(data: CreateSchedulesDisabledParams): Promise<ISchedulesDisabled>;
delete(id: string): Promise<void>; delete(id: string): Promise<void>;
find(filters: Omit<FindSchedulesDisabledParams, "sessionUser">): Promise<ISchedulesDisabled[]>;
findOne(filters: FindSchedulesDisabledParams): Promise<ISchedulesDisabled | null>; findOne(filters: FindSchedulesDisabledParams): Promise<ISchedulesDisabled | null>;
} }
@@ -98,6 +99,7 @@ export interface ISchedulesDisabledManager {
createSchedulesDisabled(data: CreateSchedulesDisabledParams): Promise<ISchedulesDisabled>; createSchedulesDisabled(data: CreateSchedulesDisabledParams): Promise<ISchedulesDisabled>;
disableSchedule(data: DisableScheduleParams): Promise<void>; disableSchedule(data: DisableScheduleParams): Promise<void>;
enableSchedule(data: DisableScheduleParams): Promise<void>; enableSchedule(data: DisableScheduleParams): Promise<void>;
find(data: FindSchedulesDisabledParams): Promise<ISchedulesDisabled[]>;
findOne(data: FindSchedulesDisabledParams): Promise<ISchedulesDisabled | null>; findOne(data: FindSchedulesDisabledParams): Promise<ISchedulesDisabled | null>;
findSchedulesDisabledByCollaborator( findSchedulesDisabledByCollaborator(
data: FindSchedulesDisabledParams data: FindSchedulesDisabledParams
@@ -271,6 +271,17 @@ class SchedulesDisabledManager implements ISchedulesDisabledManager {
return await this.schedulesDisabled.findOne(data); return await this.schedulesDisabled.findOne(data);
} }
public async find(data: FindSchedulesDisabledParams): Promise<ISchedulesDisabled[]> {
await validateSessionUser({ sessionUser: data.sessionUser });
await validatePermissionsByCompany({
sessionUser: data.sessionUser,
companyId: data.companyId,
});
const { sessionUser, ...filters } = data;
return await this.schedulesDisabled.find(filters);
}
public async delete(data: DeleteSchedulesDisabledParams): Promise<void> { public async delete(data: DeleteSchedulesDisabledParams): Promise<void> {
await validateSessionUser({ sessionUser: data.sessionUser }); await validateSessionUser({ sessionUser: data.sessionUser });
+64 -7
View File
@@ -63,6 +63,7 @@ import ClientsList from "../Clients/Clients";
import RepeatsList from "../Repeats/Repeats"; import RepeatsList from "../Repeats/Repeats";
import { OAuth2Client } from "google-auth-library"; import { OAuth2Client } from "google-auth-library";
import SchedulesDisabledList from "../SchedulesDisabled/SchedulesDisabled"; import SchedulesDisabledList from "../SchedulesDisabled/SchedulesDisabled";
import dayjs from "dayjs";
const cleanEmail = (email: string): string => { const cleanEmail = (email: string): string => {
return email.toLowerCase().trim(); return email.toLowerCase().trim();
@@ -351,6 +352,28 @@ class UsersManager implements IUsersManager {
.substring(0, 6); .substring(0, 6);
} }
private async sendWelcomeEmail(email: string, firstName?: string): Promise<void> {
try {
await NotificationsManager.sendEmail({
email: cleanEmail(email),
templateId: "68139f23f3e003139b08b4f0",
context: {
username: firstName || cleanEmail(email),
},
});
} catch (e) {
if (axios.isAxiosError(e)) {
console.error("Error enviando email de bienvenida:", {
status: e.response?.status,
data: e.response?.data,
});
return;
}
console.error("Error enviando email de bienvenida:", e);
}
}
public async signUp(data: SignUpParams): Promise<IUser> { public async signUp(data: SignUpParams): Promise<IUser> {
// Destructure email and password from the data object // Destructure email and password from the data object
const { const {
@@ -406,12 +429,13 @@ class UsersManager implements IUsersManager {
const newUser = await this.users.create(newUserData); const newUser = await this.users.create(newUserData);
const signUpUrl = "https://turnosxpress.com.ar/landing/login/verification";
await NotificationsManager.sendEmail({ await NotificationsManager.sendEmail({
email: cleanEmail(email), email: cleanEmail(email),
subject: "TurnosXpress :: Bienvenido/a", templateId: "6a85dd644ea98ccfcd05bdc7",
message: `Hola ${firstName}, Gracias por registrarte en turnosXpress. Primero debes activar tu cuenta ingresando en ${signUpUrl} . Tu código de verificación es: ${verificationCode}`, context: {
username: firstName || cleanEmail(email),
verificationcode: verificationCode,
},
}); });
return newUser; return newUser;
@@ -459,8 +483,11 @@ class UsersManager implements IUsersManager {
await NotificationsManager.sendEmail({ await NotificationsManager.sendEmail({
email: cleanEmail(data.email), email: cleanEmail(data.email),
subject: "TurnosXpress :: Recuperación de cuenta", templateId: "6a85c7ad96475f2825015277",
message: `Hola ${user.firstName}, Te enviamos un código de verificación para recuperar tu cuenta. Tu código de verificación es: ${code}. Ingresa https://turnosxpress.com.ar/landing/recover-account/verify para establecer una nueva clave`, context: {
username: user.firstName || cleanEmail(data.email),
verificationcode: code,
},
}); });
return; return;
@@ -515,10 +542,16 @@ class UsersManager implements IUsersManager {
throw new Error("El código de verificación es incorrecto"); throw new Error("El código de verificación es incorrecto");
} }
const wasVerificated = Boolean(userTest.verificated);
userTest.verificated = true; userTest.verificated = true;
await userTest.save(); await userTest.save();
if (!wasVerificated) {
await this.sendWelcomeEmail(userTest.email, userTest.firstName);
}
try { try {
const freePlan = await PlansList.plans.findOne({ price: 0 }); const freePlan = await PlansList.plans.findOne({ price: 0 });
if (freePlan) { if (freePlan) {
@@ -724,11 +757,17 @@ class UsersManager implements IUsersManager {
}); });
if (userCheck) { if (userCheck) {
const wasVerificated = Boolean(userCheck.verificated);
userCheck.external_id = googleUserId; userCheck.external_id = googleUserId;
userCheck.external_service = "google"; userCheck.external_service = "google";
userCheck.verificated = true; userCheck.verificated = true;
await userCheck.save(); await userCheck.save();
if (!wasVerificated) {
await this.sendWelcomeEmail(userCheck.email, userCheck.firstName);
}
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({ const subscription = await PlanSubscriptionsList.getSubscriptionByUser({
sessionUser: userCheck.id, sessionUser: userCheck.id,
}); });
@@ -770,6 +809,8 @@ class UsersManager implements IUsersManager {
throw new Error("Error creating user"); throw new Error("Error creating user");
} }
await this.sendWelcomeEmail(newUser.email, newUser.firstName);
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({ const subscription = await PlanSubscriptionsList.getSubscriptionByUser({
sessionUser: newUser.id, sessionUser: newUser.id,
}); });
@@ -821,11 +862,17 @@ class UsersManager implements IUsersManager {
}); });
if (userCheck) { if (userCheck) {
const wasVerificated = Boolean(userCheck.verificated);
userCheck.external_id = googleData.id; userCheck.external_id = googleData.id;
userCheck.external_service = "google"; userCheck.external_service = "google";
userCheck.verificated = true; userCheck.verificated = true;
await userCheck.save(); await userCheck.save();
if (!wasVerificated) {
await this.sendWelcomeEmail(userCheck.email, userCheck.firstName);
}
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({ const subscription = await PlanSubscriptionsList.getSubscriptionByUser({
sessionUser: userCheck.id, sessionUser: userCheck.id,
}); });
@@ -867,6 +914,8 @@ class UsersManager implements IUsersManager {
throw new Error("Error creating user"); throw new Error("Error creating user");
} }
await this.sendWelcomeEmail(newUser.email, newUser.firstName);
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({ const subscription = await PlanSubscriptionsList.getSubscriptionByUser({
sessionUser: newUser.id, sessionUser: newUser.id,
}); });
@@ -1039,10 +1088,18 @@ class UsersManager implements IUsersManager {
await this.users.update(data); await this.users.update(data);
const fecha = dayjs().format("DD/MM/YYYY");
const hora = dayjs().format("HH:mm");
await NotificationsManager.sendEmail({ await NotificationsManager.sendEmail({
email: cleanEmail(sessionUser.email), email: cleanEmail(sessionUser.email),
subject: "TurnosXpress :: Usuario actualizado", subject: "TurnosXpress :: Usuario actualizado",
message: `Hola ${sessionUser.firstName}, has actualizado tu perfil con éxito`, templateId: "6aa948e050f4bd20460f00a5",
context: {
username: sessionUser.firstName,
fecha,
hora,
},
}); });
} }
+59
View File
@@ -1,5 +1,6 @@
import { isNull } from "../../helpers/IsNull"; import { isNull } from "../../helpers/IsNull";
import CompaniesList from "../Companies/Companies"; import CompaniesList from "../Companies/Companies";
import { CompaniesAdapterMongoose } from "../Companies/Companies.Adapter.Mongoose";
import ServiceList from "../Services/Service"; import ServiceList from "../Services/Service";
import { import {
PublicServicesResults, PublicServicesResults,
@@ -15,6 +16,24 @@ import { BotEventParams, BotView } from "../WapServer/WapServer.Interface";
import WapServerList from "../WapServer/WapServer"; import WapServerList from "../WapServer/WapServer";
import { COMPANY_PUBLISHED_STATUS } from "../Companies/Companies.Interface"; import { COMPANY_PUBLISHED_STATUS } from "../Companies/Companies.Interface";
export type PublicOrganizationsParams = {
page: number;
limit: number;
};
export type PublicOrganizationListItem = {
slug: string;
name: string;
description: string;
creationDate: Date;
};
export type PublicOrganizationsResult = {
data: PublicOrganizationListItem[];
page: number;
pages: number;
};
export type HomeView = { export type HomeView = {
education: PublicServicesResults; education: PublicServicesResults;
health: PublicServicesResults; health: PublicServicesResults;
@@ -39,6 +58,7 @@ export type PublicOrganizationViewParams = {
export type PublicOrganizationView = { export type PublicOrganizationView = {
id: string; id: string;
slug: string;
headerFile: string; headerFile: string;
headerColor: string; headerColor: string;
headerFontColor: string; headerFontColor: string;
@@ -143,6 +163,7 @@ class Views {
return { return {
id: isNull<string>(company.id, ""), id: isNull<string>(company.id, ""),
slug: isNull<string>(company.slug, ""),
name: isNull<string>(company.name, ""), name: isNull<string>(company.name, ""),
description: isNull<string>(company.description, ""), description: isNull<string>(company.description, ""),
categoryId: isNull<number>(company.categoryId, 50000), categoryId: isNull<number>(company.categoryId, 50000),
@@ -249,6 +270,44 @@ class Views {
}; };
} }
public async publicOrganizations(
data: PublicOrganizationsParams
): Promise<PublicOrganizationsResult> {
const { page, limit } = data;
const filter = {
published: COMPANY_PUBLISHED_STATUS.PUBLISHED,
banned: { $ne: true },
};
const companyList = (CompaniesList.companies as CompaniesAdapterMongoose).companyList;
const count = await companyList
.countDocuments(filter)
.exec();
const skip = (page - 1) * limit;
const results = (await companyList
.find(filter)
.select("slug name description creationDate")
.sort({ creationDate: 1 })
.skip(skip)
.limit(limit)
.lean()
.exec()) as any[];
return {
data: results.map((company) => ({
slug: String(company.slug || ""),
name: String(company.name || ""),
description: String(company.description || ""),
creationDate: company.creationDate,
})),
page,
pages: Math.ceil(count / limit),
};
}
public async wapView(data: BotEventParams): Promise<BotView> { public async wapView(data: BotEventParams): Promise<BotView> {
return await WapServerList.botView(data); return await WapServerList.botView(data);
} }
@@ -0,0 +1,29 @@
import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa";
import { ApiValidationError } from "../../Models/Server.Error.model";
import {
CollaboratorScheduleDetailsParams,
CollaboratorScheduleDetailsView,
} from "../../Models/AdminAssistant/AdminAssistant.Interface";
import { authenticateMiddleware } from "../../middleware/authentication";
import { AdminAssistantService } from "./AdminAssistant.Service";
@Route("admin-assistant/collaborator-schedule-details")
@Middlewares(authenticateMiddleware)
export class GetCollaboratorScheduleDetailsController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post()
public async getCollaboratorScheduleDetails(
@Body() requestBody: CollaboratorScheduleDetailsParams
): Promise<CollaboratorScheduleDetailsView | ApiValidationError> {
try {
const details = await new AdminAssistantService().getCollaboratorScheduleDetails(requestBody);
this.setStatus(200);
return details;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@@ -0,0 +1,16 @@
import { connect } from "mongoose";
import AdminAssistantList from "../../Models/AdminAssistant/AdminAssistant";
import {
CollaboratorScheduleDetailsParams,
CollaboratorScheduleDetailsView,
} from "../../Models/AdminAssistant/AdminAssistant.Interface";
export class AdminAssistantService {
public async getCollaboratorScheduleDetails(
data: CollaboratorScheduleDetailsParams
): Promise<CollaboratorScheduleDetailsView> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await AdminAssistantList.getCollaboratorScheduleDetails(data);
}
}
@@ -38,7 +38,7 @@ export class ToggleCompanyFixedPostController extends Controller {
@SuccessResponse(200, "Done") @SuccessResponse(200, "Done")
@Post() @Post()
public async toggleFixedCompanyPost( public async toggleFixedCompanyPost(
@Body() requestBody: FixCompanyPostParams @Body() requestBody: FixCompanyPostParams,
): Promise<ApiVoidResult | ApiValidationError> { ): Promise<ApiVoidResult | ApiValidationError> {
try { try {
await new CompaniesService().toggleFixedPost(requestBody); await new CompaniesService().toggleFixedPost(requestBody);
@@ -62,7 +62,7 @@ export class MoveCompanyFixedPostController extends Controller {
@SuccessResponse(200, "Done") @SuccessResponse(200, "Done")
@Post() @Post()
public async moveFixedCompanyPost( public async moveFixedCompanyPost(
@Body() requestBody: MoveCompanyFixedPostParams @Body() requestBody: MoveCompanyFixedPostParams,
): Promise<ApiVoidResult | ApiValidationError> { ): Promise<ApiVoidResult | ApiValidationError> {
try { try {
await new CompaniesService().moveFixedPost(requestBody); await new CompaniesService().moveFixedPost(requestBody);
@@ -86,7 +86,7 @@ export class SetNotificationsStatusCompanyController extends Controller {
@SuccessResponse(200, "Done") @SuccessResponse(200, "Done")
@Post() @Post()
public async setNotificationAuto( public async setNotificationAuto(
@Body() requestBody: SetNotificationAutoParams @Body() requestBody: SetNotificationAutoParams,
): Promise<ApiVoidResult | ApiValidationError> { ): Promise<ApiVoidResult | ApiValidationError> {
try { try {
await new CompaniesService().setNotificationAuto(requestBody); await new CompaniesService().setNotificationAuto(requestBody);
@@ -110,7 +110,7 @@ export class SetPublishedStatusCompanyController extends Controller {
@SuccessResponse(200, "Done") @SuccessResponse(200, "Done")
@Post() @Post()
public async setPublishedStatus( public async setPublishedStatus(
@Body() requestBody: SetOrganizationPublishedStatusParams @Body() requestBody: SetOrganizationPublishedStatusParams,
): Promise<ApiVoidResult | ApiValidationError> { ): Promise<ApiVoidResult | ApiValidationError> {
try { try {
await new CompaniesService().setPublishedStatus(requestBody); await new CompaniesService().setPublishedStatus(requestBody);
@@ -134,7 +134,7 @@ export class CreateCompanyController extends Controller {
@SuccessResponse(200, "La compañia fue creada con exito") @SuccessResponse(200, "La compañia fue creada con exito")
@Post() @Post()
public async createCompany( public async createCompany(
@Body() requestBody: CreateCompanyParams @Body() requestBody: CreateCompanyParams,
): Promise<ICompany | ApiValidationError> { ): Promise<ICompany | ApiValidationError> {
try { try {
const company = await new CompaniesService().createCompany(requestBody); const company = await new CompaniesService().createCompany(requestBody);
@@ -155,7 +155,7 @@ export class DeleteCompanyController extends Controller {
@SuccessResponse(200, "La compañia fue modificada con exito") @SuccessResponse(200, "La compañia fue modificada con exito")
@Post() @Post()
public async deleteCompany( public async deleteCompany(
@Body() requestBody: DeleteCompanyParams @Body() requestBody: DeleteCompanyParams,
): Promise<ApiVoidResult | ApiValidationError> { ): Promise<ApiVoidResult | ApiValidationError> {
try { try {
await new CompaniesService().deleteCompany(requestBody); await new CompaniesService().deleteCompany(requestBody);
@@ -179,7 +179,7 @@ export class UpdateCompanyController extends Controller {
@SuccessResponse(200, "La compañia fue modificada con exito") @SuccessResponse(200, "La compañia fue modificada con exito")
@Post() @Post()
public async updateCompany( public async updateCompany(
@Body() requestBody: UpdateCompanyParams @Body() requestBody: UpdateCompanyParams,
): Promise<ApiVoidResult | ApiValidationError> { ): Promise<ApiVoidResult | ApiValidationError> {
try { try {
await new CompaniesService().updateCompany(requestBody); await new CompaniesService().updateCompany(requestBody);
@@ -202,7 +202,7 @@ export class GetCompanyByIdController extends Controller {
@SuccessResponse(200, "Done") @SuccessResponse(200, "Done")
@Post() @Post()
public async getById( public async getById(
@Body() requestBody: OranizationsViewByIdParams @Body() requestBody: OranizationsViewByIdParams,
): Promise<MyOranizationsView | ApiValidationError> { ): Promise<MyOranizationsView | ApiValidationError> {
try { try {
const companies = await new CompaniesService().getById(requestBody); const companies = await new CompaniesService().getById(requestBody);
@@ -222,7 +222,7 @@ export class GetCompanyByUserController extends Controller {
@SuccessResponse(200, "Done") @SuccessResponse(200, "Done")
@Post() @Post()
public async getByUserId( public async getByUserId(
@Body() requestBody: MyOranizationsViewParams @Body() requestBody: MyOranizationsViewParams,
): Promise<MyOranizationsView[] | ApiValidationError> { ): Promise<MyOranizationsView[] | ApiValidationError> {
try { try {
const companies = await new CompaniesService().getByUserId(requestBody); const companies = await new CompaniesService().getByUserId(requestBody);
@@ -242,7 +242,7 @@ export class PaginateCompanyController extends Controller {
@SuccessResponse(200, "Done") @SuccessResponse(200, "Done")
@Post() @Post()
public async paginateUsers( public async paginateUsers(
@Body() requestBody: PaginateCompaniesParams @Body() requestBody: PaginateCompaniesParams,
): Promise<PaginateCompaniesResults | ApiValidationError> { ): Promise<PaginateCompaniesResults | ApiValidationError> {
try { try {
const users = await new CompaniesService().paginateCompanies(requestBody); const users = await new CompaniesService().paginateCompanies(requestBody);
@@ -265,7 +265,7 @@ export class UploadCompanyHeaderController extends Controller {
public async uploadHeader( public async uploadHeader(
@FormField() companyId: string, @FormField() companyId: string,
@FormField() sessionUser: string, @FormField() sessionUser: string,
@UploadedFile() file: Express.Multer.File @UploadedFile() file: Express.Multer.File,
): Promise<ApiVoidResult | ApiValidationError> { ): Promise<ApiVoidResult | ApiValidationError> {
try { try {
await new CompaniesService().setCompanyHeader({ await new CompaniesService().setCompanyHeader({
@@ -295,7 +295,7 @@ export class UploadCompanyIconController extends Controller {
public async uploadIcon( public async uploadIcon(
@FormField() companyId: string, @FormField() companyId: string,
@FormField() sessionUser: string, @FormField() sessionUser: string,
@UploadedFile() file: Express.Multer.File @UploadedFile() file: Express.Multer.File,
): Promise<ApiVoidResult | ApiValidationError> { ): Promise<ApiVoidResult | ApiValidationError> {
try { try {
await new CompaniesService().setCompanyIcon({ await new CompaniesService().setCompanyIcon({
@@ -325,7 +325,7 @@ export class DeleteCompanyHeaderController extends Controller {
@SuccessResponse(200, "El archivo fue cargado con exito") @SuccessResponse(200, "El archivo fue cargado con exito")
@Post() @Post()
public async deleteHeader( public async deleteHeader(
@Body() data: DeleteCompanyFileParams @Body() data: DeleteCompanyFileParams,
): Promise<ApiVoidResult | ApiValidationError> { ): Promise<ApiVoidResult | ApiValidationError> {
try { try {
await new CompaniesService().deleteCompanyHeader(data); await new CompaniesService().deleteCompanyHeader(data);
@@ -349,7 +349,7 @@ export class DeleteCompanyIconController extends Controller {
@SuccessResponse(200, "El archivo fue cargado con exito") @SuccessResponse(200, "El archivo fue cargado con exito")
@Post() @Post()
public async deleteIcon( public async deleteIcon(
@Body() data: DeleteCompanyFileParams @Body() data: DeleteCompanyFileParams,
): Promise<ApiVoidResult | ApiValidationError> { ): Promise<ApiVoidResult | ApiValidationError> {
try { try {
await new CompaniesService().deleteCompanyIcon(data); await new CompaniesService().deleteCompanyIcon(data);
@@ -372,7 +372,7 @@ export class filterAllOrganizationsController extends Controller {
@SuccessResponse(200, "Done") @SuccessResponse(200, "Done")
@Post() @Post()
public async filterAllOrganizations( public async filterAllOrganizations(
@Body() requestBody: PaginateCompaniesParams @Body() requestBody: PaginateCompaniesParams,
): Promise<TextObjectFilterResult[] | ApiValidationError> { ): Promise<TextObjectFilterResult[] | ApiValidationError> {
try { try {
const clients = await new CompaniesService().filterAll(requestBody); const clients = await new CompaniesService().filterAll(requestBody);
@@ -392,7 +392,7 @@ export class CheckCompanyNameController extends Controller {
@SuccessResponse(200, "Done") @SuccessResponse(200, "Done")
@Post() @Post()
public async checkName( public async checkName(
@Body() requestBody: { name: string } @Body() requestBody: { name: string },
): Promise<{ valid: boolean } | ApiValidationError> { ): Promise<{ valid: boolean } | ApiValidationError> {
try { try {
const valid = await new CompaniesService().checkNameExists(requestBody.name); const valid = await new CompaniesService().checkNameExists(requestBody.name);
@@ -405,3 +405,24 @@ export class CheckCompanyNameController extends Controller {
} }
} }
} }
@Route("companies/check-admin")
@Middlewares(authenticateMiddleware)
export class CheckCompanyAdminController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post()
public async checkAdmin(
@Body() requestBody: { companyId: string; sessionUser: string },
): Promise<boolean | ApiValidationError> {
try {
const isAdmin = await new CompaniesService().checkCompanyAdmin(requestBody);
this.setStatus(200);
return isAdmin;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@@ -112,4 +112,9 @@ export class CompaniesService {
const isValid = await CompaniestList.isValidName({ name }); const isValid = await CompaniestList.isValidName({ name });
return isValid; return isValid;
} }
public async checkCompanyAdmin(data: { companyId: string; sessionUser: string }): Promise<boolean> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await CompaniestList.checkCompanyAdmin(data);
}
} }
@@ -6,6 +6,9 @@ import dayjs from "dayjs";
import PlanPaymentsList from "../../Models/PlanPayments/PlanPayments"; import PlanPaymentsList from "../../Models/PlanPayments/PlanPayments";
import { IPlanSuscriptionDocument } from "../../Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose"; import { IPlanSuscriptionDocument } from "../../Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose";
import { VerifyPendingPlanPaymentResponse } from "../../Models/PlanSubscriptions/PlanSubscriptions.interface"; import { VerifyPendingPlanPaymentResponse } from "../../Models/PlanSubscriptions/PlanSubscriptions.interface";
import PlansList from "../../Models/Plans/Plans";
import UserList from "../../Models/Users/Users";
import { NotificationsManager } from "../../Models/Notifications/Notifications";
type MercadoPagoPaymentData = { type MercadoPagoPaymentData = {
id: string | number; id: string | number;
@@ -41,6 +44,66 @@ type MercadoPagoPaymentVerification = {
const REJECTED_PAYMENT_STATUSES = ["rejected", "cancelled", "failed"]; const REJECTED_PAYMENT_STATUSES = ["rejected", "cancelled", "failed"];
export class MercadoPagoWebhookService { export class MercadoPagoWebhookService {
private formatTemplateDate(date: Date): string {
return new Intl.DateTimeFormat("es-AR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(date);
}
private formatTemplateAmount(amount: number): string {
return new Intl.NumberFormat("es-AR", {
style: "currency",
currency: "ARS",
}).format(amount);
}
private async sendApprovedPaidPlanEmail(
subscription: IPlanSuscriptionDocument,
paymentData: MercadoPagoPaymentData,
userId: string,
approvedAt: Date,
periodStart: Date
): Promise<void> {
try {
const plan = await PlansList.findOne({ _id: String(subscription.planId) });
if (plan.price <= 0) {
return;
}
const user = await UserList.users.findOne({ _id: userId });
if (!user) {
return;
}
await NotificationsManager.sendEmail({
email: user.email,
templateId: "6a85ebb1323720a53d0cfd3f",
context: {
username: user.firstName || user.email,
nombre_plan: plan.name,
importe: this.formatTemplateAmount(paymentData.transaction_amount || 0),
fecha_pago: this.formatTemplateDate(approvedAt),
periodo_desde: this.formatTemplateDate(periodStart),
periodo_hasta: this.formatTemplateDate(subscription.endDate),
},
});
} catch (e) {
if (axios.isAxiosError(e)) {
console.error("Error enviando email de pago de plan aprobado:", {
status: e.response?.status,
data: e.response?.data,
});
return;
}
console.error("Error enviando email de pago de plan aprobado:", e);
}
}
public async handleWebhook(body: any): Promise<void> { public async handleWebhook(body: any): Promise<void> {
await connect(`${process.env.DATABASE_CONNECTION}`); await connect(`${process.env.DATABASE_CONNECTION}`);
@@ -300,6 +363,9 @@ export class MercadoPagoWebhookService {
const periodStart = isExtensionPayment && dayjs(subscription.endDate).isAfter(approvedAt) const periodStart = isExtensionPayment && dayjs(subscription.endDate).isAfter(approvedAt)
? subscription.endDate ? subscription.endDate
: approvedAt; : approvedAt;
const transactionId = String(paymentData.id);
const existingPayments = await PlanPaymentsList.find({ transactionId });
const shouldSendApprovedPaymentEmail = existingPayments.length === 0;
if (isUpgradePayment && subscription.pendingPaymentPlanId) { if (isUpgradePayment && subscription.pendingPaymentPlanId) {
subscription.planId = subscription.pendingPaymentPlanId; subscription.planId = subscription.pendingPaymentPlanId;
@@ -342,8 +408,12 @@ export class MercadoPagoWebhookService {
paymentDate: approvedAt, paymentDate: approvedAt,
paymentMethod: paymentData.payment_method_id || paymentData.payment_type_id || "mercadopago", paymentMethod: paymentData.payment_method_id || paymentData.payment_type_id || "mercadopago",
status: "completed", status: "completed",
transactionId: String(paymentData.id), transactionId,
}); });
if (shouldSendApprovedPaymentEmail) {
await this.sendApprovedPaidPlanEmail(subscription, paymentData, userId, approvedAt, periodStart);
}
} }
private async applyRejectedPayment( private async applyRejectedPayment(
@@ -7,8 +7,10 @@ import { ApiVoidResult } from "../../Models/Api.VoidResult.type";
interface SendEmailByContentParams { interface SendEmailByContentParams {
systemToken: string; systemToken: string;
email: string; email: string;
subject: string; subject?: string;
message: string; message?: string;
templateId?: string;
context?: Record<string, unknown>;
} }
interface SendWapByContentParams { interface SendWapByContentParams {
@@ -29,6 +31,8 @@ export class SendEmailByContentController extends Controller {
email: requestBody.email, email: requestBody.email,
subject: requestBody.subject, subject: requestBody.subject,
message: requestBody.message, message: requestBody.message,
templateId: requestBody.templateId,
context: requestBody.context,
}); });
this.setStatus(200); this.setStatus(200);
@@ -10,6 +10,8 @@ import {
PaginateSchedulesResults, PaginateSchedulesResults,
FindSchedulesParams, FindSchedulesParams,
CollaboratorSchedulesView, CollaboratorSchedulesView,
ScheduleApplyParams,
ScheduleApplyResult,
} from "../../Models/Schedules/Schedules.Interface"; } from "../../Models/Schedules/Schedules.Interface";
import { authenticateMiddleware } from "../../middleware/authentication"; import { authenticateMiddleware } from "../../middleware/authentication";
import { ApiVoidResult } from "../../Models/Api.VoidResult.type"; import { ApiVoidResult } from "../../Models/Api.VoidResult.type";
@@ -36,6 +38,7 @@ export class CreateScheduleController extends Controller {
} }
@Route("schedules/get-all-by-collaborator") @Route("schedules/get-all-by-collaborator")
@Middlewares(authenticateMiddleware)
export class GetAllSchedulsByCollaboratoreController extends Controller { export class GetAllSchedulsByCollaboratoreController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error") @Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "El empleado fue creado con exito") @SuccessResponse(200, "El empleado fue creado con exito")
@@ -81,6 +84,27 @@ export class UpdateScheduleController extends Controller {
} }
} }
@Route("schedules/apply")
@Middlewares(authenticateMiddleware)
export class ApplyScheduleController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post()
public async applyScheduleChanges(
@Body() requestBody: ScheduleApplyParams
): Promise<ScheduleApplyResult | ApiValidationError> {
try {
const result = await new SchedulesService().applyScheduleChanges(requestBody);
this.setStatus(200);
return result;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@Route("schedules/delete") @Route("schedules/delete")
@Middlewares(authenticateMiddleware) @Middlewares(authenticateMiddleware)
export class DeleteScheduleController extends Controller { export class DeleteScheduleController extends Controller {
@@ -9,6 +9,8 @@ import {
PaginateSchedulesResults, PaginateSchedulesResults,
CollaboratorSchedulesView, CollaboratorSchedulesView,
FindSchedulesParams, FindSchedulesParams,
ScheduleApplyParams,
ScheduleApplyResult,
} from "../../Models/Schedules/Schedules.Interface"; } from "../../Models/Schedules/Schedules.Interface";
export class SchedulesService { export class SchedulesService {
@@ -26,6 +28,12 @@ export class SchedulesService {
await ScheduleList.updateSchedule(data); await ScheduleList.updateSchedule(data);
} }
public async applyScheduleChanges(data: ScheduleApplyParams): Promise<ScheduleApplyResult> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await ScheduleList.applyScheduleChanges(data);
}
public async deleteSchedule(data: DeleteScheduleParams): Promise<void> { public async deleteSchedule(data: DeleteScheduleParams): Promise<void> {
await connect(`${process.env.DATABASE_CONNECTION}`); await connect(`${process.env.DATABASE_CONNECTION}`);
await ScheduleList.delete(data); await ScheduleList.delete(data);
+22
View File
@@ -9,6 +9,8 @@ import {
PublicOrganizationServiceView, PublicOrganizationServiceView,
UserAppointmentViewParams, UserAppointmentViewParams,
UserAppointmentView, UserAppointmentView,
PublicOrganizationsParams,
PublicOrganizationsResult,
} from "../../Models/Views/Views"; } from "../../Models/Views/Views";
import { ViewsService } from "./Views.Service"; import { ViewsService } from "./Views.Service";
import { BotEventParams, BotView } from "../../Models/WapServer/WapServer.Interface"; import { BotEventParams, BotView } from "../../Models/WapServer/WapServer.Interface";
@@ -109,3 +111,23 @@ export class WapServerBotViewController extends Controller {
} }
} }
} }
@Route("views/public-organizations")
export class PublicOrganizationsController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post()
public async publicOrganizations(
@Body() requestBody: PublicOrganizationsParams
): Promise<PublicOrganizationsResult | ApiValidationError> {
try {
const result = await new ViewsService().publicOrganizations(requestBody);
this.setStatus(200);
return result;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
+10
View File
@@ -7,6 +7,8 @@ import views, {
PublicOrganizationServiceView, PublicOrganizationServiceView,
UserAppointmentViewParams, UserAppointmentViewParams,
UserAppointmentView, UserAppointmentView,
PublicOrganizationsParams,
PublicOrganizationsResult,
} from "../../Models/Views/Views"; } from "../../Models/Views/Views";
import { BotView, BotEventParams } from "../../Models/WapServer/WapServer.Interface"; import { BotView, BotEventParams } from "../../Models/WapServer/WapServer.Interface";
@@ -45,4 +47,12 @@ export class ViewsService {
const view = await views.wapView(data); const view = await views.wapView(data);
return view; return view;
} }
public async publicOrganizations(
data: PublicOrganizationsParams
): Promise<PublicOrganizationsResult> {
await connect(`${process.env.DATABASE_CONNECTION}`);
const result = await views.publicOrganizations(data);
return result;
}
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

+33
View File
@@ -0,0 +1,33 @@
# Gradle
.gradle/
build/
**/build/
gradle-app.setting
!gradle-wrapper.jar
# Android Studio
.idea/
*.iml
local.properties
.navigation/
.externalNativeBuild/
.cxx/
# Signing
keystore.properties
*.jks
*.keystore
*.p12
*.pem
# Generated
captures/
outputs/
*.apk
*.aab
*.ap_
# Logs / OS
*.log
Thumbs.db
.DS_Store
+85
View File
@@ -0,0 +1,85 @@
# TurnosXpress Android TWA
Minimal Android Trusted Web Activity app for `https://turnosxpress.com.ar`.
## Project
- Package/application id: `com.hdrdevs.turnosxpress`
- Launch URL: `https://turnosxpress.com.ar`
- TWA library: `com.google.androidbrowserhelper:androidbrowserhelper:2.7.2`
- Launcher activity: `com.google.androidbrowserhelper.trusted.LauncherActivity`
## Build
This folder intentionally does not include a Gradle wrapper. Open `tx-android` in Android Studio, or use an installed Gradle distribution:
```powershell
cd tx-android
gradle tasks
gradle assembleDebug
```
If Android Studio creates `local.properties`, keep it local. It is ignored by Git.
## Signing
For local debug builds Android uses the debug keystore. For Play Store releases, sign with the release keystore or Play App Signing certificate configured for this package.
Example local release keystore creation:
```powershell
keytool -genkeypair -v -keystore turnosxpress-release.jks -alias turnosxpress -keyalg RSA -keysize 2048 -validity 10000
```
Do not commit keystores or signing passwords. Put local signing values in `keystore.properties` if you later wire release signing into Gradle.
## SHA-256 Fingerprint
Digital Asset Links must use the SHA-256 fingerprint of the certificate that signs the installed APK.
Debug keystore example:
```powershell
keytool -list -v -alias androiddebugkey -keystore "$env:USERPROFILE\.android\debug.keystore" -storepass android -keypass android
```
Release keystore example:
```powershell
keytool -list -v -alias turnosxpress -keystore .\turnosxpress-release.jks
```
If Play App Signing is enabled, use the SHA-256 fingerprint from Play Console's app signing certificate, not only the upload key.
## Digital Asset Links
Publish this file publicly at:
```text
https://turnosxpress.com.ar/.well-known/assetlinks.json
```
Example:
```json
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.hdrdevs.turnosxpress",
"sha256_cert_fingerprints": [
"AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"
]
}
}
]
```
The JSON must be served over HTTPS with `Content-Type: application/json` and no redirects that break access to `/.well-known/assetlinks.json`.
## Play Protect And Local APK Notes
Local APK installs can show Play Protect warnings because the APK is not installed from Google Play and may be signed by a new or unknown certificate. This is expected during sideload testing and does not prove the TWA or Digital Asset Links setup is wrong.
The full-screen trusted experience only works when Android can verify the website-to-app relationship through Digital Asset Links for the certificate used to sign that APK. Until that is valid, Chrome may open the site with browser UI or behave like a Custom Tab.
+21
View File
@@ -0,0 +1,21 @@
plugins {
id 'com.android.application'
}
android {
namespace 'com.hdrdevs.turnosxpress'
compileSdk 36
defaultConfig {
applicationId 'com.hdrdevs.turnosxpress'
minSdk 23
targetSdk 35
versionCode 1
versionName '1.0.0'
}
}
dependencies {
implementation 'com.google.androidbrowserhelper:androidbrowserhelper:2.7.2'
implementation 'androidx.appcompat:appcompat:1.7.0'
}
@@ -0,0 +1,37 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.hdrdevs.turnosxpress",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 1,
"versionName": "1.0.0",
"outputFile": "app-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/app-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/app-release.dm"
]
}
],
"minSdkVersionForDexing": 23
}
@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.TurnosXpressTwa">
<meta-data
android:name="asset_statements"
android:resource="@string/asset_statements" />
<activity
android:name="com.google.androidbrowserhelper.trusted.LauncherActivity"
android:exported="true"
android:theme="@style/Theme.TurnosXpressTwa">
<meta-data
android:name="android.support.customtabs.trusted.DEFAULT_URL"
android:value="https://turnosxpress.com.ar" />
<meta-data
android:name="android.support.customtabs.trusted.STATUS_BAR_COLOR"
android:resource="@color/twa_status_bar_color" />
<meta-data
android:name="android.support.customtabs.trusted.STATUS_BAR_COLOR_DARK"
android:resource="@color/twa_status_bar_color_dark" />
<meta-data
android:name="android.support.customtabs.trusted.NAVIGATION_BAR_COLOR"
android:resource="@color/twa_navigation_bar_color" />
<meta-data
android:name="android.support.customtabs.trusted.NAVIGATION_BAR_COLOR_DARK"
android:resource="@color/twa_navigation_bar_color_dark" />
<meta-data
android:name="android.support.customtabs.trusted.FALLBACK_STRATEGY"
android:value="customtabs" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="turnosxpress.com.ar"
android:scheme="https" />
</intent-filter>
</activity>
<activity
android:name="com.google.androidbrowserhelper.trusted.ManageDataLauncherActivity"
android:exported="false"
android:theme="@style/Theme.TurnosXpressTwa" />
</application>
</manifest>
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="@color/launcher_background"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="@color/launcher_foreground"
android:pathData="M24,24h60v12h-24v48h-12v-48h-24z" />
<path
android:fillColor="@color/launcher_foreground"
android:pathData="M30,72h48v12h-48z" />
</vector>
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
android:height="108dp"
android:width="108dp"
android:viewportHeight="108"
android:viewportWidth="108"
xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z"/>
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="twa_status_bar_color">#330080</color>
<color name="twa_status_bar_color_dark">#330080</color>
<color name="twa_navigation_bar_color">#330080</color>
<color name="twa_navigation_bar_color_dark">#330080</color>
<color name="launcher_background">#330080</color>
<color name="launcher_foreground">#FFFFFF</color>
</resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">TurnosXpress</string>
<string name="asset_statements">
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "web",
"site": "https://turnosxpress.com.ar"
}
}]
</string>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.TurnosXpressTwa" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:windowNoTitle">true</item>
<item name="android:windowActionBar">false</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:navigationBarColor">@color/twa_navigation_bar_color</item>
<item name="android:statusBarColor">@color/twa_status_bar_color</item>
</style>
</resources>
+3
View File
@@ -0,0 +1,3 @@
plugins {
id 'com.android.application' version '8.9.1' apply false
}

Some files were not shown because too many files have changed in this diff Show More