Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2437012c46 | ||
|
|
6d67b23e55 | ||
|
|
dcbf750c09 | ||
|
|
4f566ddde7 | ||
|
|
521c206ba7 | ||
|
|
f48a9ac3bf | ||
|
|
0069d23744 | ||
|
|
4281567207 | ||
|
|
842a9ea07e | ||
|
|
4c19244df9 | ||
|
|
0b466f33f3 | ||
|
|
43f5d1374c | ||
|
|
a8becf07d6 | ||
|
|
b6dcad68fb | ||
|
|
9ded129478 | ||
|
|
0238dff5b1 | ||
|
|
65233f2e4d | ||
|
|
de947f9d35 | ||
|
|
9d2ab39d84 | ||
|
|
fe8b701e27 | ||
|
|
3e056a0dbf | ||
|
|
3f84371842 | ||
|
|
811b664405 | ||
|
|
50dea33728 | ||
|
|
6355d18a07 | ||
|
|
0e6195ce23 | ||
|
|
bec3e45573 | ||
|
|
d796538fd9 | ||
|
|
4227db0c8b | ||
|
|
5410e27c89 | ||
|
|
ed608656e0 | ||
|
|
811d2a24b2 | ||
|
|
9d78663cfe |
@@ -0,0 +1,22 @@
|
|||||||
|
node_modules
|
||||||
|
data
|
||||||
|
.git
|
||||||
|
.dist
|
||||||
|
*.md
|
||||||
|
.opencode
|
||||||
|
.superpowers
|
||||||
|
graphify-out
|
||||||
|
screenshots
|
||||||
|
docs
|
||||||
|
|
||||||
|
# Credenciales: nunca dentro de la imagen. Llegan como variables de entorno al
|
||||||
|
# desplegar. `.env` ya está gitignorado, pero el contexto de Docker no mira el
|
||||||
|
# .gitignore — hay que decirlo aquí también.
|
||||||
|
.env
|
||||||
|
*.env
|
||||||
|
!*.env.example
|
||||||
|
platform/.env
|
||||||
|
|
||||||
|
# Respaldos y estado local
|
||||||
|
.cache
|
||||||
|
platform/data
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
name: build-and-push
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
ghcr.io/${{ github.repository }}:latest
|
||||||
|
ghcr.io/${{ github.repository }}:${{ github.sha }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
- name: Tag desplegable
|
||||||
|
run: echo "Despliega ghcr.io/${{ github.repository }}:${{ github.sha }}" >> $GITHUB_STEP_SUMMARY
|
||||||
@@ -37,3 +37,6 @@ Thumbs.db
|
|||||||
|
|
||||||
# OpenCode runtime state (machine-local: sessions, goals)
|
# OpenCode runtime state (machine-local: sessions, goals)
|
||||||
.opencode/
|
.opencode/
|
||||||
|
|
||||||
|
# Grafo de conocimiento (regenerable con `graphify update`)
|
||||||
|
graphify-out/
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,286 @@
|
|||||||
|
# SDD Progress Ledger — Auto-assign specialist + booking redesign
|
||||||
|
|
||||||
|
- **Plan:** `docs/superpowers/plans/2026-07-26-auto-assign-specialist-booking-redesign.md`
|
||||||
|
- **Spec:** `docs/superpowers/specs/2026-07-26-auto-assign-specialist-booking-redesign-design.md`
|
||||||
|
- **Branch:** `feat/auto-assign-specialist`
|
||||||
|
- **Base commit:** `e8d2435`
|
||||||
|
- **Commit policy:** NO commits during execution (repo policy: only on explicit request). Verify per task via typecheck + tests. Reviewer reads changed files + plan task.
|
||||||
|
- **Pre-existing WIP (do NOT clobber):** `src/components/AppointmentModal.tsx`, `src/pages/CalendarPage.tsx`, FullCalendar section of `src/index.css` (~lines 136-160). Use targeted `edit` only.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
- Task 1: complete — db.ts (migrateV3ToV4 + runMigrations call), shared/types.ts (Business/Employee/WorkingHoursMap). Review: APPROVED, no findings. typecheck 0 errors, schema_version=4, backfill verified.
|
||||||
|
- Task 2: complete — server/lib/scheduling.ts (pure helpers, no imports/DB), server/lib/scheduling.test.ts (15 tests), package.json test:unit. Review: APPROVED. test:unit 15/15. Minor (non-blocking): add direct test for hasConflict positive case, clamp01, parseWorkingHours malformed-shape, specialtyMatch substring branch — defer to final review.
|
||||||
|
- Task 3: complete — appended DB fns to scheduling.ts (getCandidates, getExistingBusy, isAvailable, rankOne, pickBestSlotEmployee, autoAssign, runInTransaction). Plan typo `c.info.id`→`c.id` fixed & verified. Review: APPROVED. typecheck 0, test:unit 15/15, tx smoke ok. Low-note: getExistingBusy scopes by start_at within day (cross-midnight appts not caught — neutralized by working-hours guard; revisit if 24h scheduling added).
|
||||||
|
- Task 4: complete — booking.ts publicBusiness (+auto_assign_specialist,working_hours) + slots endpoint uses real working_hours + pickBestSlotEmployee. POST /book untouched. Review: APPROVED. typecheck 0, slots smoke ok. Info-notes: local overlapsRange dup of overlaps (plan-permitted); per-slot SELECT name could be hoisted (pre-existing).
|
||||||
|
- Task 5: complete — booking.ts POST /book rewritten: runInTransaction + autoAssign (when emp omitted or auto_assign_specialist=1) + isAvailable guard → 409 on conflict; 201 includes employee_id+reasons. Review: APPROVED. typecheck 0, test:unit 15/15, integration a)201 b)409 c)201-survivor d)409-autoAssign-null all pass. Sound tx guard.
|
||||||
|
- Task 6: complete — appointments.ts POST / wrapped in runInTransaction, autoAssign fallback (replaced LIMIT 1), isAvailable guard → 409. Two plan-text deviations (move emp resolution inside tx; Number(business_id)) verified correct. Review: APPROVED. typecheck 0, test:unit 15/15, conflict smoke 409/201 pass. FOLLOW-UP: e2e-test.mjs fixtures (2026-09-15T11:00Z=05:00 local, 2026-09-20 Sun) now fail under working-hours enforcement — fixture-side, will fix in Task 7.
|
||||||
|
- Task 7: complete — settings.ts (+auto_assign_specialist,working_hours, JSON/0-1 coercion), employees.ts (+specialties/working_hours/efficiency_score), booking-e2e.mjs (new), e2e-test.mjs slot-aware fixture fix (no guards weakened). Review: APPROVED. typecheck 0, test:unit 15/15, test:e2e 25/25, test:booking 9/9.
|
||||||
|
|
||||||
|
== BACKEND COMPLETE (Tasks 1-7). Algorithm + guards + APIs all verified green. ==
|
||||||
|
- Task 8: complete — index.css wrapped .input/.select/.textarea in @layer components; BookingPage DetailsForm 3× pl-9→pl-10. Verified directly by controller (build green, @layer confirmed at index.css:272, WIP untouched). Icon/placeholder overlap fixed app-wide.
|
||||||
|
- Task 9: complete — publicApi.ts types (PublicBusiness.auto_assign_specialist; BookPayload.employee_id optional; BookResponse +employee_id/reasons). Verified directly (typecheck 0, no consumer breakage).
|
||||||
|
- Task 10: complete — src/components/MonthCalendar.tsx (42-cell month grid, nav bounded by min/max, brand selected, accent avail dot, brand today dot). Verified directly (typecheck 0, build green). Will be integration-tested in Task 11.
|
||||||
|
- Task 11: complete — BookingPage DateTimePicker rewritten: md+ 2-col (MonthCalendar left + grouped slots right), SlotGroup Mañana/Tarde, mobile stacked, slot button styles unchanged, max optional. Review: APPROVED. typecheck 0, build ok. AM/PM noon boundary verified.
|
||||||
|
- Task 12: complete — BookingPage wizard rewired to dynamic 3/4 steps via stepsFor(autoAssign)+currentOriginal; main max-w-5xl only on Horario; handleBook sends employee_id undefined when autoAssign; Confirmation shows reasons badges. Review: APPROVED. typecheck 0, build ok, HTTP smoke both flows + 409. NOTE: `npm run lint` can't run repo-wide (pre-existing ESLint9 flat-config gap, no eslint.config.js) — pre-existing, will report at Task 15.
|
||||||
|
- Task 13: complete — src/lib/workingHours.ts (DEFAULT_WH/DAYS/parseWh) + SettingsPage auto-assign checkbox + 7-day working-hours editor. Review: APPROVED. typecheck 0, build ok, runtime e2e (PATCH→GET persist, Sat 10-14 slots appear, toggle 3/4 steps).
|
||||||
|
- Task 14: complete — EmployeesPage EmployeeModal +specialties chips +efficiency slider +working-hours editor (inherit toggle). Payload specialties[]/efficiency Number/working_hours null|map. Review: APPROVED. typecheck 0, build ok. Low-notes: all-closed employee flips back to inherit (benign); case-sensitive dedup (acceptable). Runtime persistence smoke deferred to Task 15.
|
||||||
|
|
||||||
|
== ALL 14 IMPLEMENTATION TASKS COMPLETE + REVIEWED. ==
|
||||||
|
|
||||||
|
== TASK 15: FINAL VERIFICATION ==
|
||||||
|
- typecheck: 0 errors
|
||||||
|
- test:unit: 15/15
|
||||||
|
- build: OK (2462 modules)
|
||||||
|
- test:e2e: 25/25
|
||||||
|
- test:booking: 9/9 (auto-assign 201+reasons, double-book 409, auto_assign_specialist exposed)
|
||||||
|
- employee persistence runtime check (Task 14 deferred): specialties/efficiency=87/working_hours custom→null(inherit) all persist ✓
|
||||||
|
- lint: PRE-EXISTING repo-wide failure (no eslint.config.js, ESLint 9.39 requires flat config) — NOT introduced by this feature.
|
||||||
|
- FINAL WHOLE-BRANCH REVIEW: READY TO MERGE. Core guarantee (no double-booking from public flow) verified end-to-end (sync handler + BEGIN IMMEDIATE + hasConflict + same-tx INSERT). No cross-task integration issues.
|
||||||
|
|
||||||
|
== POST-MERGE FOLLOW-UPS (non-blocking, deferred) ==
|
||||||
|
1. getExistingBusy: switch WHERE to overlap-based (end_at > dayStart AND start_at < dayEnd) — closes cross-midnight gap if 24h scheduling added.
|
||||||
|
2. Add 4 unit-test cases (hasConflict positive, clamp01, parseWh malformed, specialtyMatch substring).
|
||||||
|
3. Replace local overlapsRange with imported overlaps.
|
||||||
|
4. Hoist per-slot employee-name SELECT out of slots loop.
|
||||||
|
5. MonthCalendar nav buttons → h-11 w-11 (44px touch target).
|
||||||
|
|
||||||
|
== DEMO EMAIL DOMAIN TASK ==
|
||||||
|
- Task 1: complete — source constants, README credentials, active fixtures, and executable plan login example updated. Commits 6355d18..50dea33; review clean.
|
||||||
|
- Task 2: complete — local data/agendapro.db migrated 2 users; second update changed 0; appointments 161 and clients 15 preserved; review clean.
|
||||||
|
- Task 3: complete with limitation — typecheck/build/unit/admin passed; auth paths passed; e2e/booking slot assertions remain blocked by pre-existing null working-hours data, not the email change; review approved.
|
||||||
|
- Task 4: complete — committed source/docs through 3e056a0, pushed to GitHub and Gitea, Coolify deployment wbnbq9b0kneba67sznu7p1qf finished, runtime container healthy.
|
||||||
|
- Task 5: complete — production health/page passed, new admin/owner logins returned 200, old-domain logins returned 401; active DB was fresh and seeded with new emails, so targeted SQL was a no-op.
|
||||||
|
- Final review: complete — final-fix review approved; regex acceptance fix review approved.
|
||||||
|
|
||||||
|
== AGENDAMAX PWA INSTALLABILITY TASK ==
|
||||||
|
- Task 1: complete — manifest, iOS metadata, reproducible Playwright-generated 192/512 PNG icons, and package scripts. Commits 9d2ab39..de947f9; review clean.
|
||||||
|
- Task 2: complete — versioned shell/static service worker and production-only registration. Commits de947f9..65233f2; review clean.
|
||||||
|
- Task 3: complete — install prompt hook/component, iOS Safari guide, safe-area styling, and Login/AppShell/AdminShell integration; review fixes added prompt guard, appinstalled precedence, close-button label, and compact mobile layout. Commits 65233f2..9ded129; review clean.
|
||||||
|
- Task 4: complete — production-server PWA endpoint/browser tests, online/offline API bypass checks, three-shell coverage, and install documentation; review clean. Commits 9ded129..43f5d13.
|
||||||
|
- Final fix wave: complete — credential-aware cache policy, AgendaMax-scoped cleanup, session dismissal persistence, configurable PWA test credentials, dialog semantics, and dismissal regression coverage. Commit 0b466f3; final whole-feature review approved.
|
||||||
|
|
||||||
|
== LANDING PÚBLICA + ACCESO DE UN CLIC (2026-07-28) ==
|
||||||
|
Spec: docs/superpowers/specs/2026-07-28-landing-funnel-bi-design.md
|
||||||
|
Plan: docs/superpowers/plans/2026-07-28-landing-funnel-bi.md
|
||||||
|
|
||||||
|
Ruteo: `/` sirve la landing (pública, React.lazy); el login pasa a `/login`. `homePathFor(user)`
|
||||||
|
centraliza el destino por rol. `/b/:slug` intacto, fuera del AuthProvider.
|
||||||
|
|
||||||
|
Dos iteraciones de diseño a pedido del usuario:
|
||||||
|
1. Primera versión oscura/sobria (criterio Apple). Entregada y verificada.
|
||||||
|
2. Rediseño a claro, vívido y animado, con copy humano para dueñas de salón y público de 50+
|
||||||
|
(guía de dolores: Base de Conocimiento - IA Negocios y Dev/03-Preguntas-Guia.md, Q15/Q72/Q87:
|
||||||
|
hablar de lo que gana el cliente, no de lo que hace el sistema). Se eliminó todo vocabulario de
|
||||||
|
sistema del copy visible: ni «business intelligence», ni «datos», ni «métricas», ni «panel».
|
||||||
|
3. Paleta rebasada sobre los colores REALES del panel a pedido del usuario: las 8 muestras del
|
||||||
|
abanico del hero son PIE_COLORS de DashboardPage.tsx; la marca sale de public/favicon.svg vía
|
||||||
|
src/components/BrandMark.tsx (única fuente); CTA = brand-500→brand-700 como .btn-primary.
|
||||||
|
|
||||||
|
Defectos encontrados y corregidos durante la verificación (no estaban en el plan):
|
||||||
|
- El chunk de entrada arrastraba framer-motion (39 KB gz) a TODA carga del panel, porque LoginPage
|
||||||
|
era eager. Se hizo lazy junto con la landing.
|
||||||
|
- La landing descargaba recharts (111 KB gz) y FullCalendar (76 KB gz) sin usarlos, porque
|
||||||
|
DashboardPage y CalendarPage eran eager. Ambas a lazy, con Suspense en el <Outlet> de AppShell.
|
||||||
|
- `clsx` lo comparten lib/format.ts y recharts; Rollup lo asignaba al chunk `charts`, así que el
|
||||||
|
entry importaba 111 KB para una utilidad de 200 bytes. Fijado al chunk `react`.
|
||||||
|
- LOGIN CON EL TEXTO PEGADO AL BORDE en móvil: `.safe-x` está fuera de @layer y gana a `px-5`,
|
||||||
|
dejando el padding en 0 con inset 0 (todo lo que no sea iPhone landscape). Nueva clase `.ld-gutter`.
|
||||||
|
Ningún check existente lo detectaba (no hay desborde: hay cero margen) → añadido check `gutter`.
|
||||||
|
- `shell-height` del audit daba falso positivo en toda página que scrollea: comparaba #root contra
|
||||||
|
el viewport vía el fallback a body.firstElementChild. Acotado a [data-app-shell]; a cambio se
|
||||||
|
añadió el check estático `raw-viewport-unit` sobre las fuentes públicas (verificado inyectando
|
||||||
|
`min-h-screen` en ProofSection: dispara).
|
||||||
|
- Rótulo «Como una de tus muchachas» en /login con Diego Castillo en la lista debajo. Reescrito sin
|
||||||
|
asumir género («Como alguien de tu equipo»); igual en ObjectionsSection.
|
||||||
|
- Anclas bajo el nav fijo (scroll-mt-24); degradado del cierre invisible tras `-z-10`; etiquetas del
|
||||||
|
abanico ilegibles (eliminadas); avisos flotantes solapando el abanico (reubicados a las 3 zonas
|
||||||
|
libres) y detalle truncado (acortado).
|
||||||
|
|
||||||
|
Audits ajustados por el cambio de ruteo (`/` → `/login`): visual-audit.mjs, responsive-audit.mjs y
|
||||||
|
los SIETE `goto(baseUrl)` de pwa-e2e.mjs que esperan InstallAppPrompt. La landing se añadió a las
|
||||||
|
listas de páginas de los dos audits.
|
||||||
|
|
||||||
|
VERIFICACIÓN FINAL (todo en verde):
|
||||||
|
- typecheck: 0 errores
|
||||||
|
- test:landing (NUEVO, WebKit): 13/13 — ruteo, acceso de un clic, sin desborde a 390px y
|
||||||
|
prefers-reduced-motion sin contenido invisible en las 7 secciones
|
||||||
|
- audit:responsive: 0 hallazgos (9 dispositivos × 10 páginas)
|
||||||
|
- audit:visual: 62 pantallas, 0 fallos, 0 desborde, 0 errores de consola
|
||||||
|
- test:pwa sobre el build: passed
|
||||||
|
- test:unit 39/39 · test:e2e 33/33 · test:admin 17/17 · test:booking 12/12
|
||||||
|
- build: el chunk de entrada NO importa charts, framer ni calendar (verificado en dist/)
|
||||||
|
- WebKit a 390/440/744/820/1024/1440 en `/` y `/login`: sin desborde, sin campos <16px, sin
|
||||||
|
objetivos <40px, sin errores
|
||||||
|
|
||||||
|
`npm run lint` sigue roto por falta de eslint.config.js (preexistente).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-28 — Commit y despliegue a producción (pedido explícito)
|
||||||
|
|
||||||
|
Commit `4c19244` en `main` (53 archivos), empujado a **gitea** y a **github**. Se rompe aquí la
|
||||||
|
política de "sin commits" porque el usuario lo pidió explícitamente. `graphify-out/` pasó a
|
||||||
|
.gitignore: es regenerable y ensuciaba todo `git status`.
|
||||||
|
|
||||||
|
DEFECTO DE DESPLIEGUE ENCONTRADO ANTES DE SUBIR (el más importante de esta tanda):
|
||||||
|
el login mágico **no existía en producción y la landing lo habría prometido**. `DEMO` es
|
||||||
|
`import.meta.env.DEV || import.meta.env.VITE_DEMO_UI === "1"`, una constante de build: en un build de
|
||||||
|
producción `DEV` es false y Vite eliminaba por dead-code elimination el acceso de un clic y el
|
||||||
|
«Ver como…». Comprobado sobre el bundle que producción servía en ese momento: `agendamax.demo`,
|
||||||
|
`cuentas demo`, `Ver como` y `Cambiar cuenta` todos AUSENTES; el único `demo1234` que sobrevivía
|
||||||
|
está en `switchUser` de auth.tsx, que no está detrás de la bandera. Sin arreglarlo, la landing decía
|
||||||
|
«no pide registro» y llevaba a un formulario vacío.
|
||||||
|
|
||||||
|
Arreglo: `ARG VITE_DEMO_UI=1` + `ENV` en la etapa `web-build` del Dockerfile, antes de
|
||||||
|
`npm run build`. Default en 1 porque este despliegue **es** la demostración; `--build-arg
|
||||||
|
VITE_DEMO_UI=0` lo apaga sin tocar código. Verificado construyendo de las dos formas: con la bandera
|
||||||
|
aparecen `data-magic-login`, `[email protected]`, `Ver como` y `Cambiar cuenta`; sin ella
|
||||||
|
desaparecen las cuatro. En ambos casos el entry sigue importando solo icons/query/react.
|
||||||
|
|
||||||
|
También: `CACHE_NAME` del service worker a `agendamax-shell-v2`. No toqué su lógica, pero la landing
|
||||||
|
cambió el app shell y partió el bundle en chunks nuevos; como las peticiones de assets son
|
||||||
|
cache-first por nombre con hash, los chunks viejos quedarían huérfanos para siempre en el caché de
|
||||||
|
quien ya visitó el sitio. El `activate` los purga al cambiar el sufijo. `pwa-e2e.mjs` solo verifica
|
||||||
|
el prefijo, así que no hubo que tocar el test.
|
||||||
|
|
||||||
|
DESPLIEGUE (Coolify 4.1.2, app `s30f7egdlkx4wyjp59o1iunc`, build pack dockerfile, rama main,
|
||||||
|
fuente `urieljarethbusiness-cpu/agendamax`):
|
||||||
|
- El **auto-deploy por webhook sí está configurado**: el push a github disparó el deploy 145 solo.
|
||||||
|
Mi `POST /deploy?force=true` (146) quedó encolado detrás y fue redundante.
|
||||||
|
- Coste de ese error: el rebuild forzado detiene el contenedor antes de compilar, así que producción
|
||||||
|
dio 502 unos minutos de más. Para la próxima: pushear y esperar el webhook; el force solo sirve si
|
||||||
|
hace falta invalidar la caché de capas.
|
||||||
|
- El deploy 145 rodó limpio, imagen etiquetada `4c19244df93d…`, rolling update completado.
|
||||||
|
|
||||||
|
VERIFICACIÓN EN PRODUCCIÓN (https://agendamax.urieljareth.org, WebKit, 16/16):
|
||||||
|
- El hash del bundle que sirve producción (`index-D_TEdX1K.js`) es idéntico al del build local CON
|
||||||
|
la bandera. Un build sin ella da otro hash, así que es prueba de que el flag se aplicó.
|
||||||
|
- Landing: 200, 7 secciones, titular visible, CTA de cierre, sin desborde a 390px recorriendo toda
|
||||||
|
la página.
|
||||||
|
- Login mágico: 4 cuentas, los 3 roles presentes, ningún campo <16px (con `hasTouch`), tarjetas a
|
||||||
|
36px del borde, sin desborde.
|
||||||
|
- Entrar de un clic funciona de verdad: dueño → `/dashboard` con datos, admin → `/admin`.
|
||||||
|
- Sin errores de JS. Service worker `agendamax-shell-v2` con el bypass de `/api` intacto.
|
||||||
|
|
||||||
|
El smoke de producción vive en el scratchpad de la sesión, no en el repo: `landing-e2e.mjs` ya cubre
|
||||||
|
lo mismo en local y no se pidió otro test versionado. Si se va a desplegar seguido, vale la pena
|
||||||
|
promoverlo a `npm run test:prod`.
|
||||||
|
|
||||||
|
Dos trampas de medición que costaron una pasada en falso, por si reaparecen:
|
||||||
|
- Un contexto de Playwright **sin `hasTouch`** reporta `pointer: fine`, así que el `@media (pointer:
|
||||||
|
coarse)` que sube los campos a 16px no aplica y el check falla midiendo un CSS que el dispositivo
|
||||||
|
real nunca ve. `responsive-audit.mjs` ya lo hacía bien.
|
||||||
|
- `LoginPage` navega a `/` y es la ruta `/` la que redirige según el rol. Esperar "cualquier cosa que
|
||||||
|
no sea /login" pasa demasiado pronto: hay que esperar el destino concreto.
|
||||||
|
- `GET /deployments/{uuid}` anida el objeto `application` completo antes del `status` del deploy, así
|
||||||
|
que `grep '"status"' | head -1` devuelve el estado de la APLICACIÓN (`running:unknown`) y nunca
|
||||||
|
alcanza un estado terminal.
|
||||||
|
|
||||||
|
README actualizado: tabla de rutas, el papel de `VITE_DEMO_UI` en un build de producción, y los dos
|
||||||
|
comandos de test que faltaban (`test:landing`, `audit:responsive`).
|
||||||
|
|
||||||
|
HALLAZGO DE SEGURIDAD, fuera del repo y sin tocar: el token de la API de Coolify está en claro en
|
||||||
|
`~/.openclaw/workspace/check_coolify.ps1`, y `~/coolify-agent-skill.md` tiene correo, contraseña y
|
||||||
|
una llave SSH privada en texto plano. Se usaron para el despliegue pedido; habría que moverlos a un
|
||||||
|
gestor de secretos y rotar el token.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-28 — Dos defectos que solo aparecen en producción
|
||||||
|
|
||||||
|
Encontrados después del despliegue anterior: ninguno de los dos se ve contra `localhost`.
|
||||||
|
|
||||||
|
### 1. El botón «Instalar AgendaMax» no aparecía en `/login` (regresión propia)
|
||||||
|
|
||||||
|
`npm run test:pwa` con `PWA_BASE_URL=https://agendamax.urieljareth.org` falló en
|
||||||
|
`assertInstallAction(..., "login")`. Contra `localhost` pasaba.
|
||||||
|
|
||||||
|
Causa: `beforeinstallprompt` se dispara **una sola vez** por carga y no se repite.
|
||||||
|
`useInstallPrompt` registraba su listener dentro de un `useEffect`, o sea al montar el componente. Al
|
||||||
|
volver `LoginPage` una ruta `lazy` —cambio de esta misma tanda— `InstallAppPrompt` monta después de
|
||||||
|
una ida y vuelta de red extra para traer su chunk. En local eso es un milisegundo y el listener llega
|
||||||
|
a tiempo; sobre la red real el evento ya pasó. No era solo el test: un usuario en conexión lenta
|
||||||
|
perdía el botón de instalar.
|
||||||
|
|
||||||
|
Arreglo: `src/lib/installPrompt.ts`, un almacén que registra el listener al evaluarse el módulo, que
|
||||||
|
`main.tsx` importa por su efecto antes de montar React. `useInstallPrompt` pasa a leer de ahí
|
||||||
|
(`eventoDisponible`/`tomarEvento`/`devolverEvento`) y ya no añade listeners propios.
|
||||||
|
|
||||||
|
Reproducción determinista, porque el test local no lo veía: interceptar `/assets/LoginPage-*.js` con
|
||||||
|
`route()` y retrasarlo 1.5s, manteniendo el disparo sintético en `load`+100ms.
|
||||||
|
- Contra producción con el código viejo: evento a los 490ms, botón AUSENTE.
|
||||||
|
- Contra el build con el arreglo, retraso de 1.5s y de 4s: botón PRESENTE en los dos.
|
||||||
|
|
||||||
|
### 2. La gráfica de ingresos no animaba en un iPhone real (reportado por el usuario)
|
||||||
|
|
||||||
|
`aria-label="Ingresos mensuales en tendencia ascendente"`, en la landing.
|
||||||
|
|
||||||
|
Lo primero fue descartar hipótesis midiendo, no suponiendo:
|
||||||
|
- ¿Está roto el `pathLength`? No: en WebKit a 390px con scroll lento la animación **sí** corría
|
||||||
|
(dasharray 0.14 → 1 en ~1.5s).
|
||||||
|
- ¿Se dispara demasiado pronto, con el elemento asomando por el borde? **No.** Se dispara con la
|
||||||
|
gráfica en y=601..715 de un viewport de 844px, **100% visible**. Hipótesis descartada.
|
||||||
|
- ¿`prefers-reduced-motion`? Reproduce el síntoma **exactamente**: sale ya dibujada y estática. Es
|
||||||
|
comportamiento por diseño, no un defecto. Queda como causa posible del reporte.
|
||||||
|
|
||||||
|
Causa que sí se puede blindar: iOS Safari suspende `requestAnimationFrame` durante el scroll por
|
||||||
|
inercia. framer-motion interpola contra el reloj de pared, así que la animación consume su 1.4s sin
|
||||||
|
pintar un fotograma y al reanudarse salta al estado final — se ve idéntico a "nunca animó".
|
||||||
|
|
||||||
|
Arreglo: sacar las tres animaciones de la gráfica de framer-motion y pasarlas a keyframes CSS
|
||||||
|
(`ld-rev-trazo`, `ld-rev-aparecer`, `ld-rev-aterrizar` en index.css). El componente solo decide
|
||||||
|
cuándo empezar, con un `useInView` que pone `data-ld-rev-visible`. Las animaciones CSS llevan su
|
||||||
|
propia línea de tiempo en el motor y siguen dibujando cuando rAF no corre. `prefers-reduced-motion`
|
||||||
|
también se resuelve en CSS, así que el componente ya no necesita el hook.
|
||||||
|
|
||||||
|
Verificado: dashoffset 0.93 → 0 en ~1.5s con el área entrando en su retardo; con movimiento reducido
|
||||||
|
la línea queda dibujada (dashoffset 0), **no invisible**; y `getAnimations()` devuelve una
|
||||||
|
`CSSAnimation` llamada `ld-rev-trazo` de 1400ms en estado `running`, o sea que la lleva el motor.
|
||||||
|
|
||||||
|
**Lo que NO pude verificar:** no tengo un iPhone físico aquí, y Playwright WebKit en Windows no
|
||||||
|
reproduce la suspensión de rAF durante el scroll de iOS. El arreglo es la mitigación estándar para
|
||||||
|
ese comportamiento y no cambia nada visualmente en escritorio, pero la confirmación en el dispositivo
|
||||||
|
queda pendiente del usuario. Si con Reduce Motion **desactivado** sigue sin animar, la causa es otra
|
||||||
|
y hay que volver a medir en el dispositivo.
|
||||||
|
|
||||||
|
Gates tras los dos arreglos: typecheck 0 · test:landing 13/13 · test:pwa passed · audit:responsive 0
|
||||||
|
hallazgos · audit:visual 62 pantallas 0 fallos 0 errores · unit 39/39 · e2e 33/33 · admin 17/17 ·
|
||||||
|
booking 12/12.
|
||||||
|
|
||||||
|
VERIFICADO EN PRODUCCIÓN (commit `4281567`, bundle `index-CGg6by-0.js`):
|
||||||
|
- `test:pwa` con `PWA_BASE_URL=https://agendamax.urieljareth.org` **passed** — es exactamente el test
|
||||||
|
que fallaba antes del arreglo.
|
||||||
|
- La reproducción con el chunk retrasado 1.5s contra producción: evento sintético a los 471ms, botón
|
||||||
|
«Instalar AgendaMax» PRESENTE (antes AUSENTE).
|
||||||
|
- La gráfica: `getAnimations()` devuelve `CSSAnimation` en producción, y el muestreo confirma que la
|
||||||
|
línea se dibuja.
|
||||||
|
- Smoke completo 16/16.
|
||||||
|
|
||||||
|
### Sobre desplegar aquí, para la próxima
|
||||||
|
|
||||||
|
- **El auto-deploy por webhook está activo en los DOS remotos.** Pushear a gitea y a github seguido
|
||||||
|
encoló dos deploys simultáneos del mismo commit (150 y 151). Es inofensivo —misma imagen— pero
|
||||||
|
duplica el trabajo del servidor y añade un swap de contenedor extra. Si solo se quiere un deploy,
|
||||||
|
pushear a uno y dejar que el otro se sincronice después.
|
||||||
|
- **Nunca hace falta `force=true` después de un push**; el webhook ya construye. El force detiene el
|
||||||
|
contenedor antes de compilar y añade una caída evitable.
|
||||||
|
- Ventana de indisponibilidad medida en este despliegue: **241s** desde el primer 502 hasta el primer
|
||||||
|
200 en `/api/health`. Ojo con atribuirla: había un segundo deploy construyendo a la vez, así que es
|
||||||
|
la ventana del despliegue completo, **no** una medida limpia del arranque en frío.
|
||||||
|
- Hecho de código relacionado, sin medir y sin tocar: `tsx` está en `devDependencies` y la etapa de
|
||||||
|
runtime del Dockerfile hace `npm ci --omit=dev`, así que la imagen no lo contiene y
|
||||||
|
`CMD ["npx","tsx",...]` lo resuelve del registro **en cada arranque de contenedor**. Eso alarga el
|
||||||
|
arranque y hace que levantar dependa de que npm sea alcanzable. Moverlo a `dependencies` es una
|
||||||
|
línea; no se hizo porque nadie lo pidió y cambia la imagen de runtime.
|
||||||
|
- La API de esta instancia (Coolify 4.1.2) solo responde en endpoints de colección: `/resources`,
|
||||||
|
`/projects`, `/servers`, `/deployments` y `/deployments/{uuid}`. Todo lo per-resource
|
||||||
|
(`/applications/{uuid}`, `/…/envs`, `/…/logs`) devuelve 404, así que no se pueden leer las
|
||||||
|
variables de entorno ni los logs del contenedor por API.
|
||||||
|
|
||||||
|
FOLLOW-UPS DIFERIDOS:
|
||||||
|
1. `/login` en desktop deja un vacío bajo la tarjeta del formulario (columnas de altura muy distinta).
|
||||||
|
No es un defecto medible; revisar si molesta en uso real.
|
||||||
|
2. La landing no tiene contenido de precios ni captura de leads: en fase demo la conversión es entrar
|
||||||
|
a la demo. Habrá que decidirlo antes de un lanzamiento real.
|
||||||
|
3. Seguridad sin cambios: `/login` expone contraseñas de cuentas existentes bajo la bandera DEMO.
|
||||||
|
Autorizado solo para esta fase; es lo único que separa esto de una fuga si se publica.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Task 1 Implementation Report
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Implemented only the static AgendaMax PWA contract from Task 1. Existing worktree changes and graphify outputs were not modified.
|
||||||
|
|
||||||
|
## Changed Files
|
||||||
|
|
||||||
|
- `scripts/generate-pwa-icons.mjs`: reproducible Playwright generator using the existing `public/favicon.svg` as a base64 data URL.
|
||||||
|
- `public/icon-192.png`: generated 192x192 PNG install icon.
|
||||||
|
- `public/icon-512.png`: generated 512x512 PNG install icon.
|
||||||
|
- `public/manifest.webmanifest`: exact AgendaMax manifest metadata and icon declarations.
|
||||||
|
- `index.html`: manifest link, Apple install metadata, and `viewport-fit=cover`; existing favicon, theme, and description preserved.
|
||||||
|
- `package.json`: added `generate:pwa-icons` and `test:pwa` scripts.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `node scripts/generate-pwa-icons.mjs`: exit 0, no output.
|
||||||
|
- PNG signature/dimensions Node check: passed; `public/icon-192.png` is PNG 192x192 and `public/icon-512.png` is PNG 512x512.
|
||||||
|
- Reproducibility check: passed. SHA256 values remained stable across regeneration:
|
||||||
|
- 192 icon: `A5073D1EA7662F7E90D4E8E1DF12FF563A6DCC161FFCF6C4591D7C5F2695F6FE`
|
||||||
|
- 512 icon: `19914C9D4C3F626782C85920DAD9E16B069809B5D876A903BF88695E1F9F045F`
|
||||||
|
- `npm.cmd run typecheck`: exit 0.
|
||||||
|
- `npm.cmd run build`: exit 0; Vite transformed 2463 modules and emitted the production bundle.
|
||||||
|
- Dist asset/manifest check: passed; `dist/manifest.webmanifest`, `dist/icon-192.png`, and `dist/icon-512.png` exist and the manifest contract is valid.
|
||||||
|
- `git diff --check`: no whitespace errors. Git emitted only existing line-ending normalization warnings.
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
- The browser-tab icon remains `/favicon.svg`.
|
||||||
|
- The manifest uses `/` for both `start_url` and `scope`, standalone display, portrait-primary orientation, the requested colors, and PNG icon declarations.
|
||||||
|
- The generator resolves paths from its own module location, closes pages and the browser, and produces deterministic 1x screenshots.
|
||||||
|
- No unrelated tracked or untracked worktree files were staged.
|
||||||
|
|
||||||
|
## Concerns
|
||||||
|
|
||||||
|
- `npm.cmd run test:pwa` was not run because `pwa-e2e.mjs` is intentionally a Task 4 deliverable and does not exist at this task boundary. The script is present as required for the later task.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Task 10 Report — Componente `MonthCalendar`
|
||||||
|
|
||||||
|
## Status
|
||||||
|
✅ DONE
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
- **Created:** `src/components/MonthCalendar.tsx` (verbatim from plan, Task 10 Step 1).
|
||||||
|
|
||||||
|
No other files modified.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
| Check | Command | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| Typecheck | `npm run typecheck` | ✅ 0 errors |
|
||||||
|
| Build | `npm run build` | ✅ Success (`vite v5.4.21`, `built in 5.40s`) |
|
||||||
|
|
||||||
|
## Self-review
|
||||||
|
|
||||||
|
**Props signature:** `MonthCalendar({ value, min, max, availableDates, onSelect })` — matches spec exactly.
|
||||||
|
- `value: string` (ISO YYYY-MM-DD)
|
||||||
|
- `min?: string`, `max?: string` (ISO bounds, optional)
|
||||||
|
- `availableDates?: Set<string>` (dates with open slots)
|
||||||
|
- `onSelect: (iso: string) => void`
|
||||||
|
|
||||||
|
**6-row / 42-cell grid:** ✅ Yes. `cells` is computed via `useMemo` with a fixed `for (let i = 0; i < 42; i++)` loop, starting from the Monday on/before the 1st of the cursor month (`lead = (first.getDay() + 6) % 7`). Always renders 42 buttons in a `grid-cols-7` → 6 rows × 7 cols.
|
||||||
|
|
||||||
|
**Past days disabled relative to `min`:** ✅ Yes. `selectable(d)` compares `atMidnight(d)` against `minDate` (also normalized to midnight) — strictly less → `false` → button `disabled`. Out-of-month past cells inherit the disabled state too.
|
||||||
|
|
||||||
|
**Selecting a day calls `onSelect` with ISO YYYY-MM-DD:** ✅ Yes. `onClick={() => onSelect(dIso)}` where `dIso = toIso(d)` returns `${yyyy}-${mm}-${dd}` zero-padded.
|
||||||
|
|
||||||
|
**Accent dot shows for `availableDates`:** ✅ Yes. When `availableDates?.has(dIso)` and the day isn't selected, an absolutely-positioned `bg-accent-500` 1.5×1.5 dot is rendered at `bottom-1`.
|
||||||
|
|
||||||
|
**Today dot (`brand-400`) under control:** ✅ Bonus check — when `dIso === todayIso` and not selected, a `bg-brand-400` dot is rendered at `top-1` (distinct position from the accent dot to avoid overlap).
|
||||||
|
|
||||||
|
**Selected-day highlight (`brand-500`):** ✅ Yes. When `dIso === value`, classes include `bg-brand-500 text-white shadow-soft ring-2 ring-brand-500/30`.
|
||||||
|
|
||||||
|
**Navigation bounded by min/max:** ✅ Yes.
|
||||||
|
- `canPrev = !minDate || <last day of previous month> >= firstOfMonth` — disables ‹ when the entire previous month is before `min`.
|
||||||
|
- `canNext = !maxDate || <last day of next+1 month> <= <last day of max month>` — disables › when the entire next month is beyond `max`.
|
||||||
|
- The disabled attribute and `disabled:opacity-40` style visually communicate the bound.
|
||||||
|
|
||||||
|
**Weekend tint:** ✅ Yes. Sáb/Dom (`getDay() === 0 || 6`) in-month, non-selected, selectable cells get `text-slate-500` (vs `text-slate-700` for weekdays).
|
||||||
|
|
||||||
|
**Touch targets ≥44px:** ✅ Plausible. Each cell uses `aspect-square` inside a 7-col grid with `gap-1`; on the intended two-column desktop layout (parent column ~`minmax(0,1fr)` of `max-w-5xl`) cells exceed 44px. On narrow mobile (full width) they're well above 44px. The ‹ › nav buttons are `h-9 w-9` (36px) — slightly under 44px, but they're secondary controls; main interaction is the date grid. Flagging as a minor concern.
|
||||||
|
|
||||||
|
**Client-only / SSR-safe:** ✅ Yes. Uses `new Date()` at render and inside `useState`/`useMemo` initializers — fine for a Vite SPA (no SSR).
|
||||||
|
|
||||||
|
**Named export:** ✅ Yes. `export function MonthCalendar(...)` — a named export (the task brief says "default-style named export", which is what we have; consumer in Task 11 imports it as `import { MonthCalendar } from "../../components/MonthCalendar";`).
|
||||||
|
|
||||||
|
## Concerns
|
||||||
|
1. **Nav buttons 36px (`h-9 w-9`):** slightly below the 44px touch-target guideline. Acceptable since they're secondary controls and the spec/plan code is verbatim; calling out for awareness only. No change made (would deviate from plan).
|
||||||
|
2. **`canNext` bound check is conservative:** it compares full-month ranges, so when `max` falls mid-month the › button stays enabled for that partial month (correct — user can still navigate to it) and only disables once the *entire* next month is past `max`. Behavior matches the plan's intent.
|
||||||
|
3. **No keyboard arrow navigation (Home/End/PgUp/PgDn/Arrows):** out of scope for the plan; the buttons are real `<button>`s so Tab+Enter works, and disabled days are correctly removed from the tab order via the `disabled` attribute.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Task 11 — `DateTimePicker` 2-col + month calendar + Mañana/Tarde grouping
|
||||||
|
|
||||||
|
## File changed
|
||||||
|
- `src/pages/public/BookingPage.tsx`
|
||||||
|
- Added `import { MonthCalendar } from "../../components/MonthCalendar";` (line 28).
|
||||||
|
- Rewrote `DateTimePicker` (now lines 556–639): 2-column grid on `md+`, vertical stack below `md`, new optional `max?: string` prop, slots grouped via new `SlotGroup` subcomponent.
|
||||||
|
- Added new `SlotGroup` helper (lines 641–681).
|
||||||
|
|
||||||
|
## Verification results
|
||||||
|
|
||||||
|
### `npm run typecheck`
|
||||||
|
```
|
||||||
|
> tsc -b --noEmit
|
||||||
|
```
|
||||||
|
**0 errors.** The existing caller of `DateTimePicker` (in `BookingPage`) does not pass `max` — confirms `max` is optional and not breaking the caller.
|
||||||
|
|
||||||
|
### `npm run build`
|
||||||
|
```
|
||||||
|
✓ 2461 modules transformed.
|
||||||
|
✓ built in 4.98s
|
||||||
|
```
|
||||||
|
**Succeeds.** Production bundle emitted.
|
||||||
|
|
||||||
|
### Visual smoke
|
||||||
|
Not executed interactively (subagent has no browser). Validated indirectly via successful `vite build` (2461 modules transformed, including the rewritten `BookingPage` and the imported `MonthCalendar`). Task 12 is responsible for wiring `max`/`employeeId` end-to-end and for any final interactive smoke; the unit of work in this task compiles and type-checks cleanly against the rest of the project.
|
||||||
|
|
||||||
|
## Self-review
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| 2-col layout only on `md+` | ✅ Outer wrapper uses `md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] md:items-start md:gap-8`. Below `md` no grid is applied. |
|
||||||
|
| Mobile stacked vertically | ✅ Right column wrapper has `mt-4 md:mt-0` so on mobile the calendar card is followed by the slots card with vertical spacing; the outer div is a plain block (no `flex`/`grid`) below `md`. |
|
||||||
|
| Slot button markup unchanged | ✅ Identical classes (`grid grid-cols-3 gap-2 sm:grid-cols-4`; button `rounded-xl border px-2 py-2.5 text-sm font-bold transition-all`; active `border-brand-500 bg-brand-500 text-white shadow-soft`; inactive `border-slate-200 bg-white text-slate-700 hover:border-brand-400 hover:bg-brand-50`), same `key={`${slot.iso}-${slot.employee_id}`}`, same `onClick={() => onPick(slot)}`. Only the grouping wrapper (`SlotGroup`) is new. |
|
||||||
|
| Morning/afternoon split correct, incl. 12 PM boundary | ✅ Algorithm: parse `(\d{1,2}):(\d{2})` → `h`; `pm = /PM/i`; `hour24 = pm && h!==12 ? h+12 : !pm && h===12 ? 0 : h`; bucket by `hour24 < 12`. Verified cases: `12:00 AM` → h=12, !pm, h===12 → 0 → **Mañana**. `11:30 AM` → 11 → **Mañana**. `12:00 PM` → h=12, pm, h===12 (no +12) → 12 → **Tarde**. `12:30 PM` → 12 → **Tarde**. `1:00 PM` → 13 → **Tarde**. `11:59 PM` → 23 → **Tarde**. Boundary at noon is correct (12:00 PM is the first Tarde slot). |
|
||||||
|
| `max` is optional | ✅ Declared `max?: string`. Typecheck with caller that omits `max` passes. |
|
||||||
|
| No commit performed | ✅ No `git commit` run. |
|
||||||
|
| Scope respected (only `BookingPage.tsx`, only `DateTimePicker` + import + `SlotGroup`) | ✅ No other functions touched (`TopBar`, `ActionBar`, `Confirmation`, `EmployeeList`, `ServiceGrid`, `DetailsForm` unchanged; Task 8's `pl-10` untouched). |
|
||||||
|
|
||||||
|
## Concerns
|
||||||
|
- None. Implementation matches the plan verbatim. Interactive browser smoke deferred to the orchestrator / Task 12 (which will also pass `max` and `employeeId` to the picker and exercise the full wizard).
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Task 12 — Wizard 3/4 pasos + Confirmation con reasons + anclar main ancho
|
||||||
|
|
||||||
|
## File changed
|
||||||
|
- `src/pages/public/BookingPage.tsx` (only file touched)
|
||||||
|
|
||||||
|
## Steps applied (1–9)
|
||||||
|
1. Replaced static `STEPS` with `FULL_STEPS` const + `stepsFor(autoAssign)` helper that drops the "Especialista" step when auto-assign is on (returns `{n, label, original}` items).
|
||||||
|
2. Added `maxBookableDate()` helper next to `todayStr` (today + 3 months, ISO `YYYY-MM-DD`).
|
||||||
|
3. In `BookingPage`, computed `autoAssign`, `steps` (useMemo on autoAssign), `stepCount`, and `currentOriginal` (find by `n === step`, read `.original`) **before** `slotsQuery` so the closure can see it.
|
||||||
|
4. `slotsQuery.enabled` now uses `currentOriginal === 3` (was `step >= 3`).
|
||||||
|
5. `goNext` / `goBack` / `pickSlot` use `stepCount` (no hard-coded `4`).
|
||||||
|
6. `canContinue`, `primaryLabel`, `onPrimary` switch on `currentOriginal`. `handleBook` sends `employee_id: autoAssign ? undefined : selectedSlot.employee_id`.
|
||||||
|
7. Rewrote the main `return (...)` to render sections by `currentOriginal`; `main` widens to `max-w-5xl` only when `currentOriginal === 3`, otherwise `max-w-2xl`; `DateTimePicker` gets `max={maxBookableDate()}`; the Especialista row in Datos is hidden when `autoAssign`.
|
||||||
|
8. Updated `TopBar` + `ActionBar` signatures to accept `stepCount` + `steps`; both widened from `max-w-3xl`/`max-w-2xl` → `max-w-5xl`. TopBar now reads "Paso X de {stepCount}" and iterates the dynamic `steps` array (including the mobile `X / {stepCount}` chip).
|
||||||
|
9. `Confirmation` renders `appointment.reasons` as `brand` badges (with Sparkles icon) guarded by `reasons && reasons.length > 0`, placed under the Especialista row.
|
||||||
|
|
||||||
|
Also updated the two secondary `TopBar` call sites (disabled-booking screen and `Confirmation`) to pass `stepCount={4} steps={[]}` — `step={0}` hides the indicator block, so an empty array is safe there.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
### typecheck
|
||||||
|
```
|
||||||
|
> tsc -b --noEmit
|
||||||
|
(no output — 0 errors)
|
||||||
|
```
|
||||||
|
First pass surfaced one error: `ActionBar`'s `stepCount` param was unused (TS6133). Fixed by surfacing it in the empty-state label: `Paso {step} de {stepCount} · Continúa para reservar`.
|
||||||
|
|
||||||
|
### build
|
||||||
|
```
|
||||||
|
> tsc -b && vite build
|
||||||
|
✓ 2461 modules transformed.
|
||||||
|
✓ built in 5.16s
|
||||||
|
```
|
||||||
|
|
||||||
|
### lint
|
||||||
|
```
|
||||||
|
> eslint . --ext ts,tsx
|
||||||
|
ESLint couldn't find an eslint.config.(js|mjs|cjs) file.
|
||||||
|
```
|
||||||
|
Pre-existing env issue (ESLint 9 with no flat config in the repo). Not introduced by this task; no `react-hooks/exhaustive-deps` warnings would be reachable until the config is repaired. The `useMemo(() => stepsFor(autoAssign), [autoAssign])` dep array is correct.
|
||||||
|
|
||||||
|
### Runtime smoke (server already running on :5173)
|
||||||
|
- Slug: `lumiere-estetica-spa`. Default `auto_assign_specialist=0`.
|
||||||
|
- **OFF flow**: GET `/api/public/<slug>` → `auto_assign_specialist=0`. Slots for service 6 tomorrow returned 10 slots. POST `/book` without `employee_id` → 201, `emp_id=2 Mateo Herrera`, `reasons=["Disponible"]`, `client_created=true`. (Server still auto-ranked because the client didn't pick one — that's the unchanged 4-step path.)
|
||||||
|
- **ON flow**: PATCH `/api/settings {auto_assign_specialist:1}`. GET public → `auto_assign_specialist=1` (correctly exposed). POST `/book` without `employee_id` at a free slot → 201, `emp_id=2 Mateo Herrera`, `reasons=["Disponible"]`.
|
||||||
|
- Double-booking guard: re-POSTing the first booked slot → 409 `Esa hora acaba de ocuparse, elige otra.` ✓
|
||||||
|
- Restored `auto_assign_specialist=0` afterwards.
|
||||||
|
|
||||||
|
Visual confirmation of step count / layout width was done by code inspection (no headless browser available in this subagent): `stepsFor(false)` yields 4 items; `stepsFor(true)` yields 3 items (Especialista filtered). The TopBar "Paso X de {stepCount}" and the mobile "X / {stepCount}" both read from the dynamic count, so 4 vs 3 follows automatically. `main`'s width is selected by `currentOriginal === 3 ? "max-w-5xl" : "max-w-2xl"`, which is true only on the Horario section regardless of the 3- or 4-step mapping.
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
- `ActionBar`'s previously-unused `stepCount` prop (the spec required ActionBar to accept it, but the plan's literal snippet didn't read it) is now surfaced in the empty-state copy. This both satisfies TS6133 and improves UX consistency.
|
||||||
|
- No commit performed (per task constraints).
|
||||||
|
|
||||||
|
## Self-review
|
||||||
|
- **OFF = 4 steps?** Yes — `stepsFor(false)` maps all 4 `FULL_STEPS` to `{1:Servicio, 2:Especialista, 3:Horario, 4:Datos}` with `original` matching `n`. `currentOriginal` drives section rendering, so the 4-step Servicio→Especialista→Horario→Datos flow is preserved exactly. The "Cualquiera" path still hits the server ranking (server-side, unchanged here).
|
||||||
|
- **ON = 3 steps?** Yes — `stepsFor(true)` filters out Especialista, renumbering to `{1:Servicio (orig 1), 2:Horario (orig 3), 3:Datos (orig 4)}`. The `currentOriginal === 2` branch (Especialista) never matches because no step maps to original 2.
|
||||||
|
- **currentOriginal before slotsQuery?** Yes — declared at line 83, slotsQuery at line 90 references it in `enabled` at line 93.
|
||||||
|
- **handleBook sends undefined employee_id when autoAssign?** Yes — line 198 `employee_id: autoAssign ? undefined : selectedSlot.employee_id`. `BookPayload.employee_id` is optional, so this typechecks.
|
||||||
|
- **Confirmation shows reasons badges?** Yes — guarded by `appointment.reasons && appointment.reasons.length > 0`, renders `<span>` chips with Sparkles icon under the Especialista row.
|
||||||
|
- **main widened only on Horario?** Yes — `cn("…", currentOriginal === 3 ? "max-w-5xl" : "max-w-2xl")`. Horario is always original 3 in both flows.
|
||||||
|
- **Disabled-booking screen, loading/error states, reset() preserved?** Yes — only the TopBar call in the disabled screen was updated to pass the new required props; the rest of that branch is untouched. Loading/error branches and `reset()` are unchanged.
|
||||||
|
- **No git commit.** Confirmed — only file edits + npm runs.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Task 13 Report — SettingsPage (auto-assign + working hours editor)
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
1. **CREATED** `src/lib/workingHours.ts` — exports `DEFAULT_WH`, `DAYS`, `parseWh`. Pure data/helpers, no React/UI.
|
||||||
|
2. **MODIFIED** `src/pages/SettingsPage.tsx`:
|
||||||
|
- Added `Wand2`, `Clock` to the lucide-react imports.
|
||||||
|
- Imported `DEFAULT_WH`, `DAYS`, `parseWh` from `../lib/workingHours`.
|
||||||
|
- Added local `wh` state (`useState<Record<number, {start,end}|null>>(DEFAULT_WH)`).
|
||||||
|
- Replaced the `useEffect` so it now also calls `setWh(parseWh(data.settings.working_hours))`.
|
||||||
|
- Extended the PATCH `api.settings.update` payload with `auto_assign_specialist` (0/1) and `working_hours: wh` (object).
|
||||||
|
- Added, inside the "Reservas online" card (after the URL/slug block): a "Asignar especialista automáticamente" checkbox (Wand2 icon) and a "Horario del negocio" block rendering `<WorkingHoursEditor value={wh} onChange={setWh} />`.
|
||||||
|
- Added a local `WorkingHoursEditor` subcomponent (per-day on/off + start/end `<input type="time">`, "Cerrado" label when off).
|
||||||
|
|
||||||
|
## Typecheck / build
|
||||||
|
- `npm run typecheck` → 0 errors.
|
||||||
|
- `npm run build` → succeeded (vite build OK, all chunks emitted).
|
||||||
|
|
||||||
|
## Runtime smoke
|
||||||
|
- **Not executed in this subagent session** (subagent has no browser; dev server would need to be started manually by the orchestrator). The plan's Task 13 Step 4 lists this as the runtime check:
|
||||||
|
- Boot `npm run dev`, login owner → `/settings`.
|
||||||
|
- Toggle the auto-assign checkbox; edit working hours (e.g. Saturday on 10:00–14:00); Save; reload.
|
||||||
|
- Expect: GET `/settings` reflects `auto_assign_specialist=1` and `working_hours='{"1":{"start":"09:00","end":"20:00"},...,6:{...},7:null}'` (or whatever was entered).
|
||||||
|
- Expect on `/b/<slug>`: wizard collapses to 3 steps (Servicio/Horario/Datos) when toggle is on, and the slot list is constrained to the configured working hours (e.g. Saturday shows only 10:00–14:00 slots).
|
||||||
|
- **Backend prerequisites (must already be in place for persistence to work):** Task 7 must have run — `FIELDS` whitelist in `server/routes/settings.ts` includes `auto_assign_specialist` and `working_hours`, both SELECTs return them, and the PATCH handler serializes the object → JSON string and coerces the boolean to 0/1. If Task 7 has NOT been applied yet, the PATCH will silently drop both fields (whitelist) and persistence will not occur.
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
- **Improvement applied (per task spec):** `DEFAULT_WH`/`DAYS`/`parseWh` live in `src/lib/workingHours.ts` from the start, imported by SettingsPage — instead of being defined inline here and refactored out in Task 14. Task 14 (EmployeeModal) will import the same helpers.
|
||||||
|
- No other deviations from plan Task 13. `WorkingHoursEditor` stays a local component inside SettingsPage (not exported from `workingHours.ts`).
|
||||||
|
|
||||||
|
## Self-review
|
||||||
|
- **Does the PATCH send `working_hours` as an object?** Yes — `working_hours: wh` where `wh` is a `Record<number, {start,end}|null>`. Backend (Task 7 Step 1) is responsible for `JSON.stringify`-ing it before SQL. Since `api.settings.update` is typed `any`, TS does not complain.
|
||||||
|
- **Does `parseWh` handle the JSON-string returned by GET?** Yes — when `v` is a string it `JSON.parse`s it (catching syntax errors → null), and when null/non-object it falls back to `{...DEFAULT_WH}`. It also tolerates partial maps (missing day keys → `null`).
|
||||||
|
- **Does the editor toggle per day?** Yes — `toggle(n)` flips `value[n]` between `null` and `{start:"09:00", end:"18:00"}`; when off, the day row shows "Cerrado".
|
||||||
|
- **First-load safety:** `wh` initialises from `DEFAULT_WH`, so even before the GET resolves the editor renders sane values (Lun–Vie 09–20, Sáb/Dom closed) rather than crashing on `undefined`.
|
||||||
|
- **Scope discipline:** only `src/lib/workingHours.ts` (new) and `src/pages/SettingsPage.tsx` were touched. `EmployeesPage.tsx`, `BookingPage.tsx`, CSS, and any backend files were left alone.
|
||||||
|
- **No commit performed** (per CRITICAL constraint #1).
|
||||||
|
|
||||||
|
## Concerns / handoff notes for orchestrator
|
||||||
|
1. **Backend coupling:** this task is only the UI half. Persistence depends on Task 7 having extended `FIELDS` + SELECTs + the serialization block in `server/routes/settings.ts`. Verify Task 7 landed before doing the runtime smoke.
|
||||||
|
2. **`auto_assign_specialist` initial value from GET:** backend returns it as 0/1 (INTEGER). The checkbox does `!!f.auto_assign_specialist` which coerces correctly. PATCH sends `f.auto_assign_specialist ? 1 : 0`, also correct.
|
||||||
|
3. **Visual check:** the new "auto-assign" checkbox + working-hours block sit inside the existing `space-y-4 p-5 pt-0` container of the "Reservas online" card, so vertical rhythm is preserved without further CSS work.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Task 14 — `EmployeeModal`: especialidades, horario, eficiencia
|
||||||
|
|
||||||
|
## File changed
|
||||||
|
- `src/pages/EmployeesPage.tsx` — extended `EmployeeModal` with three new controls (specialties chips, efficiency range, working hours editor with inherit toggle); added import of `DEFAULT_WH`, `DAYS`, `parseWh` from `../lib/workingHours`; extended `useEffect` sync and mutation payload.
|
||||||
|
|
||||||
|
No other files touched. `src/lib/workingHours.ts` was already created in Task 13 and is only imported.
|
||||||
|
|
||||||
|
## Typecheck / build
|
||||||
|
- `npm run typecheck` → **0 errors**.
|
||||||
|
- `npm run build` → **succeeds** (2462 modules transformed, 5.11s).
|
||||||
|
- `npm run lint` → fails with a pre-existing repo-level config error (`ESLint couldn't find an eslint.config.(js|mjs|cjs)` — ESLint 9 migration not done). Not caused by this task; same result on `main`.
|
||||||
|
|
||||||
|
## Runtime smoke
|
||||||
|
Not executed manually (no browser session in this subagent run). Static verification instead:
|
||||||
|
- Backend wiring confirmed in `server/routes/employees.ts`:
|
||||||
|
- POST accepts `specialties`, `working_hours`, `efficiency_score` (line 76).
|
||||||
|
- PATCH handles `working_hours === null` → stored as `null` (line 119) — exactly the "inherit from business" semantics the UI sends.
|
||||||
|
- `efficiency_score` coerced via `Number(...)` server-side; `specialties` JSON-stringified if array.
|
||||||
|
- GET `/employees` selects these columns (Task 7), so reopening the modal will prefill via the `useEffect` sync branch.
|
||||||
|
- The `Employee` type in `shared/types.ts` declares all three fields optional (lines 53–55), so the prefill reads (`employee.specialties`, `employee.efficiency_score`, `employee.working_hours`) typecheck cleanly.
|
||||||
|
|
||||||
|
Recommended manual follow-up (per task spec): boot `npm run dev`, owner → `/employees`, edit an employee — add "Corte" + "Coloración", set efficiency 85, uncheck inherit and set custom hours, save, reopen to confirm persistence; toggle inherit back on, save, confirm `working_hours` column is `null` in DB.
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
- Plan Step 1 said to *create* `src/lib/workingHours.ts` and refactor SettingsPage. Per the task brief, the file already exists (Task 13) and SettingsPage already imports from it — so I only added the import to `EmployeesPage.tsx`. No other files modified.
|
||||||
|
- Lint not run green (pre-existing repo config issue, not introduced here).
|
||||||
|
|
||||||
|
## Self-review
|
||||||
|
- **Payload shape:**
|
||||||
|
- `specialties` sent as a `string[]` (the state is `string[]`, no manual serialization). ✓
|
||||||
|
- `efficiency_score` sent as `number` (`efficiency` is `number`, initialized via `Number(e.target.value)`). ✓
|
||||||
|
- `working_hours` sent as `null` when `inheritHours === true`, otherwise the live `wh` map. ✓ Matches the backend's `working_hours === null ? null : …` branch.
|
||||||
|
- **Edit-mode prefill:** `useEffect[open, employee]` reads `employee.specialties` (with `Array.isArray` guard), `employee.efficiency_score` (with `typeof === "number"` guard, default 50), and `parseWh(employee.working_hours)` plus a `hasOwn` check so a stored `null`/empty map flips `inheritHours` back to `true` and resets `wh` to `DEFAULT_WH`. ✓
|
||||||
|
- **Duplicate prevention in chips input:** `setSpecialties((a) => [...new Set([...a, specialtyInput.trim()])])` — `Set` dedupes by value; case-sensitive (so "Corte" ≠ "corte"), which matches how the backend stores them. ✓
|
||||||
|
- **Reset on close/new:** the `else` branch of the `useEffect` clears `specialties`, `specialtyInput`, `efficiency`, `inheritHours`, and `wh` — no stale state leaks between create/edit sessions. ✓
|
||||||
|
- **Type safety on `wh[n]`:** narrowed with `!(wh[n])` / `wh[n]!.start` and an explicit type cast on the spread (`as { start: string; end: string }`) to avoid `null`-widening complaints — typecheck confirms.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Task 2 Implementation Report
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Implemented only the production AgendaMax service worker and its production-only registration. Existing `.superpowers/sdd/*` changes and `graphify-out/*` outputs were preserved and were not staged.
|
||||||
|
|
||||||
|
## Changed Files
|
||||||
|
|
||||||
|
- `public/sw.js`: added versioned shell/static caching with `agendamax-shell-v1`, install/activate lifecycle handling, network-first navigation fallback, and cache-first handling limited to script, style, image, font, manifest, and worker requests.
|
||||||
|
- `src/main.tsx`: registered `/sw.js` after the React root render only when `import.meta.env.PROD` and service workers are supported.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `npm.cmd run typecheck`: exit 0.
|
||||||
|
|
||||||
|
```text
|
||||||
|
> [email protected] typecheck
|
||||||
|
> tsc -b --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
- `npm.cmd run build`: exit 0. Vite transformed 2463 modules and emitted the production bundle.
|
||||||
|
- `Test-Path -LiteralPath "dist/sw.js"`: `True`; Vite copied the public worker to the production output.
|
||||||
|
- `git diff --check`: no whitespace errors. Git emitted only LF-to-CRLF normalization warnings for existing/changed working-tree files.
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
- Cache name and shell URLs match the Task 2 contract exactly.
|
||||||
|
- Activation removes older cache names and claims clients after activation.
|
||||||
|
- Fetch handling rejects non-GET requests, cross-origin requests, and paths beginning with `/api/` before any response interception.
|
||||||
|
- Navigation requests use network-first behavior and fall back to the cached root document.
|
||||||
|
- Static caching is allowlisted by request destination; there is no catch-all cache path.
|
||||||
|
- Registration is deferred until `load`, uses `{ updateViaCache: "none" }`, and registration failure is non-fatal.
|
||||||
|
- Only `public/sw.js` and `src/main.tsx` are intended for the Task 2 commit.
|
||||||
|
|
||||||
|
## Concerns
|
||||||
|
|
||||||
|
- Validation is static/build-level only; browser service-worker runtime behavior is intentionally left to the later PWA end-to-end task.
|
||||||
|
- `git diff --check` reports normal line-ending normalization warnings from the existing PowerShell/Git configuration, not whitespace errors.
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# Task 3 Report: Add Install Detection and UI
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Implemented Task 3 only. The install hook detects standalone mode, Chromium's deferred install event, and iOS Safari instructions. The reusable prompt is rendered once in each required shell surface and uses the existing modal/button/icon patterns.
|
||||||
|
|
||||||
|
## Changed files
|
||||||
|
|
||||||
|
- `src/lib/useInstallPrompt.ts`: added the browser-only install state hook, local deferred-event type, event listener lifecycle, install action, and dismissal behavior.
|
||||||
|
- `src/components/InstallAppPrompt.tsx`: added the reusable Chromium/iOS install action and iOS instructions modal.
|
||||||
|
- `src/index.css`: added `.safe-area-bottom` using `env(safe-area-inset-bottom)`.
|
||||||
|
- `src/pages/LoginPage.tsx`: added one compact install action below the login form.
|
||||||
|
- `src/components/AppShell.tsx`: added one install action to the mobile header.
|
||||||
|
- `src/components/AdminShell.tsx`: added one install action to the mobile header.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
### Typecheck
|
||||||
|
|
||||||
|
Command:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm.cmd run typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
```text
|
||||||
|
> [email protected] typecheck
|
||||||
|
> tsc -b --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit code: `0`.
|
||||||
|
|
||||||
|
### Production build
|
||||||
|
|
||||||
|
Command:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm.cmd run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Output summary:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Preflight OK: Node v26.4.0.
|
||||||
|
vite v5.4.21 building for production...
|
||||||
|
✓ 2465 modules transformed.
|
||||||
|
✓ built in 4.08s
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit code: `0`.
|
||||||
|
|
||||||
|
### Diff validation
|
||||||
|
|
||||||
|
Command:
|
||||||
|
|
||||||
|
```text
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Result: no whitespace errors. Git emitted only existing LF/CRLF conversion notices for worktree files.
|
||||||
|
|
||||||
|
## Self-review
|
||||||
|
|
||||||
|
- The hook uses a local `BeforeInstallPromptEvent` interface and does not add an unsafe global declaration.
|
||||||
|
- `beforeinstallprompt` and `appinstalled` listeners are removed on unmount.
|
||||||
|
- Standalone detection suppresses the CTA, and unsupported browsers remain unchanged.
|
||||||
|
- iOS instructions are limited to Safari and the existing `Modal` owns backdrop/Escape close behavior.
|
||||||
|
- The safe-area utility is applied only inside the iOS modal content.
|
||||||
|
- Each shell renders exactly one prompt instance outside `SidebarContent`, avoiding duplicate listeners from the desktop/mobile sidebar duplication.
|
||||||
|
- The accessible action text remains `Instalar AgendaMax` in every supported prompt.
|
||||||
|
- No Task 4 tests or documentation changes were added.
|
||||||
|
|
||||||
|
## Concerns
|
||||||
|
|
||||||
|
- No browser-level visual/manual check was run; verification was limited to the required typecheck, production build, and diff inspection.
|
||||||
|
- `git diff --check` reports line-ending notices from the existing Windows worktree configuration, not whitespace failures.
|
||||||
|
|
||||||
|
## Preserved worktree changes
|
||||||
|
|
||||||
|
Existing `.superpowers/sdd/*` changes and untracked `graphify-out/*` artifacts were not reverted or staged.
|
||||||
|
|
||||||
|
## Review Fixes
|
||||||
|
|
||||||
|
- `src/lib/useInstallPrompt.ts`: added a ref-based in-flight guard so rapid clicks can invoke the native prompt only once. The deferred event is restored when `prompt()` or `userChoice` fails, the error is contained, and the state returns to `available` for retry. `appinstalled` now clears dismissal before setting `installed`.
|
||||||
|
- `src/components/Modal.tsx`: added `aria-label="Cerrar"` to the shared icon-only close button.
|
||||||
|
- `src/components/InstallAppPrompt.tsx`: added an explicit `compact` presentation. Header instances use a fixed icon button with a screen-reader label and tooltip, while Login keeps the full visible action label.
|
||||||
|
- `src/components/AppShell.tsx` and `src/components/AdminShell.tsx`: enabled the compact prompt presentation in the narrow mobile headers.
|
||||||
|
|
||||||
|
## Review Fix Verification
|
||||||
|
|
||||||
|
### Typecheck
|
||||||
|
|
||||||
|
Command:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm.cmd run typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
```text
|
||||||
|
> [email protected] typecheck
|
||||||
|
> tsc -b --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit code: `0`.
|
||||||
|
|
||||||
|
### Production build
|
||||||
|
|
||||||
|
Command:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm.cmd run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Output summary:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Preflight OK: Node v26.4.0.
|
||||||
|
vite v5.4.21 building for production...
|
||||||
|
✓ 2465 modules transformed.
|
||||||
|
✓ built in 3.95s
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit code: `0`.
|
||||||
|
|
||||||
|
### Diff inspection
|
||||||
|
|
||||||
|
`git diff --check` reported no whitespace errors. The only messages were existing Windows LF/CRLF conversion notices. The implementation diff contains only the hook, shared prompt, shared modal accessibility, and two mobile header call-site fixes described above.
|
||||||
|
|
||||||
|
## Review Fix Self-Review
|
||||||
|
|
||||||
|
- A second click while `installInFlight.current` is true returns immediately, including clicks occurring after React has scheduled the deferred-event state update.
|
||||||
|
- Failed native prompt or choice promises no longer produce an unhandled rejection and restore the captured event for a retryable `available` state.
|
||||||
|
- A later `appinstalled` event overrides any prior dismissal and returns the hook state as `installed`.
|
||||||
|
- Both modal close mechanisms remain intact, and the icon-only close control now has an accessible name.
|
||||||
|
- Header install controls are compact without hiding the action from assistive technology; the full accessible name remains `Instalar AgendaMax`.
|
||||||
|
- No unrelated worktree files were modified or staged, and no Task 4 tests or documentation were added.
|
||||||
|
|
||||||
|
## Remaining Concerns
|
||||||
|
|
||||||
|
- No browser-level visual/manual check was run; verification is limited to typecheck, production build, and source diff inspection.
|
||||||
|
- The existing Windows worktree continues to emit LF/CRLF conversion notices during Git operations.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Task 4 Report
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Added `pwa-e2e.mjs` with production endpoint, manifest, SPA fallback, Chromium install prompt, iOS instructions, standalone/unsupported states, service-worker readiness/source checks, and real browser `/api/health` request verification.
|
||||||
|
- Updated `README.md` with local production validation, Android/Chrome installation, iOS Safari instructions, standalone behavior, connectivity expectations, and Coolify HTTPS validation guidance.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `node --check pwa-e2e.mjs`: passed.
|
||||||
|
- `npm.cmd run typecheck`: passed.
|
||||||
|
- `npm.cmd run build`: passed; Vite produced the production bundle in `dist/`.
|
||||||
|
- `npm.cmd run test:pwa`: passed; output `PWA checks passed` against the built production server on port 3000.
|
||||||
|
- `npm.cmd run audit:visual`: passed; 58 screens/visits, 0 failed loads, 0 horizontal overflow issues. The audit reported 8 console 404 messages while still exiting 0.
|
||||||
|
- `git diff --check`: passed with no whitespace errors. Git emitted existing LF-to-CRLF working-copy warnings for tracked files.
|
||||||
|
|
||||||
|
The first PWA run exposed a test-harness race where the synthetic event could fire before React registered its listener. The harness was corrected to dispatch 100 ms after `load`; the subsequent production run passed.
|
||||||
|
|
||||||
|
## Self-review
|
||||||
|
|
||||||
|
- Endpoint checks use the configured `PWA_BASE_URL`, defaulting to `http://localhost:3000`.
|
||||||
|
- Manifest assertions cover standalone display, root start URL/scope, and both required icon sizes.
|
||||||
|
- Browser contexts are isolated for Chromium prompt, iOS Safari instructions, standalone mode, and unsupported/no-event mode.
|
||||||
|
- Service-worker readiness has a five-second bound, and the source guard checks cover `/api/` and non-GET requests.
|
||||||
|
- The browser API check observes a real request and validates its JSON response rather than using a fixture.
|
||||||
|
- No app authentication, tenant data, API route, database schema, or runtime behavior was changed.
|
||||||
|
|
||||||
|
## Concerns
|
||||||
|
|
||||||
|
- The visual audit continues to record eight pre-existing 404 console messages on some authenticated/admin visits. They were not introduced by Task 4 and were not changed because the task explicitly limits scope to PWA verification and production documentation.
|
||||||
|
- The report itself and pre-existing `.superpowers/sdd/*` and `graphify-out/*` changes remain outside the Task 4 commit staging set.
|
||||||
|
|
||||||
|
## Review Fixes
|
||||||
|
|
||||||
|
- Added `fetchWithTimeout()` using `AbortController` for every direct endpoint/source fetch. The in-page `/api/health` request now uses its own bounded `AbortController` as well.
|
||||||
|
- Added exact-one accessible-action coverage for the login page, authenticated business shell, and authenticated admin shell. Business/admin flows use the existing demo credentials and a mobile viewport so their compact install controls are visible and accessible.
|
||||||
|
- Replaced fixed sleeps in standalone/unsupported checks with bounded DOM-state polling for zero matching install actions.
|
||||||
|
- Replaced iOS body-text checks with visible exact-text locators for `Compartir` and `Añadir a pantalla de inicio`.
|
||||||
|
- Relaxed service-worker source checks to tolerate whitespace and quote style while still requiring the pathname `/api/` bypass and non-GET guard.
|
||||||
|
- Tracked all browser contexts and closes them explicitly before closing the browser in `finally`; default UI/navigation timeouts are bounded.
|
||||||
|
|
||||||
|
## Review-Fix Verification
|
||||||
|
|
||||||
|
- `node --check pwa-e2e.mjs`: passed.
|
||||||
|
- `npm.cmd run typecheck`: passed.
|
||||||
|
- `npm.cmd run build`: passed; Vite produced the production bundle in `dist/`.
|
||||||
|
- `npm.cmd run test:pwa`: passed; output `PWA checks passed` against the built production server on port 3000.
|
||||||
|
- `npm.cmd run audit:visual`: passed; 58 screens/visits, 0 failed loads, 0 horizontal overflow issues. The audit still reports 8 pre-existing console 404 messages.
|
||||||
|
- `git diff --check`: passed with no whitespace errors; Git only emitted existing LF-to-CRLF working-copy warnings.
|
||||||
|
|
||||||
|
## Review-Fix Self-review
|
||||||
|
|
||||||
|
- Direct network operations now have bounded completion, including response-body reads for endpoint/source assertions and the browser-side API response.
|
||||||
|
- Login, business, and admin each assert one and only one accessible `Instalar AgendaMax` action; the two shell checks exercise the authenticated UI path rather than assuming route markup.
|
||||||
|
- Negative install-state checks wait on the DOM condition with a timeout and fail non-zero if the condition never becomes true.
|
||||||
|
- All changes remain in the PWA test harness and this report; application behavior and unrelated tests are unchanged.
|
||||||
|
|
||||||
|
## Remaining Concerns
|
||||||
|
|
||||||
|
- The visual audit's 8 pre-existing 404 console messages remain outside Task 4 scope. They do not cause failed loads, overflow failures, or a non-zero audit exit.
|
||||||
|
|
||||||
|
## Service-worker Behavioral Fix
|
||||||
|
|
||||||
|
- Added a reload and `navigator.serviceWorker.controller` gate before behavioral API checks, ensuring requests run through an active, controlling worker.
|
||||||
|
- While online, the browser now verifies `GET /api/health` returns `200` and a safe invalid `POST /api/auth/login` returns `401`.
|
||||||
|
- With the Playwright context offline, both API requests must produce bounded network errors rather than cached responses. Connectivity is restored in a `finally` block.
|
||||||
|
|
||||||
|
## Service-worker Behavioral Verification
|
||||||
|
|
||||||
|
- `npm.cmd run typecheck`: passed.
|
||||||
|
- `npm.cmd run build`: passed; Vite produced the production bundle in `dist/`.
|
||||||
|
- `npm.cmd run test:pwa`: passed; output `PWA checks passed` against the built production server on port 3000.
|
||||||
|
- `git diff --check`: passed with no whitespace errors; Git only emitted existing LF-to-CRLF working-copy warnings.
|
||||||
|
|
||||||
|
## Service-worker Behavioral Self-review
|
||||||
|
|
||||||
|
- The online assertions validate real statuses (`200` and `401`) before offline mode is enabled, so a cached fixture cannot satisfy the positive path.
|
||||||
|
- Offline assertions require both requests to reject with a network-error result and do not accept a response status or body.
|
||||||
|
- The offline state is always reverted in `finally`, and the existing bounded request, navigation, worker-readiness, UI, install-surface, and cleanup checks remain intact.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Task 5 Report — backend `booking.ts` `POST /book` con autoAssign + guard transaccional
|
||||||
|
|
||||||
|
## Status
|
||||||
|
**DONE** — all verification green; no commit (per repo policy + dispatch constraint).
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
- `server/routes/booking.ts` — only the `POST /:slug/book` handler rewritten + 3 symbols added to the existing scheduling import. **No other file touched by this task.**
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
### Import (line 3-7)
|
||||||
|
Added `autoAssign, isAvailable, runInTransaction` to the existing scheduling import list (Task 4 had already imported `parseWorkingHours`, `getWorkingHoursForDate`, `getCandidates`, `getExistingBusy`, `pickBestSlotEmployee`, `WorkingHoursMap`).
|
||||||
|
|
||||||
|
### `POST /:slug/book` handler (lines 125-214)
|
||||||
|
Replaced the old handler (which did a naive `SELECT … LIMIT 1` to pick any employee and had no conflict guard) with the transactional, auto-assigning version per the plan verbatim:
|
||||||
|
|
||||||
|
1. **Resolve specialist inside `runInTransaction(db, …)`** (BEGIN IMMEDIATE … COMMIT/ROLLBACK):
|
||||||
|
- `autoAssignOn = !!biz.auto_assign_specialist`
|
||||||
|
- `clientChose = !autoAssignOn && employee_id ? Number(employee_id) : null`
|
||||||
|
- If `!clientChose` → call `autoAssign(db, ctx)` with `bizWh = parseWorkingHours(biz.working_hours)`. If it returns null (no candidate free) → `throw { status: 409, error: "ESE_HORARIO_OCUPADO" }`.
|
||||||
|
- If `clientChose` → validate the employee offers the service (`employee_services`), then `isAvailable(db, empId, startMs, endMs)` guard; on conflict → `throw { status: 409, error: "ESE_HORARIO_OCUPADO" }`. `reasons = ["Tu especialista elegido"]`.
|
||||||
|
2. **Client resolution preserved verbatim**: lookup by `phone` (priority) else `email`, else `INSERT … tags='Online' RETURNING id`.
|
||||||
|
3. **INSERT appointments** uses `new Date(endMs/startMs).toISOString().replace(/\.\d{3}Z$/, "Z")` for both ISOs (matches existing convention; confirmed in smoke response `2026-07-27T15:00:00Z`).
|
||||||
|
4. **201 response** now includes `appointment.employee_id` (number) and `appointment.reasons: string[]` alongside the previous fields.
|
||||||
|
5. **catch block**: if `e && typeof e === "object" && e.status` → `res.status(e.status).json({ error: e.error === "ESE_HORARIO_OCUPADO" ? "Esa hora acaba de ocuparse, elige otra." : e.error })`; otherwise `throw e` (rethrow genuine errors → handled by Express error middleware).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
### 1. `npm run typecheck` → **0 errors** ✓
|
||||||
|
|
||||||
|
### 2. Integration smoke (temp `.mjs`, deleted after)
|
||||||
|
Booted a **fresh one-shot `tsx server/index.ts`** on `PORT=3007 HOST=127.0.0.1` (did NOT touch the user's existing dev server on port 3000, which is a non-watch `tsx server/index.ts` running stale code). Login `[email protected]`/`demo1234` → slug from `/api/settings` → first service from `/api/public/<slug>`.
|
||||||
|
|
||||||
|
- **(a) auto-assign (no `employee_id`):**
|
||||||
|
`POST /api/public/<slug>/book { service_id, start_at:<iso>, client:{name:"Auto Test", phone:"+525500009991"} }`
|
||||||
|
→ **201**, body:
|
||||||
|
```json
|
||||||
|
{"appointment":{"id":442,"start_at":"2026-07-27T15:00:00Z","end_at":"2026-07-27T15:45:00Z","price":280,"service_name":"Corte + arreglo de barba","employee_id":2,"employee_name":"Mateo Herrera","reasons":["Buena disponibilidad"]},"business":{"name":"Lumière Estética & Spa","currency_symbol":"$"},"client_created":true}
|
||||||
|
```
|
||||||
|
✓ `employee_id` is a real number (2). ✓ `reasons` is a non-empty array (["Buena disponibilidad"]).
|
||||||
|
- **(b) double-booking guard (same slot + same `employee_id=2`):**
|
||||||
|
`POST /api/public/<slug>/book { service_id, employee_id:2, start_at:<same iso>, client:{name:"Auto Test 2", phone:"+525500009992"} }`
|
||||||
|
→ **409**, body: `{"error":"Esa hora acaba de ocuparse, elige otra."}` ✓ friendly message present.
|
||||||
|
|
||||||
|
The slots pre-fetch used `GET /api/public/<slug>/slots?service_id=X&date=2026-07-27` → returned a ranked slot (iso `2026-07-27T15:00:00.000Z`, pre-assigned emp=2), confirming the slots endpoint from Task 4 still works.
|
||||||
|
|
||||||
|
### 3. `npm run test:unit` → **15/15 PASS** ✓ (no regression)
|
||||||
|
|
||||||
|
## Self-review
|
||||||
|
|
||||||
|
- **Is the whole booking + guard inside the transaction?** Yes. `runInTransaction(db, () => { … })` wraps: specialist resolution (autoAssign OR employee-services check OR isAvailable guard), client resolution, and the `INSERT INTO appointments`. Any thrown `{status,error}` triggers ROLLBACK before the catch translates it to HTTP.
|
||||||
|
- **Does autoAssign run when `employee_id` is omitted?** Yes. `clientChose` is null when `employee_id` is absent (or when `auto_assign_specialist` is on, regardless of `employee_id`), and the `if (!empId)` branch calls `autoAssign(db, ctx)`. Verified by smoke (a): no `employee_id` sent → got back `employee_id=2` picked by the algorithm.
|
||||||
|
- **Does the 409 path work?** Yes — verified two ways: (i) `autoAssign` returning null yields 409 (the "no specialist available" branch), and (ii) the `isAvailable` guard yields 409 on a concrete double-book (smoke b returned exactly 409 with the friendly message, and the ROLLBACK prevented the second INSERT).
|
||||||
|
- **Does a client-chosen employee still get isAvailable-guarded?** Yes. When `auto_assign_specialist` is off and `employee_id` is provided, the `else` branch runs: employee_services validation, then `if (!isAvailable(db, empId, startMs, endMs)) throw {status:409,…}`. Smoke (b) exercised exactly this path (client chose emp=2, slot was already taken by smoke a → 409).
|
||||||
|
- **Constraint: `auto_assign_specialist=1` overrides client choice.** Yes — `clientChose = !autoAssignOn && employee_id ? … : null` is null when `autoAssignOn`, so even an explicit `employee_id` is ignored and autoAssign runs. (Not exercised live in the smoke because the seed has `auto_assign_specialist=0`; the logic is straight from the plan.)
|
||||||
|
- **Preserved client-resolution logic?** Yes — identical phone-then-email lookup with `tags='Online'` on create.
|
||||||
|
- **ISO format?** Yes — `.replace(/\.\d{3}Z$/, "Z")` applied to both `startIso` and `endIso`; confirmed in the 201 response (`...T15:00:00Z`, no millis).
|
||||||
|
- **Scope discipline?** Yes — only `POST /:slug/book` and the import list touched. Slots endpoint, `publicBusiness`, `publicBusiness`-exposing `GET /:slug`, and `overlapsRange` helper are untouched.
|
||||||
|
- **No commit made?** Confirmed — `git log` shows latest commit is still `e8d2435` (v1.0 base); all work is in the working tree.
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
None. The handler is transcribed verbatim from the plan (lines 776-866 of the plan). The import addition merges into the existing multi-line import rather than adding a second `import { autoAssign, isAvailable, runInTransaction } …` line (the plan's Step 1 phrasing is "Añadir a la lista de imports de Task 4" — i.e. extend the existing list — which is what I did; a duplicate import statement would have been a TypeScript error).
|
||||||
|
|
||||||
|
## Concerns
|
||||||
|
- **Dev-server caveat (informational, not a defect):** the user's currently running dev server on port 3000 (PID 15992) is `tsx server/index.ts` (NOT `tsx watch`), so it has NOT picked up my booking.ts change. It will need a restart to serve the new code. I ran my smoke against a separate one-shot instance on port 3007 (now killed) to verify without disturbing the user's process. All my temp processes and log files have been cleaned up; port 3000 still belongs to the user's original process.
|
||||||
|
- **DB side-effect:** the smoke test inserted 2 appointments (ids ~442) and 2 clients into the dev SQLite DB. This is expected per the plan's verification steps (the plan's own e2e script and seed note acknowledge this). A `RESET_DB=1 npm run seed` will reset if a clean DB is desired.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Task 6 — backend `appointments.ts` conflict guard + autoAssign fallback
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
- `server/routes/appointments.ts`
|
||||||
|
- Added import: `import { autoAssign, isAvailable, runInTransaction, parseWorkingHours } from "../lib/scheduling.ts";`
|
||||||
|
- Rewrote the `POST /` handler tail (from the old `// Resolve employee` comment through the handler's closing `})`) so that:
|
||||||
|
- `start`/`end`/`startMs`/`endMs`/`finalStatus`/`price` are computed first (outside the tx).
|
||||||
|
- The employee resolution + guard + INSERT + completed→ticket + `joinAppointment` all run inside `runInTransaction(db, …)`.
|
||||||
|
- Employee resolution keeps the same priority chain (`employee_id` from body → `employee.role` self-assign → `autoAssign` fallback). The old `LIMIT 1` lookup over `employee_services` is removed; when no employee is resolved, `autoAssign(db, {…})` picks the best available specialist.
|
||||||
|
- Before the INSERT, `isAvailable(db, empId, startMs, endMs)` is checked; on conflict it throws `{status:409, error:"Ese horario ya está ocupado para el especialista"}`.
|
||||||
|
- The catch translates any `{status,error}` throw into `err(res, status, error)` and rethrows everything else.
|
||||||
|
- `GET /`, `GET /:id`, `PATCH /:id`, `POST /:id/complete`, `DELETE /:id`, `GET /:id/cancellation-policy` are **untouched**. `joinAppointment` and `computeCommission` are untouched.
|
||||||
|
|
||||||
|
## Deviations from the plan's literal text
|
||||||
|
1. **Replaced a wider window than "lines 129-167".** The plan said to replace from `const start = new Date(start_at);` (line 129) through the handler end, but the verbatim block it provided *re-resolves* `empId` inside the transaction. If I had kept the original outer `empId` resolution (lines 116-127, including the `LIMIT 1` lookup and the early `return err(res,400,…)`), the outer code would (a) shadow the inner `empId`, (b) leave the new `autoAssign` path as dead code, and (c) directly contradict the task summary ("when still no employee, call `autoAssign(...)` instead of the old `LIMIT 1` lookup"). So I replaced the outer block too. This matches the verbatim block's own comment ("Resolver empleado (igual que antes, pero usando autoAssign si ninguno)") and the task summary.
|
||||||
|
2. **`businessId: Number(req.user!.business_id)` instead of `businessId: req.user!.business_id`.** `AuthedRequest.user.business_id` is typed `number | null` (from `shared/types.ts`), but `AutoAssignCtx.businessId` requires `number`. `Number(...)` is safe because `authRequired` guarantees an authenticated user with a business. This is a literal-text bug in the plan; without the coercion `tsc` errors with TS2322.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
### `npm run typecheck`
|
||||||
|
**PASS — 0 errors.** (After the `Number(...)` coercion; raw plan text produced `TS2322: Type 'number | null' is not assignable to type 'number'`.)
|
||||||
|
|
||||||
|
### `npm run test:unit`
|
||||||
|
**PASS — 15/15.** `scheduling.test.ts` unchanged and green.
|
||||||
|
|
||||||
|
### `npm run test:e2e`
|
||||||
|
**FAIL — but NOT a code bug; it is the seed/test-data concern the task anticipated.**
|
||||||
|
|
||||||
|
- The failure is at `e2e-test.mjs:46` ("create appointment"): `POST /api/appointments { service_id:1, client_id:1, start_at:"2026-09-15T11:00:00Z" }` returns **400** `{"error":"No hay empleado asignado a este servicio"}` (i.e. `autoAssign` returned `null`), which makes line 47 throw a `TypeError` reading `.id` off `undefined`.
|
||||||
|
- The other 16 checks up to that point all PASS (login, /me, business, employees+stats, services, clients+stats, appointments joined, dashboard ×9, tickets).
|
||||||
|
|
||||||
|
**Root cause (verified):** timezone mismatch between the e2e fixture and the new working-hours enforcement.
|
||||||
|
- Server TZ: `America/Mexico_City` (UTC−6).
|
||||||
|
- Fixture slot `2026-09-15T11:00:00Z` = **05:00 local** — before the business's 09:00 opening (Mon–Fri 09:00–20:00).
|
||||||
|
- `autoAssign` → `getWorkingHoursForDate` builds the day window in local time (09:00–20:00 local) and filters every candidate whose slot doesn't fit; at 05:00 local nobody qualifies → `autoAssign` returns `null` → handler throws `{status:400, "No hay empleado asignado a este servicio"}`.
|
||||||
|
- The legacy `LIMIT 1` lookup never checked working hours, so this exact fixture used to pass; the new code enforces the plan's hard constraint.
|
||||||
|
|
||||||
|
This is the "seed/test-data concern, not a code defect" case the task description calls out (it anticipated a 409, but the same principle applies to the 400 from the stricter autoAssign). The fix is data-side — either run the server in UTC, or update `e2e-test.mjs` to use in-hours ISO slots (the plan's own Task 7 `booking-e2e.mjs` already does this by fetching slots from `/slots`). **The guard was not weakened.**
|
||||||
|
|
||||||
|
### Conflict smoke (temp `.mjs`, created/verified/deleted)
|
||||||
|
Using an in-hours slot `2027-02-01T17:00:00Z` (= 11:00 local):
|
||||||
|
- Create #1 (`service_id:1`, owner, no `employee_id`): **201**, `autoAssign` placed it on `employee_id:1`.
|
||||||
|
- Create #2 (same `employee_id:1` + same slot): **409** `{"error":"Ese horario ya está ocupado para el especialista"}`.
|
||||||
|
- Create #3 (same `employee_id:1` + 2 h later, non-overlapping): **201**.
|
||||||
|
|
||||||
|
All three behave exactly as required.
|
||||||
|
|
||||||
|
## Self-review
|
||||||
|
- **Guard inside tx?** Yes — `isAvailable(db, empId, startMs, endMs)` runs inside `runInTransaction(db, …)`, before the INSERT. Throwing inside rolls back via the helper's ROLLBACK path.
|
||||||
|
- **autoAssign fallback when no employee?** Yes — after the body's `employee_id` and the employee-role self-assign, if `!empId`, the handler loads `businesses.working_hours`, parses it, and calls `autoAssign(db, {businessId, serviceId, serviceName, serviceCategory, startMs, endMs, bizWh})`. `empId = aa?.employeeId ?? null`; if still null, throws `{status:400,…}`.
|
||||||
|
- **Employee-role self-assign preserved?** Yes — `if (!empId && req.user!.role === "employee" && req.user!.employee_id) empId = req.user!.employee_id;` is intact.
|
||||||
|
- **Owner with no employee_id → autoAssign (not LIMIT 1)?** Yes — the old `SELECT … FROM employee_services … LIMIT 1` block is removed.
|
||||||
|
- **completed→ticket still works?** Yes — the `if (finalStatus === "completed") { INSERT INTO tickets … }` block is preserved verbatim inside the tx, keyed on the freshly `RETURNING *`-ed `inserted.id`.
|
||||||
|
- **Price override preserved?** Yes — `price = price_override !== undefined ? Number(price_override) : service.price` computed before the tx and passed into the INSERT.
|
||||||
|
- **Client resolution preserved?** Yes — untouched above the replaced block (existing `client_id` or `new_client` create).
|
||||||
|
- **`joinAppointment` on result?** Yes — called on `inserted` inside the tx, then returned; `res.status(201).json({ appointment: r })` outside.
|
||||||
|
- **Other endpoints untouched?** Yes — GET/PATCH/DELETE/complete/cancellation-policy are byte-identical to before.
|
||||||
|
- **Response on success?** Yes — `res.status(201).json({ appointment: r })` unchanged.
|
||||||
|
- **No `git commit` run?** Correct — no commit, no stage.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Task 7 — backend settings + employees (campos nuevos) + script de integración
|
||||||
|
|
||||||
|
## Status
|
||||||
|
**COMPLETE** — all verification green, no commits made.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
- `server/routes/settings.ts` — extended `FIELDS` with `auto_assign_specialist`, `working_hours`; added both columns to GET and final-PATCH SELECTs; serialized `working_hours` to JSON when received as object; coerced `auto_assign_specialist` to `0`/`1`.
|
||||||
|
- `server/routes/employees.ts` — `POST /` and `PATCH /:id` now accept and persist `specialties`, `working_hours`, `efficiency_score` (JSON-serialized arrays/maps, `Number(...)` coercion for efficiency).
|
||||||
|
- `server/scripts/booking-e2e.mjs` — NEW. Slot-aware integration test covering auto-assign, double-booking 409, and `auto_assign_specialist` exposure on the public business endpoint.
|
||||||
|
- `package.json` — added `"test:booking": "node server/scripts/booking-e2e.mjs"`.
|
||||||
|
- `e2e-test.mjs` — fixture adaptation only: added `findSlot(slug, serviceId, maxDays=30)` helper that queries `/public/<slug>/slots` for the next 30 days and returns the first available slot iso; replaced the two hard-coded `start_at` ISO strings (`2026-09-15T11:00:00Z`, `2026-09-20T10:00:00Z`) with slot-aware values. Added a `GET /settings` call after login to obtain the business `slug`. Employee-service fallback: tries service_id 5 first, falls back to 6 (both offered only by Mateo Herrera, so the self-assign assertion still holds).
|
||||||
|
|
||||||
|
## Verification results
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| `npm run typecheck` | **0 errors** |
|
||||||
|
| `npm run test:unit` | **15/15 passed** |
|
||||||
|
| `npm run test:e2e` | **25/25 passed** (was failing before the fixture fix) |
|
||||||
|
| `npm run test:booking` | **9/9 passed** |
|
||||||
|
|
||||||
|
## Manual API check (temp .mjs, run + deleted)
|
||||||
|
- `PATCH /settings { auto_assign_specialist: 1, working_hours: {1:{start:"09:00",end:"13:00"}, ...} }` → PATCH response reflected `auto_assign_specialist = 1` and `working_hours` as a JSON string. `GET /settings` returned the same. `working_hours` parsed back to `{1:{start:"09:00",end:"13:00"}, 3:null, ...}`.
|
||||||
|
- `PATCH /employees/:id { specialties: ["Cabello","Coloración"], efficiency_score: 87, working_hours: {...} }` → reflected `specialties = ["Cabello","Coloración"]`, `efficiency_score = 87` with `typeof === "number"`, and `working_hours` JSON. `GET /employees/:id` confirmed persistence. Restored afterwards.
|
||||||
|
|
||||||
|
## Self-review
|
||||||
|
- **settings serializes `working_hours`?** YES — `merged.working_hours = JSON.stringify(merged.working_hours)` when not already a string; stored as TEXT, returned verbatim by both SELECTs.
|
||||||
|
- **settings coerces `auto_assign_specialist` to 0/1?** YES — `merged.auto_assign_specialist = merged.auto_assign_specialist ? 1 : 0`.
|
||||||
|
- **employees coerces `efficiency_score` to `Number`?** YES — both POST (`typeof === "number" ? efficiency_score : 50`) and PATCH (`Number(efficiency_score)` with fallback to `existing.efficiency_score ?? 50`). Confirmed via manual check: returned value has `typeof === "number"`.
|
||||||
|
- **employees handles `specialties`/`working_hours` JSON?** YES — POST stringifies the array/object; PATCH merges with existing and accepts string, object/array, or `null` (for `working_hours`).
|
||||||
|
- **booking-e2e covers auto-assign + 409 + `auto_assign_specialist` exposure?** YES — step 4 books without `employee_id` and asserts the response contains `employee_id` + non-empty `reasons` (auto-assign); step 5 re-books the same slot/employee and asserts `409`; step 6 toggles `auto_assign_specialist` to 1 via PATCH and asserts the public `/public/<slug>` response exposes `business.auto_assign_specialist === 1`.
|
||||||
|
- **e2e-test now passes without weakening guards?** YES — no guards were changed (only `settings.ts`, `employees.ts`, `e2e-test.mjs`, new `booking-e2e.mjs`, and `package.json` were touched; `booking.ts`, `appointments.ts`, `scheduling.ts`, `db.ts`, `types.ts` untouched). The fix is purely test-fixture: instead of hard-coding out-of-working-hours ISO strings (`11:00Z` ≈ 05:00 local, and `2026-09-20` is Sunday — both correctly rejected by the new working-hours enforcement), the test now fetches a real available slot from the same public endpoint that respects the guards. All 25 original assertions preserved; 4 new helper assertions added (slot lookups + slug).
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
- **e2e-test.mjs employee-service fallback (svc 5 → svc 6).** The plan does not mandate this; the additional sub-task says "If service_id 5 has no slots, pick another service the employee offers". Implemented as: try service 5 across 30 days; if null, try service 6. In practice service 5 always yields slots in the seed data, so the fallback never fires — but it makes the test robust to seed variation. The self-assign assertion still holds because both services 5 and 6 are offered exclusively by Mateo Herrera.
|
||||||
|
- **`findSlot` searches 30 days** (vs. a single weekday in the suggested approach) — gives the fixture more headroom to dodge dense seed bookings without weakening any guard.
|
||||||
|
|
||||||
|
## Concerns
|
||||||
|
None.
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Task 8 — Fix de iconos solapados (`@layer components` + `pl-10`)
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
1. `src/index.css` — wrapped the `.input, .select, .textarea { ... }` block AND its `:focus` rule in `@layer components { ... }` (now lines 272–292).
|
||||||
|
2. `src/pages/public/BookingPage.tsx` — in `DetailsForm`, changed the three `className="input pl-9"` → `className="input pl-10"` (Nombre, Teléfono, Correo; lines 664, 678, 692).
|
||||||
|
|
||||||
|
## Verification results
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| `npm run typecheck` | ✅ 0 errors (`tsc -b --noEmit` exited clean) |
|
||||||
|
| `npm run build` | ✅ Succeeded in 5.06s — `dist/assets/index-*.css` emitted (36.19 kB). Confirms `@layer components` is valid CSS and Tailwind processes it. |
|
||||||
|
| Grep: `padding: 0.55rem 0.75rem` outside `@layer components` | ✅ Only occurrence is on **line 280**, which is INSIDE the `@layer components { ... }` block (lines 272–292). No bare occurrence remains. |
|
||||||
|
| Grep: BookingPage `input pl-` | ✅ All three now read `input pl-10` (lines 664, 678, 692). No `pl-9` remains. |
|
||||||
|
|
||||||
|
## Untouched (pre-existing WIP, not modified by this task)
|
||||||
|
|
||||||
|
### FullCalendar section in `src/index.css` (lines 131–163) — unchanged
|
||||||
|
Shown verbatim from the file after my edit:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.fc-event {
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 0.45rem !important;
|
||||||
|
padding: 2px 5px !important;
|
||||||
|
font-size: 0.74rem !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.12s, box-shadow 0.12s;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.fc-event:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px -4px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
.fc-daygrid-event {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.fc .fc-timegrid-slot {
|
||||||
|
height: 2.4rem;
|
||||||
|
}
|
||||||
|
.fc-timegrid-event {
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
.fc-timegrid-event-harness {
|
||||||
|
margin-right: 2px !important;
|
||||||
|
}
|
||||||
|
/* When slotEventOverlap is false, FullCalendar puts a small gap between
|
||||||
|
side-by-side events; tighten it a touch so each event gets more readable
|
||||||
|
width without bleeding into its neighbour. */
|
||||||
|
.fc .fc-timegrid-col-events {
|
||||||
|
column-gap: 2px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: `git diff src/index.css` does show these FullCalendar lines as additions vs. HEAD, but they were **already present in the working tree before Task 8 began** (pre-existing WIP on the branch). My `edit` call only targeted the `.input/.select/.textarea` block — its `oldString`/`newString` did not include any FullCalendar text — so I did not modify this section.
|
||||||
|
|
||||||
|
### `src/components/AppointmentModal.tsx` and `src/pages/CalendarPage.tsx`
|
||||||
|
Listed as modified in `git status` (pre-existing WIP from earlier tasks on this branch). I did not open, read, or edit either file during Task 8 — my only tool calls against the repo were the two `edit` operations on `src/index.css` and `src/pages/public/BookingPage.tsx`, plus read-only verification commands.
|
||||||
|
|
||||||
|
## Diff of MY two files (only the parts I changed)
|
||||||
|
|
||||||
|
```diff
|
||||||
|
--- a/src/index.css
|
||||||
|
+++ b/src/index.css
|
||||||
|
@@ -258,24 +269,26 @@ body {
|
||||||
|
background: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
-.input,
|
||||||
|
-.select,
|
||||||
|
-.textarea {
|
||||||
|
- width: 100%;
|
||||||
|
- ...
|
||||||
|
- outline: none;
|
||||||
|
-}
|
||||||
|
-.input:focus,
|
||||||
|
-.select:focus,
|
||||||
|
-.textarea:focus {
|
||||||
|
- border-color: #3b66ff;
|
||||||
|
- box-shadow: 0 0 0 3px rgba(59, 102, 255, 0.15);
|
||||||
|
+@layer components {
|
||||||
|
+ .input,
|
||||||
|
+ .select,
|
||||||
|
+ .textarea {
|
||||||
|
+ width: 100%;
|
||||||
|
+ ...
|
||||||
|
+ outline: none;
|
||||||
|
+ }
|
||||||
|
+ .input:focus,
|
||||||
|
+ .select:focus,
|
||||||
|
+ .textarea:focus {
|
||||||
|
+ border-color: #3b66ff;
|
||||||
|
+ box-shadow: 0 0 0 3px rgba(59, 102, 255, 0.15);
|
||||||
|
+ }
|
||||||
|
}
|
||||||
|
|
||||||
|
--- a/src/pages/public/BookingPage.tsx
|
||||||
|
+++ b/src/pages/public/BookingPage.tsx
|
||||||
|
@@ -661,7 +661,7 @@
|
||||||
|
- className="input pl-9"
|
||||||
|
+ className="input pl-10"
|
||||||
|
@@ -675,7 +675,7 @@
|
||||||
|
- className="input pl-9"
|
||||||
|
+ className="input pl-10"
|
||||||
|
@@ -689,7 +689,7 @@
|
||||||
|
- className="input pl-9"
|
||||||
|
+ className="input pl-10"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Self-review
|
||||||
|
|
||||||
|
- **Does `@layer components` now wrap the input rule?**
|
||||||
|
YES. `src/index.css` lines 272–292 open with `@layer components {` and close with `}`; the `.input/.select/.textarea` block and the `:focus` rule are both inside it, indented one level.
|
||||||
|
|
||||||
|
- **Will `pl-9`/`pl-10` utilities now override the component `padding` shorthand?**
|
||||||
|
YES. Tailwind emits its utilities through `@tailwind utilities;` (line 3 of `index.css`), which places them in the `utilities` CSS cascade layer. Per the CSS cascade-layer ordering rule, declarations in a layer declared **later** beat declarations in an **earlier** layer at equal specificity, regardless of source order. The layer order established by the three `@tailwind` directives is `base → components → utilities`, so the `utilities` layer (containing `.pl-10 { padding-left: 2.5rem }`) wins over the `components` layer (containing `.input { padding: 0.55rem 0.75rem }`). Before this change, `.input` was declared outside any layer, which made it an *unlayered* style — and unlayered styles beat layered styles in the cascade, so the utility was losing. Wrapping in `@layer components` fixes this app-wide for every `.input`/`.select`/`.textarea` that uses `pl-*`/`pr-*` (LoginPage, BookingPage, etc.).
|
||||||
|
|
||||||
|
- **Why `pl-10` (2.5rem = 40px) instead of `pl-9` (2.25rem = 36px)?**
|
||||||
|
Icons sit at `left-3` (12px) and are `h-4 w-4` (16px), so they occupy roughly 12–28px of horizontal space. `pl-9` left only an 8px gap between icon edge and text start, which clipped ascenders/descenders and made placeholders appear to overlap the icon. `pl-10` gives a comfortable 12px gap (text starts at 40px, icon ends ~28px). Combined with the layer fix above, the utility now actually applies.
|
||||||
|
|
||||||
|
## Concerns
|
||||||
|
None. No commit performed (per instructions). Build and typecheck both green. Pre-existing WIP files left exactly as found.
|
||||||
|
```
|
||||||
|
```
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Task 9 Report — `publicApi.ts` tipos actualizados
|
||||||
|
|
||||||
|
## Status
|
||||||
|
✅ **DONE** — typecheck passes with 0 errors.
|
||||||
|
|
||||||
|
## File changed
|
||||||
|
- `src/lib/publicApi.ts` (only this file; no consumers touched)
|
||||||
|
|
||||||
|
## Typecheck result
|
||||||
|
```
|
||||||
|
$ npm run typecheck
|
||||||
|
> [email protected] typecheck
|
||||||
|
> tsc -b --noEmit
|
||||||
|
```
|
||||||
|
**0 errors.** The changes are additive/loosening, so no consumer breakage:
|
||||||
|
- `PublicBusiness.auto_assign_specialist` (added required field) is safe — the only consumer reading `PublicBusiness` is `BookingPage.tsx`, which does not yet destructure this field (it'll be added in Task 12). Adding a required field to an interface does not error on existing readers; it only errors on object *literals* that lack it, and there are no literals of type `PublicBusiness` in the codebase (they come from `request<PublicBusinessResponse>`).
|
||||||
|
- `BookPayload.employee_id` made optional → strictly loosening; existing callers passing `employee_id` still typecheck.
|
||||||
|
- `BookResponse.appointment` widened with `employee_id`/`reasons` → existing readers of `employee_name` still work; new readers will be added in Task 12.
|
||||||
|
|
||||||
|
No consumer errors observed.
|
||||||
|
|
||||||
|
## Interface diffs (verbatim from plan)
|
||||||
|
|
||||||
|
### 1. `PublicBusiness` — added `auto_assign_specialist`
|
||||||
|
```diff
|
||||||
|
require_deposit: boolean;
|
||||||
|
deposit_pct: number;
|
||||||
|
+ auto_assign_specialist: number | boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. `BookPayload` — `employee_id` now optional
|
||||||
|
```diff
|
||||||
|
export interface BookPayload {
|
||||||
|
service_id: number;
|
||||||
|
- employee_id: number;
|
||||||
|
+ employee_id?: number;
|
||||||
|
start_at: string;
|
||||||
|
client: BookClient;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. `BookResponse.appointment` — added `employee_id` + `reasons`
|
||||||
|
```diff
|
||||||
|
export interface BookResponse {
|
||||||
|
appointment: {
|
||||||
|
id: number;
|
||||||
|
start_at: string;
|
||||||
|
price: number;
|
||||||
|
service_name: string;
|
||||||
|
+ employee_id: number;
|
||||||
|
employee_name: string;
|
||||||
|
+ reasons: string[];
|
||||||
|
};
|
||||||
|
business: { name: string; currency_symbol: string };
|
||||||
|
client_created: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Consumer errors
|
||||||
|
None. (Plan's Step 2 note had anticipated possible errors in `BookingPage.tsx`, but none materialized — the optional `employee_id` and the widened `BookResponse` are backward-compatible with the current consumer code, which will be properly updated in Tasks 11–12.)
|
||||||
|
|
||||||
|
## Concerns
|
||||||
|
None. Ready for Tasks 10–12 to consume these new types.
|
||||||
@@ -1,42 +1,193 @@
|
|||||||
# AgendaPro — Auditoría de funcionalidades (basada en investigación real)
|
# AgendaPro — Auditoría de producto e investigación de mercado
|
||||||
|
|
||||||
Fuentes: agendapro.com/mx (site + blog), comparativa Wellbe vs AgendaPro, artículo de política de cancelaciones (CEO Julio Guzmán). G2/Capterra/Trustpilot bloquearon scraping (403), pero el blog oficial expone casos de uso y pain points reales.
|
> Revisión: **2026-07-28**. Sustituye la versión anterior de este archivo, que daba por faltantes
|
||||||
|
> cinco módulos que ya están construidos (caja, comisiones, recordatorios, booking público y política
|
||||||
|
> de cancelación). Cada fila de las tablas de abajo se verificó contra el esquema de
|
||||||
|
> [server/db.ts](server/db.ts), los endpoints montados en [server/index.ts](server/index.ts) y las
|
||||||
|
> páginas de [src/pages/](src/pages/) — no contra memoria ni contra el documento anterior.
|
||||||
|
|
||||||
## Lo que YA tenemos (vs AgendaPro real)
|
---
|
||||||
- ✅ Calendario drag & drop (mes/semana/día/lista) con auto-asignación a empleados
|
|
||||||
- ✅ Servicios, empleados, clientes + historial/ficha
|
|
||||||
- ✅ Tickets, pagos, propinas
|
|
||||||
- ✅ Dashboard dueño (mejor empleado/servicio, top tickets, top/frecuentes clientes, ingresos)
|
|
||||||
- ✅ Multi-tenant SaaS + consola admin + plantillas
|
|
||||||
- ✅ Reseñas/ratings
|
|
||||||
|
|
||||||
## Brechas CRÍTICAS vs AgendaPro real (oportunidades de mejora)
|
## 1. El producto que emulamos
|
||||||
1. **Política de cancelación + no-shows** — AgendaPro destaca esto; en MX la inasistencia es 15–35%. Nosotros solo marcamos estado, sin penalización ni depósito. **#1 pain real.**
|
|
||||||
2. **Comisiones** — cálculo automático por venta/servicio. Nosotros no lo tenemos. Pain operativo grande.
|
|
||||||
3. **Cierre de caja / flujo de caja** — apertura/cierre diario, ingresos/egresos. Nosotros solo listamos tickets.
|
|
||||||
4. **Página de reservas online pública (24/7)** — el cliente se auto-agenda. Nuestro mayor gap de adquisición. Reduce ruido de WhatsApp.
|
|
||||||
5. **Recordatorios automáticos** (WhatsApp/SMS/email) — reducen inasistencias. Nosotros no tenemos centro de notificaciones.
|
|
||||||
6. **Inventario** — control de productos con alertas de stock bajo.
|
|
||||||
7. **Lista de espera / waitlist** — para rellenar cancelaciones.
|
|
||||||
8. **Fidelización / giftcards / membresías** — paquetes y crédito.
|
|
||||||
9. **Encuestas de satisfacción (NPS)** — aparte de reseñas puntuales.
|
|
||||||
10. **Multi-sucursal** dentro de un negocio (ya tenemos multi-tenant, no multi-branch).
|
|
||||||
11. **Sincronización Google Calendar.**
|
|
||||||
|
|
||||||
## Pain points reales de los usuarios (del blog/casos)
|
AgendaPro es el software de agendamiento líder en Latinoamérica: **+20.000 negocios**, presencia en
|
||||||
- No-shows = pérdida directa de ingresos (un salón perdía 15 citas/mes).
|
**+100 países**, foco en México, Colombia, Argentina y Chile. Su promesa comercial es *«el único
|
||||||
- Cierre de caja con descuadre ("menos dinero del que debería haber").
|
software que ordena tu negocio y acelera su crecimiento un 82%»*.
|
||||||
- Cálculo manual de comisiones = error y tiempo.
|
|
||||||
- Inventario desordenado en salones.
|
|
||||||
- Cobranza incómoda ("recordatorios de pago").
|
|
||||||
- Pérdida de clientes por falta de fidelización.
|
|
||||||
- Saturación de recepción y WhatsApps repetitivos preguntando horario/precio.
|
|
||||||
|
|
||||||
## Priorización (impacto × factibilidad) — lo que implementaremos
|
**Verticales:** salones de belleza, spas, barberías, peluquerías, centros de estética, clínicas,
|
||||||
- **P0 Política de cancelación + no-show** (company) — resuelve el dolor #1.
|
psicólogos, nutricionistas, fisioterapeutas, podólogos y bienestar en general.
|
||||||
- **P0 Comisiones** (company/empleado) — dolor operativo top.
|
|
||||||
- **P0 Página pública de reservas /b/:slug** (cliente) — mayor diferenciador, reduce WhatsApp.
|
|
||||||
- **P1 Cierre de caja** (company) — control financiero del día.
|
|
||||||
- **P1 Centro de notificaciones/recordatorios** (company) — reduce no-shows (simulado, sin WhatsApp real).
|
|
||||||
|
|
||||||
Perspectivas cubiertas: **cliente** (booking público), **empresa** (caja, comisiones, política, dashboard), **empleado** (sus comisiones, su agenda).
|
### 1.1 La escalera de planes (esto *es* el producto)
|
||||||
|
|
||||||
|
El modelo de negocio de AgendaPro es la escalera de planes, y cada módulo está deliberadamente
|
||||||
|
asignado a un escalón. Precios de lista en USD (LatAm) y EUR (España):
|
||||||
|
|
||||||
|
| Plan | USD/mes | EUR/mes | Profesionales | Correos mkt | Qué añade sobre el plan anterior |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| **Individual** | $9 | €10 | 1 | 500 | Agenda ilimitada + presencia en Marketplace, CRM, recordatorios automáticos, sitio de reservas, reportes de gestión, sistema de caja, niveles de acceso, control de ocupación, gestión de presupuesto |
|
||||||
|
| **Básico** | $29 | €19 | hasta 20 | 1.000 | **Inventario** (control + alertas de stock bajo), **Comisiones** (cálculo automático) |
|
||||||
|
| **Premium** ★ | $59 | €59 | hasta 20 | 2.000 | **Encuestas de satisfacción**, **Fichas personalizables**, **Ficha clínica + consentimiento informado**, **Giftcards**, **Presupuestos**, email automático de cumpleaños, sitio con URL y colores propios |
|
||||||
|
| **Pro** | $199 | €199 | hasta 20 | 5.000 | **Acceso a API**, soporte personalizado, integración Google Analytics / Meta Pixel |
|
||||||
|
|
||||||
|
★ = el que ellos marcan como «más popular».
|
||||||
|
|
||||||
|
**Complementos que se cobran aparte** (dato relevante: lo que su marketing presenta como bandera
|
||||||
|
central no viene incluido en ningún plan):
|
||||||
|
|
||||||
|
| Add-on | Precio | Detalle |
|
||||||
|
|---|---|---|
|
||||||
|
| WhatsApp | desde $7 USD / €5 al mes | **50 mensajes mensuales** |
|
||||||
|
| Videoconferencia | desde $11 USD al mes | pack de 2.500 min |
|
||||||
|
| Charly (asistente de marketing con IA) | desde $55 USD al mes | pago por resultados |
|
||||||
|
| Facturación electrónica | «próximamente» | — |
|
||||||
|
|
||||||
|
Prueba gratuita: **7 días**.
|
||||||
|
|
||||||
|
### 1.2 Módulos que anuncian, agrupados como ellos los agrupan
|
||||||
|
|
||||||
|
- **Citas:** agenda online, sitio de reservas 24/7, recordatorios automáticos por WhatsApp y email,
|
||||||
|
«IA de recordatorios por WhatsApp», administración de horarios.
|
||||||
|
- **CRM:** base de datos de clientes, historial de visitas, control de sesiones y tratamientos, ficha
|
||||||
|
del cliente, promociones personalizadas.
|
||||||
|
- **Inventario:** control de inventario, alertas de inventario bajo, comisiones por venta de productos.
|
||||||
|
- **Marketing y fidelización:** integración con Google Reserve en Google My Business, LinkPro (tarjeta
|
||||||
|
de presentación para redes), campañas de email marketing, programas de lealtad y giftcards, acceso
|
||||||
|
al **marketplace** de servicios.
|
||||||
|
- **Pagos:** registro y reportes de pagos, pagos con terminal, pagos online y link de pago,
|
||||||
|
facturación / CFDI, pago de sesiones.
|
||||||
|
- **Control del negocio:** control de caja, reportes de ingresos y egresos, reportes de ventas con IA,
|
||||||
|
control de múltiples sucursales, cálculo automático de comisiones.
|
||||||
|
- **Gimnasios / fitness:** agenda de clases grupales, control de membresías, gestión de entrenadores.
|
||||||
|
- **Multi-sucursal:** varias sedes desde una sola cuenta, con reportes consolidados.
|
||||||
|
|
||||||
|
### 1.3 Dónde AgendaPro es débil (según reseñas de usuarios)
|
||||||
|
|
||||||
|
Esto importa: son los huecos donde un competidor puede ganar en lugar de empatar.
|
||||||
|
|
||||||
|
1. **Fichas clínicas genéricas.** No tienen CIE-10 electrónico ni plantillas por especialidad. Es la
|
||||||
|
queja más concreta y sistemática.
|
||||||
|
2. **Permisos poco granulares por sede.** Problema real para clínicas medianas y grandes: no se puede
|
||||||
|
acotar bien qué ve cada persona en cada sucursal.
|
||||||
|
3. **Precio y alzas periódicas.** Usuarios reportan subidas recurrentes y retiro de funcionalidades de
|
||||||
|
planes que ya pagaban.
|
||||||
|
4. **Prueba de 7 días**, contra 14+ de la competencia.
|
||||||
|
5. **WhatsApp de pago y racionado** (50 mensajes/mes desde $7), siendo el canal que su propio marketing
|
||||||
|
pone al frente.
|
||||||
|
6. **Curva de costo para negocios chicos:** los planes intermedios superan los $40–80 USD/mes, lo que
|
||||||
|
los saca de rango para un negocio de 1–3 personas.
|
||||||
|
|
||||||
|
### 1.4 El marco competitivo, y la parte que no se resuelve con features
|
||||||
|
|
||||||
|
| | Modelo | Implicación |
|
||||||
|
|---|---|---|
|
||||||
|
| **AgendaPro** | Suscripción por escalones + add-ons | Ingreso predecible; el cliente paga antes de ver valor |
|
||||||
|
| **Fresha** | **$0 de mensualidad**; comisión sobre clientes nuevos del marketplace + ~2,19% + $0,20 USD por transacción | Sin barrera de entrada; monetiza adquisición y pagos |
|
||||||
|
|
||||||
|
La conclusión honesta de la investigación: **el foso de los dos líderes no es el software, es el
|
||||||
|
marketplace.** Fresha y AgendaPro traen clientes nuevos al negocio; eso no se replica implementando
|
||||||
|
módulos. Cualquier plan de equivalencia funcional debe asumir que empata en producto y no en
|
||||||
|
distribución.
|
||||||
|
|
||||||
|
### 1.5 Datos de industria (con su fuente, y una corrección)
|
||||||
|
|
||||||
|
- **No-shows: 10%–30%** según sector; **15%–25%** en belleza y estética. En clínicas de bienestar
|
||||||
|
(España) 12%–19%, hasta **23%** en odontología y masajes.
|
||||||
|
- **Recordatorios por WhatsApp a 3 días y 24 h antes reducen las ausencias entre 30% y 50%.**
|
||||||
|
- **Depósitos recomendados: 20%–30%** del valor del servicio, y el depósito debe **abonarse al costo
|
||||||
|
final**, no ser un cargo extra.
|
||||||
|
- **Ventana de cancelación estándar: 24–48 h**; 72–96 h en sectores de alta demanda.
|
||||||
|
- **Escala de penalización de ejemplo:** gratis con más de 48 h; 25% entre 48 y 24 h; 50% entre 24 y
|
||||||
|
12 h; 100% el mismo día.
|
||||||
|
- Coste ilustrativo: 5 citas perdidas por semana a 60 € ≈ **18.000 €/año** de ingreso perdido.
|
||||||
|
|
||||||
|
> **Corrección al documento anterior:** la versión previa de este archivo afirmaba «en MX la
|
||||||
|
> inasistencia es 15–35%» sin fuente. No encontré respaldo para el techo del 35%. Los rangos de arriba
|
||||||
|
> sí están sostenidos. Además, el artículo de política de cancelaciones de AgendaPro —citado antes como
|
||||||
|
> fuente de cifras— **no contiene estadísticas de no-show**: solo recomendaciones. Las cifras de arriba
|
||||||
|
> vienen de fuentes de industria independientes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Auditoría: qué tiene hoy AgendaMax
|
||||||
|
|
||||||
|
Verificado contra esquema, endpoints y páginas.
|
||||||
|
|
||||||
|
### 2.1 Construido y funcionando
|
||||||
|
|
||||||
|
| Capacidad de AgendaPro | Estado | Evidencia en el repo |
|
||||||
|
|---|---|---|
|
||||||
|
| Agenda / calendario | ✅ | [CalendarPage.tsx](src/pages/CalendarPage.tsx) con FullCalendar, 3 breakpoints, drag & drop; `/api/appointments` |
|
||||||
|
| Sitio de reservas público 24/7 | ✅ | `/b/:slug` → [BookingPage.tsx](src/pages/public/BookingPage.tsx); `/api/public/:slug`, `/slots`, `/book` |
|
||||||
|
| Guard anti doble-reserva | ✅ **superior** | `runInTransaction` + `BEGIN IMMEDIATE` + `isAvailable` + INSERT en la misma tx ([scheduling.ts](server/lib/scheduling.ts)) |
|
||||||
|
| Auto-asignación de especialista | ✅ **no lo tiene AgendaPro** | `autoAssign`, `pickBestSlotEmployee`, `scoreCandidate` con especialidades y `efficiency_score` |
|
||||||
|
| Horarios por negocio y por empleado | ✅ | `businesses.working_hours`, `employees.working_hours` (JSON 1..7, `null` = hereda) |
|
||||||
|
| CRM / ficha e historial de cliente | ✅ | `clients` + [ClientDetailPage.tsx](src/pages/ClientDetailPage.tsx) con `no_show_count` |
|
||||||
|
| Comisiones | ✅ | `services.commission_pct`, `employees.commission_pct`, snapshot en `tickets.commission`; `/api/dashboard/commissions`, `/api/me/commissions`, [MyPerformancePage.tsx](src/pages/MyPerformancePage.tsx) |
|
||||||
|
| Control de caja / cierre diario | ✅ | `cash_sessions` (apertura, cierre, `expected_amount` → descuadre) + `cash_entries` (income/expense); `/api/cash/*`; [CashPage.tsx](src/pages/CashPage.tsx) |
|
||||||
|
| Reportes de gestión | ✅ | 8 endpoints en `/api/dashboard/*` + [DashboardPage.tsx](src/pages/DashboardPage.tsx) con recharts |
|
||||||
|
| Reseñas / ratings | ✅ | tabla `reviews` (AgendaPro no lo vende como módulo) |
|
||||||
|
| Multi-tenant + consola de plataforma | ✅ **superior** | `/api/admin/*`, plantillas por vertical, alta de negocio, `reset-demo`, cascade |
|
||||||
|
| Sitio de reservas con marca propia | ✅ | landing + booking con paleta heredada del panel (AgendaPro lo cobra en Premium) |
|
||||||
|
| App instalable | ✅ | PWA con service worker y prompt de instalación (equivalente funcional a su app nativa) |
|
||||||
|
| Aislamiento por tenant verificado | ✅ | `test:e2e` y `test:admin` comprueban 403 y no-visibilidad cruzada |
|
||||||
|
|
||||||
|
### 2.2 Construido a medias — el hueco entre «configurable» y «operante»
|
||||||
|
|
||||||
|
Estas tres son las más engañosas de la auditoría: existen en el esquema y en la UI, pero **no cierran
|
||||||
|
el ciclo**. Un demo puede mostrarlas y no hacen nada.
|
||||||
|
|
||||||
|
| Capacidad | Qué hay | Qué falta |
|
||||||
|
|---|---|---|
|
||||||
|
| **Política de cancelación y depósito** | `businesses.cancel_window_hours`, `cancel_penalty_pct`, `require_deposit`, `deposit_pct`; `GET /api/appointments/:id/cancellation-policy`; editor en [SettingsPage.tsx](src/pages/SettingsPage.tsx) | **Nunca se cobra nada.** No hay captura de depósito en el booking, ni aplicación de la penalización a un ticket, ni registro del cargo. La política se configura y se consulta; no tiene efecto económico. |
|
||||||
|
| **Recordatorios automáticos** | tabla `notifications` (canales `whatsapp`/`sms`/`email`, tipos `confirmation`/`reminder`/`cancellation`/`follow_up`/`review`), `/api/notifications` con `regenerate`/`send`/`cancel`/`stats`, [NotificationsPage.tsx](src/pages/NotificationsPage.tsx) | Es una **bandeja simulada**: `send` marca `sent` sin salir a ningún proveedor. Aceptable para demo, pero no equivale al módulo real. |
|
||||||
|
| **Niveles de acceso y permisos** | 3 roles fijos (`admin`/`owner`/`employee`) con autorización real en middlewares y aislamiento probado | Sin permisos granulares. Nota: **AgendaPro también es débil aquí** (§1.3), así que es una oportunidad, no solo una deuda. |
|
||||||
|
|
||||||
|
### 2.3 Brechas reales — lo que no existe
|
||||||
|
|
||||||
|
Ordenado por el escalón de plan al que pertenece en AgendaPro, porque eso define qué se puede
|
||||||
|
reclamar como «equivalente al plan X».
|
||||||
|
|
||||||
|
| # | Brecha | Plan AgendaPro | Notas de implementación |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **Inventario de productos + alertas de stock bajo** | Básico | No hay tabla de productos ni movimientos de stock |
|
||||||
|
| 2 | **Venta multi-ítem (POS real)** | Básico/Individual | **Bloqueo estructural:** hoy `tickets` es 1 ticket = 1 cita = 1 servicio (`service_id` único). No se puede vender «corte + shampoo + giftcard» en una venta |
|
||||||
|
| 3 | **Comisión por venta de productos** | Básico | Depende de 1 y 2 |
|
||||||
|
| 4 | **Encuestas de satisfacción / NPS** | Premium | `reviews` cubre la reseña puntual, no la campaña de medición |
|
||||||
|
| 5 | **Fichas personalizables + ficha clínica + consentimiento informado** | Premium | Hoy solo `clients.notes`, texto libre. **Aquí AgendaPro es débil** (§1.3) |
|
||||||
|
| 6 | **Giftcards** | Premium | Depende de 2 |
|
||||||
|
| 7 | **Presupuestos** | Premium | Depende de 2 |
|
||||||
|
| 8 | **Paquetes, sesiones y membresías** | transversal | «Control de sesiones y tratamientos», «pago de sesiones», membresías de gimnasio. Nada de saldo prepago |
|
||||||
|
| 9 | **Clases grupales (capacidad > 1)** | fitness | `appointments` es estrictamente 1:1. Toca la invariante de agendado |
|
||||||
|
| 10 | **Multi-sucursal con reportes consolidados** | transversal | Tenemos multi-*tenant*, no multi-*branch*. Cambio de esquema profundo |
|
||||||
|
| 11 | **Email marketing / campañas / cumpleaños** | Individual+ (con cuotas por plan) | `notifications` es transaccional, no de campañas |
|
||||||
|
| 12 | **Pagos en línea / link de pago / cobro anticipado** | transversal | Cero captura de pago. Es lo que bloquea §2.2 |
|
||||||
|
| 13 | **Facturación electrónica / CFDI** | add-on | En AgendaPro está «próximamente» |
|
||||||
|
| 14 | **Google Reserve / Google Calendar** | Individual+ | Integración externa |
|
||||||
|
| 15 | **Control de ocupación (% de agenda ocupada)** | Individual | [metrics.ts](server/lib/metrics.ts) solo tiene `cancelRate`, `noShowRate`, `completionRate`. **Barato y visible** |
|
||||||
|
| 16 | **Gating por plan** | — | `businesses.plan` existe pero **no restringe nada**. Sin esto, la escalera de planes —que es el producto de AgendaPro— no se está emulando |
|
||||||
|
| 17 | **Videoconferencia** | add-on | |
|
||||||
|
| 18 | **API pública documentada** | Pro | |
|
||||||
|
| 19 | **Marketplace** | Individual+ | **El foso real (§1.4).** No es un módulo, es un canal de distribución |
|
||||||
|
| 20 | **Reportes con IA** | add-on (Charly) | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Lectura de la auditoría
|
||||||
|
|
||||||
|
Tres conclusiones que condicionan cualquier plan:
|
||||||
|
|
||||||
|
1. **La brecha no es de agenda, es de dinero.** Todo lo que falta cuelga de dos cosas que no existen:
|
||||||
|
la **venta multi-ítem** (brecha 2) y la **captura de pago** (brecha 12). Inventario, giftcards,
|
||||||
|
presupuestos, paquetes, comisión de producto y el cierre de la política de cancelación dependen de
|
||||||
|
una o de las dos. Atacar módulos sueltos antes de eso produce features que no se pueden conectar.
|
||||||
|
|
||||||
|
2. **Ya somos mejores en el núcleo de agendado.** Auto-asignación por especialidad y eficiencia, guard
|
||||||
|
transaccional anti doble-reserva y consola de plataforma multi-tenant no están en la oferta de
|
||||||
|
AgendaPro. La equivalencia que falta es comercial y administrativa, no de calendario.
|
||||||
|
|
||||||
|
3. **La escalera de planes no se está emulando.** Es lo más desalineado del proyecto respecto al
|
||||||
|
producto real: `businesses.plan` es decorativo. Es además de las cosas más baratas de arreglar y la
|
||||||
|
que más hace que el demo se lea como un SaaS y no como una app.
|
||||||
|
|
||||||
|
El backlog priorizado y la descomposición en sub-proyectos viven en el spec de diseño de esta tanda,
|
||||||
|
no en este archivo. Este documento es la línea base de hechos.
|
||||||
|
|||||||
@@ -0,0 +1,441 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
> El código, la UI y los mensajes de error de este repo están en **español**. Mantén ese idioma en
|
||||||
|
> strings visibles al usuario y en mensajes de error de la API.
|
||||||
|
|
||||||
|
## Requisitos del entorno
|
||||||
|
|
||||||
|
- **Node.js >= 22.5** (recomendado 24+). El servidor usa `node:sqlite` nativo: no hay dependencia
|
||||||
|
de compilación nativa, pero tampoco funciona en Node < 22.5. `scripts/preflight.mjs` valida esto
|
||||||
|
antes de `dev`, `build` y `start`.
|
||||||
|
- Windows/PowerShell: si `npm.ps1` está bloqueado por execution policy, usa **`npm.cmd`**.
|
||||||
|
- `.npmrc` apunta la caché de npm a `.cache/npm` dentro del proyecto (el caché global no siempre es
|
||||||
|
escribible en las máquinas de este proyecto). Instala con `npm run setup` (`npm ci`), no con
|
||||||
|
`npm install`, para no mutar el lockfile.
|
||||||
|
- `scripts/run-tsx.mjs` envuelve a `tsx` con un fallback de `os.userInfo()`; todo script de servidor
|
||||||
|
se lanza a través de él, nunca con `npx tsx` directo.
|
||||||
|
|
||||||
|
## Comandos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run setup # npm ci con caché local (instalación recomendada)
|
||||||
|
npm run dev # preflight + server (:3000) y vite (:5173) en paralelo
|
||||||
|
npm run dev:background # lo mismo, desacoplado; logs en .cache/runtime/dev.*.log
|
||||||
|
npm run build # tsc -b && vite build -> dist/
|
||||||
|
npm start # producción: sirve API + dist/ en :3000
|
||||||
|
npm run typecheck # tsc -b --noEmit <-- este es el gate real de calidad
|
||||||
|
npm run seed # regenera la DB demo
|
||||||
|
```
|
||||||
|
|
||||||
|
Overrides de puerto sin tocar código (preflight aborta si el puerto está ocupado):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PORT=3001 VITE_PORT=5174 API_URL=http://127.0.0.1:3001 npm run dev
|
||||||
|
$env:PORT='3001'; $env:VITE_PORT='5174'; $env:API_URL='http://127.0.0.1:3001'; npm.cmd run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
`RESET_DB=1` borra `data/agendapro.db` al arrancar y vuelve a sembrar desde cero.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:unit # node:test sobre server/lib/{scheduling,time,metrics}.test.ts (puro, sin servidor)
|
||||||
|
npm run test:e2e # API: login, CRUD, dashboard, roles, aislamiento -> requiere `npm run dev`
|
||||||
|
npm run test:admin # consola admin: alta con plantilla, reset-demo, cascade -> requiere `npm run dev`
|
||||||
|
npm run test:booking # booking público: auto-asignación y 409 -> requiere `npm run dev`
|
||||||
|
npm run audit:visual # Playwright: 10 páginas x 4 viewports -> screenshots/
|
||||||
|
npm run test:pwa # requiere `npm run build` + `npm start` (golpea :3000)
|
||||||
|
```
|
||||||
|
|
||||||
|
Detalle importante: los `.mjs` de e2e/admin/booking/visual apuntan a **`http://localhost:5173`**
|
||||||
|
(pasan por el proxy de Vite), mientras que `pwa-e2e.mjs` apunta a **`:3000`** porque necesita el
|
||||||
|
build servido y el service worker (que solo se registra en `import.meta.env.PROD`).
|
||||||
|
`PWA_BASE_URL` permite apuntarlo a otro host; la validación real de instalabilidad exige un dominio
|
||||||
|
**HTTPS**, no una IP.
|
||||||
|
|
||||||
|
Un solo test unitario:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --import tsx --test server/lib/scheduling.test.ts
|
||||||
|
node --import tsx --test --test-name-pattern="autoAssign" server/lib/scheduling.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
**`npm run lint` está roto** (ESLint 9 requiere `eslint.config.js` y el repo no tiene ninguno; es un
|
||||||
|
hueco preexistente). No lo uses como señal de verificación: usa `typecheck` + los tests.
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
### Forma general
|
||||||
|
|
||||||
|
Un solo proceso Node sirve la API y, en producción, el frontend compilado:
|
||||||
|
|
||||||
|
- `server/` — Express + SQLite (`node:sqlite`). Los routers se montan en [server/index.ts](server/index.ts).
|
||||||
|
- `src/` — SPA React 18 + Vite + Tailwind. En dev, Vite proxya `/api` al `:3000`.
|
||||||
|
- `shared/types.ts` — los tipos TS que cruzan cliente/servidor. **Actualízalo junto con el esquema.**
|
||||||
|
- Aliases: `@/*` → `src/*`, `@server/*` → `server/*` (definidos en `tsconfig.json`; Vite solo resuelve `@`).
|
||||||
|
- En producción `server/index.ts` sirve `dist/` estático con fallback SPA (`app.get("*")`) que excluye
|
||||||
|
`/api/`. Los TS del servidor se ejecutan directo con `tsx` — no hay paso de compilación de backend
|
||||||
|
(el `Dockerfile` hace `npx tsx server/index.ts`).
|
||||||
|
|
||||||
|
### Multi-tenancy: la invariante central
|
||||||
|
|
||||||
|
Cada negocio es un tenant. Salvo `businesses` y `users`, **toda** tabla lleva `business_id`, y toda
|
||||||
|
query debe filtrar por `req.user!.business_id`. Nunca aceptes un `business_id` que venga del body.
|
||||||
|
El patrón canónico está en todas las rutas:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
db.prepare(`SELECT * FROM appointments WHERE id = ? AND business_id = ?`)
|
||||||
|
.get(id, req.user!.business_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
Tres roles (`users.role`): `admin` (administrador de plataforma, `business_id` NULL, opera todos los
|
||||||
|
negocios vía `/api/admin`), `owner` y `employee`. Middlewares en [server/lib/auth.ts](server/lib/auth.ts):
|
||||||
|
`authRequired`, `ownerOnly`, `adminOnly`, más el helper `err(res, status, msg)`.
|
||||||
|
|
||||||
|
`src/App.tsx` esconde rutas por rol, pero eso es solo UX: **la autorización real vive en los
|
||||||
|
middlewares del servidor** y los tests de e2e/admin verifican el aislamiento (403 / datos de otro
|
||||||
|
tenant no visibles).
|
||||||
|
|
||||||
|
### Autenticación (demo, deliberadamente trivial)
|
||||||
|
|
||||||
|
El token **es el id del usuario en texto plano** y las contraseñas se comparan sin hashear
|
||||||
|
([server/routes/auth.ts](server/routes/auth.ts)). `authRequired` rehidrata `req.user` desde la DB en
|
||||||
|
cada request. El cliente guarda el token en `localStorage` bajo `ap_token`
|
||||||
|
([src/lib/api.ts](src/lib/api.ts)). Esto habilita el "Ver como…" del `DemoSwitcher`.
|
||||||
|
No lo endurezcas a medias: si se toca, hay que cambiar login, `authRequired`, `api.ts`, el
|
||||||
|
`AuthProvider` y los `.mjs` de test a la vez.
|
||||||
|
|
||||||
|
### Esquema y migraciones
|
||||||
|
|
||||||
|
[server/db.ts](server/db.ts) abre `data/agendapro.db` (WAL + `foreign_keys = ON`) y exporta:
|
||||||
|
|
||||||
|
- `SCHEMA` — el esquema de instalación nueva (`CREATE TABLE IF NOT EXISTS …`).
|
||||||
|
- `runMigrations()` — llama `migrateV1ToV2()` → `migrateV2ToV3()` → `migrateV3ToV4()` → `migrateV4ToV5()` **en ese orden**,
|
||||||
|
cada una idempotente y guardada por `schemaVersion()`. Ojo: en el archivo `migrateV3ToV4`
|
||||||
|
está definida *antes* de `migrateV2ToV3`; el orden de ejecución es el de `runMigrations`, no el del
|
||||||
|
archivo. Versión actual: **5**. Las guardas comparan con `schemaVersion()` (número): la comparación
|
||||||
|
de texto que había hacía que `"10" >= "2"` fuera false y reejecutara `migrateV1ToV2`, que contiene
|
||||||
|
un `DROP TABLE users`.
|
||||||
|
|
||||||
|
Para cambiar el esquema: añade las columnas/tablas a `SCHEMA` **y** una `migrateV5ToV6()` nueva que
|
||||||
|
haga el backfill, llámala desde `runMigrations()` y termina con `setMeta("schema_version", "6")`.
|
||||||
|
Usa los helpers `tableExists()` / `columnExists()` para que la migración sea re-ejecutable.
|
||||||
|
|
||||||
|
### Siembra y plantillas
|
||||||
|
|
||||||
|
`server/index.ts` llama `ensureSeed()` al arrancar: crea el admin de plataforma
|
||||||
|
(`ensurePlatformAdmin()`) y, si no hay ningún negocio, siembra el demo "Lumière".
|
||||||
|
|
||||||
|
**Todo negocio nuevo debe nacer con `slug`, `working_hours` y `timezone` en el propio `INSERT`.**
|
||||||
|
Los valores viven en [server/lib/businessDefaults.ts](server/lib/businessDefaults.ts)
|
||||||
|
(`DEFAULT_WORKING_HOURS`, `uniqueSlug`) y hay **tres** sitios que crean negocios —
|
||||||
|
`server/index.ts` (`ensureSeed`), `server/scripts/seed.ts` (`npm run seed`) y
|
||||||
|
`server/routes/admin.ts` (alta desde la consola): si añades otro, usa el mismo módulo. El backfill
|
||||||
|
de esos campos vivía solo dentro de `migrateV2ToV3`/`migrateV3ToV4`, y las migraciones corren al
|
||||||
|
importar `db.ts`, o sea **antes** de que exista la fila: un negocio sembrado después quedaba con
|
||||||
|
`working_hours = NULL` (cero franjas agendables en cualquier fecha) y `slug = NULL`
|
||||||
|
(`/b/:slug` → 404). `migrateV4ToV5()` repara los que ya quedaron rotos.
|
||||||
|
|
||||||
|
`seedBusiness({ businessId, template, ownerEmail, ownerName })` de
|
||||||
|
[server/scripts/seed.ts](server/scripts/seed.ts) es reutilizable: lo usa tanto el arranque como el
|
||||||
|
alta/reset de negocios desde `/api/admin`. Las plantillas (`estetica-spa`, `barberia`, `clinica`,
|
||||||
|
`blank`) están en [server/lib/templates.ts](server/lib/templates.ts).
|
||||||
|
|
||||||
|
Cuentas demo (todas con contraseña `demo1234`): `[email protected]`, `[email protected]`, y
|
||||||
|
seis empleados `*@lumiere.mx`. Los `.mjs` de test dependen de estos emails y del dominio
|
||||||
|
`agendamax.demo`.
|
||||||
|
|
||||||
|
### Zonas horarias: no uses `date('now')`
|
||||||
|
|
||||||
|
Es el error más fácil de cometer aquí. Los negocios son de México (UTC-6), así que el `date('now')`
|
||||||
|
de SQLite (UTC) atribuye mal las citas de la tarde/noche. Regla:
|
||||||
|
|
||||||
|
- Las citas se guardan en UTC con formato ISO-Z **`YYYY-MM-DDTHH:MM:SSZ`** (sin milisegundos).
|
||||||
|
- `datetime(columna)` de SQLite devuelve el formato canónico **`YYYY-MM-DD HH:MM:SS`**.
|
||||||
|
- Por eso [server/lib/time.ts](server/lib/time.ts) tiene **dos familias** de helpers:
|
||||||
|
`bizDayBoundsIso()` para comparar contra `start_at` crudo, y `bizDayBoundsSqlite()` para comparar
|
||||||
|
contra `datetime(columna)`. Elegir mal produce rangos que no matchean nada.
|
||||||
|
- La tz sale de `businesses.timezone`, con default `America/Mexico_City` (ver `bizTz()` en
|
||||||
|
[server/routes/me.ts](server/routes/me.ts)). Todos los helpers son puros y aceptan el instante
|
||||||
|
explícito, por eso son testeables (`server/lib/time.test.ts`).
|
||||||
|
- **`new Date(y, m, d, hh, mm)` está prohibido en lógica de negocio**, igual que `date('now')`.
|
||||||
|
Ese constructor resuelve el reloj de pared en la tz del **proceso**, no en la del negocio. En dev
|
||||||
|
(Windows en `America/Mexico_City`) coincide y todo pasa; en el contenedor de producción
|
||||||
|
(`node:22-slim`, sin `TZ`) el proceso corre en UTC y la jornada entera se desplaza 6 h — un
|
||||||
|
negocio de 09:00–20:00 se publicaba como 03:00–14:00 hora de México, de modo que a partir de la
|
||||||
|
1 PM el endpoint de slots devolvía la lista vacía y no se podía reservar. Usa
|
||||||
|
`wallToUtcDate(tz, …)` y `bizDateISO(instant, tz)` de [server/lib/time.ts](server/lib/time.ts).
|
||||||
|
`isoDateStr`/`isoDayOfWeek` de `scheduling.ts` quedan marcados `@deprecated` por lo mismo.
|
||||||
|
- Por eso **toda la API de `scheduling.ts` lleva `tz` explícita**: `getWorkingHoursForDate(empWh,
|
||||||
|
bizWh, dateIso, tz)`, `getExistingBusy(db, ids, dateIso, tz)`, `isAvailable(db, emp, s, e, tz)`,
|
||||||
|
`pickBestSlotEmployee(…, tz)` y `AutoAssignCtx.tz`. No añadas sobrecargas sin `tz`.
|
||||||
|
- `getExistingBusy` acota el día con `bizDayBoundsIsoFor(tz, dateIso)`, no concatenando
|
||||||
|
`` `${dateIso}T00:00:00` ``: `start_at` se guarda en UTC, así que una cita de las 19:00 de México
|
||||||
|
vive en el día UTC siguiente y la versión anterior no la veía — el guard anti doble-reserva era
|
||||||
|
ciego a toda la tarde.
|
||||||
|
- `server/lib/scheduling.test.ts` fija `process.env.TZ = "UTC"` en su primera línea y usa negocios
|
||||||
|
en `America/Mexico_City`: la tz del proceso y la del negocio **nunca** coinciden, así que una
|
||||||
|
recaída falla en el test y no en producción. `Dockerfile` fija `ENV TZ=UTC` por lo mismo. No las
|
||||||
|
alinees "para que sea más simple".
|
||||||
|
|
||||||
|
### Agendado, auto-asignación y guard anti doble-reserva
|
||||||
|
|
||||||
|
[server/lib/scheduling.ts](server/lib/scheduling.ts) está partido en dos mitades a propósito:
|
||||||
|
primero helpers **puros** (`parseWorkingHours`, `specialtyMatch`, `hasConflict`, `scoreCandidate`,
|
||||||
|
`getWorkingHoursForDate`…) cubiertos por `scheduling.test.ts`, y después los que tocan la DB
|
||||||
|
(`getCandidates`, `getExistingBusy`, `isAvailable`, `pickBestSlotEmployee`, `autoAssign`,
|
||||||
|
`runInTransaction`). Si añades lógica pura, ponla en la primera mitad y testéala.
|
||||||
|
|
||||||
|
Las **dos** rutas que crean citas — `POST /api/appointments`
|
||||||
|
([server/routes/appointments.ts](server/routes/appointments.ts)) y `POST /api/public/:slug/book`
|
||||||
|
([server/routes/booking.ts](server/routes/booking.ts)) — deben mantener esta secuencia dentro de
|
||||||
|
`runInTransaction` (`BEGIN IMMEDIATE` … `COMMIT` / `ROLLBACK`):
|
||||||
|
|
||||||
|
1. resolver el empleado (explícito → auto-asignar a sí mismo si el usuario es `employee` → `autoAssign`),
|
||||||
|
2. `isAvailable(db, empId, startMs, endMs)`; si falla, lanzar `{ status: 409, error: … }`,
|
||||||
|
3. `INSERT` de la cita en la **misma** transacción.
|
||||||
|
|
||||||
|
Sacar el chequeo de la transacción reabre la carrera de doble reserva. Los errores se lanzan como
|
||||||
|
objetos `{ status, error }` y el handler los traduce con `err()`.
|
||||||
|
|
||||||
|
Limitación conocida: `getExistingBusy` acota por día vía `start_at`, así que **no detecta citas que
|
||||||
|
cruzan medianoche**; hoy queda neutralizado por el guard de horario laboral. Revisítalo si alguna vez
|
||||||
|
se permite agendar 24h.
|
||||||
|
|
||||||
|
`businesses.working_hours` y `employees.working_hours` guardan un JSON `Record<1..7, {start,end}|null>`
|
||||||
|
(1=Lunes … 7=Domingo); `null` en el empleado significa "hereda del negocio". El lado cliente lo
|
||||||
|
maneja en [src/lib/workingHours.ts](src/lib/workingHours.ts).
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
Rutas públicas y protegidas ([src/App.tsx](src/App.tsx)):
|
||||||
|
|
||||||
|
```
|
||||||
|
/ → LandingPage público; con sesión redirige según rol
|
||||||
|
/login → LoginPage formulario + acceso de un clic a las cuentas demo
|
||||||
|
/b/:slug → BookingPage fuera del AuthProvider; no asumas usuario
|
||||||
|
/dashboard… → panel sin sesión → /login
|
||||||
|
```
|
||||||
|
|
||||||
|
`homePathFor(user)` es la **única** definición de a dónde va cada rol (`admin` → `/admin`,
|
||||||
|
`owner` → `/dashboard`, `employee` → `/calendar`). No la repliques.
|
||||||
|
|
||||||
|
**Cuatro páginas se cargan con `React.lazy`** y el motivo es medible, no estético:
|
||||||
|
|
||||||
|
| Lazy | Por qué |
|
||||||
|
|---|---|
|
||||||
|
| `LandingPage`, `LoginPage` | Son las únicas que usan `framer-motion` (~40 KB gz). Con el login eager, Rollup mete `lib/motion` en el chunk de entrada y el panel paga la librería en cada carga. |
|
||||||
|
| `DashboardPage`, `CalendarPage` | Son las únicas que importan `recharts` (~111 KB gz) y FullCalendar (~76 KB gz). Eager, la landing las descargaba sin graficar ni agendar nada. |
|
||||||
|
|
||||||
|
Su frontera de `Suspense` está en el `<Outlet>` de `AppShell` (fallback `RouteSpinner`).
|
||||||
|
`clsx` está fijado al chunk `react` en `vite.config.ts` a propósito: lo comparten `lib/format.ts` y
|
||||||
|
recharts, y sin fijarlo Rollup lo asigna al chunk `charts`, de modo que el chunk de entrada acaba
|
||||||
|
importando 111 KB de recharts para obtener una utilidad de 200 bytes. Verificable: `npm run build` y
|
||||||
|
comprobar que `index-*.js` no importe `charts-*` ni `framer-*`.
|
||||||
|
|
||||||
|
- [src/lib/api.ts](src/lib/api.ts) es el **único** cliente HTTP de la app autenticada: un `request<T>()`
|
||||||
|
que inyecta el bearer y normaliza errores a `Error & { status }`. Añade endpoints ahí, no `fetch`
|
||||||
|
suelto en componentes. El flujo público usa [src/lib/publicApi.ts](src/lib/publicApi.ts) aparte.
|
||||||
|
- React Query con `staleTime: 15_000`, sin refetch al enfocar, `retry: 1` ([src/main.tsx](src/main.tsx)).
|
||||||
|
- `AppShell` para negocio, `AdminShell` para plataforma; `/b/:slug` (reservas públicas) se monta
|
||||||
|
**fuera** del `AuthProvider` en [src/App.tsx](src/App.tsx) — no asumas usuario en ese árbol.
|
||||||
|
- Calendario con FullCalendar. En móvil abre en vista Día + vista Lista; Semana/Mes se reservan a
|
||||||
|
tablet/desktop a propósito (columnas aplastadas). `vite.config.ts` separa FullCalendar, recharts,
|
||||||
|
react y react-query en chunks manuales.
|
||||||
|
- Estilos de formulario (`.input`, `.select`, `.textarea`) viven en un `@layer components` de
|
||||||
|
`src/index.css`; si los sacas de la capa, la especificidad de Tailwind los pisa.
|
||||||
|
|
||||||
|
### Responsividad (iPhone / iPad) — invariantes que no hay que romper
|
||||||
|
|
||||||
|
Objetivo: iPhone 12 (390px), iPhone 16 Pro Max (440px) e iPad (744-1180px). Verificable con
|
||||||
|
`npm run audit:responsive` (WebKit real; requiere `npm run dev`). Reglas que sostienen el
|
||||||
|
comportamiento actual:
|
||||||
|
|
||||||
|
- **Ningún campo de formulario por debajo de 16px en táctil.** Mobile Safari hace auto-zoom al
|
||||||
|
enfocar un `input`/`select`/`textarea` con `font-size < 16px` y **no revierte** al desenfocar: la
|
||||||
|
vista queda escalada y corrida. Era la causa del "arranca con zoom y desplazada" al tocar el campo
|
||||||
|
de correo. La regla vive en `@media (pointer: coarse)` en [src/index.css](src/index.css) y va por
|
||||||
|
tipo de puntero, no por ancho: un iPad también se toca con el dedo. No se resuelve con
|
||||||
|
`maximum-scale=1` porque eso rompe el pinch-zoom de accesibilidad.
|
||||||
|
- **Alturas de viewport con `dvh`, nunca `100vh` a secas.** `100vh` en iOS no descuenta la barra de
|
||||||
|
URL dinámica. Usa `.h-screen-safe` / `.min-h-screen-safe` / `.max-h-screen-safe`, que declaran
|
||||||
|
`vh` como fallback y `dvh` encima.
|
||||||
|
- **`viewport-fit=cover` obliga a descontar los insets del sistema.** El chrome (headers, sidebars,
|
||||||
|
bottom-sheets) usa `.safe-top`, `.safe-x` y `.safe-area-bottom`, apoyadas en las variables
|
||||||
|
`--safe-*` de `:root`. Sin ellas el contenido queda bajo la Dynamic Island o el home indicator.
|
||||||
|
- **Los objetivos táctiles se deciden por `pointer: coarse`, no por breakpoints `sm:`.** Un `sm:` deja
|
||||||
|
fuera a las tablets, que son táctiles. Hay dos utilidades: `.tap-target` (40px de alto, para chips
|
||||||
|
densos) y `.icon-btn` (40×40, para botones de solo icono).
|
||||||
|
- **`.icon-btn` es una clase explícita a propósito; no intentes detectar los botones de icono por
|
||||||
|
selector.** Se probó `button:has(> svg:only-child)` y es una trampa: `:only-child` solo mira hijos
|
||||||
|
**elemento**, así que un botón o enlace con etiqueta (`<Plus/> Nueva cita`) también encaja, porque el
|
||||||
|
texto es un nodo de texto. Esa regla, al fijar `display: inline-flex`, convirtió los `NavLink` del
|
||||||
|
menú lateral en inline y los repartió en dos columnas en iPad Pro. Dos corolarios: `.icon-btn`
|
||||||
|
**no** toca `display` (está fuera de `@layer` y ganaría a `hidden`/`lg:block`, sacando el botón de
|
||||||
|
colapsar en el móvil), y centra el icono con `margin-inline: auto` sobre el `svg`, porque el
|
||||||
|
preflight de Tailwind lo deja en `display: block` y así ignora `text-align`.
|
||||||
|
- **Scrollbars personalizadas solo en `pointer: fine`.** Una barra de 10px en táctil roba ancho al
|
||||||
|
layout (el ancho útil bajaba de 390 a 380px) y descuadra los cálculos de 100%.
|
||||||
|
- **No combines `.safe-x` con `px-*` en el mismo elemento.** `.safe-x` está declarada **fuera** de
|
||||||
|
`@layer` en [src/index.css](src/index.css), así que gana a las utilidades de Tailwind. Como resuelve
|
||||||
|
`env(safe-area-inset-left, 0px)`, en cualquier dispositivo que no sea un iPhone en landscape deja el
|
||||||
|
padding lateral en **0** y el texto pega con el borde. Ni el check de overflow ni el de zoom lo
|
||||||
|
detectan, porque no hay desborde: hay cero margen. Usa `.ld-gutter` (o el patrón
|
||||||
|
`padding-inline: max(1.25rem, var(--safe-left))` de `.ld-section`), que resuelve las dos cosas en una
|
||||||
|
declaración. `npm run audit:responsive` lo vigila con el check `gutter` en las páginas públicas.
|
||||||
|
- **El check `shell-height` solo aplica a páginas con shell de altura fija** (`[data-app-shell]`, o sea
|
||||||
|
`AppShell` y `AdminShell`). Una landing o un login scrollean a propósito y son legítimamente más
|
||||||
|
altos que el viewport; compararlos contra `innerHeight` no mide un defecto, mide que existe scroll.
|
||||||
|
Para esas páginas el equivalente es el check estático `raw-viewport-unit`, que busca `h-screen`/`100vh`
|
||||||
|
crudos en las fuentes.
|
||||||
|
- **Nada debe poder desplazar la página en horizontal.** `body` lleva `overflow-x: hidden` como red
|
||||||
|
de seguridad, pero las causas se arreglan en origen: por ejemplo, un `grid` sin `grid-cols-1`
|
||||||
|
explícito dimensiona su columna implícita a `max-content` y la estiraba 23px más que la pantalla.
|
||||||
|
|
||||||
|
### Landing pública y marca
|
||||||
|
|
||||||
|
La landing vive en [src/components/landing/](src/components/landing/), una sección por archivo, todas
|
||||||
|
sin props ni estado compartido; [src/pages/LandingPage.tsx](src/pages/LandingPage.tsx) solo las
|
||||||
|
compone. El test de aceptación es `npm run test:landing` (`landing-e2e.mjs`, WebKit), y depende de
|
||||||
|
estos atributos de datos — si los quitas, se rompe: `data-ld-section` (deben ser **7**),
|
||||||
|
`data-ld-hero-title`, `data-ld-closing-cta`, `data-magic-login` + `data-role`.
|
||||||
|
|
||||||
|
**La paleta no se inventa: se hereda del panel.** Los tokens `--ld-*` de `src/index.css` apuntan a los
|
||||||
|
colores que la app ya usa. En particular, las ocho muestras del abanico del hero son `PIE_COLORS` de
|
||||||
|
[src/pages/DashboardPage.tsx](src/pages/DashboardPage.tsx) — los mismos hex de los servicios en la dona
|
||||||
|
y de los avatares que reparte el seed. Por eso el hilo de la página («sus colores se vuelven sus
|
||||||
|
números») es literal. Si cambias `PIE_COLORS`, cambia también el abanico.
|
||||||
|
|
||||||
|
- La marca vive en **un** sitio: [src/components/BrandMark.tsx](src/components/BrandMark.tsx), que
|
||||||
|
replica `public/favicon.svg` (cuadrado `#3b66ff`, renglones blancos, punto `#f17616`). Si tocas esos
|
||||||
|
colores, toca también el favicon y `npm run generate:pwa-icons`, o la pestaña deja de coincidir.
|
||||||
|
- El azul hace de superficie de acción (`.ld-cta` usa brand-500 → brand-700, los dos tonos que
|
||||||
|
`.btn-primary` ya usa en normal y hover) y el naranja marca, nunca es fondo de botón. Es la misma
|
||||||
|
lógica del favicon.
|
||||||
|
- **`NotebookVisual` es la única excepción** y es deliberada: papel crema, renglones y grafito. Es lo
|
||||||
|
que el producto reemplaza; si se pareciera al producto, la sección dejaría de contar un cambio.
|
||||||
|
- Tipografía: Inter de cuerpo (la misma del panel) y **Fraunces** solo en titulares. El cuerpo va a
|
||||||
|
17px mínimo con interlínea 1.6 porque el público objetivo incluye personas de 50+.
|
||||||
|
- Las animaciones salen de [src/lib/motion.ts](src/lib/motion.ts) con un único easing. Con
|
||||||
|
`prefers-reduced-motion` los `initial` resuelven al **estado final**, no se acortan: un
|
||||||
|
`initial={{opacity:0}}` cuyo `whileInView` nunca corre deja el bloque invisible para siempre. El
|
||||||
|
test lo verifica recorriendo las siete secciones.
|
||||||
|
- **La gráfica de ingresos es la excepción: se anima con CSS, no con framer-motion.** iOS Safari
|
||||||
|
suspende `requestAnimationFrame` mientras dura el scroll por inercia, y framer-motion interpola
|
||||||
|
contra el reloj de pared: la animación gasta su duración sin pintar un fotograma y al reanudarse
|
||||||
|
salta al estado final. En un iPhone se ve como si nunca hubiera animado — fue un defecto reportado
|
||||||
|
desde producción, y ningún audit lo detecta porque en WebKit de escritorio sí anima.
|
||||||
|
[RevenueLineVisual](src/components/landing/visuals/RevenueLineVisual.tsx) solo decide *cuándo*
|
||||||
|
empieza (un `useInView` que pone `data-ld-rev-visible`); el *cómo* son los keyframes `ld-rev-*` de
|
||||||
|
[src/index.css](src/index.css), que llevan su propia línea de tiempo en el motor. El trazo usa
|
||||||
|
`pathLength={1}` para que dasharray/dashoffset sean fracciones. Si añades otra animación de trazo
|
||||||
|
de línea en la landing, hazla igual.
|
||||||
|
|
||||||
|
### Barra lateral
|
||||||
|
|
||||||
|
Un único menú vertical, colapsable a solo iconos en `lg+` mediante
|
||||||
|
[src/lib/useSidebarCollapsed.ts](src/lib/useSidebarCollapsed.ts) (persistido en `localStorage` bajo
|
||||||
|
`ap_sidebar_collapsed`, sincronizado entre pestañas). Colapsada mide 68px y devuelve **188px** al
|
||||||
|
contenido, que es lo que más se nota en un iPad Pro portrait. `AppShell` y `AdminShell` comparten el
|
||||||
|
patrón: `sidebarContent(mini)` en lugar de un JSX fijo, porque el panel móvil siempre se muestra
|
||||||
|
completo aunque la de escritorio esté colapsada. En modo `mini` cada entrada conserva `title` para no
|
||||||
|
perder su nombre accesible al quedarse sin texto.
|
||||||
|
|
||||||
|
### Calendario responsivo
|
||||||
|
|
||||||
|
Es la parte que más se ha roto históricamente. Tres tamaños vía
|
||||||
|
[src/lib/useBreakpoint.ts](src/lib/useBreakpoint.ts), alineados con los media queries del CSS:
|
||||||
|
|
||||||
|
| | phone (≤640) | tablet (641-1024) | desktop (≥1025) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Vista inicial | `timeGridDay` | `timeGridThreeDay` | `timeGridWeek` |
|
||||||
|
| Toolbar | prev/next/hoy + Día/Lista | + 3 días/Semana | + Mes |
|
||||||
|
|
||||||
|
El corte tablet/desktop está en 1024/1025 y **no coincide con el `lg:` de Tailwind** (también 1024):
|
||||||
|
un iPad Pro portrait mide justo 1024px, así que a ese ancho hay barra lateral fija *y* vista de 3
|
||||||
|
días. Es intencionado — con la barra desplegada quedan ~712px de calendario, y 7 columnas ahí son
|
||||||
|
~100px por día. Si cambias uno de los dos umbrales, cambia también el otro
|
||||||
|
([src/index.css](src/index.css) tiene los media queries de tablet).
|
||||||
|
|
||||||
|
- **La altura del calendario se calcula contra el viewport, no con `height="100%"`.** FullCalendar mide
|
||||||
|
su contenedor una sola vez al montar y solo re-mide en resize de ventana, así que se quedaba con
|
||||||
|
valores viejos cuando el layout cambiaba después (en un iPhone 12 daba 360px dentro de un contenedor
|
||||||
|
de 525px). Se mide `innerHeight - rect.top` y se pasa como número; ver `CAL_BOTTOM_GAP` /
|
||||||
|
`CAL_MIN_HEIGHT` en [src/pages/CalendarPage.tsx](src/pages/CalendarPage.tsx). El `ResizeObserver`
|
||||||
|
observa la **barra de filtros**, no el `body`: el shell es `overflow-hidden` de altura fija, así que
|
||||||
|
el body nunca cambia de tamaño y observarlo no vuelve a disparar.
|
||||||
|
- **No pongas `contentHeight` fijo.** `.fc-timegrid-slot` mide 2.4rem y la grilla de 08:00-21:00 son 26
|
||||||
|
slots (~1000px), así que un `contentHeight` en píxeles hacía el calendario 1.4× más alto que la
|
||||||
|
pantalla y arrastraba la página. El calendario debe scrollear **dentro** de su tarjeta.
|
||||||
|
- **La tablet usa 3 días a propósito.** Con 7 columnas en 820px y varios especialistas a la misma hora,
|
||||||
|
`slotEventOverlap={false}` parte cada evento en columnas de ~30px y los títulos quedan en `"A.."`.
|
||||||
|
- No añadas bloques (avisos, empty states) como hermanos del calendario dentro de su tarjeta: con la
|
||||||
|
altura ya fijada no caben y se solapan con la grilla.
|
||||||
|
|
||||||
|
### PWA
|
||||||
|
|
||||||
|
`public/sw.js` cachea solo el app shell y **hace bypass explícito de `/api/`** y de cualquier request
|
||||||
|
no-GET o cross-origin. Si tocas el service worker, conserva ese bypass (hay un test dedicado en
|
||||||
|
`pwa-e2e.mjs`) y sube el sufijo de `CACHE_NAME` (`agendamax-shell-vN`) para que el cleanup de
|
||||||
|
`activate` purgue el anterior. Se registra únicamente en producción. Iconos: `npm run generate:pwa-icons`.
|
||||||
|
|
||||||
|
**`beforeinstallprompt` se captura a nivel de módulo, no en un hook.** El navegador lo dispara una
|
||||||
|
sola vez por carga y no lo repite. Como `LoginPage` es una ruta `lazy`, `InstallAppPrompt` monta
|
||||||
|
después de una ida y vuelta de red extra: un listener registrado al montar llega tarde y el botón
|
||||||
|
«Instalar AgendaMax» no aparece nunca. Por eso el listener vive en
|
||||||
|
[src/lib/installPrompt.ts](src/lib/installPrompt.ts), que `main.tsx` importa por su efecto **antes**
|
||||||
|
de montar React, y [useInstallPrompt](src/lib/useInstallPrompt.ts) solo lee de ese almacén. No
|
||||||
|
devuelvas el listener a un `useEffect`, y si vuelves eager alguna ruta no asumas que eso lo arregla.
|
||||||
|
|
||||||
|
Detalle de verificación: `npm run test:pwa` contra `localhost` **no** detecta ese defecto, porque el
|
||||||
|
chunk lazy llega antes que el evento. Solo se ve contra el dominio HTTPS real
|
||||||
|
(`PWA_BASE_URL=https://…`), donde la latencia abre la ventana. Si tocas el arranque del prompt,
|
||||||
|
prueba contra producción o retrasa el chunk a propósito con `route()`.
|
||||||
|
|
||||||
|
## Convenciones de trabajo
|
||||||
|
|
||||||
|
- **No hagas commits salvo que se pidan explícitamente** (política registrada en
|
||||||
|
`.superpowers/sdd/progress.md`). La verificación por tarea es typecheck + tests, no un commit.
|
||||||
|
- Este repo se desarrolla con un flujo spec-driven: las especificaciones y planes viven en
|
||||||
|
`docs/superpowers/{specs,plans}/` y la bitácora de ejecución con hallazgos de review en
|
||||||
|
`.superpowers/sdd/`. Léelos antes de retomar un feature a medias — `progress.md` lista los
|
||||||
|
follow-ups diferidos.
|
||||||
|
- `AUDIT.md` documenta las brechas funcionales frente al producto real que se está emulando; sirve de
|
||||||
|
backlog de producto.
|
||||||
|
- Existe un grafo de conocimiento en `graphify-out/` (ver el skill `graphify`): úsalo para orientarte
|
||||||
|
antes de leer código a ciegas; refréscalo con `graphify update` si quedó desactualizado.
|
||||||
|
- `data/`, `dist/`, `screenshots/`, `.cache/`, `*.log` y `.opencode/` están gitignorados y son
|
||||||
|
regenerables; no los versiones ni los tomes como fuente de verdad.
|
||||||
|
|
||||||
|
## Persistencia en producción
|
||||||
|
|
||||||
|
`data/agendapro.db` es el estado del producto y el despliegue **no** lo recrea. Dos
|
||||||
|
piezas lo sostienen, y hay que tocarlas juntas:
|
||||||
|
|
||||||
|
- **El `Dockerfile` NO declara `VOLUME /app/data`.** Esa instrucción hace que Docker
|
||||||
|
fabrique un volumen **anónimo** en cada arranque, y Coolify los purga al recrear el
|
||||||
|
contenedor: cada despliegue estrenaba base vacía, `ensureSeed()` la resembraba y se
|
||||||
|
perdían citas, clientes y todo lo configurado en Ajustes, sin error ni aviso. Se
|
||||||
|
verificó comparando el nombre del volumen entre despliegues: cambiaba cada vez y no
|
||||||
|
quedaba ningún huérfano con datos. **No lo reintroduzcas.**
|
||||||
|
- **La persistencia la aporta el volumen con nombre declarado en Coolify**
|
||||||
|
(`s30f7egdlkx4wyjp59o1iunc-agendamax-data` → `/app/data`, fila de
|
||||||
|
`local_persistent_volumes`). Ojo: la API de Coolify 4.1.2 devuelve 404 en
|
||||||
|
`/applications/*`, así que eso se configura por la UI o por su base de datos, no por
|
||||||
|
API. Si despliegas en otro host, declara allí un volumen equivalente: sin él la base
|
||||||
|
es efímera otra vez.
|
||||||
|
|
||||||
|
Respaldo: `/root/scripts/backup-agendamax.sh` en el host Proxmox, por cron diario a las
|
||||||
|
03:30, con retención de 14 días en `/root/backups/agendamax/`. Usa `VACUUM INTO`, no
|
||||||
|
`cp`: la base corre en WAL y copiar el `.db` en caliente da una foto anterior al último
|
||||||
|
checkpoint. El script verifica `integrity_check` y descarta el respaldo si falla.
|
||||||
|
|
||||||
|
## Seguridad
|
||||||
|
|
||||||
|
Esto es una **demo**: token trivial, contraseñas en claro, sin rate-limiting ni sesiones reales, y
|
||||||
|
`cors()` abierto. La validación de entrada es manual y ad-hoc en cada handler: `zod` figura en
|
||||||
|
`dependencies` pero **no se importa en ningún archivo**. Antes de un despliegue real haría falta
|
||||||
|
hashing (bcrypt), JWT firmados, validación estricta, rate-limiting y HTTPS. Tenlo presente antes de
|
||||||
|
proponer este código para producción.
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
# ---- Build frontend ----
|
||||||
|
FROM node:22-slim AS web-build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# El login mágico y el "Ver como…" del panel viven detrás de la constante de build
|
||||||
|
# `DEMO` (`import.meta.env.DEV || import.meta.env.VITE_DEMO_UI === "1"`, ver
|
||||||
|
# src/pages/LoginPage.tsx, src/components/AppShell.tsx y src/components/DemoSwitcher.tsx).
|
||||||
|
# En un build de producción `DEV` es false, así que sin esta variable Vite elimina esas
|
||||||
|
# ramas por dead-code elimination: el despliegue quedaba con la landing prometiendo
|
||||||
|
# "no pide registro" y un /login sin ninguna cuenta de prueba que ofrecer.
|
||||||
|
#
|
||||||
|
# Este despliegue ES la demostración del producto, de ahí el default en 1. Cuando deje
|
||||||
|
# de serlo, basta pasar `--build-arg VITE_DEMO_UI=0` (o declararlo como build variable en
|
||||||
|
# Coolify) y las cuentas demo desaparecen del bundle sin tocar código.
|
||||||
|
ARG VITE_DEMO_UI=1
|
||||||
|
ENV VITE_DEMO_UI=$VITE_DEMO_UI
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---- Runtime ----
|
||||||
|
FROM node:22-slim
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PORT=3000
|
||||||
|
# La zona del contenedor se fija explícitamente a UTC, que es como se almacenan los
|
||||||
|
# instantes en la base. La lógica de agenda ya no depende de ella (usa
|
||||||
|
# `businesses.timezone` vía server/lib/time.ts), y dejarla en UTC hace que cualquier
|
||||||
|
# recaída a la tz del proceso se note de inmediato en vez de esconderse en dev.
|
||||||
|
ENV TZ=UTC
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
|
|
||||||
|
# Built frontend (vite outputs to dist/)
|
||||||
|
COPY --from=web-build /app/dist ./dist
|
||||||
|
|
||||||
|
# Server source (tsx runs TS directly in production)
|
||||||
|
COPY server ./server
|
||||||
|
COPY shared ./shared
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
|
||||||
|
# Persistent SQLite data.
|
||||||
|
#
|
||||||
|
# NO declares `VOLUME /app/data` aquí. Esa instrucción hace que Docker fabrique un
|
||||||
|
# volumen ANÓNIMO en cada arranque, y Coolify los purga al recrear el contenedor:
|
||||||
|
# cada despliegue estrenaba base vacía y `ensureSeed()` la resembraba, así que se
|
||||||
|
# perdían las citas, los clientes y todo lo configurado en Ajustes. Se verificó
|
||||||
|
# comparando el nombre del volumen entre despliegues — cambiaba cada vez.
|
||||||
|
#
|
||||||
|
# La persistencia la aporta el almacenamiento con nombre declarado en Coolify
|
||||||
|
# (`s30f7egdlkx4wyjp59o1iunc-agendamax-data` montado en /app/data), que sobrevive a
|
||||||
|
# la recreación del contenedor. Si algún día se despliega en otro sitio, hay que
|
||||||
|
# declarar allí un volumen equivalente: sin él la base es efímera.
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["npx", "tsx", "server/index.ts"]
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# AgendaPro
|
# AgendaMax
|
||||||
|
|
||||||
Aplicación web **multi-tenant (SaaS)** para la gestión de negocios de servicios (estética, spa, barbería, clínicas, etc.):
|
Aplicación web **multi-tenant (SaaS)** para la gestión de negocios de servicios (estética, spa, barbería, clínicas, etc.):
|
||||||
calendario de citas con **arrastrar y soltar**, gestión de **empleados**, **servicios**, **clientes** y **tickets**,
|
calendario de citas con **arrastrar y soltar**, gestión de **empleados**, **servicios**, **clientes** y **tickets**,
|
||||||
@@ -44,6 +44,12 @@ npm run dev
|
|||||||
|
|
||||||
Abre http://localhost:5173 (el frontend proxya `/api` al backend en el puerto 3000).
|
Abre http://localhost:5173 (el frontend proxya `/api` al backend en el puerto 3000).
|
||||||
|
|
||||||
|
| Ruta | Qué es |
|
||||||
|
|---|---|
|
||||||
|
| `/` | Landing pública. Con sesión abierta redirige al panel según el rol. |
|
||||||
|
| `/login` | Acceso. En fase demo ofrece entrar de un clic con las cuentas de abajo. |
|
||||||
|
| `/b/:slug` | Reserva pública del negocio, sin sesión. |
|
||||||
|
|
||||||
## Producción
|
## Producción
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -51,13 +57,37 @@ npm run build # compila TS y empaqueta el frontend en dist/
|
|||||||
npm start # sirve la API y el frontend estático en http://localhost:3000
|
npm start # sirve la API y el frontend estático en http://localhost:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
El acceso de un clic y el selector «Ver como…» del panel viven detrás de la constante de
|
||||||
|
build `DEMO`, que en un build de producción solo se enciende con `VITE_DEMO_UI=1`. El
|
||||||
|
`Dockerfile` la declara con ese valor por defecto porque el despliegue **es** la
|
||||||
|
demostración; pasar `--build-arg VITE_DEMO_UI=0` los elimina del bundle.
|
||||||
|
|
||||||
|
## Instalar AgendaMax como app
|
||||||
|
|
||||||
|
- **Android/Chrome**: abre la URL HTTPS y selecciona la acción de instalación mostrada por AgendaMax o por la barra de direcciones del navegador.
|
||||||
|
- **iPhone/iPad Safari**: usa **Compartir -> Añadir a pantalla de inicio**; iOS no expone el aviso de instalación dentro de la página de Android.
|
||||||
|
- La app instalada puede abrirse sin la interfaz del navegador, pero los datos del negocio siguen requiriendo conexión a internet.
|
||||||
|
- Validación local: ejecuta `npm.cmd run build`, inicia producción con `npm.cmd start` y después ejecuta `npm.cmd run test:pwa`.
|
||||||
|
- La validación de producción requiere el dominio HTTPS de Coolify, no una dirección IP HTTP.
|
||||||
|
|
||||||
|
`npm.cmd run test:pwa` usa estas variables opcionales para los flujos autenticados;
|
||||||
|
si no se definen, usa las cuentas demo locales:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[email protected]
|
||||||
|
[email protected]
|
||||||
|
PWA_PASSWORD=demo1234
|
||||||
|
```
|
||||||
|
|
||||||
## Tests y auditoría visual
|
## Tests y auditoría visual
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test:e2e # 22 pruebas end-to-end de la API (login, CRUD, dashboard, auto-asignación, roles, multi-tenant)
|
npm run test:e2e # 22 pruebas end-to-end de la API (login, CRUD, dashboard, auto-asignación, roles, multi-tenant)
|
||||||
npm run test:admin # 17 pruebas del admin (crear negocio + plantilla, aislamiento, reset-demo, delete cascade)
|
npm run test:admin # 17 pruebas del admin (crear negocio + plantilla, aislamiento, reset-demo, delete cascade)
|
||||||
|
npm run test:landing # WebKit: ruteo landing/login, acceso de un clic y prefers-reduced-motion
|
||||||
npm run audit:visual # Playwright: navega 10 páginas × 4 viewports (móvil/tablet/desktop/wide),
|
npm run audit:visual # Playwright: navega 10 páginas × 4 viewports (móvil/tablet/desktop/wide),
|
||||||
# mide overflow horizontal, captura errores de consola y guarda capturas en screenshots/
|
# mide overflow horizontal, captura errores de consola y guarda capturas en screenshots/
|
||||||
|
npm run audit:responsive # WebKit: iPhone/iPad reales — auto-zoom, safe areas, objetivos táctiles y canaletas
|
||||||
```
|
```
|
||||||
|
|
||||||
La auditoría visual verifica responsividad en 375 / 768 / 1280 / 1536 px, detecta overflow,
|
La auditoría visual verifica responsividad en 375 / 768 / 1280 / 1536 px, detecta overflow,
|
||||||
@@ -65,25 +95,26 @@ errores de consola y confirma que el calendario, el modal de cita y el cambio de
|
|||||||
|
|
||||||
## Cuentas demo
|
## Cuentas demo
|
||||||
|
|
||||||
Todas usan la contraseña **`demo1234`**.
|
Todas usan la contraseña **`demo1234`**. En `/login` se puede entrar con un clic, sin
|
||||||
|
escribirlas.
|
||||||
|
|
||||||
| Rol | Email | Nombre |
|
| Rol | Email | Contraseña | Nombre |
|
||||||
|----------|----------------------------------|------------------|
|
|----------|----------------------------------|------------|------------------|
|
||||||
| **Admin**| `admin@agendapro.demo` | Administrador |
|
| **Admin**| `admin@agendamax.demo` | `demo1234` | Administrador |
|
||||||
| Dueño | `owner@agendapro.demo` | Daniela Reyes |
|
| Dueño | `owner@agendamax.demo` | `demo1234` | Daniela Reyes |
|
||||||
| Empleado | `[email protected]` | Valentina Cruz |
|
| Empleado | `[email protected]` | `demo1234` | Valentina Cruz |
|
||||||
| Empleado | `[email protected]` | Mateo Herrera |
|
| Empleado | `[email protected]` | `demo1234` | Mateo Herrera |
|
||||||
| Empleado | `sofia.rami[email protected]` | Sofía Ramírez |
|
| Empleado | `sofía.ramí[email protected]` | `demo1234` | Sofía Ramírez |
|
||||||
| Empleado | `[email protected]` | Diego Castillo |
|
| Empleado | `[email protected]` | `demo1234` | Diego Castillo |
|
||||||
| Empleado | `[email protected]` | Isabela Torres |
|
| Empleado | `[email protected]` | `demo1234` | Isabela Torres |
|
||||||
| Empleado | `carolina.me[email protected]` | Carolina Méndez |
|
| Empleado | `carolina.mé[email protected]` | `demo1234` | Carolina Méndez |
|
||||||
|
|
||||||
> En la pantalla de login aparecen botones de acceso rápido a las cuentas demo,
|
> En la pantalla de login aparecen botones de acceso rápido a las cuentas demo,
|
||||||
> y desde la barra lateral puedes cambiar de cuenta con **“Ver como…”**.
|
> y desde la barra lateral puedes cambiar de cuenta con **“Ver como…”**.
|
||||||
|
|
||||||
## Multi-tenant y consola de administrador
|
## Multi-tenant y consola de administrador
|
||||||
|
|
||||||
AgendaPro es **multi-tenant**: cada negocio es un tenant aislado (todos los datos llevan `business_id`
|
AgendaMax es **multi-tenant**: cada negocio es un tenant aislado (todos los datos llevan `business_id`
|
||||||
y las consultas se filtran por el negocio del usuario). El **administrador de plataforma** gestiona
|
y las consultas se filtran por el negocio del usuario). El **administrador de plataforma** gestiona
|
||||||
todos los negocios desde `/admin`:
|
todos los negocios desde `/admin`:
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -14,7 +14,7 @@ function check(name, cond, extra = "") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Admin login
|
// Admin login
|
||||||
const { json: aLogin } = await req("POST", "/auth/login", { email: "admin@agendapro.demo", password: "demo1234" });
|
const { json: aLogin } = await req("POST", "/auth/login", { email: "admin@agendamax.demo", password: "demo1234" });
|
||||||
check("admin login", aLogin.user?.role === "admin" && aLogin.token, JSON.stringify(aLogin).slice(0, 100));
|
check("admin login", aLogin.user?.role === "admin" && aLogin.token, JSON.stringify(aLogin).slice(0, 100));
|
||||||
const A = aLogin.token;
|
const A = aLogin.token;
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ const { json: oBiz } = await req("GET", "/business", null, OT);
|
|||||||
check("new owner business name", oBiz.business?.name === "Barbería Test");
|
check("new owner business name", oBiz.business?.name === "Barbería Test");
|
||||||
|
|
||||||
// Owner1 still sees only Lumière (isolation the other way)
|
// Owner1 still sees only Lumière (isolation the other way)
|
||||||
const { json: owner1 } = await req("POST", "/auth/login", { email: "owner@agendapro.demo", password: "demo1234" });
|
const { json: owner1 } = await req("POST", "/auth/login", { email: "owner@agendamax.demo", password: "demo1234" });
|
||||||
const { json: o1Emps } = await req("GET", "/employees", null, owner1.token);
|
const { json: o1Emps } = await req("GET", "/employees", null, owner1.token);
|
||||||
check("owner1 isolation: still 6 employees", o1Emps.employees?.length === 6, `got ${o1Emps.employees?.length}`);
|
check("owner1 isolation: still 6 employees", o1Emps.employees?.length === 6, `got ${o1Emps.employees?.length}`);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Desplegar agendapro en el server E3
|
||||||
|
|
||||||
|
Destino: **https://agendapro.consultoriae3.com** * Imagen: `agendapro:latest` * Puerto interno: **3100**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$S = "$env:USERPROFILE\.claude\skills\contabo-e3\scripts"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1. Publicar la imagen
|
||||||
|
|
||||||
|
Haz push a `main`: el workflow `.github/workflows/deploy.yml` construye y publica en
|
||||||
|
`ghcr.io/urieljarethbusiness-cpu/agendamax`. Toma el tag por SHA del resumen del run y usalo en el stack
|
||||||
|
(`:latest` no fuerza el re-pull en Swarm).
|
||||||
|
|
||||||
|
El paquete de GHCR nace **privado**. Elige:
|
||||||
|
- **Publico** (recomendado si el codigo no es sensible): GitHub > Packages > el paquete >
|
||||||
|
Package settings > Change visibility > Public. El server hace pull sin credenciales.
|
||||||
|
- **Privado**: en el server, `docker login ghcr.io -u <GH_USER>` con un PAT `read:packages`,
|
||||||
|
y desplegar con `--with-registry-auth`.
|
||||||
|
|
||||||
|
## 2. A-record en SiteGround
|
||||||
|
|
||||||
|
El DNS de consultoriae3.com lo sirve SiteGround. Site Tools > Domain > DNS Zone Editor > A:
|
||||||
|
|
||||||
|
Type: A * Name: agendapro * Value: 157.173.205.217
|
||||||
|
|
||||||
|
Verifica: `Resolve-DnsName agendapro.consultoriae3.com -Type A`
|
||||||
|
|
||||||
|
## 3. Base de datos
|
||||||
|
|
||||||
|
Necesita Postgres. Provisiona DB + rol dedicados (ESCRITURA, pide confirmacion):
|
||||||
|
|
||||||
|
& "$S\New-E3Database.ps1" -AppName agendapro
|
||||||
|
|
||||||
|
Crea la DB `agendapro` con owner `agendapro_app` y guarda la password en
|
||||||
|
/root/dados_vps/dados_agendapro (root-only, nunca en git).
|
||||||
|
|
||||||
|
## 4. Preflight (lectura, no toca nada)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& "$S\Test-E3Preflight.ps1" -ProjectPath "H:\MegaSync\Proyectos\AgendaPro" -AppName agendapro -Subdomain agendapro
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Desplegar (ESCRITURA)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& "$S\Deploy-E3Stack.ps1" -StackFile "H:\MegaSync\Proyectos\AgendaPro\deploy\agendapro.yml" -AppName agendapro
|
||||||
|
```
|
||||||
|
|
||||||
|
Si el YAML lleva placeholders de secretos, pasalos al desplegar:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& "$S\Deploy-E3Stack.ps1" -StackFile "H:\MegaSync\Proyectos\AgendaPro\deploy\agendapro.yml" -AppName agendapro `
|
||||||
|
-Replace @{ '<APP_DB_PASSWORD>' = '...' }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Verificar (lectura)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& "$S\Test-E3Service.ps1" -AppName agendapro -Subdomain agendapro
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& "$S\Invoke-E3Rollback.ps1" -AppName agendapro
|
||||||
|
```
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Stack de despliegue de 'agendapro' en el server E3 (Docker Swarm single-node + Traefik).
|
||||||
|
# Generado por New-E3Scaffold.ps1 - https://agendapro.consultoriae3.com
|
||||||
|
#
|
||||||
|
# NO EDITES A LA LIGERA:
|
||||||
|
# - Las labels de Traefik van bajo deploy.labels. Fuera de ahi se IGNORAN (servicio 1/1 + HTTP 404).
|
||||||
|
# - Sin clave 'build:' - Swarm no construye; la imagen tiene que existir ya.
|
||||||
|
# - Sin clave 'ports:' - el unico que publica al host es Traefik (80/443).
|
||||||
|
# - Los marcadores de secreto se sustituyen AL DESPLEGAR (Deploy-E3Stack.ps1 -SecretsFile).
|
||||||
|
# No los rellenes con valores reales aqui si este archivo se versiona en git.
|
||||||
|
|
||||||
|
version: "3.7"
|
||||||
|
|
||||||
|
services:
|
||||||
|
agendapro:
|
||||||
|
# Taguear por SHA y no ':latest': con un tag rodante Swarm no vuelve a
|
||||||
|
# hacer pull y un 'service update' no jala la imagen nueva.
|
||||||
|
image: agendapro:6d67b23
|
||||||
|
|
||||||
|
networks:
|
||||||
|
- network_public
|
||||||
|
|
||||||
|
# El healthcheck consulta la base, no solo el puerto: un proceso vivo con
|
||||||
|
# Postgres caido no esta sano, y sin esto Swarm lo daria por bueno.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3100/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
# La variable se llama PLATFORM_PORT, no PORT: `PORT` es la del backend de
|
||||||
|
# demo de server/, que es otro proceso. Con la equivocada, el servidor
|
||||||
|
# escucharia en 3100 por defecto igualmente, pero por casualidad.
|
||||||
|
- PLATFORM_PORT=3100
|
||||||
|
- HOST=0.0.0.0
|
||||||
|
# UTC, NO America/Mexico_City.
|
||||||
|
#
|
||||||
|
# La agenda saca la zona de `businesses.timezone`, no del proceso, y las
|
||||||
|
# pruebas corren en UTC a proposito para que la del proceso y la del
|
||||||
|
# negocio nunca coincidan. Poner aqui la de Mexico haria que una recaida a
|
||||||
|
# la zona del proceso pasara desapercibida en produccion, que es
|
||||||
|
# exactamente el fallo que ya costo un incidente en este repo.
|
||||||
|
- TZ=UTC
|
||||||
|
|
||||||
|
# Postgres 14 compartido - host = nombre de servicio en network_public
|
||||||
|
# Se usa la URL completa del archivo de secretos, que New-E3Database.ps1 ya
|
||||||
|
# deja armada. El andamiaje generaba un marcador de solo-la-contrasena con
|
||||||
|
# otro nombre del que usa ese archivo, se quedaba sin resolver, y el
|
||||||
|
# servicio arrancaba sin poder conectar a Postgres.
|
||||||
|
- DATABASE_URL=<DATABASE_URL>
|
||||||
|
|
||||||
|
# Clave maestra con la que se cifran los tokens de subcuenta de cada
|
||||||
|
# negocio. Sin ella el servidor arranca pero NINGUNA credencial del CRM se
|
||||||
|
# puede descifrar. Se inyecta al desplegar; jamas va en este archivo.
|
||||||
|
- CRM_MASTER_KEY=<CRM_MASTER_KEY>
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
mode: replicated
|
||||||
|
replicas: 1
|
||||||
|
placement:
|
||||||
|
constraints:
|
||||||
|
- node.role == manager
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: "1"
|
||||||
|
memory: 512M
|
||||||
|
restart_policy:
|
||||||
|
condition: on-failure
|
||||||
|
delay: 10s
|
||||||
|
update_config:
|
||||||
|
order: start-first
|
||||||
|
failure_action: rollback
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=network_public
|
||||||
|
- traefik.http.routers.agendapro.rule=Host(`agendapro.consultoriae3.com`)
|
||||||
|
- traefik.http.routers.agendapro.entrypoints=websecure
|
||||||
|
- traefik.http.routers.agendapro.tls=true
|
||||||
|
- traefik.http.routers.agendapro.tls.certresolver=letsencryptresolver
|
||||||
|
- traefik.http.routers.agendapro.service=agendapro
|
||||||
|
- traefik.http.services.agendapro.loadbalancer.server.port=3100
|
||||||
|
- traefik.http.services.agendapro.loadbalancer.passHostHeader=1
|
||||||
|
|
||||||
|
networks:
|
||||||
|
network_public:
|
||||||
|
external: true
|
||||||
|
name: network_public
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,273 @@
|
|||||||
|
# Demo Email Domain Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Standardize demo accounts on `@agendamax.demo`, make every demo login explicit in the README, and migrate local and Coolify data without deleting records.
|
||||||
|
|
||||||
|
**Architecture:** Keep the current simple demo authentication and replace only the two platform-owned demo email constants plus their test fixtures. Apply one targeted, idempotent SQLite `UPDATE` to the exact existing admin and owner rows; appointments, businesses, and related records remain unchanged. Deploy the source through the existing GitHub mirror and verify the live app through health and login endpoints.
|
||||||
|
|
||||||
|
**Tech Stack:** React/Vite, Express, TypeScript, Node 22 `node:sqlite`, SQLite, PowerShell/OpenSSH, Coolify v4.1.2.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Keep the demo password exactly `demo1234`.
|
||||||
|
- Replace active `@agendapro.demo` references with `@agendamax.demo`; retain the old domain only in explicit negative-login assertions or migration-history documentation.
|
||||||
|
- Do not reset or delete local or production business, appointment, client, employee, ticket, or review data.
|
||||||
|
- Never commit `.env.local.ps1` or print token values.
|
||||||
|
- Production deployment must finish before the production data migration is run.
|
||||||
|
- If production deployment fails, stop before changing the production SQLite database and use the documented rollback path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Update Demo Identity References and Access Documentation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `README.md:66-82` — show email and password for every demo account.
|
||||||
|
- Modify: `server/index.ts:38-43` — use the new default owner email.
|
||||||
|
- Modify: `server/scripts/seed.ts:315-334` — use the new admin and owner emails for fresh databases.
|
||||||
|
- Modify: `src/pages/LoginPage.tsx:8-15` — use the new default development login.
|
||||||
|
- Modify: `admin-test.mjs`, `e2e-test.mjs`, `e2e-full.mjs`, `server/scripts/booking-e2e.mjs`, `visual-audit.mjs`, and any other active file returned by `rg -l 'agendapro\.demo'` — keep automated login fixtures aligned.
|
||||||
|
- Modify: active plan/report documents containing executable login examples; keep old-domain examples only as migration-history documentation or explicit negative-login assertions.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Fresh seed continues to create users with the existing schema and password.
|
||||||
|
- The login page continues to load `/api/auth/demo-users` and quick-login users; only the initial email constant changes.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Record the current active references and credentials**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
rg -n --glob '!node_modules' --glob '!data/**' 'agendapro\.demo|demo1234' .
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: references are limited to the known seed, login defaults, tests, README, explicit negative-login assertions, and migration-history documentation; no secret token values are printed.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update the README credentials table first**
|
||||||
|
|
||||||
|
The resulting table must explicitly show:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
| **Admin**| `[email protected]` | `demo1234` | Administrador |
|
||||||
|
| Dueño | `[email protected]` | `demo1234` | Daniela Reyes |
|
||||||
|
```
|
||||||
|
|
||||||
|
Every employee row must also include the same password in its password column. Keep the note that the login screen offers quick access.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update fresh-seed and UI constants**
|
||||||
|
|
||||||
|
Use these exact values:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// server/index.ts and server/scripts/seed.ts
|
||||||
|
ownerEmail: "[email protected]"
|
||||||
|
|
||||||
|
// server/scripts/seed.ts
|
||||||
|
VALUES (NULL, '[email protected]', 'demo1234', 'Administrador', 'admin', '#0f172a')
|
||||||
|
|
||||||
|
// src/pages/LoginPage.tsx
|
||||||
|
useState(DEMO ? "[email protected]" : "")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update all active automated fixtures**
|
||||||
|
|
||||||
|
Replace only the email domain in existing login assertions and fixtures. Do not change employee domains or passwords.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify the source tree has no stale active domain**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
rg -n --glob '!node_modules' --glob '!data/**' --glob '!docs/superpowers/specs/**' 'agendapro\.demo' .
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no output from application code or positive fixtures; the intentional old-domain negative-login assertions and migration-history documents are the only allowed matches.
|
||||||
|
|
||||||
|
### Task 2: Migrate the Local SQLite Demo Users
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: local ignored database `data/agendapro.db` only; no tracked database files.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Uses the existing `server/db.ts` connection and `users` table.
|
||||||
|
- Produces updated exact admin/owner email rows while preserving all row counts outside `users.email`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Confirm the local database and take a count snapshot**
|
||||||
|
|
||||||
|
Run from the repository root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Test-Path -LiteralPath .\data\agendapro.db
|
||||||
|
node --import tsx --input-type=module -e "import { db } from './server/db.ts'; console.log(JSON.stringify({users: db.prepare('SELECT id,email,role FROM users ORDER BY id').all(), appointments: db.prepare('SELECT COUNT(*) AS c FROM appointments').get(), clients: db.prepare('SELECT COUNT(*) AS c FROM clients').get()}));"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: the local database exists or the command reports that a fresh seed is required; existing appointment/client counts are recorded before mutation.
|
||||||
|
|
||||||
|
Before updating, inspect the exact old/new admin and owner rows from the `users`
|
||||||
|
snapshot. For either role, if its target email already exists while its matching
|
||||||
|
old-domain row also exists, stop and resolve that unique-email collision instead
|
||||||
|
of running the update. If no old-domain rows exist and the new rows are present,
|
||||||
|
record a safe no-op and skip the update.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Apply the idempotent local email update**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node --% --import tsx --input-type=module -e "import { db } from './server/db.ts'; const r = db.prepare(\"UPDATE users SET email = CASE email WHEN '[email protected]' THEN '[email protected]' WHEN '[email protected]' THEN '[email protected]' END WHERE (email = '[email protected]' AND role = 'admin') OR (email = '[email protected]' AND role = 'owner')\").run(); console.log(JSON.stringify(r));"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: only the exact email/role pairs are changed; an old-domain employee or
|
||||||
|
other role is untouched. Running the same command again reports zero additional
|
||||||
|
changes.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify local credentials and preservation**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node --% --import tsx --input-type=module -e "import { db } from './server/db.ts'; console.log(JSON.stringify({demo: db.prepare(\"SELECT email,role,password FROM users WHERE email IN ('[email protected]','[email protected]') ORDER BY role\").all(), appointments: db.prepare('SELECT COUNT(*) AS c FROM appointments').get(), clients: db.prepare('SELECT COUNT(*) AS c FROM clients').get()}));"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `[email protected]` and `[email protected]` use `demo1234`; appointment and client counts match the snapshot.
|
||||||
|
|
||||||
|
### Task 3: Run Local Regression Checks
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- No additional files; validate Task 1 and Task 2 together.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Existing package scripts are the regression contract: typecheck, build, unit tests, and API tests.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run typecheck and build**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run typecheck
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both commands exit with code 0.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run unit and admin/API tests**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run test:unit
|
||||||
|
npm run test:admin
|
||||||
|
npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: typecheck, build, unit, admin, and non-slot authentication checks pass. On the
|
||||||
|
already-migrated local database, `businesses.working_hours` and employee
|
||||||
|
`working_hours` are null, so the slot-dependent e2e/booking assertions may fail
|
||||||
|
before booking (including their existing downstream dereference); record this
|
||||||
|
pre-existing limitation as unrelated to the email change and do not change
|
||||||
|
scheduling data for this task.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Inspect the final diff**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git diff --check
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: only intended source, test, README, and specification/plan files are changed; `.env.local.ps1` and `data/agendapro.db` remain ignored.
|
||||||
|
|
||||||
|
### Task 4: Deploy AgendaMax Through Coolify
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: the tracked AgendaPro source files from Tasks 1-3 through a normal commit.
|
||||||
|
- Do not modify: `.env.local.ps1`, local SQLite files, or Coolify credentials in Git.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Source mirror: the existing GitHub remote used by Coolify (`urieljarethbusiness-cpu/agendamax`).
|
||||||
|
- Coolify application: UUID `s30f7egdlkx4wyjp59o1iunc`, FQDN `https://agendamax.urieljareth.org`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Commit only the intended tracked changes**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git add README.md server/index.ts server/scripts/seed.ts src/pages/LoginPage.tsx admin-test.mjs e2e-test.mjs e2e-full.mjs server/scripts/booking-e2e.mjs visual-audit.mjs docs/superpowers/specs/2026-07-27-demo-email-domain-design.md docs/superpowers/plans/2026-07-27-demo-email-domain.md
|
||||||
|
git diff --cached --check
|
||||||
|
git commit -m "docs: standardize AgendaMax demo access"
|
||||||
|
```
|
||||||
|
|
||||||
|
Add any additional active fixture files found by Task 1 explicitly; never use `git add .`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Push the source mirror used by Coolify**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git push github main
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: the new commit is available in the GitHub mirror without exposing credentials in output.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Trigger and monitor the Coolify deploy**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
. .\.env.local.ps1
|
||||||
|
$h = @{ Authorization = "Bearer $env:COOLIFY_TOKEN" }
|
||||||
|
$result = Invoke-RestMethod "$env:COOLIFY_API_URL/deploy?uuid=s30f7egdlkx4wyjp59o1iunc&force=true" -Headers $h
|
||||||
|
$deployment = $result.deployments[0].deployment_uuid
|
||||||
|
Invoke-RestMethod "$env:COOLIFY_API_URL/deployments/$deployment" -Headers $h
|
||||||
|
```
|
||||||
|
|
||||||
|
Poll until the status is `finished`; do not run the production SQL migration while it is queued, in progress, or failed.
|
||||||
|
|
||||||
|
### Task 5: Migrate and Verify Production Data
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: persistent SQLite database inside the running AgendaMax Coolify app container only.
|
||||||
|
- Do not modify: source files or unrelated production tables.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Production app data path: `/app/data/agendapro.db`.
|
||||||
|
- Proxmox access: host `192.168.0.200`, LXC `102`; use the existing `scripts/Invoke-ProxmoxSsh.ps1`/`ProxmoxAgent.ps1` helpers and environment variables.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Identify the running AgendaMax app container and verify the old rows**
|
||||||
|
|
||||||
|
Use the existing Proxmox SSH chain to run inside LXC 102:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
container=$(docker ps --format '{{.ID}} {{.Names}}' | awk '$2 ~ /agendamax|s30f7/ {print $1; exit}')
|
||||||
|
test -n "$container" || { echo 'AgendaMax container not found' >&2; exit 1; }
|
||||||
|
docker exec "$container" node -e "const {DatabaseSync}=require('node:sqlite'); const db=new DatabaseSync('/app/data/agendapro.db'); console.log(db.prepare(\"SELECT id,email,role FROM users WHERE email IN ('[email protected]','[email protected]','[email protected]','[email protected]') ORDER BY id\").all())"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: the app is running the new image and the current exact admin/owner
|
||||||
|
rows are visible before mutation. If an old row and its matching new row both
|
||||||
|
exist, stop and resolve the unique-email collision before updating.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Apply the production email update**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker exec "$container" node -e "const {DatabaseSync}=require('node:sqlite'); const db=new DatabaseSync('/app/data/agendapro.db'); const r=db.prepare(\"UPDATE users SET email=CASE email WHEN '[email protected]' THEN '[email protected]' WHEN '[email protected]' THEN '[email protected]' END WHERE (email='[email protected]' AND role='admin') OR (email='[email protected]' AND role='owner')\").run(); console.log(r)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: only the matching admin and owner rows move to `@agendamax.demo`; no
|
||||||
|
old-domain employee or other role is rewritten, and no business or appointment
|
||||||
|
rows are deleted.
|
||||||
|
|
||||||
|
If the preflight query shows an old row alongside its matching new row, stop and
|
||||||
|
resolve the unique-email collision. If it shows no old-domain rows because the
|
||||||
|
deployment seeded a fresh database with the new domain, record the update as a
|
||||||
|
safe no-op and skip it.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify live health and credentials**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$health = Invoke-RestMethod https://agendamax.urieljareth.org/api/health
|
||||||
|
$admin = Invoke-RestMethod https://agendamax.urieljareth.org/api/auth/login -Method Post -ContentType 'application/json' -Body '{"email":"[email protected]","password":"demo1234"}'
|
||||||
|
$owner = Invoke-RestMethod https://agendamax.urieljareth.org/api/auth/login -Method Post -ContentType 'application/json' -Body '{"email":"[email protected]","password":"demo1234"}'
|
||||||
|
Write-Host "health_ok=$($health.ok) admin_role=$($admin.user.role) admin_token_present=$([bool]$admin.token) owner_role=$($owner.user.role) owner_token_present=$([bool]$owner.token)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: health returns `ok: true`; both logins return the correct roles. Only boolean token-presence values are printed, never token contents.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify the deployed page and repository state**
|
||||||
|
|
||||||
|
Run the existing browser/HTTP post-deploy check if available, then:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: the deployed AgendaMax page loads, no uncaught frontend errors are reported, and only intentional local follow-up changes remain.
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
# AgendaMax PWA Installability Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Make AgendaMax installable as a web app on Android, iOS, and compatible desktop browsers without adding offline writes or changing the existing API/authentication model.
|
||||||
|
|
||||||
|
**Architecture:** Add a hand-maintained web manifest and service worker under `public/`, register the worker only in production, and provide a reusable React install prompt. Chromium uses `beforeinstallprompt`; iOS Safari receives explicit Share -> Add to Home Screen instructions. The worker caches only the app shell/static assets and bypasses all API and non-GET requests.
|
||||||
|
|
||||||
|
**Tech Stack:** React 18, TypeScript, Vite 5, Tailwind CSS, Express static hosting, Playwright, Node.js 22.5+.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Use a manually maintained manifest and service worker; do not add `vite-plugin-pwa`.
|
||||||
|
- Keep the data model online-first; never cache `/api` responses or queue offline writes.
|
||||||
|
- Keep AgendaMax's existing Spanish visual language and make installation a secondary action.
|
||||||
|
- Production installation requires HTTPS; `localhost` is valid for local testing.
|
||||||
|
- Do not change authentication, tenant isolation, API routes, or database schema.
|
||||||
|
- Preserve the existing `public/favicon.svg` and brand color `#3b66ff`.
|
||||||
|
- Keep service-worker registration failure non-fatal and log it only in development.
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
- Create `public/manifest.webmanifest`: browser install metadata and icon declarations.
|
||||||
|
- Create `public/sw.js`: versioned shell/static-asset cache with API bypass.
|
||||||
|
- Create `public/icon-192.png` and `public/icon-512.png`: install icons derived from the existing favicon mark.
|
||||||
|
- Create `scripts/generate-pwa-icons.mjs`: reproducible local icon generation using the existing Playwright dependency.
|
||||||
|
- Create `src/lib/useInstallPrompt.ts`: browser capability detection and deferred install event lifecycle.
|
||||||
|
- Create `src/components/InstallAppPrompt.tsx`: install button and iOS instruction modal.
|
||||||
|
- Create `pwa-e2e.mjs`: production-server PWA endpoint and browser-behavior checks.
|
||||||
|
- Modify `index.html`: manifest link, iOS metadata, and safe-area viewport setting.
|
||||||
|
- Modify `src/main.tsx`: production service-worker registration.
|
||||||
|
- Modify `src/index.css`: safe-area utility for the install dialog.
|
||||||
|
- Modify `src/pages/LoginPage.tsx`, `src/components/AppShell.tsx`, and `src/components/AdminShell.tsx`: render the reusable install action in the existing UI surfaces.
|
||||||
|
- Modify `package.json`: icon-generation and PWA test scripts.
|
||||||
|
- Modify `README.md`: document install behavior, HTTPS, and validation commands.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add Static PWA Metadata and Icons
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `scripts/generate-pwa-icons.mjs`
|
||||||
|
- Create: `public/icon-192.png`
|
||||||
|
- Create: `public/icon-512.png`
|
||||||
|
- Create: `public/manifest.webmanifest`
|
||||||
|
- Modify: `index.html:5-12`
|
||||||
|
- Modify: `package.json:7-25`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces `/manifest.webmanifest`, `/icon-192.png`, and `/icon-512.png` in the Vite output.
|
||||||
|
- Keeps `/favicon.svg` as the browser-tab icon.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add a reproducible icon generator.**
|
||||||
|
|
||||||
|
Create `scripts/generate-pwa-icons.mjs` using the already-installed Playwright package. It must load `public/favicon.svg` as a data URL, render it on a white 1:1 page, and screenshot exactly 192x192 and 512x512 PNG files:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const svg = await fs.readFile(path.join(root, "public", "favicon.svg"), "utf8");
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const size of [192, 512]) {
|
||||||
|
const page = await browser.newPage({ viewport: { width: size, height: size }, deviceScaleFactor: 1 });
|
||||||
|
await page.setContent(`<!doctype html><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#fff}svg{display:block;width:100%;height:100%}</style>${svg}`);
|
||||||
|
await page.screenshot({ path: path.join(root, "public", `icon-${size}.png`), type: "png" });
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the generator and verify PNG dimensions.**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```text
|
||||||
|
node scripts/generate-pwa-icons.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `public/icon-192.png` and `public/icon-512.png` exist. Verify their PNG signature and dimensions with a short Node check before continuing; do not substitute SVG files for the required PNG icons.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the manifest.**
|
||||||
|
|
||||||
|
Create `public/manifest.webmanifest` with this exact contract:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "AgendaMax",
|
||||||
|
"short_name": "AgendaMax",
|
||||||
|
"description": "Gestión visual de citas, empleados e ingresos para tu negocio.",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"background_color": "#f6f7fb",
|
||||||
|
"theme_color": "#3b66ff",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||||
|
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update HTML metadata and package scripts.**
|
||||||
|
|
||||||
|
In `index.html`, keep the existing favicon/theme/description and add:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="AgendaMax" />
|
||||||
|
```
|
||||||
|
|
||||||
|
Change the viewport content to include `viewport-fit=cover` while preserving the current scale values. Add these scripts to `package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"generate:pwa-icons": "node scripts/generate-pwa-icons.mjs",
|
||||||
|
"test:pwa": "node pwa-e2e.mjs"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build and inspect static output.**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm.cmd run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: exit code 0 and `dist/manifest.webmanifest`, `dist/icon-192.png`, and `dist/icon-512.png` exist. The worker is added in Task 2, so do not treat its absence as a failure in this task.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit the static PWA contract.**
|
||||||
|
|
||||||
|
```text
|
||||||
|
git add package.json index.html public/manifest.webmanifest public/icon-192.png public/icon-512.png scripts/generate-pwa-icons.mjs
|
||||||
|
git commit -m "feat: add AgendaMax PWA metadata and icons"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Add the Production Service Worker
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `public/sw.js`
|
||||||
|
- Modify: `src/main.tsx:18-25`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Browser loads `/sw.js` from the same origin in production.
|
||||||
|
- The worker owns only shell/static caching; API requests remain network-only.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the service worker with explicit routing.**
|
||||||
|
|
||||||
|
Create `public/sw.js` with a versioned cache and these rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const CACHE_NAME = "agendamax-shell-v1";
|
||||||
|
const SHELL_URLS = ["/", "/manifest.webmanifest", "/favicon.svg", "/icon-192.png", "/icon-512.png"];
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_URLS)).then(() => self.skipWaiting())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys()
|
||||||
|
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))))
|
||||||
|
.then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (request.method !== "GET" || url.origin !== self.location.origin || url.pathname.startsWith("/api/")) return;
|
||||||
|
|
||||||
|
if (request.mode === "navigate") {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(request)
|
||||||
|
.then((response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
const copy = response.clone();
|
||||||
|
void caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match("/"))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheableDestination = new Set(["script", "style", "image", "font", "manifest", "worker"]);
|
||||||
|
if (!cacheableDestination.has(request.destination)) return;
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(request).then((cached) => {
|
||||||
|
if (cached) return cached;
|
||||||
|
return fetch(request).then((response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
const copy = response.clone();
|
||||||
|
void caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The worker must not add a catch-all cache path, intercept non-GET requests, or cache requests whose path starts with `/api/`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Register the worker only in production.**
|
||||||
|
|
||||||
|
Append this registration after the React root render in `src/main.tsx`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
if (import.meta.env.PROD && "serviceWorker" in navigator) {
|
||||||
|
window.addEventListener("load", () => {
|
||||||
|
void navigator.serviceWorker
|
||||||
|
.register("/sw.js", { updateViaCache: "none" })
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (import.meta.env.DEV) console.warn("AgendaMax service worker registration failed", error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The registration failure is non-fatal and must never reject or delay React startup; the development-only warning is retained for local diagnostics if the environment condition is changed during debugging.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Validate worker behavior statically.**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm.cmd run typecheck
|
||||||
|
npm.cmd run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both exit 0, and `dist/sw.js` exists because Vite copies `public/sw.js`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit the worker.**
|
||||||
|
|
||||||
|
```text
|
||||||
|
git add public/sw.js src/main.tsx
|
||||||
|
git commit -m "feat: add production PWA service worker"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Add Install Detection and UI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/lib/useInstallPrompt.ts`
|
||||||
|
- Create: `src/components/InstallAppPrompt.tsx`
|
||||||
|
- Modify: `src/index.css:228-322`
|
||||||
|
- Modify: `src/pages/LoginPage.tsx:1-6` and rendered layout
|
||||||
|
- Modify: `src/components/AppShell.tsx:1-18` and mobile header
|
||||||
|
- Modify: `src/components/AdminShell.tsx:1-14` and mobile header
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- `useInstallPrompt(): { state: InstallPromptState; install: () => Promise<void>; dismiss: () => void }`.
|
||||||
|
- `InstallPromptState` is exactly `"unsupported" | "available" | "ios-instructions" | "installed"`.
|
||||||
|
- `<InstallAppPrompt />` renders nothing for `unsupported` or `installed` and owns the iOS `Modal` lifecycle.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Define the deferred prompt type and write the hook contract.**
|
||||||
|
|
||||||
|
Create `src/lib/useInstallPrompt.ts` with a local event type instead of adding an unsafe global declaration:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export type InstallPromptState = "unsupported" | "available" | "ios-instructions" | "installed";
|
||||||
|
|
||||||
|
interface BeforeInstallPromptEvent extends Event {
|
||||||
|
prompt: () => Promise<void>;
|
||||||
|
userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStandalone() {
|
||||||
|
return window.matchMedia("(display-mode: standalone)").matches ||
|
||||||
|
("standalone" in navigator && Boolean((navigator as Navigator & { standalone?: boolean }).standalone));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIOSSafari() {
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
const ios = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||||
|
return ios && /Safari/i.test(ua) && !/CriOS|FxiOS|EdgiOS|OPiOS/i.test(ua);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInstallPrompt() {
|
||||||
|
const [state, setState] = useState<InstallPromptState>(() => {
|
||||||
|
if (typeof window === "undefined") return "unsupported";
|
||||||
|
if (isStandalone()) return "installed";
|
||||||
|
return isIOSSafari() ? "ios-instructions" : "unsupported";
|
||||||
|
});
|
||||||
|
const [deferred, setDeferred] = useState<BeforeInstallPromptEvent | null>(null);
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isStandalone()) {
|
||||||
|
setState("installed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const onBeforeInstallPrompt = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setDeferred(event as BeforeInstallPromptEvent);
|
||||||
|
setState("available");
|
||||||
|
};
|
||||||
|
const onInstalled = () => {
|
||||||
|
setDeferred(null);
|
||||||
|
setState("installed");
|
||||||
|
};
|
||||||
|
window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt);
|
||||||
|
window.addEventListener("appinstalled", onInstalled);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt);
|
||||||
|
window.removeEventListener("appinstalled", onInstalled);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const install = async () => {
|
||||||
|
if (!deferred) return;
|
||||||
|
const event = deferred;
|
||||||
|
setDeferred(null);
|
||||||
|
await event.prompt();
|
||||||
|
const choice = await event.userChoice;
|
||||||
|
if (choice.outcome === "accepted") setState("installed");
|
||||||
|
else setDismissed(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { state: dismissed ? "unsupported" : state, install, dismiss: () => setDismissed(true) };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the hook browser-only and ensure event listeners are removed on unmount. The iOS state is intentionally limited to Safari; unsupported browsers remain normal web pages.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build the reusable prompt component.**
|
||||||
|
|
||||||
|
Create `src/components/InstallAppPrompt.tsx` using `useInstallPrompt`, `Modal`, and `Download`, `Share`, and `PlusSquare` icons from `lucide-react`. The component must:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export function InstallAppPrompt() {
|
||||||
|
const { state, install } = useInstallPrompt();
|
||||||
|
const [iosOpen, setIosOpen] = useState(false);
|
||||||
|
|
||||||
|
if (state === "unsupported" || state === "installed") return null;
|
||||||
|
if (state === "ios-instructions") {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button type="button" className="btn btn-secondary w-full justify-start" onClick={() => setIosOpen(true)}>
|
||||||
|
<Download className="h-4 w-4" /> Instalar AgendaMax
|
||||||
|
</button>
|
||||||
|
<Modal open={iosOpen} onClose={() => setIosOpen(false)} title="Instalar AgendaMax" subtitle="Safari lo añade a tu pantalla de inicio.">
|
||||||
|
<div className="safe-area-bottom">
|
||||||
|
<ol className="space-y-3 text-sm text-slate-600">
|
||||||
|
<li className="flex gap-3"><Share className="mt-0.5 h-5 w-5 shrink-0 text-brand-600" /><span>Toca <strong>Compartir</strong> en la barra de Safari.</span></li>
|
||||||
|
<li className="flex gap-3"><PlusSquare className="mt-0.5 h-5 w-5 shrink-0 text-brand-600" /><span>Elige <strong>Añadir a pantalla de inicio</strong> y confirma.</span></li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <button type="button" className="btn btn-secondary w-full justify-start" onClick={() => void install()}><Download className="h-4 w-4" /> Instalar AgendaMax</button>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The final component may use `aria-label`/`aria-labelledby` as needed, but must keep the exact accessible action name `/Instalar AgendaMax/`, close the iOS modal on backdrop/Escape through the existing `Modal`, and never show an install CTA after standalone detection.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add safe-area styling.**
|
||||||
|
|
||||||
|
Append a focused utility to `src/index.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.safe-area-bottom {
|
||||||
|
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply it to the iOS modal content/footer surface only; do not change the global page height or reserve a permanent bottom band.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Integrate one visible action per page shell.**
|
||||||
|
|
||||||
|
Import `InstallAppPrompt` and render it once in each of these existing surfaces:
|
||||||
|
|
||||||
|
- `LoginPage`: immediately below the login card's submit area, using a compact centered container so it does not affect the desktop brand panel.
|
||||||
|
- `AppShell`: in the existing mobile header beside the AgendaMax wordmark/menu controls; keep it hidden on large screens with the same `lg:hidden` responsive convention.
|
||||||
|
- `AdminShell`: in the existing mobile header beside the Admin wordmark/menu controls; keep it hidden on large screens with the same `lg:hidden` convention.
|
||||||
|
|
||||||
|
Do not put the component inside `SidebarContent`, because that JSX is rendered once for desktop and again when the mobile drawer opens. One instance per shell avoids duplicate deferred prompt listeners.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify TypeScript and responsive rendering.**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm.cmd run typecheck
|
||||||
|
npm.cmd run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: exit code 0 for both. Confirm the app still loads the login page and both authenticated shells without console errors.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit the install UI.**
|
||||||
|
|
||||||
|
```text
|
||||||
|
git add src/lib/useInstallPrompt.ts src/components/InstallAppPrompt.tsx src/index.css src/pages/LoginPage.tsx src/components/AppShell.tsx src/components/AdminShell.tsx
|
||||||
|
git commit -m "feat: add cross-platform PWA install prompt"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Add PWA Verification and Production Documentation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `pwa-e2e.mjs`
|
||||||
|
- Modify: `README.md:47-64`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- `npm run test:pwa` checks a built production server at `PWA_BASE_URL` or `http://localhost:3000`.
|
||||||
|
- The check exits non-zero on missing assets, invalid manifest fields, unexpected API interception, or UI behavior failures.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add endpoint and manifest assertions.**
|
||||||
|
|
||||||
|
In `pwa-e2e.mjs`, fetch the base URL and assert status 200 for `/manifest.webmanifest`, `/sw.js`, `/icon-192.png`, and `/icon-512.png`. Parse the manifest and assert `display === "standalone"`, `start_url === "/"`, `scope === "/"`, and both declared icon sizes. Fetch a known SPA route such as `/calendar` and assert it returns the built HTML rather than a 404. Fetch `/api/health` and assert it remains a JSON API response.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add browser install-event checks.**
|
||||||
|
|
||||||
|
Use Playwright Chromium with `BASE = process.env.PWA_BASE_URL || "http://localhost:3000"`. Add an init script that dispatches a cancelable `beforeinstallprompt` event after load and records `prompt()` calls:
|
||||||
|
|
||||||
|
```js
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.__installPromptCalls = 0;
|
||||||
|
setTimeout(() => {
|
||||||
|
const event = new Event("beforeinstallprompt", { cancelable: true });
|
||||||
|
event.prompt = async () => { window.__installPromptCalls += 1; };
|
||||||
|
event.userChoice = Promise.resolve({ outcome: "accepted", platform: "web" });
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
On `/`, assert the accessible `Instalar AgendaMax` action becomes visible, click it, and assert `window.__installPromptCalls === 1`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add iOS and standalone checks.**
|
||||||
|
|
||||||
|
Create a second context with an iPhone Safari user agent. Before page scripts run, define `Navigator.prototype.standalone` as `false`; assert the install action is visible, click it, and assert the modal contains `Compartir` and `Añadir a pantalla de inicio`. Create a third context whose `matchMedia` returns `matches: true` for `display-mode: standalone`; assert no `Instalar AgendaMax` action is visible. Create a fourth normal context without an install event and assert no install action is visible.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add service-worker/API safety checks.**
|
||||||
|
|
||||||
|
After loading the production page, wait for `navigator.serviceWorker.ready` with a bounded timeout. Inspect the worker source returned by `/sw.js` and assert it contains the `/api/` bypass and `request.method !== "GET"` guard. Use Playwright request listeners to confirm normal page/API loading still reaches `/api/health`; do not use a cached fixture as a substitute for the real API response.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Document local and production validation.**
|
||||||
|
|
||||||
|
Add a `## Instalar AgendaMax como app` section to `README.md` after the production commands:
|
||||||
|
|
||||||
|
- Android/Chrome: open the HTTPS URL and select the install action shown by AgendaMax or the browser address bar.
|
||||||
|
- iPhone/iPad Safari: use Share -> Add to Home Screen; iOS does not expose Android's in-page install prompt.
|
||||||
|
- The installed shell can be reopened without browser chrome, but business data still requires an internet connection.
|
||||||
|
- Local validation: run `npm.cmd run build`, start production with `npm.cmd start`, then run `npm.cmd run test:pwa`.
|
||||||
|
- Production validation requires the Coolify HTTPS domain, not an HTTP IP address.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run the complete verification set.**
|
||||||
|
|
||||||
|
With a built production server running on port 3000, run:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm.cmd run typecheck
|
||||||
|
npm.cmd run build
|
||||||
|
npm.cmd run test:pwa
|
||||||
|
npm.cmd run audit:visual
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: typecheck, build, PWA checks, and visual audit exit 0; `git diff --check` reports no whitespace errors. If the pre-existing scheduling data causes API booking assertions in unrelated suites, record that limitation without weakening PWA checks.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit verification and docs.**
|
||||||
|
|
||||||
|
```text
|
||||||
|
git add pwa-e2e.mjs README.md
|
||||||
|
git commit -m "test: verify AgendaMax PWA installation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final Review Checklist
|
||||||
|
|
||||||
|
- [ ] `manifest.webmanifest` has valid name, scope, start URL, standalone display, theme/background colors, and 192/512 PNG icons.
|
||||||
|
- [ ] `index.html` includes manifest, iOS metadata, `apple-touch-icon`, and `viewport-fit=cover`.
|
||||||
|
- [ ] `sw.js` is copied into `dist`, updates by version, falls back only for navigation, and bypasses `/api` and non-GET requests.
|
||||||
|
- [ ] Service-worker registration runs only in production and cannot block React startup.
|
||||||
|
- [ ] Android/Chromium install prompt, iOS instructions, unsupported browser, dismissed prompt, and standalone states are covered.
|
||||||
|
- [ ] Login, business mobile shell, and admin mobile shell each have one install action instance.
|
||||||
|
- [ ] No authentication, tenant data, API routes, database schema, or offline writes changed.
|
||||||
|
- [ ] `npm run typecheck`, `npm run build`, `npm run test:pwa`, and the visual audit have evidence before claiming completion.
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,190 @@
|
|||||||
|
# Auto-asignación de especialista + rediseño de la página de reservas
|
||||||
|
|
||||||
|
**Fecha:** 2026-07-26
|
||||||
|
**Estado:** Borrador para revisión del usuario
|
||||||
|
**Alcance:** Página pública de reservas (`/b/:slug`) + sección de Configuración + modelo de datos
|
||||||
|
|
||||||
|
## 1. Objetivo
|
||||||
|
|
||||||
|
1. Añadir en Configuración un check **"Asignar especialista automáticamente"** que, al activarse, **omite el paso de elección de especialista** para el cliente en la URL pública de reservas.
|
||||||
|
2. La asignación se resuelve con un **algoritmo matemático** que considera especialidad, eficiencia y —sobre todo— **disponibilidad**, garantizando que **un especialista nunca atienda a dos clientes a la vez** (sin traslape con citas ya agendadas).
|
||||||
|
3. En "Elige fecha y hora", reemplazar el `<input type="date">` nativo por un **calendario de mes siempre visible**.
|
||||||
|
4. En **escritorio**, dividir "Elige fecha y hora" en **dos columnas** (calendario + horarios) para aprovechar el ancho de pantalla; en móvil se mantiene el stack actual.
|
||||||
|
5. **Colores indicativos llamativos** (alto contraste, mayor tamaño) para ayudar a usuarios mayores de 50, **manteniendo** el estilo moderno/fresco de AgendaMax y **sin alterar** los bloques de horario actuales.
|
||||||
|
6. **Fix:** iconos de "Tus datos" (nombre/teléfono/correo) que se solapan con el placeholder gris.
|
||||||
|
|
||||||
|
## 2. No-objetivos (YAGNI)
|
||||||
|
|
||||||
|
- Pasarela de pago / depósitos (`require_deposit` queda sin aplicar).
|
||||||
|
- Política de cancelación automatizada (fuera de alcance aquí).
|
||||||
|
- Recurrencia de citas, descansos/almuerzos puntuales.
|
||||||
|
- Vista de disponibilidad tipo FullCalendar en el flujo público (se usa una grilla de mes propia, más simple y enfocada).
|
||||||
|
|
||||||
|
## 3. Arquitectura
|
||||||
|
|
||||||
|
Nuevo módulo **`server/lib/scheduling.ts`** con funciones puras y testeables que centralizan toda la "data science":
|
||||||
|
|
||||||
|
- `getWorkingHours(business, employee|null, date) → { start, end } | null` (resuelve horario: empleado → si no, negocio; si el día es `null`, fuera de servicio).
|
||||||
|
- `isAvailable(db, employeeId, startIso, endIso) → boolean` (verifica traslape contra citas `(scheduled, completed)`).
|
||||||
|
- `computeOccupiedMinutes(db, employeeId, date) → number` (para load balance).
|
||||||
|
- `specialtyMatch(employee, service) → 0..1`.
|
||||||
|
- `scoreCandidate(ctx) → { score, reasons }`.
|
||||||
|
- `autoAssign(db, { business, service, startIso, endIso }) → { employeeId, score, reasons } | null` (filtra por hard constraints, luego scorea).
|
||||||
|
|
||||||
|
Se descarta una versión SQL-pura (window functions): el scoring ponderado es más legible y mantenible en TypeScript y el número de especialistas por negocio es pequeño (cabe en memoria). El módulo es importado por `server/routes/booking.ts` y `server/routes/appointments.ts`.
|
||||||
|
|
||||||
|
## 4. Modelo de datos (migración V3 → V4)
|
||||||
|
|
||||||
|
Función `migrateV3ToV4` en `server/db.ts` (sigue el patrón de `migrateV2ToV3`).
|
||||||
|
|
||||||
|
### Tabla `businesses` — 2 columnas
|
||||||
|
| Columna | Tipo | Default | Notas |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `auto_assign_specialist` | `INTEGER NOT NULL` | `0` | El check que omite el paso de especialista. |
|
||||||
|
| `working_hours` | `TEXT` (JSON) | ver backfill | Horario default del negocio por día. |
|
||||||
|
|
||||||
|
Formato JSON: `{"1":{"start":"09:00","end":"20:00"}, "2":{...}, ..., "6":null, "7":null}` (1=Lun … 7=Dom; `null` = cerrado).
|
||||||
|
|
||||||
|
### Tabla `employees` — 3 columnas
|
||||||
|
| Columna | Tipo | Default | Notas |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `specialties` | `TEXT` (JSON array) | `'[]'` | Etiquetas: `["Corte","Barba"]`. Se comparan con `service.category` y tokens de `service.name`. |
|
||||||
|
| `working_hours` | `TEXT` (JSON) | `NULL` | Horario individual; `NULL` = hereda el del negocio. |
|
||||||
|
| `efficiency_score` | `REAL NOT NULL` | `50` | 0–100. Lo captura el dueño. |
|
||||||
|
|
||||||
|
### Backfill (en la migración)
|
||||||
|
- `auto_assign_specialist = 0` para todos.
|
||||||
|
- `businesses.working_hours` = `{"1":{"start":"09:00","end":"20:00"},...,"5":{...},"6":null,"7":null}` (replica el `09:00–20:00` Lun–Vie actualmente hardcodeado en `booking.ts:57-58`).
|
||||||
|
- `employees.specialties = '[]'`, `employees.working_hours = NULL`, `employees.efficiency_score = 50`.
|
||||||
|
|
||||||
|
### Tipos (`shared/types.ts`)
|
||||||
|
- `Business` añade `auto_assign_specialist: boolean`, `working_hours: WorkingHours | null`.
|
||||||
|
- `Employee` añade `specialties: string[]`, `working_hours: WorkingHours | null`, `efficiency_score: number`.
|
||||||
|
- `WorkingHours` = `Record<1..7, { start: string; end: string } | null>`.
|
||||||
|
|
||||||
|
### Settings API (`server/routes/settings.ts`)
|
||||||
|
- La lista blanca (`settings.ts:8-20`) añade: `auto_assign_specialist`, `working_hours`.
|
||||||
|
|
||||||
|
## 5. Algoritmo de auto-asignación (la "data science")
|
||||||
|
|
||||||
|
Restricción dura + score ponderado, para una cita candidata (servicio `S`, intervalo `[t0, t1)`):
|
||||||
|
|
||||||
|
### 5.1 Hard constraints — candidato válido si:
|
||||||
|
1. El empleado está `active = 1`.
|
||||||
|
2. Ofrece el servicio: existe fila en `employee_services(employee_id, service_id)`.
|
||||||
|
3. `[t0, t1)` cae **dentro** de su horario laboral ese día (`getWorkingHours`). Si el día es cerrado → descartado.
|
||||||
|
4. **No traslapa** ninguna cita existente con `status IN ('scheduled','completed')`: para toda cita `c`, es falso que `t0 < c.end_at && t1 > c.start_at` (`isAvailable`).
|
||||||
|
|
||||||
|
→ Garantiza que **un especialista jamás atiende a dos personas a la vez**.
|
||||||
|
|
||||||
|
### 5.2 Score (0–100) de cada candidato válido
|
||||||
|
```
|
||||||
|
score = 50 * specialtyMatch + 30 * efficiency + 20 * (1 - loadBalance)
|
||||||
|
```
|
||||||
|
- **specialtyMatch (0..1):** `1.0` si una etiqueta de `employee.specialties` coincide (normalizado: lowercase, sin acentos, match por token/subcadena) con `service.category` o un token de `service.name`; `0.5` si ofrece el servicio (paso 2) pero sin coincidencia de etiqueta.
|
||||||
|
- **efficiency (0..1):** `employee.efficiency_score / 100`.
|
||||||
|
- **loadBalance (0..1):** `min(1, ocupadoHoy / laborableHoy)`, donde `ocupadoHoy = computeOccupiedMinutes` (suma de duraciones de citas del día) y `laborableHoy = minutos del turno ese día`. A menor carga → mayor `(1 - loadBalance)` → premia reparto justo.
|
||||||
|
|
||||||
|
### 5.3 Desempate y resultado
|
||||||
|
- Mayor `score` gana. Empate → menor `id` (determinista).
|
||||||
|
- `autoAssign` devuelve `{ employeeId, score, reasons: string[] }` para auditoría/UI.
|
||||||
|
|
||||||
|
### 5.4 Pesos
|
||||||
|
`specialty 50 / efficiency 30 / load 20`. Disponibilidad es **restricción dura** (peso infinito): nunca se viola.
|
||||||
|
|
||||||
|
## 6. Guard anti double-booking (transaccional)
|
||||||
|
|
||||||
|
Hoy `POST /api/public/:slug/book` (`booking.ts:100-153`) y `POST /api/appointments` (`appointments.ts:75-167`) insertan **sin re-verificar** conflictos (race condition). Fix:
|
||||||
|
|
||||||
|
- Envolver la creación en una **transacción** de SQLite (`db.exec('BEGIN IMMEDIATE')` o el equivalente de `node:sqlite`).
|
||||||
|
- Antes del `INSERT`, llamar a `isAvailable(employeeId, t0, t1)`.
|
||||||
|
- Si ya no está libre → **409 Conflict** con `{ error: "ESE_HORARIO_OCUPADO" }`. El cliente público ve "Esa hora acaba de ocuparse, elige otra" y vuelve al paso de horario.
|
||||||
|
- En auto-asignación, `autoAssign` corre **dentro** de la misma transacción, sobre el estado ya bloqueado.
|
||||||
|
|
||||||
|
## 7. Flujo del wizard público (`src/pages/public/BookingPage.tsx`)
|
||||||
|
|
||||||
|
### 7.1 Pasos
|
||||||
|
- `auto_assign_specialist = ON` → **3 pasos**: Servicio → **Horario** → Datos (sin paso Especialista).
|
||||||
|
- `OFF` → 4 pasos actuales, pero la card "Cualquiera" usa `autoAssign` (no más `LIMIT 1`).
|
||||||
|
|
||||||
|
### 7.2 Slots (`GET /api/public/:slug/slots`)
|
||||||
|
- Se mantiene la firma y la grilla de **30 min**, pero el rango ahora se lee de `getWorkingHours` (negocio/empleado) en vez del hardcodeado `09:00–20:00`.
|
||||||
|
- Cuando se omite el especialista, los candidatos son **todos** los empleados que ofrecen el servicio; un slot se muestra si **al menos uno** está libre (unión de disponibilidades). La asignación concreta se resuelve al confirmar.
|
||||||
|
- El payload del slot puede incluir un flag opcional `hasMultiple` para mostrar "varios especialistas disponibles" (informativo).
|
||||||
|
|
||||||
|
### 7.3 Confirmación
|
||||||
|
- Muestra el especialista asignado: avatar/color, nombre, rol, y 1–2 `reasons` legibles (p. ej. "Especialista en Corte · alta eficiencia · disponible"). El cliente nunca queda sin saber a quién le tocó.
|
||||||
|
|
||||||
|
## 8. UI — `DateTimePicker` (Elige fecha y hora)
|
||||||
|
|
||||||
|
### 8.1 Calendario de mes siempre visible (`MonthCalendar`)
|
||||||
|
- Componente nuevo (sin librería externa). Grilla **Lun–Dom**, header con mes/año + flechas ‹ › (limite ±3 meses).
|
||||||
|
- Días pasados o fuera de horario: atenuados (`slate-100`, texto `slate-300`), no seleccionables.
|
||||||
|
- Fin de semana: color tenue (no bloqueado salvo que `working_hours` sea `null`).
|
||||||
|
- Día con disponibilidad (≥1 slot): **punto `accent`** (naranja AgendaMax) bajo el número.
|
||||||
|
- Día seleccionado: relleno `brand-500`, texto blanco, `ring-2 ring-brand-500/30`, `shadow-soft`.
|
||||||
|
- Tipografía base >=16px; celda táctil >=44px (accesibilidad 50+).
|
||||||
|
|
||||||
|
### 8.2 Dos columnas en escritorio
|
||||||
|
- En el paso Horario, `main` pasa de `max-w-2xl` a **`max-w-5xl`** (solo en este paso).
|
||||||
|
- `DateTimePicker`: `md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] md:gap-8` → **calendario izquierda**, **tarjeta de horarios derecha**.
|
||||||
|
- Tarjeta de horarios: encabezado con la fecha elegida en grande + agrupación visual de slots por **Mañana / Tarde** (etiquetas con color `accent`/`brand` suave). **La grilla de bloques actual no se modifica** (`grid-cols-3 sm:grid-cols-4`, estilos seleccionado/disponible idénticos).
|
||||||
|
- En `<md` se conserva el stack vertical actual (aprobado por el usuario).
|
||||||
|
- `TopBar` (`max-w-3xl`) y `ActionBar` (`max-w-2xl`) se ensanchan a `max-w-5xl` para acompañar.
|
||||||
|
|
||||||
|
### 8.3 Colores indicativos (alta legibilidad, sin romper la marca)
|
||||||
|
- No se introducen colores nuevos estridentes; se **refuerza contraste y tamaño** y se usa `accent` como señal secundaria.
|
||||||
|
- Seleccionado: `bg-brand-500 text-white shadow-soft` + badge `Check` (más grande).
|
||||||
|
- Disponible: `border-slate-200 bg-white`, hover → `border-brand-400 bg-brand-50`.
|
||||||
|
- Indisponible: `bg-slate-100 text-slate-300` + ícono de candado sutil.
|
||||||
|
- Texto de etiquetas y campos >=16px; estado enfocado con `ring` más visible.
|
||||||
|
|
||||||
|
## 9. UI — Fix de iconos en "Tus datos" (y app-wide)
|
||||||
|
|
||||||
|
### 9.1 Causa raíz
|
||||||
|
`src/index.css:272-284`: `.input { padding: 0.55rem 0.75rem }` (shorthand) tiene la misma especificidad (0,1,0) que `.pl-9` de Tailwind. Como `@tailwind utilities;` se emite **antes** en el archivo, la regla `.input` (posterior) **gana** y fija `padding-left: 0.75rem` (12px), justo donde inicia el icono (`left-3` = 12px, ancho 16px → ocupa 12–28px). El texto arranca en 12px → **solape**. Afecta a **9 inputs** de la app.
|
||||||
|
|
||||||
|
### 9.2 Fix
|
||||||
|
1. Envolver `.input`, `.select`, `.textarea` (y demás reglas de componente custom) en **`@layer components { … }`** en `src/index.css`. Así las utilidades del layer `utilities` (incluido `pl-*`) **siempre** ganan, independientemente del orden de fuente. Resuelve los 9 sitios a la vez.
|
||||||
|
2. En los 3 fields de "Tus datos" subir `pl-9` → **`pl-10`** (icono `left-3` + 16px → texto a 40px, ~12px de holgura, mejor para 50+). Opcionalmente replicar `pl-10` en los otros 6 inputs con icono para consistencia.
|
||||||
|
|
||||||
|
## 10. UI — Configuración (`src/pages/SettingsPage.tsx`)
|
||||||
|
|
||||||
|
- Card **"Reservas online"** (`:64-101`): añade
|
||||||
|
- Check **"Asignar especialista automáticamente"** con helper "El cliente no elige especialista; lo asigna el sistema según especialidad, eficiencia y disponibilidad".
|
||||||
|
- Editor de **horario del negocio**: grilla 7 días, cada uno con switch activo + `start`/`end` (type=time). Persiste en `businesses.working_hours`.
|
||||||
|
- El listado de campos del mutation PATCH (`SettingsPage.tsx:28-49`) incluye los nuevos.
|
||||||
|
|
||||||
|
## 11. UI — Ficha de Especialista (edición de empleado)
|
||||||
|
|
||||||
|
- Vista de edición de empleado añade:
|
||||||
|
- **Especialidades** (input de chips/tags → array).
|
||||||
|
- **Horario laboral** (default: "Heredar del negocio"; si se edita, grilla 7 días como en el negocio).
|
||||||
|
- **Eficiencia** (slider 0–100 con etiqueta descriptiva).
|
||||||
|
- Endpoints existentes `PATCH /api/employees/:id` se extienden con los 3 campos en la lista blanca.
|
||||||
|
|
||||||
|
## 12. Plan de pruebas
|
||||||
|
|
||||||
|
- **Unit (scheduling):** `isAvailable` (casos de traslape en bordes), `specialtyMatch` (acentos/case), `scoreCandidate` (monotocidad de cada factor), `autoAssign` (elige óptimo, desempate determinista, respeta horario, ninguno disponible → null).
|
||||||
|
- **Integración (rutas):** reservar la misma hora dos veces → la 2ª da 409; reservar con auto-assign activo asigna y no traspasa.
|
||||||
|
- **Build/estático:** `npm run typecheck` y `npm run lint` limpios.
|
||||||
|
- **E2E si aplica:** flujo público completo con check activo (3 pasos) y sin check (4 pasos).
|
||||||
|
|
||||||
|
## 13. Riesgos y mitigaciones
|
||||||
|
|
||||||
|
| Riesgo | Mitigación |
|
||||||
|
|---|---|
|
||||||
|
| Datos vacíos al migrar (sin especialidades/eficiencia) | Defaults neutros: `specialties=[]`, `efficiency=50`. El algoritmo degrada a disponibilidad + load balance. |
|
||||||
|
| Race condition de doble reserva | Transacción `BEGIN IMMEDIATE` + `isAvailable` dentro de la misma. |
|
||||||
|
| Performance con muchos especialistas | N por negocio es chico; peor caso O(N·slots·citas). Cache de `computeOccupiedMinutes` por (employee, date). |
|
||||||
|
| Cambio de `working_hours` rompe slots pasados | Solo afecta a fechas futuras; citas pasadas no se revalidan. |
|
||||||
|
| `@layer components` cambia cascada | Re-auditar visualmente inputs/selects existentes; los utilities ya pensados para ganar. |
|
||||||
|
|
||||||
|
## 14. Entregables (resumen de archivos)
|
||||||
|
|
||||||
|
- **Nuevos:** `server/lib/scheduling.ts`.
|
||||||
|
- **Migración:** `server/db.ts` (`migrateV3ToV4`).
|
||||||
|
- **Backend rutas:** `server/routes/booking.ts`, `server/routes/appointments.ts`, `server/routes/settings.ts`, `server/routes/employees.ts`.
|
||||||
|
- **Tipos:** `shared/types.ts`.
|
||||||
|
- **Frontend:** `src/pages/public/BookingPage.tsx` (MonthCalendar, layout 2 col, flujo 3/4 pasos, confirmación), `src/pages/SettingsPage.tsx`, ficha de empleado, `src/lib/publicApi.ts`, `src/lib/api.ts`.
|
||||||
|
- **Estilos:** `src/index.css` (`@layer components` + fix de padding).
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Demo Accesses and Email Domain
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make the demo credentials easy to use and standardize the platform demo accounts
|
||||||
|
from `@agendapro.demo` to `@agendamax.demo` in source data, documentation, local
|
||||||
|
data, and the deployed Coolify instance.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Replace active positive `@agendapro.demo` references in application code,
|
||||||
|
tests, README, and operational documentation with `@agendamax.demo`; retain
|
||||||
|
the old domain only in migration-history documentation or explicit
|
||||||
|
negative-login assertions.
|
||||||
|
- Keep the existing demo password `demo1234` and expose it clearly beside each
|
||||||
|
demo account in `README.md`.
|
||||||
|
- Update the local SQLite demo users without deleting businesses, appointments,
|
||||||
|
or other seeded records.
|
||||||
|
- Deploy the code to the existing AgendaMax Coolify application.
|
||||||
|
- Update the persistent production SQLite users for the exact admin and owner
|
||||||
|
accounts with a targeted SQL change, preserving all other data.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
Fresh databases receive the new admin and owner emails from `server/scripts/seed.ts`
|
||||||
|
and `server/index.ts`. Existing local and production databases are migrated by
|
||||||
|
updating only these two exact rows: `[email protected]` and
|
||||||
|
`[email protected]`. If the active database is fresh and already contains the
|
||||||
|
new emails, the SQL update is a no-op and is skipped.
|
||||||
|
|
||||||
|
The employee accounts already use their business domains and are not changed.
|
||||||
|
Login verification uses `POST /api/auth/login` with the documented password and
|
||||||
|
does not expose password values through the API.
|
||||||
|
|
||||||
|
## Production Procedure
|
||||||
|
|
||||||
|
1. Run the local checks and build.
|
||||||
|
2. Push the change to the AgendaMax source mirror used by Coolify.
|
||||||
|
3. Trigger and monitor a Coolify deployment.
|
||||||
|
4. Inspect the exact old/new admin and owner rows. If an old row and its matching
|
||||||
|
new row both exist, stop and resolve the unique-email collision. If no old
|
||||||
|
rows exist and the new rows are present, record a safe no-op. Otherwise run a
|
||||||
|
targeted SQLite update inside the persistent production app volume:
|
||||||
|
`UPDATE users SET email = CASE email WHEN '[email protected]' THEN '[email protected]' WHEN '[email protected]' THEN '[email protected]' END WHERE (email = '[email protected]' AND role = 'admin') OR (email = '[email protected]' AND role = 'owner');`
|
||||||
|
5. Verify health, login for admin and owner, and the production page.
|
||||||
|
|
||||||
|
If deployment fails, do not run the production SQL migration; inspect the
|
||||||
|
deployment first and use the existing Coolify rollback procedure.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- `rg "agendapro\.demo"` returns no active positive application, test, or
|
||||||
|
README references; only explicit negative-login assertions and
|
||||||
|
migration-history documentation may retain the old domain.
|
||||||
|
- README lists every demo account with email and `demo1234`.
|
||||||
|
- Fresh seed creates `[email protected]` and `[email protected]`.
|
||||||
|
- Existing local data contains the new emails and retains its records.
|
||||||
|
- Coolify deployment finishes successfully.
|
||||||
|
- Production `/api/health` returns `ok: true`.
|
||||||
|
- Production admin and owner logins succeed with `demo1234`.
|
||||||
|
- Production admin and owner logins using the old domain return `401`.
|
||||||
|
- README employee emails exactly match the accent-preserving emails generated by the seed.
|
||||||
|
- The targeted SQL pairs each old email with its matching role and never rewrites
|
||||||
|
another old-domain user.
|
||||||
|
|
||||||
|
## Known Verification Limitation
|
||||||
|
|
||||||
|
The already-migrated local database has null `businesses.working_hours` and
|
||||||
|
employee `working_hours` values. As a result, the slot-dependent `test:e2e` and
|
||||||
|
`test:booking` assertions can fail before booking, including their existing
|
||||||
|
downstream dereference. This is a pre-existing scheduling-data limitation,
|
||||||
|
unrelated to the email-domain change; do not change working-hours data for this
|
||||||
|
task.
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
# AgendaMax PWA Installability
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make AgendaMax installable as a web app on mobile and desktop browsers, with a
|
||||||
|
native installation prompt where the browser supports it and an explicit iOS
|
||||||
|
installation guide where Apple does not expose that prompt API.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- Use a manually maintained manifest and service worker instead of adding
|
||||||
|
`vite-plugin-pwa`. This keeps the caching policy visible and avoids a new
|
||||||
|
build-time dependency for a small, already stable Vite app.
|
||||||
|
- Use an online-first data model. The service worker may recover the application
|
||||||
|
shell after a previous visit, but it must never cache `/api` responses or
|
||||||
|
attempt offline writes for appointments, clients, cash, or settings.
|
||||||
|
- Keep the existing AgendaMax visual language and Spanish copy. Installation is
|
||||||
|
an optional secondary action, not a permanent mobile banner.
|
||||||
|
- Require HTTPS in deployed environments. `localhost` remains valid for local
|
||||||
|
testing because browsers treat it as a secure context.
|
||||||
|
|
||||||
|
## Current Context
|
||||||
|
|
||||||
|
- Frontend: React 18, TypeScript, Vite, Tailwind CSS, React Router.
|
||||||
|
- Production serving: Express serves `dist/` and falls back to `index.html` for
|
||||||
|
non-API routes.
|
||||||
|
- Existing brand assets: `public/favicon.svg`, `#3b66ff` theme color, AgendaMax
|
||||||
|
name and current Spanish UI.
|
||||||
|
- Existing entry points: `src/main.tsx`, `src/App.tsx`, `LoginPage`,
|
||||||
|
`AppShell`, and `AdminShell`.
|
||||||
|
- There is no manifest, service worker, install prompt handling, or iOS-specific
|
||||||
|
home-screen metadata today.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Static PWA assets
|
||||||
|
|
||||||
|
Add the following public assets, copied by Vite into `dist/`:
|
||||||
|
|
||||||
|
- `manifest.webmanifest` with `name` and `short_name` set to AgendaMax,
|
||||||
|
`start_url` `/`, `scope` `/`, `display` `standalone`, portrait orientation,
|
||||||
|
brand colors, and PNG icons at 192x192 and 512x512.
|
||||||
|
- `sw.js` with a versioned cache name.
|
||||||
|
- PNG icons derived from the existing AgendaMax favicon mark. Keep the SVG
|
||||||
|
favicon unchanged for browser tabs.
|
||||||
|
|
||||||
|
Update `index.html` with the manifest link, iOS home-screen metadata, an
|
||||||
|
explicit mobile web app title, `viewport-fit=cover`, and the existing theme and
|
||||||
|
description metadata.
|
||||||
|
|
||||||
|
### Service worker
|
||||||
|
|
||||||
|
Register the worker from `src/main.tsx` only for production builds, using
|
||||||
|
`updateViaCache: "none"` so a deployed worker is checked promptly.
|
||||||
|
|
||||||
|
The worker has these rules:
|
||||||
|
|
||||||
|
- On install, cache only the stable shell/bootstrap assets and call
|
||||||
|
`skipWaiting`.
|
||||||
|
- On navigation requests, use network-first and fall back to the cached root
|
||||||
|
document. This allows deployments to deliver fresh HTML while preserving a
|
||||||
|
previously visited shell during a temporary outage.
|
||||||
|
- On same-origin static GET requests, use cache-first for immutable or hashed
|
||||||
|
assets and add successful responses to the runtime cache.
|
||||||
|
- Bypass `/api/`, non-GET requests, cross-origin requests, and requests with
|
||||||
|
credentials or other conditions that could expose tenant data.
|
||||||
|
- On activation, remove caches from older versions and call `clients.claim`.
|
||||||
|
|
||||||
|
The worker is deliberately not a data-sync layer. If an API request fails, the
|
||||||
|
existing application error/loading behavior remains authoritative.
|
||||||
|
|
||||||
|
### Install state and UI
|
||||||
|
|
||||||
|
Create a small reusable install hook/component with these states:
|
||||||
|
|
||||||
|
1. `unsupported`: no UI.
|
||||||
|
2. `available`: show an install action backed by `beforeinstallprompt`.
|
||||||
|
3. `ios-instructions`: show a short dialog explaining Safari's Share then Add
|
||||||
|
to Home Screen flow.
|
||||||
|
4. `installed`: no UI, detected from `display-mode: standalone` and
|
||||||
|
`navigator.standalone`.
|
||||||
|
|
||||||
|
The hook must retain the deferred Android prompt only until it is used, handle
|
||||||
|
`appinstalled`, and avoid showing the CTA repeatedly after a user dismisses it
|
||||||
|
within the current browser session.
|
||||||
|
|
||||||
|
Render the reusable action in:
|
||||||
|
|
||||||
|
- `LoginPage`, so a user can install before authentication;
|
||||||
|
- the authenticated business mobile header/sidebar in `AppShell`;
|
||||||
|
- the authenticated platform-admin mobile header/sidebar in `AdminShell`.
|
||||||
|
|
||||||
|
The action should remain a compact secondary button with a download icon. The
|
||||||
|
iOS dialog is instructional and must not claim that a native prompt is
|
||||||
|
available. It must not reserve a persistent bottom band or interfere with
|
||||||
|
existing mobile navigation.
|
||||||
|
|
||||||
|
Use safe-area-aware spacing where the install dialog or mobile controls touch a
|
||||||
|
screen edge.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
1. Browser loads the Vite entry document.
|
||||||
|
2. Production client registers `/sw.js`; the worker installs and claims future
|
||||||
|
navigations.
|
||||||
|
3. The install hook evaluates browser capability and standalone state.
|
||||||
|
4. Android/Chromium emits `beforeinstallprompt`; the hook stores the event and
|
||||||
|
exposes the CTA.
|
||||||
|
5. User selects the CTA; the hook calls the native prompt and handles the
|
||||||
|
resulting `appinstalled` or dismissal event.
|
||||||
|
6. iOS Safari is identified when not standalone; the CTA opens the instruction
|
||||||
|
dialog and does not attempt an unsupported API.
|
||||||
|
7. All authenticated API requests continue to use the existing `fetch`/auth
|
||||||
|
path and always go to the network.
|
||||||
|
|
||||||
|
## Error Handling and Compatibility
|
||||||
|
|
||||||
|
- Failure to register the service worker is non-fatal and must not prevent the
|
||||||
|
React application from rendering; log a concise warning in development only.
|
||||||
|
- Missing or invalid install events result in hidden UI, not an exception.
|
||||||
|
- Existing browsers that cannot install PWAs continue to work as normal web
|
||||||
|
pages.
|
||||||
|
- iOS installation is supported through Safari's system flow. Other iOS
|
||||||
|
browsers may render the app but are not promised a direct install action.
|
||||||
|
- The service worker must not intercept API errors, mutate auth tokens, or
|
||||||
|
serve one user's tenant data to another user.
|
||||||
|
|
||||||
|
## Testing and Verification
|
||||||
|
|
||||||
|
### Automated
|
||||||
|
|
||||||
|
- `npm run typecheck` passes.
|
||||||
|
- `npm run build` passes and emits the manifest, worker, icons, and metadata in
|
||||||
|
`dist/`.
|
||||||
|
- Add a Playwright check for Chromium's deferred install event and `prompt()`.
|
||||||
|
- Add a Playwright check for iOS-style user-agent/standalone detection and the
|
||||||
|
instructional dialog.
|
||||||
|
- Add checks that unsupported and already-installed states do not render the
|
||||||
|
CTA.
|
||||||
|
- Verify the production Express server returns status 200 for the manifest,
|
||||||
|
worker, and icon paths, while SPA fallback still serves client routes.
|
||||||
|
- Run `npm run audit:visual` and confirm no horizontal overflow at 375, 768,
|
||||||
|
1280, or 1536 pixels.
|
||||||
|
|
||||||
|
### Manual device acceptance
|
||||||
|
|
||||||
|
- Android Chrome on HTTPS displays the native install action and opens
|
||||||
|
AgendaMax in standalone mode with the correct icon/name.
|
||||||
|
- iPhone Safari on HTTPS shows the two-step installation guide and the added
|
||||||
|
home-screen app opens without browser chrome.
|
||||||
|
- After a prior visit, temporarily disabling the network still allows the
|
||||||
|
shell to load; API-backed data correctly remains unavailable rather than
|
||||||
|
showing stale cached data.
|
||||||
|
- A new deployment replaces the old worker/cache without requiring the user to
|
||||||
|
clear browser storage manually.
|
||||||
|
|
||||||
|
## Scope Exclusions
|
||||||
|
|
||||||
|
- No offline appointment creation, edits, queued writes, conflict resolution,
|
||||||
|
or background synchronization.
|
||||||
|
- No native App Store/Play Store packaging.
|
||||||
|
- No change to authentication, tenant isolation, API routes, or database schema.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- AgendaMax has a valid install manifest and 192x192/512x512 icons.
|
||||||
|
- Android/Chromium exposes an in-app install action when the native event is
|
||||||
|
available.
|
||||||
|
- iOS Safari exposes clear Share -> Add to Home Screen instructions.
|
||||||
|
- Installed standalone mode hides the install action.
|
||||||
|
- The service worker is registered in production, preserves the shell after a
|
||||||
|
prior visit, and never caches `/api` or non-GET requests.
|
||||||
|
- Production HTTPS serving works through the existing Express/Docker path.
|
||||||
|
- Typecheck, build, PWA behavior checks, and visual audit pass.
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
# Landing de funnel con enfoque Business Intelligence + login mágico
|
||||||
|
|
||||||
|
**Fecha:** 2026-07-28
|
||||||
|
**Estado:** aprobado para plan
|
||||||
|
**Alcance:** solo frontend (`src/`) + ajuste de tres audits Playwright. No toca servidor, esquema ni API.
|
||||||
|
|
||||||
|
## Problema
|
||||||
|
|
||||||
|
AgendaMax no tiene página de aterrizaje. `/` sirve el formulario de login
|
||||||
|
([src/App.tsx:34](../../../src/App.tsx#L34) renderiza `<LoginPage/>` inline cuando no hay sesión), así
|
||||||
|
que un visitante que llega al dominio se topa con una pared de credenciales sin haber leído nunca qué
|
||||||
|
resuelve el producto ni por qué debería importarle.
|
||||||
|
|
||||||
|
Falta la mitad de arriba del funnel: no hay nada que nombre el dolor, cuantifique su costo, muestre el
|
||||||
|
mecanismo ni empuje a probar. Y el acceso a la demo — que es la conversión real en esta fase — está
|
||||||
|
escondido al pie del login como una lista de cuentas sin jerarquía.
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Una landing pública en `/` que venda el ángulo **business intelligence**: el dueño de negocio en LATAM
|
||||||
|
opera a ciegas. El scroll es el vehículo narrativo, no un contenedor de secciones apiladas. Al final,
|
||||||
|
un `/login` rediseñado donde entrar a la demo cuesta un clic.
|
||||||
|
|
||||||
|
### Criterios de éxito
|
||||||
|
|
||||||
|
1. `/` sirve la landing sin sesión; con sesión redirige al panel según rol.
|
||||||
|
2. `/login` es una ruta enlazable con acceso mágico de un clic por cuenta demo.
|
||||||
|
3. `npm run typecheck` limpio.
|
||||||
|
4. `npm run audit:responsive` sin hallazgos nuevos a 390 / 440 / 744 / 1180 px, con la landing incluida
|
||||||
|
en la lista de páginas auditadas.
|
||||||
|
5. Cero scroll horizontal en cualquiera de esos anchos.
|
||||||
|
6. Con `prefers-reduced-motion: reduce` la landing es completamente legible y estática.
|
||||||
|
7. El chunk de `framer-motion` no se descarga al cargar el panel.
|
||||||
|
|
||||||
|
### Fuera de alcance
|
||||||
|
|
||||||
|
- Pasarela de pago, formulario de registro o captura de leads. La conversión en fase demo es **entrar a
|
||||||
|
la demo**, no registrarse.
|
||||||
|
- Endurecer la autenticación. El token trivial y las contraseñas en claro siguen como están; ver
|
||||||
|
«Seguridad» más abajo.
|
||||||
|
- Modo oscuro para el panel. La landing usa fondos oscuros mediante un scope propio.
|
||||||
|
- Internacionalización. Todo el copy es español de LATAM, como el resto del repo.
|
||||||
|
|
||||||
|
## Decisiones tomadas
|
||||||
|
|
||||||
|
| Decisión | Elegido | Por qué |
|
||||||
|
|---|---|---|
|
||||||
|
| Dolor del hero | «No sabes qué pasa en tu negocio» | Es el ángulo BI puro y es lo que la app ya demuestra: dashboard de ingresos, top empleados, top clientes. |
|
||||||
|
| Librería de animación | `framer-motion` | Orquestación declarativa y física de resorte que no vale la pena reimplementar. Se aísla en su propio chunk para que el panel no lo pague. |
|
||||||
|
| Ruteo | Landing en `/`, login en `/login` | Es lo que espera un visitante. No rompe `/b/:slug` ni las rutas del panel. |
|
||||||
|
| Acceso mágico | Solo en `/login` | Mantiene la landing enfocada en persuadir y concentra toda la mecánica de acceso en un lugar. |
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
### Ruteo
|
||||||
|
|
||||||
|
```
|
||||||
|
/ → LandingPage público; con sesión → redirige al panel
|
||||||
|
/login → LoginPage formulario + acceso mágico; con sesión → redirige al panel
|
||||||
|
/b/:slug → BookingPage sin cambios, sigue fuera de AuthProvider
|
||||||
|
/dashboard… → panel protegido sin sesión → <Navigate to="/login" replace/>
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/App.tsx` se reestructura: `Protected` deja de renderizar `<LoginPage/>` como fallback y en su lugar
|
||||||
|
el árbol de rutas distingue tres zonas — pública (`/`, `/login`), pública sin auth (`/b/:slug`) y
|
||||||
|
protegida. El splash de carga (`loading === true`) se conserva tal cual.
|
||||||
|
|
||||||
|
Detalle: el destino de la redirección con sesión es el mismo que hoy calcula
|
||||||
|
[src/App.tsx:43-54](../../../src/App.tsx#L43-L54) — `admin` → `/admin`, `owner` → `/dashboard`,
|
||||||
|
`employee` → `/calendar`. Se extrae a un helper `homePathFor(user)` para no duplicar la regla en tres
|
||||||
|
sitios.
|
||||||
|
|
||||||
|
### Code splitting
|
||||||
|
|
||||||
|
`LandingPage` se monta con `React.lazy` + `Suspense`. Dos razones que van en direcciones opuestas y por
|
||||||
|
eso importan las dos: el usuario con sesión nunca ve la landing y no debe descargarla, y el visitante
|
||||||
|
en la landing no debe descargar FullCalendar ni recharts.
|
||||||
|
|
||||||
|
`framer-motion` se añade a `manualChunks` en [vite.config.ts:33](../../../vite.config.ts#L33) como chunk
|
||||||
|
`motion`. Sin esto Rollup lo mete en el chunk compartido y los ~50 KB gzip se cobran en cada carga del
|
||||||
|
panel.
|
||||||
|
|
||||||
|
### Archivos
|
||||||
|
|
||||||
|
```
|
||||||
|
src/pages/LandingPage.tsx orquestador; solo compone secciones, ~40 líneas
|
||||||
|
src/components/landing/
|
||||||
|
LandingNav.tsx nav flotante que se condensa al scrollear
|
||||||
|
HeroSection.tsx
|
||||||
|
CostSection.tsx «el costo de no saber» — 4 fugas con contadores
|
||||||
|
ShiftSection.tsx cuaderno → tablero, scrub por scroll
|
||||||
|
ProductSection.tsx 3 actos con panel sticky
|
||||||
|
ProofSection.tsx mock del dashboard real
|
||||||
|
ObjectionsSection.tsx
|
||||||
|
ClosingSection.tsx CTA final + footer
|
||||||
|
visuals/
|
||||||
|
CalendarGridVisual.tsx retícula que se puebla de citas
|
||||||
|
RevenueLineVisual.tsx línea de ingresos que se traza
|
||||||
|
TeamLoadVisual.tsx carga por especialista
|
||||||
|
NotebookVisual.tsx cuaderno con tachones (lado «antes»)
|
||||||
|
DashboardMockVisual.tsx composición del panel a escala
|
||||||
|
src/lib/motion.ts easings y variants compartidos
|
||||||
|
src/lib/useReducedMotion.ts wrapper sobre prefers-reduced-motion
|
||||||
|
```
|
||||||
|
|
||||||
|
Una sección por archivo: cada una recibe cero props, no comparte estado con sus hermanas y se puede
|
||||||
|
leer o rehacer sin abrir las demás. Los visuales viven aparte de las secciones porque son la parte que
|
||||||
|
más va a iterar y no deberían obligar a releer el copy para tocarlos.
|
||||||
|
|
||||||
|
`src/lib/motion.ts` exporta el contrato de movimiento que todas las secciones consumen:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const EASE_EXPO = [0.16, 1, 0.3, 1] as const; // ease-out-expo
|
||||||
|
|
||||||
|
// Estado inicial → visible. `reduced` colapsa el initial al estado final.
|
||||||
|
export const revealUp = (reduced: boolean) => ({
|
||||||
|
hidden: reduced ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 },
|
||||||
|
show: { opacity: 1, y: 0, transition: { duration: 0.7, ease: EASE_EXPO } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Contenedor que escalona a sus hijos; 0 cuando hay movimiento reducido.
|
||||||
|
export const revealStagger = (reduced: boolean) => ({
|
||||||
|
hidden: {},
|
||||||
|
show: { transition: { staggerChildren: reduced ? 0 : 0.08 } },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Un solo easing en todo el sitio. Es lo que produce la sensación de que los elementos «pesan algo y
|
||||||
|
frenan solos», y mezclar curvas es lo que hace que una landing se sienta improvisada.
|
||||||
|
|
||||||
|
## Criterio visual
|
||||||
|
|
||||||
|
Cinco reglas, no una lista de efectos:
|
||||||
|
|
||||||
|
| Regla | Aplicación |
|
||||||
|
|---|---|
|
||||||
|
| Una idea por pantalla | Cada sección ocupa el viewport y afirma *una* cosa. Prohibido el grid de 6 features. |
|
||||||
|
| Tipografía protagonista | Titulares `clamp(2.75rem, 7vw, 6rem)`, `tracking-tight`, peso 700-800. El texto es la imagen. |
|
||||||
|
| Silencio | 120-200px entre secciones. El aire es lo que hace que se lea caro. |
|
||||||
|
| Color contenido | Base `#08080a` y `#fafafa` alternándose a sangre. `brand-500` solo en CTAs y datos. Sin gradientes de relleno. |
|
||||||
|
| El producto es la foto | Cero stock photos. Los visuales son el producto dibujándose: agenda que se llena, línea que se traza, KPI que cuenta. |
|
||||||
|
|
||||||
|
### Tokens de la landing
|
||||||
|
|
||||||
|
El `body` del panel es `#f6f7fb` con `color-scheme: light`
|
||||||
|
([src/index.css:5-42](../../../src/index.css#L5-L42)). La landing necesita fondos oscuros a sangre sin
|
||||||
|
alterar eso, así que se declara un scope propio en `src/index.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@layer components {
|
||||||
|
.landing {
|
||||||
|
--ld-ink: #08080a; /* casi-negro, no negro puro: el negro puro aplana */
|
||||||
|
--ld-paper: #fafafa;
|
||||||
|
--ld-muted: #6b6b73; /* texto secundario sobre papel */
|
||||||
|
--ld-muted-dark: #8a8a94; /* texto secundario sobre tinta */
|
||||||
|
--ld-hairline: rgba(255, 255, 255, 0.09);
|
||||||
|
}
|
||||||
|
.landing-dark { background: var(--ld-ink); color: var(--ld-paper); }
|
||||||
|
.landing-light { background: var(--ld-paper); color: var(--ld-ink); }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Va **dentro** de `@layer components`, que es el default del repo: así cualquier utilidad de Tailwind
|
||||||
|
aplicada en el JSX gana por especificidad de capa y las secciones pueden sobrescribir puntualmente. Solo
|
||||||
|
las reglas que deben ganar a las utilidades salen de la capa, y aquí ninguna lo necesita
|
||||||
|
([src/index.css:415](../../../src/index.css#L415)).
|
||||||
|
|
||||||
|
## Estructura del funnel
|
||||||
|
|
||||||
|
Orden: *problema → costo → mecanismo → prueba → objeción → cierre*.
|
||||||
|
|
||||||
|
**1 · Hero** (oscuro)
|
||||||
|
> **Tu negocio te habla todos los días. Nadie está escuchando.**
|
||||||
|
> Cada cita, cada cancelación y cada cliente que no volvió es un dato. AgendaMax los convierte en
|
||||||
|
> decisiones que te dejan dinero.
|
||||||
|
> `[Entrar a la demo]` `[Ver cómo funciona ↓]`
|
||||||
|
|
||||||
|
Movimiento: titular por líneas con stagger de 80 ms. Detrás, `CalendarGridVisual` poblándose sola,
|
||||||
|
desenfocada al 20% de opacidad. Indicador de scroll con respiración lenta.
|
||||||
|
|
||||||
|
**2 · El costo de no saber** (claro) — cuatro fugas, no cuatro features. Contadores que se animan al
|
||||||
|
entrar en viewport:
|
||||||
|
- El 34% de tus horas disponibles se van vacías.
|
||||||
|
- El cliente que no volvió hace cinco meses sigue en tu lista.
|
||||||
|
- Un servicio te está costando más de lo que cobra.
|
||||||
|
- Tu mejor empleado lo sabes por corazonada, no por dato.
|
||||||
|
|
||||||
|
**3 · El cambio** (oscuro) — sección sticky con scrub por progreso de scroll. `NotebookVisual` con
|
||||||
|
tachones se desvanece mientras el mismo día se reconstruye como tablero.
|
||||||
|
> **No es que trabajes poco. Es que trabajas sin instrumentos.**
|
||||||
|
|
||||||
|
**4 · El producto en tres actos** (claro) — panel sticky a la derecha, texto scrolleando a la izquierda,
|
||||||
|
visual que cambia por acto:
|
||||||
|
- **Agenda** → `CalendarGridVisual` con una cita arrastrándose. *«Tu día completo en una pantalla.»*
|
||||||
|
- **Inteligencia** → `RevenueLineVisual` trazándose y top clientes ordenándose. *«Los números que tu
|
||||||
|
contador te da en marzo, hoy a las 3 de la tarde.»*
|
||||||
|
- **Equipo** → `TeamLoadVisual`. *«Deja de repartir el trabajo por intuición.»*
|
||||||
|
|
||||||
|
**5 · Prueba** (claro) — `DashboardMockVisual` a escala, animándose. Sello: *«Estos son datos reales de
|
||||||
|
la cuenta demo. Puedes entrar y moverlos.»*
|
||||||
|
|
||||||
|
**6 · Objeciones** (claro) — las tres reales de un dueño en LATAM:
|
||||||
|
- *«No soy de tecnología.»* → Si sabes usar WhatsApp, sabes usar esto.
|
||||||
|
- *«Mi equipo no lo va a adoptar.»* → Cada empleado ve solo su día. Nada que aprender.
|
||||||
|
- *«Ya tengo mi cuaderno y me funciona.»* → Tu cuaderno no te dice qué servicio te está costando dinero.
|
||||||
|
|
||||||
|
**7 · Cierre** (oscuro) — cierra el círculo del hero:
|
||||||
|
> **Deja de adivinar.**
|
||||||
|
> `[Entrar a la demo →]` · Sin registro. Sin tarjeta. Cuentas ya cargadas.
|
||||||
|
|
||||||
|
**8 · Footer** — logo, «Demo · AgendaMax», enlace a `/login`.
|
||||||
|
|
||||||
|
## Login mágico
|
||||||
|
|
||||||
|
`src/pages/LoginPage.tsx` se rediseña con el lenguaje visual de la landing. El cambio de fondo es que
|
||||||
|
las tarjetas de cuenta pasan a ser el camino **principal**, no un apéndice al pie.
|
||||||
|
|
||||||
|
- Consume `GET /api/auth/demo-users`, que **ya existe**
|
||||||
|
([server/routes/auth.ts:24](../../../server/routes/auth.ts#L24)) y devuelve las cuentas ordenadas
|
||||||
|
admin → owner → empleados con `email`, `name`, `role`, `avatar_color`. **No se toca el servidor.**
|
||||||
|
- Se agrupan por rol, rotulando lo que cada uno desbloquea: **Dueña** (tablero completo), **Empleado**
|
||||||
|
(solo su agenda), **Plataforma** (consola multi-negocio).
|
||||||
|
- Un clic entra, reutilizando el `quickLogin` ya presente en
|
||||||
|
[src/pages/LoginPage.tsx:45](../../../src/pages/LoginPage.tsx#L45).
|
||||||
|
- El formulario manual se conserva **visible** bajo un separador, no colapsado tras un toggle. Es la ruta
|
||||||
|
que usan los tres audits Playwright, y esconderlo tras un clic obligaría a añadir un paso de expansión
|
||||||
|
a cada uno. Los selectores `input[type="email"]` y `input[type="password"]` deben existir en el DOM en
|
||||||
|
la carga inicial de `/login`, sin interacción previa.
|
||||||
|
|
||||||
|
Se conserva la bandera `DEMO` (`import.meta.env.DEV || import.meta.env.VITE_DEMO_UI === "1"`) tal como
|
||||||
|
está hoy. Es una constante de build, así que la contraseña demo se elimina del bundle de producción por
|
||||||
|
dead-code elimination. En local `npm run dev` la activa sola.
|
||||||
|
|
||||||
|
Cuando `DEMO` es falso, `/login` degrada al formulario manual expandido y sin tarjetas.
|
||||||
|
|
||||||
|
## Invariantes de responsividad
|
||||||
|
|
||||||
|
Ya documentadas en `CLAUDE.md`; romperlas reintroduce bugs conocidos.
|
||||||
|
|
||||||
|
- Alturas con `.min-h-screen-safe` / `.h-screen-safe` (`dvh` con `vh` de fallback), **nunca** `100vh`. Una
|
||||||
|
landing full-bleed es exactamente donde `100vh` falla en iOS por la barra de URL dinámica.
|
||||||
|
- Campos de formulario a 16px en `pointer: coarse` — ya lo garantiza la regla global de
|
||||||
|
[src/index.css:444](../../../src/index.css#L444); el login no debe declarar tamaños menores.
|
||||||
|
- CTAs con 44px de alto mínimo; los decide `pointer: coarse`, no breakpoints `sm:`.
|
||||||
|
- Todo `grid` con `grid-cols-1` explícito. Sin él la columna implícita se dimensiona a `max-content` y
|
||||||
|
desborda — es la causa exacta del comentario en
|
||||||
|
[src/pages/LoginPage.tsx:63-66](../../../src/pages/LoginPage.tsx#L63-L66).
|
||||||
|
- Visuales anchos scrollean dentro de su contenedor con `overflow-x: auto`; la página nunca.
|
||||||
|
- Secciones sticky: `position: sticky` con `top` calculado sobre `dvh`, no sobre `vh`.
|
||||||
|
|
||||||
|
## Movimiento reducido
|
||||||
|
|
||||||
|
`@media (prefers-reduced-motion: reduce)` y el hook `useReducedMotion` deben dejar la landing estática
|
||||||
|
**y completa**. El riesgo concreto es un elemento con `initial={{ opacity: 0 }}` que nunca anima y queda
|
||||||
|
invisible: el contenido desaparecería para quien tiene la preferencia activada. La regla es que con
|
||||||
|
movimiento reducido los `initial` se resuelven al estado final, no que las transiciones se acorten.
|
||||||
|
|
||||||
|
Las secciones con scrub por scroll (3 y 4) degradan a su estado final estático, con los visuales
|
||||||
|
apilados en vez de intercambiados.
|
||||||
|
|
||||||
|
## Verificación
|
||||||
|
|
||||||
|
| Comprobación | Comando |
|
||||||
|
|---|---|
|
||||||
|
| Gate de calidad del repo | `npm run typecheck` |
|
||||||
|
| Responsividad | `npm run audit:responsive` (requiere `npm run dev`) |
|
||||||
|
| Capturas | `npm run audit:visual` |
|
||||||
|
| API sin regresión | `npm run test:e2e`, `npm run test:admin`, `npm run test:booking` |
|
||||||
|
|
||||||
|
### Audits que se rompen y hay que arreglar
|
||||||
|
|
||||||
|
Tres scripts hacen `goto('/')` y a continuación `fill('input[type="email"]')`. Con `/` sirviendo la
|
||||||
|
landing ese `fill` falla. Deben apuntar a `/login`:
|
||||||
|
|
||||||
|
- [visual-audit.mjs:44](../../../visual-audit.mjs#L44)
|
||||||
|
- [responsive-audit.mjs:264](../../../responsive-audit.mjs#L264)
|
||||||
|
- [pwa-e2e.mjs:129](../../../pwa-e2e.mjs#L129) junto con los `goto(baseUrl)` de las líneas 187-288, que
|
||||||
|
ahora aterrizan en la landing en lugar del login.
|
||||||
|
|
||||||
|
El arreglo es un cambio de URL, no de flujo: el formulario manual sigue visible sin interacción previa en
|
||||||
|
`/login`, así que los `fill` existentes funcionan tal cual. Es exactamente la razón por la que el spec
|
||||||
|
prohíbe colapsarlo.
|
||||||
|
|
||||||
|
Caso aparte en `pwa-e2e.mjs`: el prompt de instalación PWA se probaba sobre `/`. Ahora `/` es la landing,
|
||||||
|
que no monta `InstallAppPrompt` (vive en el login y dentro del panel). Esos casos apuntan a `/login`.
|
||||||
|
|
||||||
|
Además, la landing se añade a la lista de páginas de `responsive-audit.mjs` y `visual-audit.mjs` como
|
||||||
|
página pública, sin paso de login.
|
||||||
|
|
||||||
|
Los tests de API (`test:e2e`, `test:admin`, `test:booking`, `test:unit`) no se tocan: golpean `/api`
|
||||||
|
directo y no dependen del ruteo del cliente.
|
||||||
|
|
||||||
|
## Seguridad
|
||||||
|
|
||||||
|
La landing no cambia el modelo de seguridad, pero conviene dejarlo escrito porque el cambio lo hace más
|
||||||
|
visible: `/login` expone contraseñas de cuentas **existentes** en el cliente. Está autorizado
|
||||||
|
explícitamente para esta fase demo y ya es el comportamiento actual del repo. La bandera `DEMO` es lo
|
||||||
|
único que separa eso de una fuga de credenciales si el proyecto se publica en un dominio real. Antes de
|
||||||
|
cualquier despliegue no-demo hace falta lo que ya lista `CLAUDE.md`: hashing, JWT firmados, validación
|
||||||
|
estricta y rate-limiting.
|
||||||
|
|
||||||
|
## Riesgos
|
||||||
|
|
||||||
|
| Riesgo | Mitigación |
|
||||||
|
|---|---|
|
||||||
|
| `framer-motion` se filtra al bundle del panel | Chunk manual `motion` + `React.lazy` en la landing. Verificar en la salida de `npm run build` que el chunk existe y que el panel no lo importa. |
|
||||||
|
| Las secciones sticky descuadran en iPad portrait (1024px) | El repo ya tiene el conflicto documentado entre el corte tablet/desktop y el `lg:` de Tailwind. La landing usa un layout de una columna por debajo de 1024px, evitando el borde. |
|
||||||
|
| `prefers-reduced-motion` deja contenido invisible | Los `initial` resuelven al estado final; se verifica emulando la preferencia en el audit. |
|
||||||
|
| Los contadores animados disparan reflow en móvil | Se animan con `transform`/`opacity` y el número se interpola en un `<span>` de ancho tabular (`font-variant-numeric: tabular-nums`) para no relayoutear en cada frame. |
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
# Propuesta técnica breve: plataforma de gestión para Yola Franco Spa
|
||||||
|
|
||||||
|
**Versión:** 0.1 — propuesta conceptual
|
||||||
|
**Objetivo:** construir una web app de operación diaria para el spa, con una experiencia extremadamente rápida para empleadas y un dashboard de control para la dueña, conectada con GoHighLevel (GHL) mediante API y webhooks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Resumen de la solución
|
||||||
|
|
||||||
|
La plataforma funcionará como un sistema operativo interno del spa:
|
||||||
|
|
||||||
|
- **Dueña/administradora:** visualiza ventas, citas, rendimiento, clientes, servicios, campañas y operación.
|
||||||
|
- **Empleada:** gestiona su agenda, crea y modifica citas, consulta clientes y registra la atención con el mínimo número de clics.
|
||||||
|
- **GoHighLevel:** conserva la relación omnicanal y automatizaciones de marketing. La app sincroniza contactos, conversaciones y eventos de cita mediante API/webhooks.
|
||||||
|
- **PostgreSQL:** fuente de datos operativos de la plataforma: citas, clientes, servicios, empleadas, pagos, comisiones, auditoría y sincronizaciones.
|
||||||
|
|
||||||
|
La recomendación es no intentar clonar toda la superficie de AgendaPro en la primera versión. El MVP debe resolver primero la operación diaria, la visibilidad del negocio y la integración confiable con GHL.
|
||||||
|
|
||||||
|
## 2. Alcance funcional
|
||||||
|
|
||||||
|
### 2.1 Panel de la administradora
|
||||||
|
|
||||||
|
1. **Dashboard ejecutivo**
|
||||||
|
- Ventas del día, semana y mes.
|
||||||
|
- Citas agendadas, atendidas, canceladas y no-show.
|
||||||
|
- Ingresos por servicio, empleada y canal.
|
||||||
|
- Ticket promedio.
|
||||||
|
- Tasa de recompra y clientes nuevos.
|
||||||
|
- Ocupación de agenda por empleada.
|
||||||
|
- Top clientes por frecuencia, gasto y última visita.
|
||||||
|
- Top servicios y servicios con baja demanda.
|
||||||
|
- Fuente de adquisición: orgánico, Instagram, Facebook, WhatsApp, campañas GHL, referido u otro.
|
||||||
|
|
||||||
|
2. **Calendario global**
|
||||||
|
- Vista diaria, semanal y mensual.
|
||||||
|
- Filtros por empleada, servicio, estado y ubicación.
|
||||||
|
- Crear, mover, confirmar, reprogramar y cancelar citas.
|
||||||
|
- Bloqueos de horario, descansos, vacaciones y días no laborables.
|
||||||
|
|
||||||
|
3. **Clientes/CRM operativo**
|
||||||
|
- Búsqueda por nombre, teléfono, correo o identificador GHL.
|
||||||
|
- Historial de citas, servicios, pagos, notas y conversaciones enlazadas.
|
||||||
|
- Etiquetas: nuevo, frecuente, VIP, inactivo, campaña, referido, etc.
|
||||||
|
- Consentimiento de comunicaciones y preferencias.
|
||||||
|
- Próxima recomendación de servicio y fecha sugerida de regreso.
|
||||||
|
- Detección de posibles duplicados antes de crear un cliente.
|
||||||
|
|
||||||
|
4. **Catálogo y configuración**
|
||||||
|
- Servicios, categorías, duración, precio, buffer y empleadas habilitadas.
|
||||||
|
- Horarios de atención y reglas de disponibilidad.
|
||||||
|
- Comisiones por servicio o por empleada.
|
||||||
|
- Paquetes, promociones y tarjetas/membresías en una fase posterior.
|
||||||
|
|
||||||
|
5. **Reportes**
|
||||||
|
- Exportación CSV/XLSX de clientes, citas y ventas.
|
||||||
|
- Reporte por periodo, empleada, servicio y canal.
|
||||||
|
- Registro de cambios y actividad administrativa.
|
||||||
|
|
||||||
|
### 2.2 Panel de la empleada
|
||||||
|
|
||||||
|
Diseñado primero para móvil y tablet, con navegación reducida:
|
||||||
|
|
||||||
|
- **Hoy:** próximas citas, hora, cliente, servicio y estado.
|
||||||
|
- **Mi agenda:** día/semana con bloques visuales.
|
||||||
|
- **Nueva cita rápida:** seleccionar fecha/hora, servicio, cliente y confirmar.
|
||||||
|
- **Búsqueda de cliente:** resultados mientras se escribe; evitar duplicados.
|
||||||
|
- **Alta rápida:** nombre y teléfono obligatorios; correo y notas opcionales.
|
||||||
|
- **Acciones de una cita:** confirmar, iniciar, completar, reprogramar, cancelar y marcar no-show.
|
||||||
|
- **Ficha resumida:** historial reciente, notas relevantes, preferencias y próxima visita.
|
||||||
|
- **Rendimiento personal:** citas atendidas, ventas generadas, ticket promedio, cancelaciones y comisión estimada.
|
||||||
|
|
||||||
|
La empleada no debe ver información financiera global ni clientes ajenos a sus permisos, salvo que la administradora lo configure.
|
||||||
|
|
||||||
|
## 3. Roles y permisos
|
||||||
|
|
||||||
|
Usar autorización basada en roles (RBAC), no solamente ocultamiento visual:
|
||||||
|
|
||||||
|
| Recurso | Administradora | Empleada |
|
||||||
|
|---|---:|---:|
|
||||||
|
| Dashboard global | Sí | No |
|
||||||
|
| Dashboard personal | Sí | Sí |
|
||||||
|
| Calendario global | Sí | Según permiso |
|
||||||
|
| Mi agenda | Sí | Sí |
|
||||||
|
| Crear cita | Sí | Sí |
|
||||||
|
| Editar/cancelar cualquier cita | Sí | Solo propias, según regla |
|
||||||
|
| Ver clientes | Todos | Necesarios para operar |
|
||||||
|
| Exportar clientes/ventas | Sí | No |
|
||||||
|
| Editar precios/comisiones | Sí | No |
|
||||||
|
| Ver ventas globales | Sí | No |
|
||||||
|
| Configurar GHL | Sí | No |
|
||||||
|
| Auditoría | Sí | No |
|
||||||
|
|
||||||
|
La aplicación debe estar preparada para `tenant_id`, aunque inicialmente exista un solo spa. Esto evita rediseñar la base si después se ofrecen cuentas a otros negocios.
|
||||||
|
|
||||||
|
## 4. Arquitectura propuesta
|
||||||
|
|
||||||
|
```text
|
||||||
|
[Web app responsive]
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[API Python: FastAPI]
|
||||||
|
| | |
|
||||||
|
| | +--> [Worker: Celery/RQ + Redis]
|
||||||
|
| +-----------> [GoHighLevel API]
|
||||||
|
+-------------------> [PostgreSQL]
|
||||||
|
|
|
||||||
|
+--> métricas/reportes
|
||||||
|
|
||||||
|
[GHL Webhooks] ---> [Endpoint seguro] ---> [Event inbox] ---> [Worker]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Componentes
|
||||||
|
|
||||||
|
- **Frontend:** Next.js/React + TypeScript, PWA instalable, Tailwind CSS o sistema de componentes equivalente.
|
||||||
|
- **Backend:** Python 3.12+ con FastAPI, Pydantic y SQLAlchemy 2.x/SQLModel.
|
||||||
|
- **Base de datos:** PostgreSQL 16+, migraciones con Alembic.
|
||||||
|
- **Cola y caché:** Redis; workers para webhooks, sincronizaciones y mensajes sin bloquear la interfaz.
|
||||||
|
- **Autenticación:** sesiones seguras con cookies HttpOnly o JWT de corta duración con refresh rotativo. MFA para la administradora en una fase posterior.
|
||||||
|
- **Despliegue inicial:** Docker Compose en staging; producción con PostgreSQL administrado, Redis administrado y servicio web/worker separado.
|
||||||
|
- **Observabilidad:** logs estructurados, Sentry/OpenTelemetry opcional, métricas de errores de integración y tiempos de respuesta.
|
||||||
|
|
||||||
|
## 5. Modelo de datos inicial
|
||||||
|
|
||||||
|
Tablas principales:
|
||||||
|
|
||||||
|
- `tenants`: spa/cuenta, zona horaria, configuración y estado.
|
||||||
|
- `users`: usuarios internos, correo, estado y último acceso.
|
||||||
|
- `roles`, `user_roles`: administradora y empleada.
|
||||||
|
- `employees`: perfil operativo, especialidades, horarios y comisión.
|
||||||
|
- `services`: nombre, categoría, duración, precio, buffer, activo.
|
||||||
|
- `employee_services`: servicios que puede realizar cada empleada.
|
||||||
|
- `customers`: nombre, teléfono normalizado, correo, consentimiento, `ghl_contact_id`.
|
||||||
|
- `customer_tags`: etiquetas operativas y de adquisición.
|
||||||
|
- `appointments`: cliente, empleada, servicio, inicio, fin, estado, origen, notas y `ghl_appointment_id`.
|
||||||
|
- `appointment_events`: historial de cambios de una cita.
|
||||||
|
- `payments`: monto, método, estado, referencia y fecha.
|
||||||
|
- `campaign_attributions`: UTM, campaña, fuente, medio y primer/último contacto.
|
||||||
|
- `conversations`: referencia a conversación/canal en GHL; no necesariamente almacenar todo el contenido si GHL es la fuente principal.
|
||||||
|
- `integration_connections`: ubicación GHL, tokens cifrados, scopes y estado.
|
||||||
|
- `integration_events`: webhook/evento recibido, payload hash, estado, reintentos e idempotency key.
|
||||||
|
- `outbox_events`: eventos internos pendientes de enviar a GHL.
|
||||||
|
- `audit_logs`: quién cambió qué, cuándo y desde dónde.
|
||||||
|
|
||||||
|
### Reglas importantes
|
||||||
|
|
||||||
|
- Normalizar teléfonos a formato E.164 (`+52...`) antes de buscar o crear contactos.
|
||||||
|
- `UNIQUE (tenant_id, normalized_phone)` para evitar duplicados básicos.
|
||||||
|
- Guardar fechas en UTC y mostrar en `America/Mexico_City`.
|
||||||
|
- Usar `timestamptz` y rangos para impedir doble reserva.
|
||||||
|
- Crear una restricción de exclusión PostgreSQL por empleada para evitar solapamientos de citas confirmadas.
|
||||||
|
- No borrar clientes físicamente; usar estado, anonimización y política de retención.
|
||||||
|
|
||||||
|
## 6. Integración con GoHighLevel
|
||||||
|
|
||||||
|
### 6.1 Autenticación y configuración
|
||||||
|
|
||||||
|
Preferir OAuth 2.0 para una integración comercial reutilizable. Para una sola subcuenta controlada por el equipo, puede iniciarse con credenciales de ubicación/API adecuadas, almacenadas cifradas en el servidor.
|
||||||
|
|
||||||
|
No guardar tokens en frontend ni en PostgreSQL en texto plano. Usar un gestor de secretos o variables de entorno del servidor; las variables deben contener secretos, no configuración funcional.
|
||||||
|
|
||||||
|
Configurar en GHL:
|
||||||
|
|
||||||
|
- Location/Sub-account ID del spa.
|
||||||
|
- Client ID y Client Secret de la aplicación, si se usa OAuth.
|
||||||
|
- Scopes mínimos necesarios.
|
||||||
|
- URLs de redirección OAuth.
|
||||||
|
- URLs de webhooks.
|
||||||
|
- Firma/secreto de validación de webhooks, si está disponible en el evento utilizado.
|
||||||
|
|
||||||
|
### 6.2 Sincronización de contactos
|
||||||
|
|
||||||
|
**Crear desde la app:**
|
||||||
|
|
||||||
|
1. La empleada captura teléfono y nombre.
|
||||||
|
2. La API busca primero en PostgreSQL por teléfono normalizado.
|
||||||
|
3. Si existe `ghl_contact_id`, actualiza o reutiliza el contacto.
|
||||||
|
4. Si no existe, consulta GHL por teléfono/correo.
|
||||||
|
5. Si tampoco existe, crea el contacto mediante API.
|
||||||
|
6. Guarda `ghl_contact_id`, respuesta resumida y evento de sincronización.
|
||||||
|
7. La cita se crea solamente después de resolver el cliente local.
|
||||||
|
|
||||||
|
**Actualizar desde la app:** usar una cola `outbox_events`, con reintentos y clave de idempotencia. La pantalla no debe quedar bloqueada si GHL está temporalmente fuera de servicio; debe mostrar “guardado local / sincronización pendiente”.
|
||||||
|
|
||||||
|
**Recibir desde GHL:** registrar webhooks de creación/actualización de contacto, cambios relevantes y eventos de conversación disponibles para la cuenta. El webhook debe responder rápido con HTTP 2xx y procesarse en segundo plano.
|
||||||
|
|
||||||
|
### 6.3 Conversaciones y mensajes
|
||||||
|
|
||||||
|
La app puede mostrar una vista resumida de conversaciones, pero conviene mantener GHL como sistema principal de mensajería omnicanal:
|
||||||
|
|
||||||
|
- GHL recibe mensajes de WhatsApp, Facebook e Instagram.
|
||||||
|
- GHL dispara webhooks hacia la plataforma cuando exista un evento compatible.
|
||||||
|
- La plataforma almacena metadatos y referencias, no necesariamente todo el historial.
|
||||||
|
- Cuando la dueña o empleada envía un mensaje desde la app, el backend ejecuta una petición autenticada a la API de GHL.
|
||||||
|
- El frontend nunca llama directamente a GHL.
|
||||||
|
|
||||||
|
**Flujo de envío:**
|
||||||
|
|
||||||
|
```text
|
||||||
|
Frontend -> POST /api/conversations/{id}/messages
|
||||||
|
-> valida permiso y contenido
|
||||||
|
-> crea outbox_event
|
||||||
|
-> worker llama API GHL
|
||||||
|
-> guarda resultado/id externo
|
||||||
|
-> frontend recibe estado enviado/fallido
|
||||||
|
```
|
||||||
|
|
||||||
|
Debe contemplar límites de frecuencia, reintentos con backoff, mensajes duplicados, archivos multimedia y errores de permisos. Las capacidades exactas de envío deben validarse contra la versión actual de la API y los canales habilitados en la subcuenta.
|
||||||
|
|
||||||
|
### 6.4 Webhooks seguros e idempotentes
|
||||||
|
|
||||||
|
Endpoint sugerido:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/integrations/gohighlevel/webhooks/{tenant_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
Proceso:
|
||||||
|
|
||||||
|
1. Validar firma, secreto o mecanismo oficial disponible.
|
||||||
|
2. Validar tamaño y estructura del payload.
|
||||||
|
3. Calcular hash del evento y revisar `integration_events`.
|
||||||
|
4. Si ya fue procesado, responder 200 sin duplicar efectos.
|
||||||
|
5. Insertar el evento en la bandeja de entrada.
|
||||||
|
6. Responder 200 rápidamente.
|
||||||
|
7. Worker transforma el evento y actualiza contacto/conversación/cita.
|
||||||
|
8. Registrar resultado, duración y número de reintentos.
|
||||||
|
|
||||||
|
## 7. API interna sugerida
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/auth/login
|
||||||
|
GET /api/dashboard/summary?from=&to=
|
||||||
|
GET /api/calendar?from=&to=&employee_id=
|
||||||
|
POST /api/appointments
|
||||||
|
PATCH /api/appointments/{id}
|
||||||
|
POST /api/appointments/{id}/confirm
|
||||||
|
POST /api/appointments/{id}/complete
|
||||||
|
POST /api/appointments/{id}/cancel
|
||||||
|
GET /api/customers?query=
|
||||||
|
POST /api/customers
|
||||||
|
GET /api/customers/{id}
|
||||||
|
PATCH /api/customers/{id}
|
||||||
|
GET /api/services
|
||||||
|
POST /api/services
|
||||||
|
GET /api/employees
|
||||||
|
GET /api/reports/sales
|
||||||
|
GET /api/reports/performance
|
||||||
|
GET /api/conversations
|
||||||
|
POST /api/conversations/{id}/messages
|
||||||
|
POST /api/integrations/gohighlevel/connect
|
||||||
|
POST /api/integrations/gohighlevel/sync
|
||||||
|
POST /api/integrations/gohighlevel/webhooks/{tenant_id}
|
||||||
|
GET /api/integrations/jobs/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
Todos los endpoints deben validar `tenant_id`, rol, permisos de recurso y esquema de entrada. La API debe devolver errores consistentes (`code`, `message`, `details`, `request_id`).
|
||||||
|
|
||||||
|
## 8. Métricas que importan al dueño
|
||||||
|
|
||||||
|
### Ventas
|
||||||
|
|
||||||
|
- Ingresos brutos/netos por periodo.
|
||||||
|
- Ticket promedio.
|
||||||
|
- Ventas por servicio, empleada y canal.
|
||||||
|
- Métodos de pago.
|
||||||
|
- Comisiones.
|
||||||
|
|
||||||
|
### Operación
|
||||||
|
|
||||||
|
- Utilización de horas disponibles.
|
||||||
|
- Citas atendidas, canceladas, reprogramadas y no-show.
|
||||||
|
- Tiempo promedio entre citas.
|
||||||
|
- Huecos disponibles próximos 7/14 días.
|
||||||
|
|
||||||
|
### Clientes
|
||||||
|
|
||||||
|
- Clientes nuevos vs recurrentes.
|
||||||
|
- Recompra a 30/60/90 días.
|
||||||
|
- Frecuencia y valor acumulado.
|
||||||
|
- Clientes inactivos.
|
||||||
|
- Fuente de adquisición y campaña.
|
||||||
|
|
||||||
|
### Marketing/GHL
|
||||||
|
|
||||||
|
- Contactos creados.
|
||||||
|
- Conversaciones iniciadas.
|
||||||
|
- Leads que terminaron en cita.
|
||||||
|
- Citas por campaña/UTM.
|
||||||
|
- Tiempo de respuesta, si GHL expone el dato necesario.
|
||||||
|
|
||||||
|
Los dashboards deben mostrar periodo, filtros, definición de cada métrica y fuente del dato. No presentar “ROI” si no se cuenta con costo de campaña confiable.
|
||||||
|
|
||||||
|
## 9. Experiencia de usuario y responsive
|
||||||
|
|
||||||
|
Prioridad de diseño: **la empleada opera con una mano y pocos segundos disponibles**.
|
||||||
|
|
||||||
|
- Mobile-first; soportar 360 px de ancho como mínimo.
|
||||||
|
- Botón persistente “Nueva cita”.
|
||||||
|
- Búsqueda global rápida de cliente.
|
||||||
|
- Calendario con colores por estado, no solamente por empleada.
|
||||||
|
- Formularios cortos y autoguardado de notas.
|
||||||
|
- Confirmación clara antes de cancelar o modificar una cita.
|
||||||
|
- Estados offline/pending para sincronizaciones.
|
||||||
|
- Accesibilidad WCAG 2.2 AA como objetivo.
|
||||||
|
- PWA para acceso desde la pantalla de inicio; no asumir aplicación nativa en el MVP.
|
||||||
|
|
||||||
|
## 10. Seguridad, privacidad y operación
|
||||||
|
|
||||||
|
- HTTPS obligatorio y cookies `Secure`, `HttpOnly`, `SameSite`.
|
||||||
|
- Hash de contraseñas con Argon2id o bcrypt configurado correctamente.
|
||||||
|
- Rate limiting en login, búsquedas y envío de mensajes.
|
||||||
|
- Validación de permisos en backend.
|
||||||
|
- Auditoría de cambios sensibles.
|
||||||
|
- Cifrado de secretos y datos sensibles en reposo cuando el proveedor lo permita.
|
||||||
|
- Backups automáticos de PostgreSQL y prueba periódica de restauración.
|
||||||
|
- Protección contra CSRF si se usan cookies de sesión.
|
||||||
|
- Sanitización de notas y contenido de mensajes.
|
||||||
|
- Política de privacidad, consentimiento para marketing y procedimiento de eliminación/anonimización.
|
||||||
|
- No guardar datos completos de tarjeta; integrar un proveedor de pagos si después se requiere cobro en línea.
|
||||||
|
|
||||||
|
## 11. Fases recomendadas
|
||||||
|
|
||||||
|
### Fase 0 — Descubrimiento técnico
|
||||||
|
|
||||||
|
- Confirmar documentación y scopes vigentes de GHL.
|
||||||
|
- Confirmar canales disponibles y eventos de webhook.
|
||||||
|
- Levantar catálogo, horarios, empleadas, reglas de reserva y métodos de pago.
|
||||||
|
- Definir si la fuente principal de agenda será la nueva app o AgendaPro durante la transición.
|
||||||
|
|
||||||
|
### Fase 1 — MVP operativo
|
||||||
|
|
||||||
|
- Login y RBAC.
|
||||||
|
- Clientes y búsqueda anti-duplicados.
|
||||||
|
- Servicios y empleadas.
|
||||||
|
- Calendario y nueva cita rápida.
|
||||||
|
- Estados de cita.
|
||||||
|
- Dashboard básico.
|
||||||
|
- PostgreSQL, migraciones, backups y auditoría.
|
||||||
|
|
||||||
|
### Fase 2 — Integración GHL
|
||||||
|
|
||||||
|
- Conexión segura con subcuenta.
|
||||||
|
- Crear/actualizar contactos.
|
||||||
|
- Sincronización inicial controlada.
|
||||||
|
- Webhooks idempotentes.
|
||||||
|
- Outbox y workers.
|
||||||
|
- Vista de conversaciones y envío de mensajes compatible con canales habilitados.
|
||||||
|
|
||||||
|
### Fase 3 — Analítica y crecimiento
|
||||||
|
|
||||||
|
- Ventas y pagos.
|
||||||
|
- Comisiones.
|
||||||
|
- Campañas/UTM.
|
||||||
|
- Recompra y reactivación.
|
||||||
|
- Reportes exportables.
|
||||||
|
- Paquetes, promociones, recordatorios y membresías.
|
||||||
|
|
||||||
|
## 12. Criterios de aceptación del MVP
|
||||||
|
|
||||||
|
- Una empleada puede crear una cita en menos de un minuto desde móvil.
|
||||||
|
- La búsqueda por teléfono encuentra un cliente existente sin crear duplicado.
|
||||||
|
- Dos usuarios no pueden reservar el mismo horario para la misma empleada.
|
||||||
|
- La administradora puede filtrar citas, ventas y rendimiento por periodo y empleada.
|
||||||
|
- Un cliente creado localmente se sincroniza con GHL o queda claramente marcado como pendiente.
|
||||||
|
- Un webhook repetido no duplica clientes, citas ni mensajes.
|
||||||
|
- Una caída temporal de GHL no borra ni impide guardar la operación local.
|
||||||
|
- Cada cambio relevante deja registro de usuario, fecha y acción.
|
||||||
|
- Los permisos impiden a una empleada consultar reportes globales o modificar precios.
|
||||||
|
- Los datos mostrados en dashboard incluyen su periodo y fuente.
|
||||||
|
|
||||||
|
## 13. Riesgos y decisiones pendientes
|
||||||
|
|
||||||
|
1. **API de GHL:** endpoints, scopes, límites y eventos disponibles pueden variar por versión y plan; deben validarse en un spike antes de comprometer el alcance.
|
||||||
|
2. **Doble agenda:** operar simultáneamente AgendaPro y la nueva app puede producir conflictos. Se debe elegir una fuente de verdad o construir sincronización explícita.
|
||||||
|
3. **Mensajería omnicanal:** Instagram, Facebook y WhatsApp pueden tener restricciones distintas; el sistema debe degradar con gracia y mostrar el estado real.
|
||||||
|
4. **Migración de datos:** antes de importar contactos hay que normalizar teléfonos y definir política de duplicados.
|
||||||
|
5. **Privacidad:** nombre, teléfono, historial y conversaciones requieren consentimiento, controles de acceso y política de retención.
|
||||||
|
6. **Pagos:** si inicialmente solo se registra pago manual, etiquetarlo como registro operativo y no como conciliación bancaria.
|
||||||
|
|
||||||
|
## Recomendación final
|
||||||
|
|
||||||
|
Construir primero una **agenda operacional mobile-first con clientes y dashboard**, y después agregar la capa omnicanal de GHL mediante una arquitectura de eventos (`outbox`, `event inbox`, workers e idempotencia). PostgreSQL es una elección adecuada para escalar la operación y los mensajes referenciados, pero GHL debe permanecer como sistema de conversaciones mientras la app se consolida como sistema de agenda, clientes y rendimiento.
|
||||||
|
|
||||||
|
El siguiente paso técnico recomendable es un **spike de integración de 3–5 días** que pruebe: autenticación GHL, creación/búsqueda de contacto, recepción de un webhook, envío de un mensaje permitido y sincronización de una cita. El resultado debe incluir scopes reales, payloads, límites y decisiones de fuente de verdad antes de iniciar el desarrollo completo.
|
||||||
+16
-4
@@ -24,13 +24,25 @@ console.log("\n=== HEALTH & AUTH ===");
|
|||||||
const h = await req("/api/health"); check("health", h.json?.ok === true);
|
const h = await req("/api/health"); check("health", h.json?.ok === true);
|
||||||
const me = await req("/api/auth/demo-users"); check("demo-users >= 8", me.json.users.length >= 8);
|
const me = await req("/api/auth/demo-users"); check("demo-users >= 8", me.json.users.length >= 8);
|
||||||
|
|
||||||
const owner = await login("owner@agendapro.demo"); check("owner login", !!owner.token && owner.user.role === "owner");
|
const owner = await login("owner@agendamax.demo"); check("owner login", !!owner.token && owner.user.role === "owner");
|
||||||
const empLogin = await login("[email protected]"); check("employee login", empLogin.user.role === "employee" && empLogin.user.employee_id);
|
const empLogin = await login("[email protected]"); check("employee login", empLogin.user.role === "employee" && empLogin.user.employee_id);
|
||||||
const adminLogin = await login("admin@agendapro.demo"); check("admin login", adminLogin.user.role === "admin");
|
const adminLogin = await login("admin@agendamax.demo"); check("admin login", adminLogin.user.role === "admin");
|
||||||
|
const legacyAdmin = await req("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email: "[email protected]", password: "demo1234" }),
|
||||||
|
});
|
||||||
|
check("[email protected] legacy login rejected", legacyAdmin.status === 401, `status=${legacyAdmin.status}`);
|
||||||
|
const legacyOwner = await req("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email: "[email protected]", password: "demo1234" }),
|
||||||
|
});
|
||||||
|
check("[email protected] legacy login rejected", legacyOwner.status === 401, `status=${legacyOwner.status}`);
|
||||||
const t = owner.token, eT = empLogin.token, aT = adminLogin.token;
|
const t = owner.token, eT = empLogin.token, aT = adminLogin.token;
|
||||||
|
|
||||||
// /me fix
|
// /me fix
|
||||||
const meR = await req("/api/auth/me", { headers: H(t) }); check("/me returns owner", meR.json.user.email === "owner@agendapro.demo");
|
const meR = await req("/api/auth/me", { headers: H(t) }); check("/me returns owner", meR.json.user.email === "owner@agendamax.demo");
|
||||||
|
|
||||||
console.log("\n=== EXISTING: business / employees / services / clients / appointments / dashboard ===");
|
console.log("\n=== EXISTING: business / employees / services / clients / appointments / dashboard ===");
|
||||||
const biz = await req("/api/business", { headers: H(t) }); check("business loaded", biz.json.business?.name === "Lumière Estética & Spa");
|
const biz = await req("/api/business", { headers: H(t) }); check("business loaded", biz.json.business?.name === "Lumière Estética & Spa");
|
||||||
@@ -38,7 +50,7 @@ const emps = await req("/api/employees", { headers: H(t) }); check("employees li
|
|||||||
const svcs = await req("/api/services", { headers: H(t) }); check("services list", svcs.json.services.length === 12 && svcs.json.services[0].employee_ids);
|
const svcs = await req("/api/services", { headers: H(t) }); check("services list", svcs.json.services.length === 12 && svcs.json.services[0].employee_ids);
|
||||||
const cls = await req("/api/clients", { headers: H(t) }); check("clients list", cls.json.clients.length > 0 && cls.json.clients[0].stats?.visits >= 0);
|
const cls = await req("/api/clients", { headers: H(t) }); check("clients list", cls.json.clients.length > 0 && cls.json.clients[0].stats?.visits >= 0);
|
||||||
const appts = await req("/api/appointments?limit=5", { headers: H(t) }); check("appointments joined", appts.json.appointments[0]?.service && appts.json.appointments[0]?.client);
|
const appts = await req("/api/appointments?limit=5", { headers: H(t) }); check("appointments joined", appts.json.appointments[0]?.service && appts.json.appointments[0]?.client);
|
||||||
const ov = await req("/api/dashboard/overview", { headers: H(t) }); check("overview KPIs", ov.json.revenue_month > 0 && ov.json.appts_upcoming > 0);
|
const ov = await req("/api/dashboard/overview", { headers: H(t) }); check("overview KPIs", ov.json.revenue_range > 0 && ov.json.appts_upcoming > 0);
|
||||||
const be = await req("/api/dashboard/best-employees", { headers: H(t) }); check("best-employees ranked", be.json.employees.length === 6);
|
const be = await req("/api/dashboard/best-employees", { headers: H(t) }); check("best-employees ranked", be.json.employees.length === 6);
|
||||||
const bs = await req("/api/dashboard/best-services", { headers: H(t) }); check("best-services ranked", bs.json.services.length > 0);
|
const bs = await req("/api/dashboard/best-services", { headers: H(t) }); check("best-services ranked", bs.json.services.length > 0);
|
||||||
const tt = await req("/api/dashboard/top-tickets", { headers: H(t) }); check("top-tickets", tt.json.tickets.length > 0);
|
const tt = await req("/api/dashboard/top-tickets", { headers: H(t) }); check("top-tickets", tt.json.tickets.length > 0);
|
||||||
|
|||||||
+63
-6
@@ -13,12 +13,30 @@ function check(name, cond, extra = "") {
|
|||||||
else { fail++; console.log(` ✗ ${name} ${extra}`); }
|
else { fail++; console.log(` ✗ ${name} ${extra}`); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch the first available slot (iso) for a service over the next `maxDays` days.
|
||||||
|
// Slots already filter out non-working days and double-booked specialists, so any
|
||||||
|
// returned iso is safe to use as start_at for either autoAssign or self-assign paths.
|
||||||
|
async function findSlot(slug, serviceId, maxDays = 30) {
|
||||||
|
for (let i = 1; i <= maxDays; i++) {
|
||||||
|
const d = new Date(Date.now() + i * 86400000);
|
||||||
|
const date = d.toISOString().slice(0, 10);
|
||||||
|
const { json } = await req("GET", `/public/${slug}/slots?service_id=${serviceId}&date=${date}`);
|
||||||
|
if (Array.isArray(json.slots) && json.slots.length > 0) return json.slots[0].iso;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// /me now works (the bug we fixed) — login first to get a real token
|
// /me now works (the bug we fixed) — login first to get a real token
|
||||||
const { json: login } = await req("POST", "/auth/login", { email: "owner@agendapro.demo", password: "demo1234" });
|
const { json: login } = await req("POST", "/auth/login", { email: "owner@agendamax.demo", password: "demo1234" });
|
||||||
check("login owner", !!login.token && login.user?.role === "owner", JSON.stringify(login).slice(0, 100));
|
check("login owner", !!login.token && login.user?.role === "owner", JSON.stringify(login).slice(0, 100));
|
||||||
const t = login.token;
|
const t = login.token;
|
||||||
const { json: me } = await req("GET", "/auth/me", null, t);
|
const { json: me } = await req("GET", "/auth/me", null, t);
|
||||||
check("/api/auth/me returns user", me.user?.email === "owner@agendapro.demo", JSON.stringify(me).slice(0, 100));
|
check("/api/auth/me returns user", me.user?.email === "owner@agendamax.demo", JSON.stringify(me).slice(0, 100));
|
||||||
|
|
||||||
|
// Slug del negocio (para consultar slots públicos)
|
||||||
|
const { json: settings } = await req("GET", "/settings", null, t);
|
||||||
|
const slug = settings.settings?.slug;
|
||||||
|
check("tiene slug", !!slug, String(slug));
|
||||||
|
|
||||||
const checks = [
|
const checks = [
|
||||||
["business", "/business", (j) => j.business?.name === "Lumière Estética & Spa"],
|
["business", "/business", (j) => j.business?.name === "Lumière Estética & Spa"],
|
||||||
@@ -26,7 +44,7 @@ const checks = [
|
|||||||
["services", "/services", (j) => j.services?.length === 12 && Array.isArray(j.services[0].employee_ids)],
|
["services", "/services", (j) => j.services?.length === 12 && Array.isArray(j.services[0].employee_ids)],
|
||||||
["clients+stats", "/clients", (j) => j.clients?.length > 0 && j.clients[0].stats?.visits !== undefined],
|
["clients+stats", "/clients", (j) => j.clients?.length > 0 && j.clients[0].stats?.visits !== undefined],
|
||||||
["appointments joined", "/appointments?limit=5", (j) => j.appointments?.[0]?.service && j.appointments[0].client],
|
["appointments joined", "/appointments?limit=5", (j) => j.appointments?.[0]?.service && j.appointments[0].client],
|
||||||
["dashboard overview", "/dashboard/overview", (j) => j.revenue_month > 0],
|
["dashboard overview", "/dashboard/overview", (j) => j.revenue_range > 0],
|
||||||
["best-employees", "/dashboard/best-employees", (j) => j.employees?.length === 6],
|
["best-employees", "/dashboard/best-employees", (j) => j.employees?.length === 6],
|
||||||
["best-services", "/dashboard/best-services", (j) => j.services?.length > 0],
|
["best-services", "/dashboard/best-services", (j) => j.services?.length > 0],
|
||||||
["top-tickets", "/dashboard/top-tickets", (j) => j.tickets?.length > 0],
|
["top-tickets", "/dashboard/top-tickets", (j) => j.tickets?.length > 0],
|
||||||
@@ -42,13 +60,21 @@ for (const [name, path, cond] of checks) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
const { json: created } = await req("POST", "/appointments", { service_id: 1, client_id: 1, start_at: "2026-09-15T11:00:00Z" }, t);
|
const slotOwner = await findSlot(slug, 1);
|
||||||
|
check("found slot for service 1", !!slotOwner);
|
||||||
|
const { json: created } = await req("POST", "/appointments", { service_id: 1, client_id: 1, start_at: slotOwner }, t);
|
||||||
check("create appointment", !!created.appointment?.id);
|
check("create appointment", !!created.appointment?.id);
|
||||||
const { json: completed } = await req("POST", `/appointments/${created.appointment.id}/complete`, { tip: 50, payment_method: "cash" }, t);
|
const { json: completed } = await req("POST", `/appointments/${created.appointment.id}/complete`, { tip: 50, payment_method: "cash" }, t);
|
||||||
check("complete -> ticket", completed.appointment?.status === "completed");
|
check("complete -> ticket", completed.appointment?.status === "completed");
|
||||||
const { json: empLogin } = await req("POST", "/auth/login", { email: "[email protected]", password: "demo1234" });
|
const { json: empLogin } = await req("POST", "/auth/login", { email: "[email protected]", password: "demo1234" });
|
||||||
const { json: empAppt } = await req("POST", "/appointments", { service_id: 5, client_id: 2, start_at: "2026-09-20T10:00:00Z" }, empLogin.token);
|
// Mateo offers services "Corte caballero" (5) and "Corte + arreglo de barba" (6).
|
||||||
check("employee auto-assign", empAppt.appointment?.employee_id === empLogin.user?.employee_id);
|
// Try 5 first (keeps the original assertion intent); fall back to 6 if 5 has no slots.
|
||||||
|
let empSvcId = 5;
|
||||||
|
let slotEmp = await findSlot(slug, empSvcId);
|
||||||
|
if (!slotEmp) { empSvcId = 6; slotEmp = await findSlot(slug, empSvcId); }
|
||||||
|
check("found slot for employee service", !!slotEmp);
|
||||||
|
const { json: empAppt } = await req("POST", "/appointments", { service_id: empSvcId, client_id: 2, start_at: slotEmp }, empLogin.token);
|
||||||
|
check("employee auto-assign", empAppt.appointment?.employee_id === empLogin.user?.employee_id, JSON.stringify(empAppt).slice(0, 150));
|
||||||
const { status: badLogin } = await req("POST", "/auth/login", { email: "[email protected]", password: "wrong" });
|
const { status: badLogin } = await req("POST", "/auth/login", { email: "[email protected]", password: "wrong" });
|
||||||
check("bad login 401", badLogin === 401);
|
check("bad login 401", badLogin === 401);
|
||||||
const { status: noAuth } = await req("GET", "/employees");
|
const { status: noAuth } = await req("GET", "/employees");
|
||||||
@@ -56,5 +82,36 @@ check("no auth 401", noAuth === 401);
|
|||||||
const { status: empForb } = await req("GET", "/dashboard/overview", null, empLogin.token);
|
const { status: empForb } = await req("GET", "/dashboard/overview", null, empLogin.token);
|
||||||
check("employee blocked dashboard 403", empForb === 403);
|
check("employee blocked dashboard 403", empForb === 403);
|
||||||
|
|
||||||
|
// ---- Permission boundary (server-side enforcement) ----
|
||||||
|
// F1: employees must NOT see commission_pct on any colleague row
|
||||||
|
const { json: empLeak } = await req("GET", "/employees", null, empLogin.token);
|
||||||
|
const leaked = (empLeak.employees || []).filter((e) => Object.prototype.hasOwnProperty.call(e, "commission_pct"));
|
||||||
|
check("F1: employee cannot read commission_pct", leaked.length === 0, `${leaked.length} rows leaked commission_pct`);
|
||||||
|
|
||||||
|
// F2: employees cannot mutate a colleague's appointment (PATCH / complete / DELETE)
|
||||||
|
const mateoId = empLogin.user?.employee_id;
|
||||||
|
const { json: allAppts } = await req("GET", "/appointments?limit=80", null, t);
|
||||||
|
const foreign = (allAppts.appointments || []).find((a) => a.employee_id !== mateoId);
|
||||||
|
check("F2: found a colleague's appointment", !!foreign, "no foreign appt in seed");
|
||||||
|
if (foreign) {
|
||||||
|
const { status: sPatch } = await req("PATCH", `/appointments/${foreign.id}`, { notes: "x" }, empLogin.token);
|
||||||
|
check("F2: employee PATCH foreign appt -> 403", sPatch === 403, `status=${sPatch}`);
|
||||||
|
const { status: sComplete } = await req("POST", `/appointments/${foreign.id}/complete`, { tip: 0 }, empLogin.token);
|
||||||
|
check("F2: employee complete foreign appt -> 403", sComplete === 403, `status=${sComplete}`);
|
||||||
|
const { status: sDel } = await req("DELETE", `/appointments/${foreign.id}`, null, empLogin.token);
|
||||||
|
check("F2: employee DELETE foreign appt -> 403", sDel === 403, `status=${sDel}`);
|
||||||
|
}
|
||||||
|
// positive control: employee can still mutate their OWN appointment
|
||||||
|
if (empAppt.appointment?.id) {
|
||||||
|
const { status: sOwn } = await req("PATCH", `/appointments/${empAppt.appointment.id}`, { notes: "nota propia" }, empLogin.token);
|
||||||
|
check("F2: employee PATCH own appt -> 200", sOwn === 200, `status=${sOwn}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// F3: employees cannot edit/delete clients (ownerOnly)
|
||||||
|
const { status: sClientPatch } = await req("PATCH", "/clients/1", { notes: "x" }, empLogin.token);
|
||||||
|
check("F3: employee PATCH client -> 403", sClientPatch === 403, `status=${sClientPatch}`);
|
||||||
|
const { status: sClientDel } = await req("DELETE", "/clients/1", null, empLogin.token);
|
||||||
|
check("F3: employee DELETE client -> 403", sClientDel === 403, `status=${sClientDel}`);
|
||||||
|
|
||||||
console.log(`\n${pass}/${pass + fail} passed${fail ? `, ${fail} FAILED` : ""}`);
|
console.log(`\n${pass}/${pass + fail} passed${fail ? `, ${fail} FAILED` : ""}`);
|
||||||
process.exit(fail ? 1 : 0);
|
process.exit(fail ? 1 : 0);
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
C:\Users\Uriel Jareth\AppData\Local\Programs\Python\Python312\python.exe
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
H:\MegaSync\Proyectos\AgendaPro
|
||||||
+16
-4
@@ -3,13 +3,25 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#3b66ff" />
|
<meta name="theme-color" content="#3b66ff" />
|
||||||
<meta name="description" content="AgendaPro — Gestión visual de citas, empleados e ingresos para tu negocio." />
|
<meta name="description" content="AgendaMax — Gestión visual de citas, empleados e ingresos para tu negocio." />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="AgendaMax" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
<!-- Inter es la voz de la plataforma y también el cuerpo de la landing: misma
|
||||||
<title>AgendaPro — Gestión visual de tu negocio</title>
|
tipografía dentro y fuera del producto. Fraunces (display cálido, con eje
|
||||||
|
WONK) se reserva para los titulares de la landing, que es lo único que
|
||||||
|
necesita personalidad de marketing. -->
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Fraunces:opsz,[email protected],600..900&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<title>AgendaMax — Gestión visual de tu negocio</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+202
@@ -0,0 +1,202 @@
|
|||||||
|
// landing-e2e.mjs — aceptación de la landing pública y del acceso mágico.
|
||||||
|
//
|
||||||
|
// Cubre lo que ningún test existente cubre: que `/` sirva la landing en lugar del
|
||||||
|
// login, que `/login` siga siendo alcanzable con su formulario sin interacción
|
||||||
|
// previa (de eso dependen los otros tres audits), que el acceso mágico entre de un
|
||||||
|
// clic, y que la landing no desborde en horizontal ni esconda contenido cuando el
|
||||||
|
// sistema pide menos movimiento.
|
||||||
|
//
|
||||||
|
// Uso: npm run test:landing (requiere `npm run dev` corriendo)
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
const BASE = process.env.LANDING_BASE_URL || "http://localhost:5173";
|
||||||
|
const T = 20000;
|
||||||
|
|
||||||
|
async function pickBrowser() {
|
||||||
|
const pw = await import("playwright");
|
||||||
|
for (const name of ["webkit", "chromium"]) {
|
||||||
|
try {
|
||||||
|
return { browser: await pw[name].launch(), engine: name };
|
||||||
|
} catch {
|
||||||
|
/* siguiente motor */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("No se pudo lanzar ningún navegador de Playwright.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const passed = [];
|
||||||
|
|
||||||
|
async function check(name, fn) {
|
||||||
|
await fn();
|
||||||
|
passed.push(name);
|
||||||
|
console.log(` ok ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { browser, engine } = await pickBrowser();
|
||||||
|
console.log(`Motor: ${engine}\nBase: ${BASE}\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ---- 1. `/` sirve la landing, no el login ----
|
||||||
|
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||||
|
ctx.setDefaultTimeout(T);
|
||||||
|
ctx.setDefaultNavigationTimeout(T);
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
const pageErrors = [];
|
||||||
|
page.on("pageerror", (e) => pageErrors.push(e.message));
|
||||||
|
|
||||||
|
await page.goto(`${BASE}/`, { waitUntil: "networkidle" });
|
||||||
|
|
||||||
|
await check("`/` monta la landing", async () => {
|
||||||
|
await page.waitForSelector("[data-ld-hero-title]", { state: "visible" });
|
||||||
|
});
|
||||||
|
|
||||||
|
await check("`/` no expone el formulario de login", async () => {
|
||||||
|
assert.equal(await page.locator('input[type="email"]').count(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await check("la landing tiene las 7 secciones del funnel", async () => {
|
||||||
|
assert.equal(await page.locator("[data-ld-section]").count(), 7);
|
||||||
|
});
|
||||||
|
|
||||||
|
await check("la landing no lanza errores de runtime", async () => {
|
||||||
|
assert.deepEqual(pageErrors, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 2. `/login` conserva el formulario sin interacción previa ----
|
||||||
|
// Es un requisito duro: visual-audit, responsive-audit y pwa-e2e rellenan
|
||||||
|
// estos campos directo tras el goto. Colapsarlos rompería los tres.
|
||||||
|
await page.goto(`${BASE}/login`, { waitUntil: "networkidle" });
|
||||||
|
|
||||||
|
await check("`/login` muestra el formulario manual sin abrir nada", async () => {
|
||||||
|
await page.waitForSelector('input[type="email"]', { state: "visible" });
|
||||||
|
await page.waitForSelector('input[type="password"]', { state: "visible" });
|
||||||
|
await page.waitForSelector('button[type="submit"]', { state: "visible" });
|
||||||
|
});
|
||||||
|
|
||||||
|
await check("`/login` ofrece al menos 3 cuentas de acceso mágico", async () => {
|
||||||
|
await page.waitForSelector("[data-magic-login]", { state: "visible" });
|
||||||
|
const n = await page.locator("[data-magic-login]").count();
|
||||||
|
assert.ok(n >= 3, `esperaba >= 3 tarjetas, encontré ${n}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 3. El acceso mágico entra de un clic ----
|
||||||
|
await check("un clic en la tarjeta de dueña entra al panel", async () => {
|
||||||
|
await page.locator('[data-magic-login][data-role="owner"]').first().click();
|
||||||
|
await page.waitForURL(/\/dashboard/, { timeout: T });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 4. Con sesión, `/` redirige al panel ----
|
||||||
|
await check("`/` redirige al panel cuando hay sesión", async () => {
|
||||||
|
await page.goto(`${BASE}/`, { waitUntil: "networkidle" });
|
||||||
|
await page.waitForURL(/\/dashboard/, { timeout: T });
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.close();
|
||||||
|
|
||||||
|
// ---- 5. Sin sesión, una ruta protegida manda al login ----
|
||||||
|
const anon = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||||
|
anon.setDefaultTimeout(T);
|
||||||
|
anon.setDefaultNavigationTimeout(T);
|
||||||
|
const anonPage = await anon.newPage();
|
||||||
|
await check("`/dashboard` sin sesión redirige a `/login`", async () => {
|
||||||
|
await anonPage.goto(`${BASE}/dashboard`, { waitUntil: "networkidle" });
|
||||||
|
await anonPage.waitForURL(/\/login/, { timeout: T });
|
||||||
|
});
|
||||||
|
await anon.close();
|
||||||
|
|
||||||
|
// ---- 6. Sin desborde horizontal en el teléfono más estrecho del objetivo ----
|
||||||
|
const phone = await browser.newContext({
|
||||||
|
viewport: { width: 390, height: 844 },
|
||||||
|
deviceScaleFactor: 3,
|
||||||
|
hasTouch: true,
|
||||||
|
});
|
||||||
|
phone.setDefaultTimeout(T);
|
||||||
|
phone.setDefaultNavigationTimeout(T);
|
||||||
|
const phonePage = await phone.newPage();
|
||||||
|
await phonePage.goto(`${BASE}/`, { waitUntil: "networkidle" });
|
||||||
|
await phonePage.waitForSelector("[data-ld-hero-title]", { state: "visible" });
|
||||||
|
|
||||||
|
await check("la landing no desborda en horizontal a 390px", async () => {
|
||||||
|
// Se recorre toda la página: las secciones sticky y los visuales anchos solo
|
||||||
|
// desbordan una vez que entran en viewport y arrancan su animación.
|
||||||
|
const overflow = await phonePage.evaluate(async () => {
|
||||||
|
const doc = document.documentElement;
|
||||||
|
let worst = 0;
|
||||||
|
const steps = Math.ceil(doc.scrollHeight / window.innerHeight) + 1;
|
||||||
|
for (let i = 0; i < steps; i += 1) {
|
||||||
|
window.scrollTo(0, i * window.innerHeight);
|
||||||
|
await new Promise((r) => setTimeout(r, 350));
|
||||||
|
worst = Math.max(
|
||||||
|
worst,
|
||||||
|
Math.max(doc.scrollWidth, document.body.scrollWidth) - doc.clientWidth
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return worst;
|
||||||
|
});
|
||||||
|
assert.ok(overflow <= 1, `desborde de ${overflow}px`);
|
||||||
|
});
|
||||||
|
await phone.close();
|
||||||
|
|
||||||
|
// ---- 7. Movimiento reducido no esconde contenido ----
|
||||||
|
// El fallo que se busca: un initial={{opacity:0}} cuyo whileInView nunca corre
|
||||||
|
// deja el bloque invisible de forma permanente.
|
||||||
|
const calm = await browser.newContext({
|
||||||
|
viewport: { width: 1280, height: 900 },
|
||||||
|
reducedMotion: "reduce",
|
||||||
|
});
|
||||||
|
calm.setDefaultTimeout(T);
|
||||||
|
calm.setDefaultNavigationTimeout(T);
|
||||||
|
const calmPage = await calm.newPage();
|
||||||
|
await calmPage.goto(`${BASE}/`, { waitUntil: "networkidle" });
|
||||||
|
await calmPage.waitForSelector("[data-ld-hero-title]", { state: "visible" });
|
||||||
|
|
||||||
|
await check("con movimiento reducido el hero es visible", async () => {
|
||||||
|
const o = await calmPage
|
||||||
|
.locator("[data-ld-hero-title]")
|
||||||
|
.evaluate((el) => Number(getComputedStyle(el).opacity));
|
||||||
|
assert.ok(o >= 0.99, `opacity del hero = ${o}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await check("con movimiento reducido el CTA de cierre es visible", async () => {
|
||||||
|
const cta = calmPage.locator("[data-ld-closing-cta]");
|
||||||
|
await cta.scrollIntoViewIfNeeded();
|
||||||
|
const o = await cta.evaluate((el) => Number(getComputedStyle(el).opacity));
|
||||||
|
assert.ok(o >= 0.99, `opacity del CTA de cierre = ${o}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await check("con movimiento reducido todas las secciones son visibles", async () => {
|
||||||
|
const hidden = await calmPage.evaluate(async () => {
|
||||||
|
const out = [];
|
||||||
|
const nodes = [...document.querySelectorAll("[data-ld-section]")];
|
||||||
|
for (const [i, el] of nodes.entries()) {
|
||||||
|
el.scrollIntoView();
|
||||||
|
await new Promise((r) => setTimeout(r, 250));
|
||||||
|
const invisibles = [...el.querySelectorAll("*")].filter(
|
||||||
|
(n) => Number(getComputedStyle(n).opacity) < 0.05 && n.textContent?.trim()
|
||||||
|
);
|
||||||
|
if (invisibles.length)
|
||||||
|
out.push({
|
||||||
|
section: i,
|
||||||
|
count: invisibles.length,
|
||||||
|
sample: invisibles[0].textContent.trim().slice(0, 50),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
assert.deepEqual(hidden, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
await calm.close();
|
||||||
|
|
||||||
|
console.log(`\n${passed.length} comprobaciones OK`);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(`\nlanding-e2e falló: ${error.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
Generated
+203
-14
@@ -20,7 +20,9 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
|
"framer-motion": "^11.18.2",
|
||||||
"lucide-react": "^0.451.0",
|
"lucide-react": "^0.451.0",
|
||||||
|
"pg": "^8.23.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.26.2",
|
"react-router-dom": "^6.26.2",
|
||||||
@@ -32,6 +34,7 @@
|
|||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^22.7.4",
|
"@types/node": "^22.7.4",
|
||||||
|
"@types/pg": "^8.23.1",
|
||||||
"@types/react": "^18.3.11",
|
"@types/react": "^18.3.11",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react": "^4.3.2",
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
@@ -91,7 +94,6 @@
|
|||||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.7",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/generator": "^7.29.7",
|
"@babel/generator": "^7.29.7",
|
||||||
@@ -369,7 +371,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/accessibility": "^3.1.1",
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
@@ -997,7 +998,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.21.tgz",
|
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.21.tgz",
|
||||||
"integrity": "sha512-t3u/+sqh3Iq7TWtUnVLcGDUE6OWZh0UD3c04bI/l7lSLAgAKr3kngBmhHiQD1QXpwC8ZN5iNqG7a7gOVixhSKQ==",
|
"integrity": "sha512-t3u/+sqh3Iq7TWtUnVLcGDUE6OWZh0UD3c04bI/l7lSLAgAKr3kngBmhHiQD1QXpwC8ZN5iNqG7a7gOVixhSKQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"preact": "~10.12.1"
|
"preact": "~10.12.1"
|
||||||
}
|
}
|
||||||
@@ -1817,6 +1817,18 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/pg": {
|
||||||
|
"version": "8.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
|
||||||
|
"integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"pg-protocol": "*",
|
||||||
|
"pg-types": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/prop-types": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
@@ -1844,7 +1856,6 @@
|
|||||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -1933,7 +1944,6 @@
|
|||||||
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -2188,7 +2198,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.44",
|
"baseline-browser-mapping": "^2.10.44",
|
||||||
"caniuse-lite": "^1.0.30001806",
|
"caniuse-lite": "^1.0.30001806",
|
||||||
@@ -2894,7 +2903,6 @@
|
|||||||
"integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==",
|
"integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -3309,6 +3317,33 @@
|
|||||||
"url": "https://github.com/sponsors/rawify"
|
"url": "https://github.com/sponsors/rawify"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/framer-motion": {
|
||||||
|
"version": "11.18.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz",
|
||||||
|
"integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"motion-dom": "^11.18.1",
|
||||||
|
"motion-utils": "^11.18.1",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emotion/is-prop-valid": "*",
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@emotion/is-prop-valid": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fresh": {
|
"node_modules/fresh": {
|
||||||
"version": "0.5.2",
|
"version": "0.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||||
@@ -3649,7 +3684,6 @@
|
|||||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
@@ -3940,6 +3974,21 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/motion-dom": {
|
||||||
|
"version": "11.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
|
||||||
|
"integrity": "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"motion-utils": "^11.18.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/motion-utils": {
|
||||||
|
"version": "11.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.18.1.tgz",
|
||||||
|
"integrity": "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -4161,6 +4210,95 @@
|
|||||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pg": {
|
||||||
|
"version": "8.23.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
|
||||||
|
"integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-connection-string": "^2.14.0",
|
||||||
|
"pg-pool": "^3.14.0",
|
||||||
|
"pg-protocol": "^1.16.0",
|
||||||
|
"pg-types": "2.2.0",
|
||||||
|
"pgpass": "1.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"pg-cloudflare": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg-native": ">=3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pg-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-cloudflare": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/pg-connection-string": {
|
||||||
|
"version": "2.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||||
|
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-int8": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-pool": {
|
||||||
|
"version": "3.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||||
|
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-protocol": {
|
||||||
|
"version": "1.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
|
||||||
|
"integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-types": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-int8": "1.0.1",
|
||||||
|
"postgres-array": "~2.0.0",
|
||||||
|
"postgres-bytea": "~1.0.0",
|
||||||
|
"postgres-date": "~1.0.4",
|
||||||
|
"postgres-interval": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pgpass": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -4268,7 +4406,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.16",
|
"nanoid": "^3.3.16",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -4412,6 +4549,45 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/postgres-array": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-bytea": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-date": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-interval": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/preact": {
|
"node_modules/preact": {
|
||||||
"version": "10.12.1",
|
"version": "10.12.1",
|
||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
|
||||||
@@ -4538,7 +4714,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -4551,7 +4726,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@@ -5050,6 +5224,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
@@ -5260,7 +5443,6 @@
|
|||||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -5319,7 +5501,6 @@
|
|||||||
"integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==",
|
"integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "~0.28.0"
|
"esbuild": "~0.28.0"
|
||||||
},
|
},
|
||||||
@@ -5483,7 +5664,6 @@
|
|||||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.43",
|
"postcss": "^8.4.43",
|
||||||
@@ -6012,6 +6192,15 @@
|
|||||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xtend": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/y18n": {
|
"node_modules/y18n": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
|||||||
+16
-2
@@ -20,7 +20,18 @@
|
|||||||
"seed": "node scripts/run-tsx.mjs server/scripts/seed.ts",
|
"seed": "node scripts/run-tsx.mjs server/scripts/seed.ts",
|
||||||
"test:e2e": "node e2e-test.mjs",
|
"test:e2e": "node e2e-test.mjs",
|
||||||
"test:admin": "node admin-test.mjs",
|
"test:admin": "node admin-test.mjs",
|
||||||
"audit:visual": "node visual-audit.mjs"
|
"test:unit": "node --import tsx --test server/lib/scheduling.test.ts server/lib/time.test.ts server/lib/metrics.test.ts",
|
||||||
|
"test:booking": "node server/scripts/booking-e2e.mjs",
|
||||||
|
"test:landing": "node landing-e2e.mjs",
|
||||||
|
"audit:visual": "node visual-audit.mjs",
|
||||||
|
"audit:responsive": "node responsive-audit.mjs",
|
||||||
|
"generate:pwa-icons": "node scripts/generate-pwa-icons.mjs",
|
||||||
|
"test:pwa": "node pwa-e2e.mjs",
|
||||||
|
"pg:up": "docker compose -f platform/docker-compose.yml up -d",
|
||||||
|
"pg:down": "docker compose -f platform/docker-compose.yml down",
|
||||||
|
"pg:migrate": "node scripts/run-tsx.mjs platform/db/migrate.ts",
|
||||||
|
"platform": "node scripts/run-tsx.mjs platform/index.ts",
|
||||||
|
"test:platform": "cross-env DATABASE_URL=postgres://yola:[email protected]:5434/yola_test node --import tsx --test --test-concurrency=1 platform/test/*.test.ts platform/lib/*.test.ts platform/crm/*.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.1.0",
|
"@dnd-kit/core": "^6.1.0",
|
||||||
@@ -35,11 +46,14 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
|
"framer-motion": "^11.18.2",
|
||||||
"lucide-react": "^0.451.0",
|
"lucide-react": "^0.451.0",
|
||||||
|
"pg": "^8.23.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.26.2",
|
"react-router-dom": "^6.26.2",
|
||||||
"recharts": "^2.12.7",
|
"recharts": "^2.12.7",
|
||||||
|
"tsx": "^4.19.1",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -47,6 +61,7 @@
|
|||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^22.7.4",
|
"@types/node": "^22.7.4",
|
||||||
|
"@types/pg": "^8.23.1",
|
||||||
"@types/react": "^18.3.11",
|
"@types/react": "^18.3.11",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react": "^4.3.2",
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
@@ -57,7 +72,6 @@
|
|||||||
"playwright": "^1.62.0",
|
"playwright": "^1.62.0",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"tailwindcss": "^3.4.13",
|
"tailwindcss": "^3.4.13",
|
||||||
"tsx": "^4.19.1",
|
|
||||||
"typescript": "^5.6.2",
|
"typescript": "^5.6.2",
|
||||||
"vite": "^5.4.8"
|
"vite": "^5.4.8"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Base de datos de la plataforma
|
||||||
|
DATABASE_URL=postgres://yola:[email protected]:5434/yola
|
||||||
|
TEST_DATABASE_URL=postgres://yola:[email protected]:5434/yola_test
|
||||||
|
PLATFORM_PORT=3100
|
||||||
|
|
||||||
|
# ── Bucéfalo CRM ────────────────────────────────────────────────────────────
|
||||||
|
# El token es de SUBCUENTA (PIT). No lo subas al repo: platform/.env está
|
||||||
|
# gitignorado. Si sospechas que se filtró, regenéralo en el CRM.
|
||||||
|
CRM_BASE_URL=https://services.leadconnectorhq.com
|
||||||
|
|
||||||
|
# Clave maestra con la que se cifran en Postgres los tokens de cada subcuenta.
|
||||||
|
# 32 bytes en base64. Genérala UNA vez con:
|
||||||
|
# node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||||
|
# Si la pierdes, los tokens guardados dejan de descifrarse y hay que volver a
|
||||||
|
# vincular cada subcuenta a mano. Guárdala donde guardes los secretos.
|
||||||
|
CRM_MASTER_KEY=
|
||||||
|
|
||||||
|
# HEREDADAS: a partir del multi-tenant, cada negocio guarda sus credenciales
|
||||||
|
# cifradas en la base y se ponen desde la consola de administración. Estas dos
|
||||||
|
# solo las usa el script de migración de la credencial del primer negocio.
|
||||||
|
CRM_LOCATION_ID=
|
||||||
|
CRM_TOKEN=
|
||||||
|
|
||||||
|
# Mientras esta variable tenga valor, el servidor SOLO envía mensajes a esta
|
||||||
|
# dirección, sin importar a quién apunte la interfaz. Es la red de seguridad
|
||||||
|
# que impide escribirle a los 3 200 contactos reales del cliente por accidente.
|
||||||
|
CRM_TEST_EMAIL=[email protected]
|
||||||
|
|
||||||
|
# Descomenta para permitir envíos a las clientas de verdad. Es una decisión
|
||||||
|
# deliberada del dueño del proyecto, no un ajuste de configuración.
|
||||||
|
# CRM_ALLOW_REAL_SENDS=1
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# ---- Frontend compilado ----
|
||||||
|
FROM node:22-slim AS web-build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---- Runtime ----
|
||||||
|
FROM node:22-slim
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PLATFORM_PORT=3100
|
||||||
|
# La zona del contenedor se fija a UTC, que es como Postgres almacena los
|
||||||
|
# instantes. La lógica de agenda no depende de ella —usa `businesses.timezone`—
|
||||||
|
# y dejarla en UTC hace que una recaída a la zona del proceso se note aquí en
|
||||||
|
# vez de esconderse en el equipo de desarrollo, que está en México.
|
||||||
|
ENV TZ=UTC
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
# `--omit=dev` a secas dejaría fuera a `tsx`, que este backend NECESITA en
|
||||||
|
# tiempo de ejecución porque corre TypeScript directo. Por eso `tsx` está en
|
||||||
|
# `dependencies` y no en `devDependencies`: arrancar con `npx tsx` lo bajaría
|
||||||
|
# de npm en cada arranque, sin versión fijada y sobre todo el código del
|
||||||
|
# servidor. Es un riesgo de cadena de suministro que no compensa.
|
||||||
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
|
|
||||||
|
COPY --from=web-build /app/dist ./dist
|
||||||
|
COPY platform ./platform
|
||||||
|
COPY shared ./shared
|
||||||
|
COPY scripts ./scripts
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
|
||||||
|
# `platform/.env` está gitignorado y NO se copia: las credenciales llegan como
|
||||||
|
# variables de entorno al desplegar. Si alguna vez aparece dentro de la imagen,
|
||||||
|
# es un fallo del `.dockerignore`.
|
||||||
|
|
||||||
|
EXPOSE 3100
|
||||||
|
|
||||||
|
# Consulta la base, no solo el puerto: un proceso vivo con Postgres caido no
|
||||||
|
# esta sano, y sin esto el orquestador lo daria por bueno y no reiniciaria.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 CMD node -e "fetch('http://127.0.0.1:3100/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||||
|
|
||||||
|
|
||||||
|
# El contenedor no corre como root.
|
||||||
|
USER node
|
||||||
|
|
||||||
|
# Las migraciones NO se ejecutan aquí: se lanzan como paso explícito del
|
||||||
|
# despliegue. Correrlas al arrancar hace que dos réplicas migren a la vez sobre
|
||||||
|
# la misma base, y que un arranque fallido deje el esquema a medias.
|
||||||
|
CMD ["node", "scripts/run-tsx.mjs", "platform/index.ts"]
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
# `platform/` — backend Postgres de Yola Franco Spa
|
||||||
|
|
||||||
|
Backend nuevo sobre PostgreSQL 16 para la plataforma del spa. Habla los mismos
|
||||||
|
contratos `/api` que el frontend de este repo, así que la SPA de `src/` funciona
|
||||||
|
contra él sin cambios de stack.
|
||||||
|
|
||||||
|
**El `server/` de SQLite sigue en pie y sin tocar**: es la demo de AgendaPro y el
|
||||||
|
punto de comparación. Los dos backends no se hablan ni comparten base.
|
||||||
|
|
||||||
|
Plan e historia de las decisiones:
|
||||||
|
[`docs/superpowers/plans/2026-08-29-yola-nucleo-postgres.md`](../docs/superpowers/plans/2026-08-29-yola-nucleo-postgres.md).
|
||||||
|
|
||||||
|
## Arranque
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run pg:up # Postgres 16 en Docker, puerto 5434
|
||||||
|
npm run pg:migrate # aplica las migraciones pendientes
|
||||||
|
node scripts/run-tsx.mjs platform/scripts/seed.ts # siembra el spa y citas de hoy
|
||||||
|
npm run platform # API en :3100
|
||||||
|
|
||||||
|
# el frontend contra este backend:
|
||||||
|
API_URL=http://127.0.0.1:3100 npx vite --port 5175
|
||||||
|
```
|
||||||
|
|
||||||
|
Cuenta de la dueña: `[email protected]` / `demo1234`. El personal entra con
|
||||||
|
`karla@`, `brenda@` y `[email protected]`, misma contraseña.
|
||||||
|
|
||||||
|
**Puerto 5434 y no 5432/5433:** los dos están ocupados por contenedores de otros
|
||||||
|
proyectos en esta máquina.
|
||||||
|
|
||||||
|
## Pruebas
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:platform # 38 pruebas
|
||||||
|
node --import tsx --test platform/lib/phone.test.ts # solo las puras
|
||||||
|
```
|
||||||
|
|
||||||
|
Corren contra la base `yola_test`, que se crea una vez:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec yola-postgres psql -U yola -d postgres -c "CREATE DATABASE yola_test OWNER yola"
|
||||||
|
```
|
||||||
|
|
||||||
|
`--test-concurrency=1` en el script **no es cosmético**: cada archivo de prueba
|
||||||
|
hace `DROP SCHEMA public` y en paralelo se pisan entre sí.
|
||||||
|
`resetDb()` se niega a correr si `DATABASE_URL` no apunta a `yola_test`.
|
||||||
|
|
||||||
|
## Las tres decisiones que sostienen el diseño
|
||||||
|
|
||||||
|
1. **La doble reserva la impide el motor, no un `if`.** `appointments` lleva una
|
||||||
|
restricción `EXCLUDE USING gist (employee_id WITH =, during WITH &&)` sobre un
|
||||||
|
`tstzrange` generado. Postgres rechaza la fila con `23P01` y el router lo
|
||||||
|
traduce a un 409 en español. Requiere la extensión `btree_gist`, que aplica
|
||||||
|
`000_bootstrap.sql`.
|
||||||
|
2. **`visits` está separada de `appointments`.** Una cita es una intención; una
|
||||||
|
visita es un hecho con dinero. Un solo registro que sirve para planear y para
|
||||||
|
cerrar termina sin cerrarse nunca — es exactamente lo que dejó 3 002
|
||||||
|
oportunidades congeladas en el CRM del spa. Por eso el "no vino" es un estado
|
||||||
|
de la cita y **no** crea una visita vacía.
|
||||||
|
3. **`clients.phone_e164` es la clave de identidad.** Índice único parcial por
|
||||||
|
`(business_id, phone_e164)`, parcial porque el 40.8 % del histórico medido no
|
||||||
|
tiene teléfono y esas clientas tienen que poder existir: quedan marcadas
|
||||||
|
`contactable = false`.
|
||||||
|
|
||||||
|
## Deuda conocida, dicha sin rodeos
|
||||||
|
|
||||||
|
- **La autenticación no se endureció.** El token es el id del usuario en texto
|
||||||
|
plano y la contraseña se compara sin hashear, portado tal cual del backend de
|
||||||
|
demo. Arreglarlo es un entregable propio: bcrypt/Argon2id + sesión real +
|
||||||
|
`src/lib/api.ts` + el `AuthProvider` + las pruebas, todo a la vez. A medias
|
||||||
|
rompe el login.
|
||||||
|
- **Sin rate limiting y con `cors()` abierto.** Igual que el backend de demo.
|
||||||
|
- **La validación de entrada es manual.** `zod` está en `dependencies` y sigue
|
||||||
|
sin importarse en ningún archivo.
|
||||||
|
- **De Bucéfalo CRM falta lo de entrada.** Lo que hay está en la segunda mitad de
|
||||||
|
este documento; lo que no: webhooks (exigen OAuth y este token es un PIT), el
|
||||||
|
espejo persistido de conversaciones —las tablas existen y nadie las escribe—, y
|
||||||
|
el arrastre de citas.
|
||||||
|
- **El catálogo sembrado no es el del negocio.** Los nombres salen del
|
||||||
|
vocabulario medido en los hilos del CRM; **las duraciones y los precios son
|
||||||
|
marcadores de posición** y hay que sustituirlos por los reales antes de
|
||||||
|
enseñar esto como catálogo del spa.
|
||||||
|
- **Falta parte de la superficie de `/api`.** Hoy están `auth`, `business`,
|
||||||
|
`clients`, `appointments`, `attendance`, `day-close`, `crm` y `messages`.
|
||||||
|
Servicios, empleados, tablero, caja, tickets y recordatorios siguen solo en el
|
||||||
|
backend de SQLite.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Integración con Bucéfalo CRM
|
||||||
|
|
||||||
|
Subcuenta **Yola Franco Spa**. Todo el diseño sale de hallazgos **medidos** contra el CRM real,
|
||||||
|
no de la especificación. Dos documentos, y conviene no confundirlos:
|
||||||
|
|
||||||
|
- [`crm/HALLAZGOS.md`](crm/HALLAZGOS.md) — los **47 hallazgos empíricos**: qué se ejerció, contra qué
|
||||||
|
y con qué resultado. Es la fuente de verdad y manda sobre la documentación oficial.
|
||||||
|
- [`crm/API.md`](crm/API.md) — la **referencia de endpoints**: rutas, parámetros, scopes, límites de
|
||||||
|
tasa, y la lista explícita de lo que la documentación oficial dice mal o no dice.
|
||||||
|
|
||||||
|
Los spikes que produjeron los hallazgos se pueden volver a correr.
|
||||||
|
|
||||||
|
## Puesta en marcha
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clave maestra del cifrado de credenciales (una vez por instalación):
|
||||||
|
cp platform/.env.example platform/.env
|
||||||
|
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||||
|
# → pégala en CRM_MASTER_KEY
|
||||||
|
|
||||||
|
# 2. Vincula la subcuenta desde la consola de administración:
|
||||||
|
# PUT /api/admin/businesses/:id/crm { location_id, token, label }
|
||||||
|
# Las credenciales se COMPRUEBAN contra el CRM antes de guardarse.
|
||||||
|
|
||||||
|
# 3. Trae los contactos (o pulsa el botón en la pantalla de Clientes)
|
||||||
|
```
|
||||||
|
|
||||||
|
Para migrar un negocio que ya estaba vinculado por variables de entorno:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/run-tsx.mjs platform/scripts/crm-migrar-credencial.ts <businessId>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Qué hace hoy
|
||||||
|
|
||||||
|
| Pieza | Estado |
|
||||||
|
|---|---|
|
||||||
|
| **Traer contactos con su atribución UTM** | ✅ 3 210 en ~22 s, idempotente, con botón en Clientes |
|
||||||
|
| **Deduplicar por id → teléfono → correo** | ✅ Misma cadena que el CRM aplica. Cero duplicados sobre datos reales |
|
||||||
|
| **Proyectar citas como oportunidades** | ✅ `SERVICIO — CLIENTA`, importe del servicio, `open`/`won`/`lost` |
|
||||||
|
| **Bandeja de salida con reintentos** | ✅ Encolada en la misma transacción del cambio, despachada cada minuto |
|
||||||
|
| **Leer conversaciones y responder** | ✅ Solo correo: WhatsApp y SMS no están conectados en la subcuenta |
|
||||||
|
| **Ver la atribución en la ficha** | ✅ Fuente, medio, campaña, UTM, anuncio y fecha de sincronización |
|
||||||
|
|
||||||
|
## Multi-tenancy: una credencial por negocio
|
||||||
|
|
||||||
|
**Cada negocio guarda su propio `locationId` y su token privado, cifrado en la
|
||||||
|
base.** Antes el `locationId` era por negocio pero el token era una variable de
|
||||||
|
entorno global: con dos cuentas, el servidor usaba el token de la primera contra
|
||||||
|
la subcuenta de la segunda —401 en el mejor caso, escritura en la subcuenta
|
||||||
|
equivocada en el peor—. Tres piezas lo sostienen y hay que tocarlas juntas:
|
||||||
|
|
||||||
|
- **`CrmOptions.token` es obligatorio** ([crm/client.ts](crm/client.ts)). No tiene
|
||||||
|
valor por defecto a propósito: olvidarlo es un error de compilación, no una
|
||||||
|
petición con la credencial de otro cliente.
|
||||||
|
- **`CrmCtx { businessId, locationId, token }`** ([crm/ctx.ts](crm/ctx.ts)) sustituye
|
||||||
|
al `locationId: string` suelto que antes viajaba por once firmas. Es un objeto y
|
||||||
|
no dos parámetros porque dos `string` seguidos se cruzan sin que el compilador
|
||||||
|
diga nada. `ctxDe(businessId)` es el **único** sitio donde el token existe
|
||||||
|
descifrado, y solo en memoria.
|
||||||
|
- **El token se cifra con AES-256-GCM** ([lib/crypto.ts](lib/crypto.ts)), autenticado
|
||||||
|
a propósito: una fila manipulada hace que el descifrado **falle**, en vez de
|
||||||
|
devolver basura que acabaríamos mandando como credencial. La clave maestra vive
|
||||||
|
en `CRM_MASTER_KEY`, fuera de la base.
|
||||||
|
|
||||||
|
Lo único de la credencial que sale del servidor es `token_fingerprint`, los 6
|
||||||
|
últimos caracteres. Ni la API, ni los registros, ni `audit_log` ven el token.
|
||||||
|
|
||||||
|
El estrangulador también es **por token** y aprende la cuota de las cabeceras
|
||||||
|
`x-ratelimit-*` que el CRM devuelve: son 100 peticiones por 10 s, no la estimación
|
||||||
|
de 1 cada 650 ms con la que se escribió el cliente.
|
||||||
|
|
||||||
|
## La consola de administración de plataforma
|
||||||
|
|
||||||
|
`/api/admin`, solo para el rol `admin` (cuyo `business_id` es NULL):
|
||||||
|
|
||||||
|
| Endpoint | Qué hace |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/admin/businesses` | Las cuentas, con el estado de su vínculo. Nunca devuelve el token |
|
||||||
|
| `POST /api/admin/businesses` | Alta de cuenta y su dueña, en una transacción. El negocio nace con slug y horario |
|
||||||
|
| `PUT /api/admin/businesses/:id/crm` | Vincula la subcuenta. **Comprueba las credenciales contra el CRM antes de guardarlas** |
|
||||||
|
| `DELETE /api/admin/businesses/:id/crm` | Desvincula. Borra la credencial y conserva lo sincronizado |
|
||||||
|
| `PATCH /api/admin/businesses/:id` | Suspender o reactivar, renombrar, cambiar zona horaria |
|
||||||
|
|
||||||
|
## Sincronización por identificador
|
||||||
|
|
||||||
|
`POST /api/crm/sync/:entidad/:id` resuelve **una** entidad. Las cinco están ejercidas contra la
|
||||||
|
subcuenta real. La dirección la decide la entidad, no quien llama:
|
||||||
|
|
||||||
|
| Entidad | Dirección | Por qué |
|
||||||
|
|---|---|---|
|
||||||
|
| `contacto` | ← del CRM | Es su dueño: ahí viven la deduplicación y las automatizaciones |
|
||||||
|
| `conversacion` | ← del CRM | Se espeja con todos sus mensajes |
|
||||||
|
| `mensaje` | ← del CRM | Se espeja **con su hilo**: `messages.conversation_id` es obligatorio |
|
||||||
|
| `cita` | → al CRM | MEDIDO: el calendario del CRM tiene **una** cita en dos años |
|
||||||
|
| `servicio` | → al CRM | MEDIDO: su catálogo está **vacío** |
|
||||||
|
|
||||||
|
`POST /api/crm/sync/conversations` espeja las conversaciones recientes con sus mensajes.
|
||||||
|
|
||||||
|
**Si el spa empieza a agendar dentro del CRM, la premisa de las dos últimas se cae** y habrá que
|
||||||
|
decidir cuál de los dos manda cuando difieran. Conviene decidirlo antes de que pase.
|
||||||
|
|
||||||
|
## Las tres decisiones que no son obvias
|
||||||
|
|
||||||
|
**1. La sincronización de contactos va en una sola dirección: del CRM hacia aquí.**
|
||||||
|
El contacto es del CRM —es su llave de deduplicación y donde viven las automatizaciones—, así que
|
||||||
|
esta sincronización nunca escribe hacia allá. Lo que la plataforma quiere empujar pasa por la
|
||||||
|
bandeja de salida, que es otra cosa y tiene otras garantías.
|
||||||
|
|
||||||
|
**2. La oportunidad se recicla, no se duplica.** La subcuenta tiene
|
||||||
|
`allowDuplicateOpportunity: false`, y eso hace que el CRM rechace una segunda oportunidad por
|
||||||
|
contacto **aunque la primera esté cerrada**. La plataforma intenta crear y, si recibe ese rechazo,
|
||||||
|
reutiliza la existente con el nombre, el importe y el estado de la cita nueva. Si alguien activa
|
||||||
|
ese ajuste en el CRM, pasa a «una cita = una oportunidad» sin tocar código.
|
||||||
|
|
||||||
|
**3. Un fallo de transporte no se reintenta.** Un `5xx` es una respuesta: el servidor habló. Un
|
||||||
|
timeout no dice nada sobre si la escritura entró, y reenviarlo es fabricar la doble creación. Esas
|
||||||
|
filas quedan en `indeterminado` y se resuelven **leyendo**.
|
||||||
|
|
||||||
|
## Modo prueba de mensajes
|
||||||
|
|
||||||
|
Mientras `CRM_TEST_EMAIL` esté definido, **el servidor solo envía a esa dirección**, sin importar a
|
||||||
|
quién apunte la interfaz. La subcuenta es la de un cliente real con 3 200 contactos: un bucle mal
|
||||||
|
escrito escribiría a personas de verdad. Se levanta con `CRM_ALLOW_REAL_SENDS=1`, y esa es una
|
||||||
|
decisión deliberada, no un descuido de configuración.
|
||||||
|
|
||||||
|
## Lo que NO hace, y conviene tener presente
|
||||||
|
|
||||||
|
- **La bandeja de mensajes todavía lee en vivo del CRM**, no del espejo. Las tablas `conversations`
|
||||||
|
y `messages` ya se llenan (`POST /api/crm/sync/conversations`), pero `MessagesPage` sigue sin
|
||||||
|
apuntar a ellas.
|
||||||
|
- **No recibe webhooks.** Exigen OAuth y este token es un PIT. La entrada es por sondeo: el botón.
|
||||||
|
- **No confirma entrega de correo.** El CRM acusa «encolado». La interfaz dice «en camino» a
|
||||||
|
propósito, y no «entregado».
|
||||||
|
- **No trae el catálogo de servicios**, porque el del CRM está vacío. Duración y precio viven aquí,
|
||||||
|
y lo que sí se puede es **publicarlos** hacia el CRM.
|
||||||
|
- **4 de cada 10 clientas no tienen teléfono.** El panel lo enseña en ámbar. Es el techo de
|
||||||
|
utilidad de cualquier recordatorio, y se arregla pidiendo el teléfono al agendar, no con código.
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/run-tsx.mjs platform/scripts/crm-spike.ts # sondeo de lectura
|
||||||
|
node scripts/run-tsx.mjs platform/scripts/crm-spike-write.ts # escrituras, con relectura
|
||||||
|
node scripts/run-tsx.mjs platform/scripts/crm-spike-opps.ts # regla de duplicados
|
||||||
|
node scripts/run-tsx.mjs platform/scripts/crm-spike-dup.ts # ajustes de la subcuenta
|
||||||
|
node scripts/run-tsx.mjs platform/scripts/crm-conectar.ts # conectar y autodetectar
|
||||||
|
node scripts/run-tsx.mjs platform/scripts/crm-limpiar-pruebas.ts # listar basura de pruebas
|
||||||
|
node scripts/run-tsx.mjs platform/scripts/crm-limpiar-pruebas.ts --borrar
|
||||||
|
```
|
||||||
|
|
||||||
|
Los spikes **escriben en la subcuenta real del cliente**. Todo lo que crean lleva el tag
|
||||||
|
`agendamax:prueba` y el correo autorizado, y `crm-limpiar-pruebas.ts` los borra.
|
||||||
|
|
||||||
|
## Sondeos contra el CRM
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/run-tsx.mjs platform/scripts/crm-spike-lectura-id.ts # lectura por id (solo lectura)
|
||||||
|
node scripts/run-tsx.mjs platform/scripts/crm-spike-calendarios.ts # los 7 calendarios (solo lectura)
|
||||||
|
node scripts/run-tsx.mjs platform/scripts/crm-spike-permisos.ts # qué permisos tiene el token, sin crear nada
|
||||||
|
node scripts/run-tsx.mjs platform/scripts/crm-spike-borrado.ts # ¿se puede deshacer?, sin crear nada
|
||||||
|
node scripts/run-tsx.mjs platform/scripts/crm-spike-escritura-cita-servicio.ts # ESCRIBE: crea, relee y borra
|
||||||
|
node scripts/run-tsx.mjs platform/scripts/crm-migrar-credencial.ts <id> # mueve la credencial del .env a la base, cifrada
|
||||||
|
node platform/scripts/admin-ui-check.mjs # la consola de cuentas, en navegador
|
||||||
|
```
|
||||||
|
|
||||||
|
Los tres primeros **no escriben nada**. El de escritura crea, relee y **borra en un `finally`**, así
|
||||||
|
que no deja rastro aunque falle a mitad — y antes de escribir se comprobó con `crm-spike-borrado.ts`
|
||||||
|
que el borrado existe. Preguntar si se puede deshacer **antes** de tocar el CRM de un cliente, no
|
||||||
|
después.
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
# Referencia de la API de Bucéfalo CRM
|
||||||
|
|
||||||
|
Host base: `https://services.leadconnectorhq.com` · Autenticación: `Authorization: Bearer <token privado de subcuenta>`.
|
||||||
|
|
||||||
|
Este documento es la **referencia de endpoints**. Los hallazgos empíricos —lo que se ha ejercido
|
||||||
|
contra la subcuenta real y con qué resultado— viven en [`HALLAZGOS.md`](HALLAZGOS.md), y **mandan
|
||||||
|
sobre lo que diga aquí**: la documentación oficial de esta API está incompleta o desactualizada en
|
||||||
|
varios puntos concretos, todos marcados abajo.
|
||||||
|
|
||||||
|
Cada entrada lleva su origen:
|
||||||
|
**[DOC]** de la especificación OpenAPI pública del proveedor · **[MEDIDO]** ejercido contra la
|
||||||
|
subcuenta real de este proyecto · **[INFERENCIA]** deducción no verificada, que **no debe usarse como
|
||||||
|
base para escribir código sin comprobarla antes**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## La cabecera `Version`: donde más chocan la documentación y la realidad
|
||||||
|
|
||||||
|
| Familia | Según la documentación | Lo que funciona [MEDIDO] |
|
||||||
|
|---|---|---|
|
||||||
|
| `/contacts/` | `2021-07-28` | `2021-07-28` ✅ |
|
||||||
|
| `/locations/` | `2021-07-28` | `2021-07-28` ✅ |
|
||||||
|
| `/conversations/` | `2021-04-15` | **`2021-07-28`** ⚠ |
|
||||||
|
| `/calendars/` | `2021-04-15` | **`v3`** ⚠ |
|
||||||
|
| `/opportunities/` | `2021-07-28` | `2021-07-28` ✅ |
|
||||||
|
|
||||||
|
`client.ts` la elige sola por el prefijo de la ruta. **No la cambies «para alinearla con la
|
||||||
|
documentación»**: con estos valores se ejercieron los 3 210 contactos, los 3 213 hilos y los 7
|
||||||
|
calendarios. Equivocarla es un `400`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tres convenciones de paginación distintas en la misma API
|
||||||
|
|
||||||
|
Confundirlas devuelve listas **incompletas sin ningún error**, que es la peor forma de fallar.
|
||||||
|
|
||||||
|
| Qué se pagina | Cómo | Detalle |
|
||||||
|
|---|---|---|
|
||||||
|
| Contactos | `searchAfter` | El cursor sale del **último elemento** del array, no de la raíz de la respuesta. Con `page` se topa un techo de profundidad antes de los 3 200 |
|
||||||
|
| Conversaciones | `startAfterDate` | El valor de ordenación del último hilo de la página |
|
||||||
|
| Mensajes | `lastMessageId` + `nextPage` | El id del último mensaje, y un booleano que dice si hay más |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A · Contactos
|
||||||
|
|
||||||
|
### `GET /contacts/{contactId}`
|
||||||
|
Scope `contacts.readonly`. Devuelve el contacto **envuelto en `contact`**.
|
||||||
|
**[MEDIDO]** El `404` existe aunque la documentación no lo liste.
|
||||||
|
**[MEDIDO]** `attributionSource` trae `sessionSource` y `adId` poblados, que el esquema oficial **no
|
||||||
|
declara**. `mapAtribucion()` es más fiel que el esquema.
|
||||||
|
|
||||||
|
### `POST /contacts/search`
|
||||||
|
Scope `contacts.readonly`. **El esquema del cuerpo NO está documentado**: el `$ref` del OpenAPI
|
||||||
|
apunta a un objeto vacío. Todo lo que sabemos es medido:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "locationId": "…", "pageLimit": 100, "searchAfter": [1724900000000, "seD4Pf…"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
**[MEDIDO]** 3 210 contactos en ~22 s, idempotente. **[INFERENCIA]** 100 es probablemente el máximo
|
||||||
|
de `pageLimit`; no está escrito en ningún sitio.
|
||||||
|
**[MEDIDO]** El buscador descarta `utmCampaign` y conserva `campaign`. Al dar de alta hay que mandar
|
||||||
|
**los dos**.
|
||||||
|
|
||||||
|
**Filtrar por id, teléfono o correo dentro de este buscador no está documentado en ninguna parte.**
|
||||||
|
Lo que funciona: por id → `GET /contacts/{id}`; por teléfono o correo → `GET /contacts/?query=`.
|
||||||
|
|
||||||
|
### `GET /contacts/`
|
||||||
|
**Marcado DEPRECADO** por la propia documentación. `limit` máximo 100, default 20. Útil solo para
|
||||||
|
búsquedas puntuales de 1-5 resultados, que es como lo usa `buscarPorIdentificador`.
|
||||||
|
|
||||||
|
**Trampa de forma:** aquí la atribución se llama **`attributions` (array)**; en `GET /contacts/{id}`
|
||||||
|
se llama **`attributionSource` (objeto)**. Misma información, dos formas.
|
||||||
|
|
||||||
|
### `POST /contacts/`
|
||||||
|
Scope `contacts.write`.
|
||||||
|
**[MEDIDO]** `locationId` va en el cuerpo del POST y **rompe el PUT** con `422 property locationId
|
||||||
|
should not exist`.
|
||||||
|
**[MEDIDO]** Un duplicado responde `400` **con `meta.contactId`**: es idempotencia regalada por el
|
||||||
|
servidor, y mejor que un `upsert`, cuya rama de actualización descarta la atribución.
|
||||||
|
**[MEDIDO]** La atribución **solo se escribe en el alta**; un PUT posterior devuelve `200` sin
|
||||||
|
guardar nada.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B · Conversaciones y mensajes
|
||||||
|
|
||||||
|
### `GET /conversations/search`
|
||||||
|
Scope `conversations.readonly`. Filtros documentados: `locationId` (obligatorio), **`contactId`**,
|
||||||
|
**`id`**, `assignedTo`, `followers`, `mentions`, `query`, `sort`, `sortBy`, `status`,
|
||||||
|
`lastMessageType` (43 valores), `lastMessageDirection`, `lastMessageAction`, `startDate`/`endDate`
|
||||||
|
(epoch ms), `startAfterDate`, `limit`.
|
||||||
|
|
||||||
|
### `GET /conversations/{conversationId}`
|
||||||
|
**[MEDIDO]** Devuelve los campos **en la raíz**, sin envoltorio.
|
||||||
|
**[MEDIDO, hallazgo 38] NO devuelve el nombre del contacto ni un canal reconocible** — eso solo
|
||||||
|
viene del buscador. Sincronizar un hilo por su id degradaba el nombre a «Sin nombre»; ver
|
||||||
|
`syncConversations.ts`, que ya no deja que un dato pobre pise a uno bueno.
|
||||||
|
**Trampa de forma:** aquí `type` es un **número**; en el buscador es la cadena `TYPE_PHONE`.
|
||||||
|
|
||||||
|
### `GET /conversations/{conversationId}/messages`
|
||||||
|
Scope **`conversations/message.readonly`** — distinto de `conversations.readonly`. Tener uno no da
|
||||||
|
el otro.
|
||||||
|
**[MEDIDO]** La respuesta viene **anidada dos niveles**: `{ messages: { messages: [...],
|
||||||
|
lastMessageId, nextPage } }`.
|
||||||
|
|
||||||
|
### `GET /conversations/messages/{id}`
|
||||||
|
**[MEDIDO]** Funciona y devuelve el mensaje en la raíz. El parámetro de ruta no está declarado en la
|
||||||
|
especificación — es un fallo de la documentación, no de la API.
|
||||||
|
|
||||||
|
### `POST /conversations/messages`
|
||||||
|
Scope `conversations/message.write`. Tipos: `SMS`, `RCS`, `Email`, `WhatsApp`, `IG`, `FB`, `Custom`,
|
||||||
|
`Live_Chat`, `TIKTOK`.
|
||||||
|
**[CONTRADICCIÓN]** La documentación marca `subType` y `status` como obligatorios. `enviarCorreo()`
|
||||||
|
**no manda ninguno** y responde `200`. Manda lo medido; no los añadas «por cumplir el esquema» sin
|
||||||
|
un sondeo, porque cualquier clave inesperada es `422`.
|
||||||
|
**[MEDIDO]** La respuesta es `Email queued successfully`: acuse de **encolado**, no de entrega. Y al
|
||||||
|
releer el hilo, el `status` del mensaje propio viene `null` (hallazgo 23). **No se puede afirmar que
|
||||||
|
llegó.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C · Calendarios, citas y servicios
|
||||||
|
|
||||||
|
### `GET /calendars/`
|
||||||
|
Scope `calendars.readonly`, `Version: v3`. **[MEDIDO]** 7 calendarios en la subcuenta, uno
|
||||||
|
`Servicio Spa` (`LXZIuRPYa3uCPlUlsqY7`).
|
||||||
|
|
||||||
|
### `GET /calendars/events`
|
||||||
|
Scope `calendars/events.readonly`.
|
||||||
|
**[MEDIDO, hallazgo 28]** Exige uno de `calendarId`, `userId` o `groupId`; sin ellos es
|
||||||
|
`422 Either of userId, calendarId or groupId is required`. **No existe «todas las citas de la
|
||||||
|
subcuenta»**: hay que iterar los calendarios.
|
||||||
|
**Asimetría de formato:** la **entrada** (`startTime`/`endTime` del query) va en **milisegundos
|
||||||
|
epoch**; la **salida** viene en **ISO con desplazamiento** (`2026-12-28T14:00:00-06:00`).
|
||||||
|
|
||||||
|
### `GET /calendars/events/appointments/{eventId}`
|
||||||
|
**[MEDIDO, hallazgo 43] Sigue devolviendo la cita después de borrarla** — es un borrado lógico.
|
||||||
|
Para comprobar si una cita existe, **lista el rango del calendario**; por id da un falso positivo.
|
||||||
|
|
||||||
|
### `POST /calendars/events/appointments`
|
||||||
|
Scope `calendars/events.write` — **[MEDIDO, hallazgo 33] el token lo tiene**.
|
||||||
|
Obligatorios: `calendarId`, `locationId`, `contactId`, `startTime`.
|
||||||
|
`startTime`/`endTime` en **ISO con desplazamiento**, no en epoch — al revés que el filtro de arriba.
|
||||||
|
Palancas que este proyecto usa a propósito:
|
||||||
|
- `toNotify: false` — la plataforma ya avisó a la clienta; que el CRM no lo haga otra vez.
|
||||||
|
- `ignoreFreeSlotValidation: true` — AgendaMax es la fuente de verdad del horario y su base ya
|
||||||
|
impide el solape.
|
||||||
|
|
||||||
|
**[MEDIDO, hallazgo 42]** Verificado de punta a punta: creada, releída con la **hora de pared
|
||||||
|
idéntica** a la escrita, y borrada.
|
||||||
|
|
||||||
|
### `PUT /calendars/events/appointments/{eventId}`
|
||||||
|
**No admite `locationId` ni `contactId`.** Misma trampa que en contactos: no recicles el cuerpo del
|
||||||
|
alta.
|
||||||
|
|
||||||
|
### `DELETE /calendars/events/{eventId}`
|
||||||
|
**[MEDIDO]** Existe y el token lo tiene. Borrado lógico (ver hallazgo 43).
|
||||||
|
|
||||||
|
### Servicios: `GET`/`POST /calendars/services/catalog`
|
||||||
|
Scopes `calendars.readonly` / **`calendars.write`** — **[MEDIDO, hallazgo 34] el token lo tiene**.
|
||||||
|
Un «servicio» es una prestación vendible con duración, precio, categoría, personal y variaciones:
|
||||||
|
literalmente el modelo de un spa.
|
||||||
|
|
||||||
|
**[MEDIDO, hallazgos 6 y 32] El catálogo de esta subcuenta está VACÍO.** Por eso la duración y el
|
||||||
|
precio viven en la plataforma y «sincronizar servicios» solo puede significar **publicar**.
|
||||||
|
|
||||||
|
Obligatorios: `locationId`, `name`, `slug`, **`staff[]` con al menos un miembro** (como
|
||||||
|
`[{ id: "…" }]`, no como `["…"]`).
|
||||||
|
|
||||||
|
**[MEDIDO, hallazgos 44-45] La primera publicación en una subcuenta sin catálogo falla** con
|
||||||
|
`400 No default service category found for this location` — y **ese mismo intento hace que el CRM
|
||||||
|
cree la categoría por defecto**. El reintento entra sin cambiar nada. `publicarServicio` reintenta
|
||||||
|
una vez y **solo** ante ese mensaje.
|
||||||
|
|
||||||
|
### `GET /calendars/service-categories`
|
||||||
|
**[MEDIDO, hallazgo 46]** Esta es la ruta correcta. `/calendars/services/categories` cae en el
|
||||||
|
comodín `/{serviceId}` y devuelve `404 Please provide a valid service ID`, que se lee como «no
|
||||||
|
existe el recurso» cuando en realidad significa «no existe la ruta».
|
||||||
|
|
||||||
|
### `DELETE /calendars/services/catalog/{serviceId}`
|
||||||
|
**[MEDIDO]** Existe y el token lo tiene.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D · Subcuentas
|
||||||
|
|
||||||
|
### `GET /locations/{locationId}`
|
||||||
|
Scope `locations.readonly`. **Un token privado de subcuenta basta**; no hace falta token de agencia.
|
||||||
|
|
||||||
|
**[MEDIDO] `settings` trae una quinta clave que la documentación no declara:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
"contactUniqueIdentifiers": ["email", "phone"]
|
||||||
|
```
|
||||||
|
|
||||||
|
No es un detalle: es la cadena de identidad que el CRM aplica para deduplicar, y la justificación de
|
||||||
|
que la del proyecto (id → teléfono → correo) **coincida con la suya** en vez de pelearse con ella.
|
||||||
|
|
||||||
|
`allowDuplicateOpportunity: false` es la causa del `400 OPPORTUNITY_NO_DUPLICATE`, y es **un
|
||||||
|
interruptor de la interfaz**, no un límite duro.
|
||||||
|
|
||||||
|
### Cómo validar que un token corresponde a una subcuenta
|
||||||
|
**No hay endpoint de introspección de token.** El método correcto, y el que usa
|
||||||
|
`PUT /api/admin/businesses/:id/crm`, es leer `GET /locations/{locationId}` con ese token y **comparar
|
||||||
|
`location.id` con el esperado** — identidad contra identidad, no un `200` genérico.
|
||||||
|
|
||||||
|
**[MEDIDO] El `401` de esta API es ambiguo**: significa a la vez «token caducado», «al token le falta
|
||||||
|
el scope» y (probablemente) «el token es de otra subcuenta». Por eso el mensaje de error de la
|
||||||
|
consola nombra las tres posibilidades en vez de afirmar una.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E · Límites de tasa
|
||||||
|
|
||||||
|
**[MEDIDO, hallazgo 37]** Cabeceras reales de la subcuenta:
|
||||||
|
|
||||||
|
| Cabecera | Valor observado |
|
||||||
|
|---|---|
|
||||||
|
| `x-ratelimit-max` | `100` |
|
||||||
|
| `x-ratelimit-interval-milliseconds` | `10000` |
|
||||||
|
| `x-ratelimit-limit-daily` | `200000` |
|
||||||
|
| `x-ratelimit-remaining` | va bajando en la ventana |
|
||||||
|
| `x-ratelimit-daily-remaining` | `199970` |
|
||||||
|
|
||||||
|
O sea **1 petición cada 100 ms**, no cada 650 como asumía el cliente originalmente. `client.ts` lee
|
||||||
|
esas cabeceras en cada respuesta y ajusta el intervalo con un 50 % de margen; el 650 ms queda solo
|
||||||
|
como respaldo para la primera petición de un token, antes de haber visto ninguna cabecera.
|
||||||
|
|
||||||
|
La cuota es **por aplicación y por subcuenta**: añadir cuentas no reparte el límite, cada una tiene
|
||||||
|
el suyo. Por eso el estrangulador es **por token** y no global.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## F · Scopes
|
||||||
|
|
||||||
|
```
|
||||||
|
contacts.readonly contacts.write
|
||||||
|
conversations.readonly conversations.write
|
||||||
|
conversations/message.readonly conversations/message.write
|
||||||
|
calendars.readonly calendars.write
|
||||||
|
calendars/events.readonly calendars/events.write
|
||||||
|
calendars/groups.readonly calendars/groups.write
|
||||||
|
locations.readonly locations.write
|
||||||
|
locations/customFields.* locations/customValues.* locations/tags.*
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dos parejas que se confunden fácil y dan `401` donde no se espera:**
|
||||||
|
`conversations.readonly` **no** incluye `conversations/message.readonly`, y `calendars.readonly`
|
||||||
|
**no** incluye `calendars/events.readonly`.
|
||||||
|
|
||||||
|
**[MEDIDO] Estado del token de esta subcuenta**, al 2026-08-29:
|
||||||
|
|
||||||
|
| Scope | Estado |
|
||||||
|
|---|---|
|
||||||
|
| contactos, conversaciones, mensajes, subcuenta | ✔ |
|
||||||
|
| `calendars/events.write` | ✔ (hallazgo 33) |
|
||||||
|
| `calendars.write` | ✔ (hallazgo 34) |
|
||||||
|
| usuarios | ✔ **hoy** — daba `401` cuando se midió por primera vez (hallazgos 14 → 35) |
|
||||||
|
| crear pipelines | ✖ `401 The token is not authorized for this scope` |
|
||||||
|
|
||||||
|
**Un permiso medido una vez no queda medido para siempre.** El de usuarios cambió entre dos
|
||||||
|
mediciones porque alguien tocó el token. Conviene comprobar los permisos al vincular una subcuenta y
|
||||||
|
volver a hacerlo cuando algo falle con `401`, en vez de fiarse de una tabla escrita en el pasado.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lo que NO está documentado y no debe inventarse
|
||||||
|
|
||||||
|
1. El esquema del cuerpo de `POST /contacts/search`: filtros, operadores y campos filtrables. El
|
||||||
|
OpenAPI lo declara como objeto vacío.
|
||||||
|
2. El máximo real de `pageLimit`.
|
||||||
|
3. El valor del techo de profundidad al paginar por número de página. Que existe está medido; cuánto
|
||||||
|
es, no.
|
||||||
|
4. El cuerpo de la respuesta `429` y la política de reintento recomendada.
|
||||||
|
5. Si los tokens privados tienen cuota distinta de las aplicaciones de marketplace.
|
||||||
|
6. Un endpoint de introspección de token.
|
||||||
|
7. El código exacto que devuelve un token de **otra** subcuenta. Se infiere `401`; comprobarlo exige
|
||||||
|
un segundo token y este proyecto solo tiene uno.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Webhooks
|
||||||
|
|
||||||
|
**Exigen OAuth.** Un token privado de subcuenta no puede suscribirse, así que **la entrada de datos
|
||||||
|
es por sondeo**: los botones de sincronización y `POST /api/crm/sync/:entidad/:id`. Si algún día se
|
||||||
|
quiere tiempo real, hay que pasar por OAuth, y eso es un entregable propio.
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# Hallazgos medidos contra Bucéfalo CRM
|
||||||
|
|
||||||
|
Subcuenta **Yola Franco Spa** (`Pk89Wa23QaxvkOfKgwjZ`) · **2026-08-29** · token PIT de subcuenta.
|
||||||
|
|
||||||
|
Todo lo de aquí se ejerció contra el CRM real y **se verificó releyendo**, nunca aceptando un
|
||||||
|
`200` como prueba. Los spikes que lo produjeron están en `platform/scripts/crm-spike*.ts` y se
|
||||||
|
pueden volver a correr.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lo que quedó confirmado
|
||||||
|
|
||||||
|
| # | Hallazgo | Consecuencia |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | La subcuenta responde: `Yola Franco Spa`, tz `America/Mexico_City`, país `MX` | El token y el `locationId` son correctos |
|
||||||
|
| 2 | **3 209 contactos** y **3 210 conversaciones** | Hay material real que sincronizar |
|
||||||
|
| 3 | `attributionSource` viene **poblado** con datos reales (`sessionSource`, `medium`, `campaign`, `campaignId`, `adId`, `utmMedium`, `utmContent`) | La atribución UTM que pide el proyecto **existe y se puede traer** |
|
||||||
|
| 4 | Pipeline único: **`Standar`** `Mrclt4VzRZV1DI4Vbt5c`, 9 etapas, con **`Ganado`** (`b91c1653-…`) y **`Perdido`** (`04b28d7f-…`) | Hay dónde aterrizar `won` y `lost` sin inventar nada |
|
||||||
|
| 5 | **SÍ existen 7 calendarios**, uno de ellos `Servicio Spa` (`LXZIuRPYa3uCPlUlsqY7`) | Resuelve la pregunta que el análisis previo marcaba como bloqueante |
|
||||||
|
| 6 | **`GET /calendars/services/catalog` devuelve `services: []`** | El catálogo de servicios del CRM está **vacío**: la duración y el precio tienen que vivir en la plataforma. Confirma la sospecha previa |
|
||||||
|
| 7 | `POST /contacts/` con `attributionSource` → **persiste íntegro** (verificado releyendo) | Se puede dar de alta con UTM completo |
|
||||||
|
| 8 | `POST /contacts/` duplicado → **`400` con `meta.contactId` y `meta.matchingField`** | **Idempotencia real y gratuita.** Es mejor que `upsert`, cuya rama *actualizar* descarta la atribución |
|
||||||
|
| 9 | `POST /opportunities/` → crea y **el importe persiste** | El valor del servicio llega al CRM |
|
||||||
|
| 10 | `POST /conversations/messages` con `type: "Email"` → `200` `Email queued successfully` con `conversationId`, `messageId`, `threadId` | Hay canal de vuelta para probar mensajes |
|
||||||
|
|
||||||
|
## Lo que NO funciona como uno esperaría
|
||||||
|
|
||||||
|
| # | Hallazgo | Cómo se sortea |
|
||||||
|
|---|---|---|
|
||||||
|
| 11 | **`PUT /opportunities/{id}/status` rechaza `pipelineStageId`** con `422 property pipelineStageId should not exist` | El estado y la etapa se cambian en **dos llamadas**: `/status` con solo `status`, y `PUT /opportunities/{id}` con `pipelineId` + `pipelineStageId` |
|
||||||
|
| 12 | **`POST /opportunities/` rechaza una segunda oportunidad del mismo contacto aunque la primera esté en `won`** (`400 OPPORTUNITY_NO_DUPLICATE` con `meta.existingId`) | Ver «La decisión de las oportunidades» abajo |
|
||||||
|
| 13 | La causa es el ajuste **`settings.allowDuplicateOpportunity: false`** de la subcuenta | **Es un ajuste, no un límite duro.** El cliente puede activarlo |
|
||||||
|
| 14 | ~~`GET /users/?locationId` → **`401` fuera de scope**~~ **OBSOLETO — ver hallazgo 35: hoy responde `200`** | El token PIT no listaba personal. Volvió a medirse el 2026-08-29 y sí lo lista |
|
||||||
|
| 15 | `POST /opportunities/pipelines` → **`401` `The token is not authorized for this scope`** | `pipelines.create` no está en el token. Rediseñar el pipeline a etapas de spa es trabajo de UI, no de código |
|
||||||
|
| 16 | `GET /contacts/{id}/opportunities` → **`404`, la ruta no existe** | Se usa `GET /opportunities/search?location_id=&contact_id=`, que sí funciona |
|
||||||
|
|
||||||
|
## Lo que el CRM usa para deduplicar, y coincide con lo pedido
|
||||||
|
|
||||||
|
`GET /locations/{id}` devuelve:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"settings": {
|
||||||
|
"allowDuplicateContact": false,
|
||||||
|
"allowDuplicateOpportunity": false,
|
||||||
|
"contactUniqueIdentifiers": ["email", "phone"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
La cadena de identidad pedida para el proyecto —**id de contacto → teléfono → correo**— es
|
||||||
|
exactamente la que el CRM aplica. Con `allowDuplicateContact: false`, el propio CRM devuelve el
|
||||||
|
`contactId` existente en el `400`: la deduplicación no hay que construirla, hay que **leerla del
|
||||||
|
rechazo**.
|
||||||
|
|
||||||
|
## La decisión de las oportunidades
|
||||||
|
|
||||||
|
El encargo es «una cita = una oportunidad», con el nombre `SERVICIO + NOMBRE CONTACTO`, el importe
|
||||||
|
del servicio, y `open` / `won` / `lost` según el estado. El hallazgo 12 lo impide **hoy**: con
|
||||||
|
`allowDuplicateOpportunity: false`, una clienta que vuelve por segunda vez no puede estrenar
|
||||||
|
oportunidad, y una clienta de spa vuelve muchas veces.
|
||||||
|
|
||||||
|
Se implementan los dos caminos y la plataforma elige solo, sin configuración:
|
||||||
|
|
||||||
|
1. **Intenta crear.** Si el CRM la acepta, una cita = una oportunidad, tal como se pidió.
|
||||||
|
2. **Si responde `400 OPPORTUNITY_NO_DUPLICATE`**, toma el `meta.existingId` y **recicla esa
|
||||||
|
oportunidad**: le pone el nombre de la cita nueva, su importe y su estado. Verificado que se
|
||||||
|
puede renombrar, cambiar el importe, cerrar y **reabrir** una ya cerrada.
|
||||||
|
|
||||||
|
Con el ajuste desactivado, la oportunidad representa *la cita vigente de la clienta* y el histórico
|
||||||
|
completo vive en AgendaMax. Con el ajuste activado, el modelo pasa a ser el pedido **sin tocar una
|
||||||
|
línea de código**.
|
||||||
|
|
||||||
|
> **Para que sea «una cita = una oportunidad» hace falta que alguien active
|
||||||
|
> _Allow Duplicate Opportunity_ en los ajustes de la subcuenta.** Es un interruptor de la UI del
|
||||||
|
> CRM; el token no puede cambiarlo. Mientras tanto el MVP funciona reciclando.
|
||||||
|
|
||||||
|
## Cabeceras y trampas
|
||||||
|
|
||||||
|
- `Version: 2021-07-28` para contactos, oportunidades y conversaciones; **`Version: v3` para todo
|
||||||
|
`/calendars/`**. Equivocarla es `400`.
|
||||||
|
- `locationId` **va en el cuerpo del `POST /contacts/`** y **rompe el `PUT`** (`422 property
|
||||||
|
locationId should not exist`). Es una asimetría fácil de cruzar reciclando código.
|
||||||
|
- Hay lista blanca de propiedades: cualquier clave desconocida es `422` y no crea nada. Es un fallo
|
||||||
|
seguro y sirve de herramienta de descubrimiento.
|
||||||
|
- La atribución **es de una sola oportunidad**: se escribe en el alta y un `PUT` posterior devuelve
|
||||||
|
`200` sin guardar nada.
|
||||||
|
- `campaign` hay que mandarlo **además** de `utmCampaign`: el buscador de contactos descarta
|
||||||
|
`utmCampaign` y conserva `campaign`.
|
||||||
|
|
||||||
|
## Hallazgos posteriores, ya con la integración escrita
|
||||||
|
|
||||||
|
| # | Hallazgo | Consecuencia |
|
||||||
|
|---|---|---|
|
||||||
|
| 17 | **`PUT .../status` mueve la etapa por su cuenta.** Con la etapa escrita primero y el estado después, el CRM la devolvió de «Ganado» a «Cotización Aceptada» | El orden correcto es **estado primero, etapa después** |
|
||||||
|
| 18 | Ese movimiento es **asíncrono y gana igualmente en `won`**: se reescribió y releyó tres veces y el CRM la volvió a mover después de que la relectura ya confirmaba la nuestra. En `lost` sí respeta «Perdido» | Hay una regla del lado del CRM que gobierna la etapa en `won`. **No se pelea con ella**: lo que el negocio pidió mapear es el `status`, y ese sí queda estable |
|
||||||
|
| 19 | El buscador de contactos pagina con **`searchAfter`**, tomado del último contacto de la página anterior | Con `page` se topa un techo de profundidad mucho antes de los 3 200 |
|
||||||
|
| 20 | Sincronización completa medida: **3 210 contactos en 22 s**, idempotente (segunda corrida: 0 creados, 3 210 actualizados) | El botón puede correr en primer plano sin tarea de fondo |
|
||||||
|
| 21 | Calidad real de los contactos traídos: **59,4 % con teléfono normalizable**, **8 con correo** de 3 215, 801 con campaña, 824 con anuncio | Coincide con la auditoría independiente previa (59,8 % y 0,2 %). **Cuatro de cada diez clientas no son contactables** |
|
||||||
|
| 22 | El prefijo `521` heredado de mensajería aparece en los teléfonos reales (`+5215656592254`) y se colapsa bien a `+525656592254`. **Cero duplicados** por teléfono tras sincronizar 3 210 | La deduplicación por E.164 funciona sobre datos reales |
|
||||||
|
| 23 | El mensaje enviado **aparece al releer el hilo**, pero su `status` viene `null` | El CRM no expone el estado de entrega ahí: sigue sin poder afirmarse que llegó |
|
||||||
|
|
||||||
|
## Lo que sigue sin verificarse
|
||||||
|
|
||||||
|
- **Que el correo se entregue.** `Email queued successfully` es acuse de encolado, no de entrega.
|
||||||
|
Exige mirar una bandeja real.
|
||||||
|
- **WhatsApp y SMS**: no están conectados en la subcuenta. Fuera del MVP.
|
||||||
|
- **Escritura de citas al calendario del CRM** (`POST /calendars/events/appointments`): no se ha
|
||||||
|
ejercido. El MVP proyecta las citas como oportunidades, no como eventos de calendario.
|
||||||
|
- **Webhooks**: exigen OAuth, que este token no es. La sincronización de entrada es por sondeo.
|
||||||
|
|
||||||
|
## Datos de prueba creados en la subcuenta real
|
||||||
|
|
||||||
|
Contacto `WzBTBaHkNnpmjMb1Avx3` (`urieljareth@grupo-e3.com`, tag `agendamax:prueba`) y su
|
||||||
|
oportunidad `IMkYdAkBowggN9aKVbfc`. Se limpian con
|
||||||
|
`node scripts/run-tsx.mjs platform/scripts/crm-limpiar-pruebas.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sondeo de lectura por id — 2026-08-29
|
||||||
|
|
||||||
|
Medido con `crm-spike-lectura-id.ts` y `crm-spike-calendarios.ts`, ambos **solo lectura**. Cubre
|
||||||
|
las cinco entidades que pide la sincronización por id: contactos, conversaciones, mensajes, citas
|
||||||
|
y servicios.
|
||||||
|
|
||||||
|
| # | Hallazgo | Consecuencia |
|
||||||
|
|---|---|---|
|
||||||
|
| 24 | `GET /conversations/{id}` **funciona** y trae `contactId`, `messageTypes`, `unreadCount`, `firstUnreadInboundMessageId` | Se puede anclar una conversación por su id sin recorrer la lista |
|
||||||
|
| 25 | `GET /conversations/search?contactId=…` **filtra por contacto** | Es el camino para «las conversaciones de esta clienta» sin traerse las 3 213 |
|
||||||
|
| 26 | `GET /conversations/{id}/messages` pagina con **`lastMessageId` + `nextPage`**, no con `page` ni `searchAfter` | Tercera convención de paginación distinta en la misma API. No reciclar la de contactos |
|
||||||
|
| 27 | **`GET /conversations/messages/{id}` funciona**: trae un mensaje suelto por su id, con `from`, `messageType`, `contentType`, `meta` | Permite reconciliar un mensaje concreto sin releer el hilo entero |
|
||||||
|
| 28 | `GET /calendars/events` **exige** uno de `userId`, `calendarId` o `groupId`; sin ellos es `422`. Con un `userId` inexistente es `400 User with id … not found` | No hay forma de pedir «todas las citas de la subcuenta» en una llamada: hay que iterar los calendarios |
|
||||||
|
| 29 | **La subcuenta tiene 1 sola cita en total** en los 7 calendarios, en una ventana de 2 años atrás y 1 adelante, y está en **`Servicio Spa`** (`LXZIuRPYa3uCPlUlsqY7`) | El calendario del CRM está prácticamente sin usar. La agenda real no vive ahí: nace en la plataforma. Confirma que la sincronización de citas es **empuje**, no arrastre |
|
||||||
|
| 30 | Forma del evento: `appointmentStatus`, `assignedUserId`, `calendarId`, `contactId`, `startTime`, `endTime`, `dateAdded`, `address`. Devuelve **además** `appoinmentStatus` — con la errata — con el mismo valor | Si se lee el estado, leer `appointmentStatus` y tolerar la errata: es del CRM, no nuestra |
|
||||||
|
| 31 | `GET /calendars/groups` → 0 grupos | No hay agrupación que aprovechar |
|
||||||
|
| 32 | `GET /calendars/services/catalog` → `services: []` **reconfirmado** | El catálogo de servicios del CRM sigue vacío. Duración y precio viven en la plataforma, y «sincronizar servicios» no puede significar traerlos de allá |
|
||||||
|
|
||||||
|
### Lo que esto decide
|
||||||
|
|
||||||
|
- **Contactos, conversaciones y mensajes**: los tres se pueden traer por id concreto. La
|
||||||
|
sincronización selectiva que pide el encargo es viable tal cual, sin rodeos.
|
||||||
|
- **Citas**: no hay nada que arrastrar. La dirección útil es empujar la cita de la plataforma al
|
||||||
|
calendario del CRM (`POST /calendars/events/appointments`, **aún sin ejercer**) además de la
|
||||||
|
oportunidad que ya se proyecta.
|
||||||
|
- **Servicios**: no hay catálogo en el CRM que sincronizar. Lo único con sentido es publicar hacia
|
||||||
|
allá los de la plataforma, y eso exige comprobar antes si el token tiene permiso de escritura
|
||||||
|
sobre `/calendars/services` — no se ha probado.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sondeo de permisos y de límites — 2026-08-29
|
||||||
|
|
||||||
|
Medido con `crm-spike-permisos.ts`. La técnica no crea nada: se manda un `POST` **deliberadamente
|
||||||
|
incompleto** y se mira qué error vuelve. Un `401 not authorized for this scope` significa que falta
|
||||||
|
el permiso; un `422` sobre los campos significa que el permiso está y lo que falla es el cuerpo.
|
||||||
|
Distinguir esas dos cosas era lo único que faltaba para saber si se puede planificar escritura, y
|
||||||
|
no costó un solo registro basura en la subcuenta del cliente.
|
||||||
|
|
||||||
|
| # | Hallazgo | Consecuencia |
|
||||||
|
|---|---|---|
|
||||||
|
| 33 | **`calendars/events.write` SÍ está.** `POST /calendars/events/appointments` responde `422 calendarId should not be empty · startTime must be a valid ISO 8601 date string`, no `401` | **Se pueden escribir citas al calendario del CRM.** Deja de ser una incógnita: el MVP puede proyectar la cita como evento además de como oportunidad |
|
||||||
|
| 34 | **`calendars.write` SÍ está.** `POST /calendars/services/catalog` responde `422 name should not be empty · At least one staff member is required` | **Se puede poblar el catálogo de servicios del CRM.** Que esté vacío (hallazgo 6) no es un límite de la API: es que nadie lo llenó |
|
||||||
|
| 35 | **`GET /users/?locationId` responde `200` con 6 usuarios.** Contradice el hallazgo 14, medido semanas antes | El token tiene ahora permiso de personal. Esto **desbloquea el `staff[]` obligatorio** del alta de servicios, que era el impedimento práctico del hallazgo 34. Ids disponibles, entre ellos `6HCOVjDvvdUlv1bjbR7W` (Yola Spa Recepción), que es el `assignedUserId` de la única cita real |
|
||||||
|
| 36 | `GET /contacts/{id}/appointments` responde `200` con `{"events":[]}` | Hay una ruta directa para «las citas de esta clienta» sin recorrer calendarios. Devuelve vacío porque la subcuenta casi no tiene citas (hallazgo 29) |
|
||||||
|
| 37 | **Cabeceras de límite reales**: `x-ratelimit-max: 100`, `x-ratelimit-interval-milliseconds: 10000`, `x-ratelimit-limit-daily: 200000` | La cuota es **1 petición cada 100 ms**, no cada 650. El cliente estrangula **6,5× por debajo** de lo permitido. Bajar `MIN_INTERVAL_MS` acortaría la sincronización de 22 s a ~4 s. Hasta hoy nadie leía esas cabeceras: el 650 ms era una estimación observada, no una cuota conocida |
|
||||||
|
|
||||||
|
### Lo que esto cambia
|
||||||
|
|
||||||
|
- **Las cinco entidades del encargo son viables.** Contactos, conversaciones y mensajes se leen por
|
||||||
|
id (24-27); las citas se pueden **escribir** al calendario (33) además de proyectarse como
|
||||||
|
oportunidad; y los servicios se pueden **publicar** al catálogo (34) ahora que hay ids de personal
|
||||||
|
(35). Ninguna queda bloqueada por permisos.
|
||||||
|
- **«Sincronizar servicios» solo puede significar empujar**, nunca traer: el catálogo del CRM está
|
||||||
|
vacío y la duración y el precio los define el negocio en la plataforma.
|
||||||
|
- **Un permiso medido una vez no queda medido para siempre.** El hallazgo 14 era cierto cuando se
|
||||||
|
midió y hoy es falso, porque alguien cambió el token o sus permisos. Conviene que la plataforma
|
||||||
|
compruebe los permisos al vincular una subcuenta y lo vuelva a hacer cuando algo falle con `401`,
|
||||||
|
en vez de fiarse de una tabla escrita en el pasado.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejercido con la sincronización por id escrita — 2026-08-29
|
||||||
|
|
||||||
|
| # | Hallazgo | Consecuencia |
|
||||||
|
|---|---|---|
|
||||||
|
| 38 | **`GET /conversations/{id}` no devuelve el nombre del contacto ni un canal reconocible.** Solo el buscador los trae | Sincronizar un hilo por su id **degradaba** un nombre bueno a «Sin nombre» y el canal a «Desconocido». El upsert ya no deja que un dato pobre pise a uno que ya se tenía, y el nombre se toma de la clienta enlazada |
|
||||||
|
| 39 | El canal del hilo **se puede deducir de su último mensaje**, que sí lo trae | Evita el «Desconocido» sin gastar una petición más. Se excluyen los `TYPE_ACTIVITY_*`, que son notas del propio CRM y no un canal por el que hablar con la clienta |
|
||||||
|
| 40 | Los hilos reales traen **`TYPE_INSTAGRAM`** y **`TYPE_ACTIVITY_OPPORTUNITY`**, que no estaban en ningún mapa | Instagram es un canal de verdad de este negocio; las actividades no lo son y conviene distinguirlas en la bandeja |
|
||||||
|
| 41 | Sincronización por id verificada de punta a punta contra la subcuenta real: contacto (`creado`), conversación (`espejada`, 3 mensajes) y mensaje suelto (`espejado con su hilo`) | Las tres entidades que el CRM posee se pueden traer una a una. La conversación quedó enlazada con la clienta local por `crm_contact_id` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Primera escritura de citas y servicios al CRM — 2026-08-29
|
||||||
|
|
||||||
|
Ejercida con `crm-spike-escritura-cita-servicio.ts`, que crea, **relee**, borra y
|
||||||
|
**confirma el borrado** en la misma corrida. Nada quedó en la subcuenta: la limpieza va en un
|
||||||
|
`finally`, así que se ejecuta aunque el sondeo falle a mitad. Antes se comprobó con
|
||||||
|
`crm-spike-borrado.ts` que el borrado existe — preguntar si se puede deshacer **antes** de escribir
|
||||||
|
en el CRM de un cliente real, no después.
|
||||||
|
|
||||||
|
| # | Hallazgo | Consecuencia |
|
||||||
|
|---|---|---|
|
||||||
|
| 42 | **Escribir una cita al calendario funciona.** `POST /calendars/events/appointments` la crea, se relee con el contacto correcto, el estado `confirmed`, y **la hora de pared idéntica a la escrita**: se mandó `2026-12-28T14:00:00-06:00` y se releyó igual | La proyección de citas al calendario deja de ser una incógnita. Y confirma que `isoConDesplazamiento` acierta: escribir con `toISOString()` habría movido la hora que el CRM enseña |
|
||||||
|
| 43 | **`GET /calendars/events/appointments/{id}` SIGUE devolviendo la cita después de borrarla.** El listado del rango sí deja de incluirla | Es un borrado lógico. Comprobar existencia por id da un falso positivo: **la comprobación fiable es listar el calendario** |
|
||||||
|
| 44 | **La primera publicación de un servicio falla** con `400 No default service category found for this location`, aunque `staff`, `name` y `slug` sean correctos | La documentación marca `serviceCategoryId` como opcional. No lo es cuando la subcuenta no tiene ninguna categoría |
|
||||||
|
| 45 | **Ese mismo intento fallido hace que el CRM cree la categoría por defecto** (`GET /calendars/service-categories` la devuelve con `isSystemGenerated: true` y fecha del segundo del fallo). El reintento entra sin cambiar nada | `publicarServicio` reintenta **una vez** y **solo** ante ese mensaje. Reintentar un POST a ciegas fabrica duplicados |
|
||||||
|
| 46 | La ruta de categorías es **`/calendars/service-categories`**, no `/calendars/services/categories` — esta última cae en el comodín `/{serviceId}` y devuelve `404 Please provide a valid service ID` | Un 404 con ese texto significa «la ruta no existe», no «el recurso no existe». Es fácil de leer al revés |
|
||||||
|
| 47 | Publicar servicio verificado de punta a punta: catálogo **0 → 1**, con la duración correcta, y borrado después dejándolo en 0 | Las cinco entidades del encargo quedan ejercidas contra el CRM real |
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { isoConDesplazamiento, estadoCitaCrm } from "./calendars.ts";
|
||||||
|
|
||||||
|
// La suite corre con la zona del proceso FIJADA a otra distinta de la del
|
||||||
|
// negocio, para que un cálculo que se ancle a la del proceso falle aquí.
|
||||||
|
process.env.TZ = "UTC";
|
||||||
|
|
||||||
|
test("isoConDesplazamiento escribe la hora de pared del negocio con su desplazamiento", () => {
|
||||||
|
// 2026-09-03 11:00 en México = 17:00Z
|
||||||
|
const d = new Date("2026-09-03T17:00:00Z");
|
||||||
|
assert.equal(isoConDesplazamiento(d, "America/Mexico_City"), "2026-09-03T11:00:00-06:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isoConDesplazamiento no usa la zona del proceso", () => {
|
||||||
|
const d = new Date("2026-09-03T17:00:00Z");
|
||||||
|
assert.equal(isoConDesplazamiento(d, "UTC"), "2026-09-03T17:00:00+00:00");
|
||||||
|
// Misma entrada, dos zonas, dos horas de pared distintas.
|
||||||
|
assert.notEqual(
|
||||||
|
isoConDesplazamiento(d, "UTC"),
|
||||||
|
isoConDesplazamiento(d, "America/Mexico_City")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isoConDesplazamiento: la medianoche se escribe 00, no 24", () => {
|
||||||
|
// 00:00 del 4 de septiembre en México = 06:00Z
|
||||||
|
const d = new Date("2026-09-04T06:00:00Z");
|
||||||
|
assert.equal(isoConDesplazamiento(d, "America/Mexico_City"), "2026-09-04T00:00:00-06:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isoConDesplazamiento: una cita de la tarde cae en el día correcto", () => {
|
||||||
|
// 19:00 de México del día 3 = 01:00Z del día 4. El día de pared es el 3.
|
||||||
|
const d = new Date("2026-09-04T01:00:00Z");
|
||||||
|
assert.equal(isoConDesplazamiento(d, "America/Mexico_City"), "2026-09-03T19:00:00-06:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("estadoCitaCrm traduce los estados de la plataforma a los del CRM", () => {
|
||||||
|
assert.equal(estadoCitaCrm("scheduled"), "confirmed");
|
||||||
|
assert.equal(estadoCitaCrm("completed"), "showed");
|
||||||
|
assert.equal(estadoCitaCrm("no_show"), "noshow");
|
||||||
|
assert.equal(estadoCitaCrm("cancelled"), "cancelled");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("estadoCitaCrm: un estado que no conocemos no inventa, cae en confirmed", () => {
|
||||||
|
assert.equal(estadoCitaCrm("lo-que-sea"), "confirmed");
|
||||||
|
});
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { crmRequest, CrmError, VERSION_CALENDARS } from "./client.ts";
|
||||||
|
import type { CrmCtx } from "./ctx.ts";
|
||||||
|
|
||||||
|
export interface CrmCalendar {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
calendarType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrmEvent {
|
||||||
|
id: string;
|
||||||
|
calendarId: string;
|
||||||
|
contactId?: string;
|
||||||
|
title?: string;
|
||||||
|
appointmentStatus?: string;
|
||||||
|
assignedUserId?: string;
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AltaCita {
|
||||||
|
calendarId: string;
|
||||||
|
contactId: string;
|
||||||
|
/** ISO con desplazamiento, no epoch. Ver `isoConDesplazamiento`. */
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
title: string;
|
||||||
|
assignedUserId?: string;
|
||||||
|
appointmentStatus?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ISO con el desplazamiento horario del NEGOCIO.
|
||||||
|
*
|
||||||
|
* El CRM acepta `2026-09-03T11:00:00-06:00` en el alta de citas, y **no**
|
||||||
|
* milisegundos — al revés que el filtro de rango de `/calendars/events`, que sí
|
||||||
|
* los exige. Esa asimetría es de la API, no nuestra.
|
||||||
|
*
|
||||||
|
* Y no vale `toISOString()`: devuelve UTC con `Z`, y aunque el instante sea el
|
||||||
|
* mismo, la hora de pared que el CRM enseña en su interfaz sale de lo que se
|
||||||
|
* escribe aquí. Se construye con `Intl` y nunca con `new Date(y, m, d, …)`, que
|
||||||
|
* resuelve el reloj en la zona del proceso — el error que ya costó un fallo de
|
||||||
|
* producción en este repo (ver la sección de zonas horarias de CLAUDE.md).
|
||||||
|
*/
|
||||||
|
export function isoConDesplazamiento(d: Date, tz: string): string {
|
||||||
|
const zona = tz || "America/Mexico_City";
|
||||||
|
const p = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: zona,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
}).formatToParts(d);
|
||||||
|
const g = (t: string) => p.find((x) => x.type === t)!.value;
|
||||||
|
|
||||||
|
const off = new Intl.DateTimeFormat("en-US", { timeZone: zona, timeZoneName: "longOffset" })
|
||||||
|
.formatToParts(d)
|
||||||
|
.find((x) => x.type === "timeZoneName")!.value;
|
||||||
|
const m = off.match(/GMT([+-])(\d{2}):(\d{2})/);
|
||||||
|
const desp = m ? `${m[1]}${m[2]}:${m[3]}` : "+00:00";
|
||||||
|
|
||||||
|
// `en-CA` con hour12:false puede rendir la medianoche como 24; el CRM espera 00.
|
||||||
|
const hora = g("hour") === "24" ? "00" : g("hour");
|
||||||
|
return `${g("year")}-${g("month")}-${g("day")}T${hora}:${g("minute")}:${g("second")}${desp}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado de la cita de la plataforma → estado del CRM.
|
||||||
|
*
|
||||||
|
* En la PETICIÓN el enum admite `new|confirmed|cancelled|showed|noshow|invalid`.
|
||||||
|
* En la respuesta hay dos más (`active`, `completed`) que el CRM asigna por su
|
||||||
|
* cuenta y no se pueden escribir.
|
||||||
|
*/
|
||||||
|
export function estadoCitaCrm(estado: string): string {
|
||||||
|
switch (estado) {
|
||||||
|
case "completed":
|
||||||
|
return "showed";
|
||||||
|
case "no_show":
|
||||||
|
return "noshow";
|
||||||
|
case "cancelled":
|
||||||
|
return "cancelled";
|
||||||
|
default:
|
||||||
|
return "confirmed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listarCalendarios(ctx: CrmCtx): Promise<CrmCalendar[]> {
|
||||||
|
const r = await crmRequest<any>("GET", "/calendars/", {
|
||||||
|
token: ctx.token,
|
||||||
|
query: { locationId: ctx.locationId },
|
||||||
|
version: VERSION_CALENDARS,
|
||||||
|
});
|
||||||
|
return r?.calendars ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* El personal de la subcuenta.
|
||||||
|
*
|
||||||
|
* MEDIDO (hallazgo 35): esta ruta devolvía `401` cuando se midió por primera vez
|
||||||
|
* y hoy responde `200` con 6 usuarios. Da los ids que `staff[]` exige al crear
|
||||||
|
* servicios y `assignedUserId` al crear citas. Si vuelve a dar 401, quien llame
|
||||||
|
* debe poder seguir sin ella, no romperse.
|
||||||
|
*/
|
||||||
|
export async function listarPersonal(ctx: CrmCtx): Promise<{ id: string; name: string }[]> {
|
||||||
|
const r = await crmRequest<any>("GET", "/users/", {
|
||||||
|
token: ctx.token,
|
||||||
|
query: { locationId: ctx.locationId },
|
||||||
|
});
|
||||||
|
return (r?.users ?? []).map((u: any) => ({ id: u.id, name: u.name ?? "" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function obtenerCita(ctx: CrmCtx, eventId: string): Promise<CrmEvent | null> {
|
||||||
|
try {
|
||||||
|
const r = await crmRequest<any>("GET", `/calendars/events/appointments/${eventId}`, {
|
||||||
|
token: ctx.token,
|
||||||
|
version: VERSION_CALENDARS,
|
||||||
|
});
|
||||||
|
return (r?.event ?? r?.appointment ?? r) as CrmEvent;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof CrmError && e.status === 404) return null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Citas de un calendario en un rango.
|
||||||
|
*
|
||||||
|
* MEDIDO (hallazgo 28): sin `calendarId`, `userId` o `groupId` la API responde
|
||||||
|
* `422 Either of userId, calendarId or groupId is required`. No existe «dame
|
||||||
|
* todas las citas de la subcuenta»: hay que iterar los calendarios.
|
||||||
|
*
|
||||||
|
* El rango va en **milisegundos epoch**, al revés que el alta.
|
||||||
|
*/
|
||||||
|
export async function citasEnRango(
|
||||||
|
ctx: CrmCtx,
|
||||||
|
calendarId: string,
|
||||||
|
desdeMs: number,
|
||||||
|
hastaMs: number
|
||||||
|
): Promise<CrmEvent[]> {
|
||||||
|
const r = await crmRequest<any>("GET", "/calendars/events", {
|
||||||
|
token: ctx.token,
|
||||||
|
version: VERSION_CALENDARS,
|
||||||
|
query: {
|
||||||
|
locationId: ctx.locationId,
|
||||||
|
calendarId,
|
||||||
|
startTime: String(desdeMs),
|
||||||
|
endTime: String(hastaMs),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return r?.events ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function crearCita(ctx: CrmCtx, a: AltaCita): Promise<{ id: string }> {
|
||||||
|
const r = await crmRequest<any>("POST", "/calendars/events/appointments", {
|
||||||
|
token: ctx.token,
|
||||||
|
version: VERSION_CALENDARS,
|
||||||
|
body: {
|
||||||
|
// `locationId` va en el POST y ROMPE el PUT con 422. No reciclar el cuerpo
|
||||||
|
// del alta para actualizar: es la misma trampa ya medida en contactos.
|
||||||
|
locationId: ctx.locationId,
|
||||||
|
calendarId: a.calendarId,
|
||||||
|
contactId: a.contactId,
|
||||||
|
startTime: a.startTime,
|
||||||
|
endTime: a.endTime,
|
||||||
|
title: a.title,
|
||||||
|
appointmentStatus: a.appointmentStatus ?? "confirmed",
|
||||||
|
...(a.assignedUserId ? { assignedUserId: a.assignedUserId } : {}),
|
||||||
|
// La plataforma ya avisó a la clienta: que el CRM no dispare además sus
|
||||||
|
// automatizaciones y le llegue el mismo aviso dos veces.
|
||||||
|
toNotify: false,
|
||||||
|
// AgendaMax es la fuente de verdad del horario, y su base ya impide el
|
||||||
|
// solape con una restricción de exclusión. Que el CRM no rechace por su
|
||||||
|
// propia idea de disponibilidad, que no conoce la agenda real.
|
||||||
|
ignoreFreeSlotValidation: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const id = r?.id ?? r?.event?.id ?? r?.appointment?.id;
|
||||||
|
if (!id) throw new Error("El CRM aceptó la cita pero no devolvió su identificador");
|
||||||
|
return { id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Actualiza una cita ya escrita. Sin `locationId` ni `contactId`: el PUT los rechaza. */
|
||||||
|
export async function actualizarCita(
|
||||||
|
ctx: CrmCtx,
|
||||||
|
eventId: string,
|
||||||
|
cambios: Partial<Omit<AltaCita, "contactId">>
|
||||||
|
): Promise<void> {
|
||||||
|
await crmRequest("PUT", `/calendars/events/appointments/${eventId}`, {
|
||||||
|
token: ctx.token,
|
||||||
|
version: VERSION_CALENDARS,
|
||||||
|
body: {
|
||||||
|
...(cambios.calendarId ? { calendarId: cambios.calendarId } : {}),
|
||||||
|
...(cambios.startTime ? { startTime: cambios.startTime } : {}),
|
||||||
|
...(cambios.endTime ? { endTime: cambios.endTime } : {}),
|
||||||
|
...(cambios.title ? { title: cambios.title } : {}),
|
||||||
|
...(cambios.appointmentStatus
|
||||||
|
? { appointmentStatus: cambios.appointmentStatus }
|
||||||
|
: {}),
|
||||||
|
toNotify: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
esperaDeToken,
|
||||||
|
registrarPeticion,
|
||||||
|
MIN_INTERVAL_MS,
|
||||||
|
anotarLimites,
|
||||||
|
limitesDe,
|
||||||
|
intervaloDe,
|
||||||
|
} from "./client.ts";
|
||||||
|
|
||||||
|
test("el estrangulador cuenta por token, no globalmente", () => {
|
||||||
|
const ahora = 1_000_000;
|
||||||
|
registrarPeticion("token-A", ahora);
|
||||||
|
// El mismo token tiene que esperar…
|
||||||
|
assert.ok(esperaDeToken("token-A", ahora + 10) > 0);
|
||||||
|
// …pero otro token no espera nada: su límite es independiente.
|
||||||
|
assert.equal(esperaDeToken("token-B", ahora + 10), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pasado el intervalo, el mismo token deja de esperar", () => {
|
||||||
|
const ahora = 2_000_000;
|
||||||
|
registrarPeticion("token-C", ahora);
|
||||||
|
assert.equal(esperaDeToken("token-C", ahora + MIN_INTERVAL_MS + 1), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sin cabeceras se usa el intervalo conservador por defecto", () => {
|
||||||
|
assert.equal(intervaloDe("token-sin-datos"), MIN_INTERVAL_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("las cabeceras del CRM mandan sobre el valor por defecto", () => {
|
||||||
|
// MEDIDO (hallazgo 37): la cuota real de la subcuenta.
|
||||||
|
anotarLimites(
|
||||||
|
"token-D",
|
||||||
|
new Headers({
|
||||||
|
"x-ratelimit-max": "100",
|
||||||
|
"x-ratelimit-interval-milliseconds": "10000",
|
||||||
|
"x-ratelimit-remaining": "94",
|
||||||
|
"x-ratelimit-daily-remaining": "199970",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const l = limitesDe("token-D");
|
||||||
|
assert.equal(l?.max, 100);
|
||||||
|
assert.equal(l?.ventanaMs, 10000);
|
||||||
|
assert.equal(l?.diarioRestante, 199970);
|
||||||
|
// 10000/100 = 100 ms teóricos, con 50 % de margen = 150
|
||||||
|
assert.equal(intervaloDe("token-D"), 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("con la ventana casi agotada se espacia más, para no comerse un 429", () => {
|
||||||
|
anotarLimites(
|
||||||
|
"token-E",
|
||||||
|
new Headers({
|
||||||
|
"x-ratelimit-max": "100",
|
||||||
|
"x-ratelimit-interval-milliseconds": "10000",
|
||||||
|
"x-ratelimit-remaining": "3",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert.ok(intervaloDe("token-E") > 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("una respuesta sin cabeceras de límite no borra lo que ya se sabía", () => {
|
||||||
|
anotarLimites(
|
||||||
|
"token-F",
|
||||||
|
new Headers({ "x-ratelimit-max": "100", "x-ratelimit-interval-milliseconds": "10000" })
|
||||||
|
);
|
||||||
|
anotarLimites("token-F", new Headers({}));
|
||||||
|
assert.equal(limitesDe("token-F")?.max, 100);
|
||||||
|
});
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { loadEnv } from "../lib/env.ts";
|
||||||
|
|
||||||
|
const BASE_URL_DEFAULT = "https://services.leadconnectorhq.com";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La cabecera `Version` no es opcional y no es una sola: la familia de
|
||||||
|
* calendarios exige `v3` y el resto `2021-07-28`. Omitirla o equivocarla es un
|
||||||
|
* 400, y es el error más fácil de cometer al reciclar código entre dominios.
|
||||||
|
*/
|
||||||
|
export const VERSION_DEFAULT = "2021-07-28";
|
||||||
|
export const VERSION_CALENDARS = "v3";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intervalo conservador mientras el CRM no diga su cuota real.
|
||||||
|
*
|
||||||
|
* MEDIDO (hallazgo 37): las cabeceras `x-ratelimit-*` declaran 100 peticiones
|
||||||
|
* por 10 s, o sea 1 cada 100 ms — 6,5 veces más de lo que este valor asume. Se
|
||||||
|
* mantiene como respaldo para la primera petición de un token, antes de haber
|
||||||
|
* visto ninguna cabecera; a partir de ahí manda `intervaloDe()`.
|
||||||
|
*/
|
||||||
|
export const MIN_INTERVAL_MS = 650;
|
||||||
|
/** Margen sobre la cuota declarada: no se corre al límite exacto. */
|
||||||
|
const MARGEN = 1.5;
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
|
||||||
|
export interface Limites {
|
||||||
|
max: number;
|
||||||
|
ventanaMs: number;
|
||||||
|
restantes: number;
|
||||||
|
diarioRestante: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitesPorToken = new Map<string, Limites>();
|
||||||
|
|
||||||
|
/** Registra lo que el CRM dice de su propia cuota. Una respuesta sin cabeceras
|
||||||
|
* no borra lo ya sabido: no todas las rutas las devuelven. */
|
||||||
|
export function anotarLimites(token: string, h: Headers): void {
|
||||||
|
const max = Number(h.get("x-ratelimit-max"));
|
||||||
|
const ventanaMs = Number(h.get("x-ratelimit-interval-milliseconds"));
|
||||||
|
if (!max || !ventanaMs) return;
|
||||||
|
limitesPorToken.set(token, {
|
||||||
|
max,
|
||||||
|
ventanaMs,
|
||||||
|
restantes: Number(h.get("x-ratelimit-remaining") ?? max),
|
||||||
|
diarioRestante: h.get("x-ratelimit-daily-remaining")
|
||||||
|
? Number(h.get("x-ratelimit-daily-remaining"))
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function limitesDe(token: string): Limites | null {
|
||||||
|
return limitesPorToken.get(token) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cuánto esperar entre peticiones de ESTE token.
|
||||||
|
*
|
||||||
|
* Se toma la cuota que el CRM declara, con un 50 % de margen y no al límite
|
||||||
|
* exacto: el worker de la bandeja y una sincronización manual pueden coincidir.
|
||||||
|
* Si la ventana está casi agotada se espacia hasta que se renueve, que sale más
|
||||||
|
* barato que comerse un 429 y su espera lineal de 5, 10 y 15 s.
|
||||||
|
*/
|
||||||
|
export function intervaloDe(token: string): number {
|
||||||
|
const l = limitesPorToken.get(token);
|
||||||
|
if (!l) return MIN_INTERVAL_MS;
|
||||||
|
const base = Math.ceil((l.ventanaMs / l.max) * MARGEN);
|
||||||
|
if (l.restantes <= 5) {
|
||||||
|
return Math.max(base, Math.ceil(l.ventanaMs / Math.max(1, l.restantes)));
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CrmError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
message: string,
|
||||||
|
readonly body?: unknown
|
||||||
|
) {
|
||||||
|
super(`CRM ${status}: ${message}`);
|
||||||
|
this.name = "CrmError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un fallo de transporte no es una respuesta: el servidor no habló, así que no
|
||||||
|
* se sabe si la escritura entró. Reenviarlo es fabricar la doble creación. Se
|
||||||
|
* marca aparte para que la bandeja de salida lo deje en `indeterminado` y lo
|
||||||
|
* resuelva **leyendo**, nunca reintentando.
|
||||||
|
*/
|
||||||
|
export class CrmTransportError extends Error {
|
||||||
|
readonly indeterminate = true;
|
||||||
|
constructor(message: string) {
|
||||||
|
super(`CRM sin respuesta: ${message}`);
|
||||||
|
this.name = "CrmTransportError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un reloj por token, no uno global: el límite del CRM es por credencial, así
|
||||||
|
// que un semáforo único serializaría negocios que pueden ir en paralelo. Con
|
||||||
|
// diez cuentas, la décima esperaría a las nueve anteriores sin ninguna razón.
|
||||||
|
const ultimaPeticionPorToken = new Map<string, number>();
|
||||||
|
|
||||||
|
/** Milisegundos que este token debe esperar antes de su próxima petición. */
|
||||||
|
export function esperaDeToken(token: string, ahora = Date.now()): number {
|
||||||
|
const ultima = ultimaPeticionPorToken.get(token) ?? 0;
|
||||||
|
return Math.max(0, intervaloDe(token) - (ahora - ultima));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registrarPeticion(token: string, ahora = Date.now()): void {
|
||||||
|
ultimaPeticionPorToken.set(token, ahora);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function throttle(token: string) {
|
||||||
|
const espera = esperaDeToken(token);
|
||||||
|
if (espera > 0) await new Promise((r) => setTimeout(r, espera));
|
||||||
|
registrarPeticion(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrmOptions {
|
||||||
|
/**
|
||||||
|
* Token privado de la subcuenta. **Obligatorio y sin valor por defecto.**
|
||||||
|
*
|
||||||
|
* Antes caía a `requireEnv("CRM_TOKEN")`, una variable global del proceso: con
|
||||||
|
* dos negocios, olvidar el token no daba error — usaba el del primero contra
|
||||||
|
* la subcuenta del segundo. Al hacerlo obligatorio, ese olvido pasa a ser un
|
||||||
|
* error de compilación, que es el gate real de calidad de este repo.
|
||||||
|
*
|
||||||
|
* Sale siempre de `CrmCtx.token` (ver platform/crm/ctx.ts).
|
||||||
|
*/
|
||||||
|
token: string;
|
||||||
|
body?: unknown;
|
||||||
|
version?: string;
|
||||||
|
query?: Record<string, string | number | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function crmRequest<T = unknown>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
opts: CrmOptions
|
||||||
|
): Promise<T> {
|
||||||
|
loadEnv();
|
||||||
|
const base = process.env.CRM_BASE_URL || BASE_URL_DEFAULT;
|
||||||
|
const token = opts.token;
|
||||||
|
|
||||||
|
let url = `${base}${path}`;
|
||||||
|
if (opts.query) {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
for (const [k, v] of Object.entries(opts.query)) {
|
||||||
|
if (v !== undefined) q.set(k, String(v));
|
||||||
|
}
|
||||||
|
const s = q.toString();
|
||||||
|
if (s) url += (url.includes("?") ? "&" : "?") + s;
|
||||||
|
}
|
||||||
|
|
||||||
|
const version =
|
||||||
|
opts.version ?? (path.startsWith("/calendars/") ? VERSION_CALENDARS : VERSION_DEFAULT);
|
||||||
|
|
||||||
|
let intento = 0;
|
||||||
|
for (;;) {
|
||||||
|
await throttle(token);
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
version,
|
||||||
|
accept: "application/json",
|
||||||
|
...(opts.body !== undefined ? { "content-type": "application/json" } : {}),
|
||||||
|
},
|
||||||
|
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
// Timeout / conexión caída: no hubo respuesta. Se reintenta el transporte
|
||||||
|
// solo en GET, que es idempotente por naturaleza; en escrituras se
|
||||||
|
// propaga para que arriba se resuelva leyendo.
|
||||||
|
if (method === "GET" && intento < MAX_RETRIES) {
|
||||||
|
await new Promise((r) => setTimeout(r, 2000 * 2 ** intento));
|
||||||
|
intento++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new CrmTransportError(`${e?.name ?? "Error"}: ${e?.message ?? e}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// El CRM declara su propia cuota en cada respuesta. Leerla es la única
|
||||||
|
// forma de no ir a ciegas: el intervalo por defecto era una estimación.
|
||||||
|
anotarLimites(token, res.headers);
|
||||||
|
|
||||||
|
if (res.status === 429 || res.status >= 500) {
|
||||||
|
if (intento < MAX_RETRIES) {
|
||||||
|
// 429 lineal (5/10/15 s), 5xx exponencial: es la política ya medida en
|
||||||
|
// el proyecto hermano.
|
||||||
|
const espera = res.status === 429 ? 5000 * (intento + 1) : 2000 * 2 ** intento;
|
||||||
|
await new Promise((r) => setTimeout(r, espera));
|
||||||
|
intento++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const texto = await res.text();
|
||||||
|
let cuerpo: any = null;
|
||||||
|
try {
|
||||||
|
cuerpo = texto ? JSON.parse(texto) : null;
|
||||||
|
} catch {
|
||||||
|
cuerpo = texto;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
// Rotar el token es trabajo humano: no se reintenta y se dice claro.
|
||||||
|
throw new CrmError(401, "Token rechazado — hay que regenerarlo en el CRM", cuerpo);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg =
|
||||||
|
(Array.isArray(cuerpo?.message) ? cuerpo.message.join("; ") : cuerpo?.message) ||
|
||||||
|
cuerpo?.error ||
|
||||||
|
res.statusText;
|
||||||
|
throw new CrmError(res.status, String(msg), cuerpo);
|
||||||
|
}
|
||||||
|
return cuerpo as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { crmRequest } from "./client.ts";
|
||||||
|
import type { CrmCtx } from "./ctx.ts";
|
||||||
|
import type { EtapasPipeline } from "./opportunities.ts";
|
||||||
|
|
||||||
|
export interface CrmConnection {
|
||||||
|
id: number;
|
||||||
|
business_id: number;
|
||||||
|
location_id: string;
|
||||||
|
pipeline_id: string | null;
|
||||||
|
stage_open_id: string | null;
|
||||||
|
stage_won_id: string | null;
|
||||||
|
stage_lost_id: string | null;
|
||||||
|
allow_duplicate_opp: boolean;
|
||||||
|
last_sync_at: string | null;
|
||||||
|
last_sync_status: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function obtenerConexion(businessId: number): Promise<CrmConnection | null> {
|
||||||
|
const { rows } = await pool.query<CrmConnection>(
|
||||||
|
`SELECT * FROM crm_connections WHERE business_id = $1`,
|
||||||
|
[businessId]
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function etapasDe(c: CrmConnection): EtapasPipeline {
|
||||||
|
if (!c.pipeline_id) throw new Error("La conexión con el CRM no tiene pipeline configurado");
|
||||||
|
return {
|
||||||
|
pipelineId: c.pipeline_id,
|
||||||
|
open: c.stage_open_id,
|
||||||
|
won: c.stage_won_id,
|
||||||
|
lost: c.stage_lost_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detecta el pipeline y las etapas de la subcuenta y las guarda.
|
||||||
|
*
|
||||||
|
* Las etapas se eligen por nombre porque sus identificadores son opacos y
|
||||||
|
* distintos en cada subcuenta. Se busca «ganado» y «perdido»; si no aparecen,
|
||||||
|
* se cae a la primera y la última por posición, que es lo que un embudo suele
|
||||||
|
* significar. Ese respaldo se registra en `last_sync_status` para que no pase
|
||||||
|
* inadvertido.
|
||||||
|
*/
|
||||||
|
export async function autoconfigurar(ctx: CrmCtx): Promise<CrmConnection> {
|
||||||
|
const r = await crmRequest<any>("GET", "/opportunities/pipelines", {
|
||||||
|
token: ctx.token,
|
||||||
|
query: { locationId: ctx.locationId },
|
||||||
|
});
|
||||||
|
const pipelines: any[] = r?.pipelines ?? [];
|
||||||
|
if (!pipelines.length) {
|
||||||
|
throw new Error("La subcuenta del CRM no tiene ningún pipeline");
|
||||||
|
}
|
||||||
|
const pipe = pipelines[0];
|
||||||
|
const stages: any[] = [...(pipe.stages ?? [])].sort(
|
||||||
|
(a, b) => (a.position ?? 0) - (b.position ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
const porNombre = (...palabras: string[]) =>
|
||||||
|
stages.find((s) => {
|
||||||
|
const n = String(s.name ?? "").toLowerCase();
|
||||||
|
return palabras.some((p) => n.includes(p));
|
||||||
|
})?.id ?? null;
|
||||||
|
|
||||||
|
const won = porNombre("ganado", "won", "asisti", "complet");
|
||||||
|
const lost = porNombre("perdido", "lost", "cancel", "no asis");
|
||||||
|
const open = stages[0]?.id ?? null;
|
||||||
|
|
||||||
|
// MEDIDO: la subcuenta expone el ajuste que decide si una cita puede estrenar
|
||||||
|
// su propia oportunidad o hay que reciclar la de la clienta.
|
||||||
|
let permiteDuplicados = false;
|
||||||
|
try {
|
||||||
|
const loc = await crmRequest<any>("GET", `/locations/${ctx.locationId}`, {
|
||||||
|
token: ctx.token,
|
||||||
|
});
|
||||||
|
permiteDuplicados = Boolean(loc?.location?.settings?.allowDuplicateOpportunity);
|
||||||
|
} catch {
|
||||||
|
// Si no se puede leer, se asume el caso restrictivo: reciclar nunca rompe,
|
||||||
|
// crear a ciegas sí.
|
||||||
|
}
|
||||||
|
|
||||||
|
const nota =
|
||||||
|
won && lost
|
||||||
|
? `pipeline «${pipe.name}»; etapas detectadas por nombre`
|
||||||
|
: `pipeline «${pipe.name}»; OJO: no se hallaron etapas de ganado/perdido por nombre`;
|
||||||
|
|
||||||
|
// `location_id` NO se escribe aquí: lo puso `guardarCredencial` junto al token,
|
||||||
|
// y son la misma decisión. Escribirlo desde dos sitios permite que se separen.
|
||||||
|
const { rows } = await pool.query<CrmConnection>(
|
||||||
|
`INSERT INTO crm_connections
|
||||||
|
(business_id, location_id, pipeline_id, stage_open_id, stage_won_id, stage_lost_id,
|
||||||
|
allow_duplicate_opp, last_sync_status)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
|
||||||
|
ON CONFLICT (business_id) DO UPDATE SET
|
||||||
|
pipeline_id = EXCLUDED.pipeline_id,
|
||||||
|
stage_open_id = EXCLUDED.stage_open_id,
|
||||||
|
stage_won_id = EXCLUDED.stage_won_id,
|
||||||
|
stage_lost_id = EXCLUDED.stage_lost_id,
|
||||||
|
allow_duplicate_opp = EXCLUDED.allow_duplicate_opp,
|
||||||
|
last_sync_status = EXCLUDED.last_sync_status
|
||||||
|
RETURNING *`,
|
||||||
|
[ctx.businessId, ctx.locationId, pipe.id, open, won, lost, permiteDuplicados, nota]
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mapAtribucion, nombreDe } from "./contacts.ts";
|
||||||
|
|
||||||
|
test("aplana la atribución real que devuelve el CRM", () => {
|
||||||
|
// Este objeto es una captura literal de un contacto real de la subcuenta.
|
||||||
|
const a = mapAtribucion({
|
||||||
|
id: "x",
|
||||||
|
source: "WhatsApp",
|
||||||
|
attributionSource: {
|
||||||
|
sessionSource: "Paid Social",
|
||||||
|
medium: "instagram",
|
||||||
|
mediumId: "28379457221686971",
|
||||||
|
campaign: "Servicios-Interaccion-WhatsApp",
|
||||||
|
utmMedium: "Uñas-Interaccion-WA",
|
||||||
|
utmContent: "Post - Uñas - WA",
|
||||||
|
campaignId: "120249003827580681",
|
||||||
|
adId: "120249003827530681",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(a.crm_source, "WhatsApp");
|
||||||
|
assert.equal(a.attr_session_source, "Paid Social");
|
||||||
|
assert.equal(a.attr_medium, "instagram");
|
||||||
|
assert.equal(a.attr_campaign, "Servicios-Interaccion-WhatsApp");
|
||||||
|
assert.equal(a.attr_campaign_id, "120249003827580681");
|
||||||
|
assert.equal(a.attr_ad_id, "120249003827530681");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("«campaign» gana a «utmCampaign»", () => {
|
||||||
|
// No es un capricho de orden: el buscador de contactos del CRM descarta
|
||||||
|
// `utmCampaign` y conserva `campaign`. Leerlos al revés deja la campaña
|
||||||
|
// vacía en la mitad de los contactos.
|
||||||
|
const a = mapAtribucion({
|
||||||
|
id: "x",
|
||||||
|
attributionSource: { campaign: "la_buena", utmCampaign: "la_descartada" },
|
||||||
|
});
|
||||||
|
assert.equal(a.attr_campaign, "la_buena");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cae a utmCampaign cuando campaign no viene", () => {
|
||||||
|
const a = mapAtribucion({ id: "x", attributionSource: { utmCampaign: "solo_esta" } });
|
||||||
|
assert.equal(a.attr_campaign, "solo_esta");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("una atribución vacía no inventa valores", () => {
|
||||||
|
const a = mapAtribucion({ id: "x" });
|
||||||
|
assert.equal(a.attr_campaign, null);
|
||||||
|
assert.equal(a.attr_session_source, null);
|
||||||
|
assert.equal(a.crm_source, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("las cadenas vacías cuentan como ausencia, no como valor", () => {
|
||||||
|
const a = mapAtribucion({ id: "x", attributionSource: { campaign: "", utmCampaign: "buena" } });
|
||||||
|
assert.equal(a.attr_campaign, "buena");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("el nombre sale de los tres orígenes que trae el CRM, en orden", () => {
|
||||||
|
assert.equal(nombreDe({ id: "1", firstName: "Ana", lastName: "Ruiz" }), "Ana Ruiz");
|
||||||
|
assert.equal(nombreDe({ id: "2", firstName: "Ana" }), "Ana");
|
||||||
|
assert.equal(nombreDe({ id: "3", contactName: "Ana R." }), "Ana R.");
|
||||||
|
assert.equal(nombreDe({ id: "4", email: "[email protected]" }), "[email protected]");
|
||||||
|
assert.equal(nombreDe({ id: "5" }), "Sin nombre");
|
||||||
|
});
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { crmRequest, CrmError } from "./client.ts";
|
||||||
|
import type { CrmCtx } from "./ctx.ts";
|
||||||
|
import { normalizePhone } from "../lib/phone.ts";
|
||||||
|
|
||||||
|
/** Lo que el CRM devuelve de un contacto, en la forma que nos interesa. */
|
||||||
|
export interface CrmContact {
|
||||||
|
id: string;
|
||||||
|
firstName?: string | null;
|
||||||
|
lastName?: string | null;
|
||||||
|
contactName?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
source?: string | null;
|
||||||
|
tags?: string[] | null;
|
||||||
|
dateAdded?: string | null;
|
||||||
|
dateOfBirth?: string | null;
|
||||||
|
attributionSource?: Record<string, string | null> | null;
|
||||||
|
customFields?: { id: string; value: unknown }[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** La atribución, aplanada a las columnas de `clients`. */
|
||||||
|
export interface Atribucion {
|
||||||
|
crm_source: string | null;
|
||||||
|
attr_session_source: string | null;
|
||||||
|
attr_medium: string | null;
|
||||||
|
attr_campaign: string | null;
|
||||||
|
attr_campaign_id: string | null;
|
||||||
|
attr_utm_source: string | null;
|
||||||
|
attr_utm_medium: string | null;
|
||||||
|
attr_utm_content: string | null;
|
||||||
|
attr_ad_id: string | null;
|
||||||
|
attr_referrer: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aplana `attributionSource`.
|
||||||
|
*
|
||||||
|
* `campaign` se lee ANTES que `utmCampaign` a propósito: el buscador de
|
||||||
|
* contactos del CRM descarta `utmCampaign` y conserva `campaign`, así que
|
||||||
|
* leerlos al revés deja la campaña vacía en la mitad de los contactos.
|
||||||
|
*/
|
||||||
|
export function mapAtribucion(c: CrmContact): Atribucion {
|
||||||
|
const a = c.attributionSource ?? {};
|
||||||
|
const g = (...claves: string[]) => {
|
||||||
|
for (const k of claves) {
|
||||||
|
const v = (a as any)[k];
|
||||||
|
if (v !== undefined && v !== null && v !== "") return String(v);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
crm_source: c.source ?? null,
|
||||||
|
attr_session_source: g("sessionSource"),
|
||||||
|
attr_medium: g("medium"),
|
||||||
|
attr_campaign: g("campaign", "utmCampaign"),
|
||||||
|
attr_campaign_id: g("campaignId"),
|
||||||
|
attr_utm_source: g("utmSource"),
|
||||||
|
attr_utm_medium: g("utmMedium"),
|
||||||
|
attr_utm_content: g("utmContent"),
|
||||||
|
attr_ad_id: g("adId"),
|
||||||
|
attr_referrer: g("referrer", "url"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nombre presentable, con los tres orígenes que trae el CRM. */
|
||||||
|
export function nombreDe(c: CrmContact): string {
|
||||||
|
const compuesto = [c.firstName, c.lastName].filter(Boolean).join(" ").trim();
|
||||||
|
return compuesto || (c.contactName ?? "").trim() || (c.email ?? "").trim() || "Sin nombre";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Una página del buscador de contactos. */
|
||||||
|
export interface PaginaContactos {
|
||||||
|
contacts: CrmContact[];
|
||||||
|
total: number;
|
||||||
|
searchAfter?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recorre los contactos de la subcuenta.
|
||||||
|
*
|
||||||
|
* Pagina con `searchAfter` y no con `page`: el buscador tiene un techo de
|
||||||
|
* profundidad por número de página, y con 3 209 contactos se alcanza. El cursor
|
||||||
|
* sale del ÚLTIMO contacto de la página anterior.
|
||||||
|
*/
|
||||||
|
export async function buscarContactos(
|
||||||
|
ctx: CrmCtx,
|
||||||
|
opts: { pageLimit?: number; searchAfter?: unknown } = {}
|
||||||
|
): Promise<PaginaContactos> {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
locationId: ctx.locationId,
|
||||||
|
pageLimit: opts.pageLimit ?? 100,
|
||||||
|
};
|
||||||
|
if (opts.searchAfter) body.searchAfter = opts.searchAfter;
|
||||||
|
|
||||||
|
const r = await crmRequest<any>("POST", "/contacts/search", { token: ctx.token, body });
|
||||||
|
return {
|
||||||
|
contacts: r?.contacts ?? [],
|
||||||
|
total: r?.total ?? 0,
|
||||||
|
searchAfter: r?.contacts?.length ? r.contacts[r.contacts.length - 1]?.searchAfter : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function obtenerContacto(ctx: CrmCtx, id: string): Promise<CrmContact | null> {
|
||||||
|
try {
|
||||||
|
const r = await crmRequest<any>("GET", `/contacts/${id}`, { token: ctx.token });
|
||||||
|
return r?.contact ?? null;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof CrmError && e.status === 404) return null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Busca por un identificador natural. El CRM deduplica por email y teléfono. */
|
||||||
|
export async function buscarPorIdentificador(
|
||||||
|
ctx: CrmCtx,
|
||||||
|
q: string
|
||||||
|
): Promise<CrmContact | null> {
|
||||||
|
const r = await crmRequest<any>("GET", "/contacts/", {
|
||||||
|
token: ctx.token,
|
||||||
|
query: { locationId: ctx.locationId, query: q, limit: 5 },
|
||||||
|
});
|
||||||
|
return r?.contacts?.[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AltaContacto {
|
||||||
|
locationId: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
source?: string;
|
||||||
|
tags?: string[];
|
||||||
|
attribution?: Record<string, string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResultadoResolucion {
|
||||||
|
contact: CrmContact;
|
||||||
|
/** Cómo se llegó a él: importa para la auditoría y para depurar duplicados. */
|
||||||
|
via: "crm_id" | "telefono" | "correo" | "creado" | "duplicado_400";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resuelve el contacto en el CRM siguiendo la cadena de identidad acordada:
|
||||||
|
* **id de contacto → teléfono → correo**, y si no existe, lo crea.
|
||||||
|
*
|
||||||
|
* Es la misma cadena que el CRM aplica por su cuenta
|
||||||
|
* (`contactUniqueIdentifiers: ["email","phone"]`), así que las dos coinciden y
|
||||||
|
* no se pelean.
|
||||||
|
*
|
||||||
|
* El caso interesante es el último: si el alta choca con un duplicado, el CRM
|
||||||
|
* responde `400` **con el `contactId` existente en `meta`**. Eso es idempotencia
|
||||||
|
* de verdad, regalada por el servidor, y es mejor que `upsert` — cuya rama
|
||||||
|
* *actualizar* descarta la atribución en silencio.
|
||||||
|
*/
|
||||||
|
export async function resolverContacto(
|
||||||
|
ctx: CrmCtx,
|
||||||
|
datos: {
|
||||||
|
crmContactId?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
name: string;
|
||||||
|
source?: string;
|
||||||
|
tags?: string[];
|
||||||
|
attribution?: Record<string, string | undefined>;
|
||||||
|
}
|
||||||
|
): Promise<ResultadoResolucion> {
|
||||||
|
// 1. Por id del CRM, si ya lo teníamos anclado.
|
||||||
|
if (datos.crmContactId) {
|
||||||
|
const c = await obtenerContacto(ctx, datos.crmContactId);
|
||||||
|
if (c) return { contact: c, via: "crm_id" };
|
||||||
|
// El id guardado ya no resuelve: el contacto se borró en el CRM. Se sigue
|
||||||
|
// por los fallbacks en vez de fallar.
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Por teléfono normalizado.
|
||||||
|
const tel = normalizePhone(datos.phone);
|
||||||
|
if (tel) {
|
||||||
|
const c = await buscarPorIdentificador(ctx, tel);
|
||||||
|
if (c) return { contact: c, via: "telefono" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Por correo.
|
||||||
|
if (datos.email) {
|
||||||
|
const c = await buscarPorIdentificador(ctx, datos.email);
|
||||||
|
if (c) return { contact: c, via: "correo" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Crear. La atribución solo entra AQUÍ: después es inmutable.
|
||||||
|
const partes = datos.name.trim().split(/\s+/);
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
// MEDIDO: `locationId` va en el POST de alta y ROMPE el PUT con
|
||||||
|
// `422 property locationId should not exist`. No reciclar este cuerpo.
|
||||||
|
locationId: ctx.locationId,
|
||||||
|
firstName: partes[0] || datos.name,
|
||||||
|
lastName: partes.slice(1).join(" ") || undefined,
|
||||||
|
country: "MX",
|
||||||
|
source: datos.source ?? "AgendaMax",
|
||||||
|
};
|
||||||
|
if (tel) body.phone = tel;
|
||||||
|
if (datos.email) body.email = datos.email;
|
||||||
|
if (datos.tags?.length) body.tags = datos.tags;
|
||||||
|
if (datos.attribution && Object.keys(datos.attribution).length) {
|
||||||
|
body.attributionSource = datos.attribution;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await crmRequest<any>("POST", "/contacts/", { token: ctx.token, body });
|
||||||
|
return { contact: r.contact, via: "creado" };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof CrmError && e.status === 400) {
|
||||||
|
const existente = (e.body as any)?.meta?.contactId;
|
||||||
|
if (existente) {
|
||||||
|
const c = await obtenerContacto(ctx, existente);
|
||||||
|
if (c) return { contact: c, via: "duplicado_400" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { normalizarTipo, formaDeMensajes } from "./conversations.ts";
|
||||||
|
|
||||||
|
test("normalizarTipo: la API devuelve número o cadena según el endpoint", () => {
|
||||||
|
// MEDIDO (hallazgo 24): `/conversations/{id}` da un número y el buscador una
|
||||||
|
// cadena `TYPE_SMS`. Es la misma información con dos formas.
|
||||||
|
assert.equal(normalizarTipo("TYPE_SMS"), "SMS");
|
||||||
|
assert.equal(normalizarTipo("TYPE_EMAIL"), "Email");
|
||||||
|
assert.equal(normalizarTipo(1), "Phone");
|
||||||
|
assert.equal(normalizarTipo(2), "Email");
|
||||||
|
assert.equal(normalizarTipo(3), "FB");
|
||||||
|
assert.equal(normalizarTipo(undefined), "Desconocido");
|
||||||
|
assert.equal(normalizarTipo(""), "Desconocido");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizarTipo: un tipo desconocido no se traga, se ve", () => {
|
||||||
|
assert.equal(normalizarTipo("TYPE_TIKTOK"), "TIKTOK");
|
||||||
|
assert.equal(normalizarTipo(99), "Desconocido");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formaDeMensajes: desanida la respuesta real, que trae messages.messages", () => {
|
||||||
|
const r = formaDeMensajes({
|
||||||
|
messages: { messages: [{ id: "m1" }], lastMessageId: "m1", nextPage: true },
|
||||||
|
});
|
||||||
|
assert.equal(r.mensajes.length, 1);
|
||||||
|
assert.equal(r.lastMessageId, "m1");
|
||||||
|
assert.equal(r.hayMas, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formaDeMensajes: tolera la forma plana por si la API cambia", () => {
|
||||||
|
const r = formaDeMensajes({ messages: [{ id: "m1" }] });
|
||||||
|
assert.equal(r.mensajes.length, 1);
|
||||||
|
assert.equal(r.hayMas, false);
|
||||||
|
assert.equal(r.lastMessageId, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formaDeMensajes: una respuesta vacía no revienta", () => {
|
||||||
|
const r = formaDeMensajes({});
|
||||||
|
assert.deepEqual(r.mensajes, []);
|
||||||
|
assert.equal(r.hayMas, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizarTipo reconoce los canales reales de la subcuenta", () => {
|
||||||
|
// MEDIDO: los hilos reales traen TYPE_INSTAGRAM y TYPE_ACTIVITY_OPPORTUNITY.
|
||||||
|
assert.equal(normalizarTipo("TYPE_INSTAGRAM"), "Instagram");
|
||||||
|
assert.equal(normalizarTipo("TYPE_ACTIVITY_OPPORTUNITY"), "Actividad");
|
||||||
|
assert.equal(normalizarTipo("TYPE_WHATSAPP"), "WhatsApp");
|
||||||
|
});
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { crmRequest, CrmError } from "./client.ts";
|
||||||
|
import type { CrmCtx } from "./ctx.ts";
|
||||||
|
|
||||||
|
export interface CrmConversation {
|
||||||
|
id: string;
|
||||||
|
contactId?: string;
|
||||||
|
fullName?: string;
|
||||||
|
contactName?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
lastMessageBody?: string;
|
||||||
|
lastMessageType?: string;
|
||||||
|
lastMessageDate?: string | number;
|
||||||
|
unreadCount?: number;
|
||||||
|
type?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrmMessage {
|
||||||
|
id: string;
|
||||||
|
body?: string;
|
||||||
|
direction?: "inbound" | "outbound";
|
||||||
|
messageType?: string;
|
||||||
|
status?: string | null;
|
||||||
|
dateAdded?: string;
|
||||||
|
contactId?: string;
|
||||||
|
conversationId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* El canal llega como cadena (`TYPE_SMS`) desde el buscador y como número desde
|
||||||
|
* `GET /conversations/{id}`.
|
||||||
|
*
|
||||||
|
* MEDIDO (hallazgo 24). Es la misma información con dos formas, y mezclarlas
|
||||||
|
* produce una bandeja que etiqueta mal los hilos. Un tipo que no se reconozca se
|
||||||
|
* deja pasar tal cual en vez de esconderlo: si el CRM añade un canal, se verá.
|
||||||
|
*/
|
||||||
|
const POR_NUMERO: Record<number, string> = {
|
||||||
|
1: "Phone",
|
||||||
|
2: "Email",
|
||||||
|
3: "FB",
|
||||||
|
4: "Review",
|
||||||
|
5: "SMS",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Los canales conocidos se rinden con su nombre propio; el resto pasa tal cual. */
|
||||||
|
const POR_NOMBRE: Record<string, string> = {
|
||||||
|
SMS: "SMS",
|
||||||
|
EMAIL: "Email",
|
||||||
|
CALL: "Llamada",
|
||||||
|
VOICEMAIL: "Buzón de voz",
|
||||||
|
WHATSAPP: "WhatsApp",
|
||||||
|
FB: "Facebook",
|
||||||
|
IG: "Instagram",
|
||||||
|
INSTAGRAM: "Instagram",
|
||||||
|
FACEBOOK: "Facebook",
|
||||||
|
GMB: "Google Business",
|
||||||
|
WEBCHAT: "Chat web",
|
||||||
|
// Los TYPE_ACTIVITY_* no son mensajes de la clienta: son notas que el propio
|
||||||
|
// CRM escribe en el hilo cuando pasa algo (se creó una oportunidad, se agendó
|
||||||
|
// una cita). Se etiquetan como actividad para poder distinguirlos en la
|
||||||
|
// bandeja en vez de mostrarlos como si alguien los hubiera escrito.
|
||||||
|
ACTIVITY_OPPORTUNITY: "Actividad",
|
||||||
|
ACTIVITY_APPOINTMENT: "Actividad",
|
||||||
|
ACTIVITY_CONTACT: "Actividad",
|
||||||
|
ACTIVITY: "Actividad",
|
||||||
|
REVIEW: "Reseña",
|
||||||
|
LIVE_CHAT: "Chat en vivo",
|
||||||
|
CUSTOM: "Otro",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizarTipo(t: string | number | undefined | null): string {
|
||||||
|
if (typeof t === "number") return POR_NUMERO[t] ?? "Desconocido";
|
||||||
|
if (typeof t === "string" && t) {
|
||||||
|
const crudo = t.replace(/^TYPE_/, "");
|
||||||
|
return POR_NOMBRE[crudo] ?? crudo.replace(/_/g, " ");
|
||||||
|
}
|
||||||
|
return "Desconocido";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MEDIDO (hallazgo 26): la respuesta real es `{ messages: { messages: [...],
|
||||||
|
* lastMessageId, nextPage } }` — anidada dos niveles.
|
||||||
|
*
|
||||||
|
* Esta es la TERCERA convención de paginación de la misma API: contactos usan
|
||||||
|
* `searchAfter`, conversaciones `startAfterDate`, y los mensajes `lastMessageId`
|
||||||
|
* con un booleano `nextPage`. Reciclar una por otra devuelve listas incompletas
|
||||||
|
* sin dar ningún error.
|
||||||
|
*/
|
||||||
|
export function formaDeMensajes(r: any): {
|
||||||
|
mensajes: CrmMessage[];
|
||||||
|
lastMessageId: string | null;
|
||||||
|
hayMas: boolean;
|
||||||
|
} {
|
||||||
|
const anidado = r?.messages?.messages;
|
||||||
|
if (Array.isArray(anidado)) {
|
||||||
|
return {
|
||||||
|
mensajes: anidado,
|
||||||
|
lastMessageId: r.messages.lastMessageId ?? null,
|
||||||
|
hayMas: Boolean(r.messages.nextPage),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const plano = Array.isArray(r?.messages) ? r.messages : [];
|
||||||
|
return { mensajes: plano, lastMessageId: null, hayMas: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Una conversación por su id. MEDIDO: los campos vienen en la raíz, sin envoltorio. */
|
||||||
|
export async function obtenerConversacion(
|
||||||
|
ctx: CrmCtx,
|
||||||
|
id: string
|
||||||
|
): Promise<CrmConversation | null> {
|
||||||
|
try {
|
||||||
|
return await crmRequest<CrmConversation>("GET", `/conversations/${id}`, {
|
||||||
|
token: ctx.token,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof CrmError && e.status === 404) return null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Las conversaciones de un contacto. MEDIDO (hallazgo 25): `contactId` es filtro. */
|
||||||
|
export async function conversacionesDeContacto(
|
||||||
|
ctx: CrmCtx,
|
||||||
|
contactId: string
|
||||||
|
): Promise<CrmConversation[]> {
|
||||||
|
const r = await crmRequest<any>("GET", "/conversations/search", {
|
||||||
|
token: ctx.token,
|
||||||
|
query: { locationId: ctx.locationId, contactId, limit: 50 },
|
||||||
|
});
|
||||||
|
return r?.conversations ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buscarConversaciones(
|
||||||
|
ctx: CrmCtx,
|
||||||
|
opts: { limit?: number; startAfterDate?: number } = {}
|
||||||
|
): Promise<{ conversations: CrmConversation[]; total: number }> {
|
||||||
|
const r = await crmRequest<any>("GET", "/conversations/search", {
|
||||||
|
token: ctx.token,
|
||||||
|
query: {
|
||||||
|
locationId: ctx.locationId,
|
||||||
|
limit: opts.limit ?? 20,
|
||||||
|
sortBy: "last_message_date",
|
||||||
|
sort: "desc",
|
||||||
|
startAfterDate: opts.startAfterDate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { conversations: r?.conversations ?? [], total: r?.total ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mensajesDeConversacion(
|
||||||
|
ctx: CrmCtx,
|
||||||
|
conversationId: string,
|
||||||
|
opts: { limit?: number; lastMessageId?: string } = {}
|
||||||
|
) {
|
||||||
|
const r = await crmRequest<any>("GET", `/conversations/${conversationId}/messages`, {
|
||||||
|
token: ctx.token,
|
||||||
|
query: { limit: opts.limit ?? 50, lastMessageId: opts.lastMessageId },
|
||||||
|
});
|
||||||
|
return formaDeMensajes(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Un mensaje suelto por su id. MEDIDO (hallazgo 27): funciona y viene en la raíz. */
|
||||||
|
export async function obtenerMensaje(ctx: CrmCtx, id: string): Promise<CrmMessage | null> {
|
||||||
|
try {
|
||||||
|
const r = await crmRequest<any>("GET", `/conversations/messages/${id}`, {
|
||||||
|
token: ctx.token,
|
||||||
|
});
|
||||||
|
return (r?.message ?? r) as CrmMessage;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof CrmError && e.status === 404) return null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { cifrar, descifrar, huella } from "../lib/crypto.ts";
|
||||||
|
import { loadEnv } from "../lib/env.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Todo lo que hace falta para hablar con la subcuenta de UN negocio.
|
||||||
|
*
|
||||||
|
* Sustituye al `locationId: string` suelto que antes viajaba por once firmas.
|
||||||
|
* Es un objeto y no dos parámetros a propósito: dos `string` seguidos se pueden
|
||||||
|
* cruzar sin que el compilador diga nada, y cruzarlos aquí significa mandar el
|
||||||
|
* token de un cliente a la subcuenta de otro.
|
||||||
|
*
|
||||||
|
* Es el ÚNICO sitio del código donde el token existe descifrado, y solo en
|
||||||
|
* memoria. Ni se registra, ni se audita, ni sale por la API.
|
||||||
|
*/
|
||||||
|
export interface CrmCtx {
|
||||||
|
businessId: number;
|
||||||
|
locationId: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilaCredencial {
|
||||||
|
location_id: string;
|
||||||
|
token_cipher: Buffer | null;
|
||||||
|
token_nonce: Buffer | null;
|
||||||
|
token_tag: Buffer | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carga la credencial del negocio y la descifra.
|
||||||
|
*
|
||||||
|
* Distingue «no vinculado» de «vinculado sin token» a propósito: son dos
|
||||||
|
* situaciones con dos arreglos distintos, y un solo mensaje para las dos manda
|
||||||
|
* a quien lo lea a mirar donde no es.
|
||||||
|
*/
|
||||||
|
export async function ctxDe(businessId: number): Promise<CrmCtx> {
|
||||||
|
const { rows } = await pool.query<FilaCredencial>(
|
||||||
|
`SELECT location_id, token_cipher, token_nonce, token_tag
|
||||||
|
FROM crm_connections WHERE business_id = $1`,
|
||||||
|
[businessId]
|
||||||
|
);
|
||||||
|
const c = rows[0];
|
||||||
|
if (!c) {
|
||||||
|
throw { status: 409, error: "Este negocio no está vinculado a Bucéfalo CRM" };
|
||||||
|
}
|
||||||
|
if (!c.token_cipher || !c.token_nonce || !c.token_tag) {
|
||||||
|
throw {
|
||||||
|
status: 409,
|
||||||
|
error:
|
||||||
|
"Este negocio no tiene token de Bucéfalo CRM. Vincúlalo desde la consola de administración.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
businessId,
|
||||||
|
locationId: c.location_id,
|
||||||
|
token: descifrar({ cipher: c.token_cipher, nonce: c.token_nonce, tag: c.token_tag }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guarda o rota la credencial de un negocio. Idempotente.
|
||||||
|
*
|
||||||
|
* La etiqueta se conserva si no se manda otra: al rotar un token caducado nadie
|
||||||
|
* quiere volver a teclear el nombre de la subcuenta, y perderlo en silencio
|
||||||
|
* dejaría la consola llena de cuentas sin identificar.
|
||||||
|
*/
|
||||||
|
export async function guardarCredencial(
|
||||||
|
businessId: number,
|
||||||
|
locationId: string,
|
||||||
|
token: string,
|
||||||
|
label?: string
|
||||||
|
): Promise<void> {
|
||||||
|
const c = cifrar(token);
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO crm_connections
|
||||||
|
(business_id, location_id, token_cipher, token_nonce, token_tag,
|
||||||
|
token_fingerprint, token_updated_at, label)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6, now(), $7)
|
||||||
|
ON CONFLICT (business_id) DO UPDATE SET
|
||||||
|
location_id = EXCLUDED.location_id,
|
||||||
|
token_cipher = EXCLUDED.token_cipher,
|
||||||
|
token_nonce = EXCLUDED.token_nonce,
|
||||||
|
token_tag = EXCLUDED.token_tag,
|
||||||
|
token_fingerprint = EXCLUDED.token_fingerprint,
|
||||||
|
token_updated_at = now(),
|
||||||
|
label = COALESCE(EXCLUDED.label, crm_connections.label)`,
|
||||||
|
[businessId, locationId, c.cipher, c.nonce, c.tag, huella(token), label ?? null]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desvincula: borra la credencial y **conserva** la conexión y todo lo ya
|
||||||
|
* sincronizado. Quitar el token no es motivo para tirar 3 200 contactos, sus
|
||||||
|
* conversaciones y la atribución que costó traer.
|
||||||
|
*/
|
||||||
|
export async function olvidarCredencial(businessId: number): Promise<void> {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE crm_connections
|
||||||
|
SET token_cipher = NULL, token_nonce = NULL, token_tag = NULL,
|
||||||
|
token_fingerprint = NULL, token_updated_at = NULL
|
||||||
|
WHERE business_id = $1`,
|
||||||
|
[businessId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contexto construido desde el entorno, **solo para los scripts de sondeo**.
|
||||||
|
*
|
||||||
|
* El servidor nunca debe usar esto: sus credenciales salen de la base, por
|
||||||
|
* negocio, vía `ctxDe`. Aquí existe porque los spikes se lanzan a mano contra
|
||||||
|
* la subcuenta que esté configurada en `platform/.env`, antes incluso de que
|
||||||
|
* exista una fila en `crm_connections`.
|
||||||
|
*/
|
||||||
|
export function ctxDesdeEnv(businessId = 0): CrmCtx {
|
||||||
|
// Carga el .env explícitamente: depender de que otro import lo haya hecho
|
||||||
|
// antes funciona por casualidad y se rompe al reordenar los imports.
|
||||||
|
loadEnv();
|
||||||
|
const locationId = process.env.CRM_LOCATION_ID;
|
||||||
|
const token = process.env.CRM_TOKEN;
|
||||||
|
if (!locationId || !token) {
|
||||||
|
throw new Error(
|
||||||
|
"Faltan CRM_LOCATION_ID y/o CRM_TOKEN en platform/.env — este script los necesita para hablar con la subcuenta."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { businessId, locationId, token };
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { crmRequest } from "./client.ts";
|
||||||
|
import type { CrmCtx } from "./ctx.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envío de mensajes hacia Bucéfalo CRM.
|
||||||
|
*
|
||||||
|
* La LECTURA de conversaciones y mensajes vive en `conversations.ts`: son dos
|
||||||
|
* responsabilidades distintas y la de lectura creció con la sincronización por
|
||||||
|
* id. Aquí queda solo lo que escribe.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface EnvioCorreo {
|
||||||
|
contactId: string;
|
||||||
|
emailTo: string;
|
||||||
|
subject: string;
|
||||||
|
html: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResultadoEnvio {
|
||||||
|
conversationId?: string;
|
||||||
|
messageId?: string;
|
||||||
|
emailMessageId?: string;
|
||||||
|
msg?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envía un correo por el CRM.
|
||||||
|
*
|
||||||
|
* **Un `200` aquí es acuse de encolado, no de entrega** — la respuesta literal
|
||||||
|
* es `Email queued successfully`. No se puede afirmar que el mensaje llegó sin
|
||||||
|
* mirar una bandeja real, y la interfaz no debe decir «enviado» como si fuera
|
||||||
|
* un hecho confirmado.
|
||||||
|
*
|
||||||
|
* WhatsApp y SMS no están conectados en esta subcuenta: el correo es el único
|
||||||
|
* canal ejercible hoy.
|
||||||
|
*/
|
||||||
|
export async function enviarCorreo(ctx: CrmCtx, e: EnvioCorreo): Promise<ResultadoEnvio> {
|
||||||
|
return crmRequest<ResultadoEnvio>("POST", "/conversations/messages", {
|
||||||
|
token: ctx.token,
|
||||||
|
body: {
|
||||||
|
type: "Email",
|
||||||
|
contactId: e.contactId,
|
||||||
|
subject: e.subject,
|
||||||
|
html: e.html,
|
||||||
|
emailTo: e.emailTo,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { estadoOportunidad, nombreOportunidad } from "./opportunities.ts";
|
||||||
|
|
||||||
|
test("el estado de la cita se mapea al de la oportunidad", () => {
|
||||||
|
assert.equal(estadoOportunidad("scheduled"), "open", "en espera → open");
|
||||||
|
assert.equal(estadoOportunidad("completed"), "won", "completada → won");
|
||||||
|
assert.equal(estadoOportunidad("cancelled"), "lost", "cancelada → lost");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no vino también es una pérdida para el embudo", () => {
|
||||||
|
// El matiz de POR QUÉ se perdió (no vino / canceló la clienta / canceló el
|
||||||
|
// spa) vive en AgendaMax: el CRM aplana los tres en `lost`.
|
||||||
|
assert.equal(estadoOportunidad("no_show"), "lost");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un estado desconocido no cierra la oportunidad", () => {
|
||||||
|
// Cerrar por error es peor que dejar abierto: `won` mete ingreso inventado en
|
||||||
|
// los reportes del CRM y `lost` mata una cita viva.
|
||||||
|
assert.equal(estadoOportunidad("cualquier_cosa"), "open");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("el nombre de la oportunidad es SERVICIO + CLIENTA", () => {
|
||||||
|
assert.equal(
|
||||||
|
nombreOportunidad("Extensiones de pestañas", "Mariana López"),
|
||||||
|
"Extensiones de pestañas — Mariana López"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("el nombre se recorta para no romper el límite del CRM", () => {
|
||||||
|
const n = nombreOportunidad("S".repeat(200), "C".repeat(200));
|
||||||
|
assert.equal(n.length, 255);
|
||||||
|
});
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { crmRequest, CrmError } from "./client.ts";
|
||||||
|
import type { CrmCtx } from "./ctx.ts";
|
||||||
|
|
||||||
|
/** El enum de la API. La cita en espera es `open`, completada `won`, cancelada `lost`. */
|
||||||
|
export type CrmOppStatus = "open" | "won" | "lost" | "abandoned";
|
||||||
|
|
||||||
|
export interface CrmOpportunity {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
status?: CrmOppStatus;
|
||||||
|
monetaryValue?: number;
|
||||||
|
pipelineId?: string;
|
||||||
|
pipelineStageId?: string;
|
||||||
|
contactId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Estado de la cita en AgendaMax → estado de la oportunidad en el CRM. */
|
||||||
|
export function estadoOportunidad(estadoCita: string): CrmOppStatus {
|
||||||
|
switch (estadoCita) {
|
||||||
|
case "completed":
|
||||||
|
return "won";
|
||||||
|
case "cancelled":
|
||||||
|
case "no_show":
|
||||||
|
// Una cita a la que no vino la clienta tampoco produjo ingreso: para el
|
||||||
|
// embudo del CRM es una pérdida. El matiz de POR QUÉ se perdió (no vino,
|
||||||
|
// canceló ella, canceló el spa) vive en AgendaMax, que sí lo distingue.
|
||||||
|
return "lost";
|
||||||
|
default:
|
||||||
|
return "open";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* El nombre de la oportunidad, con el formato acordado: SERVICIO + NOMBRE.
|
||||||
|
* Se recorta a 255 porque el CRM no documenta el límite y un nombre largo
|
||||||
|
* es la clase de cosa que falla en producción y no en pruebas.
|
||||||
|
*/
|
||||||
|
export function nombreOportunidad(servicio: string, cliente: string): string {
|
||||||
|
return `${servicio} — ${cliente}`.slice(0, 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EtapasPipeline {
|
||||||
|
pipelineId: string;
|
||||||
|
open?: string | null;
|
||||||
|
won?: string | null;
|
||||||
|
lost?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function etapaPara(estado: CrmOppStatus, etapas: EtapasPipeline): string | null {
|
||||||
|
if (estado === "won") return etapas.won ?? null;
|
||||||
|
if (estado === "lost") return etapas.lost ?? null;
|
||||||
|
return etapas.open ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function obtenerOportunidad(
|
||||||
|
ctx: CrmCtx,
|
||||||
|
id: string
|
||||||
|
): Promise<CrmOpportunity | null> {
|
||||||
|
try {
|
||||||
|
const r = await crmRequest<any>("GET", `/opportunities/${id}`, { token: ctx.token });
|
||||||
|
return r?.opportunity ?? null;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof CrmError && e.status === 404) return null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Las oportunidades de un contacto. `GET /contacts/{id}/opportunities` NO existe (404). */
|
||||||
|
export async function oportunidadesDeContacto(
|
||||||
|
ctx: CrmCtx,
|
||||||
|
contactId: string
|
||||||
|
): Promise<CrmOpportunity[]> {
|
||||||
|
const r = await crmRequest<any>("GET", "/opportunities/search", {
|
||||||
|
token: ctx.token,
|
||||||
|
query: { location_id: ctx.locationId, contact_id: contactId, limit: 20 },
|
||||||
|
});
|
||||||
|
return r?.opportunities ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cambia el estado y la etapa.
|
||||||
|
*
|
||||||
|
* Tres cosas medidas gobiernan esta función, y las tres son contraintuitivas:
|
||||||
|
*
|
||||||
|
* 1. Son **dos llamadas**, no una: `PUT /opportunities/{id}/status` rechaza
|
||||||
|
* `pipelineStageId` con `422 property pipelineStageId should not exist`.
|
||||||
|
* 2. **El orden importa**: el `/status` mueve la etapa por su cuenta, así que
|
||||||
|
* va primero y la etapa deseada se escribe después. Al revés, el `/status`
|
||||||
|
* pisa la etapa recién puesta (medido: de «Ganado» a «Cotización Aceptada»).
|
||||||
|
* 3. En `won`, la subcuenta acaba imponiendo **su** etapa igualmente: se probó
|
||||||
|
* reescribir y releer tres veces y el CRM la devuelve a «Cotización
|
||||||
|
* Aceptada» de forma asíncrona, después de que la relectura ya confirmó la
|
||||||
|
* nuestra. Hay una regla del lado del CRM que gobierna eso, y pelearse con
|
||||||
|
* ella sería un bucle que nunca gana. En `lost` sí respeta «Perdido».
|
||||||
|
*
|
||||||
|
* Se escribe la etapa una vez y no se insiste. Lo que el negocio pidió mapear
|
||||||
|
* es el **estado** —`open` / `won` / `lost`—, y ese sí queda estable y
|
||||||
|
* verificado; la etapa es presentación y la manda el CRM.
|
||||||
|
*/
|
||||||
|
async function aplicarEstado(
|
||||||
|
ctx: CrmCtx,
|
||||||
|
id: string,
|
||||||
|
estado: CrmOppStatus,
|
||||||
|
etapas: EtapasPipeline
|
||||||
|
): Promise<void> {
|
||||||
|
await crmRequest("PUT", `/opportunities/${id}/status`, {
|
||||||
|
token: ctx.token,
|
||||||
|
body: { status: estado },
|
||||||
|
});
|
||||||
|
|
||||||
|
const etapa = etapaPara(estado, etapas);
|
||||||
|
if (!etapa) return;
|
||||||
|
|
||||||
|
await crmRequest("PUT", `/opportunities/${id}`, {
|
||||||
|
token: ctx.token,
|
||||||
|
body: { pipelineId: etapas.pipelineId, pipelineStageId: etapa },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResultadoOportunidad {
|
||||||
|
opportunity: CrmOpportunity;
|
||||||
|
via: "creada" | "reciclada" | "actualizada";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proyecta una cita al CRM como oportunidad.
|
||||||
|
*
|
||||||
|
* Dos caminos, y la plataforma elige sola sin configuración:
|
||||||
|
*
|
||||||
|
* 1. **Crear.** Si la subcuenta permite duplicados, cada cita estrena su
|
||||||
|
* oportunidad — que es el modelo pedido.
|
||||||
|
* 2. **Reciclar.** Si responde `400 OPPORTUNITY_NO_DUPLICATE`, el CRM entrega
|
||||||
|
* en `meta.existingId` la que ya existe, y se le pone el nombre, el importe
|
||||||
|
* y el estado de esta cita.
|
||||||
|
*
|
||||||
|
* El camino 2 es el que corre hoy en Yola: la subcuenta tiene
|
||||||
|
* `allowDuplicateOpportunity: false`. Ahí la oportunidad representa *la cita
|
||||||
|
* vigente de la clienta*, y el histórico completo vive en AgendaMax. Si alguien
|
||||||
|
* activa el ajuste en el CRM, esta misma función pasa al camino 1 sin cambios.
|
||||||
|
*/
|
||||||
|
export async function upsertOportunidad(
|
||||||
|
ctx: CrmCtx,
|
||||||
|
args: {
|
||||||
|
contactId: string;
|
||||||
|
nombre: string;
|
||||||
|
importe: number;
|
||||||
|
estado: CrmOppStatus;
|
||||||
|
etapas: EtapasPipeline;
|
||||||
|
/** Si ya la teníamos anclada, se actualiza directamente. */
|
||||||
|
oportunidadId?: string | null;
|
||||||
|
}
|
||||||
|
): Promise<ResultadoOportunidad> {
|
||||||
|
const { contactId, nombre, importe, estado, etapas } = args;
|
||||||
|
|
||||||
|
// Ya anclada: actualizar en sitio.
|
||||||
|
if (args.oportunidadId) {
|
||||||
|
const existente = await obtenerOportunidad(ctx, args.oportunidadId);
|
||||||
|
if (existente) {
|
||||||
|
await crmRequest("PUT", `/opportunities/${args.oportunidadId}`, {
|
||||||
|
token: ctx.token,
|
||||||
|
body: { pipelineId: etapas.pipelineId, name: nombre, monetaryValue: importe },
|
||||||
|
});
|
||||||
|
await aplicarEstado(ctx, args.oportunidadId, estado, etapas);
|
||||||
|
const releida = await obtenerOportunidad(ctx, args.oportunidadId);
|
||||||
|
return { opportunity: releida ?? existente, via: "actualizada" };
|
||||||
|
}
|
||||||
|
// El id guardado ya no resuelve; se sigue por el camino normal.
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
pipelineId: etapas.pipelineId,
|
||||||
|
locationId: ctx.locationId,
|
||||||
|
name: nombre,
|
||||||
|
status: estado,
|
||||||
|
contactId,
|
||||||
|
monetaryValue: importe,
|
||||||
|
};
|
||||||
|
const etapa = etapaPara(estado, etapas);
|
||||||
|
if (etapa) body.pipelineStageId = etapa;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await crmRequest<any>("POST", "/opportunities/", { token: ctx.token, body });
|
||||||
|
const creada = r?.opportunity;
|
||||||
|
// Se RELEE antes de dar el id por bueno: la respuesta de creación de esta
|
||||||
|
// API refleja lo que mandaste, no necesariamente lo que persistió.
|
||||||
|
const releida = creada?.id ? await obtenerOportunidad(ctx, creada.id) : null;
|
||||||
|
return { opportunity: releida ?? creada, via: "creada" };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof CrmError && e.status === 400) {
|
||||||
|
const cuerpo = e.body as any;
|
||||||
|
const existenteId =
|
||||||
|
cuerpo?.meta?.existingId ??
|
||||||
|
(cuerpo?.code === "OPPORTUNITY_NO_DUPLICATE" ? cuerpo?.meta?.id : null);
|
||||||
|
|
||||||
|
let id: string | null = existenteId ?? null;
|
||||||
|
if (!id) {
|
||||||
|
// El 400 no trajo el id: se busca. Es el tercer mecanismo, el más débil,
|
||||||
|
// pero aquí la llave natural (contacto + subcuenta) es exacta.
|
||||||
|
const previas = await oportunidadesDeContacto(ctx, contactId);
|
||||||
|
id = previas[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
await crmRequest("PUT", `/opportunities/${id}`, {
|
||||||
|
token: ctx.token,
|
||||||
|
body: { pipelineId: etapas.pipelineId, name: nombre, monetaryValue: importe },
|
||||||
|
});
|
||||||
|
await aplicarEstado(ctx, id, estado, etapas);
|
||||||
|
const releida = await obtenerOportunidad(ctx, id);
|
||||||
|
if (releida) return { opportunity: releida, via: "reciclada" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import type { PoolClient } from "pg";
|
||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { CrmTransportError } from "./client.ts";
|
||||||
|
import { proyectarCita } from "./syncAppointments.ts";
|
||||||
|
|
||||||
|
export type EntidadOutbox = "appointment" | "client" | "message";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clave de deduplicación **propia y estable**. Nunca se deriva del contenido:
|
||||||
|
* dos ediciones que dejan el mismo valor son dos intenciones distintas y las
|
||||||
|
* dos tienen que salir.
|
||||||
|
*/
|
||||||
|
export function claveDedup(
|
||||||
|
businessId: number,
|
||||||
|
entidad: EntidadOutbox,
|
||||||
|
entidadId: number,
|
||||||
|
operacion: string,
|
||||||
|
secuencia: number | string
|
||||||
|
): string {
|
||||||
|
return crypto
|
||||||
|
.createHash("sha256")
|
||||||
|
.update([businessId, entidad, entidadId, operacion, secuencia].join("|"))
|
||||||
|
.digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encola un cambio para el CRM **dentro de la transacción que lo produjo**.
|
||||||
|
*
|
||||||
|
* Recibe el `PoolClient` a propósito: el cambio local y su fila de bandeja se
|
||||||
|
* escriben juntos o no se escriben. Sin eso aparece la escritura perdida — el
|
||||||
|
* usuario ve «guardado», el proceso muere antes de encolar, y nadie lo reclama
|
||||||
|
* nunca.
|
||||||
|
*/
|
||||||
|
export async function encolar(
|
||||||
|
tx: PoolClient,
|
||||||
|
args: {
|
||||||
|
businessId: number;
|
||||||
|
entidad: EntidadOutbox;
|
||||||
|
entidadId: number;
|
||||||
|
operacion: string;
|
||||||
|
payload: unknown;
|
||||||
|
secuencia?: number | string;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const secuencia = args.secuencia ?? Date.now();
|
||||||
|
const dedup = claveDedup(
|
||||||
|
args.businessId,
|
||||||
|
args.entidad,
|
||||||
|
args.entidadId,
|
||||||
|
args.operacion,
|
||||||
|
secuencia
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO crm_outbox (business_id, entity, entity_id, operation, payload, dedup_key)
|
||||||
|
VALUES ($1,$2,$3,$4,$5::jsonb,$6)
|
||||||
|
ON CONFLICT (dedup_key) DO NOTHING`,
|
||||||
|
[
|
||||||
|
args.businessId,
|
||||||
|
args.entidad,
|
||||||
|
args.entidadId,
|
||||||
|
args.operacion,
|
||||||
|
JSON.stringify(args.payload ?? {}),
|
||||||
|
dedup,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResumenDespacho {
|
||||||
|
tomadas: number;
|
||||||
|
confirmadas: number;
|
||||||
|
fallidas: number;
|
||||||
|
indeterminadas: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Despacha la bandeja de salida de un negocio.
|
||||||
|
*
|
||||||
|
* FIFO estricto y **una sola escritura en vuelo por registro**: el CRM
|
||||||
|
* estrangula por token y dos escrituras concurrentes sobre la misma cita
|
||||||
|
* corren contra una base que ya cambió.
|
||||||
|
*/
|
||||||
|
export async function despachar(
|
||||||
|
businessId: number,
|
||||||
|
limite = 25
|
||||||
|
): Promise<ResumenDespacho> {
|
||||||
|
const resumen: ResumenDespacho = {
|
||||||
|
tomadas: 0,
|
||||||
|
confirmadas: 0,
|
||||||
|
fallidas: 0,
|
||||||
|
indeterminadas: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id, entity, entity_id, operation, attempts
|
||||||
|
FROM crm_outbox
|
||||||
|
WHERE business_id = $1 AND status IN ('pendiente','indeterminado')
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT $2`,
|
||||||
|
[businessId, limite]
|
||||||
|
);
|
||||||
|
resumen.tomadas = rows.length;
|
||||||
|
|
||||||
|
for (const fila of rows) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE crm_outbox SET status = 'enviando', attempts = attempts + 1 WHERE id = $1`,
|
||||||
|
[fila.id]
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
let crmId: string | null = null;
|
||||||
|
|
||||||
|
if (fila.entity === "appointment") {
|
||||||
|
const r = await proyectarCita(businessId, fila.entity_id);
|
||||||
|
crmId = r.crmOpportunityId;
|
||||||
|
} else {
|
||||||
|
// Todavía no hay más entidades salientes; se descarta explícitamente
|
||||||
|
// en vez de dejarla girando en la cola para siempre.
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE crm_outbox
|
||||||
|
SET status = 'fallido', last_error = 'entidad no soportada todavía'
|
||||||
|
WHERE id = $1`,
|
||||||
|
[fila.id]
|
||||||
|
);
|
||||||
|
resumen.fallidas++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE crm_outbox
|
||||||
|
SET status = 'confirmado', crm_id = $2, evidence = 'relectura', sent_at = now(),
|
||||||
|
last_error = NULL
|
||||||
|
WHERE id = $1`,
|
||||||
|
[fila.id, crmId]
|
||||||
|
);
|
||||||
|
resumen.confirmadas++;
|
||||||
|
} catch (e: any) {
|
||||||
|
// Un fallo de transporte NO se reintenta: el servidor no habló, así que
|
||||||
|
// no se sabe si la escritura entró, y reenviar es fabricar el duplicado.
|
||||||
|
// Queda en `indeterminado` para resolverlo LEYENDO.
|
||||||
|
const indeterminado = e instanceof CrmTransportError || e?.indeterminate === true;
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE crm_outbox SET status = $2, last_error = $3 WHERE id = $1`,
|
||||||
|
[
|
||||||
|
fila.id,
|
||||||
|
indeterminado ? "indeterminado" : fila.attempts >= 4 ? "fallido" : "pendiente",
|
||||||
|
String(e?.message ?? e).slice(0, 500),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
if (indeterminado) resumen.indeterminadas++;
|
||||||
|
else resumen.fallidas++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resumen;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EstadoOutbox {
|
||||||
|
pendiente: number;
|
||||||
|
enviando: number;
|
||||||
|
confirmado: number;
|
||||||
|
fallido: number;
|
||||||
|
indeterminado: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function estadoOutbox(businessId: number): Promise<EstadoOutbox> {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT status, count(*)::int AS c FROM crm_outbox WHERE business_id = $1 GROUP BY status`,
|
||||||
|
[businessId]
|
||||||
|
);
|
||||||
|
const base: EstadoOutbox = {
|
||||||
|
pendiente: 0,
|
||||||
|
enviando: 0,
|
||||||
|
confirmado: 0,
|
||||||
|
fallido: 0,
|
||||||
|
indeterminado: 0,
|
||||||
|
};
|
||||||
|
for (const r of rows) (base as any)[r.status] = r.c;
|
||||||
|
return base;
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { crmRequest, VERSION_CALENDARS } from "./client.ts";
|
||||||
|
import { ctxDe, type CrmCtx } from "./ctx.ts";
|
||||||
|
import { listarPersonal } from "./calendars.ts";
|
||||||
|
import { slugify } from "../lib/businessDefaults.ts";
|
||||||
|
|
||||||
|
export interface CrmService {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
serviceDuration?: number;
|
||||||
|
serviceDurationUnit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* El catálogo de servicios de la subcuenta.
|
||||||
|
*
|
||||||
|
* MEDIDO dos veces (hallazgos 6 y 32): devuelve `services: []`. El catálogo del
|
||||||
|
* CRM está VACÍO, no ausente — el modelo existe y admite duración, precio,
|
||||||
|
* categoría y variaciones. Simplemente nadie lo ha poblado.
|
||||||
|
*
|
||||||
|
* De ahí la dirección: «sincronizar servicios» no puede significar traerlos. La
|
||||||
|
* duración y el precio los define el negocio en la plataforma, y lo único con
|
||||||
|
* sentido es publicarlos hacia allá.
|
||||||
|
*/
|
||||||
|
export async function catalogoDelCrm(ctx: CrmCtx): Promise<CrmService[]> {
|
||||||
|
const r = await crmRequest<any>("GET", "/calendars/services/catalog", {
|
||||||
|
token: ctx.token,
|
||||||
|
query: { locationId: ctx.locationId },
|
||||||
|
version: VERSION_CALENDARS,
|
||||||
|
});
|
||||||
|
return r?.services ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResultadoPublicacion {
|
||||||
|
crmServiceId: string;
|
||||||
|
nombre: string;
|
||||||
|
yaEstaba: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publica un servicio de la plataforma en el catálogo del CRM.
|
||||||
|
*
|
||||||
|
* MEDIDO (hallazgo 34): `calendars.write` está y `staff[]` con al menos un
|
||||||
|
* miembro es obligatorio — el `422` lo dice literalmente. Los ids de personal
|
||||||
|
* salen de `GET /users/`, que volvió a estar disponible (hallazgo 35).
|
||||||
|
*/
|
||||||
|
export async function publicarServicio(
|
||||||
|
businessId: number,
|
||||||
|
serviceId: number
|
||||||
|
): Promise<ResultadoPublicacion> {
|
||||||
|
const ctx = await ctxDe(businessId);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id, name, description, duration_min, price, color, crm_service_id
|
||||||
|
FROM services WHERE id = $1 AND business_id = $2 AND active = true`,
|
||||||
|
[serviceId, businessId]
|
||||||
|
);
|
||||||
|
const s = rows[0];
|
||||||
|
if (!s) throw { status: 404, error: "Ese servicio no existe en este negocio, o está inactivo" };
|
||||||
|
|
||||||
|
if (s.crm_service_id) {
|
||||||
|
// Ya publicado. Se comprueba que siga existiendo antes de darlo por bueno:
|
||||||
|
// alguien pudo borrarlo desde la interfaz del CRM.
|
||||||
|
const catalogo = await catalogoDelCrm(ctx);
|
||||||
|
if (catalogo.some((x) => x.id === s.crm_service_id)) {
|
||||||
|
return { crmServiceId: s.crm_service_id, nombre: s.name, yaEstaba: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let personal: { id: string; name: string }[] = [];
|
||||||
|
try {
|
||||||
|
personal = await listarPersonal(ctx);
|
||||||
|
} catch (e: any) {
|
||||||
|
// El permiso de personal se ha visto ir y venir (hallazgo 14 → 35). Si no
|
||||||
|
// está, se dice qué falta en vez de fallar con el 422 del catálogo.
|
||||||
|
throw {
|
||||||
|
status: 409,
|
||||||
|
error:
|
||||||
|
"No se pudo leer el personal de la subcuenta, y el catálogo exige al menos una persona por servicio. Revisa que el token tenga permiso de usuarios.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!personal.length) {
|
||||||
|
throw {
|
||||||
|
status: 409,
|
||||||
|
error:
|
||||||
|
"La subcuenta de Bucéfalo CRM no tiene personal, y el catálogo exige al menos una persona por servicio",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cuerpo = {
|
||||||
|
locationId: ctx.locationId,
|
||||||
|
name: s.name,
|
||||||
|
slug: slugify(s.name),
|
||||||
|
...(s.description ? { description: s.description } : {}),
|
||||||
|
...(s.color ? { eventColor: s.color } : {}),
|
||||||
|
serviceDuration: Number(s.duration_min),
|
||||||
|
serviceDurationUnit: "mins",
|
||||||
|
staff: personal.slice(0, 1).map((p) => ({ id: p.id })),
|
||||||
|
variations: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
let r: any;
|
||||||
|
try {
|
||||||
|
r = await crmRequest<any>("POST", "/calendars/services/catalog", {
|
||||||
|
token: ctx.token,
|
||||||
|
version: VERSION_CALENDARS,
|
||||||
|
body: cuerpo,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
// MEDIDO: la PRIMERA publicación en una subcuenta que nunca ha tenido
|
||||||
|
// catálogo falla con `400 No default service category found for this
|
||||||
|
// location` — y ese mismo intento hace que el CRM cree la categoría por
|
||||||
|
// defecto (`isSystemGenerated: true`). El reintento sí entra.
|
||||||
|
//
|
||||||
|
// Se reintenta UNA vez y solo ante ese mensaje concreto: reintentar a ciegas
|
||||||
|
// un POST es fabricar duplicados.
|
||||||
|
const msg = String(e?.message ?? "");
|
||||||
|
if (e?.status === 400 && /default service category/i.test(msg)) {
|
||||||
|
r = await crmRequest<any>("POST", "/calendars/services/catalog", {
|
||||||
|
token: ctx.token,
|
||||||
|
version: VERSION_CALENDARS,
|
||||||
|
body: cuerpo,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const crmServiceId = r?.service?.id ?? r?.id;
|
||||||
|
if (!crmServiceId) {
|
||||||
|
throw new Error("El CRM aceptó el servicio pero no devolvió su identificador");
|
||||||
|
}
|
||||||
|
|
||||||
|
// No se acepta el 200 como prueba: se relee el catálogo y se busca.
|
||||||
|
const catalogo = await catalogoDelCrm(ctx);
|
||||||
|
if (!catalogo.some((x) => x.id === crmServiceId)) {
|
||||||
|
throw new Error(
|
||||||
|
"El servicio no aparece al releer el catálogo del CRM: la escritura no persistió"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE services SET crm_service_id = $2, crm_synced_at = now() WHERE id = $1`,
|
||||||
|
[serviceId, crmServiceId]
|
||||||
|
);
|
||||||
|
return { crmServiceId, nombre: s.name, yaEstaba: false };
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { ctxDe } from "./ctx.ts";
|
||||||
|
import { obtenerConexion, etapasDe } from "./connection.ts";
|
||||||
|
import { resolverContacto } from "./contacts.ts";
|
||||||
|
import {
|
||||||
|
upsertOportunidad,
|
||||||
|
estadoOportunidad,
|
||||||
|
nombreOportunidad,
|
||||||
|
} from "./opportunities.ts";
|
||||||
|
|
||||||
|
export interface ResultadoProyeccion {
|
||||||
|
appointmentId: number;
|
||||||
|
crmContactId: string;
|
||||||
|
crmOpportunityId: string;
|
||||||
|
status: string;
|
||||||
|
via: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proyecta UNA cita al CRM como oportunidad.
|
||||||
|
*
|
||||||
|
* Nombre `SERVICIO — CLIENTA`, importe el del servicio, y estado según la cita:
|
||||||
|
* en espera → `open`, completada → `won`, cancelada o no asistió → `lost`.
|
||||||
|
*
|
||||||
|
* Resuelve primero el contacto: `POST /calendars/...` y `POST /opportunities/`
|
||||||
|
* exigen `contactId`, así que una clienta nacida en la plataforma tiene que
|
||||||
|
* existir en el CRM antes de que su cita pueda salir.
|
||||||
|
*/
|
||||||
|
export async function proyectarCita(
|
||||||
|
businessId: number,
|
||||||
|
appointmentId: number
|
||||||
|
): Promise<ResultadoProyeccion> {
|
||||||
|
const conexion = await obtenerConexion(businessId);
|
||||||
|
if (!conexion) {
|
||||||
|
throw Object.assign(new Error("Este negocio no tiene conexión con Bucéfalo CRM"), {
|
||||||
|
status: 409,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// La conexión da pipeline y etapas; el contexto da la credencial. Son dos
|
||||||
|
// cosas distintas y por eso se piden por separado.
|
||||||
|
const ctx = await ctxDe(businessId);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT a.id, a.status, a.price, a.crm_opportunity_id,
|
||||||
|
c.id AS client_id, c.name AS client_name, c.phone, c.email,
|
||||||
|
c.crm_contact_id, s.name AS service_name
|
||||||
|
FROM appointments a
|
||||||
|
JOIN clients c ON c.id = a.client_id
|
||||||
|
JOIN services s ON s.id = a.service_id
|
||||||
|
WHERE a.id = $1 AND a.business_id = $2`,
|
||||||
|
[appointmentId, businessId]
|
||||||
|
);
|
||||||
|
const cita = rows[0];
|
||||||
|
if (!cita) {
|
||||||
|
throw Object.assign(new Error("Cita no encontrada"), { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contacto = await resolverContacto(ctx, {
|
||||||
|
crmContactId: cita.crm_contact_id,
|
||||||
|
phone: cita.phone,
|
||||||
|
email: cita.email,
|
||||||
|
name: cita.client_name,
|
||||||
|
source: "AgendaMax",
|
||||||
|
tags: ["agendamax"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Se ancla el contacto en cuanto se conoce: si la oportunidad falla después,
|
||||||
|
// al menos la clienta ya no se volverá a crear duplicada.
|
||||||
|
if (contacto.contact.id !== cita.crm_contact_id) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE clients SET crm_contact_id = $2, crm_synced_at = now() WHERE id = $1`,
|
||||||
|
[cita.client_id, contacto.contact.id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const estado = estadoOportunidad(cita.status);
|
||||||
|
const oportunidad = await upsertOportunidad(ctx, {
|
||||||
|
contactId: contacto.contact.id,
|
||||||
|
nombre: nombreOportunidad(cita.service_name, cita.client_name),
|
||||||
|
importe: Number(cita.price) || 0,
|
||||||
|
estado,
|
||||||
|
etapas: etapasDe(conexion),
|
||||||
|
oportunidadId: cita.crm_opportunity_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE appointments
|
||||||
|
SET crm_opportunity_id = $2, crm_status = $3, crm_synced_at = now()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[appointmentId, oportunidad.opportunity.id, estado]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
appointmentId,
|
||||||
|
crmContactId: contacto.contact.id,
|
||||||
|
crmOpportunityId: oportunidad.opportunity.id,
|
||||||
|
status: estado,
|
||||||
|
via: `contacto:${contacto.via} · oportunidad:${oportunidad.via}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { pool, withTx } from "../db/pool.ts";
|
||||||
|
import { ctxDe } from "./ctx.ts";
|
||||||
|
import { normalizePhone } from "../lib/phone.ts";
|
||||||
|
import { buscarContactos, mapAtribucion, nombreDe, type CrmContact } from "./contacts.ts";
|
||||||
|
|
||||||
|
export interface ResumenSync {
|
||||||
|
runId: number;
|
||||||
|
fetched: number;
|
||||||
|
created: number;
|
||||||
|
updated: number;
|
||||||
|
skipped: number;
|
||||||
|
total_crm: number;
|
||||||
|
status: "ok" | "error";
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trae los contactos del CRM a la plataforma, con su atribución.
|
||||||
|
*
|
||||||
|
* Dirección: **una sola, del CRM hacia aquí.** El contacto es del CRM —es su
|
||||||
|
* llave de deduplicación y donde viven las automatizaciones—, así que esta
|
||||||
|
* sincronización nunca escribe hacia allá. Lo que la plataforma quiere empujar
|
||||||
|
* pasa por la bandeja de salida, que es otra cosa.
|
||||||
|
*
|
||||||
|
* Reconciliación, en el mismo orden que la cadena de identidad acordada:
|
||||||
|
* 1. `crm_contact_id` — si ya está anclado, es él y no se busca más.
|
||||||
|
* 2. teléfono normalizado a E.164.
|
||||||
|
* 3. correo.
|
||||||
|
* Si ninguno encaja, se crea la clienta.
|
||||||
|
*
|
||||||
|
* La atribución se sobrescribe siempre desde el CRM: es dato del CRM y él es su
|
||||||
|
* único dueño. El nombre, en cambio, **no pisa** uno editado en la plataforma
|
||||||
|
* si el CRM no trae nada mejor.
|
||||||
|
*/
|
||||||
|
export async function sincronizarContactos(
|
||||||
|
businessId: number,
|
||||||
|
opts: { userId?: number | null; maxPaginas?: number } = {}
|
||||||
|
): Promise<ResumenSync> {
|
||||||
|
// El contexto se pide UNA vez, al principio: descifra el token y ya no se
|
||||||
|
// vuelve a tocar la base para eso en toda la corrida.
|
||||||
|
const ctx = await ctxDe(businessId);
|
||||||
|
|
||||||
|
const run = await pool.query<{ id: number }>(
|
||||||
|
`INSERT INTO crm_sync_runs (business_id, kind, direction, started_by_user_id)
|
||||||
|
VALUES ($1,'contacts','pull',$2) RETURNING id`,
|
||||||
|
[businessId, opts.userId ?? null]
|
||||||
|
);
|
||||||
|
const runId = run.rows[0].id;
|
||||||
|
|
||||||
|
let fetched = 0;
|
||||||
|
let created = 0;
|
||||||
|
let updated = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let cursor: unknown = undefined;
|
||||||
|
const maxPaginas = opts.maxPaginas ?? 60; // 60 × 100 = 6 000 contactos por corrida
|
||||||
|
|
||||||
|
for (let pagina = 0; pagina < maxPaginas; pagina++) {
|
||||||
|
const p = await buscarContactos(ctx, {
|
||||||
|
pageLimit: 100,
|
||||||
|
searchAfter: cursor,
|
||||||
|
});
|
||||||
|
total = p.total;
|
||||||
|
if (!p.contacts.length) break;
|
||||||
|
fetched += p.contacts.length;
|
||||||
|
|
||||||
|
for (const c of p.contacts) {
|
||||||
|
const r = await upsertClienteDesdeCrm(businessId, c);
|
||||||
|
if (r === "created") created++;
|
||||||
|
else if (r === "updated") updated++;
|
||||||
|
else skipped++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!p.searchAfter) break;
|
||||||
|
cursor = p.searchAfter;
|
||||||
|
if (fetched >= total) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE crm_sync_runs
|
||||||
|
SET finished_at = now(), status = 'ok',
|
||||||
|
fetched = $2, created = $3, updated = $4, skipped = $5
|
||||||
|
WHERE id = $1`,
|
||||||
|
[runId, fetched, created, updated, skipped]
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE crm_connections
|
||||||
|
SET last_sync_at = now(),
|
||||||
|
last_sync_status = $2
|
||||||
|
WHERE business_id = $1`,
|
||||||
|
[businessId, `${created} nuevas, ${updated} actualizadas de ${fetched} leídas`]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { runId, fetched, created, updated, skipped, total_crm: total, status: "ok" };
|
||||||
|
} catch (e: any) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE crm_sync_runs
|
||||||
|
SET finished_at = now(), status = 'error', error = $2,
|
||||||
|
fetched = $3, created = $4, updated = $5, skipped = $6
|
||||||
|
WHERE id = $1`,
|
||||||
|
[runId, String(e?.message ?? e).slice(0, 500), fetched, created, updated, skipped]
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE crm_connections SET last_sync_status = $2 WHERE business_id = $1`,
|
||||||
|
[businessId, `error: ${String(e?.message ?? e).slice(0, 200)}`]
|
||||||
|
);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Resultado = "created" | "updated" | "skipped";
|
||||||
|
|
||||||
|
export async function upsertClienteDesdeCrm(
|
||||||
|
businessId: number,
|
||||||
|
c: CrmContact
|
||||||
|
): Promise<Resultado> {
|
||||||
|
const tel = normalizePhone(c.phone);
|
||||||
|
const email = (c.email ?? "").trim().toLowerCase() || null;
|
||||||
|
const nombre = nombreDe(c);
|
||||||
|
const attr = mapAtribucion(c);
|
||||||
|
const tags = Array.isArray(c.tags) ? c.tags.join(",") : null;
|
||||||
|
|
||||||
|
return withTx(async (tx) => {
|
||||||
|
// Cadena de identidad: id anclado → teléfono → correo.
|
||||||
|
let existente: { id: number; name: string } | null = null;
|
||||||
|
|
||||||
|
const porId = await tx.query(
|
||||||
|
`SELECT id, name FROM clients WHERE business_id = $1 AND crm_contact_id = $2`,
|
||||||
|
[businessId, c.id]
|
||||||
|
);
|
||||||
|
existente = porId.rows[0] ?? null;
|
||||||
|
|
||||||
|
if (!existente && tel) {
|
||||||
|
const porTel = await tx.query(
|
||||||
|
`SELECT id, name FROM clients
|
||||||
|
WHERE business_id = $1 AND phone_e164 = $2 AND deleted_at IS NULL`,
|
||||||
|
[businessId, tel]
|
||||||
|
);
|
||||||
|
existente = porTel.rows[0] ?? null;
|
||||||
|
}
|
||||||
|
if (!existente && email) {
|
||||||
|
const porMail = await tx.query(
|
||||||
|
`SELECT id, name FROM clients
|
||||||
|
WHERE business_id = $1 AND lower(email) = $2 AND deleted_at IS NULL`,
|
||||||
|
[businessId, email]
|
||||||
|
);
|
||||||
|
existente = porMail.rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cols = [
|
||||||
|
attr.crm_source,
|
||||||
|
attr.attr_session_source,
|
||||||
|
attr.attr_medium,
|
||||||
|
attr.attr_campaign,
|
||||||
|
attr.attr_campaign_id,
|
||||||
|
attr.attr_utm_source,
|
||||||
|
attr.attr_utm_medium,
|
||||||
|
attr.attr_utm_content,
|
||||||
|
attr.attr_ad_id,
|
||||||
|
attr.attr_referrer,
|
||||||
|
tags,
|
||||||
|
c.dateAdded ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (existente) {
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE clients SET
|
||||||
|
crm_contact_id = $2,
|
||||||
|
crm_synced_at = now(),
|
||||||
|
-- El teléfono y el correo solo se rellenan si aquí faltaban: son
|
||||||
|
-- las llaves de identidad y pisarlas puede fusionar dos personas.
|
||||||
|
phone = COALESCE(phone, $3),
|
||||||
|
phone_e164 = COALESCE(phone_e164, $4),
|
||||||
|
email = COALESCE(email, $5),
|
||||||
|
-- El nombre solo se completa si el de aquí está vacío: alguien pudo
|
||||||
|
-- corregirlo en la plataforma y el CRM trae 56 % de apellidos.
|
||||||
|
name = CASE WHEN btrim(name) = '' THEN $6 ELSE name END,
|
||||||
|
crm_source = $7, attr_session_source = $8, attr_medium = $9,
|
||||||
|
attr_campaign = $10, attr_campaign_id = $11, attr_utm_source = $12,
|
||||||
|
attr_utm_medium = $13, attr_utm_content = $14, attr_ad_id = $15,
|
||||||
|
attr_referrer = $16, crm_tags = $17, crm_date_added = $18::timestamptz
|
||||||
|
WHERE id = $1`,
|
||||||
|
[existente.id, c.id, c.phone ?? null, tel, email, nombre, ...cols]
|
||||||
|
);
|
||||||
|
return "updated";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO clients
|
||||||
|
(business_id, name, email, phone, phone_e164, crm_contact_id, crm_synced_at,
|
||||||
|
crm_source, attr_session_source, attr_medium, attr_campaign, attr_campaign_id,
|
||||||
|
attr_utm_source, attr_utm_medium, attr_utm_content, attr_ad_id, attr_referrer,
|
||||||
|
crm_tags, crm_date_added, birth_date)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,now(),
|
||||||
|
$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18::timestamptz,$19::date)`,
|
||||||
|
[
|
||||||
|
businessId,
|
||||||
|
nombre,
|
||||||
|
email,
|
||||||
|
c.phone ?? null,
|
||||||
|
tel,
|
||||||
|
c.id,
|
||||||
|
...cols,
|
||||||
|
c.dateOfBirth ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return "created";
|
||||||
|
} catch (e: any) {
|
||||||
|
// El índice único de teléfono ganó una carrera: otra clienta con el mismo
|
||||||
|
// número entró entre la consulta y este INSERT. Se ancla al existente en
|
||||||
|
// vez de perder el contacto.
|
||||||
|
if (e.code === "23505" && tel) {
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE clients SET crm_contact_id = $3, crm_synced_at = now()
|
||||||
|
WHERE business_id = $1 AND phone_e164 = $2 AND crm_contact_id IS NULL`,
|
||||||
|
[businessId, tel, c.id]
|
||||||
|
);
|
||||||
|
return "updated";
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { ctxDe } from "./ctx.ts";
|
||||||
|
import {
|
||||||
|
buscarConversaciones,
|
||||||
|
mensajesDeConversacion,
|
||||||
|
obtenerConversacion,
|
||||||
|
normalizarTipo,
|
||||||
|
type CrmConversation,
|
||||||
|
type CrmMessage,
|
||||||
|
} from "./conversations.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fecha del CRM → `Date`.
|
||||||
|
*
|
||||||
|
* La API mezcla formatos: `lastMessageDate` llega como epoch en milisegundos y
|
||||||
|
* `dateAdded` como ISO. Aceptar los dos aquí evita repartir esa comprobación por
|
||||||
|
* todos los sitios que guardan una fecha.
|
||||||
|
*/
|
||||||
|
function fecha(v: string | number | undefined | null): Date | null {
|
||||||
|
if (v == null) return null;
|
||||||
|
const d = new Date(v);
|
||||||
|
return isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertConversacion(
|
||||||
|
businessId: number,
|
||||||
|
c: CrmConversation
|
||||||
|
): Promise<number> {
|
||||||
|
const { rows } = await pool.query<{ id: number }>(
|
||||||
|
`INSERT INTO conversations
|
||||||
|
(business_id, crm_conversation_id, crm_contact_id, contact_name,
|
||||||
|
last_message_type, last_message_body, last_message_at, unread_count,
|
||||||
|
client_id, synced_at)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,
|
||||||
|
(SELECT id FROM clients
|
||||||
|
WHERE business_id = $1 AND crm_contact_id = $3 AND deleted_at IS NULL
|
||||||
|
LIMIT 1),
|
||||||
|
now())
|
||||||
|
ON CONFLICT (business_id, crm_conversation_id) DO UPDATE SET
|
||||||
|
crm_contact_id = COALESCE(EXCLUDED.crm_contact_id, conversations.crm_contact_id),
|
||||||
|
-- Ni el nombre ni el canal se degradan.
|
||||||
|
--
|
||||||
|
-- MEDIDO: GET /conversations/{id} NO devuelve el nombre del contacto ni
|
||||||
|
-- un canal reconocible; eso solo viene del buscador. Sincronizar un hilo
|
||||||
|
-- por su id sobrescribia un nombre bueno con "Sin nombre" y el canal con
|
||||||
|
-- "Desconocido". Un dato pobre no puede pisar a uno que ya se tenia.
|
||||||
|
contact_name = CASE
|
||||||
|
WHEN EXCLUDED.contact_name = 'Sin nombre'
|
||||||
|
THEN COALESCE(conversations.contact_name, EXCLUDED.contact_name)
|
||||||
|
ELSE EXCLUDED.contact_name
|
||||||
|
END,
|
||||||
|
last_message_type = CASE
|
||||||
|
WHEN EXCLUDED.last_message_type = 'Desconocido'
|
||||||
|
THEN COALESCE(conversations.last_message_type, EXCLUDED.last_message_type)
|
||||||
|
ELSE EXCLUDED.last_message_type
|
||||||
|
END,
|
||||||
|
last_message_body = COALESCE(EXCLUDED.last_message_body, conversations.last_message_body),
|
||||||
|
last_message_at = COALESCE(EXCLUDED.last_message_at, conversations.last_message_at),
|
||||||
|
unread_count = EXCLUDED.unread_count,
|
||||||
|
-- El enlace con la clienta solo se RELLENA, nunca se borra: si la
|
||||||
|
-- sincronizacion de contactos todavia no ha corrido, client_id es NULL,
|
||||||
|
-- y pisarlo con NULL mas tarde perderia un enlace ya resuelto.
|
||||||
|
client_id = COALESCE(conversations.client_id, EXCLUDED.client_id),
|
||||||
|
synced_at = now()
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
businessId,
|
||||||
|
c.id,
|
||||||
|
c.contactId ?? null,
|
||||||
|
c.fullName || c.contactName || "Sin nombre",
|
||||||
|
normalizarTipo(c.lastMessageType),
|
||||||
|
c.lastMessageBody ?? null,
|
||||||
|
fecha(c.lastMessageDate),
|
||||||
|
c.unreadCount ?? 0,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertMensaje(
|
||||||
|
businessId: number,
|
||||||
|
conversationId: number,
|
||||||
|
m: CrmMessage
|
||||||
|
): Promise<void> {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO messages
|
||||||
|
(business_id, conversation_id, crm_message_id, crm_contact_id,
|
||||||
|
direction, channel, channel_raw, body, status, sent_at)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||||
|
ON CONFLICT (business_id, crm_message_id) DO UPDATE SET
|
||||||
|
-- El CRM es el dueno del historico: aqui se reescribe desde el, nunca se
|
||||||
|
-- edita. Solo cambian cuerpo y estado; el resto es inmutable.
|
||||||
|
body = EXCLUDED.body,
|
||||||
|
status = EXCLUDED.status`,
|
||||||
|
[
|
||||||
|
businessId,
|
||||||
|
conversationId,
|
||||||
|
m.id,
|
||||||
|
m.contactId ?? null,
|
||||||
|
m.direction === "outbound" ? "outbound" : "inbound",
|
||||||
|
normalizarTipo(m.messageType),
|
||||||
|
m.messageType ?? null,
|
||||||
|
m.body ?? null,
|
||||||
|
m.status ?? null,
|
||||||
|
fecha(m.dateAdded),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResumenSyncConv {
|
||||||
|
conversaciones: number;
|
||||||
|
mensajes: number;
|
||||||
|
runId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Espeja UNA conversación con todos sus mensajes.
|
||||||
|
*
|
||||||
|
* Pagina hasta 20 vueltas de 100: son 2 000 mensajes por hilo, muy por encima de
|
||||||
|
* cualquier conversación real, y el tope existe para que un `nextPage` que nunca
|
||||||
|
* deje de ser `true` no cuelgue la petición para siempre.
|
||||||
|
*/
|
||||||
|
export async function sincronizarConversacion(
|
||||||
|
businessId: number,
|
||||||
|
crmConversationId: string
|
||||||
|
): Promise<{ conversacion: number; mensajes: number }> {
|
||||||
|
const ctx = await ctxDe(businessId);
|
||||||
|
const c = await obtenerConversacion(ctx, crmConversationId);
|
||||||
|
if (!c) throw { status: 404, error: "Esa conversación no existe en Bucéfalo CRM" };
|
||||||
|
|
||||||
|
// El endpoint de una conversación suelta no trae el nombre del contacto. Si
|
||||||
|
// la clienta ya está en la plataforma, se usa el suyo: es mejor dato que el
|
||||||
|
// relleno, y evita que la bandeja muestre "Sin nombre" para alguien conocido.
|
||||||
|
let nombre = c.fullName || c.contactName;
|
||||||
|
if (!nombre && c.contactId) {
|
||||||
|
const { rows } = await pool.query<{ name: string }>(
|
||||||
|
`SELECT name FROM clients
|
||||||
|
WHERE business_id = $1 AND crm_contact_id = $2 AND deleted_at IS NULL LIMIT 1`,
|
||||||
|
[businessId, c.contactId]
|
||||||
|
);
|
||||||
|
nombre = rows[0]?.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const convId = await upsertConversacion(businessId, {
|
||||||
|
...c,
|
||||||
|
id: crmConversationId,
|
||||||
|
fullName: nombre,
|
||||||
|
});
|
||||||
|
|
||||||
|
let cursor: string | undefined;
|
||||||
|
let total = 0;
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const { mensajes, lastMessageId, hayMas } = await mensajesDeConversacion(
|
||||||
|
ctx,
|
||||||
|
crmConversationId,
|
||||||
|
{ limit: 100, lastMessageId: cursor }
|
||||||
|
);
|
||||||
|
for (const m of mensajes) {
|
||||||
|
await upsertMensaje(businessId, convId, m);
|
||||||
|
total++;
|
||||||
|
}
|
||||||
|
if (!hayMas || !lastMessageId || !mensajes.length) break;
|
||||||
|
cursor = lastMessageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// El canal del hilo se deduce de su ultimo mensaje real.
|
||||||
|
//
|
||||||
|
// GET /conversations/{id} no devuelve un canal reconocible, pero los mensajes
|
||||||
|
// que acabamos de traer si lo traen. Deducirlo de ahi es mejor que dejar
|
||||||
|
// "Desconocido" en la bandeja, y no cuesta ni una peticion mas.
|
||||||
|
// Se excluyen las actividades: son notas que el propio CRM escribe en el hilo,
|
||||||
|
// no un canal por el que hablar con la clienta.
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE conversations c
|
||||||
|
SET last_message_type = COALESCE(
|
||||||
|
(SELECT m.channel FROM messages m
|
||||||
|
WHERE m.conversation_id = c.id AND m.channel <> 'Actividad'
|
||||||
|
ORDER BY m.sent_at DESC NULLS LAST, m.id DESC LIMIT 1),
|
||||||
|
c.last_message_type)
|
||||||
|
WHERE c.id = $1 AND c.last_message_type = 'Desconocido'`,
|
||||||
|
[convId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { conversacion: convId, mensajes: total };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Espeja las conversaciones más recientes con sus últimos mensajes. */
|
||||||
|
export async function sincronizarConversaciones(
|
||||||
|
businessId: number,
|
||||||
|
opts: { limit?: number; userId?: number | null } = {}
|
||||||
|
): Promise<ResumenSyncConv> {
|
||||||
|
const ctx = await ctxDe(businessId);
|
||||||
|
const { rows: run } = await pool.query<{ id: number }>(
|
||||||
|
`INSERT INTO crm_sync_runs (business_id, kind, direction, started_by_user_id)
|
||||||
|
VALUES ($1,'conversations','pull',$2) RETURNING id`,
|
||||||
|
[businessId, opts.userId ?? null]
|
||||||
|
);
|
||||||
|
const runId = run[0].id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { conversations } = await buscarConversaciones(ctx, { limit: opts.limit ?? 50 });
|
||||||
|
let mensajes = 0;
|
||||||
|
for (const c of conversations) {
|
||||||
|
const convId = await upsertConversacion(businessId, c);
|
||||||
|
const { mensajes: ms } = await mensajesDeConversacion(ctx, c.id, { limit: 50 });
|
||||||
|
for (const m of ms) {
|
||||||
|
await upsertMensaje(businessId, convId, m);
|
||||||
|
mensajes++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE crm_sync_runs
|
||||||
|
SET status='ok', finished_at=now(), fetched=$2, created=$3
|
||||||
|
WHERE id = $1`,
|
||||||
|
[runId, conversations.length, mensajes]
|
||||||
|
);
|
||||||
|
return { conversaciones: conversations.length, mensajes, runId };
|
||||||
|
} catch (e: any) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE crm_sync_runs SET status='error', finished_at=now(), error=$2 WHERE id=$1`,
|
||||||
|
[runId, String(e?.error ?? e?.message ?? e).slice(0, 500)]
|
||||||
|
);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { ctxDe } from "./ctx.ts";
|
||||||
|
import { obtenerContacto } from "./contacts.ts";
|
||||||
|
import { upsertClienteDesdeCrm } from "./syncContacts.ts";
|
||||||
|
import { sincronizarConversacion } from "./syncConversations.ts";
|
||||||
|
import { obtenerMensaje } from "./conversations.ts";
|
||||||
|
import { proyectarCita } from "./syncAppointments.ts";
|
||||||
|
import { publicarServicio } from "./services.ts";
|
||||||
|
|
||||||
|
export const ENTIDADES = ["contacto", "conversacion", "mensaje", "cita", "servicio"] as const;
|
||||||
|
export type Entidad = (typeof ENTIDADES)[number];
|
||||||
|
|
||||||
|
export function esEntidad(v: string): v is Entidad {
|
||||||
|
return (ENTIDADES as readonly string[]).includes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResultadoUno {
|
||||||
|
entidad: Entidad;
|
||||||
|
id: string;
|
||||||
|
accion: string;
|
||||||
|
detalle: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sincroniza UNA entidad por su identificador.
|
||||||
|
*
|
||||||
|
* La dirección no es la misma para las cinco, y no es un capricho:
|
||||||
|
*
|
||||||
|
* - **contacto, conversación y mensaje se TRAEN**: el CRM es su dueño. Es donde
|
||||||
|
* viven la deduplicación y las automatizaciones.
|
||||||
|
* - **cita y servicio se EMPUJAN.** MEDIDO (hallazgos 29 y 32): el calendario
|
||||||
|
* del CRM tiene UNA cita en dos años y su catálogo de servicios está vacío.
|
||||||
|
* No hay nada que arrastrar; la agenda y el catálogo nacen en la plataforma.
|
||||||
|
*
|
||||||
|
* Si algún día el spa empieza a agendar dentro del CRM, esa premisa se cae y
|
||||||
|
* habrá que decidir cuál de los dos manda cuando difieran. Conviene decidirlo
|
||||||
|
* antes de que ocurra.
|
||||||
|
*/
|
||||||
|
export async function sincronizarPorId(
|
||||||
|
businessId: number,
|
||||||
|
entidad: Entidad,
|
||||||
|
id: string
|
||||||
|
): Promise<ResultadoUno> {
|
||||||
|
switch (entidad) {
|
||||||
|
case "contacto": {
|
||||||
|
const ctx = await ctxDe(businessId);
|
||||||
|
const c = await obtenerContacto(ctx, id);
|
||||||
|
if (!c) throw { status: 404, error: "Ese contacto no existe en Bucéfalo CRM" };
|
||||||
|
const r = await upsertClienteDesdeCrm(businessId, c);
|
||||||
|
return {
|
||||||
|
entidad,
|
||||||
|
id,
|
||||||
|
accion: r === "created" ? "creado" : r === "updated" ? "actualizado" : "sin cambios",
|
||||||
|
detalle: { resultado: r },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "conversacion": {
|
||||||
|
const r = await sincronizarConversacion(businessId, id);
|
||||||
|
return { entidad, id, accion: "espejada", detalle: r };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "mensaje": {
|
||||||
|
const ctx = await ctxDe(businessId);
|
||||||
|
const m = await obtenerMensaje(ctx, id);
|
||||||
|
if (!m) throw { status: 404, error: "Ese mensaje no existe en Bucéfalo CRM" };
|
||||||
|
if (!m.conversationId) {
|
||||||
|
throw {
|
||||||
|
status: 409,
|
||||||
|
error: "El mensaje no dice a qué conversación pertenece, y sin ella no se puede guardar",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Se sincroniza el hilo entero: `messages.conversation_id` es obligatorio,
|
||||||
|
// así que un mensaje suelto sin su conversación no tiene dónde ir.
|
||||||
|
const r = await sincronizarConversacion(businessId, m.conversationId);
|
||||||
|
return {
|
||||||
|
entidad,
|
||||||
|
id,
|
||||||
|
accion: "espejado con su hilo",
|
||||||
|
detalle: { ...r, conversacion_crm: m.conversationId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "cita": {
|
||||||
|
const n = Number(id);
|
||||||
|
if (!Number.isFinite(n)) {
|
||||||
|
throw { status: 400, error: "El identificador de la cita es el numérico de la plataforma" };
|
||||||
|
}
|
||||||
|
const r = await proyectarCita(businessId, n);
|
||||||
|
return { entidad, id, accion: "empujada", detalle: r as unknown as Record<string, unknown> };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "servicio": {
|
||||||
|
const n = Number(id);
|
||||||
|
if (!Number.isFinite(n)) {
|
||||||
|
throw {
|
||||||
|
status: 400,
|
||||||
|
error: "El identificador del servicio es el numérico de la plataforma",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const r = await publicarServicio(businessId, n);
|
||||||
|
return {
|
||||||
|
entidad,
|
||||||
|
id,
|
||||||
|
accion: r.yaEstaba ? "ya estaba publicado" : "publicado",
|
||||||
|
detalle: r as unknown as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { despachar } from "./outbox.ts";
|
||||||
|
|
||||||
|
let corriendo = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vacía la bandeja de salida cada cierto tiempo.
|
||||||
|
*
|
||||||
|
* Es un intervalo y no una cola de verdad a propósito: hay un solo negocio, el
|
||||||
|
* CRM estrangula a ~1 petición cada 0.65 s, y el volumen real son unas pocas
|
||||||
|
* citas al día. Redis y un worker aparte serían infraestructura sin problema
|
||||||
|
* que resolver. Cuando haya varios negocios habrá que revisarlo, porque el
|
||||||
|
* estrangulamiento es **por token** y estos despachos serían secuenciales.
|
||||||
|
*
|
||||||
|
* La guarda `corriendo` evita que dos vueltas se solapen: dos escrituras
|
||||||
|
* concurrentes sobre la misma cita corren contra una base que ya cambió.
|
||||||
|
*/
|
||||||
|
export function arrancarWorker(intervaloMs = 60_000): NodeJS.Timeout {
|
||||||
|
const tick = async () => {
|
||||||
|
if (corriendo) return;
|
||||||
|
corriendo = true;
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<{ business_id: number }>(
|
||||||
|
`SELECT DISTINCT business_id FROM crm_outbox WHERE status = 'pendiente'`
|
||||||
|
);
|
||||||
|
for (const r of rows) {
|
||||||
|
const res = await despachar(r.business_id, 25);
|
||||||
|
if (res.tomadas) {
|
||||||
|
console.log(
|
||||||
|
`[crm-worker] negocio ${r.business_id}: ${res.confirmadas} confirmadas, ` +
|
||||||
|
`${res.fallidas} fallidas, ${res.indeterminadas} indeterminadas`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Un fallo aquí no debe tumbar el servidor: la bandeja seguirá llena y el
|
||||||
|
// panel de clientes lo enseña, que es justo para lo que existe.
|
||||||
|
console.error("[crm-worker]", (e as Error).message);
|
||||||
|
} finally {
|
||||||
|
corriendo = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const t = setInterval(tick, intervaloMs);
|
||||||
|
// No mantiene vivo el proceso: si el servidor se cierra, no hay que esperarlo.
|
||||||
|
t.unref?.();
|
||||||
|
return t;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { pool } from "./pool.ts";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const MIGRATIONS_DIR = path.join(__dirname, "migrations");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aplica en orden alfabético los .sql que aún no estén en schema_migrations.
|
||||||
|
* Cada archivo corre dentro de su propia transacción: si falla a la mitad, no
|
||||||
|
* queda registrado y la siguiente corrida lo reintenta entero.
|
||||||
|
*/
|
||||||
|
export async function runMigrations(): Promise<string[]> {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
filename text PRIMARY KEY,
|
||||||
|
applied_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const files = fs
|
||||||
|
.readdirSync(MIGRATIONS_DIR)
|
||||||
|
.filter((f) => f.endsWith(".sql"))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
const { rows } = await pool.query<{ filename: string }>(
|
||||||
|
`SELECT filename FROM schema_migrations`
|
||||||
|
);
|
||||||
|
const applied = new Set(rows.map((r) => r.filename));
|
||||||
|
|
||||||
|
const ran: string[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
if (applied.has(file)) continue;
|
||||||
|
const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), "utf8");
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
await client.query(sql);
|
||||||
|
await client.query(`INSERT INTO schema_migrations (filename) VALUES ($1)`, [file]);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
ran.push(file);
|
||||||
|
console.log(`[migrate] aplicada ${file}`);
|
||||||
|
} catch (e) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw new Error(`Migración ${file} falló: ${(e as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ran;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permite `node scripts/run-tsx.mjs platform/db/migrate.ts` desde la línea de comandos.
|
||||||
|
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
|
||||||
|
runMigrations()
|
||||||
|
.then((ran) => {
|
||||||
|
console.log(ran.length ? `[migrate] ${ran.length} aplicadas` : "[migrate] al día");
|
||||||
|
return pool.end();
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- btree_gist permite mezclar un igualador (employee_id) con un operador de
|
||||||
|
-- solapamiento (&&) dentro de la misma restricción de exclusión. Sin esta
|
||||||
|
-- extensión, EXCLUDE USING gist (employee_id WITH =, during WITH &&) no compila.
|
||||||
|
CREATE EXTENSION IF NOT EXISTS btree_gist;
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Núcleo de la plataforma. Nombres de tabla y columna en inglés a propósito:
|
||||||
|
-- son los que shared/types.ts y el frontend ya consumen.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE businesses (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
name text NOT NULL,
|
||||||
|
industry text NOT NULL DEFAULT 'Estética y Spa',
|
||||||
|
currency text NOT NULL DEFAULT 'MXN',
|
||||||
|
currency_symbol text NOT NULL DEFAULT '$',
|
||||||
|
phone text,
|
||||||
|
address text,
|
||||||
|
slug text UNIQUE,
|
||||||
|
timezone text NOT NULL DEFAULT 'America/Mexico_City',
|
||||||
|
working_hours jsonb NOT NULL,
|
||||||
|
status text NOT NULL DEFAULT 'active',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE employees (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
email text,
|
||||||
|
phone text,
|
||||||
|
color text NOT NULL DEFAULT '#3b66ff',
|
||||||
|
role text NOT NULL DEFAULT 'specialist',
|
||||||
|
active boolean NOT NULL DEFAULT true,
|
||||||
|
working_hours jsonb, -- NULL = hereda del negocio
|
||||||
|
commission_pct numeric(5,2) NOT NULL DEFAULT 0,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE services (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
description text,
|
||||||
|
category text NOT NULL DEFAULT 'General',
|
||||||
|
duration_min integer NOT NULL DEFAULT 60,
|
||||||
|
price numeric(10,2) NOT NULL DEFAULT 0,
|
||||||
|
color text NOT NULL DEFAULT '#3b66ff',
|
||||||
|
commission_pct numeric(5,2) NOT NULL DEFAULT 0,
|
||||||
|
active boolean NOT NULL DEFAULT true,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE employee_services (
|
||||||
|
employee_id bigint NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
|
||||||
|
service_id bigint NOT NULL REFERENCES services(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (employee_id, service_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
business_id bigint REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
email text NOT NULL UNIQUE,
|
||||||
|
password text NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
role text NOT NULL CHECK (role IN ('admin','owner','employee')),
|
||||||
|
employee_id bigint REFERENCES employees(id),
|
||||||
|
avatar_color text NOT NULL DEFAULT '#3b66ff',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- La clienta. `phone_e164` es la clave de identidad: es lo único que puede
|
||||||
|
-- reconciliar el mismo número que llega por canales distintos, y el índice
|
||||||
|
-- parcial de abajo es lo que impide el duplicado.
|
||||||
|
CREATE TABLE clients (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
email text,
|
||||||
|
phone text, -- lo que tecleó la persona, tal cual
|
||||||
|
phone_e164 text, -- lo normalizado; NULL si no se pudo
|
||||||
|
contactable boolean GENERATED ALWAYS AS (phone_e164 IS NOT NULL) STORED,
|
||||||
|
birth_date date,
|
||||||
|
notes text,
|
||||||
|
tags text,
|
||||||
|
source_channel text, -- whatsapp|facebook|instagram|mostrador|referido
|
||||||
|
-- Se declara desde el día uno aunque la Fase 2 aún no exista: es el ancla de
|
||||||
|
-- correlación con Bucéfalo CRM, y añadirla después obliga a un backfill que
|
||||||
|
-- no se puede hacer sin releer el CRM entero.
|
||||||
|
crm_contact_id text,
|
||||||
|
crm_synced_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz -- baja lógica: la clienta nunca se borra
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Un mismo teléfono no puede repetirse dentro de un negocio. Es parcial porque
|
||||||
|
-- el 40.8 % del histórico medido no tiene teléfono y esas filas deben convivir.
|
||||||
|
CREATE UNIQUE INDEX clients_phone_unique
|
||||||
|
ON clients (business_id, phone_e164)
|
||||||
|
WHERE phone_e164 IS NOT NULL AND deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX clients_business_name ON clients (business_id, name);
|
||||||
|
|
||||||
|
CREATE TABLE appointments (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
client_id bigint NOT NULL REFERENCES clients(id),
|
||||||
|
employee_id bigint NOT NULL REFERENCES employees(id),
|
||||||
|
service_id bigint NOT NULL REFERENCES services(id),
|
||||||
|
start_at timestamptz NOT NULL,
|
||||||
|
-- `end_at` se materializa, no se deriva: si mañana cambia la duración del
|
||||||
|
-- servicio, las citas ya agendadas no deben moverse.
|
||||||
|
end_at timestamptz NOT NULL,
|
||||||
|
during tstzrange GENERATED ALWAYS AS (tstzrange(start_at, end_at, '[)')) STORED,
|
||||||
|
status text NOT NULL DEFAULT 'scheduled'
|
||||||
|
CHECK (status IN ('scheduled','completed','cancelled','no_show')),
|
||||||
|
cancelled_by text CHECK (cancelled_by IN ('client','business')),
|
||||||
|
cancel_reason text,
|
||||||
|
price numeric(10,2) NOT NULL DEFAULT 0,
|
||||||
|
notes text,
|
||||||
|
source_channel text,
|
||||||
|
created_by_user_id bigint REFERENCES users(id),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT appointments_end_after_start CHECK (end_at > start_at),
|
||||||
|
CONSTRAINT appointments_cancelled_by_only_when_cancelled
|
||||||
|
CHECK (cancelled_by IS NULL OR status = 'cancelled'),
|
||||||
|
-- Aquí está la diferencia con el backend de SQLite: la doble reserva deja de
|
||||||
|
-- ser una validación que alguien puede saltarse y pasa a ser el motor
|
||||||
|
-- rechazando la fila. Las canceladas no reservan hueco.
|
||||||
|
CONSTRAINT appointments_no_overlap EXCLUDE USING gist (
|
||||||
|
employee_id WITH =,
|
||||||
|
during WITH &&
|
||||||
|
) WHERE (status <> 'cancelled')
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX appointments_business_start ON appointments (business_id, start_at);
|
||||||
|
CREATE INDEX appointments_employee_start ON appointments (employee_id, start_at);
|
||||||
|
CREATE INDEX appointments_client ON appointments (client_id);
|
||||||
|
|
||||||
|
-- La visita es el hecho consumado, y está separada de la cita a propósito:
|
||||||
|
-- una cita es una intención. Fusionarlas es el error que dejó 3 002
|
||||||
|
-- oportunidades congeladas en el CRM — un registro que sirve para planear y
|
||||||
|
-- para cerrar termina sin cerrarse nunca.
|
||||||
|
CREATE TABLE visits (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
appointment_id bigint UNIQUE REFERENCES appointments(id),
|
||||||
|
client_id bigint NOT NULL REFERENCES clients(id),
|
||||||
|
employee_id bigint NOT NULL REFERENCES employees(id),
|
||||||
|
occurred_at timestamptz NOT NULL,
|
||||||
|
total_charged numeric(10,2),
|
||||||
|
payment_method text CHECK (payment_method IN ('cash','card','transfer','other')),
|
||||||
|
recorded_by_user_id bigint REFERENCES users(id),
|
||||||
|
recorded_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX visits_business_occurred ON visits (business_id, occurred_at);
|
||||||
|
CREATE INDEX visits_client ON visits (client_id);
|
||||||
|
|
||||||
|
-- Historial de la cita. Append-only: nunca se actualiza ni se borra.
|
||||||
|
CREATE TABLE appointment_events (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
appointment_id bigint NOT NULL REFERENCES appointments(id) ON DELETE CASCADE,
|
||||||
|
actor_user_id bigint REFERENCES users(id),
|
||||||
|
action text NOT NULL, -- created|rescheduled|cancelled|attended|no_show
|
||||||
|
from_status text,
|
||||||
|
to_status text,
|
||||||
|
detail jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX appointment_events_appointment ON appointment_events (appointment_id, created_at);
|
||||||
|
|
||||||
|
-- Quién cambió qué, cuándo y desde dónde.
|
||||||
|
CREATE TABLE audit_log (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
business_id bigint,
|
||||||
|
actor_user_id bigint REFERENCES users(id),
|
||||||
|
entity text NOT NULL,
|
||||||
|
entity_id bigint,
|
||||||
|
action text NOT NULL,
|
||||||
|
before jsonb,
|
||||||
|
after jsonb,
|
||||||
|
ip text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX audit_log_business_created ON audit_log (business_id, created_at DESC);
|
||||||
|
CREATE INDEX audit_log_entity ON audit_log (entity, entity_id);
|
||||||
|
|
||||||
|
-- El cierre de día. Una fila por día cerrado; la restricción única es lo que
|
||||||
|
-- hace que cerrar dos veces no sea posible.
|
||||||
|
CREATE TABLE day_closures (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
business_date date NOT NULL,
|
||||||
|
closed_by_user_id bigint NOT NULL REFERENCES users(id),
|
||||||
|
closed_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
attended_count integer NOT NULL,
|
||||||
|
no_show_count integer NOT NULL,
|
||||||
|
cancelled_count integer NOT NULL,
|
||||||
|
UNIQUE (business_id, business_date)
|
||||||
|
);
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Integración con Bucéfalo CRM.
|
||||||
|
--
|
||||||
|
-- Todo lo de aquí está diseñado contra hallazgos MEDIDOS contra la subcuenta
|
||||||
|
-- real de Yola Franco Spa (Pk89Wa23QaxvkOfKgwjZ) el 2026-08-29, no contra la
|
||||||
|
-- especificación. Ver platform/crm/HALLAZGOS.md.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- La conexión con la subcuenta. Una fila por negocio.
|
||||||
|
-- El token NO vive aquí: vive en el entorno del servidor. Esta tabla guarda
|
||||||
|
-- qué subcuenta, qué pipeline y qué etapas usa cada negocio.
|
||||||
|
CREATE TABLE crm_connections (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
business_id bigint NOT NULL UNIQUE REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
location_id text NOT NULL,
|
||||||
|
pipeline_id text,
|
||||||
|
stage_open_id text,
|
||||||
|
stage_won_id text,
|
||||||
|
stage_lost_id text,
|
||||||
|
-- MEDIDO: la subcuenta trae `allowDuplicateOpportunity: false`, así que el
|
||||||
|
-- CRM rechaza una segunda oportunidad por contacto AUNQUE la anterior esté
|
||||||
|
-- cerrada. Mientras esté en false, la plataforma recicla la oportunidad
|
||||||
|
-- existente en vez de crear una por cita. Si el cliente activa el ajuste,
|
||||||
|
-- esta bandera pasa a true y cada cita estrena la suya.
|
||||||
|
allow_duplicate_opp boolean NOT NULL DEFAULT false,
|
||||||
|
last_sync_at timestamptz,
|
||||||
|
last_sync_status text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Atribución de la clienta. Se separa de `clients` porque son 10+ columnas que
|
||||||
|
-- solo existen si el contacto vino del CRM, y porque el CRM las declara
|
||||||
|
-- inmutables: se escriben en el alta y un PUT posterior devuelve 200 sin
|
||||||
|
-- guardar nada. Aquí son espejo de lectura.
|
||||||
|
ALTER TABLE clients
|
||||||
|
ADD COLUMN crm_source text,
|
||||||
|
ADD COLUMN attr_session_source text,
|
||||||
|
ADD COLUMN attr_medium text,
|
||||||
|
ADD COLUMN attr_campaign text,
|
||||||
|
ADD COLUMN attr_campaign_id text,
|
||||||
|
ADD COLUMN attr_utm_source text,
|
||||||
|
ADD COLUMN attr_utm_medium text,
|
||||||
|
ADD COLUMN attr_utm_content text,
|
||||||
|
ADD COLUMN attr_ad_id text,
|
||||||
|
ADD COLUMN attr_referrer text,
|
||||||
|
ADD COLUMN crm_tags text,
|
||||||
|
ADD COLUMN crm_date_added timestamptz;
|
||||||
|
|
||||||
|
CREATE INDEX clients_crm_contact ON clients (crm_contact_id)
|
||||||
|
WHERE crm_contact_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- La cita se proyecta al CRM como oportunidad.
|
||||||
|
ALTER TABLE appointments
|
||||||
|
ADD COLUMN crm_opportunity_id text,
|
||||||
|
ADD COLUMN crm_synced_at timestamptz,
|
||||||
|
ADD COLUMN crm_status text; -- lo que el CRM cree: open|won|lost
|
||||||
|
|
||||||
|
CREATE INDEX appointments_crm_opp ON appointments (crm_opportunity_id)
|
||||||
|
WHERE crm_opportunity_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Bandeja de salida. Existe desde el día uno a propósito: la API del CRM falla,
|
||||||
|
-- y sin cola un fallo se traga la cita de una clienta sin que nadie lo sepa.
|
||||||
|
-- El cambio local y su fila de bandeja se escriben en la MISMA transacción; sin
|
||||||
|
-- eso aparece la escritura perdida (el usuario ve "guardado", el proceso muere
|
||||||
|
-- antes de encolar, y nadie lo reclama nunca).
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE crm_outbox (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
entity text NOT NULL, -- client | appointment | message
|
||||||
|
entity_id bigint NOT NULL,
|
||||||
|
operation text NOT NULL, -- create | update | status | send
|
||||||
|
payload jsonb NOT NULL,
|
||||||
|
-- pendiente → enviando → confirmado | fallido | indeterminado
|
||||||
|
--
|
||||||
|
-- `indeterminado` no es un adorno: es donde cae un fallo de TRANSPORTE
|
||||||
|
-- (timeout, conexión caída). Un 5xx es una respuesta —el servidor habló—;
|
||||||
|
-- un timeout no dice nada sobre si la escritura entró. Reenviarlo es
|
||||||
|
-- fabricar la doble creación, así que se resuelve leyendo, nunca reenviando.
|
||||||
|
status text NOT NULL DEFAULT 'pendiente'
|
||||||
|
CHECK (status IN ('pendiente','enviando','confirmado','fallido','indeterminado')),
|
||||||
|
attempts integer NOT NULL DEFAULT 0,
|
||||||
|
last_error text,
|
||||||
|
-- Clave de deduplicación propia y estable. NUNCA se deriva del contenido:
|
||||||
|
-- dos ediciones que dejan el mismo valor son dos intenciones distintas.
|
||||||
|
dedup_key text NOT NULL,
|
||||||
|
crm_id text, -- se llena tras RELEER, no tras el 200
|
||||||
|
evidence text, -- relectura | 400_meta | busqueda
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
sent_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX crm_outbox_pendientes ON crm_outbox (business_id, status, id)
|
||||||
|
WHERE status IN ('pendiente','indeterminado');
|
||||||
|
CREATE INDEX crm_outbox_entidad ON crm_outbox (entity, entity_id);
|
||||||
|
CREATE UNIQUE INDEX crm_outbox_dedup ON crm_outbox (dedup_key);
|
||||||
|
|
||||||
|
-- Historial de cada corrida del botón de sincronización.
|
||||||
|
CREATE TABLE crm_sync_runs (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
kind text NOT NULL, -- contacts | appointments
|
||||||
|
direction text NOT NULL, -- pull | push
|
||||||
|
started_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
finished_at timestamptz,
|
||||||
|
status text NOT NULL DEFAULT 'corriendo'
|
||||||
|
CHECK (status IN ('corriendo','ok','error')),
|
||||||
|
fetched integer NOT NULL DEFAULT 0,
|
||||||
|
created integer NOT NULL DEFAULT 0,
|
||||||
|
updated integer NOT NULL DEFAULT 0,
|
||||||
|
skipped integer NOT NULL DEFAULT 0,
|
||||||
|
error text,
|
||||||
|
started_by_user_id bigint REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX crm_sync_runs_business ON crm_sync_runs (business_id, started_at DESC);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Espejo de conversaciones y mensajes. El CRM es el dueño: aquí solo se
|
||||||
|
-- guardan metadatos y referencias, y nunca se editan — se reescriben desde el
|
||||||
|
-- CRM. La plataforma solo CREA mensajes salientes.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE conversations (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
crm_conversation_id text NOT NULL,
|
||||||
|
client_id bigint REFERENCES clients(id),
|
||||||
|
crm_contact_id text,
|
||||||
|
contact_name text,
|
||||||
|
last_message_type text,
|
||||||
|
last_message_body text,
|
||||||
|
last_message_at timestamptz,
|
||||||
|
unread_count integer NOT NULL DEFAULT 0,
|
||||||
|
synced_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (business_id, crm_conversation_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX conversations_reciente ON conversations (business_id, last_message_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE messages (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
conversation_id bigint NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||||
|
crm_message_id text,
|
||||||
|
direction text NOT NULL CHECK (direction IN ('inbound','outbound')),
|
||||||
|
channel text NOT NULL, -- Email | SMS | WhatsApp | FB | IG…
|
||||||
|
body text,
|
||||||
|
subject text,
|
||||||
|
status text, -- del CRM: queued|sent|delivered|failed
|
||||||
|
sent_by_user_id bigint REFERENCES users(id),
|
||||||
|
sent_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (business_id, crm_message_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX messages_conversacion ON messages (conversation_id, sent_at);
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- De un negocio con un token global, a N negocios con credencial propia.
|
||||||
|
--
|
||||||
|
-- Hasta aquí `crm_connections.location_id` ya era por negocio, pero el token
|
||||||
|
-- vivía en la variable de entorno CRM_TOKEN, una sola para todo el proceso
|
||||||
|
-- (platform/crm/client.ts). Con dos negocios eso usa el token del primero
|
||||||
|
-- contra la subcuenta del segundo: 401 en el mejor caso, escritura en la
|
||||||
|
-- subcuenta equivocada en el peor.
|
||||||
|
--
|
||||||
|
-- El token se guarda CIFRADO con AES-256-GCM (platform/lib/crypto.ts). La clave
|
||||||
|
-- maestra vive en CRM_MASTER_KEY, fuera de la base: quien consiga un volcado de
|
||||||
|
-- Postgres no consigue los tokens de los clientes.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ALTER TABLE crm_connections
|
||||||
|
ADD COLUMN token_cipher bytea,
|
||||||
|
ADD COLUMN token_nonce bytea,
|
||||||
|
ADD COLUMN token_tag bytea,
|
||||||
|
-- Los 6 últimos caracteres. Permite que la interfaz diga «termina en …f4a2c1»
|
||||||
|
-- y detectar una rotación, sin exponer nunca la credencial.
|
||||||
|
ADD COLUMN token_fingerprint text,
|
||||||
|
ADD COLUMN token_updated_at timestamptz,
|
||||||
|
-- MEDIDO (hallazgo 29): la subcuenta tiene 7 calendarios y la única cita real
|
||||||
|
-- está en «Servicio Spa». Sin fijar cuál, empujar una cita al calendario del
|
||||||
|
-- CRM sería adivinar a cuál.
|
||||||
|
ADD COLUMN calendar_id text,
|
||||||
|
-- La red de seguridad de mensajes pasa a ser POR NEGOCIO. Como variable de
|
||||||
|
-- entorno global decidía por todas las cuentas a la vez: o se abrían los
|
||||||
|
-- envíos reales para todas, o ninguna podía salir de pruebas.
|
||||||
|
ADD COLUMN test_email text,
|
||||||
|
ADD COLUMN allow_real_sends boolean NOT NULL DEFAULT false,
|
||||||
|
-- Nombre legible de la subcuenta, para que la administración no tenga que
|
||||||
|
-- reconocer cuentas por un identificador opaco.
|
||||||
|
ADD COLUMN label text;
|
||||||
|
|
||||||
|
-- La credencial va completa o no va. Media credencial produce un descifrado que
|
||||||
|
-- falla en tiempo de petición, y eso es un fallo lejos de su causa.
|
||||||
|
ALTER TABLE crm_connections
|
||||||
|
ADD CONSTRAINT crm_connections_credencial_completa CHECK (
|
||||||
|
(token_cipher IS NULL AND token_nonce IS NULL AND token_tag IS NULL)
|
||||||
|
OR
|
||||||
|
(token_cipher IS NOT NULL AND token_nonce IS NOT NULL AND token_tag IS NOT NULL)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN crm_connections.token_cipher IS
|
||||||
|
'Token privado de la subcuenta, cifrado con AES-256-GCM. Nunca se devuelve por la API.';
|
||||||
|
COMMENT ON COLUMN crm_connections.token_fingerprint IS
|
||||||
|
'Los 6 ultimos caracteres del token. Lo unico de la credencial que puede salir del servidor.';
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Sincronización por id de las cinco entidades.
|
||||||
|
--
|
||||||
|
-- Las tablas `conversations` y `messages` se declararon en 002_crm.sql y hasta
|
||||||
|
-- ahora NADIE escribía en ellas: la bandeja consultaba el CRM en vivo en cada
|
||||||
|
-- carga. Eso significa que sin red no hay bandeja, que cada visita gasta cuota,
|
||||||
|
-- y que no se puede cruzar un hilo con una clienta sin volver a salir a internet.
|
||||||
|
-- Aquí se añade lo que faltaba para llenarlas.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ALTER TABLE messages
|
||||||
|
-- De qué contacto del CRM es el mensaje, para cruzarlo con la clienta sin
|
||||||
|
-- pasar por la conversación.
|
||||||
|
ADD COLUMN crm_contact_id text,
|
||||||
|
-- El canal tal cual lo devolvió el CRM, además del normalizado. La API da el
|
||||||
|
-- tipo como número o como cadena según el endpoint, y guardar solo la versión
|
||||||
|
-- traducida perdería el dato original si mañana cambia la traducción.
|
||||||
|
ADD COLUMN channel_raw text;
|
||||||
|
|
||||||
|
CREATE INDEX messages_crm_contact ON messages (business_id, crm_contact_id)
|
||||||
|
WHERE crm_contact_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Cursor de la última sincronización de conversaciones, para continuar donde se
|
||||||
|
-- quedó en vez de releer las 3 213 cada vez.
|
||||||
|
ALTER TABLE crm_connections
|
||||||
|
ADD COLUMN conv_cursor_date bigint;
|
||||||
|
|
||||||
|
-- El servicio de la plataforma, una vez publicado en el catálogo del CRM.
|
||||||
|
-- MEDIDO (hallazgos 6 y 32): el catálogo del CRM está VACÍO, así que
|
||||||
|
-- «sincronizar servicios» solo puede significar empujar, nunca traer.
|
||||||
|
ALTER TABLE services
|
||||||
|
ADD COLUMN crm_service_id text,
|
||||||
|
ADD COLUMN crm_synced_at timestamptz;
|
||||||
|
|
||||||
|
CREATE INDEX services_crm ON services (crm_service_id) WHERE crm_service_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- La cita de la plataforma, una vez escrita como evento en el calendario del
|
||||||
|
-- CRM. Es distinto de `crm_opportunity_id`: la oportunidad es el embudo de
|
||||||
|
-- ventas y el evento es la agenda. Una cita puede tener las dos cosas.
|
||||||
|
ALTER TABLE appointments
|
||||||
|
ADD COLUMN crm_event_id text;
|
||||||
|
|
||||||
|
CREATE INDEX appointments_crm_event ON appointments (crm_event_id)
|
||||||
|
WHERE crm_event_id IS NOT NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN crm_sync_runs.kind IS
|
||||||
|
'contacts | appointments | conversations | one — "one" es la sincronizacion de una sola entidad por id';
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import pg from "pg";
|
||||||
|
|
||||||
|
const { Pool } = pg;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Postgres devuelve NUMERIC como string para no perder precisión, y bigint igual.
|
||||||
|
* El frontend declara `number` en shared/types.ts, así que se convierten aquí, en
|
||||||
|
* el único sitio que abre conexiones, y no en cada handler.
|
||||||
|
*/
|
||||||
|
pg.types.setTypeParser(1700, (v: string) => Number(v)); // numeric
|
||||||
|
pg.types.setTypeParser(20, (v: string) => Number(v)); // int8 / bigint
|
||||||
|
|
||||||
|
const connectionString =
|
||||||
|
process.env.DATABASE_URL || "postgres://yola:[email protected]:5434/yola";
|
||||||
|
|
||||||
|
export const pool = new Pool({ connectionString, max: 10 });
|
||||||
|
|
||||||
|
/** Ejecuta `fn` dentro de una transacción; hace ROLLBACK ante cualquier excepción. */
|
||||||
|
export async function withTx<T>(fn: (c: pg.PoolClient) => Promise<T>): Promise<T> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
const out = await fn(client);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return out;
|
||||||
|
} catch (e) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: yola-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: yola
|
||||||
|
POSTGRES_PASSWORD: yola_dev
|
||||||
|
POSTGRES_DB: yola
|
||||||
|
# 5434 y no 5432/5433: los dos están ocupados por contenedores de otros
|
||||||
|
# proyectos en esta máquina.
|
||||||
|
ports:
|
||||||
|
- "5434:5432"
|
||||||
|
volumes:
|
||||||
|
- yola_pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U yola -d yola"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
yola_pgdata:
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import express from "express";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { pool } from "./db/pool.ts";
|
||||||
|
import cors from "cors";
|
||||||
|
import { authRequired } from "./lib/auth.ts";
|
||||||
|
import { authRouter } from "./routes/auth.ts";
|
||||||
|
import { businessRouter } from "./routes/business.ts";
|
||||||
|
import { clientsRouter } from "./routes/clients.ts";
|
||||||
|
import { appointmentsRouter } from "./routes/appointments.ts";
|
||||||
|
import { attendanceRouter } from "./routes/attendance.ts";
|
||||||
|
import { dayCloseRouter } from "./routes/dayClose.ts";
|
||||||
|
import { adminRouter } from "./routes/admin.ts";
|
||||||
|
import { crmRouter } from "./routes/crm.ts";
|
||||||
|
import { messagesRouter } from "./routes/messages.ts";
|
||||||
|
import { arrancarWorker } from "./crm/worker.ts";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export function createApp() {
|
||||||
|
const app = express();
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json({ limit: "1mb" }));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Salud del servicio.
|
||||||
|
*
|
||||||
|
* Consulta la base a propósito. Un health check que solo responde `{ok:true}`
|
||||||
|
* sigue en verde con Postgres caído o el disco lleno — justo el escenario en
|
||||||
|
* que el orquestador debería reiniciar y no lo haría.
|
||||||
|
*/
|
||||||
|
app.get("/api/health", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
await pool.query("SELECT 1");
|
||||||
|
res.json({ ok: true, db: "ok", ts: Date.now() });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(503).json({ ok: false, db: "error", error: String(e?.message ?? e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use("/api/auth", authRouter);
|
||||||
|
app.use("/api/business", authRequired, businessRouter);
|
||||||
|
app.use("/api/clients", authRequired, clientsRouter);
|
||||||
|
// Va ANTES que el router de citas: si se monta después, el `/:id` de
|
||||||
|
// appointments se traga la ruta y `/attendance` nunca llega aquí.
|
||||||
|
app.use("/api/appointments/:id/attendance", authRequired, attendanceRouter);
|
||||||
|
app.use("/api/appointments", authRequired, appointmentsRouter);
|
||||||
|
app.use("/api/day-close", authRequired, dayCloseRouter);
|
||||||
|
app.use("/api/admin", authRequired, adminRouter);
|
||||||
|
app.use("/api/crm", authRequired, crmRouter);
|
||||||
|
app.use("/api/messages", authRequired, messagesRouter);
|
||||||
|
|
||||||
|
// En producción este proceso también sirve el frontend compilado. El fallback
|
||||||
|
// de SPA excluye `/api/` para que una ruta de API inexistente devuelva 404 y
|
||||||
|
// no el index.html, que el cliente no sabría interpretar.
|
||||||
|
const dist = path.resolve(__dirname, "..", "dist");
|
||||||
|
if (fs.existsSync(dist)) {
|
||||||
|
app.use(express.static(dist));
|
||||||
|
app.get(/^(?!\/api\/).*/, (_req, res) => res.sendFile(path.join(dist, "index.html")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traductor final de errores: sin esto, un rechazo dentro de un handler async
|
||||||
|
// devuelve el HTML de stack de Express y el cliente no puede leer el mensaje.
|
||||||
|
app.use(
|
||||||
|
(
|
||||||
|
e: any,
|
||||||
|
_req: express.Request,
|
||||||
|
res: express.Response,
|
||||||
|
_next: express.NextFunction
|
||||||
|
) => {
|
||||||
|
console.error("[platform]", e);
|
||||||
|
res.status(e?.status ?? 500).json({ error: e?.error ?? "Error interno del servidor" });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoked = process.argv[1]?.replace(/\\/g, "/") ?? "";
|
||||||
|
if (invoked.endsWith("platform/index.ts")) {
|
||||||
|
const port = Number(process.env.PLATFORM_PORT) || 3100;
|
||||||
|
// 0.0.0.0 explícito: dentro de un contenedor, escuchar solo en localhost deja
|
||||||
|
// el servicio inalcanzable desde la red del orquestador.
|
||||||
|
const host = process.env.HOST || "0.0.0.0";
|
||||||
|
const server = createApp().listen(port, host, () =>
|
||||||
|
console.log(`[platform] escuchando en ${host}:${port}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apagado ordenado: sin esto, cada redespliegue corta las peticiones en vuelo.
|
||||||
|
for (const senal of ["SIGTERM", "SIGINT"] as const) {
|
||||||
|
process.on(senal, () => {
|
||||||
|
console.log(`[platform] ${senal} recibida, cerrando…`);
|
||||||
|
server.close(() => {
|
||||||
|
pool.end().finally(() => process.exit(0));
|
||||||
|
});
|
||||||
|
// Si algo se atasca, no se cuelga para siempre.
|
||||||
|
setTimeout(() => process.exit(1), 10_000).unref();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Despacha la bandeja hacia el CRM. No se arranca en `createApp()` para que
|
||||||
|
// las pruebas no salgan a la red por su cuenta.
|
||||||
|
arrancarWorker(60_000);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { PoolClient } from "pg";
|
||||||
|
|
||||||
|
export interface AuditEntry {
|
||||||
|
businessId: number | null;
|
||||||
|
actorUserId: number | null;
|
||||||
|
entity: string;
|
||||||
|
entityId: number | null;
|
||||||
|
action: string;
|
||||||
|
before?: unknown;
|
||||||
|
after?: unknown;
|
||||||
|
ip?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escribe una fila de auditoría **con el cliente de la transacción en curso**.
|
||||||
|
* Recibe el `PoolClient` a propósito y no usa el pool por su cuenta: si el
|
||||||
|
* cambio se revierte, su rastro tiene que revertirse con él. Una auditoría que
|
||||||
|
* registra cambios que no ocurrieron es peor que no tener auditoría.
|
||||||
|
*/
|
||||||
|
export async function writeAudit(c: PoolClient, e: AuditEntry): Promise<void> {
|
||||||
|
await c.query(
|
||||||
|
`INSERT INTO audit_log
|
||||||
|
(business_id, actor_user_id, entity, entity_id, action, before, after, ip)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7::jsonb,$8)`,
|
||||||
|
[
|
||||||
|
e.businessId,
|
||||||
|
e.actorUserId,
|
||||||
|
e.entity,
|
||||||
|
e.entityId,
|
||||||
|
e.action,
|
||||||
|
e.before === undefined ? null : JSON.stringify(e.before),
|
||||||
|
e.after === undefined ? null : JSON.stringify(e.after),
|
||||||
|
e.ip ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { Request, Response, NextFunction } from "express";
|
||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
|
||||||
|
export interface PlatformUser {
|
||||||
|
id: number;
|
||||||
|
business_id: number | null;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: "admin" | "owner" | "employee";
|
||||||
|
employee_id: number | null;
|
||||||
|
avatar_color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthedRequest extends Request {
|
||||||
|
user?: PlatformUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DEUDA CONOCIDA: el token es el id del usuario en texto plano y la contraseña
|
||||||
|
* se compara sin hashear. Se porta tal cual desde el backend de demo para no
|
||||||
|
* romper `src/lib/api.ts`, el AuthProvider y los .mjs de prueba en el mismo
|
||||||
|
* cambio. Endurecerlo es un entregable propio: bcrypt/Argon2id + sesión real +
|
||||||
|
* los cinco sitios a la vez.
|
||||||
|
*/
|
||||||
|
export async function authRequired(
|
||||||
|
req: AuthedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
const header = req.header("authorization") || "";
|
||||||
|
const token = header.startsWith("Bearer ") ? header.slice(7) : req.header("x-user-id");
|
||||||
|
if (!token) {
|
||||||
|
err(res, 401, "No autorizado");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = Number(token);
|
||||||
|
if (!Number.isFinite(userId)) {
|
||||||
|
err(res, 401, "Token inválido");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await pool.query<PlatformUser>(
|
||||||
|
`SELECT id, business_id, email, name, role, employee_id, avatar_color
|
||||||
|
FROM users WHERE id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
if (!rows[0]) {
|
||||||
|
err(res, 401, "Usuario no encontrado");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = rows[0];
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ownerOnly(req: AuthedRequest, res: Response, next: NextFunction) {
|
||||||
|
if (req.user?.role !== "owner") {
|
||||||
|
err(res, 403, "Solo la administradora puede realizar esta acción");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Administración de la plataforma: opera todas las cuentas y su `business_id`
|
||||||
|
* es NULL.
|
||||||
|
*
|
||||||
|
* No se confunde con `ownerOnly`, que manda dentro de UN negocio. Son dos
|
||||||
|
* autoridades distintas: la dueña de un spa no debe poder dar de alta cuentas
|
||||||
|
* ajenas ni ver las credenciales de nadie.
|
||||||
|
*/
|
||||||
|
export function adminOnly(req: AuthedRequest, res: Response, next: NextFunction) {
|
||||||
|
if (req.user?.role !== "admin") {
|
||||||
|
err(res, 403, "Solo la administración de la plataforma puede realizar esta acción");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function err(res: Response, status: number, message: string) {
|
||||||
|
return res.status(status).json({ error: message });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Envuelve un handler async para que un rechazo no cuelgue la petición. */
|
||||||
|
export function h(fn: (req: AuthedRequest, res: Response) => Promise<unknown>) {
|
||||||
|
return (req: AuthedRequest, res: Response, next: NextFunction) => {
|
||||||
|
fn(req, res).catch(next);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { PoolClient } from "pg";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valores con los que nace un negocio.
|
||||||
|
*
|
||||||
|
* Existe por la misma razón que su gemelo del backend de demo: un negocio sin
|
||||||
|
* `working_hours` no tiene ninguna franja agendable en ninguna fecha, y uno sin
|
||||||
|
* `slug` no tiene página pública. Poner el default en el `INSERT` —y no en una
|
||||||
|
* migración de relleno— es lo único que cubre a las filas creadas después de que
|
||||||
|
* la migración ya corrió.
|
||||||
|
*
|
||||||
|
* NO se importa desde `server/`: los dos backends conviven sin compartir código,
|
||||||
|
* y cruzarlos ataría la evolución de uno a la del otro.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_WORKING_HOURS = JSON.stringify({
|
||||||
|
1: { start: "09:00", end: "20:00" },
|
||||||
|
2: { start: "09:00", end: "20:00" },
|
||||||
|
3: { start: "09:00", end: "20:00" },
|
||||||
|
4: { start: "09:00", end: "20:00" },
|
||||||
|
5: { start: "09:00", end: "20:00" },
|
||||||
|
6: null,
|
||||||
|
7: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** "Lumière Estética & Spa" → "lumiere-estetica-spa". Puro. */
|
||||||
|
export function slugify(s: string): string {
|
||||||
|
return (
|
||||||
|
(s || "negocio")
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[̀-ͯ]/g, "")
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 60) || "negocio"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slug único dentro de la plataforma, con sufijo numérico si ya está tomado.
|
||||||
|
*
|
||||||
|
* Recibe el cliente de la transacción, no el pool: comprobar la unicidad en una
|
||||||
|
* conexión y escribir en otra deja una ventana en la que dos altas simultáneas
|
||||||
|
* eligen el mismo slug. La restricción `UNIQUE` de la columna es la red final,
|
||||||
|
* pero conviene no depender de que salte.
|
||||||
|
*/
|
||||||
|
export async function uniqueSlugPg(tx: PoolClient, nombre: string): Promise<string> {
|
||||||
|
const base = slugify(nombre);
|
||||||
|
let slug = base;
|
||||||
|
for (let n = 2; ; n++) {
|
||||||
|
const { rows } = await tx.query(`SELECT 1 FROM businesses WHERE slug = $1`, [slug]);
|
||||||
|
if (!rows.length) return slug;
|
||||||
|
slug = `${base}-${n}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { cifrar, descifrar, huella } from "./crypto.ts";
|
||||||
|
|
||||||
|
const CLAVE = Buffer.alloc(32, 7).toString("base64");
|
||||||
|
|
||||||
|
test("cifrar/descifrar: ida y vuelta devuelve el original", () => {
|
||||||
|
process.env.CRM_MASTER_KEY = CLAVE;
|
||||||
|
const token = "pit-abc123def456";
|
||||||
|
assert.equal(descifrar(cifrar(token)), token);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cifrar: dos cifrados del mismo texto son distintos (nonce aleatorio)", () => {
|
||||||
|
process.env.CRM_MASTER_KEY = CLAVE;
|
||||||
|
const a = cifrar("mismo-token");
|
||||||
|
const b = cifrar("mismo-token");
|
||||||
|
assert.notEqual(a.cipher.toString("hex"), b.cipher.toString("hex"));
|
||||||
|
assert.equal(descifrar(a), descifrar(b));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("descifrar: un cipher manipulado lanza, no devuelve basura", () => {
|
||||||
|
process.env.CRM_MASTER_KEY = CLAVE;
|
||||||
|
const c = cifrar("token-real");
|
||||||
|
c.cipher[0] ^= 0xff;
|
||||||
|
assert.throws(() => descifrar(c), /no se pudo descifrar/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("descifrar: con otra clave maestra lanza, no devuelve basura", () => {
|
||||||
|
process.env.CRM_MASTER_KEY = CLAVE;
|
||||||
|
const c = cifrar("token-real");
|
||||||
|
process.env.CRM_MASTER_KEY = Buffer.alloc(32, 9).toString("base64");
|
||||||
|
assert.throws(() => descifrar(c), /no se pudo descifrar/i);
|
||||||
|
process.env.CRM_MASTER_KEY = CLAVE;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("huella: son los 6 últimos caracteres, para distinguir tokens sin exponerlos", () => {
|
||||||
|
assert.equal(huella("pit-abcdef123456"), "123456");
|
||||||
|
assert.equal(huella("corto"), "corto");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("una clave que no mide 32 bytes se rechaza con un mensaje que lo dice", () => {
|
||||||
|
process.env.CRM_MASTER_KEY = Buffer.alloc(16, 1).toString("base64");
|
||||||
|
assert.throws(() => cifrar("x"), /32 bytes/);
|
||||||
|
process.env.CRM_MASTER_KEY = CLAVE;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sin CRM_MASTER_KEY se lanza un error que dice qué falta y dónde ponerlo", () => {
|
||||||
|
delete process.env.CRM_MASTER_KEY;
|
||||||
|
assert.throws(() => cifrar("x"), /CRM_MASTER_KEY/);
|
||||||
|
process.env.CRM_MASTER_KEY = CLAVE;
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import { loadEnv } from "./env.ts";
|
||||||
|
|
||||||
|
export interface Cifrado {
|
||||||
|
cipher: Buffer;
|
||||||
|
nonce: Buffer;
|
||||||
|
tag: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cifrado de los tokens de subcuenta que se guardan en Postgres.
|
||||||
|
*
|
||||||
|
* AES-256-GCM, es decir cifrado **autenticado**, y eso es la decisión que
|
||||||
|
* importa: si alguien manipula la fila en la base, `descifrar` lanza en vez de
|
||||||
|
* devolver basura. Con un cifrado sin autenticar, una fila corrupta se
|
||||||
|
* convertiría en una petición al CRM con una credencial mal formada, y el fallo
|
||||||
|
* aparecería lejos de su causa.
|
||||||
|
*
|
||||||
|
* La clave maestra vive en el entorno, nunca en la base: quien consiga un
|
||||||
|
* volcado de Postgres no consigue los tokens de los clientes.
|
||||||
|
*/
|
||||||
|
function clave(): Buffer {
|
||||||
|
loadEnv();
|
||||||
|
const b64 = process.env.CRM_MASTER_KEY;
|
||||||
|
if (!b64) {
|
||||||
|
throw new Error(
|
||||||
|
'Falta CRM_MASTER_KEY. Genera una con: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'base64\'))" y ponla en platform/.env'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const k = Buffer.from(b64, "base64");
|
||||||
|
if (k.length !== 32) {
|
||||||
|
throw new Error(
|
||||||
|
`CRM_MASTER_KEY debe ser de 32 bytes en base64; llegaron ${k.length}. Genera una nueva con randomBytes(32).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return k;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cifrar(claro: string): Cifrado {
|
||||||
|
const nonce = crypto.randomBytes(12);
|
||||||
|
const c = crypto.createCipheriv("aes-256-gcm", clave(), nonce);
|
||||||
|
const cipher = Buffer.concat([c.update(claro, "utf8"), c.final()]);
|
||||||
|
return { cipher, nonce, tag: c.getAuthTag() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function descifrar(c: Cifrado): string {
|
||||||
|
// La clave se pide FUERA del try: si falta o mide mal, ese error debe salir
|
||||||
|
// tal cual, no disfrazado de «fila corrupta». Son dos causas distintas y
|
||||||
|
// llevan a dos arreglos distintos.
|
||||||
|
const k = clave();
|
||||||
|
try {
|
||||||
|
const d = crypto.createDecipheriv("aes-256-gcm", k, c.nonce);
|
||||||
|
d.setAuthTag(c.tag);
|
||||||
|
return Buffer.concat([d.update(c.cipher), d.final()]).toString("utf8");
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
"El token guardado no se pudo descifrar: la clave maestra cambió o la fila está corrupta. Hay que volver a vincular la subcuenta."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Los 6 últimos caracteres del token. Sirve para que la interfaz pueda decir
|
||||||
|
* «termina en …f4a2c1» y para detectar una rotación, sin exponer nunca la
|
||||||
|
* credencial completa ni en la API, ni en los registros, ni en la auditoría.
|
||||||
|
*/
|
||||||
|
export function huella(token: string): string {
|
||||||
|
return token.length <= 6 ? token : token.slice(-6);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const ENV_PATH = path.resolve(__dirname, "..", ".env");
|
||||||
|
|
||||||
|
let cargado = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lee `platform/.env` y lo vuelca en `process.env` sin pisar lo que ya viniera
|
||||||
|
* del entorno — un valor exportado en la terminal gana al archivo, que es lo
|
||||||
|
* que se espera al apuntar a otra subcuenta sin editar nada.
|
||||||
|
*
|
||||||
|
* Sin dependencia externa a propósito: son quince líneas y el archivo lleva
|
||||||
|
* el token del CRM, así que conviene que se vea exactamente qué lo lee.
|
||||||
|
*/
|
||||||
|
export function loadEnv(): void {
|
||||||
|
if (cargado) return;
|
||||||
|
cargado = true;
|
||||||
|
if (!fs.existsSync(ENV_PATH)) return;
|
||||||
|
|
||||||
|
for (const raw of fs.readFileSync(ENV_PATH, "utf8").split(/\r?\n/)) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line || line.startsWith("#")) continue;
|
||||||
|
const eq = line.indexOf("=");
|
||||||
|
if (eq < 1) continue;
|
||||||
|
const key = line.slice(0, eq).trim();
|
||||||
|
let value = line.slice(eq + 1).trim();
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
if (process.env[key] === undefined) process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lee una variable obligatoria, con un mensaje que dice qué falta y dónde ponerlo. */
|
||||||
|
export function requireEnv(key: string): string {
|
||||||
|
loadEnv();
|
||||||
|
const v = process.env[key];
|
||||||
|
if (!v) {
|
||||||
|
throw new Error(
|
||||||
|
`Falta ${key}. Defínelo en platform/.env (ver platform/.env.example) o expórtalo en el entorno.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { normalizePhone } from "./phone.ts";
|
||||||
|
|
||||||
|
test("normaliza las formas mexicanas de diez dígitos", () => {
|
||||||
|
assert.equal(normalizePhone("5588887777"), "+525588887777");
|
||||||
|
assert.equal(normalizePhone("55 8888 7777"), "+525588887777");
|
||||||
|
assert.equal(normalizePhone("(55) 8888-7777"), "+525588887777");
|
||||||
|
assert.equal(normalizePhone("55.8888.7777"), "+525588887777");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("acepta el prefijo de larga distancia 01", () => {
|
||||||
|
assert.equal(normalizePhone("01 55 8888 7777"), "+525588887777");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("acepta el 52 con y sin más", () => {
|
||||||
|
assert.equal(normalizePhone("+52 55 8888 7777"), "+525588887777");
|
||||||
|
assert.equal(normalizePhone("525588887777"), "+525588887777");
|
||||||
|
assert.equal(normalizePhone("0052 55 8888 7777"), "+525588887777");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("colapsa el 521 heredado de WhatsApp al formato actual", () => {
|
||||||
|
// El 1 después del 52 era el marcador de móvil; desde 2019 ya no se disca,
|
||||||
|
// pero sigue apareciendo en los identificadores de mensajería.
|
||||||
|
assert.equal(normalizePhone("5215588887777"), "+525588887777");
|
||||||
|
assert.equal(normalizePhone("+52 1 55 8888 7777"), "+525588887777");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("respeta un internacional que no es México", () => {
|
||||||
|
assert.equal(normalizePhone("+1 305 555 0134"), "+13055550134");
|
||||||
|
assert.equal(normalizePhone("+34 600 123 456"), "+34600123456");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("devuelve null cuando no se puede normalizar", () => {
|
||||||
|
assert.equal(normalizePhone(null), null);
|
||||||
|
assert.equal(normalizePhone(""), null);
|
||||||
|
assert.equal(normalizePhone(" "), null);
|
||||||
|
assert.equal(normalizePhone("no tengo"), null);
|
||||||
|
assert.equal(normalizePhone("123"), null, "demasiado corto");
|
||||||
|
assert.equal(normalizePhone("12345678901234567"), null, "demasiado largo");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("es idempotente sobre su propia salida", () => {
|
||||||
|
const once = normalizePhone("55 8888 7777")!;
|
||||||
|
assert.equal(normalizePhone(once), once);
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Normaliza un teléfono a E.164 (`+` seguido de 8 a 15 dígitos).
|
||||||
|
*
|
||||||
|
* Es la clave de identidad de la clienta: sin ella, el mismo número tecleado de
|
||||||
|
* dos formas produce dos fichas, y la auditoría del spa midió que el teléfono es
|
||||||
|
* el único campo con cobertura suficiente para reconciliar canales.
|
||||||
|
*
|
||||||
|
* Devuelve `null` cuando no se puede normalizar con certeza. `null` no es un
|
||||||
|
* error: significa "clienta no contactable", que es un estado legítimo y medido
|
||||||
|
* (40.8 % del histórico). Nunca se inventa un país para rellenarlo.
|
||||||
|
*/
|
||||||
|
export function normalizePhone(
|
||||||
|
raw: string | null | undefined,
|
||||||
|
defaultCountry = "52"
|
||||||
|
): string | null {
|
||||||
|
if (raw == null) return null;
|
||||||
|
const trimmed = String(raw).trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
// Una letra en el campo significa texto libre ("no tengo", "el de su mamá"),
|
||||||
|
// no un teléfono mal escrito. No se intenta rescatar.
|
||||||
|
if (/[a-zA-Z]/.test(trimmed)) return null;
|
||||||
|
|
||||||
|
const explicitIntl = trimmed.startsWith("+") || /^00\d/.test(trimmed);
|
||||||
|
let digits = trimmed.replace(/\D/g, "");
|
||||||
|
if (trimmed.startsWith("00")) digits = digits.slice(2);
|
||||||
|
|
||||||
|
if (!digits) return null;
|
||||||
|
|
||||||
|
if (!explicitIntl) {
|
||||||
|
// "01" es el prefijo mexicano de larga distancia y se quita como unidad, no
|
||||||
|
// como "ceros a la izquierda": si solo se quitara el 0, el 1 restante se
|
||||||
|
// confundiría con el código de país de Estados Unidos.
|
||||||
|
if (digits.length === 12 && digits.startsWith("01")) {
|
||||||
|
digits = digits.slice(2);
|
||||||
|
} else {
|
||||||
|
digits = digits.replace(/^0+/, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "52 1 XXXXXXXXXX": el 1 de móvil que WhatsApp sigue arrastrando.
|
||||||
|
if (digits.length === 13 && digits.startsWith(`${defaultCountry}1`)) {
|
||||||
|
digits = defaultCountry + digits.slice(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diez dígitos sueltos = número nacional.
|
||||||
|
if (!explicitIntl && digits.length === 10) {
|
||||||
|
digits = defaultCountry + digits;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (digits.length < 8 || digits.length > 15) return null;
|
||||||
|
return `+${digits}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// platform/lib/time.ts
|
||||||
|
//
|
||||||
|
// Helpers puros de zona horaria del negocio.
|
||||||
|
//
|
||||||
|
// Son una COPIA de `server/lib/time.ts`, y es deliberado: los dos backends
|
||||||
|
// conviven sin compartir código —misma decisión que `businessDefaults.ts`— y
|
||||||
|
// cruzarlos ataría la evolución de uno a la del otro. El coste es duplicar unas
|
||||||
|
// líneas de funciones puras; el beneficio es que `platform/` se pueda empaquetar
|
||||||
|
// solo, sin arrastrar el backend de demo dentro de su imagen.
|
||||||
|
//
|
||||||
|
// Se descubrió al contenerizar: `dayClose.ts` importaba de `../../server/`, la
|
||||||
|
// imagen no copiaba `server/`, y el proceso moría al arrancar con
|
||||||
|
// ERR_MODULE_NOT_FOUND. Un typecheck limpio no lo detecta.
|
||||||
|
/** Returns the calendar date (YYYY-MM-DD) of the given instant in the given IANA tz. */
|
||||||
|
export function bizDateISO(instant: Date, tz: string): string {
|
||||||
|
return new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: tz || "UTC",
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
}).format(instant); // en-CA yields "YYYY-MM-DD"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Business "today" (YYYY-MM-DD) for a tz, at the given instant (default: server now). */
|
||||||
|
export function bizTodayISO(tz: string, now: Date = new Date()): string {
|
||||||
|
return bizDateISO(now, tz);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** tz offset (in minutes) of the given instant, east of UTC positive. */
|
||||||
|
function tzOffsetMinutes(instant: Date, tz: string): number {
|
||||||
|
const parts = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone: tz || "UTC",
|
||||||
|
timeZoneName: "longOffset",
|
||||||
|
}).formatToParts(instant);
|
||||||
|
const off = parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT+00:00";
|
||||||
|
// e.g. "GMT-06:00", "GMT+05:30", "GMT+00:00", or bare "GMT" for UTC
|
||||||
|
const m = off.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/);
|
||||||
|
if (!m) return 0; // bare "GMT" → UTC
|
||||||
|
const sign = m[1] === "-" ? -1 : 1;
|
||||||
|
const h = parseInt(m[2], 10);
|
||||||
|
const min = m[3] ? parseInt(m[3], 10) : 0;
|
||||||
|
return sign * (h * 60 + min);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a business-local wall-clock (year, month, day, hh, mm, ss) in `tz` to a UTC Date.
|
||||||
|
* We first treat the wall-clock as if it were UTC, read the tz offset at that instant,
|
||||||
|
* then subtract the offset: a west tz (negative offset) is "behind" UTC, so the same
|
||||||
|
* wall-clock happens later in UTC → UTC = wall − offset.
|
||||||
|
*/
|
||||||
|
export function wallToUtcDate(
|
||||||
|
tz: string,
|
||||||
|
y: number,
|
||||||
|
mo: number,
|
||||||
|
d: number,
|
||||||
|
hh: number,
|
||||||
|
mm: number,
|
||||||
|
ss: number
|
||||||
|
): Date {
|
||||||
|
const guess = new Date(Date.UTC(y, mo - 1, d, hh, mm, ss));
|
||||||
|
const offsetMin = tzOffsetMinutes(guess, tz);
|
||||||
|
return new Date(guess.getTime() - offsetMin * 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Business-day UTC bounds (start = local 00:00:00, end = local 23:59:59) for today +/- offset. */
|
||||||
|
export function bizDayBounds(
|
||||||
|
tz: string,
|
||||||
|
dayOffset = 0,
|
||||||
|
now: Date = new Date()
|
||||||
|
): { start: Date; end: Date } {
|
||||||
|
const today = bizDateISO(now, tz);
|
||||||
|
const [y, m, d] = today.split("-").map(Number);
|
||||||
|
const base = new Date(Date.UTC(y, m - 1, d));
|
||||||
|
base.setUTCDate(base.getUTCDate() + dayOffset);
|
||||||
|
const ty = base.getUTCFullYear();
|
||||||
|
const tm = base.getUTCMonth() + 1;
|
||||||
|
const td = base.getUTCDate();
|
||||||
|
return {
|
||||||
|
start: wallToUtcDate(tz, ty, tm, td, 0, 0, 0),
|
||||||
|
end: wallToUtcDate(tz, ty, tm, td, 23, 59, 59),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format a UTC instant as SQLite canonical "YYYY-MM-DD HH:MM:SS" (matches datetime() output). */
|
||||||
|
export function toSqliteUtc(d: Date): string {
|
||||||
|
return d.toISOString().slice(0, 19).replace("T", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format a UTC instant as ISO "YYYY-MM-DDTHH:MM:SSZ" (matches the stored start_at format). */
|
||||||
|
export function toIsoUtc(d: Date): string {
|
||||||
|
return d.toISOString().slice(0, 19) + "Z";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Business-day bounds in SQLite canonical format — pair with `datetime(column) >= ?`. */
|
||||||
|
export function bizDayBoundsSqlite(
|
||||||
|
tz: string,
|
||||||
|
dayOffset = 0,
|
||||||
|
now: Date = new Date()
|
||||||
|
): { start: string; end: string } {
|
||||||
|
const b = bizDayBounds(tz, dayOffset, now);
|
||||||
|
return { start: toSqliteUtc(b.start), end: toSqliteUtc(b.end) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Business-day bounds in ISO-Z format — pair with raw `start_at >= ?`. */
|
||||||
|
export function bizDayBoundsIso(
|
||||||
|
tz: string,
|
||||||
|
dayOffset = 0,
|
||||||
|
now: Date = new Date()
|
||||||
|
): { start: string; end: string } {
|
||||||
|
const b = bizDayBounds(tz, dayOffset, now);
|
||||||
|
return { start: toIsoUtc(b.start), end: toIsoUtc(b.end) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience: convert a business-local wall-clock to a UTC ISO string. */
|
||||||
|
export function wallToUtcISO(
|
||||||
|
tz: string,
|
||||||
|
y: number,
|
||||||
|
mo: number,
|
||||||
|
d: number,
|
||||||
|
hh: number,
|
||||||
|
mm: number,
|
||||||
|
ss: number
|
||||||
|
): string {
|
||||||
|
return wallToUtcDate(tz, y, mo, d, hh, mm, ss).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zona horaria por defecto de los negocios (México). Única definición. */
|
||||||
|
export const DEFAULT_TZ = "America/Mexico_City";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Día ISO de la semana (1=Lun … 7=Dom) de una fecha natural "YYYY-MM-DD".
|
||||||
|
* Deriva del string, nunca de un `Date` local, así que es independiente de
|
||||||
|
* la tz del proceso (en un contenedor UTC `new Date("…").getDay()` puede
|
||||||
|
* caer en el día anterior).
|
||||||
|
*/
|
||||||
|
export function isoDowFromDateStr(dateIso: string): number {
|
||||||
|
const [y, m, d] = dateIso.split("-").map(Number);
|
||||||
|
const j = new Date(Date.UTC(y, m - 1, d)).getUTCDay(); // 0=Dom..6=Sáb
|
||||||
|
return j === 0 ? 7 : j;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Límites UTC del día natural `dateIso` **en la tz del negocio**, en formato
|
||||||
|
* ISO-Z. Emparéjalo con `start_at >= ? AND start_at <= ?` (comparación
|
||||||
|
* lexicográfica sobre el formato canónico almacenado).
|
||||||
|
*
|
||||||
|
* A diferencia de `bizDayBoundsIso`, que trabaja con desplazamientos respecto
|
||||||
|
* de "hoy", este acepta la fecha explícita que pide el cliente.
|
||||||
|
*/
|
||||||
|
export function bizDayBoundsIsoFor(tz: string, dateIso: string): { start: string; end: string } {
|
||||||
|
const [y, m, d] = dateIso.split("-").map(Number);
|
||||||
|
return {
|
||||||
|
start: toIsoUtc(wallToUtcDate(tz || DEFAULT_TZ, y, m, d, 0, 0, 0)),
|
||||||
|
end: toIsoUtc(wallToUtcDate(tz || DEFAULT_TZ, y, m, d, 23, 59, 59)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { pool, withTx } from "../db/pool.ts";
|
||||||
|
import { adminOnly, err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||||
|
import { writeAudit } from "../lib/audit.ts";
|
||||||
|
import { guardarCredencial, olvidarCredencial } from "../crm/ctx.ts";
|
||||||
|
import { crmRequest, CrmError } from "../crm/client.ts";
|
||||||
|
import { DEFAULT_WORKING_HOURS, uniqueSlugPg } from "../lib/businessDefaults.ts";
|
||||||
|
|
||||||
|
export const adminRouter = Router();
|
||||||
|
|
||||||
|
// Todo el router exige rol de plataforma. Se aplica una vez aquí y no ruta por
|
||||||
|
// ruta: olvidarlo en una sola ruta abriría el alta de cuentas a cualquier dueña.
|
||||||
|
adminRouter.use(adminOnly);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Las cuentas de la plataforma, con el estado de su vínculo con Bucéfalo CRM.
|
||||||
|
*
|
||||||
|
* `token_cipher` NO se selecciona siquiera: lo único de la credencial que sale
|
||||||
|
* del servidor es la huella de 6 caracteres.
|
||||||
|
*/
|
||||||
|
adminRouter.get(
|
||||||
|
"/businesses",
|
||||||
|
h(async (_req: AuthedRequest, res) => {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT b.id, b.name, b.slug, b.timezone, b.status, b.created_at,
|
||||||
|
c.location_id, c.label AS crm_label, c.token_fingerprint,
|
||||||
|
c.token_updated_at, c.pipeline_id, c.calendar_id,
|
||||||
|
c.allow_real_sends, c.test_email,
|
||||||
|
c.last_sync_at, c.last_sync_status,
|
||||||
|
(SELECT count(*) FROM clients cl
|
||||||
|
WHERE cl.business_id = b.id AND cl.deleted_at IS NULL)::int AS clientes,
|
||||||
|
(SELECT count(*) FROM users u WHERE u.business_id = b.id)::int AS usuarios
|
||||||
|
FROM businesses b
|
||||||
|
LEFT JOIN crm_connections c ON c.business_id = b.id
|
||||||
|
ORDER BY b.created_at DESC`
|
||||||
|
);
|
||||||
|
res.json({ businesses: rows });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Alta de cuenta: el negocio y su dueña, en la misma transacción. */
|
||||||
|
adminRouter.post(
|
||||||
|
"/businesses",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const { name, timezone, owner_email, owner_name, owner_password, industry } = req.body ?? {};
|
||||||
|
if (!name || !owner_email || !owner_name || !owner_password) {
|
||||||
|
err(res, 400, "Faltan el nombre del negocio y los datos de la dueña");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = String(owner_email).trim().toLowerCase();
|
||||||
|
const { rows: ya } = await pool.query(`SELECT 1 FROM users WHERE email = $1`, [email]);
|
||||||
|
if (ya.length) {
|
||||||
|
err(res, 409, `Ya existe una persona con el correo ${email}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const creado = await withTx(async (tx) => {
|
||||||
|
const slug = await uniqueSlugPg(tx, String(name));
|
||||||
|
const { rows: bs } = await tx.query(
|
||||||
|
`INSERT INTO businesses (name, industry, timezone, slug, working_hours)
|
||||||
|
VALUES ($1, $2, $3, $4, $5::jsonb)
|
||||||
|
RETURNING id, name, slug, timezone, status, created_at`,
|
||||||
|
[
|
||||||
|
String(name).trim(),
|
||||||
|
industry || "Estética y Spa",
|
||||||
|
timezone || "America/Mexico_City",
|
||||||
|
slug,
|
||||||
|
DEFAULT_WORKING_HOURS,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const business = bs[0];
|
||||||
|
|
||||||
|
const { rows: us } = await tx.query(
|
||||||
|
`INSERT INTO users (business_id, email, password, name, role)
|
||||||
|
VALUES ($1, $2, $3, $4, 'owner')
|
||||||
|
RETURNING id, email, name, role`,
|
||||||
|
[business.id, email, owner_password, String(owner_name).trim()]
|
||||||
|
);
|
||||||
|
|
||||||
|
await writeAudit(tx, {
|
||||||
|
businessId: business.id,
|
||||||
|
actorUserId: req.user!.id,
|
||||||
|
entity: "businesses",
|
||||||
|
entityId: business.id,
|
||||||
|
action: "create",
|
||||||
|
after: { name: business.name, slug: business.slug, owner_email: email },
|
||||||
|
ip: req.ip ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { business, owner: us[0] };
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(creado);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.code === "23505") {
|
||||||
|
err(res, 409, "Ya existe una cuenta con ese nombre o ese correo");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vincula la cuenta con su subcuenta de Bucéfalo CRM.
|
||||||
|
*
|
||||||
|
* Las credenciales se COMPRUEBAN antes de guardarlas. Un token que no se valida
|
||||||
|
* traslada el fallo al primer intento de sincronizar, lejos de donde se cometió,
|
||||||
|
* y con un mensaje que no dice cuál de las dos cosas está mal. La prueba correcta
|
||||||
|
* es leer la propia subcuenta con ese token y comparar identidad contra identidad,
|
||||||
|
* no dar por bueno un 200 genérico.
|
||||||
|
*/
|
||||||
|
adminRouter.put(
|
||||||
|
"/businesses/:id/crm",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const businessId = Number(req.params.id);
|
||||||
|
const { location_id, token, label } = req.body ?? {};
|
||||||
|
if (!Number.isFinite(businessId)) {
|
||||||
|
err(res, 400, "Identificador de cuenta inválido");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!location_id || !token) {
|
||||||
|
err(res, 400, "Hacen falta el identificador de la subcuenta y el token privado");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await pool.query(`SELECT id, name FROM businesses WHERE id = $1`, [
|
||||||
|
businessId,
|
||||||
|
]);
|
||||||
|
if (!rows[0]) {
|
||||||
|
err(res, 404, "La cuenta no existe");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nombreSubcuenta: string | null = null;
|
||||||
|
try {
|
||||||
|
const loc = await crmRequest<any>("GET", `/locations/${location_id}`, {
|
||||||
|
token: String(token),
|
||||||
|
});
|
||||||
|
const devuelto = loc?.location?.id;
|
||||||
|
if (devuelto && devuelto !== location_id) {
|
||||||
|
err(
|
||||||
|
res,
|
||||||
|
400,
|
||||||
|
"El token pertenece a otra subcuenta distinta de la que indicaste"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nombreSubcuenta = loc?.location?.name ?? null;
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e instanceof CrmError && e.status === 401) {
|
||||||
|
// MEDIDO: en esta API el 401 es ambiguo — token caducado, sin permiso, o
|
||||||
|
// de otra subcuenta. El mensaje lo dice en vez de afirmar una sola causa.
|
||||||
|
err(
|
||||||
|
res,
|
||||||
|
400,
|
||||||
|
"Bucéfalo CRM rechazó el token: puede estar caducado, no tener permiso de lectura de la subcuenta, o pertenecer a otra"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e instanceof CrmError && e.status === 404) {
|
||||||
|
err(res, 400, "Ese identificador de subcuenta no existe, o el token no da acceso a ella");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
err(res, 502, `Bucéfalo CRM no respondió: ${e?.message ?? e}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await guardarCredencial(
|
||||||
|
businessId,
|
||||||
|
String(location_id),
|
||||||
|
String(token),
|
||||||
|
label || nombreSubcuenta || undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
await withTx((tx) =>
|
||||||
|
writeAudit(tx, {
|
||||||
|
businessId,
|
||||||
|
actorUserId: req.user!.id,
|
||||||
|
entity: "crm_connections",
|
||||||
|
entityId: businessId,
|
||||||
|
action: "link",
|
||||||
|
// El token NO se audita, ni cifrado: el registro de auditoría se lee, se
|
||||||
|
// exporta y se copia, y una credencial ahí dentro acaba donde no debe.
|
||||||
|
after: { location_id, label: label || nombreSubcuenta },
|
||||||
|
ip: req.ip ?? null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ ok: true, location_id, label: label || nombreSubcuenta });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Desvincula. Borra la credencial y conserva todo lo ya sincronizado. */
|
||||||
|
adminRouter.delete(
|
||||||
|
"/businesses/:id/crm",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const businessId = Number(req.params.id);
|
||||||
|
if (!Number.isFinite(businessId)) {
|
||||||
|
err(res, 400, "Identificador de cuenta inválido");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await olvidarCredencial(businessId);
|
||||||
|
await withTx((tx) =>
|
||||||
|
writeAudit(tx, {
|
||||||
|
businessId,
|
||||||
|
actorUserId: req.user!.id,
|
||||||
|
entity: "crm_connections",
|
||||||
|
entityId: businessId,
|
||||||
|
action: "unlink",
|
||||||
|
ip: req.ip ?? null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
res.json({ ok: true });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Ajustes de la cuenta que pertenecen a la plataforma, no al negocio. */
|
||||||
|
adminRouter.patch(
|
||||||
|
"/businesses/:id",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const businessId = Number(req.params.id);
|
||||||
|
const { status, name, timezone } = req.body ?? {};
|
||||||
|
if (status && !["active", "suspended"].includes(status)) {
|
||||||
|
err(res, 400, "El estado solo puede ser «active» o «suspended»");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`UPDATE businesses
|
||||||
|
SET status = COALESCE($2, status),
|
||||||
|
name = COALESCE($3, name),
|
||||||
|
timezone = COALESCE($4, timezone)
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, name, slug, timezone, status`,
|
||||||
|
[businessId, status ?? null, name ?? null, timezone ?? null]
|
||||||
|
);
|
||||||
|
if (!rows[0]) {
|
||||||
|
err(res, 404, "La cuenta no existe");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ business: rows[0] });
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { pool, withTx } from "../db/pool.ts";
|
||||||
|
import { writeAudit } from "../lib/audit.ts";
|
||||||
|
import { err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||||
|
import { encolar } from "../crm/outbox.ts";
|
||||||
|
|
||||||
|
export const appointmentsRouter = Router();
|
||||||
|
|
||||||
|
// `start_at` y `end_at` se serializan a ISO-Z sin milisegundos, que es el
|
||||||
|
// formato que el frontend ya parsea. `to_char` sobre el valor convertido a UTC
|
||||||
|
// evita depender de la zona del proceso de Node.
|
||||||
|
const COLS = `id, business_id, client_id, employee_id, service_id,
|
||||||
|
to_char(start_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS start_at,
|
||||||
|
to_char(end_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS end_at,
|
||||||
|
status, cancelled_by, cancel_reason, price, notes, source_channel, created_at`;
|
||||||
|
|
||||||
|
/** Traduce la violación de exclusión de Postgres a un 409 en español. */
|
||||||
|
function isOverlap(e: any) {
|
||||||
|
return e?.code === "23P01" && String(e?.constraint) === "appointments_no_overlap";
|
||||||
|
}
|
||||||
|
|
||||||
|
const OCUPADO = "Ese horario ya está ocupado para esta especialista";
|
||||||
|
|
||||||
|
appointmentsRouter.get(
|
||||||
|
"/",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const { from, to, employee_id, client_id, status, limit } = req.query as Record<
|
||||||
|
string,
|
||||||
|
string | undefined
|
||||||
|
>;
|
||||||
|
const params: unknown[] = [req.user!.business_id];
|
||||||
|
let sql = `SELECT ${COLS} FROM appointments WHERE business_id = $1`;
|
||||||
|
if (from) {
|
||||||
|
params.push(from);
|
||||||
|
sql += ` AND start_at >= $${params.length}::timestamptz`;
|
||||||
|
}
|
||||||
|
if (to) {
|
||||||
|
params.push(to);
|
||||||
|
sql += ` AND start_at < $${params.length}::timestamptz`;
|
||||||
|
}
|
||||||
|
if (employee_id) {
|
||||||
|
params.push(Number(employee_id));
|
||||||
|
sql += ` AND employee_id = $${params.length}`;
|
||||||
|
}
|
||||||
|
// Sin este filtro, la ficha de una clienta enseñaba las citas de todas: el
|
||||||
|
// cliente lo mandaba y el servidor lo ignoraba en silencio.
|
||||||
|
if (client_id) {
|
||||||
|
params.push(Number(client_id));
|
||||||
|
sql += ` AND client_id = $${params.length}`;
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
params.push(status);
|
||||||
|
sql += ` AND status = $${params.length}`;
|
||||||
|
}
|
||||||
|
params.push(Math.min(Number(limit) || 500, 1000));
|
||||||
|
sql += ` ORDER BY start_at DESC LIMIT $${params.length}`;
|
||||||
|
|
||||||
|
const { rows } = await pool.query(sql, params);
|
||||||
|
res.json({ appointments: rows });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
appointmentsRouter.post(
|
||||||
|
"/",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const { client_id, employee_id, service_id, start_at, notes, source_channel } =
|
||||||
|
req.body ?? {};
|
||||||
|
if (!client_id || !employee_id || !service_id || !start_at) {
|
||||||
|
err(res, 400, "Faltan datos de la cita");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bid = req.user!.business_id;
|
||||||
|
|
||||||
|
const svc = await pool.query(
|
||||||
|
`SELECT duration_min, price FROM services
|
||||||
|
WHERE id = $1 AND business_id = $2 AND active`,
|
||||||
|
[service_id, bid]
|
||||||
|
);
|
||||||
|
if (!svc.rows[0]) {
|
||||||
|
err(res, 404, "Servicio no encontrado");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emp = await pool.query(
|
||||||
|
`SELECT 1 FROM employees WHERE id = $1 AND business_id = $2 AND active`,
|
||||||
|
[employee_id, bid]
|
||||||
|
);
|
||||||
|
if (!emp.rows[0]) {
|
||||||
|
err(res, 404, "Especialista no encontrada");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cli = await pool.query(
|
||||||
|
`SELECT 1 FROM clients WHERE id = $1 AND business_id = $2 AND deleted_at IS NULL`,
|
||||||
|
[client_id, bid]
|
||||||
|
);
|
||||||
|
if (!cli.rows[0]) {
|
||||||
|
err(res, 404, "Clienta no encontrada");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const appointment = await withTx(async (c) => {
|
||||||
|
const { rows } = await c.query(
|
||||||
|
`INSERT INTO appointments
|
||||||
|
(business_id, client_id, employee_id, service_id, start_at, end_at,
|
||||||
|
price, notes, source_channel, created_by_user_id)
|
||||||
|
VALUES ($1,$2,$3,$4,$5::timestamptz,
|
||||||
|
$5::timestamptz + make_interval(mins => $6::int),
|
||||||
|
$7,$8,$9,$10)
|
||||||
|
RETURNING ${COLS}`,
|
||||||
|
[
|
||||||
|
bid,
|
||||||
|
client_id,
|
||||||
|
employee_id,
|
||||||
|
service_id,
|
||||||
|
start_at,
|
||||||
|
svc.rows[0].duration_min,
|
||||||
|
svc.rows[0].price,
|
||||||
|
notes || null,
|
||||||
|
source_channel || null,
|
||||||
|
req.user!.id,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
await c.query(
|
||||||
|
`INSERT INTO appointment_events (appointment_id, actor_user_id, action, to_status)
|
||||||
|
VALUES ($1,$2,'created','scheduled')`,
|
||||||
|
[rows[0].id, req.user!.id]
|
||||||
|
);
|
||||||
|
await writeAudit(c, {
|
||||||
|
businessId: bid,
|
||||||
|
actorUserId: req.user!.id,
|
||||||
|
entity: "appointments",
|
||||||
|
entityId: rows[0].id,
|
||||||
|
action: "create",
|
||||||
|
after: rows[0],
|
||||||
|
ip: req.ip ?? null,
|
||||||
|
});
|
||||||
|
// Se encola en la MISMA transacción: si el proceso muere aquí, la cita
|
||||||
|
// y su intención de sincronizar caen juntas o sobreviven juntas.
|
||||||
|
await encolar(c, {
|
||||||
|
businessId: bid!, entidad: "appointment", entidadId: rows[0].id,
|
||||||
|
operacion: "create", payload: { status: "scheduled" }, secuencia: "create",
|
||||||
|
});
|
||||||
|
return rows[0];
|
||||||
|
});
|
||||||
|
res.status(201).json({ appointment });
|
||||||
|
} catch (e) {
|
||||||
|
if (isOverlap(e)) {
|
||||||
|
err(res, 409, OCUPADO);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
appointmentsRouter.patch(
|
||||||
|
"/:id",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const bid = req.user!.business_id;
|
||||||
|
const { start_at, employee_id, notes } = req.body ?? {};
|
||||||
|
|
||||||
|
const cur = await pool.query(
|
||||||
|
`SELECT ${COLS} FROM appointments WHERE id = $1 AND business_id = $2`,
|
||||||
|
[id, bid]
|
||||||
|
);
|
||||||
|
if (!cur.rows[0]) {
|
||||||
|
err(res, 404, "Cita no encontrada");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cur.rows[0].status === "cancelled") {
|
||||||
|
err(res, 409, "Una cita cancelada no se puede modificar");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const appointment = await withTx(async (c) => {
|
||||||
|
const { rows } = await c.query(
|
||||||
|
`UPDATE appointments SET
|
||||||
|
start_at = COALESCE($3::timestamptz, start_at),
|
||||||
|
end_at = CASE WHEN $3::timestamptz IS NULL THEN end_at
|
||||||
|
ELSE $3::timestamptz + (end_at - start_at) END,
|
||||||
|
employee_id = COALESCE($4::bigint, employee_id),
|
||||||
|
notes = COALESCE($5::text, notes),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1 AND business_id = $2
|
||||||
|
RETURNING ${COLS}`,
|
||||||
|
[id, bid, start_at ?? null, employee_id ?? null, notes ?? null]
|
||||||
|
);
|
||||||
|
if (start_at || employee_id) {
|
||||||
|
await c.query(
|
||||||
|
`INSERT INTO appointment_events (appointment_id, actor_user_id, action, detail)
|
||||||
|
VALUES ($1,$2,'rescheduled',$3::jsonb)`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
req.user!.id,
|
||||||
|
JSON.stringify({
|
||||||
|
from: {
|
||||||
|
start_at: cur.rows[0].start_at,
|
||||||
|
employee_id: cur.rows[0].employee_id,
|
||||||
|
},
|
||||||
|
to: { start_at: rows[0].start_at, employee_id: rows[0].employee_id },
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await writeAudit(c, {
|
||||||
|
businessId: bid,
|
||||||
|
actorUserId: req.user!.id,
|
||||||
|
entity: "appointments",
|
||||||
|
entityId: id,
|
||||||
|
action: "update",
|
||||||
|
before: cur.rows[0],
|
||||||
|
after: rows[0],
|
||||||
|
ip: req.ip ?? null,
|
||||||
|
});
|
||||||
|
return rows[0];
|
||||||
|
});
|
||||||
|
res.json({ appointment });
|
||||||
|
} catch (e) {
|
||||||
|
if (isOverlap(e)) {
|
||||||
|
err(res, 409, OCUPADO);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
appointmentsRouter.post(
|
||||||
|
"/:id/cancel",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const bid = req.user!.business_id;
|
||||||
|
const { cancelled_by, reason } = req.body ?? {};
|
||||||
|
if (cancelled_by !== "client" && cancelled_by !== "business") {
|
||||||
|
err(res, 400, "Indica quién canceló: la clienta o el spa");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cur = await pool.query(
|
||||||
|
`SELECT ${COLS} FROM appointments WHERE id = $1 AND business_id = $2`,
|
||||||
|
[id, bid]
|
||||||
|
);
|
||||||
|
if (!cur.rows[0]) {
|
||||||
|
err(res, 404, "Cita no encontrada");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appointment = await withTx(async (c) => {
|
||||||
|
const { rows } = await c.query(
|
||||||
|
`UPDATE appointments
|
||||||
|
SET status = 'cancelled', cancelled_by = $3, cancel_reason = $4,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1 AND business_id = $2 RETURNING ${COLS}`,
|
||||||
|
[id, bid, cancelled_by, reason || null]
|
||||||
|
);
|
||||||
|
await c.query(
|
||||||
|
`INSERT INTO appointment_events
|
||||||
|
(appointment_id, actor_user_id, action, from_status, to_status, detail)
|
||||||
|
VALUES ($1,$2,'cancelled',$3,'cancelled',$4::jsonb)`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
req.user!.id,
|
||||||
|
cur.rows[0].status,
|
||||||
|
JSON.stringify({ cancelled_by, reason: reason || null }),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
await writeAudit(c, {
|
||||||
|
businessId: bid,
|
||||||
|
actorUserId: req.user!.id,
|
||||||
|
entity: "appointments",
|
||||||
|
entityId: id,
|
||||||
|
action: "cancel",
|
||||||
|
before: cur.rows[0],
|
||||||
|
after: rows[0],
|
||||||
|
ip: req.ip ?? null,
|
||||||
|
});
|
||||||
|
await encolar(c, {
|
||||||
|
businessId: bid!, entidad: "appointment", entidadId: id,
|
||||||
|
operacion: "status", payload: { status: "cancelled", cancelled_by },
|
||||||
|
secuencia: "cancelled",
|
||||||
|
});
|
||||||
|
return rows[0];
|
||||||
|
});
|
||||||
|
res.json({ appointment });
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { withTx, pool } from "../db/pool.ts";
|
||||||
|
import { writeAudit } from "../lib/audit.ts";
|
||||||
|
import { err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||||
|
import { encolar } from "../crm/outbox.ts";
|
||||||
|
|
||||||
|
export const attendanceRouter = Router({ mergeParams: true });
|
||||||
|
|
||||||
|
const APPT_COLS = `id, business_id, client_id, employee_id, service_id,
|
||||||
|
to_char(start_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS start_at,
|
||||||
|
to_char(end_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS end_at,
|
||||||
|
status, cancelled_by, price, notes, created_at`;
|
||||||
|
|
||||||
|
const VALID_PAYMENT = new Set(["cash", "card", "transfer", "other"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* El toque de asistencia: un solo POST resuelve la cita.
|
||||||
|
*
|
||||||
|
* La visita se crea **solo** si la clienta vino. Una visita es un hecho con
|
||||||
|
* dinero; el "no vino" es un estado de la cita. Fusionar los dos conceptos es
|
||||||
|
* lo que produce registros que sirven para planear y para cerrar, y que
|
||||||
|
* terminan sin cerrarse nunca.
|
||||||
|
*/
|
||||||
|
attendanceRouter.post(
|
||||||
|
"/",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const bid = req.user!.business_id;
|
||||||
|
const { attended, total_charged, payment_method } = req.body ?? {};
|
||||||
|
|
||||||
|
if (typeof attended !== "boolean") {
|
||||||
|
err(res, 400, "Indica si la clienta vino o no vino");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payment_method != null && !VALID_PAYMENT.has(payment_method)) {
|
||||||
|
err(res, 400, "Método de pago no válido");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cur = await pool.query(
|
||||||
|
`SELECT ${APPT_COLS} FROM appointments WHERE id = $1 AND business_id = $2`,
|
||||||
|
[id, bid]
|
||||||
|
);
|
||||||
|
const appt = cur.rows[0];
|
||||||
|
if (!appt) {
|
||||||
|
err(res, 404, "Cita no encontrada");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (appt.status === "cancelled") {
|
||||||
|
err(res, 409, "Esta cita está cancelada: no se le puede marcar asistencia");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (appt.status === "completed" || appt.status === "no_show") {
|
||||||
|
err(res, 409, "Esta cita ya se resolvió");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Una empleada solo resuelve sus propias citas; la administradora, cualquiera.
|
||||||
|
if (req.user!.role === "employee" && req.user!.employee_id !== appt.employee_id) {
|
||||||
|
err(res, 403, "Solo puedes marcar asistencia en tus propias citas");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = await withTx(async (c) => {
|
||||||
|
const nextStatus = attended ? "completed" : "no_show";
|
||||||
|
const { rows } = await c.query(
|
||||||
|
`UPDATE appointments SET status = $3, updated_at = now()
|
||||||
|
WHERE id = $1 AND business_id = $2 RETURNING ${APPT_COLS}`,
|
||||||
|
[id, bid, nextStatus]
|
||||||
|
);
|
||||||
|
|
||||||
|
let visit = null;
|
||||||
|
if (attended) {
|
||||||
|
const v = await c.query(
|
||||||
|
`INSERT INTO visits
|
||||||
|
(business_id, appointment_id, client_id, employee_id, occurred_at,
|
||||||
|
total_charged, payment_method, recorded_by_user_id)
|
||||||
|
SELECT business_id, id, client_id, employee_id, start_at, $2, $3, $4
|
||||||
|
FROM appointments WHERE id = $1
|
||||||
|
RETURNING id, business_id, appointment_id, client_id, employee_id,
|
||||||
|
to_char(occurred_at AT TIME ZONE 'UTC',
|
||||||
|
'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS occurred_at,
|
||||||
|
total_charged, payment_method, recorded_by_user_id, recorded_at`,
|
||||||
|
[id, total_charged ?? null, payment_method ?? null, req.user!.id]
|
||||||
|
);
|
||||||
|
visit = v.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
await c.query(
|
||||||
|
`INSERT INTO appointment_events
|
||||||
|
(appointment_id, actor_user_id, action, from_status, to_status)
|
||||||
|
VALUES ($1,$2,$3,$4,$5)`,
|
||||||
|
[id, req.user!.id, attended ? "attended" : "no_show", appt.status, nextStatus]
|
||||||
|
);
|
||||||
|
await writeAudit(c, {
|
||||||
|
businessId: bid,
|
||||||
|
actorUserId: req.user!.id,
|
||||||
|
entity: "appointments",
|
||||||
|
entityId: id,
|
||||||
|
action: "attendance",
|
||||||
|
before: { status: appt.status },
|
||||||
|
after: { status: nextStatus, visit_id: visit?.id ?? null },
|
||||||
|
ip: req.ip ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// La proyección al CRM se ENCOLA en esta misma transacción. Si se hiciera
|
||||||
|
// aquí una llamada de red, un CRM caído impediría marcar la asistencia —
|
||||||
|
// justo el registro que el negocio no puede permitirse perder.
|
||||||
|
await encolar(c, {
|
||||||
|
businessId: bid!,
|
||||||
|
entidad: "appointment",
|
||||||
|
entidadId: id,
|
||||||
|
operacion: "status",
|
||||||
|
payload: { status: nextStatus },
|
||||||
|
secuencia: nextStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { appointment: rows[0], visit };
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(out);
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { authRequired, err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||||
|
|
||||||
|
export const authRouter = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Portado tal cual del backend de demo: el token es el id del usuario y la
|
||||||
|
* contraseña se compara en claro. Ver la nota de deuda en lib/auth.ts — se
|
||||||
|
* endurece entero (hash + sesión real + cliente + pruebas) o no se toca.
|
||||||
|
*/
|
||||||
|
authRouter.post(
|
||||||
|
"/login",
|
||||||
|
h(async (req, res) => {
|
||||||
|
const { email, password } = req.body ?? {};
|
||||||
|
if (!email || !password) {
|
||||||
|
err(res, 400, "Faltan credenciales");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id, business_id, email, name, role, employee_id, avatar_color, password
|
||||||
|
FROM users WHERE email = $1`,
|
||||||
|
[String(email).toLowerCase().trim()]
|
||||||
|
);
|
||||||
|
const user = rows[0];
|
||||||
|
if (!user || user.password !== password) {
|
||||||
|
err(res, 401, "Correo o contraseña incorrectos");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { password: _pw, ...safe } = user;
|
||||||
|
res.json({ token: String(user.id), user: safe });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
authRouter.get("/me", authRequired, (req: AuthedRequest, res) => {
|
||||||
|
res.json({ user: req.user });
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.get(
|
||||||
|
"/demo-users",
|
||||||
|
h(async (_req, res) => {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT email, name, role, avatar_color FROM users
|
||||||
|
ORDER BY CASE role WHEN 'admin' THEN 0 WHEN 'owner' THEN 1 ELSE 2 END, name`
|
||||||
|
);
|
||||||
|
res.json({ users: rows });
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||||
|
|
||||||
|
export const businessRouter = Router();
|
||||||
|
|
||||||
|
businessRouter.get(
|
||||||
|
"/",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id, name, industry, currency, currency_symbol, phone, address,
|
||||||
|
slug, timezone, working_hours, status
|
||||||
|
FROM businesses WHERE id = $1`,
|
||||||
|
[req.user!.business_id]
|
||||||
|
);
|
||||||
|
if (!rows[0]) {
|
||||||
|
err(res, 404, "Negocio no encontrado");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ business: rows[0] });
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { pool, withTx } from "../db/pool.ts";
|
||||||
|
import { normalizePhone } from "../lib/phone.ts";
|
||||||
|
import { writeAudit } from "../lib/audit.ts";
|
||||||
|
import { err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||||
|
|
||||||
|
export const clientsRouter = Router();
|
||||||
|
|
||||||
|
const COLS = `id, business_id, name, email, phone, phone_e164, contactable,
|
||||||
|
birth_date, notes, tags, source_channel, created_at,
|
||||||
|
crm_contact_id, crm_synced_at, crm_source, crm_tags,
|
||||||
|
attr_session_source, attr_medium, attr_campaign, attr_campaign_id,
|
||||||
|
attr_utm_source, attr_utm_medium, attr_utm_content, attr_ad_id,
|
||||||
|
attr_referrer`;
|
||||||
|
|
||||||
|
clientsRouter.get(
|
||||||
|
"/",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const q = (req.query.q as string | undefined)?.trim();
|
||||||
|
const bid = req.user!.business_id;
|
||||||
|
|
||||||
|
if (!q) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT ${COLS} FROM clients
|
||||||
|
WHERE business_id = $1 AND deleted_at IS NULL
|
||||||
|
ORDER BY name LIMIT 50`,
|
||||||
|
[bid]
|
||||||
|
);
|
||||||
|
res.json({ clients: rows });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se busca por tres vías a la vez: el nombre, el teléfono tal cual se guardó,
|
||||||
|
// y el normalizado. La tercera es la que hace que teclear "5588887777"
|
||||||
|
// encuentre a quien está guardada como "+52 55 8888 7777".
|
||||||
|
const like = `%${q}%`;
|
||||||
|
const e164 = normalizePhone(q);
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT ${COLS} FROM clients
|
||||||
|
WHERE business_id = $1 AND deleted_at IS NULL
|
||||||
|
AND (name ILIKE $2 OR phone ILIKE $2 OR email ILIKE $2
|
||||||
|
OR ($3::text IS NOT NULL AND phone_e164 = $3))
|
||||||
|
ORDER BY name LIMIT 50`,
|
||||||
|
[bid, like, e164]
|
||||||
|
);
|
||||||
|
res.json({ clients: rows });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
clientsRouter.get(
|
||||||
|
"/:id",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const bid = req.user!.business_id;
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT ${COLS} FROM clients
|
||||||
|
WHERE id = $1 AND business_id = $2 AND deleted_at IS NULL`,
|
||||||
|
[id, bid]
|
||||||
|
);
|
||||||
|
if (!rows[0]) {
|
||||||
|
err(res, 404, "Clienta no encontrada");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Las visitas salen de `visits`, no de las citas: una cita es una
|
||||||
|
// intención y una visita es el hecho consumado. Contar citas como visitas
|
||||||
|
// infla el historial con gente que no vino.
|
||||||
|
const v = await pool.query(
|
||||||
|
`SELECT count(*)::int AS visits,
|
||||||
|
COALESCE(sum(total_charged),0)::float AS total_spent,
|
||||||
|
max(occurred_at) AS last_visit
|
||||||
|
FROM visits WHERE business_id = $1 AND client_id = $2`,
|
||||||
|
[bid, id]
|
||||||
|
);
|
||||||
|
const ns = await pool.query(
|
||||||
|
`SELECT count(*)::int AS c FROM appointments
|
||||||
|
WHERE business_id = $1 AND client_id = $2 AND status = 'no_show'`,
|
||||||
|
[bid, id]
|
||||||
|
);
|
||||||
|
const visits = v.rows[0].visits as number;
|
||||||
|
const total = v.rows[0].total_spent as number;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
client: {
|
||||||
|
...rows[0],
|
||||||
|
stats: {
|
||||||
|
visits,
|
||||||
|
total_spent: Math.round(total * 100) / 100,
|
||||||
|
last_visit: v.rows[0].last_visit,
|
||||||
|
avg_ticket: visits ? Math.round((total / visits) * 100) / 100 : 0,
|
||||||
|
no_show_count: ns.rows[0].c,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
clientsRouter.post(
|
||||||
|
"/",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const { name, phone, email, notes, source_channel } = req.body ?? {};
|
||||||
|
if (!name || typeof name !== "string" || !name.trim()) {
|
||||||
|
err(res, 400, "El nombre es obligatorio");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bid = req.user!.business_id;
|
||||||
|
const e164 = normalizePhone(phone);
|
||||||
|
|
||||||
|
// Se pregunta antes de insertar para poder devolver la ficha existente. El
|
||||||
|
// índice único sigue siendo la garantía real: entre esta consulta y el
|
||||||
|
// INSERT cabe otra alta, y por eso abajo también se atrapa el 23505.
|
||||||
|
if (e164) {
|
||||||
|
const dup = await pool.query(
|
||||||
|
`SELECT ${COLS} FROM clients
|
||||||
|
WHERE business_id = $1 AND phone_e164 = $2 AND deleted_at IS NULL`,
|
||||||
|
[bid, e164]
|
||||||
|
);
|
||||||
|
if (dup.rows[0]) {
|
||||||
|
res.status(409).json({
|
||||||
|
error: "Ya existe una clienta con ese teléfono",
|
||||||
|
existing: dup.rows[0],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = await withTx(async (c) => {
|
||||||
|
const { rows } = await c.query(
|
||||||
|
`INSERT INTO clients
|
||||||
|
(business_id, name, email, phone, phone_e164, notes, source_channel)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING ${COLS}`,
|
||||||
|
[
|
||||||
|
bid,
|
||||||
|
name.trim(),
|
||||||
|
email || null,
|
||||||
|
phone || null,
|
||||||
|
e164,
|
||||||
|
notes || null,
|
||||||
|
source_channel || null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
await writeAudit(c, {
|
||||||
|
businessId: bid,
|
||||||
|
actorUserId: req.user!.id,
|
||||||
|
entity: "clients",
|
||||||
|
entityId: rows[0].id,
|
||||||
|
action: "create",
|
||||||
|
after: rows[0],
|
||||||
|
ip: req.ip ?? null,
|
||||||
|
});
|
||||||
|
return rows[0];
|
||||||
|
});
|
||||||
|
res.status(201).json({ client });
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.code === "23505") {
|
||||||
|
const dup = await pool.query(
|
||||||
|
`SELECT ${COLS} FROM clients WHERE business_id = $1 AND phone_e164 = $2`,
|
||||||
|
[bid, e164]
|
||||||
|
);
|
||||||
|
res.status(409).json({
|
||||||
|
error: "Ya existe una clienta con ese teléfono",
|
||||||
|
existing: dup.rows[0] ?? null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { err, h, ownerOnly, type AuthedRequest } from "../lib/auth.ts";
|
||||||
|
import { writeAudit } from "../lib/audit.ts";
|
||||||
|
import { withTx } from "../db/pool.ts";
|
||||||
|
import { obtenerConexion, autoconfigurar } from "../crm/connection.ts";
|
||||||
|
import { ctxDe } from "../crm/ctx.ts";
|
||||||
|
import { sincronizarContactos } from "../crm/syncContacts.ts";
|
||||||
|
import { proyectarCita } from "../crm/syncAppointments.ts";
|
||||||
|
import { despachar, estadoOutbox } from "../crm/outbox.ts";
|
||||||
|
import { sincronizarPorId, esEntidad, ENTIDADES } from "../crm/syncOne.ts";
|
||||||
|
import { sincronizarConversaciones } from "../crm/syncConversations.ts";
|
||||||
|
|
||||||
|
export const crmRouter = Router();
|
||||||
|
|
||||||
|
/** Estado de la conexión: lo que la pantalla de clientes necesita para el botón. */
|
||||||
|
crmRouter.get(
|
||||||
|
"/status",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const bid = req.user!.business_id!;
|
||||||
|
const conexion = await obtenerConexion(bid);
|
||||||
|
if (!conexion) {
|
||||||
|
res.json({ connected: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await pool.query(
|
||||||
|
`SELECT count(*)::int AS clientes,
|
||||||
|
count(*) FILTER (WHERE crm_contact_id IS NOT NULL)::int AS sincronizados,
|
||||||
|
count(*) FILTER (WHERE contactable)::int AS contactables,
|
||||||
|
count(*) FILTER (WHERE attr_campaign IS NOT NULL)::int AS con_campana
|
||||||
|
FROM clients WHERE business_id = $1 AND deleted_at IS NULL`,
|
||||||
|
[bid]
|
||||||
|
);
|
||||||
|
const ultima = await pool.query(
|
||||||
|
`SELECT id, kind, status, fetched, created, updated, started_at, finished_at, error
|
||||||
|
FROM crm_sync_runs WHERE business_id = $1 ORDER BY id DESC LIMIT 1`,
|
||||||
|
[bid]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
connected: true,
|
||||||
|
location_id: conexion.location_id,
|
||||||
|
pipeline_id: conexion.pipeline_id,
|
||||||
|
allow_duplicate_opp: conexion.allow_duplicate_opp,
|
||||||
|
last_sync_at: conexion.last_sync_at,
|
||||||
|
last_sync_status: conexion.last_sync_status,
|
||||||
|
stats: stats.rows[0],
|
||||||
|
last_run: ultima.rows[0] ?? null,
|
||||||
|
outbox: await estadoOutbox(bid),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Conecta o reconfigura la subcuenta. El token vive en el entorno, no en el body. */
|
||||||
|
crmRouter.post(
|
||||||
|
"/connect",
|
||||||
|
ownerOnly,
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const bid = req.user!.business_id!;
|
||||||
|
// Las credenciales las pone la administración de la plataforma, no el
|
||||||
|
// negocio: son de la subcuenta del cliente y no deben viajar por aquí.
|
||||||
|
// Esta ruta solo redetecta pipeline y etapas de la subcuenta ya vinculada.
|
||||||
|
const ctx = await ctxDe(bid); // lanza 409 si no está vinculado
|
||||||
|
const c = await autoconfigurar(ctx);
|
||||||
|
await withTx((tx) =>
|
||||||
|
writeAudit(tx, {
|
||||||
|
businessId: bid,
|
||||||
|
actorUserId: req.user!.id,
|
||||||
|
entity: "crm_connections",
|
||||||
|
entityId: c.id,
|
||||||
|
action: "connect",
|
||||||
|
after: { location_id: c.location_id, pipeline_id: c.pipeline_id },
|
||||||
|
ip: req.ip ?? null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
res.json({ connection: c });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* El botón de sincronizar contactos.
|
||||||
|
*
|
||||||
|
* Es una corrida en primer plano y no una tarea de fondo a propósito: 3 200
|
||||||
|
* contactos tardan ~22 s y quien pulsa el botón quiere ver el resultado. Si el
|
||||||
|
* volumen crece hasta molestar, se mueve a la bandeja; hoy sería complejidad
|
||||||
|
* sin problema que resolver.
|
||||||
|
*/
|
||||||
|
crmRouter.post(
|
||||||
|
"/sync/contacts",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const bid = req.user!.business_id!;
|
||||||
|
try {
|
||||||
|
const r = await sincronizarContactos(bid, {
|
||||||
|
userId: req.user!.id,
|
||||||
|
maxPaginas: Number(req.body?.max_paginas) || 60,
|
||||||
|
});
|
||||||
|
res.json(r);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.status) {
|
||||||
|
err(res, e.status, e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
err(res, 502, `El CRM no respondió como se esperaba: ${e.message}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Empuja una cita concreta al CRM como oportunidad. */
|
||||||
|
crmRouter.post(
|
||||||
|
"/sync/appointment/:id",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const bid = req.user!.business_id!;
|
||||||
|
try {
|
||||||
|
const r = await proyectarCita(bid, Number(req.params.id));
|
||||||
|
res.json(r);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.status) {
|
||||||
|
err(res, e.status, e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
err(res, 502, `El CRM no respondió como se esperaba: ${e.message}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Vacía la bandeja de salida. */
|
||||||
|
crmRouter.post(
|
||||||
|
"/outbox/flush",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const bid = req.user!.business_id!;
|
||||||
|
const r = await despachar(bid, Number(req.body?.limite) || 25);
|
||||||
|
res.json(r);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Lo que no se pudo sincronizar, para que alguien pueda mirarlo. */
|
||||||
|
crmRouter.get(
|
||||||
|
"/outbox",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const bid = req.user!.business_id!;
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id, entity, entity_id, operation, status, attempts, last_error,
|
||||||
|
crm_id, created_at, sent_at
|
||||||
|
FROM crm_outbox
|
||||||
|
WHERE business_id = $1
|
||||||
|
ORDER BY CASE status WHEN 'indeterminado' THEN 0 WHEN 'fallido' THEN 1
|
||||||
|
WHEN 'pendiente' THEN 2 ELSE 3 END, id DESC
|
||||||
|
LIMIT 100`,
|
||||||
|
[bid]
|
||||||
|
);
|
||||||
|
res.json({ items: rows, resumen: await estadoOutbox(bid) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Espejo de las conversaciones recientes con sus mensajes.
|
||||||
|
*
|
||||||
|
* Va declarada ANTES que `/sync/:entidad/:id` no por ambigüedad —tienen distinto
|
||||||
|
* número de segmentos— sino para que el orden del archivo diga cuál es la ruta
|
||||||
|
* concreta y cuál la genérica.
|
||||||
|
*/
|
||||||
|
crmRouter.post(
|
||||||
|
"/sync/conversations",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const bid = req.user!.business_id!;
|
||||||
|
try {
|
||||||
|
res.json(
|
||||||
|
await sincronizarConversaciones(bid, {
|
||||||
|
limit: Math.min(Number(req.body?.limite) || 50, 200),
|
||||||
|
userId: req.user!.id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.status) {
|
||||||
|
err(res, e.status, e.error ?? e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
err(res, 502, `Bucéfalo CRM no respondió como se esperaba: ${e.message}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sincroniza UNA entidad por su identificador.
|
||||||
|
*
|
||||||
|
* Es el punto de entrada único que pedía el encargo. La dirección la decide la
|
||||||
|
* entidad, no quien llama: contactos, conversaciones y mensajes se traen del
|
||||||
|
* CRM; citas y servicios se empujan hacia él. Ver `crm/syncOne.ts`.
|
||||||
|
*/
|
||||||
|
crmRouter.post(
|
||||||
|
"/sync/:entidad/:id",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const { entidad, id } = req.params;
|
||||||
|
if (!esEntidad(entidad)) {
|
||||||
|
err(res, 400, `Entidad no reconocida. Las válidas son: ${ENTIDADES.join(", ")}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!id || id.length > 64) {
|
||||||
|
err(res, 400, "Identificador ausente o demasiado largo");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
res.json(await sincronizarPorId(req.user!.business_id!, entidad, id));
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.status) {
|
||||||
|
err(res, e.status, e.error ?? e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
err(res, 502, `Bucéfalo CRM no respondió como se esperaba: ${e.message}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { pool, withTx } from "../db/pool.ts";
|
||||||
|
import { writeAudit } from "../lib/audit.ts";
|
||||||
|
import { err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||||
|
import { bizDayBoundsIsoFor, bizTodayISO, DEFAULT_TZ } from "../lib/time.ts";
|
||||||
|
|
||||||
|
export const dayCloseRouter = Router();
|
||||||
|
|
||||||
|
const APPT_COLS = `a.id, a.client_id, a.employee_id, a.service_id,
|
||||||
|
to_char(a.start_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS start_at,
|
||||||
|
to_char(a.end_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS end_at,
|
||||||
|
a.status, a.price, c.name AS client_name, e.name AS employee_name, s.name AS service_name`;
|
||||||
|
|
||||||
|
// `bizDayBoundsIsoFor` devuelve un fin INCLUSIVO (23:59:59 hora local), así que
|
||||||
|
// la comparación es `<=`. Con `<` se perdería la última cita del día.
|
||||||
|
const IN_DAY = `a.start_at >= $2::timestamptz AND a.start_at <= $3::timestamptz`;
|
||||||
|
|
||||||
|
const UNRESOLVED_SQL = `
|
||||||
|
SELECT ${APPT_COLS} FROM appointments a
|
||||||
|
JOIN clients c ON c.id = a.client_id
|
||||||
|
JOIN employees e ON e.id = a.employee_id
|
||||||
|
JOIN services s ON s.id = a.service_id
|
||||||
|
WHERE a.business_id = $1 AND ${IN_DAY} AND a.status = 'scheduled'
|
||||||
|
ORDER BY a.start_at`;
|
||||||
|
|
||||||
|
const COUNTS_SQL = `
|
||||||
|
SELECT
|
||||||
|
count(*) FILTER (WHERE status = 'completed')::int AS attended,
|
||||||
|
count(*) FILTER (WHERE status = 'no_show')::int AS no_show,
|
||||||
|
count(*) FILTER (WHERE status = 'cancelled')::int AS cancelled
|
||||||
|
FROM appointments
|
||||||
|
WHERE business_id = $1
|
||||||
|
AND start_at >= $2::timestamptz AND start_at <= $3::timestamptz`;
|
||||||
|
|
||||||
|
async function bizTz(businessId: number): Promise<string> {
|
||||||
|
const { rows } = await pool.query(`SELECT timezone FROM businesses WHERE id = $1`, [
|
||||||
|
businessId,
|
||||||
|
]);
|
||||||
|
return rows[0]?.timezone || DEFAULT_TZ;
|
||||||
|
}
|
||||||
|
|
||||||
|
dayCloseRouter.get(
|
||||||
|
"/",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const bid = req.user!.business_id!;
|
||||||
|
const tz = await bizTz(bid);
|
||||||
|
const date = (req.query.date as string | undefined) || bizTodayISO(tz);
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||||
|
err(res, 400, "Fecha no válida");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { start, end } = bizDayBoundsIsoFor(tz, date);
|
||||||
|
|
||||||
|
const unresolved = await pool.query(UNRESOLVED_SQL, [bid, start, end]);
|
||||||
|
const counts = await pool.query(COUNTS_SQL, [bid, start, end]);
|
||||||
|
const closure = await pool.query(
|
||||||
|
`SELECT closed_at FROM day_closures WHERE business_id = $1 AND business_date = $2::date`,
|
||||||
|
[bid, date]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
date,
|
||||||
|
closed_at: closure.rows[0]?.closed_at ?? null,
|
||||||
|
unresolved: unresolved.rows,
|
||||||
|
attended: counts.rows[0].attended,
|
||||||
|
no_show: counts.rows[0].no_show,
|
||||||
|
cancelled: counts.rows[0].cancelled,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
dayCloseRouter.post(
|
||||||
|
"/",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const bid = req.user!.business_id!;
|
||||||
|
const tz = await bizTz(bid);
|
||||||
|
const date = (req.body?.date as string | undefined) || bizTodayISO(tz);
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||||
|
err(res, 400, "Fecha no válida");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { start, end } = bizDayBoundsIsoFor(tz, date);
|
||||||
|
|
||||||
|
const ya = await pool.query(
|
||||||
|
`SELECT 1 FROM day_closures WHERE business_id = $1 AND business_date = $2::date`,
|
||||||
|
[bid, date]
|
||||||
|
);
|
||||||
|
if (ya.rows[0]) {
|
||||||
|
err(res, 409, "Ese día ya está cerrado");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// La regla que sostiene todo el proyecto: no se puede cerrar el día dejando
|
||||||
|
// citas sin desenlace. Es lo que convierte el registro en el camino más
|
||||||
|
// corto para trabajar, en vez de una tarea añadida al final.
|
||||||
|
const pend = await pool.query(UNRESOLVED_SQL, [bid, start, end]);
|
||||||
|
if (pend.rows.length) {
|
||||||
|
res.status(409).json({
|
||||||
|
error: `Quedan ${pend.rows.length} cita(s) sin resolver: marca si vinieron o no antes de cerrar`,
|
||||||
|
unresolved: pend.rows,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const closure = await withTx(async (c) => {
|
||||||
|
const counts = await c.query(COUNTS_SQL, [bid, start, end]);
|
||||||
|
const { rows } = await c.query(
|
||||||
|
`INSERT INTO day_closures
|
||||||
|
(business_id, business_date, closed_by_user_id,
|
||||||
|
attended_count, no_show_count, cancelled_count)
|
||||||
|
VALUES ($1,$2::date,$3,$4,$5,$6)
|
||||||
|
RETURNING id, business_id,
|
||||||
|
to_char(business_date, 'YYYY-MM-DD') AS business_date,
|
||||||
|
closed_by_user_id, closed_at,
|
||||||
|
attended_count, no_show_count, cancelled_count`,
|
||||||
|
[
|
||||||
|
bid,
|
||||||
|
date,
|
||||||
|
req.user!.id,
|
||||||
|
counts.rows[0].attended,
|
||||||
|
counts.rows[0].no_show,
|
||||||
|
counts.rows[0].cancelled,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
await writeAudit(c, {
|
||||||
|
businessId: bid,
|
||||||
|
actorUserId: req.user!.id,
|
||||||
|
entity: "day_closures",
|
||||||
|
entityId: rows[0].id,
|
||||||
|
action: "close",
|
||||||
|
after: rows[0],
|
||||||
|
ip: req.ip ?? null,
|
||||||
|
});
|
||||||
|
return rows[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ closure });
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { pool, withTx } from "../db/pool.ts";
|
||||||
|
import { err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||||
|
import { writeAudit } from "../lib/audit.ts";
|
||||||
|
import { obtenerConexion } from "../crm/connection.ts";
|
||||||
|
import { ctxDe } from "../crm/ctx.ts";
|
||||||
|
import { buscarConversaciones, mensajesDeConversacion } from "../crm/conversations.ts";
|
||||||
|
import { enviarCorreo } from "../crm/messages.ts";
|
||||||
|
import { loadEnv } from "../lib/env.ts";
|
||||||
|
|
||||||
|
export const messagesRouter = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MODO PRUEBA — restricción deliberada del MVP.
|
||||||
|
*
|
||||||
|
* Mientras `CRM_TEST_EMAIL` esté definido, **el servidor solo envía a esa
|
||||||
|
* dirección**, sin importar a quién apunte la interfaz. La subcuenta es la de un
|
||||||
|
* cliente real con 3 200 contactos: un bucle mal escrito o un clic de más
|
||||||
|
* escribiría a personas de verdad, y eso no se arregla pidiendo perdón.
|
||||||
|
*
|
||||||
|
* Se quita definiendo `CRM_ALLOW_REAL_SENDS=1`, y esa es una decisión del dueño
|
||||||
|
* del proyecto, no un descuido de configuración.
|
||||||
|
*/
|
||||||
|
function destinoPermitido(deseado: string): { to: string; forzado: boolean } {
|
||||||
|
loadEnv();
|
||||||
|
const prueba = process.env.CRM_TEST_EMAIL;
|
||||||
|
const libre = process.env.CRM_ALLOW_REAL_SENDS === "1";
|
||||||
|
if (prueba && !libre) {
|
||||||
|
return { to: prueba, forzado: prueba.toLowerCase() !== deseado.toLowerCase() };
|
||||||
|
}
|
||||||
|
return { to: deseado, forzado: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bandeja: conversaciones del CRM, con la clienta local enlazada si se conoce. */
|
||||||
|
messagesRouter.get(
|
||||||
|
"/",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const bid = req.user!.business_id!;
|
||||||
|
const conexion = await obtenerConexion(bid);
|
||||||
|
if (!conexion) {
|
||||||
|
err(res, 409, "Este negocio no tiene conexión con Bucéfalo CRM");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = await ctxDe(bid);
|
||||||
|
const { conversations, total } = await buscarConversaciones(ctx, {
|
||||||
|
limit: Number(req.query.limit) || 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Se enlazan con las clientas locales por el id del CRM para poder abrir su
|
||||||
|
// ficha desde la bandeja: es el "al lado" que hace útil esta pantalla.
|
||||||
|
const ids = conversations.map((c) => c.contactId).filter(Boolean) as string[];
|
||||||
|
const locales = ids.length
|
||||||
|
? await pool.query(
|
||||||
|
`SELECT id, name, crm_contact_id, phone_e164, contactable
|
||||||
|
FROM clients WHERE business_id = $1 AND crm_contact_id = ANY($2::text[])`,
|
||||||
|
[bid, ids]
|
||||||
|
)
|
||||||
|
: { rows: [] as any[] };
|
||||||
|
const porCrmId = new Map(locales.rows.map((r: any) => [r.crm_contact_id, r]));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
total,
|
||||||
|
conversations: conversations.map((c) => ({
|
||||||
|
crm_conversation_id: c.id,
|
||||||
|
crm_contact_id: c.contactId ?? null,
|
||||||
|
contact_name: c.fullName || c.contactName || "Sin nombre",
|
||||||
|
last_message_body: c.lastMessageBody ?? null,
|
||||||
|
last_message_type: c.lastMessageType ?? null,
|
||||||
|
last_message_at: c.lastMessageDate ?? null,
|
||||||
|
unread_count: c.unreadCount ?? 0,
|
||||||
|
client: porCrmId.get(c.contactId ?? "") ?? null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Los mensajes de un hilo. Solo lectura: el CRM es el dueño del histórico. */
|
||||||
|
messagesRouter.get(
|
||||||
|
"/:conversationId",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const conexion = await obtenerConexion(req.user!.business_id!);
|
||||||
|
if (!conexion) {
|
||||||
|
err(res, 409, "Este negocio no tiene conexión con Bucéfalo CRM");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ctx = await ctxDe(req.user!.business_id!);
|
||||||
|
const { mensajes, hayMas } = await mensajesDeConversacion(ctx, req.params.conversationId, {
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
res.json({
|
||||||
|
hay_mas: hayMas,
|
||||||
|
messages: mensajes.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
body: m.body ?? null,
|
||||||
|
direction: m.direction ?? null,
|
||||||
|
channel: m.messageType ?? null,
|
||||||
|
status: m.status ?? null,
|
||||||
|
sent_at: m.dateAdded ?? null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Responder por correo.
|
||||||
|
*
|
||||||
|
* WhatsApp y SMS no están conectados en esta subcuenta: el correo es el único
|
||||||
|
* canal ejercible hoy, y la respuesta lo dice explícitamente para que la
|
||||||
|
* interfaz no prometa lo que no puede cumplir.
|
||||||
|
*/
|
||||||
|
messagesRouter.post(
|
||||||
|
"/send",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const bid = req.user!.business_id!;
|
||||||
|
const { client_id, crm_contact_id, subject, body } = req.body ?? {};
|
||||||
|
if (!subject || !body) {
|
||||||
|
err(res, 400, "El asunto y el mensaje son obligatorios");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conexion = await obtenerConexion(bid);
|
||||||
|
if (!conexion) {
|
||||||
|
err(res, 409, "Este negocio no tiene conexión con Bucéfalo CRM");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let contactId: string | null = crm_contact_id ?? null;
|
||||||
|
let correoDestino: string | null = null;
|
||||||
|
let clienteLocal: any = null;
|
||||||
|
|
||||||
|
if (client_id) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id, name, email, crm_contact_id FROM clients
|
||||||
|
WHERE id = $1 AND business_id = $2 AND deleted_at IS NULL`,
|
||||||
|
[Number(client_id), bid]
|
||||||
|
);
|
||||||
|
clienteLocal = rows[0] ?? null;
|
||||||
|
if (!clienteLocal) {
|
||||||
|
err(res, 404, "Clienta no encontrada");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
contactId = contactId ?? clienteLocal.crm_contact_id;
|
||||||
|
correoDestino = clienteLocal.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contactId) {
|
||||||
|
err(res, 409, "Esta clienta todavía no está sincronizada con el CRM");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { to, forzado } = destinoPermitido(correoDestino || "");
|
||||||
|
if (!to) {
|
||||||
|
err(res, 409, "No hay dirección de correo a la que escribir");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = await ctxDe(bid);
|
||||||
|
const r = await enviarCorreo(ctx, {
|
||||||
|
contactId,
|
||||||
|
emailTo: to,
|
||||||
|
subject: String(subject).slice(0, 200),
|
||||||
|
html: `<div style="font-family:system-ui,sans-serif;font-size:15px;line-height:1.6">${String(
|
||||||
|
body
|
||||||
|
)
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => `<p>${escaparHtml(l)}</p>`)
|
||||||
|
.join("")}</div>`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await withTx((tx) =>
|
||||||
|
writeAudit(tx, {
|
||||||
|
businessId: bid,
|
||||||
|
actorUserId: req.user!.id,
|
||||||
|
entity: "messages",
|
||||||
|
entityId: clienteLocal?.id ?? null,
|
||||||
|
action: "send_email",
|
||||||
|
after: { to, forzado, crm: r },
|
||||||
|
ip: req.ip ?? null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
// El CRM responde "Email queued successfully": es acuse de ENCOLADO, no
|
||||||
|
// de entrega. La interfaz debe decir "en camino", nunca "entregado".
|
||||||
|
queued: true,
|
||||||
|
crm: r,
|
||||||
|
sent_to: to,
|
||||||
|
redirigido: forzado,
|
||||||
|
aviso: forzado
|
||||||
|
? `Modo prueba: el mensaje se envió a ${to}, no a la clienta.`
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
function escaparHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Comprueba que la consola de cuentas se pinta de verdad y que el token NO
|
||||||
|
* aparece en ningún sitio del DOM.
|
||||||
|
*
|
||||||
|
* Un typecheck limpio y un build correcto no dicen nada sobre si la pantalla
|
||||||
|
* renderiza: eso hay que mirarlo.
|
||||||
|
*
|
||||||
|
* node platform/scripts/admin-ui-check.mjs
|
||||||
|
*/
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
|
||||||
|
const BASE = process.env.UI_BASE_URL || "http://localhost:5176";
|
||||||
|
const EMAIL = "[email protected]";
|
||||||
|
const PASS = "demo1234";
|
||||||
|
|
||||||
|
let fallos = 0;
|
||||||
|
const check = (nombre, ok, detalle = "") => {
|
||||||
|
console.log(` ${ok ? "✔" : "✖"} ${nombre}${detalle ? ` — ${detalle}` : ""}`);
|
||||||
|
if (!ok) fallos++;
|
||||||
|
};
|
||||||
|
|
||||||
|
const navegador = await chromium.launch();
|
||||||
|
const pagina = await navegador.newPage();
|
||||||
|
const erroresConsola = [];
|
||||||
|
pagina.on("console", (m) => m.type() === "error" && erroresConsola.push(m.text()));
|
||||||
|
pagina.on("pageerror", (e) => erroresConsola.push(String(e)));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pagina.goto(`${BASE}/login`, { waitUntil: "networkidle" });
|
||||||
|
await pagina.fill('input[type="email"]', EMAIL);
|
||||||
|
await pagina.fill('input[type="password"]', PASS);
|
||||||
|
await pagina.click('button[type="submit"]');
|
||||||
|
await pagina.waitForURL(/\/admin/, { timeout: 15000 });
|
||||||
|
check("entra como administración de plataforma", true, pagina.url());
|
||||||
|
|
||||||
|
await pagina.goto(`${BASE}/admin/cuentas`, { waitUntil: "networkidle" });
|
||||||
|
await pagina.waitForSelector("table", { timeout: 15000 });
|
||||||
|
|
||||||
|
const texto = await pagina.innerText("body");
|
||||||
|
check("se pinta la tabla de cuentas", /Cuentas de la plataforma/.test(texto));
|
||||||
|
check("aparece el negocio real", /Yola Franco Spa/.test(texto));
|
||||||
|
check("muestra la huella del token", /token …f89621|token \.\.\.f89621/.test(texto), "huella visible");
|
||||||
|
|
||||||
|
// Lo que NO puede pasar bajo ningún concepto.
|
||||||
|
const html = await pagina.content();
|
||||||
|
check("el token completo NO está en el DOM", !/pit-/i.test(html) && !html.includes("f89621f"), "");
|
||||||
|
|
||||||
|
// El modal de vínculo: el campo del token debe ser de contraseña y venir vacío.
|
||||||
|
await pagina.click("text=Cambiar token");
|
||||||
|
await pagina.waitForSelector("#cr-tok", { timeout: 8000 });
|
||||||
|
const tipo = await pagina.getAttribute("#cr-tok", "type");
|
||||||
|
const valor = await pagina.inputValue("#cr-tok");
|
||||||
|
check("el campo del token es de contraseña", tipo === "password", `type=${tipo}`);
|
||||||
|
check("y viene vacío: no hay token que traer", valor === "", `valor=«${valor}»`);
|
||||||
|
const locId = await pagina.inputValue("#cr-loc");
|
||||||
|
check("la subcuenta sí se prerrellena", locId === "Pk89Wa23QaxvkOfKgwjZ", locId);
|
||||||
|
|
||||||
|
// Etiquetas asociadas a su control.
|
||||||
|
const sinLabel = await pagina.$$eval("#cr-tok, #cr-loc", (els) =>
|
||||||
|
els.filter((el) => !document.querySelector(`label[for="${el.id}"]`)).map((el) => el.id)
|
||||||
|
);
|
||||||
|
check("cada campo tiene su etiqueta asociada", sinLabel.length === 0, sinLabel.join(", "));
|
||||||
|
|
||||||
|
check("sin errores de consola", erroresConsola.length === 0, erroresConsola.slice(0, 2).join(" | "));
|
||||||
|
|
||||||
|
await pagina.screenshot({ path: "screenshots/admin-cuentas.png", fullPage: true });
|
||||||
|
console.log("\n captura en screenshots/admin-cuentas.png");
|
||||||
|
} finally {
|
||||||
|
await navegador.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${fallos === 0 ? "Todo en verde" : `${fallos} comprobaciones fallaron`}`);
|
||||||
|
process.exit(fallos === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Conecta el negocio de la plataforma con su subcuenta de Bucéfalo CRM y
|
||||||
|
* autodetecta pipeline y etapas.
|
||||||
|
*
|
||||||
|
* node scripts/run-tsx.mjs platform/scripts/crm-conectar.ts [slug]
|
||||||
|
*/
|
||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { loadEnv, requireEnv } from "../lib/env.ts";
|
||||||
|
import { autoconfigurar } from "../crm/connection.ts";
|
||||||
|
import { ctxDe, ctxDesdeEnv, guardarCredencial } from "../crm/ctx.ts";
|
||||||
|
|
||||||
|
loadEnv();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const slug = process.argv[2] || "yola-franco";
|
||||||
|
const locationId = requireEnv("CRM_LOCATION_ID");
|
||||||
|
|
||||||
|
const b = await pool.query<{ id: number; name: string }>(
|
||||||
|
`SELECT id, name FROM businesses WHERE slug = $1`,
|
||||||
|
[slug]
|
||||||
|
);
|
||||||
|
if (!b.rows[0]) {
|
||||||
|
console.error(`No existe el negocio con slug «${slug}»`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// El script vincula la subcuenta configurada en el entorno: guarda la
|
||||||
|
// credencial cifrada y después autodetecta pipeline y etapas. Antes solo
|
||||||
|
// hacía lo segundo, porque el token era global.
|
||||||
|
await guardarCredencial(b.rows[0].id, locationId, ctxDesdeEnv().token);
|
||||||
|
const c = await autoconfigurar(await ctxDe(b.rows[0].id));
|
||||||
|
console.log(`Conectado «${b.rows[0].name}» ↔ subcuenta ${c.location_id}`);
|
||||||
|
console.log(` pipeline : ${c.pipeline_id}`);
|
||||||
|
console.log(` etapa «en espera» : ${c.stage_open_id}`);
|
||||||
|
console.log(` etapa «ganado» : ${c.stage_won_id}`);
|
||||||
|
console.log(` etapa «perdido» : ${c.stage_lost_id}`);
|
||||||
|
console.log(` permite duplicados : ${c.allow_duplicate_opp}`);
|
||||||
|
console.log(` nota : ${c.last_sync_status}`);
|
||||||
|
if (!c.allow_duplicate_opp) {
|
||||||
|
console.log(
|
||||||
|
`\n >> Con «allow duplicate opportunity» desactivado, cada clienta tiene UNA\n` +
|
||||||
|
` oportunidad que se recicla en cada cita. Para que cada cita estrene la\n` +
|
||||||
|
` suya, hay que activar ese ajuste en la UI del CRM.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user