Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84e17e2a32 | |||
| 7a97e278a2 | |||
| 215a5b28f4 | |||
| 1438585a0a | |||
| 0d6231a02e | |||
| 13ba0fa4de | |||
| 2df20233d4 | |||
| 1a9de06393 | |||
| ec083ddf42 | |||
| fe9eb52a6a | |||
| 392156fd31 | |||
| f5a2c0774e | |||
| 0dd2998f5c | |||
| 97943fa4a9 | |||
| 5e65a48fe1 | |||
| f086c6398c | |||
| 728d0a711c | |||
| 2d91d08e16 | |||
| eff1988d66 | |||
| 60eb940d3e | |||
| 8c1089df41 | |||
| ae63d0dea0 | |||
| deb1291274 | |||
| 583ddf1ea9 | |||
| 65d149a4e7 | |||
| 322f1d3f15 | |||
| 4460d66f39 | |||
| 0e7e690ecc | |||
| 35a132a288 | |||
| 28727e2630 | |||
| e7a94b93f1 | |||
| fccaa44e9c | |||
| 421a8b5582 | |||
| 546b66c972 | |||
| d96bde5963 | |||
| 69e6e659e0 | |||
| 86f5422828 | |||
| db6981cad5 | |||
| f200f91f93 | |||
| 06aac5b126 | |||
| 80d9f5d428 | |||
| ad44e775e2 | |||
| cebb34c88a | |||
| 0095bd884a | |||
| c238f28179 | |||
| 57c5deb3bd | |||
| c5217aa7c8 | |||
| 48ed64eb87 | |||
| 95b3e3698b | |||
| ee9da60b3b | |||
| f00e258819 | |||
| 33743f12b9 | |||
| b6399d28fc | |||
| e4441fa476 | |||
| c8029a6762 | |||
| 18fa523fbe | |||
| 63a85e8aab | |||
| e82d2a0ece | |||
| ff733296c8 | |||
| ef044522b4 | |||
| e6686597f0 | |||
| 9e090c465a | |||
| b7bbef8035 |
@@ -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.
|
||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Frx8us2W@
|
||||||
@@ -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] } } }
|
||||||
|
])
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ngrok config add-authtoken 3I94dMHXyh3wjRjGBu0oGQ8D8h4_2yBusck6ZWHJBx7WcbBFf
|
||||||
|
|
||||||
|
ngrok http 3000
|
||||||
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
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>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.application' version '8.9.1' apply false
|
||||||
|
}
|
||||||