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;
|
||||
|
||||
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.Build;
|
||||
import android.os.Message;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.media.MediaScannerConnection;
|
||||
import android.net.Uri;
|
||||
import android.util.Log;
|
||||
import android.view.KeyEvent;
|
||||
@@ -33,6 +39,7 @@ import android.content.ContentResolver;
|
||||
public class MainActivity extends AppCompatActivity {
|
||||
|
||||
private static final int LOGIN_REQUEST_CODE = 1;
|
||||
private static final int STORAGE_PERMISSION_REQUEST_CODE = 2;
|
||||
private WebView webView;
|
||||
private ValueCallback<Uri[]> fileChooserCallback;
|
||||
|
||||
@@ -43,6 +50,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
tokenResult = "";
|
||||
requestLegacyStoragePermissionIfNeeded();
|
||||
|
||||
// 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.
|
||||
@@ -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 {
|
||||
@JavascriptInterface
|
||||
public void startGoogleLogin() {
|
||||
@@ -235,16 +265,20 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
@JavascriptInterface
|
||||
public void savePdf(String base64Data, String filename) {
|
||||
String safeFilename = safePdfFilename(filename);
|
||||
|
||||
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;
|
||||
|
||||
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();
|
||||
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.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS);
|
||||
contentValues.put(MediaStore.MediaColumns.IS_PENDING, 1);
|
||||
|
||||
Uri uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues);
|
||||
|
||||
@@ -253,18 +287,31 @@ public class MainActivity extends AppCompatActivity {
|
||||
outputStream.write(pdfAsBytes);
|
||||
isSaved = true;
|
||||
} 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 {
|
||||
File path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
|
||||
File file = new File(path, filename);
|
||||
|
||||
try (FileOutputStream os = new FileOutputStream(file)) {
|
||||
os.write(pdfAsBytes);
|
||||
isSaved = true;
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
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)) {
|
||||
os.write(pdfAsBytes);
|
||||
isSaved = true;
|
||||
MediaScannerConnection.scanFile(MainActivity.this, new String[]{file.getAbsolutePath()}, new String[]{"application/pdf"}, null);
|
||||
} catch (IOException e) {
|
||||
Log.e("WEBVIEW", "Error writing PDF to Downloads: " + safeFilename, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +322,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
} 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/mongoose": "^5.11.96",
|
||||
"@types/node": "^24.3.3",
|
||||
"cross-env": "^10.1.0",
|
||||
"jest": "^30.4.2",
|
||||
"ts-jest": "^29.4.11",
|
||||
"ts-node": "^10.9.2",
|
||||
@@ -600,6 +601,13 @@
|
||||
"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": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
@@ -2322,6 +2330,24 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"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",
|
||||
"start": "node dist/index.js",
|
||||
"test": "jest --config jest.config.cjs"
|
||||
@@ -18,6 +18,7 @@
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/mongoose": "^5.11.96",
|
||||
"@types/node": "^24.3.3",
|
||||
"cross-env": "^10.1.0",
|
||||
"jest": "^30.4.2",
|
||||
"ts-jest": "^29.4.11",
|
||||
"ts-node": "^10.9.2",
|
||||
|
||||
@@ -91,12 +91,17 @@ export async function dispatchEmail(params: {
|
||||
systemToken: string;
|
||||
type: string;
|
||||
email: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
subject?: string;
|
||||
message?: string;
|
||||
templateId?: string;
|
||||
context?: Record<string, unknown>;
|
||||
}): Promise<DispatchResult> {
|
||||
try {
|
||||
if (!params.email.trim() || !params.subject.trim() || !params.message.trim()) {
|
||||
return { success: false, error: "Email dispatch requires email, subject, and message" };
|
||||
const hasContent = Boolean(params.subject?.trim() && params.message?.trim());
|
||||
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.
|
||||
@@ -106,8 +111,10 @@ export async function dispatchEmail(params: {
|
||||
{
|
||||
systemToken: params.systemToken,
|
||||
email: params.email,
|
||||
subject: params.subject,
|
||||
message: params.message,
|
||||
...(params.subject ? { subject: params.subject } : {}),
|
||||
...(params.message ? { message: params.message } : {}),
|
||||
...(params.templateId ? { templateId: params.templateId } : {}),
|
||||
...(params.context ? { context: params.context } : {}),
|
||||
},
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
@@ -202,6 +202,8 @@ export class JobProcessor {
|
||||
email: content.email || "",
|
||||
subject: content.subject,
|
||||
message: content.message,
|
||||
templateId: content.emailTemplateId,
|
||||
context: content.emailContext,
|
||||
});
|
||||
break;
|
||||
case "system":
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface ResolvedNotificationContent {
|
||||
companyOwnerId?: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
emailTemplateId?: string;
|
||||
emailContext?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const appointmentSchema = new Schema(
|
||||
@@ -169,6 +171,8 @@ export async function resolveNotificationContent(job: INotificationJobDocument):
|
||||
companyOwnerId: companyOwnerId ? String(companyOwnerId) : undefined,
|
||||
subject: fallbackSubject.trim(),
|
||||
message: fallbackMessage.trim(),
|
||||
emailTemplateId: job.payload.emailTemplateId?.trim(),
|
||||
emailContext: job.payload.emailContext,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface NotificationJobPayload {
|
||||
message?: string;
|
||||
emailSubject?: string;
|
||||
emailMessage?: string;
|
||||
emailTemplateId?: string;
|
||||
emailContext?: Record<string, unknown>;
|
||||
wapMessage?: string;
|
||||
systemSubject?: 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 () => {
|
||||
await dispatchWhatsApp({
|
||||
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_BACK_URL = https://turnosxpress.com.ar/landing/dashboard
|
||||
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
|
||||
|
||||
|
||||
@@ -6648,38 +6648,6 @@
|
||||
"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": {
|
||||
"version": "6.10.0",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "0.9.0",
|
||||
"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;
|
||||
type: APPOINTMENT_NOTIFICATION_TYPE;
|
||||
channel?: "whatsapp" | "email";
|
||||
allowClientCancellation?: boolean;
|
||||
};
|
||||
|
||||
export type CreateImmediateAppointmentNotificationJobsParams = {
|
||||
@@ -203,6 +204,8 @@ export interface AppointmentNotificationIntent {
|
||||
message: string;
|
||||
checkClient: IClientDocument;
|
||||
companyCheck: ICompanyDocument;
|
||||
emailTemplateId?: string;
|
||||
emailContext?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IAppointment {
|
||||
|
||||
@@ -287,6 +287,23 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
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 {
|
||||
let cancellationTime = this.getCancellationTime(aTime);
|
||||
|
||||
@@ -517,7 +534,7 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
|
||||
await this.updateHeatMap(availabilityConfig);
|
||||
|
||||
await this.tryApplyDiscounts(newAppointment);
|
||||
await this.tryApplyDiscounts(newAppointment, String(companyCheck.ownerId));
|
||||
|
||||
if (notification && checkClient.userId) {
|
||||
//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
|
||||
// y la fecha coincide con el rango especificado.
|
||||
|
||||
@@ -615,7 +632,7 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
appointmentId: String(appointment.id),
|
||||
discountId: String(existDiscount.discountId),
|
||||
companyId: String(appointment.companyId),
|
||||
sessionUser: String(appointment.userId),
|
||||
sessionUser: sessionUser,
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
@@ -670,6 +687,47 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
// Update both heatmaps
|
||||
await this.updateHeatMap(originalConfig);
|
||||
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> {
|
||||
@@ -1073,7 +1131,12 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
serviceId: String(appointment.serviceId),
|
||||
employeeId: String(appointment.employeeId),
|
||||
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),
|
||||
avatar: clientAvatar,
|
||||
color: isNull<string>(employee.calendarColor, "#ff9900"),
|
||||
@@ -1199,16 +1262,21 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
|
||||
//get min startTime hour from returnData
|
||||
let minStartTime = 24;
|
||||
let maxEndTime = 24;
|
||||
for (const event of returnData) {
|
||||
if (event.startTime < minStartTime) {
|
||||
minStartTime = event.startTime;
|
||||
}
|
||||
if (event.endTime > maxEndTime) {
|
||||
maxEndTime = event.endTime;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
events: returnData,
|
||||
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;
|
||||
companyName: string;
|
||||
emailMessage: string;
|
||||
emailTemplateId?: string;
|
||||
emailContext?: Record<string, unknown>;
|
||||
wapMessage: string;
|
||||
}): Promise<void> {
|
||||
const systemSubject = `Turno cancelado en ${data.companyName}`;
|
||||
@@ -2187,6 +2257,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
message: data.emailMessage,
|
||||
emailSubject: "TurnosXpress :: Turno cancelado",
|
||||
emailMessage: data.emailMessage,
|
||||
emailTemplateId: data.emailTemplateId,
|
||||
emailContext: data.emailContext,
|
||||
wapMessage: data.wapMessage,
|
||||
systemSubject,
|
||||
systemMessage: data.emailMessage,
|
||||
@@ -2206,8 +2278,12 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
appointmentStart: Date;
|
||||
emailMessage: string;
|
||||
wapMessage: string;
|
||||
emailTemplateId?: string;
|
||||
emailContext?: Record<string, unknown>;
|
||||
reminderEmailMessage: string;
|
||||
reminderWapMessage: string;
|
||||
reminderEmailTemplateId?: string;
|
||||
reminderEmailContext?: Record<string, unknown>;
|
||||
}): Promise<void> {
|
||||
const systemSubject = `Turno reservado en ${data.companyName}`;
|
||||
|
||||
@@ -2220,6 +2296,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
message: data.emailMessage,
|
||||
emailSubject: "TurnosXpress :: Alta de turno",
|
||||
emailMessage: data.emailMessage,
|
||||
emailTemplateId: data.emailTemplateId,
|
||||
emailContext: data.emailContext,
|
||||
wapMessage: data.wapMessage,
|
||||
systemSubject,
|
||||
systemMessage: data.emailMessage,
|
||||
@@ -2277,6 +2355,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
companyName: data.companyName,
|
||||
appointmentStart: data.appointmentStart,
|
||||
reminderEmailMessage: reminderEmailContent.message,
|
||||
reminderEmailTemplateId: reminderEmailContent.emailTemplateId,
|
||||
reminderEmailContext: reminderEmailContent.emailContext,
|
||||
reminderWapMessage: reminderWapContent.message,
|
||||
};
|
||||
|
||||
@@ -2301,6 +2381,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
await this.createCreationNotificationJobs({
|
||||
...notificationJobData,
|
||||
emailMessage: emailContent.message,
|
||||
emailTemplateId: emailContent.emailTemplateId,
|
||||
emailContext: emailContent.emailContext,
|
||||
wapMessage: wapContent.message,
|
||||
});
|
||||
}
|
||||
@@ -2317,6 +2399,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
appointmentStart: Date;
|
||||
reminderEmailMessage: string;
|
||||
reminderWapMessage: string;
|
||||
reminderEmailTemplateId?: string;
|
||||
reminderEmailContext?: Record<string, unknown>;
|
||||
}): Promise<void> {
|
||||
const reminderSystemSubject = `Recordatorio de turno en ${data.companyName}`;
|
||||
|
||||
@@ -2335,6 +2419,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
message: data.reminderEmailMessage,
|
||||
emailSubject: "TurnosXpress :: Recordatorio",
|
||||
emailMessage: data.reminderEmailMessage,
|
||||
emailTemplateId: data.reminderEmailTemplateId,
|
||||
emailContext: data.reminderEmailContext,
|
||||
wapMessage: data.reminderWapMessage,
|
||||
systemSubject: reminderSystemSubject,
|
||||
systemMessage: data.reminderEmailMessage,
|
||||
@@ -2491,12 +2577,14 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
sessionUser: data.sessionUser,
|
||||
type: APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION,
|
||||
channel: "email",
|
||||
allowClientCancellation: true,
|
||||
});
|
||||
const wapContent = await this.tryToSendNotification({
|
||||
appointmentId: String(checkAppointment._id),
|
||||
sessionUser: data.sessionUser,
|
||||
type: APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION,
|
||||
channel: "whatsapp",
|
||||
allowClientCancellation: true,
|
||||
});
|
||||
|
||||
await this.createCancellationNotificationJobs({
|
||||
@@ -2509,6 +2597,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
companyOwnerId: String(companyCheck.ownerId),
|
||||
companyName: companyCheck.name,
|
||||
emailMessage: emailContent.message,
|
||||
emailTemplateId: emailContent.emailTemplateId,
|
||||
emailContext: emailContent.emailContext,
|
||||
wapMessage: wapContent.message,
|
||||
});
|
||||
}
|
||||
@@ -2650,6 +2740,14 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
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);
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
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({
|
||||
_id: String(checkAppointment.employeeId),
|
||||
});
|
||||
@@ -2695,6 +2793,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
}
|
||||
|
||||
let wapMessage = "";
|
||||
let emailTemplateId: string | undefined;
|
||||
let emailContext: Record<string, unknown> | undefined;
|
||||
|
||||
if (data.type == APPOINTMENT_NOTIFICATION_TYPE.REMINDER) {
|
||||
if (companyCheck.templateWapNotifId) {
|
||||
@@ -2738,6 +2838,19 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
|
||||
wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name);
|
||||
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) {
|
||||
const templateId = data.channel === "email"
|
||||
? companyCheck.templateEmailAltaId || companyCheck.templateWapAltaId
|
||||
@@ -2787,6 +2900,19 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
|
||||
wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name);
|
||||
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) {
|
||||
const isUpdate = data.type == APPOINTMENT_NOTIFICATION_TYPE.UPDATE;
|
||||
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("<---ORGANIZACION--->", companyCheck.name);
|
||||
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 {
|
||||
message: wapMessage,
|
||||
checkClient: checkClient,
|
||||
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,
|
||||
channel: "email",
|
||||
});
|
||||
@@ -2903,6 +3046,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
email: clientEmail,
|
||||
subject: subjectEmail,
|
||||
message: message,
|
||||
templateId: emailTemplateId,
|
||||
context: emailContext,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2954,6 +3099,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
message: emailContent.message,
|
||||
emailSubject: isCreation ? "TurnosXpress :: Alta de turno" : "TurnosXpress :: Recordatorio",
|
||||
emailMessage: emailContent.message,
|
||||
emailTemplateId: emailContent.emailTemplateId,
|
||||
emailContext: emailContent.emailContext,
|
||||
wapMessage: wapContent.message,
|
||||
systemSubject,
|
||||
systemMessage: emailContent.message,
|
||||
|
||||
@@ -2,6 +2,9 @@ import crypto from "crypto";
|
||||
import axios from "axios";
|
||||
import forge from "node-forge";
|
||||
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 {
|
||||
ARCA_WSAA_ENVIRONMENT,
|
||||
@@ -123,6 +126,23 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
|
||||
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(
|
||||
companyId: string,
|
||||
sessionUser: string
|
||||
@@ -730,6 +750,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
|
||||
|
||||
public async generateCsr(data: GenerateArcaCredentialCsrParams): Promise<ArcaCredentialView> {
|
||||
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
||||
await this.requirePaymentsFeature(data.companyId);
|
||||
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
|
||||
|
||||
const keys = forge.pki.rsa.generateKeyPair(2048);
|
||||
@@ -778,6 +799,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
|
||||
|
||||
public async uploadCertificate(data: UploadArcaCredentialCertificateParams): Promise<ArcaCredentialView> {
|
||||
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
||||
await this.requirePaymentsFeature(data.companyId);
|
||||
|
||||
if (!data.certificatePem || !data.certificatePem.trim()) {
|
||||
throw new Error("Se requiere el certificado ARCA");
|
||||
@@ -836,6 +858,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
|
||||
|
||||
public async testWsaaLogin(data: TestArcaWsaaLoginParams): Promise<ArcaWsaaLoginTestResult> {
|
||||
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
||||
await this.requirePaymentsFeature(data.companyId);
|
||||
|
||||
const service = ARCA_WSAA_SERVICE.WSFE;
|
||||
const environment = this.getArcaEnvironment();
|
||||
@@ -853,6 +876,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
|
||||
|
||||
public async getWsfeLastVoucher(data: ArcaWsfeLastVoucherParams): Promise<ArcaWsfeLastVoucherResult> {
|
||||
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
||||
await this.requirePaymentsFeature(data.companyId);
|
||||
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
|
||||
const loginTicket = await this.getWsaaLoginTicket(data.companyId);
|
||||
const environment = this.getArcaEnvironment();
|
||||
@@ -886,6 +910,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
|
||||
|
||||
public async issueFacturaC(data: ArcaWsfeIssueFacturaCParams): Promise<ArcaWsfeIssueFacturaCResult> {
|
||||
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
||||
await this.requirePaymentsFeature(data.companyId);
|
||||
|
||||
if (data.amount <= 0) {
|
||||
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> {
|
||||
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
||||
await this.requirePaymentsFeature(data.companyId);
|
||||
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
|
||||
const loginTicket = await this.getWsaaLoginTicket(data.companyId);
|
||||
const environment = this.getArcaEnvironment();
|
||||
@@ -1054,8 +1080,11 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
|
||||
|
||||
await validateSessionUser({ sessionUser: data.sessionUser });
|
||||
|
||||
let companyId: string | undefined;
|
||||
|
||||
if (data.companyId) {
|
||||
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
|
||||
companyId = data.companyId;
|
||||
} else if (data.id) {
|
||||
const credential = await this.arcaCredentials.findOne(data);
|
||||
|
||||
@@ -1067,12 +1096,13 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
|
||||
companyId: String(credential.companyId),
|
||||
sessionUser: data.sessionUser,
|
||||
});
|
||||
|
||||
return [this.sanitizeCredential(credential)];
|
||||
companyId = String(credential.companyId);
|
||||
} else {
|
||||
throw new Error("Se requiere una organización para buscar credenciales ARCA");
|
||||
}
|
||||
|
||||
await this.requirePaymentsFeature(companyId);
|
||||
|
||||
const credentials = await this.arcaCredentials.find(data);
|
||||
return credentials.map((credential) => this.sanitizeCredential(credential));
|
||||
}
|
||||
|
||||
@@ -99,7 +99,9 @@ export class CashFlowAdapterMongoose implements ICashFlowAdapter {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,8 @@ export class CashFlowBalanceAdapterMongoose implements ICashFlowBalanceAdapter {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -231,7 +231,10 @@ class CashFlowBalanceManager implements ICashFlowBalanceManager {
|
||||
const userSession = await this.validateSessionUser(data.sessionUser);
|
||||
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 companyId = data.companyId;
|
||||
|
||||
|
||||
@@ -32,6 +32,14 @@ export class ClientsAdapterMongoose implements IClientsAdapter {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -266,6 +266,27 @@ class ClientManager implements IClientsManager {
|
||||
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);
|
||||
|
||||
if (!canAdd) {
|
||||
@@ -274,17 +295,36 @@ class ClientManager implements IClientsManager {
|
||||
);
|
||||
}
|
||||
|
||||
const newClient = await this.clients.create({
|
||||
...data,
|
||||
...{
|
||||
email: userCheck.email,
|
||||
firstName: userCheck.firstName,
|
||||
lastName: userCheck.lastName,
|
||||
phoneAreaCode: userCheck.phoneAreaCode,
|
||||
phoneCountryCode: userCheck.phoneCountryCode,
|
||||
phoneNumber: userCheck.phoneNumber,
|
||||
},
|
||||
});
|
||||
let newClient: IClientDocument;
|
||||
|
||||
try {
|
||||
newClient = await this.clients.create({
|
||||
...data,
|
||||
...{
|
||||
email: userCheck.email,
|
||||
firstName: userCheck.firstName,
|
||||
lastName: userCheck.lastName,
|
||||
phoneAreaCode: userCheck.phoneAreaCode,
|
||||
phoneCountryCode: userCheck.phoneCountryCode,
|
||||
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({
|
||||
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 },
|
||||
});
|
||||
|
||||
this.schema.index({ published: 1, banned: 1 });
|
||||
|
||||
this.companyList = model<ICompanyDocument>("Company", this.schema);
|
||||
}
|
||||
|
||||
|
||||
@@ -380,4 +380,5 @@ export interface ICompaniesManager {
|
||||
sysAdminUpdate(data: SysAdminUpdateCompanyParams): Promise<void>;
|
||||
sysAdminSetBanned(data: SysAdminSetCompanyBannedParams): Promise<void>;
|
||||
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();
|
||||
|
||||
@@ -157,7 +157,10 @@ export class MetricsAdapterMongoose implements IMetricsAdapter {
|
||||
if (!metrics.repeatsCount) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface IMetricsAdapter {
|
||||
addService(data: MetricsParams): Promise<void>;
|
||||
addAppointment(data: MetricsParams): Promise<void>;
|
||||
addClient(data: MetricsParams): Promise<void>;
|
||||
addRepeat(data: MetricsParams): Promise<void>;
|
||||
}
|
||||
|
||||
export interface IMetricsManager {
|
||||
@@ -50,6 +51,7 @@ export interface IMetricsManager {
|
||||
reserveAppointment(data: MetricsParams): Promise<boolean>;
|
||||
releaseAppointment(data: MetricsParams): Promise<void>;
|
||||
addClient(data: MetricsParams): Promise<void>;
|
||||
addRepeat(data: MetricsParams): Promise<void>;
|
||||
canAddOrganization(userId: string): Promise<boolean>;
|
||||
canAddEmployee(userId: string): Promise<boolean>;
|
||||
canAddService(userId: string): Promise<boolean>;
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface NotificationJobPayload {
|
||||
message?: string;
|
||||
emailSubject?: string;
|
||||
emailMessage?: string;
|
||||
emailTemplateId?: string;
|
||||
emailContext?: Record<string, unknown>;
|
||||
wapMessage?: string;
|
||||
systemSubject?: string;
|
||||
systemMessage?: string;
|
||||
|
||||
@@ -5,9 +5,11 @@ import {
|
||||
import { NotificationJobsAdapterMongoose } from "../NotificationJobs/NotificationJobs.Adapter.Mongoose";
|
||||
import { PolicyResolver, ResolvedPolicy } from "./PolicyResolver";
|
||||
import dayjs from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
export interface CreateJobServiceParams {
|
||||
companyId: string;
|
||||
@@ -66,12 +68,17 @@ export class NotificationJobService {
|
||||
);
|
||||
|
||||
for (const rule of enabledReminderRules) {
|
||||
const reminderScheduledAt = this.adjustForQuietHours(
|
||||
dayjs(params.appointmentStart).subtract(rule.offset, "minute").toDate(),
|
||||
const originalReminderScheduledAt = dayjs(params.appointmentStart)
|
||||
.subtract(rule.offset, "minute")
|
||||
.toDate();
|
||||
const reminderScheduledAt = this.selectAutomaticReminderScheduledAt(
|
||||
originalReminderScheduledAt,
|
||||
now,
|
||||
params.appointmentStart,
|
||||
resolvedPolicy
|
||||
);
|
||||
|
||||
if (reminderScheduledAt < now || reminderScheduledAt >= params.appointmentStart) {
|
||||
if (reminderScheduledAt === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -169,12 +176,17 @@ export class NotificationJobService {
|
||||
);
|
||||
|
||||
for (const rule of enabledReminderRules) {
|
||||
const reminderScheduledAt = this.adjustForQuietHours(
|
||||
dayjs(params.appointmentStart).subtract(rule.offset, "minute").toDate(),
|
||||
const originalReminderScheduledAt = dayjs(params.appointmentStart)
|
||||
.subtract(rule.offset, "minute")
|
||||
.toDate();
|
||||
const reminderScheduledAt = this.selectAutomaticReminderScheduledAt(
|
||||
originalReminderScheduledAt,
|
||||
now,
|
||||
params.appointmentStart,
|
||||
resolvedPolicy
|
||||
);
|
||||
|
||||
if (reminderScheduledAt < now || reminderScheduledAt >= params.appointmentStart) {
|
||||
if (reminderScheduledAt === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -239,6 +251,78 @@ export class NotificationJobService {
|
||||
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[] {
|
||||
const channels: NotificationChannel[] = [];
|
||||
|
||||
|
||||
@@ -180,28 +180,35 @@ describe("NotificationJobService.createJob", () => {
|
||||
expect(createCalls.filter((job) => job.type === "reminder")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips reminder jobs adjusted by quiet hours after appointment start", async () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T20:00:00.000Z"));
|
||||
it("keeps UTC adjustment for the createJob base job and uses local candidates for reminders", async () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T23:00:00.000Z"));
|
||||
const resolvedPolicy: ResolvedPolicy = {
|
||||
channels: ["system", "email"],
|
||||
channels: ["system"],
|
||||
timezone: "America/Argentina/Buenos_Aires",
|
||||
quietHours: { from: "22:00", to: "07:00" },
|
||||
reminderRules: [{ offset: 15, enabled: true }],
|
||||
reminderRules: [{ offset: 30, enabled: true }],
|
||||
};
|
||||
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
||||
const baseScheduledAt = new Date("2026-07-22T02:00:00.000Z");
|
||||
|
||||
await service.createJob({
|
||||
companyId: "company-1",
|
||||
clientId: "client-1",
|
||||
appointmentId: "appt-1",
|
||||
type: "creation",
|
||||
appointmentStart: new Date("2026-07-21T23:30:00.000Z"),
|
||||
type: "update",
|
||||
scheduledAt: baseScheduledAt,
|
||||
appointmentStart: new Date("2026-07-22T03:00:00.000Z"),
|
||||
});
|
||||
|
||||
const createCalls = mockJobCreate.mock.calls.map((call) => call[0]);
|
||||
expect(createCalls.filter((job) => job.type === "creation")).toHaveLength(2);
|
||||
expect(createCalls.filter((job) => job.type === "reminder")).toHaveLength(0);
|
||||
expect(createCalls).toHaveLength(2);
|
||||
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 () => {
|
||||
@@ -330,77 +337,208 @@ describe("NotificationJobService.createJob", () => {
|
||||
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 = {
|
||||
channels: ["system"],
|
||||
timezone: "America/Argentina/Buenos_Aires",
|
||||
quietHours: { from: "22:00", to: "07:00" },
|
||||
timezone: "UTC",
|
||||
quietHours: { from: "08:00", to: "10:00" },
|
||||
reminderRules: [{ offset: 270, enabled: true }],
|
||||
};
|
||||
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
||||
|
||||
// scheduledAt at 23:30 UTC — should be adjusted to 07:00 next day UTC
|
||||
const scheduledAt = new Date("2026-07-21T23:30:00.000Z");
|
||||
|
||||
await service.createJob({
|
||||
const result = await service.createReminderJobs({
|
||||
companyId: "company-1",
|
||||
clientId: "client-1",
|
||||
appointmentId: "appt-1",
|
||||
type: "creation",
|
||||
scheduledAt,
|
||||
type: "reminder",
|
||||
appointmentStart: new Date("2026-07-21T12:00:00.123Z"),
|
||||
});
|
||||
|
||||
const createCall = mockJobCreate.mock.calls[0][0];
|
||||
// 23:30 is inside quiet hours (22:00-07:00), adjusted to 07:00 next day
|
||||
expect(createCall.scheduledAt.getUTCHours()).toBe(7);
|
||||
expect(createCall.scheduledAt.getUTCDate()).toBe(22); // next day
|
||||
expect(result).toHaveLength(1);
|
||||
expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
|
||||
"2026-07-21T07:30:00.123Z"
|
||||
);
|
||||
});
|
||||
|
||||
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 = {
|
||||
channels: ["system"],
|
||||
timezone: "America/Argentina/Buenos_Aires",
|
||||
quietHours: { from: "22:00", to: "07:00" },
|
||||
timezone: "UTC",
|
||||
quietHours: { from: "01:00", to: "06:00" },
|
||||
reminderRules: [{ offset: 150, enabled: true }],
|
||||
};
|
||||
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
||||
|
||||
await service.createJob({
|
||||
const result = await service.createReminderJobs({
|
||||
companyId: "company-1",
|
||||
clientId: "client-1",
|
||||
appointmentId: "appt-1",
|
||||
type: "creation",
|
||||
scheduledAt: new Date("2026-07-21T05:00:00.000Z"),
|
||||
type: "reminder",
|
||||
appointmentStart: new Date("2026-07-21T08:00:00.123Z"),
|
||||
});
|
||||
|
||||
const createCall = mockJobCreate.mock.calls[0][0];
|
||||
expect(createCall.scheduledAt.toISOString()).toBe("2026-07-21T07:00:00.000Z");
|
||||
expect(result).toHaveLength(1);
|
||||
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 = {
|
||||
channels: ["system"],
|
||||
timezone: "America/Argentina/Buenos_Aires",
|
||||
quietHours: { from: "22:00", to: "07:00" },
|
||||
reminderRules: [{ offset: 510, enabled: true }],
|
||||
};
|
||||
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
||||
|
||||
// scheduledAt at 14:00 UTC — outside quiet hours (22:00-07:00)
|
||||
const scheduledAt = new Date("2026-07-21T14:00:00.000Z");
|
||||
|
||||
await service.createJob({
|
||||
const result = await service.createReminderJobs({
|
||||
companyId: "company-1",
|
||||
clientId: "client-1",
|
||||
appointmentId: "appt-1",
|
||||
type: "creation",
|
||||
scheduledAt,
|
||||
type: "reminder",
|
||||
appointmentStart: new Date("2026-07-22T12:00:00.123Z"),
|
||||
});
|
||||
|
||||
const createCall = mockJobCreate.mock.calls[0][0];
|
||||
// 14:00 is outside quiet hours, should remain unchanged
|
||||
expect(createCall.scheduledAt.getUTCHours()).toBe(14);
|
||||
expect(createCall.scheduledAt.getUTCDate()).toBe(21);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
|
||||
"2026-07-22T00:59:00.123Z"
|
||||
);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
@@ -428,21 +566,24 @@ describe("NotificationJobService.createJob", () => {
|
||||
expect(createCall.clientId).toBe("client-1");
|
||||
});
|
||||
|
||||
it("creates immediate jobs without reminder offsets", async () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T14:00:00.000Z"));
|
||||
it("does not apply quiet-hours adjustment to immediate reminder jobs", async () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T09:00:00.000Z"));
|
||||
const resolvedPolicy: ResolvedPolicy = {
|
||||
channels: ["system", "email"],
|
||||
timezone: "America/Argentina/Buenos_Aires",
|
||||
timezone: "UTC",
|
||||
quietHours: { from: "08:00", to: "10:00" },
|
||||
reminderRules: [{ offset: 30, enabled: true }],
|
||||
};
|
||||
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
||||
const scheduledAt = new Date("2026-07-21T09:30:00.123Z");
|
||||
|
||||
const result = await service.createImmediateJob({
|
||||
companyId: "company-1",
|
||||
clientId: "client-1",
|
||||
appointmentId: "appt-1",
|
||||
type: "reminder",
|
||||
scheduledAt,
|
||||
appointmentStart: new Date("2026-07-21T16:00:00.000Z"),
|
||||
});
|
||||
|
||||
@@ -453,12 +594,12 @@ describe("NotificationJobService.createJob", () => {
|
||||
expect.objectContaining({
|
||||
channel: "system",
|
||||
type: "reminder",
|
||||
scheduledAt: new Date("2026-07-21T14:00:00.000Z"),
|
||||
scheduledAt,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
channel: "email",
|
||||
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> {
|
||||
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",
|
||||
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 = {
|
||||
method: "post",
|
||||
maxBodyLength: Infinity,
|
||||
|
||||
@@ -6,8 +6,11 @@ export enum NOTIFICATION_TYPES {
|
||||
|
||||
export type NotificationDataEmail = {
|
||||
email: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
subject?: string;
|
||||
message?: string;
|
||||
templateId?: string;
|
||||
context?: Record<string, unknown>;
|
||||
substitutions?: Record<string, string | number | boolean>;
|
||||
};
|
||||
|
||||
export type NotificationDataWap = {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
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 { OrganizationFiscalProfilesAdapterMongoose } from "./OrganizationFiscalProfiles.Adapter.Mongoose";
|
||||
import {
|
||||
@@ -70,6 +73,21 @@ class OrganizationFiscalProfilesManager implements IOrganizationFiscalProfilesMa
|
||||
|
||||
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);
|
||||
|
||||
const existingProfile = await this.organizationFiscalProfiles.findOne({
|
||||
@@ -140,6 +158,18 @@ class OrganizationFiscalProfilesManager implements IOrganizationFiscalProfilesMa
|
||||
companyId: data.companyId,
|
||||
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) {
|
||||
const profile = await this.organizationFiscalProfiles.findOne(data);
|
||||
|
||||
@@ -152,6 +182,18 @@ class OrganizationFiscalProfilesManager implements IOrganizationFiscalProfilesMa
|
||||
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];
|
||||
} else {
|
||||
throw new Error("Se requiere una organización para buscar perfiles fiscales");
|
||||
|
||||
@@ -112,7 +112,6 @@ export class PlanUsageCycleAdapterMongoose implements IPlanUsageCycleAdapter {
|
||||
planId: data.planId,
|
||||
cycleStart: data.cycleStart,
|
||||
cycleEnd: data.cycleEnd,
|
||||
appointmentsCount: 0,
|
||||
creationDate: now,
|
||||
},
|
||||
$inc: { appointmentsCount: data.quantity },
|
||||
|
||||
@@ -212,6 +212,7 @@ class RepeatsManager implements IRepeatsManager {
|
||||
}
|
||||
|
||||
await this.repeats.delete(data.repeatId);
|
||||
await MetricsList.addRepeat({ userId: String(companyCheck.ownerId), quantity: -1 });
|
||||
}
|
||||
|
||||
public async deleteRepeatsByCompany(data: DeleteRepeatsByCompanyParams): Promise<void> {
|
||||
|
||||
@@ -8,6 +8,34 @@ export type ScheduleItem = {
|
||||
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 = {
|
||||
_id?: string;
|
||||
companyId?: string;
|
||||
@@ -100,6 +128,7 @@ export interface ISchedulesManager {
|
||||
findAllSchedulesByCollaborator(data: FindSchedulesParams): Promise<CollaboratorSchedulesView>;
|
||||
delete(data: DeleteScheduleParams): Promise<void>;
|
||||
isAvailable(data: AvailableSchedulesParams): Promise<boolean>;
|
||||
applyScheduleChanges(data: ScheduleApplyParams): Promise<ScheduleApplyResult>;
|
||||
deleteSchedulesByCompany(data: DeleteScheulesByCompanyParams): Promise<void>;
|
||||
deleteSchedulesByEmployee(data: DeleteSchedulesByEmployeeParams): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
DeleteScheulesByCompanyParams,
|
||||
DeleteSchedulesByEmployeeParams,
|
||||
ScheduleItem,
|
||||
ScheduleApplyParams,
|
||||
ScheduleApplyResult,
|
||||
ScheduleConflictDetail,
|
||||
} from "./Schedules.Interface";
|
||||
import { EmployeeRoles } from "../Employees/Employees.Interface";
|
||||
import UsersManager from "../Users/Users";
|
||||
@@ -24,6 +27,9 @@ import { isNull } from "../../helpers/IsNull";
|
||||
import getAvatar from "../../helpers/getAvatar";
|
||||
import { NotificationsManager } from "../Notifications/Notifications";
|
||||
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 {
|
||||
schedules: SchedulesAdapterMongoose;
|
||||
@@ -173,6 +179,169 @@ class SchedulesManager implements ISchedulesManager {
|
||||
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 {
|
||||
if (!serviceId || !schedule.serviceScope || schedule.serviceScope === "all") {
|
||||
return true;
|
||||
|
||||
@@ -32,6 +32,12 @@ export class SchedulesDisabledAdapterMongoose implements ISchedulesDisabledAdapt
|
||||
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(
|
||||
filters: Omit<FindSchedulesDisabledParams, "sessionUser">
|
||||
): Promise<ISchedulesDisabledDocument | null> {
|
||||
|
||||
@@ -90,6 +90,7 @@ export interface SchedulesDisabledByCollaboratorView {
|
||||
export interface ISchedulesDisabledAdapter {
|
||||
create(data: CreateSchedulesDisabledParams): Promise<ISchedulesDisabled>;
|
||||
delete(id: string): Promise<void>;
|
||||
find(filters: Omit<FindSchedulesDisabledParams, "sessionUser">): Promise<ISchedulesDisabled[]>;
|
||||
findOne(filters: FindSchedulesDisabledParams): Promise<ISchedulesDisabled | null>;
|
||||
}
|
||||
|
||||
@@ -98,6 +99,7 @@ export interface ISchedulesDisabledManager {
|
||||
createSchedulesDisabled(data: CreateSchedulesDisabledParams): Promise<ISchedulesDisabled>;
|
||||
disableSchedule(data: DisableScheduleParams): Promise<void>;
|
||||
enableSchedule(data: DisableScheduleParams): Promise<void>;
|
||||
find(data: FindSchedulesDisabledParams): Promise<ISchedulesDisabled[]>;
|
||||
findOne(data: FindSchedulesDisabledParams): Promise<ISchedulesDisabled | null>;
|
||||
findSchedulesDisabledByCollaborator(
|
||||
data: FindSchedulesDisabledParams
|
||||
|
||||
@@ -271,6 +271,17 @@ class SchedulesDisabledManager implements ISchedulesDisabledManager {
|
||||
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> {
|
||||
await validateSessionUser({ sessionUser: data.sessionUser });
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ import ClientsList from "../Clients/Clients";
|
||||
import RepeatsList from "../Repeats/Repeats";
|
||||
import { OAuth2Client } from "google-auth-library";
|
||||
import SchedulesDisabledList from "../SchedulesDisabled/SchedulesDisabled";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const cleanEmail = (email: string): string => {
|
||||
return email.toLowerCase().trim();
|
||||
@@ -351,6 +352,28 @@ class UsersManager implements IUsersManager {
|
||||
.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> {
|
||||
// Destructure email and password from the data object
|
||||
const {
|
||||
@@ -406,12 +429,13 @@ class UsersManager implements IUsersManager {
|
||||
|
||||
const newUser = await this.users.create(newUserData);
|
||||
|
||||
const signUpUrl = "https://turnosxpress.com.ar/landing/login/verification";
|
||||
|
||||
await NotificationsManager.sendEmail({
|
||||
email: cleanEmail(email),
|
||||
subject: "TurnosXpress :: Bienvenido/a",
|
||||
message: `Hola ${firstName}, Gracias por registrarte en turnosXpress. Primero debes activar tu cuenta ingresando en ${signUpUrl} . Tu código de verificación es: ${verificationCode}`,
|
||||
templateId: "6a85dd644ea98ccfcd05bdc7",
|
||||
context: {
|
||||
username: firstName || cleanEmail(email),
|
||||
verificationcode: verificationCode,
|
||||
},
|
||||
});
|
||||
|
||||
return newUser;
|
||||
@@ -459,8 +483,11 @@ class UsersManager implements IUsersManager {
|
||||
|
||||
await NotificationsManager.sendEmail({
|
||||
email: cleanEmail(data.email),
|
||||
subject: "TurnosXpress :: Recuperación de cuenta",
|
||||
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`,
|
||||
templateId: "6a85c7ad96475f2825015277",
|
||||
context: {
|
||||
username: user.firstName || cleanEmail(data.email),
|
||||
verificationcode: code,
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -515,10 +542,16 @@ class UsersManager implements IUsersManager {
|
||||
throw new Error("El código de verificación es incorrecto");
|
||||
}
|
||||
|
||||
const wasVerificated = Boolean(userTest.verificated);
|
||||
|
||||
userTest.verificated = true;
|
||||
|
||||
await userTest.save();
|
||||
|
||||
if (!wasVerificated) {
|
||||
await this.sendWelcomeEmail(userTest.email, userTest.firstName);
|
||||
}
|
||||
|
||||
try {
|
||||
const freePlan = await PlansList.plans.findOne({ price: 0 });
|
||||
if (freePlan) {
|
||||
@@ -724,11 +757,17 @@ class UsersManager implements IUsersManager {
|
||||
});
|
||||
|
||||
if (userCheck) {
|
||||
const wasVerificated = Boolean(userCheck.verificated);
|
||||
|
||||
userCheck.external_id = googleUserId;
|
||||
userCheck.external_service = "google";
|
||||
userCheck.verificated = true;
|
||||
await userCheck.save();
|
||||
|
||||
if (!wasVerificated) {
|
||||
await this.sendWelcomeEmail(userCheck.email, userCheck.firstName);
|
||||
}
|
||||
|
||||
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({
|
||||
sessionUser: userCheck.id,
|
||||
});
|
||||
@@ -770,6 +809,8 @@ class UsersManager implements IUsersManager {
|
||||
throw new Error("Error creating user");
|
||||
}
|
||||
|
||||
await this.sendWelcomeEmail(newUser.email, newUser.firstName);
|
||||
|
||||
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({
|
||||
sessionUser: newUser.id,
|
||||
});
|
||||
@@ -821,11 +862,17 @@ class UsersManager implements IUsersManager {
|
||||
});
|
||||
|
||||
if (userCheck) {
|
||||
const wasVerificated = Boolean(userCheck.verificated);
|
||||
|
||||
userCheck.external_id = googleData.id;
|
||||
userCheck.external_service = "google";
|
||||
userCheck.verificated = true;
|
||||
await userCheck.save();
|
||||
|
||||
if (!wasVerificated) {
|
||||
await this.sendWelcomeEmail(userCheck.email, userCheck.firstName);
|
||||
}
|
||||
|
||||
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({
|
||||
sessionUser: userCheck.id,
|
||||
});
|
||||
@@ -867,6 +914,8 @@ class UsersManager implements IUsersManager {
|
||||
throw new Error("Error creating user");
|
||||
}
|
||||
|
||||
await this.sendWelcomeEmail(newUser.email, newUser.firstName);
|
||||
|
||||
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({
|
||||
sessionUser: newUser.id,
|
||||
});
|
||||
@@ -1039,10 +1088,18 @@ class UsersManager implements IUsersManager {
|
||||
|
||||
await this.users.update(data);
|
||||
|
||||
const fecha = dayjs().format("DD/MM/YYYY");
|
||||
const hora = dayjs().format("HH:mm");
|
||||
|
||||
await NotificationsManager.sendEmail({
|
||||
email: cleanEmail(sessionUser.email),
|
||||
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 CompaniesList from "../Companies/Companies";
|
||||
import { CompaniesAdapterMongoose } from "../Companies/Companies.Adapter.Mongoose";
|
||||
import ServiceList from "../Services/Service";
|
||||
import {
|
||||
PublicServicesResults,
|
||||
@@ -15,6 +16,24 @@ import { BotEventParams, BotView } from "../WapServer/WapServer.Interface";
|
||||
import WapServerList from "../WapServer/WapServer";
|
||||
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 = {
|
||||
education: PublicServicesResults;
|
||||
health: PublicServicesResults;
|
||||
@@ -39,6 +58,7 @@ export type PublicOrganizationViewParams = {
|
||||
|
||||
export type PublicOrganizationView = {
|
||||
id: string;
|
||||
slug: string;
|
||||
headerFile: string;
|
||||
headerColor: string;
|
||||
headerFontColor: string;
|
||||
@@ -143,6 +163,7 @@ class Views {
|
||||
|
||||
return {
|
||||
id: isNull<string>(company.id, ""),
|
||||
slug: isNull<string>(company.slug, ""),
|
||||
name: isNull<string>(company.name, ""),
|
||||
description: isNull<string>(company.description, ""),
|
||||
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> {
|
||||
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")
|
||||
@Post()
|
||||
public async toggleFixedCompanyPost(
|
||||
@Body() requestBody: FixCompanyPostParams
|
||||
@Body() requestBody: FixCompanyPostParams,
|
||||
): Promise<ApiVoidResult | ApiValidationError> {
|
||||
try {
|
||||
await new CompaniesService().toggleFixedPost(requestBody);
|
||||
@@ -62,7 +62,7 @@ export class MoveCompanyFixedPostController extends Controller {
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post()
|
||||
public async moveFixedCompanyPost(
|
||||
@Body() requestBody: MoveCompanyFixedPostParams
|
||||
@Body() requestBody: MoveCompanyFixedPostParams,
|
||||
): Promise<ApiVoidResult | ApiValidationError> {
|
||||
try {
|
||||
await new CompaniesService().moveFixedPost(requestBody);
|
||||
@@ -86,7 +86,7 @@ export class SetNotificationsStatusCompanyController extends Controller {
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post()
|
||||
public async setNotificationAuto(
|
||||
@Body() requestBody: SetNotificationAutoParams
|
||||
@Body() requestBody: SetNotificationAutoParams,
|
||||
): Promise<ApiVoidResult | ApiValidationError> {
|
||||
try {
|
||||
await new CompaniesService().setNotificationAuto(requestBody);
|
||||
@@ -110,7 +110,7 @@ export class SetPublishedStatusCompanyController extends Controller {
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post()
|
||||
public async setPublishedStatus(
|
||||
@Body() requestBody: SetOrganizationPublishedStatusParams
|
||||
@Body() requestBody: SetOrganizationPublishedStatusParams,
|
||||
): Promise<ApiVoidResult | ApiValidationError> {
|
||||
try {
|
||||
await new CompaniesService().setPublishedStatus(requestBody);
|
||||
@@ -134,7 +134,7 @@ export class CreateCompanyController extends Controller {
|
||||
@SuccessResponse(200, "La compañia fue creada con exito")
|
||||
@Post()
|
||||
public async createCompany(
|
||||
@Body() requestBody: CreateCompanyParams
|
||||
@Body() requestBody: CreateCompanyParams,
|
||||
): Promise<ICompany | ApiValidationError> {
|
||||
try {
|
||||
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")
|
||||
@Post()
|
||||
public async deleteCompany(
|
||||
@Body() requestBody: DeleteCompanyParams
|
||||
@Body() requestBody: DeleteCompanyParams,
|
||||
): Promise<ApiVoidResult | ApiValidationError> {
|
||||
try {
|
||||
await new CompaniesService().deleteCompany(requestBody);
|
||||
@@ -179,7 +179,7 @@ export class UpdateCompanyController extends Controller {
|
||||
@SuccessResponse(200, "La compañia fue modificada con exito")
|
||||
@Post()
|
||||
public async updateCompany(
|
||||
@Body() requestBody: UpdateCompanyParams
|
||||
@Body() requestBody: UpdateCompanyParams,
|
||||
): Promise<ApiVoidResult | ApiValidationError> {
|
||||
try {
|
||||
await new CompaniesService().updateCompany(requestBody);
|
||||
@@ -202,7 +202,7 @@ export class GetCompanyByIdController extends Controller {
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post()
|
||||
public async getById(
|
||||
@Body() requestBody: OranizationsViewByIdParams
|
||||
@Body() requestBody: OranizationsViewByIdParams,
|
||||
): Promise<MyOranizationsView | ApiValidationError> {
|
||||
try {
|
||||
const companies = await new CompaniesService().getById(requestBody);
|
||||
@@ -222,7 +222,7 @@ export class GetCompanyByUserController extends Controller {
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post()
|
||||
public async getByUserId(
|
||||
@Body() requestBody: MyOranizationsViewParams
|
||||
@Body() requestBody: MyOranizationsViewParams,
|
||||
): Promise<MyOranizationsView[] | ApiValidationError> {
|
||||
try {
|
||||
const companies = await new CompaniesService().getByUserId(requestBody);
|
||||
@@ -242,7 +242,7 @@ export class PaginateCompanyController extends Controller {
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post()
|
||||
public async paginateUsers(
|
||||
@Body() requestBody: PaginateCompaniesParams
|
||||
@Body() requestBody: PaginateCompaniesParams,
|
||||
): Promise<PaginateCompaniesResults | ApiValidationError> {
|
||||
try {
|
||||
const users = await new CompaniesService().paginateCompanies(requestBody);
|
||||
@@ -265,7 +265,7 @@ export class UploadCompanyHeaderController extends Controller {
|
||||
public async uploadHeader(
|
||||
@FormField() companyId: string,
|
||||
@FormField() sessionUser: string,
|
||||
@UploadedFile() file: Express.Multer.File
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
): Promise<ApiVoidResult | ApiValidationError> {
|
||||
try {
|
||||
await new CompaniesService().setCompanyHeader({
|
||||
@@ -295,7 +295,7 @@ export class UploadCompanyIconController extends Controller {
|
||||
public async uploadIcon(
|
||||
@FormField() companyId: string,
|
||||
@FormField() sessionUser: string,
|
||||
@UploadedFile() file: Express.Multer.File
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
): Promise<ApiVoidResult | ApiValidationError> {
|
||||
try {
|
||||
await new CompaniesService().setCompanyIcon({
|
||||
@@ -325,7 +325,7 @@ export class DeleteCompanyHeaderController extends Controller {
|
||||
@SuccessResponse(200, "El archivo fue cargado con exito")
|
||||
@Post()
|
||||
public async deleteHeader(
|
||||
@Body() data: DeleteCompanyFileParams
|
||||
@Body() data: DeleteCompanyFileParams,
|
||||
): Promise<ApiVoidResult | ApiValidationError> {
|
||||
try {
|
||||
await new CompaniesService().deleteCompanyHeader(data);
|
||||
@@ -349,7 +349,7 @@ export class DeleteCompanyIconController extends Controller {
|
||||
@SuccessResponse(200, "El archivo fue cargado con exito")
|
||||
@Post()
|
||||
public async deleteIcon(
|
||||
@Body() data: DeleteCompanyFileParams
|
||||
@Body() data: DeleteCompanyFileParams,
|
||||
): Promise<ApiVoidResult | ApiValidationError> {
|
||||
try {
|
||||
await new CompaniesService().deleteCompanyIcon(data);
|
||||
@@ -372,7 +372,7 @@ export class filterAllOrganizationsController extends Controller {
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post()
|
||||
public async filterAllOrganizations(
|
||||
@Body() requestBody: PaginateCompaniesParams
|
||||
@Body() requestBody: PaginateCompaniesParams,
|
||||
): Promise<TextObjectFilterResult[] | ApiValidationError> {
|
||||
try {
|
||||
const clients = await new CompaniesService().filterAll(requestBody);
|
||||
@@ -392,7 +392,7 @@ export class CheckCompanyNameController extends Controller {
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post()
|
||||
public async checkName(
|
||||
@Body() requestBody: { name: string }
|
||||
@Body() requestBody: { name: string },
|
||||
): Promise<{ valid: boolean } | ApiValidationError> {
|
||||
try {
|
||||
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 });
|
||||
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 { IPlanSuscriptionDocument } from "../../Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose";
|
||||
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 = {
|
||||
id: string | number;
|
||||
@@ -41,6 +44,66 @@ type MercadoPagoPaymentVerification = {
|
||||
const REJECTED_PAYMENT_STATUSES = ["rejected", "cancelled", "failed"];
|
||||
|
||||
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> {
|
||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||
|
||||
@@ -300,6 +363,9 @@ export class MercadoPagoWebhookService {
|
||||
const periodStart = isExtensionPayment && dayjs(subscription.endDate).isAfter(approvedAt)
|
||||
? subscription.endDate
|
||||
: approvedAt;
|
||||
const transactionId = String(paymentData.id);
|
||||
const existingPayments = await PlanPaymentsList.find({ transactionId });
|
||||
const shouldSendApprovedPaymentEmail = existingPayments.length === 0;
|
||||
|
||||
if (isUpgradePayment && subscription.pendingPaymentPlanId) {
|
||||
subscription.planId = subscription.pendingPaymentPlanId;
|
||||
@@ -342,8 +408,12 @@ export class MercadoPagoWebhookService {
|
||||
paymentDate: approvedAt,
|
||||
paymentMethod: paymentData.payment_method_id || paymentData.payment_type_id || "mercadopago",
|
||||
status: "completed",
|
||||
transactionId: String(paymentData.id),
|
||||
transactionId,
|
||||
});
|
||||
|
||||
if (shouldSendApprovedPaymentEmail) {
|
||||
await this.sendApprovedPaidPlanEmail(subscription, paymentData, userId, approvedAt, periodStart);
|
||||
}
|
||||
}
|
||||
|
||||
private async applyRejectedPayment(
|
||||
|
||||
@@ -7,8 +7,10 @@ import { ApiVoidResult } from "../../Models/Api.VoidResult.type";
|
||||
interface SendEmailByContentParams {
|
||||
systemToken: string;
|
||||
email: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
subject?: string;
|
||||
message?: string;
|
||||
templateId?: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface SendWapByContentParams {
|
||||
@@ -29,6 +31,8 @@ export class SendEmailByContentController extends Controller {
|
||||
email: requestBody.email,
|
||||
subject: requestBody.subject,
|
||||
message: requestBody.message,
|
||||
templateId: requestBody.templateId,
|
||||
context: requestBody.context,
|
||||
});
|
||||
|
||||
this.setStatus(200);
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
PaginateSchedulesResults,
|
||||
FindSchedulesParams,
|
||||
CollaboratorSchedulesView,
|
||||
ScheduleApplyParams,
|
||||
ScheduleApplyResult,
|
||||
} from "../../Models/Schedules/Schedules.Interface";
|
||||
import { authenticateMiddleware } from "../../middleware/authentication";
|
||||
import { ApiVoidResult } from "../../Models/Api.VoidResult.type";
|
||||
@@ -36,6 +38,7 @@ export class CreateScheduleController extends Controller {
|
||||
}
|
||||
|
||||
@Route("schedules/get-all-by-collaborator")
|
||||
@Middlewares(authenticateMiddleware)
|
||||
export class GetAllSchedulsByCollaboratoreController extends Controller {
|
||||
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||
@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")
|
||||
@Middlewares(authenticateMiddleware)
|
||||
export class DeleteScheduleController extends Controller {
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
PaginateSchedulesResults,
|
||||
CollaboratorSchedulesView,
|
||||
FindSchedulesParams,
|
||||
ScheduleApplyParams,
|
||||
ScheduleApplyResult,
|
||||
} from "../../Models/Schedules/Schedules.Interface";
|
||||
|
||||
export class SchedulesService {
|
||||
@@ -26,6 +28,12 @@ export class SchedulesService {
|
||||
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> {
|
||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||
await ScheduleList.delete(data);
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
PublicOrganizationServiceView,
|
||||
UserAppointmentViewParams,
|
||||
UserAppointmentView,
|
||||
PublicOrganizationsParams,
|
||||
PublicOrganizationsResult,
|
||||
} from "../../Models/Views/Views";
|
||||
import { ViewsService } from "./Views.Service";
|
||||
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,
|
||||
UserAppointmentViewParams,
|
||||
UserAppointmentView,
|
||||
PublicOrganizationsParams,
|
||||
PublicOrganizationsResult,
|
||||
} from "../../Models/Views/Views";
|
||||
import { BotView, BotEventParams } from "../../Models/WapServer/WapServer.Interface";
|
||||
|
||||
@@ -45,4 +47,12 @@ export class ViewsService {
|
||||
const view = await views.wapView(data);
|
||||
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
|
||||
}
|
||||