Compare commits
20 Commits
main
...
feature/pl
| Author | SHA1 | Date | |
|---|---|---|---|
| c4097bc737 | |||
| ba6b4fef4f | |||
| a31b411437 | |||
| b4c5d2af4f | |||
| 69f5860b7f | |||
| 5d078f3932 | |||
| 237a5427dd | |||
| 80778c0ed9 | |||
| 8522d02170 | |||
| cbcea72848 | |||
| 25876e733b | |||
| 93ac1db5f1 | |||
| c9b4b4871d | |||
| 9c5219863b | |||
| ce3d01a180 | |||
| 57dbd5b1fa | |||
| 44d1adecdc | |||
| 09610df995 | |||
| 922da441eb | |||
| f7962f894d |
58
.env.development
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
# Archivo de variables de entorno para docker-compose.yml
|
||||||
|
COMPOSE_PROJECT_NAME=suitecoffee_dev
|
||||||
|
|
||||||
|
# =======================================================
|
||||||
|
# Runtime
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
MANSO_PORT=1010 # MVP Manso Microservicio -> services/manso/src/index.mjs
|
||||||
|
APP_PORT=3030 # Microservicio APP-> services/app/src/index.mjs
|
||||||
|
AUTH_PORT=4040 # Microservicio AUTH -> services/auth/src/index.mjs
|
||||||
|
PLUGINS_PORT=5050 # Microservicio PLUGINS-> services/plugins/src/index.mjs
|
||||||
|
# =======================================================
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# =======================================================
|
||||||
|
# Configuración de Dases de Datos
|
||||||
|
CORE_DB_HOST=dev-db
|
||||||
|
CORE_DB_NAME=dev_suitecoffee_core
|
||||||
|
CORE_DB_PORT=5432
|
||||||
|
CORE_DB_USER=dev-user-suitecoffee
|
||||||
|
CORE_DB_PASS=dev-pass-suitecoffee
|
||||||
|
|
||||||
|
TENANTS_DB_HOST=dev-tenants
|
||||||
|
TENANTS_DB_NAME=dev_suitecoffee_tenants
|
||||||
|
TENANTS_DB_PORT=5432
|
||||||
|
TENANTS_DB_USER=suitecoffee
|
||||||
|
TENANTS_DB_PASS=suitecoffee
|
||||||
|
|
||||||
|
AK_HOST_DB=ak-db
|
||||||
|
AK_PG_DB=authentik
|
||||||
|
AK_PG_USER=authentik
|
||||||
|
AK_PG_PASS=gOWjL8V564vyh1aXUcqh4o/xo7eObraaCVZezPi3iw2LzPlU
|
||||||
|
# =======================================================
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# =======================================================
|
||||||
|
# Authentik
|
||||||
|
# Authentik Cookies
|
||||||
|
AUTHENTIK_COOKIE__DOMAIN=dev.sso.suitecoffee.uy
|
||||||
|
AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS=https://dev.sso.suitecoffee.uy,https://dev.suitecoffee.uy
|
||||||
|
|
||||||
|
# Authentik Security
|
||||||
|
AUTHENTIK_SECRET_KEY=11zMsUL57beO+okjeGh7OB3lQdGUWII+VaATHs/zsw1+6KMSTyGfAY0yHpq3C442+3CwrZ/KtjgHBfbv
|
||||||
|
|
||||||
|
# Authentik Bootstrap
|
||||||
|
AUTHENTIK_BOOTSTRAP_PASSWORD=info.suitecoffee@gmail.com
|
||||||
|
AUTHENTIK_BOOTSTRAP_EMAIL=info.suitecoffee@gmail.com
|
||||||
|
|
||||||
|
AUTHENTIK_EMAIL__HOST=smtp.gmail.com
|
||||||
|
AUTHENTIK_EMAIL__PORT=25
|
||||||
|
AUTHENTIK_EMAIL__USERNAME=info.suitecoffee@gmail.com
|
||||||
|
AUTHENTIK_EMAIL__PASSWORD=Succulent-Sanded7
|
||||||
|
AUTHENTIK_EMAIL__USE_TLS=true # Or false if not using TLS
|
||||||
|
AUTHENTIK_EMAIL__USE_SSL=false # Or true if using SSL directly
|
||||||
|
AUTHENTIK_EMAIL__FROM=info.suitecoffee@gmail.com
|
||||||
|
# =======================================================
|
||||||
4
.gitignore
vendored
@ -1,7 +1,7 @@
|
|||||||
# Ignorar los directorios de dependencias
|
# Ignorar los directorios de dependencias
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
# Ignorar los volumenes respaldados
|
# Ignorar los volumenes respaldados de docker compose
|
||||||
docker-volumes*
|
docker-volumes*
|
||||||
|
|
||||||
# Ignorar las carpetas de bases de datos
|
# Ignorar las carpetas de bases de datos
|
||||||
@ -33,6 +33,6 @@ tests/
|
|||||||
.gitmodules
|
.gitmodules
|
||||||
|
|
||||||
# Ignorar archivos personales o privados (si existen)
|
# Ignorar archivos personales o privados (si existen)
|
||||||
.env.*
|
# .env.*
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
303
README.md
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
# SuiteCoffee — Sistema de gestión para cafeterías (Dockerizado y multi‑servicio)
|
||||||
|
|
||||||
|
SuiteCoffee es un sistema modular pensado para la **gestión de cafeterías** (y negocios afines), con servicios Node.js para **aplicación** y Authentik **autenticación**, bases de datos **PostgreSQL** separadas para negocio y multi‑tenencia, y un **stack Docker Compose** que facilita levantar entornos de **desarrollo** y **producción**. Incluye herramientas auxiliares como **Nginx Proxy Manager (NPM)** y **CloudBeaver** para administrar bases de datos desde el navegador.
|
||||||
|
|
||||||
|
> Repositorio: https://gitea.mateosaldain.uy/msaldain/SuiteCoffee.git
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tabla de contenidos
|
||||||
|
|
||||||
|
- [Arquitectura](#arquitectura)
|
||||||
|
- [Características principales](#características-principales)
|
||||||
|
- [Requisitos](#requisitos)
|
||||||
|
- [Inicio rápido](#inicio-rápido)
|
||||||
|
- [Variables de entorno](#variables-de-entorno)
|
||||||
|
- [Endpoints](#endpoints)
|
||||||
|
- [Estructura del proyecto](#estructura-del-proyecto)
|
||||||
|
- [Herramientas auxiliares (NPM y CloudBeaver)](#herramientas-auxiliares-npm-y-cloudbeaver)
|
||||||
|
- [Backups y restauración de volúmenes](#backups-y-restauración-de-volúmenes)
|
||||||
|
- [Comandos útiles](#comandos-útiles)
|
||||||
|
- [Licencia](#licencia)
|
||||||
|
- [Sugerencias de mejora](#sugerencias-de-mejora)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
**Servicios principales**
|
||||||
|
|
||||||
|
- **app** (Node.js / Express): API de negocio y páginas simples para cargar y listar *roles, usuarios, categorías y productos*.
|
||||||
|
- **auth** (Node.js / Express + bcrypt): endpoints de **registro** e **inicio de sesión**.
|
||||||
|
- **db** (PostgreSQL 16): base de datos de la aplicación.
|
||||||
|
- **tenants** (PostgreSQL 16): base de datos separada para **multi-tenencia** (aislar clientes/tiendas).
|
||||||
|
|
||||||
|
**Herramientas**
|
||||||
|
|
||||||
|
- **Nginx Proxy Manager (NPM)**: reverse proxy y certificados (Let’s Encrypt) para exponer servicios.
|
||||||
|
- **CloudBeaver (DBeaver)**: administración de PostgreSQL vía web.
|
||||||
|
|
||||||
|
**Redes & Volúmenes**
|
||||||
|
|
||||||
|
- Redes independientes por entorno (`suitecoffee_dev_net` / `suitecoffee_prod_net`).
|
||||||
|
- Volúmenes gestionados por Compose para persistencia: `suitecoffee-db`, `tenants-db`, etc.
|
||||||
|
|
||||||
|
### Diagrama (alto nivel)
|
||||||
|
|
||||||
|
```plantuml
|
||||||
|
@startuml
|
||||||
|
skinparam componentStyle rectangle
|
||||||
|
skinparam rectangle {
|
||||||
|
BorderColor #555
|
||||||
|
RoundCorner 10
|
||||||
|
}
|
||||||
|
actor Usuario
|
||||||
|
|
||||||
|
package "Entorno DEV/PROD" {
|
||||||
|
[app (Express)] as APP
|
||||||
|
[auth (Express + bcrypt)] as AUTH
|
||||||
|
database "db (PostgreSQL)" as DB
|
||||||
|
database "tenants (PostgreSQL)" as TENANTS
|
||||||
|
APP -down-> DB : Pool PG
|
||||||
|
APP -down-> TENANTS : Pool PG
|
||||||
|
AUTH -down-> DB : Pool PG (usuarios)
|
||||||
|
Usuario --> APP : UI / API
|
||||||
|
Usuario --> AUTH : Login/Registro
|
||||||
|
}
|
||||||
|
|
||||||
|
package "Herramientas" {
|
||||||
|
[Nginx Proxy Manager] as NPM
|
||||||
|
[CloudBeaver] as DBVR
|
||||||
|
NPM ..> APP : proxy
|
||||||
|
NPM ..> AUTH : proxy
|
||||||
|
DBVR ..> DB : admin
|
||||||
|
DBVR ..> TENANTS : admin
|
||||||
|
}
|
||||||
|
@enduml
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Características principales
|
||||||
|
|
||||||
|
- **API REST** para entidades clave (roles, usuarios, categorías y productos).
|
||||||
|
- **Autenticación básica** (registro y login) con **hash de contraseñas** (bcrypt).
|
||||||
|
- **Multi‑tenencia** con base `tenants` separada para aislar clientes/tiendas.
|
||||||
|
- **Docker Compose v2** con entornos de **desarrollo** y **producción**.
|
||||||
|
- **Herramientas integradas** (NPM + CloudBeaver) en un `compose.tools.yaml` aparte.
|
||||||
|
- **Scripts** de **backup/restauración de volúmenes** y **gestión de entornos**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requisitos
|
||||||
|
|
||||||
|
- **Docker** y **Docker Compose v2** (recomendado).
|
||||||
|
- **Python 3.9+** (para scripts `suitecoffee.py`, backups y utilidades).
|
||||||
|
- **Node.js 20+** (sólo si vas a ejecutar servicios Node fuera de Docker).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inicio rápido
|
||||||
|
|
||||||
|
### Opción A — Gestor interactivo (recomendado)
|
||||||
|
|
||||||
|
1. Clona el repo y entra al directorio:
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.mateosaldain.uy/msaldain/SuiteCoffee.git
|
||||||
|
cd SuiteCoffee
|
||||||
|
```
|
||||||
|
2. (Opcional) Crea/copía tus archivos `.env` para **app** y **auth** en `./services/<service>/.env.development` (ver sección de variables).
|
||||||
|
3. Ejecuta el gestor:
|
||||||
|
```bash
|
||||||
|
python3 suitecoffee.py
|
||||||
|
```
|
||||||
|
- Verás un **menú** para levantar **DESARROLLO** o **PRODUCCIÓN**.
|
||||||
|
- Desde ahí también puedes **levantar/apagar** las herramientas **NPM** y **CloudBeaver**.
|
||||||
|
4. Accede:
|
||||||
|
- App (dev): suele estar disponible via NPM o directamente dentro de la red, según tu configuración.
|
||||||
|
- Páginas simples: `/roles`, `/usuarios`, `/categorias`, `/productos` (servidas por `app`).
|
||||||
|
- Salud: `/health` en `app` y `auth`.
|
||||||
|
|
||||||
|
> Consejo: primero levanta **desarrollo/producción** y luego las **herramientas** para que existan las redes externas `suitecoffee_dev_net`/`suitecoffee_prod_net` que usa `compose.tools.yaml`.
|
||||||
|
|
||||||
|
### Opción B — Comandos Docker Compose (avanzado)
|
||||||
|
|
||||||
|
- **Desarrollo**:
|
||||||
|
```bash
|
||||||
|
docker compose -f compose.yaml -f compose.dev.yaml --env-file ./services/app/.env.development --env-file ./services/auth/.env.development -p suitecoffee_dev up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Producción**:
|
||||||
|
```bash
|
||||||
|
docker compose -f compose.yaml -f compose.prod.yaml --env-file ./services/app/.env.production --env-file ./services/auth/.env.production -p suitecoffee_prod up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
> Los puertos se **exponen** para herramientas (NPM UI `:81`, CloudBeaver `:8978`); los servicios `app` y `auth` se **exponen dentro de la red** y se publican externamente a través de NPM.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Variables de entorno
|
||||||
|
|
||||||
|
Crea un archivo `.env.development` (y uno `.env.production`) en **cada servicio** (`./services/app` y `./services/auth`). Variables comunes:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
# Servidor
|
||||||
|
PORT=4000 # puerto HTTP del servicio
|
||||||
|
NODE_ENV=development # development | production
|
||||||
|
|
||||||
|
# Base de datos
|
||||||
|
DB_HOST=db # nombre del servicio postgres (o host)
|
||||||
|
DB_LOCAL_PORT=5432 # puerto de PG al que conectarse
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASS=postgres
|
||||||
|
DB_NAME=suitecoffee_db # para 'db' (aplicación)
|
||||||
|
TENANTS_DB_NAME=tenants_db # si el servicio necesita apuntar a 'tenants'
|
||||||
|
```
|
||||||
|
|
||||||
|
> Ajusta `DB_HOST` a `db` o `tenants` según corresponda. En desarrollo, los alias útiles son `dev-db` y `dev-tenants`; en producción: `prod-db` y `prod-tenants`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### Servicio **app** (negocio)
|
||||||
|
|
||||||
|
- `GET /health`
|
||||||
|
- `GET /api/roles` — lista roles
|
||||||
|
- `POST /api/roles` — crea un rol
|
||||||
|
- `GET /api/usuarios` — lista usuarios
|
||||||
|
- `POST /api/usuarios` — crea un usuario
|
||||||
|
- `GET /api/categorias` — lista categorías
|
||||||
|
- `POST /api/categorias` — crea una categoría
|
||||||
|
- `GET /api/productos` — lista productos
|
||||||
|
- `POST /api/productos` — crea un producto
|
||||||
|
- Páginas estáticas simples para probar: `/roles`, `/usuarios`, `/categorias`, `/productos`
|
||||||
|
|
||||||
|
### Servicio **auth** (autenticación)
|
||||||
|
|
||||||
|
- `GET /health`
|
||||||
|
- `POST /register` — registro de usuario (password con **bcrypt**)
|
||||||
|
- `POST /auth/login` — inicio de sesión
|
||||||
|
|
||||||
|
> **Nota**: En esta etapa los endpoints son **básicos** y pensados para desarrollo/PoC. Ver la sección *Sugerencias de mejora* para próximos pasos (JWT, autorización, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estructura del proyecto
|
||||||
|
|
||||||
|
```
|
||||||
|
SuiteCoffee/
|
||||||
|
├─ services/
|
||||||
|
│ ├─ app/
|
||||||
|
│ │ ├─ src/
|
||||||
|
│ │ │ ├─ index.js # API y páginas simples
|
||||||
|
│ │ │ └─ pages/ # roles.html, usuarios.html, categorias.html, productos.html
|
||||||
|
│ │ ├─ .env.development # variables (ejemplo)
|
||||||
|
│ │ └─ .env.production
|
||||||
|
│ └─ auth/
|
||||||
|
│ ├─ src/
|
||||||
|
│ │ └─ index.js # /register y /auth/login
|
||||||
|
│ ├─ .env.development
|
||||||
|
│ └─ .env.production
|
||||||
|
├─ compose.yaml # base (db, tenants)
|
||||||
|
├─ compose.dev.yaml # entorno desarrollo (app, auth, db, tenants)
|
||||||
|
├─ compose.prod.yaml # entorno producción (app, auth, db, tenants)
|
||||||
|
├─ compose.tools.yaml # herramientas (NPM, CloudBeaver) con redes externas
|
||||||
|
├─ suitecoffee.py # gestor interactivo (Docker Compose)
|
||||||
|
├─ backup_compose_volumes.py # backups de volúmenes Compose
|
||||||
|
└─ restore_compose_volumes.py# restauración de volúmenes Compose
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Herramientas auxiliares (NPM y CloudBeaver)
|
||||||
|
|
||||||
|
Los servicios de **herramientas** están separados para poder usarlos con **ambos entornos** (dev y prod) a la vez. Se levantan con `compose.tools.yaml` y se conectan a las **redes externas** `suitecoffee_dev_net` y `suitecoffee_prod_net`.
|
||||||
|
|
||||||
|
- **Nginx Proxy Manager (NPM)**
|
||||||
|
Puertos: `80` (HTTP), `81` (UI). Volúmenes: `npm_data`, `npm_letsencrypt`.
|
||||||
|
- **CloudBeaver**
|
||||||
|
Puerto: `8978`. Volúmenes: `dbeaver_logs`, `dbeaver_workspace`.
|
||||||
|
|
||||||
|
> Si es la primera vez, arranca un entorno (dev/prod) para que Compose cree las redes; luego levanta las herramientas:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> docker compose -f compose.tools.yaml --profile npm -p suitecoffee up -d
|
||||||
|
> docker compose -f compose.tools.yaml --profile dbeaver -p suitecoffee up -d
|
||||||
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backups y restauración de volúmenes
|
||||||
|
|
||||||
|
Este repo incluye dos utilidades:
|
||||||
|
|
||||||
|
- `backup_compose_volumes.py` — detecta volúmenes de un proyecto de Compose (por **labels** y nombres) y los exporta a `tar.gz` usando un contenedor `alpine` temporal.
|
||||||
|
- `restore_compose_volumes.py` — permite restaurar esos `tar.gz` en volúmenes (útil para migraciones y pruebas).
|
||||||
|
|
||||||
|
**Ejemplos básicos**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Listar ayuda
|
||||||
|
python3 backup_compose_volumes.py --help
|
||||||
|
|
||||||
|
# Respaldar volúmenes asociados a "suitecoffee_dev" en ./backups
|
||||||
|
python3 backup_compose_volumes.py --project suitecoffee_dev --output ./backups
|
||||||
|
|
||||||
|
# Restaurar un archivo a un volumen
|
||||||
|
python3 restore_compose_volumes.py --archive ./backups/suitecoffee_dev_suitecoffee-db-YYYYmmddHHMMSS.tar.gz --volume suitecoffee_dev_suitecoffee-db
|
||||||
|
```
|
||||||
|
|
||||||
|
> Consejo: si migraste manualmente y ves advertencias tipo “volume ... already exists but was not created by Docker Compose”, considera marcar el volumen como `external: true` en el YAML o recrearlo para que Compose lo etiquete correctamente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comandos útiles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ver estado (menú interactivo)
|
||||||
|
python3 suitecoffee.py
|
||||||
|
|
||||||
|
# Levantar DEV/PROD por menú (con o sin --force-recreate)
|
||||||
|
python3 suitecoffee.py
|
||||||
|
|
||||||
|
# Levantar herramientas (también desde menú)
|
||||||
|
docker compose -f compose.tools.yaml --profile npm -p suitecoffee up -d
|
||||||
|
docker compose -f compose.tools.yaml --profile dbeaver -p suitecoffee up -d
|
||||||
|
|
||||||
|
# Inspeccionar servicios/volúmenes que Compose detecta desde los YAML
|
||||||
|
docker compose -f compose.yaml -f compose.dev.yaml config --services
|
||||||
|
docker compose -f compose.yaml -f compose.dev.yaml config --format json | jq .volumes
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Licencia
|
||||||
|
|
||||||
|
- **ISC** (ver `package.json`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sugerencias de mejora
|
||||||
|
|
||||||
|
- **Autenticación y seguridad**
|
||||||
|
- Emitir **JWT** en el login y proteger rutas (roles/autorización por perfil).
|
||||||
|
- Configurar **CORS** por orígenes (en dev está abierto; en prod restringir).
|
||||||
|
- Añadir **rate‑limit** y **helmet** en Express.
|
||||||
|
- **Esquema de datos y migraciones**
|
||||||
|
- Añadir migraciones automatizadas (p.ej. **Prisma**, **Knex**, **Sequelize** o SQL versionado) y seeds iniciales.
|
||||||
|
- Clarificar el **modelo multi‑tenant**: por **BD por tenant** o **schema por tenant**; documentar estrategia.
|
||||||
|
- **Calidad & DX**
|
||||||
|
- Tests (unitarios e integración) y **CI** básico.
|
||||||
|
- Validación de entrada (**zod / joi**), manejo de errores consistente y logs estructurados.
|
||||||
|
- **Docker/DevOps**
|
||||||
|
- Documentar variables `.env` completas por servicio.
|
||||||
|
- Publicar imágenes de producción y usar `IMAGE:TAG` en `compose.prod.yaml` (evitar build en servidor).
|
||||||
|
- Añadir **healthchecks** a `app`/`auth` (ya hay ejemplos comentados).
|
||||||
|
- **Frontend**
|
||||||
|
- Reemplazar páginas HTML de prueba por un **frontend** (React/Vite) o una UI admin mínima.
|
||||||
|
- **Pequeños fixes**
|
||||||
|
- En los HTML de ejemplo corregir las referencias a campos `id_rol`, `id_categoria`, etc.
|
||||||
|
- Centralizar constantes (nombres de tablas/campos) y normalizar respuestas API.
|
||||||
|
|
||||||
|
---
|
||||||
1015
auth.index.js
Normal file
BIN
authentik-media/public/application-icons/SuiteCoffee.png
Normal file
|
After Width: | Height: | Size: 1005 KiB |
BIN
authentik-media/public/application-icons/SuiteCoffee_TB_1.png
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
authentik-media/public/application-icons/SuiteCoffee_TM_1.png
Normal file
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 717 KiB |
|
After Width: | Height: | Size: 717 KiB |
BIN
authentik-media/suitecoffee-logo/SuiteCoffee.png
Normal file
|
After Width: | Height: | Size: 1005 KiB |
BIN
authentik-media/suitecoffee-logo/SuiteCoffee_BM_1.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
13
authentik-media/suitecoffee-logo/SuiteCoffee_BN_1.svg
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Capa_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
|
||||||
|
<!-- Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9) -->
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.st0 {
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<path class="st0" d="M1024,0v1024H0V0h1024ZM555.65,53.34c-9.62.5-42.47,33.75-50.29,42.27-52.83,57.58-92.54,133.71-99.27,212.63-9.61,112.64,65.25,175.4,107.41,269.2,52.92,117.75,31.19,241.15-37.67,346.37-5.24,8.01-19.02,22.41-21.61,30.02-2.38,7.01,4.2,10.95,10.05,6.97,98.88-80.26,173.94-198.57,145.59-331.12-19.98-93.4-85.71-170.1-121.65-256.47-40.46-97.24-10.37-194.22,47.61-276.58,5.77-8.2,22.16-24.87,25.06-32.31.97-2.5,1.81-4.69.97-7.43-.72-2.16-3.99-3.67-6.19-3.56Z"/>
|
||||||
|
<path d="M555.65,53.34c2.2-.12,5.46,1.4,6.19,3.56.85,2.74,0,4.92-.97,7.43-2.89,7.44-19.28,24.11-25.06,32.31-57.98,82.36-88.07,179.34-47.61,276.58,35.94,86.37,101.67,163.07,121.65,256.47,28.35,132.55-46.71,250.87-145.59,331.12-5.85,3.99-12.43.04-10.05-6.97,2.59-7.61,16.36-22.01,21.61-30.02,68.86-105.22,90.59-228.62,37.67-346.37-42.16-93.81-117.02-156.57-107.41-269.2,6.74-78.93,46.45-155.06,99.27-212.63,7.81-8.51,40.66-41.77,50.29-42.27Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
BIN
authentik-media/suitecoffee-logo/SuiteCoffee_TB_1.png
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
authentik-media/suitecoffee-logo/SuiteCoffee_TM_1.png
Normal file
|
After Width: | Height: | Size: 199 KiB |
BIN
authentik-media/suitecoffee-logo/SuiteCoffee_TN_1.png
Normal file
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 528 KiB |
BIN
authentik-media/suitecoffee-logo/favicon_io/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
authentik-media/suitecoffee-logo/favicon_io/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 488 B |
BIN
authentik-media/suitecoffee-logo/favicon_io/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
authentik-media/suitecoffee-logo/favicon_io/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
@ -0,0 +1 @@
|
|||||||
|
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||||
2
backup.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
|
||||||
140
compose.dev.yaml
@ -1,58 +1,81 @@
|
|||||||
# docker-compose.overrride.yml
|
# compose.dev.yaml
|
||||||
# Docker Comose para entorno de desarrollo o development.
|
# Docker Compose para entorno de desarrollo.
|
||||||
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: node:20-bookworm
|
image: node:20.19.5-bookworm
|
||||||
expose:
|
|
||||||
- ${APP_LOCAL_PORT}
|
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
user: "${UID:-1000}:${GID:-1000}"
|
user: "${UID:-1000}:${GID:-1000}"
|
||||||
volumes:
|
volumes:
|
||||||
- ./services/app:/app:rw
|
- ./services/app:/app:rw
|
||||||
- ./services/app/node_modules:/app/node_modules
|
- ./services/app/node_modules:/app/node_modules
|
||||||
|
- ./packages:/packages
|
||||||
env_file:
|
env_file:
|
||||||
- ./services/app/.env.development
|
- ./services/app/.env.development
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=${NODE_ENV}
|
NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development
|
||||||
|
NODE_OPTIONS: --preserve-symlinks # la resolución por symlinks (y que @suitecoffee/db encuentre pg instalado en services/app/node_modules
|
||||||
|
expose:
|
||||||
|
- ${APP_PORT}
|
||||||
networks:
|
networks:
|
||||||
net:
|
net:
|
||||||
aliases: [dev-app]
|
aliases: [dev-app]
|
||||||
command: npm run dev
|
command: npm run dev
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
image: node:20.19.5-bookworm
|
||||||
|
working_dir: /app
|
||||||
|
user: "${UID:-1000}:${GID:-1000}"
|
||||||
|
volumes:
|
||||||
|
- ./services/plugins:/app:rw
|
||||||
|
- ./services/plugins/node_modules:/app/node_modules
|
||||||
|
- ./packages:/packages
|
||||||
|
env_file:
|
||||||
|
- ./services/plugins/.env.development
|
||||||
|
environment:
|
||||||
|
NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development
|
||||||
|
NODE_OPTIONS: --preserve-symlinks
|
||||||
|
expose:
|
||||||
|
- ${PLUGINS_PORT}
|
||||||
|
networks:
|
||||||
|
net:
|
||||||
|
aliases: [dev-plugins]
|
||||||
|
command: npm run dev
|
||||||
|
|
||||||
auth:
|
auth:
|
||||||
image: node:20-bookworm
|
image: node:20.19.5-bookworm
|
||||||
expose:
|
|
||||||
- ${AUTH_LOCAL_PORT}
|
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
user: "${UID:-1000}:${GID:-1000}"
|
user: "${UID:-1000}:${GID:-1000}"
|
||||||
volumes:
|
volumes:
|
||||||
- ./services/auth:/app:rw
|
- ./services/auth:/app:rw
|
||||||
- ./services/auth/node_modules:/app/node_modules
|
- ./services/auth/node_modules:/app/node_modules
|
||||||
|
- ./packages:/packages
|
||||||
env_file:
|
env_file:
|
||||||
- ./services/auth/.env.development
|
- ./services/auth/.env.development
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=${NODE_ENV}
|
NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development
|
||||||
command: npm run dev
|
NODE_OPTIONS: --preserve-symlinks
|
||||||
|
expose:
|
||||||
|
- ${AUTH_PORT}
|
||||||
networks:
|
networks:
|
||||||
net:
|
net:
|
||||||
aliases: [dev-auth]
|
aliases: [dev-auth]
|
||||||
|
command: npm run dev
|
||||||
|
|
||||||
db:
|
dbCore:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${DB_NAME}
|
POSTGRES_DB: ${CORE_DB_NAME}
|
||||||
POSTGRES_USER: ${DB_USER}
|
POSTGRES_USER: ${CORE_DB_USER}
|
||||||
POSTGRES_PASSWORD: ${DB_PASS}
|
POSTGRES_PASSWORD: ${CORE_DB_PASS}
|
||||||
volumes:
|
volumes:
|
||||||
- suitecoffee-db:/var/lib/postgresql/data
|
- suitecoffee-db:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
net:
|
net:
|
||||||
aliases: [dev-db]
|
aliases: [dev-db]
|
||||||
|
|
||||||
tenants:
|
dbTenants:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${TENANTS_DB_NAME}
|
POSTGRES_DB: ${TENANTS_DB_NAME}
|
||||||
@ -63,11 +86,90 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
net:
|
net:
|
||||||
aliases: [dev-tenants]
|
aliases: [dev-tenants]
|
||||||
|
|
||||||
|
#################
|
||||||
|
### Authentik ###
|
||||||
|
#################
|
||||||
|
|
||||||
|
ak-db:
|
||||||
|
image: docker.io/library/postgres:16-alpine
|
||||||
|
env_file:
|
||||||
|
- .env.development
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${AK_PG_DB:-authentik}
|
||||||
|
POSTGRES_PASSWORD: ${AK_PG_PASS:?database password required}
|
||||||
|
POSTGRES_USER: ${AK_PG_USER:-authentik}
|
||||||
|
volumes:
|
||||||
|
- authentik-db:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
net:
|
||||||
|
aliases: [dev-ak-db]
|
||||||
|
|
||||||
|
ak-redis:
|
||||||
|
image: docker.io/library/redis:alpine
|
||||||
|
command: --save 60 1 --loglevel warning
|
||||||
|
networks:
|
||||||
|
net:
|
||||||
|
aliases: [dev-ak-redis]
|
||||||
|
volumes:
|
||||||
|
- ak-redis:/data
|
||||||
|
|
||||||
|
ak:
|
||||||
|
image: ghcr.io/goauthentik/server:latest
|
||||||
|
env_file:
|
||||||
|
- .env.development
|
||||||
|
command: server
|
||||||
|
environment:
|
||||||
|
AUTHENTIK_DEBUG: false
|
||||||
|
AUTHENTIK_POSTGRESQL__HOST: ak-db
|
||||||
|
AUTHENTIK_POSTGRESQL__NAME: ${AK_PG_DB:-authentik}
|
||||||
|
AUTHENTIK_POSTGRESQL__PASSWORD: ${AK_PG_PASS}
|
||||||
|
AUTHENTIK_POSTGRESQL__USER: ${AK_PG_USER:-authentik}
|
||||||
|
AUTHENTIK_REDIS__HOST: ak-redis
|
||||||
|
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
|
||||||
|
|
||||||
|
AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD}
|
||||||
|
AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL}
|
||||||
|
AUTHENTIK_HTTP__TRUSTED_PROXY__CIDRS: "0.0.0.0/0,::/0"
|
||||||
|
AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS: ${AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS}
|
||||||
|
AUTHENTIK_COOKIE__DOMAIN: ${AUTHENTIK_COOKIE__DOMAIN}
|
||||||
|
networks:
|
||||||
|
net:
|
||||||
|
aliases: [dev-authentik]
|
||||||
|
volumes:
|
||||||
|
- ./authentik-media:/media
|
||||||
|
- ./authentik-custom-templates:/templates
|
||||||
|
|
||||||
|
ak-worker:
|
||||||
|
image: ghcr.io/goauthentik/server:latest
|
||||||
|
command: worker
|
||||||
|
environment:
|
||||||
|
AUTHENTIK_POSTGRESQL__HOST: ak-db
|
||||||
|
AUTHENTIK_POSTGRESQL__NAME: ${AK_PG_DB:-authentik}
|
||||||
|
AUTHENTIK_POSTGRESQL__PASSWORD: ${AK_PG_PASS}
|
||||||
|
AUTHENTIK_POSTGRESQL__USER: ${AK_PG_USER:-authentik}
|
||||||
|
AUTHENTIK_REDIS__HOST: ak-redis
|
||||||
|
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
|
||||||
|
|
||||||
|
AUTHENTIK_HTTP__TRUSTED_PROXY__CIDRS: "0.0.0.0/0,::/0"
|
||||||
|
AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS: ${AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS}
|
||||||
|
AUTHENTIK_COOKIE__DOMAIN: ${AUTHENTIK_COOKIE__DOMAIN}
|
||||||
|
networks:
|
||||||
|
net:
|
||||||
|
aliases: [dev-ak-work]
|
||||||
|
user: root
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./authentik-media:/media
|
||||||
|
- ./authentik-certs:/certs
|
||||||
|
- ./authentik-custom-templates:/templates
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
tenants-db:
|
tenants-db:
|
||||||
suitecoffee-db:
|
suitecoffee-db:
|
||||||
|
authentik-db:
|
||||||
|
ak-redis:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
net:
|
net:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
68
compose.manso.yaml
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
# compose.manso.yml
|
||||||
|
# Docker Comose para entorno de desarrollo o development.
|
||||||
|
|
||||||
|
|
||||||
|
services:
|
||||||
|
|
||||||
|
manso:
|
||||||
|
image: node:20-bookworm
|
||||||
|
# depends_on:
|
||||||
|
# db:
|
||||||
|
# condition: service_healthy
|
||||||
|
# tenants:
|
||||||
|
# condition: service_healthy
|
||||||
|
expose:
|
||||||
|
- ${MANSO_PORT}
|
||||||
|
working_dir: /app
|
||||||
|
user: "${UID:-1000}:${GID:-1000}"
|
||||||
|
volumes:
|
||||||
|
- ./services/manso:/app:rw
|
||||||
|
- ./services/manso/node_modules:/app/node_modules
|
||||||
|
env_file:
|
||||||
|
- ./services/manso/.env.development
|
||||||
|
environment:
|
||||||
|
NODE_ENV: development
|
||||||
|
networks:
|
||||||
|
net:
|
||||||
|
aliases: [manso]
|
||||||
|
#healthcheck:
|
||||||
|
# test: ["CMD-SHELL", "curl -fsS http://localhost:${MANSO_PORT}/health || exit 1"]
|
||||||
|
# interval: 10s
|
||||||
|
# timeout: 3s
|
||||||
|
# retries: 10
|
||||||
|
# start_period: 20s
|
||||||
|
command: npm run dev
|
||||||
|
profiles: [manso]
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# db:
|
||||||
|
# image: postgres:16
|
||||||
|
# environment:
|
||||||
|
# POSTGRES_DB: ${DB_NAME}
|
||||||
|
# POSTGRES_USER: ${DB_USER}
|
||||||
|
# POSTGRES_PASSWORD: ${DB_PASS}
|
||||||
|
# volumes:
|
||||||
|
# - suitecoffee-db:/var/lib/postgresql/data
|
||||||
|
# networks:
|
||||||
|
# net:
|
||||||
|
# aliases: [dev-db]
|
||||||
|
|
||||||
|
# tenants:
|
||||||
|
# image: postgres:16
|
||||||
|
# environment:
|
||||||
|
# POSTGRES_DB: ${TENANTS_DB_NAME}
|
||||||
|
# POSTGRES_USER: ${TENANTS_DB_USER}
|
||||||
|
# POSTGRES_PASSWORD: ${TENANTS_DB_PASS}
|
||||||
|
# volumes:
|
||||||
|
# - tenants-db:/var/lib/postgresql/data
|
||||||
|
# networks:
|
||||||
|
# net:
|
||||||
|
# aliases: [dev-tenants]
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
tenants-db:
|
||||||
|
suitecoffee-db:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
net:
|
||||||
|
driver: bridge
|
||||||
@ -9,16 +9,33 @@ services:
|
|||||||
context: ./services/app
|
context: ./services/app
|
||||||
dockerfile: Dockerfile.production
|
dockerfile: Dockerfile.production
|
||||||
expose:
|
expose:
|
||||||
- ${APP_LOCAL_PORT}
|
- ${APP_PORT}
|
||||||
volumes:
|
volumes:
|
||||||
- ./services/app:/app
|
- ./services/app:/app
|
||||||
env_file:
|
env_file:
|
||||||
- ./services/app/.env.production
|
- ./services/app/.env.production
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=${NODE_ENV}
|
- NODE_ENV: production
|
||||||
networks:
|
networks:
|
||||||
net:
|
net:
|
||||||
aliases: [prod-app]
|
aliases: [app]
|
||||||
|
command: npm run start
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
build:
|
||||||
|
context: ./services/plugins
|
||||||
|
dockerfile: Dockerfile.production
|
||||||
|
expose:
|
||||||
|
- ${PLUGIN_PORT}
|
||||||
|
volumes:
|
||||||
|
- ./services/plugins:/app
|
||||||
|
env_file:
|
||||||
|
- ./services/plugins/.env.production
|
||||||
|
environment:
|
||||||
|
- NODE_ENV: production
|
||||||
|
networks:
|
||||||
|
net:
|
||||||
|
aliases: [plugins]
|
||||||
command: npm run start
|
command: npm run start
|
||||||
|
|
||||||
auth:
|
auth:
|
||||||
@ -26,46 +43,47 @@ services:
|
|||||||
context: ./services/auth
|
context: ./services/auth
|
||||||
dockerfile: Dockerfile.production
|
dockerfile: Dockerfile.production
|
||||||
expose:
|
expose:
|
||||||
- ${AUTH_LOCAL_PORT}
|
- ${AUTH_PORT}
|
||||||
volumes:
|
volumes:
|
||||||
- ./services/auth:/app
|
- ./services/auth:/app
|
||||||
env_file:
|
env_file:
|
||||||
- ./services/auth/.env.production
|
- ./services/auth/.env.production
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=${NODE_ENV}
|
- NODE_ENV: production
|
||||||
command: npm run start
|
|
||||||
networks:
|
networks:
|
||||||
net:
|
net:
|
||||||
aliases: [prod-auth]
|
aliases: [auth]
|
||||||
|
command: npm run start
|
||||||
|
|
||||||
db:
|
dbCore:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${DB_NAME}
|
POSTGRES_DB: ${DB_NAME}
|
||||||
POSTGRES_USER: ${DB_USER}
|
POSTGRES_USER: ${DB_USER}
|
||||||
POSTGRES_PASSWORD: ${DB_PASS}
|
POSTGRES_PASSWORD: ${DB_PASS}
|
||||||
volumes:
|
volumes:
|
||||||
- suitecoffee-db:/var/lib/postgresql/data
|
- dbCore_data:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
net:
|
net:
|
||||||
aliases: [prod-db]
|
aliases: [dbCore]
|
||||||
|
|
||||||
tenants:
|
dbTenants:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${TENANTS_DB_NAME}
|
POSTGRES_DB: ${TENANTS_DB_NAME}
|
||||||
POSTGRES_USER: ${TENANTS_DB_USER}
|
POSTGRES_USER: ${TENANTS_DB_USER}
|
||||||
POSTGRES_PASSWORD: ${TENANTS_DB_PASS}
|
POSTGRES_PASSWORD: ${TENANTS_DB_PASS}
|
||||||
volumes:
|
volumes:
|
||||||
- tenants-db:/var/lib/postgresql/data
|
- dbTenants_data:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
net:
|
net:
|
||||||
aliases: [prod-tenants]
|
aliases: [dbTenants]
|
||||||
|
|
||||||
|
falta implementar authentik en compose.prod.yaml
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
tenants-db:
|
dbCore_data:
|
||||||
suitecoffee-db:
|
dbTenants_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
net:
|
net:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
@ -1,4 +1,4 @@
|
|||||||
# compose.tools.yaml
|
# $ compose.tools.yaml
|
||||||
name: suitecoffee_tools
|
name: suitecoffee_tools
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@ -14,7 +14,7 @@ services:
|
|||||||
- dbeaver_logs:/opt/cloudbeaver/logs
|
- dbeaver_logs:/opt/cloudbeaver/logs
|
||||||
- dbeaver_workspace:/opt/cloudbeaver/workspace
|
- dbeaver_workspace:/opt/cloudbeaver/workspace
|
||||||
networks:
|
networks:
|
||||||
suitecoffee_prod_net: {}
|
# suitecoffee_prod_net: {}
|
||||||
suitecoffee_dev_net: {}
|
suitecoffee_dev_net: {}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -fsS http://localhost:8978 || exit 1"]
|
test: ["CMD-SHELL", "curl -fsS http://localhost:8978 || exit 1"]
|
||||||
@ -37,7 +37,7 @@ services:
|
|||||||
- npm_data:/data
|
- npm_data:/data
|
||||||
- npm_letsencrypt:/etc/letsencrypt
|
- npm_letsencrypt:/etc/letsencrypt
|
||||||
networks:
|
networks:
|
||||||
suitecoffee_prod_net: {}
|
# suitecoffee_prod_net: {}
|
||||||
suitecoffee_dev_net: {}
|
suitecoffee_dev_net: {}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -fsS http://localhost:81 || exit 1"]
|
test: ["CMD-SHELL", "curl -fsS http://localhost:81 || exit 1"]
|
||||||
@ -50,8 +50,8 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
suitecoffee_dev_net:
|
suitecoffee_dev_net:
|
||||||
external: true
|
external: true
|
||||||
suitecoffee_prod_net:
|
# suitecoffee_prod_net:
|
||||||
external: true
|
# external: true
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
npm_data:
|
npm_data:
|
||||||
|
|||||||
112
compose.yaml
@ -1,48 +1,63 @@
|
|||||||
# compose.yml
|
# compose.yml
|
||||||
# Comose base
|
# Compose base
|
||||||
name: ${COMPOSE_PROJECT_NAME:-suitecoffee}
|
name: ${COMPOSE_PROJECT_NAME:-suitecoffee}
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
app:
|
||||||
|
depends_on:
|
||||||
|
dbCore:
|
||||||
|
condition: service_healthy
|
||||||
|
dbTenants:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -fsS http://localhost:${APP_PORT}/health || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
start_period: 20s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
# app:
|
plugins:
|
||||||
# depends_on:
|
depends_on:
|
||||||
# db:
|
app:
|
||||||
# condition: service_healthy
|
condition: service_healthy
|
||||||
# tenants:
|
auth:
|
||||||
# condition: service_healthy
|
condition: service_healthy
|
||||||
# healthcheck:
|
healthcheck:
|
||||||
# test: ["CMD-SHELL", "curl -fsS http://localhost:${APP_DOCKER_PORT}/health || exit 1"]
|
test: ["CMD-SHELL", "curl -fsS http://localhost:${PLUGINS_PORT}/health || exit 1"]
|
||||||
# interval: 10s
|
interval: 10s
|
||||||
# timeout: 3s
|
timeout: 3s
|
||||||
# retries: 10
|
retries: 10
|
||||||
# start_period: 20s
|
start_period: 20s
|
||||||
# restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# auth:
|
auth:
|
||||||
# depends_on:
|
depends_on:
|
||||||
# db:
|
dbCore:
|
||||||
# condition: service_healthy
|
condition: service_healthy
|
||||||
# healthcheck:
|
ak:
|
||||||
# test: ["CMD-SHELL", "curl -fsS http://localhost:${AUTH_DOCKER_PORT}/health || exit 1"]
|
condition: service_healthy
|
||||||
# interval: 10s
|
healthcheck:
|
||||||
# timeout: 3s
|
test: ["CMD-SHELL", "curl -fsS http://localhost:${AUTH_PORT}/health || exit 1"]
|
||||||
# retries: 10
|
interval: 10s
|
||||||
# start_period: 15s
|
timeout: 3s
|
||||||
# restart: unless-stopped
|
retries: 10
|
||||||
|
start_period: 20s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
db:
|
dbCore:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
environment:
|
environment:
|
||||||
TZ: America/Montevideo
|
TZ: America/Montevideo
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
test: ["CMD-SHELL", "pg_isready -U ${CORE_DB_USER} -d ${CORE_DB_NAME}"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 20
|
retries: 20
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
tenants:
|
dbTenants:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
environment:
|
environment:
|
||||||
TZ: America/Montevideo
|
TZ: America/Montevideo
|
||||||
@ -52,4 +67,43 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 20
|
retries: 20
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
ak-db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -d ${AK_PG_DB} -U ${AK_PG_USER} || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
timeout: 5s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
ak-redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
ak:
|
||||||
|
image: ghcr.io/goauthentik/server:latest
|
||||||
|
depends_on:
|
||||||
|
ak-db:
|
||||||
|
condition: service_healthy
|
||||||
|
ak-redis:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
ak-worker:
|
||||||
|
image: ghcr.io/goauthentik/server:latest
|
||||||
|
depends_on:
|
||||||
|
ak-db:
|
||||||
|
condition: service_healthy
|
||||||
|
ak-redis:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
|||||||
316
docs/ak.md
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
# Documentación detallada de funciones: `ak.js`
|
||||||
|
|
||||||
|
Este documento fue generado automáticamente a partir del archivo `ak.js` proporcionado. Incluye una sección por función detectada, con firma, ubicación, descripción, parámetros, valores de retorno, posibles errores y un ejemplo de uso.
|
||||||
|
|
||||||
|
> **Nota:** Las descripciones y tipos se infieren heurísticamente a partir de los nombres, comentarios y cuerpo de cada función. Revise y ajuste donde corresponda.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `getConfig`
|
||||||
|
|
||||||
|
**Firma:** `function getConfig()`
|
||||||
|
**Ubicación:** línea 28
|
||||||
|
|
||||||
|
**Comentario previo en el código:**
|
||||||
|
```js
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Cliente para la API Admin de Authentik (v3)
|
||||||
|
// - Sin dependencias externas (usa fetch nativo de Node >=18)
|
||||||
|
// - ESM compatible
|
||||||
|
// - Timeouts, reintentos opcionales y mensajes de error claros
|
||||||
|
// - Compatible con services/auth/src/index.js actual
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
// Utiliza AUTHENTIK_BASE_URL y AUTHENTIK_TOKEN para validar y devuelve la configuración (base URL y token) desde variables de entorno.
|
||||||
|
// Devuelve la URL base y el Token que se leyó desde .env
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} AkCfg
|
||||||
|
* @property {string} BASE // p.ej. "https://idp.example.com"
|
||||||
|
* @property {string} TOKEN // bearer
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} AkOpts
|
||||||
|
* @property {Record<string, string|number|boolean|Array<string|number|boolean>>} [qs]
|
||||||
|
* @property {any} [body]
|
||||||
|
* @property {number} [timeoutMs=10000]
|
||||||
|
* @property {number} [retries=0]
|
||||||
|
* @property {Record<string,string>} [headers]
|
||||||
|
*/
|
||||||
|
```
|
||||||
|
**Descripción:** Obtiene Config.
|
||||||
|
|
||||||
|
**Parámetros:** *(sin parámetros)*
|
||||||
|
|
||||||
|
**Retorna (aprox.):** `{ BASE, TOKEN }`
|
||||||
|
|
||||||
|
**Errores/excepciones:**
|
||||||
|
- Puede lanzar `Error('[AK_CONFIG] Falta variable AUTHENTIK_BASE_URL')`.
|
||||||
|
- Puede lanzar `Error('[AK_CONFIG] Falta variable AUTHENTIK_TOKEN')`.
|
||||||
|
|
||||||
|
**Ejemplo de uso:**
|
||||||
|
```js
|
||||||
|
const result = getConfig();
|
||||||
|
console.log(result);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
### `akListGroups`
|
||||||
|
|
||||||
|
**Firma:** `export async function akListGroups(search = "")`
|
||||||
|
**Ubicación:** línea 60
|
||||||
|
|
||||||
|
**Comentario previo en el código:**
|
||||||
|
```js
|
||||||
|
// Listar grupos con búsqueda por nombre/slug
|
||||||
|
```
|
||||||
|
**Descripción:** Función `akListGroups`. Interactúa con una API HTTP.
|
||||||
|
|
||||||
|
**Parámetros:**
|
||||||
|
- `search` (opcional, por defecto = `""`): descripción.
|
||||||
|
|
||||||
|
**Retorna (aprox.):** `[]`
|
||||||
|
|
||||||
|
**Ejemplo de uso:**
|
||||||
|
```js
|
||||||
|
const result = await akListGroups(search);
|
||||||
|
console.log(result);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
### `akPatchUserAttributes`
|
||||||
|
|
||||||
|
**Firma:** `export async function akPatchUserAttributes(userPk, partialAttrs = {})`
|
||||||
|
**Ubicación:** línea 73
|
||||||
|
|
||||||
|
**Descripción:** Función `akPatchUserAttributes`.
|
||||||
|
|
||||||
|
**Parámetros:**
|
||||||
|
- `userPk`: descripción.
|
||||||
|
- `partialAttrs` (opcional, por defecto = `{}`): descripción.
|
||||||
|
|
||||||
|
**Retorna:** Puede no retornar valor explícito (`void`) o retorna según la rama de ejecución.
|
||||||
|
|
||||||
|
**Ejemplo de uso:**
|
||||||
|
```js
|
||||||
|
const result = await akPatchUserAttributes(userPk, partialAttrs);
|
||||||
|
console.log(result);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
### `akEnsureGroupForTenant`
|
||||||
|
|
||||||
|
**Firma:** `export async function akEnsureGroupForTenant(tenantHex)`
|
||||||
|
**Ubicación:** línea 97
|
||||||
|
|
||||||
|
**Descripción:** Función `akEnsureGroupForTenant`. Interactúa con una API HTTP. Maneja errores con bloques try/catch.
|
||||||
|
|
||||||
|
**Parámetros:**
|
||||||
|
- `tenantHex`: descripción.
|
||||||
|
|
||||||
|
**Retorna (aprox.):** `found.pk ?? found.id`
|
||||||
|
|
||||||
|
**Errores/excepciones:**
|
||||||
|
- Puede lanzar `TypeError("akEnsureGroupForTenant: `tenantHex` is required")`.
|
||||||
|
|
||||||
|
**Ejemplo de uso:**
|
||||||
|
```js
|
||||||
|
const result = await akEnsureGroupForTenant(tenantHex);
|
||||||
|
console.log(result);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
### `akAddUserToGroup`
|
||||||
|
|
||||||
|
**Firma:** `export async function akAddUserToGroup(userPk, groupPk)`
|
||||||
|
**Ubicación:** línea 130
|
||||||
|
|
||||||
|
**Descripción:** Función `akAddUserToGroup`. Interactúa con una API HTTP. Maneja errores con bloques try/catch.
|
||||||
|
|
||||||
|
**Parámetros:**
|
||||||
|
- `userPk`: descripción.
|
||||||
|
- `groupPk`: descripción.
|
||||||
|
|
||||||
|
**Retorna (aprox.):** `await akPOST("/core/group_memberships/", { body: { user, group } })`
|
||||||
|
|
||||||
|
**Errores/excepciones:**
|
||||||
|
- Puede lanzar `TypeError("akAddUserToGroup: `userPk` is required")`.
|
||||||
|
- Puede lanzar `TypeError("akAddUserToGroup: `groupPk` is required")`.
|
||||||
|
|
||||||
|
**Ejemplo de uso:**
|
||||||
|
```js
|
||||||
|
const result = await akAddUserToGroup(userPk, groupPk);
|
||||||
|
console.log(result);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
### `request`
|
||||||
|
|
||||||
|
**Firma:** `export async function request(method, path, opts = {}, cfg)`
|
||||||
|
**Ubicación:** línea 167
|
||||||
|
|
||||||
|
**Comentario previo en el código:**
|
||||||
|
```js
|
||||||
|
/**
|
||||||
|
* Llamada HTTP genérica con fetch + timeout + manejo de errores.
|
||||||
|
* @param {'GET'|'POST'|'PUT'|'PATCH'|'DELETE'} method
|
||||||
|
* @param {string} path Ruta relativa (ej. "/core/users/") o absoluta; si es relativa se antepone "/api/v3".
|
||||||
|
* @param {AkOpts} [opts]
|
||||||
|
* @param {AkCfg} [cfg] Config inyectada; si se omite se usa getConfig()
|
||||||
|
* @returns {Promise<any|null>}
|
||||||
|
*/
|
||||||
|
```
|
||||||
|
**Descripción:** Función `request`.
|
||||||
|
|
||||||
|
**Parámetros:**
|
||||||
|
- `method`: descripción.
|
||||||
|
- `path`: descripción.
|
||||||
|
- `opts` (opcional, por defecto = `{}`): descripción.
|
||||||
|
- `cfg`: descripción.
|
||||||
|
|
||||||
|
**Retorna:** Puede no retornar valor explícito (`void`) o retorna según la rama de ejecución.
|
||||||
|
|
||||||
|
**Ejemplo de uso:**
|
||||||
|
```js
|
||||||
|
const result = await request(method, path, opts, cfg);
|
||||||
|
console.log(result);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
### `akFindUserByEmail`
|
||||||
|
|
||||||
|
**Firma:** `export async function akFindUserByEmail(email)`
|
||||||
|
**Ubicación:** línea 262
|
||||||
|
|
||||||
|
**Comentario previo en el código:**
|
||||||
|
```js
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Funciones públicas
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
```
|
||||||
|
**Descripción:** Función `akFindUserByEmail`. Interactúa con una API HTTP.
|
||||||
|
|
||||||
|
**Parámetros:**
|
||||||
|
- `email`: descripción.
|
||||||
|
|
||||||
|
**Retorna (aprox.):** `null`
|
||||||
|
|
||||||
|
**Errores/excepciones:**
|
||||||
|
- Puede lanzar `TypeError("akFindUserByEmail: `email` is required")`.
|
||||||
|
|
||||||
|
**Ejemplo de uso:**
|
||||||
|
```js
|
||||||
|
const result = await akFindUserByEmail(email);
|
||||||
|
console.log(result);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
### `akCreateUser`
|
||||||
|
|
||||||
|
**Firma:** `export async function akCreateUser(p = {})`
|
||||||
|
**Ubicación:** línea 298
|
||||||
|
|
||||||
|
**Descripción:** Función `akCreateUser`.
|
||||||
|
|
||||||
|
**Parámetros:**
|
||||||
|
- `p` (opcional, por defecto = `{}`): descripción.
|
||||||
|
|
||||||
|
**Retorna:** Puede no retornar valor explícito (`void`) o retorna según la rama de ejecución.
|
||||||
|
|
||||||
|
**Ejemplo de uso:**
|
||||||
|
```js
|
||||||
|
const result = await akCreateUser(p);
|
||||||
|
console.log(result);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
### `akSetPassword`
|
||||||
|
|
||||||
|
**Firma:** `export async function akSetPassword(userPk, password, requireChange = true)`
|
||||||
|
**Ubicación:** línea 349
|
||||||
|
|
||||||
|
**Descripción:** Función `akSetPassword`. Interactúa con una API HTTP. Maneja errores con bloques try/catch.
|
||||||
|
|
||||||
|
**Parámetros:**
|
||||||
|
- `userPk`: descripción.
|
||||||
|
- `password`: descripción.
|
||||||
|
- `requireChange` (opcional, por defecto = `true`): descripción.
|
||||||
|
|
||||||
|
**Retorna (aprox.):** `true`
|
||||||
|
|
||||||
|
**Errores/excepciones:**
|
||||||
|
- Puede lanzar `TypeError("akSetPassword: `userPk` is required")`.
|
||||||
|
- Puede lanzar `TypeError("akSetPassword: `password` is required")`.
|
||||||
|
|
||||||
|
**Ejemplo de uso:**
|
||||||
|
```js
|
||||||
|
const result = await akSetPassword(userPk, password, requireChange);
|
||||||
|
console.log(result);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
### `akResolveGroupIdByName`
|
||||||
|
|
||||||
|
**Firma:** `export async function akResolveGroupIdByName(name)`
|
||||||
|
**Ubicación:** línea 373
|
||||||
|
|
||||||
|
**Descripción:** Función `akResolveGroupIdByName`.
|
||||||
|
|
||||||
|
**Parámetros:**
|
||||||
|
- `name`: descripción.
|
||||||
|
|
||||||
|
**Retorna (aprox.):** `byName?.pk ?? byName?.id ?? null`
|
||||||
|
|
||||||
|
**Errores/excepciones:**
|
||||||
|
- Puede lanzar `TypeError("akResolveGroupIdByName: `name` is required")`.
|
||||||
|
|
||||||
|
**Ejemplo de uso:**
|
||||||
|
```js
|
||||||
|
const result = await akResolveGroupIdByName(name);
|
||||||
|
console.log(result);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
### `akResolveGroupId`
|
||||||
|
|
||||||
|
**Firma:** `export async function akResolveGroupId({ id, pk, uuid, name, slug } = {})`
|
||||||
|
**Ubicación:** línea 389
|
||||||
|
|
||||||
|
**Descripción:** Función `akResolveGroupId`.
|
||||||
|
|
||||||
|
**Parámetros:**
|
||||||
|
- `{ id`: descripción.
|
||||||
|
- `pk`: descripción.
|
||||||
|
- `uuid`: descripción.
|
||||||
|
- `name`: descripción.
|
||||||
|
- `slug }` (opcional, por defecto = `{}`): descripción.
|
||||||
|
|
||||||
|
**Retorna:** Puede no retornar valor explícito (`void`) o retorna según la rama de ejecución.
|
||||||
|
|
||||||
|
**Ejemplo de uso:**
|
||||||
|
```js
|
||||||
|
const result = await akResolveGroupId({ id, pk, uuid, name, slug });
|
||||||
|
console.log(result);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
### `toPk`
|
||||||
|
|
||||||
|
**Firma:** `const => toPk(v)`
|
||||||
|
**Ubicación:** línea 390
|
||||||
|
|
||||||
|
**Descripción:** Función `toPk`.
|
||||||
|
|
||||||
|
**Parámetros:**
|
||||||
|
- `v`: descripción.
|
||||||
|
|
||||||
|
**Retorna (aprox.):** `Number.isFinite(n) ? n : String(v)`
|
||||||
|
|
||||||
|
**Ejemplo de uso:**
|
||||||
|
```js
|
||||||
|
const result = toPk(v);
|
||||||
|
console.log(result);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
0
docs/db's.md
Normal file
@ -2,6 +2,8 @@
|
|||||||
"name": "suitecoffee",
|
"name": "suitecoffee",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Software para gestión de cafeterías",
|
"description": "Software para gestión de cafeterías",
|
||||||
|
"private": true,
|
||||||
|
"workspaces": [],
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"coffee",
|
"coffee",
|
||||||
"suite",
|
"suite",
|
||||||
|
|||||||
5
packages/api/api.mjs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
// @suitecoffee/api/api.mjs
|
||||||
|
// packages/api/api.mjs
|
||||||
|
// Punto de entrada general del paquete de api.
|
||||||
|
|
||||||
|
export { default as apiv1 } from './v1/apiv1.mjs';
|
||||||
16
packages/api/package.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "@suitecoffee/api",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./api.mjs",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./api.mjs",
|
||||||
|
"default": "./api.mjs"
|
||||||
|
},
|
||||||
|
"./package.json": "./package.json"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
".api.mjs"
|
||||||
|
]
|
||||||
|
}
|
||||||
21
packages/api/v1/apiv1.mjs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
// packages/api/v1/apiv1.mjs
|
||||||
|
import { Router } from 'express';
|
||||||
|
|
||||||
|
// Sub-routers (cada uno define sus propios paths absolutos)
|
||||||
|
import comandasApiRouter from './routes/comandas.mjs';
|
||||||
|
// import productosApiRouter from './routes/productos.mjs'; // cuando exista
|
||||||
|
// import clientesApiRouter from './routes/clientes.mjs'; // etc.
|
||||||
|
|
||||||
|
const apiv1 = Router();
|
||||||
|
|
||||||
|
// Monta routers (no pongas prefijo aquí porque ya lo tienen adentro)
|
||||||
|
apiv1.use(comandasApiRouter);
|
||||||
|
// apiv1.use(productosApiRouter);
|
||||||
|
// apiv1.use(clientesApiRouter);
|
||||||
|
|
||||||
|
|
||||||
|
export default apiv1;
|
||||||
|
|
||||||
|
// (Opcional) re-export para tests puntuales
|
||||||
|
// export { comandasApiRouter };
|
||||||
|
// export { productosApiRouter };
|
||||||
111
packages/api/v1/repositories/comandasRepo.mjs
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
// packages/api/v1/repositories/comandasRepo.mjs
|
||||||
|
|
||||||
|
import { withTenantClient } from './db.mjs';
|
||||||
|
import { loadColumns, loadPrimaryKey } from '../routes/utils/schemaInspector.mjs';
|
||||||
|
|
||||||
|
const TABLE = 'comandas';
|
||||||
|
const VALID_IDENT = /^[a-z_][a-z0-9_]*$/i;
|
||||||
|
|
||||||
|
export async function listComandas({ schema, abierta, limit }) {
|
||||||
|
return withTenantClient(schema, async (db) => {
|
||||||
|
const max = Math.min(parseInt(limit || 200, 10), 1000);
|
||||||
|
const { rows } = await db.query(
|
||||||
|
`SELECT * FROM public.f_comandas_resumen($1, $2)`,
|
||||||
|
[abierta, max]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDetalleItems({ schema, id }) {
|
||||||
|
return withTenantClient(schema, async (db) => {
|
||||||
|
const { rows } = await db.query(
|
||||||
|
`SELECT id_det_comanda, id_producto, producto_nombre,
|
||||||
|
cantidad, pre_unitario, subtotal, observaciones
|
||||||
|
FROM public.v_comandas_detalle_items
|
||||||
|
WHERE id_comanda = $1::int
|
||||||
|
ORDER BY id_det_comanda`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function abrirComanda({ schema, id }) {
|
||||||
|
return withTenantClient(schema, async (db) => {
|
||||||
|
const st = await db.query(`SELECT eliminada FROM public.${q(TABLE)} WHERE id_comanda = $1`, [id]);
|
||||||
|
if (!st.rowCount) return null;
|
||||||
|
if (st.rows[0].eliminada === true) {
|
||||||
|
const err = new Error('Comanda eliminada. Debe restaurarse antes de abrir.');
|
||||||
|
err.http = { status: 409 };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const { rows } = await db.query(`SELECT public.f_abrir_comanda($1) AS data`, [id]);
|
||||||
|
return rows[0]?.data || null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cerrarComanda({ schema, id }) {
|
||||||
|
return withTenantClient(schema, async (db) => {
|
||||||
|
const { rows } = await db.query(`SELECT public.f_cerrar_comanda($1) AS data`, [id]);
|
||||||
|
return rows[0]?.data || null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restaurarComanda({ schema, id }) {
|
||||||
|
return withTenantClient(schema, async (db) => {
|
||||||
|
const { rows } = await db.query(`SELECT public.f_restaurar_comanda($1) AS data`, [id]);
|
||||||
|
return rows[0]?.data || null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function eliminarComanda({ schema, id }) {
|
||||||
|
return withTenantClient(schema, async (db) => {
|
||||||
|
const { rows } = await db.query(`SELECT public.f_eliminar_comanda($1) AS data`, [id]);
|
||||||
|
return rows[0]?.data || null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchComanda({ schema, id, payload }) {
|
||||||
|
return withTenantClient(schema, async (db) => {
|
||||||
|
const columns = await loadColumns(db, TABLE);
|
||||||
|
const updatable = new Set(
|
||||||
|
columns
|
||||||
|
.filter(c =>
|
||||||
|
!c.is_primary &&
|
||||||
|
!c.is_identity &&
|
||||||
|
!(String(c.column_default || '').startsWith('nextval('))
|
||||||
|
)
|
||||||
|
.map(c => c.column_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
const sets = [];
|
||||||
|
const params = [];
|
||||||
|
let idx = 1;
|
||||||
|
for (const [k, v] of Object.entries(payload || {})) {
|
||||||
|
if (!VALID_IDENT.test(k)) continue;
|
||||||
|
if (!updatable.has(k)) continue;
|
||||||
|
sets.push(`${q(k)} = $${idx++}`);
|
||||||
|
params.push(v);
|
||||||
|
}
|
||||||
|
if (!sets.length) return { error: 'Nada para actualizar' };
|
||||||
|
|
||||||
|
const pks = await loadPrimaryKey(db, TABLE);
|
||||||
|
if (pks.length !== 1) {
|
||||||
|
const err = new Error('PK compuesta no soportada');
|
||||||
|
err.http = { status: 400 };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
params.push(id);
|
||||||
|
|
||||||
|
const { rows } = await db.query(
|
||||||
|
`UPDATE ${q(TABLE)} SET ${sets.join(', ')} WHERE ${q(pks[0])} = $${idx} RETURNING *`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return rows[0] || null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function q(ident) {
|
||||||
|
return `"${String(ident).replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
29
packages/api/v1/repositories/db.mjs
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
// packages/api/v1/repositories/db.mjs
|
||||||
|
|
||||||
|
import { poolTenants } from '@suitecoffee/db';
|
||||||
|
|
||||||
|
const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||||
|
|
||||||
|
export async function withTenantClient(req, fn, { trx = false } = {}) {
|
||||||
|
const schema = req?.tenant?.schema;
|
||||||
|
if (!schema || !VALID_IDENT.test(schema)) {
|
||||||
|
throw new Error('Schema de tenant no resuelto/ inválido');
|
||||||
|
}
|
||||||
|
const client = await poolTenants.connect();
|
||||||
|
try {
|
||||||
|
if (trx) await client.query('BEGIN');
|
||||||
|
await client.query(`SET LOCAL search_path = "${schema}", public`);
|
||||||
|
const result = await fn(client);
|
||||||
|
if (trx) await client.query('COMMIT');
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
if (trx) await client.query('ROLLBACK');
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function tquery(req, sql, params = [], opts = {}) {
|
||||||
|
return withTenantClient(req, (c) => c.query(sql, params), opts);
|
||||||
|
}
|
||||||
50
packages/api/v1/routes/comandas.mjs
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
// packages/api/v1/routes/comandas.mjs
|
||||||
|
|
||||||
|
import { Router } from 'express';
|
||||||
|
|
||||||
|
import { tenantContext } from '@suitecoffee/middlewares';
|
||||||
|
import { listarComandas, detalleComanda, actualizarComanda, eliminarComanda } from './handlers/comandasHand.mjs';
|
||||||
|
|
||||||
|
const comandasApiRouter = Router();
|
||||||
|
|
||||||
|
comandasApiRouter.use(tenantContext);
|
||||||
|
|
||||||
|
// Colección
|
||||||
|
comandasApiRouter.route('/comandas').get(listarComandas);
|
||||||
|
|
||||||
|
// Recurso
|
||||||
|
comandasApiRouter.route('/comandas/:id').get(detalleComanda)
|
||||||
|
.put(actualizarComanda)
|
||||||
|
.delete(eliminarComanda);
|
||||||
|
|
||||||
|
export default comandasApiRouter;
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// API Comandas
|
||||||
|
/*
|
||||||
|
Escalabilidad: si luego agregás PUT /comandas/:id o DELETE /comandas/:id,
|
||||||
|
lo hacés en la misma ruta encadenando métodos:
|
||||||
|
|
||||||
|
router
|
||||||
|
.route('/comandas/:id')
|
||||||
|
.get(detalleComanda)
|
||||||
|
.put(actualizarComanda)
|
||||||
|
.delete(eliminarComanda);
|
||||||
|
|
||||||
|
Middleware común: podrías usar .all(requireAuth) o .all(validarTenant) si necesitás autenticación o contexto del tenant.
|
||||||
|
*/
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
/*
|
||||||
|
router.route('/comandas').get(listarComandas); // GET /comandas
|
||||||
|
|
||||||
|
// router.route('/comandas/:id').get(detalleComanda); // GET /comandas/:id
|
||||||
|
// router.route('/comandas/:id/abrir').post(abrirComanda); // POST /comandas/:id/abrir
|
||||||
|
// router.route('/comandas/:id/cerrar').post(cerrarComanda); // POST /comandas/:id/cerrar
|
||||||
|
|
||||||
|
// Recurso
|
||||||
|
router.route('/comandas/:id')
|
||||||
|
.get(detalleComanda) // GET /comandas/:id
|
||||||
|
.put(actualizarComanda) // PUT /comandas/:id (accion: abrir|cerrar|restaurar) o patch genérico
|
||||||
|
.delete(eliminarComanda); // DELETE /comandas/:id -> borrado lógico (eliminada=true)
|
||||||
|
*/
|
||||||
91
packages/api/v1/routes/handlers/comandasHand.mjs
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
// packages/api/v1/routes/handlers/comandas.js
|
||||||
|
|
||||||
|
import {
|
||||||
|
listComandas,
|
||||||
|
getDetalleItems,
|
||||||
|
abrirComanda,
|
||||||
|
cerrarComanda,
|
||||||
|
restaurarComanda,
|
||||||
|
eliminarComanda as eliminarComandaRepo,
|
||||||
|
patchComanda
|
||||||
|
} from '../../repositories/comandasRepo.mjs';
|
||||||
|
|
||||||
|
const asBoolean = (v) => {
|
||||||
|
const s = (v ?? '').toString().trim().toLowerCase();
|
||||||
|
return s === 'true' ? true : s === 'false' ? false : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listarComandas(req, res, next) {
|
||||||
|
try {
|
||||||
|
const abierta = asBoolean(req.query.abierta);
|
||||||
|
const limit = req.query.limit;
|
||||||
|
const rows = await listComandas({ schema: req.tenant.schema, abierta, limit });
|
||||||
|
res.json(rows);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detalleComanda(req, res, next) {
|
||||||
|
try {
|
||||||
|
const id = parseId(req.params.id);
|
||||||
|
const rows = await getDetalleItems({ schema: req.tenant.schema, id });
|
||||||
|
res.json(rows);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function actualizarComanda(req, res, next) {
|
||||||
|
try {
|
||||||
|
const id = parseId(req.params.id);
|
||||||
|
const { accion, ...patch } = req.body || {};
|
||||||
|
|
||||||
|
if (accion === 'abrir') {
|
||||||
|
const data = await abrirComanda({ schema: req.tenant.schema, id });
|
||||||
|
return data ? res.json(data) : res.status(404).json({ error: 'Comanda no encontrada' });
|
||||||
|
}
|
||||||
|
if (accion === 'cerrar') {
|
||||||
|
const data = await cerrarComanda({ schema: req.tenant.schema, id });
|
||||||
|
return data ? res.json(data) : res.status(404).json({ error: 'Comanda no encontrada' });
|
||||||
|
}
|
||||||
|
if (accion === 'restaurar') {
|
||||||
|
const data = await restaurarComanda({ schema: req.tenant.schema, id });
|
||||||
|
return data ? res.json(data) : res.status(404).json({ error: 'Comanda no encontrada' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await patchComanda({ schema: req.tenant.schema, id, payload: patch });
|
||||||
|
if (!result) return res.status(404).json({ error: 'Comanda no encontrada' });
|
||||||
|
if (result?.error) return res.status(400).json({ error: result.error });
|
||||||
|
res.json(result);
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.http?.status) return res.status(e.http.status).json({ error: e.message });
|
||||||
|
// PG codes comunes
|
||||||
|
if (e?.code === '23503') return res.status(409).json({ error: 'Violación de clave foránea', detail: e.detail });
|
||||||
|
if (e?.code === '23505') return res.status(400).json({ error: 'Violación de unicidad', detail: e.detail });
|
||||||
|
if (e?.code === '23514') return res.status(400).json({ error: 'Violación de CHECK', detail: e.detail });
|
||||||
|
if (e?.code === '23502') return res.status(400).json({ error: 'Campo NOT NULL faltante', detail: e.detail });
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function eliminarComanda(req, res, next) {
|
||||||
|
try {
|
||||||
|
const id = parseId(req.params.id);
|
||||||
|
const data = await eliminarComandaRepo({ schema: req.tenant.schema, id });
|
||||||
|
return data ? res.json(data) : res.status(404).json({ error: 'Comanda no encontrada' });
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.http?.status) return res.status(e.http.status).json({ error: e.message });
|
||||||
|
if (e?.code === '23503') return res.status(409).json({ error: 'Violación de clave foránea', detail: e.detail });
|
||||||
|
if (e?.code === '23505') return res.status(400).json({ error: 'Violación de unicidad', detail: e.detail });
|
||||||
|
if (e?.code === '23514') return res.status(400).json({ error: 'Violación de CHECK', detail: e.detail });
|
||||||
|
if (e?.code === '23502') return res.status(400).json({ error: 'Campo NOT NULL faltante', detail: e.detail });
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseId(value) {
|
||||||
|
const id = Number(value);
|
||||||
|
if (!Number.isInteger(id) || id <= 0) {
|
||||||
|
const err = new Error('id inválido');
|
||||||
|
err.http = { status: 400 };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
449
packages/api/v1/routes/routes.js
Normal file
@ -0,0 +1,449 @@
|
|||||||
|
// packages/api/v1/routes/routes.js
|
||||||
|
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { withTenantClient, tquery } from '../repositories/db.mjs'
|
||||||
|
import { listarComandas, detalleComanda, actualizarComanda, eliminarComanda } from './handlers/comandasHand.mjs';
|
||||||
|
import { loadColumns, loadForeignKeys, loadPrimaryKey, pickLabelColumn } from './utils/schemaInspector.mjs';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const ALLOWED_TABLES = [
|
||||||
|
'roles', 'usuarios', 'usua_roles',
|
||||||
|
'categorias', 'productos',
|
||||||
|
'clientes', 'mesas',
|
||||||
|
'comandas', 'deta_comandas',
|
||||||
|
'proveedores', 'compras', 'deta_comp_producto',
|
||||||
|
'mate_primas', 'deta_comp_materias',
|
||||||
|
'prov_producto', 'prov_mate_prima',
|
||||||
|
'receta_producto', 'asistencia_resumen_diario',
|
||||||
|
'asistencia_intervalo', 'asistencia_detalle',
|
||||||
|
'vw_compras'
|
||||||
|
];
|
||||||
|
|
||||||
|
const VALID_IDENT = /^[a-z_][a-z0-9_]*$/i;
|
||||||
|
const q = (ident) => `"${String(ident).replace(/"/g, '""')}"`;
|
||||||
|
|
||||||
|
function ensureTable(name) {
|
||||||
|
if (!VALID_IDENT.test(name)) throw new Error('Identificador inválido');
|
||||||
|
if (!ALLOWED_TABLES.includes(name)) throw new Error('Tabla no permitida');
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================
|
||||||
|
// Rutas de API v1
|
||||||
|
// ==========================================================
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// API Tablas
|
||||||
|
/*router.route('/tables').get( async (_req, res) => {
|
||||||
|
res.json(ALLOWED_TABLES);
|
||||||
|
});*/
|
||||||
|
|
||||||
|
// GET /api/schema/:table → columnas + foreign keys
|
||||||
|
/*router.get('/schema/:table', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const table = ensureTable(req.params.table);
|
||||||
|
const client = await poolTenants.getClient();
|
||||||
|
try {
|
||||||
|
const columns = await loadColumns(client, table);
|
||||||
|
const fks = await loadForeignKeys(client, table);
|
||||||
|
const enriched = columns.map(c => ({ ...c, foreign: fks[c.column_name] || null }));
|
||||||
|
res.json({ table, columns: enriched });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
res.status(400).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});*/
|
||||||
|
|
||||||
|
// GET /api/options/:table/:column → opciones FK
|
||||||
|
/*router.get('/options/:table/:column', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const table = ensureTable(req.params.table);
|
||||||
|
const column = req.params.column;
|
||||||
|
if (!VALID_IDENT.test(column)) throw new Error('Columna inválida');
|
||||||
|
|
||||||
|
const client = await poolTenants.getClient();
|
||||||
|
try {
|
||||||
|
const fks = await loadForeignKeys(client, table);
|
||||||
|
const fk = fks[column];
|
||||||
|
if (!fk) return res.json([]);
|
||||||
|
|
||||||
|
const refTable = fk.foreign_table;
|
||||||
|
const refId = fk.foreign_column;
|
||||||
|
const labelCol = await pickLabelColumn(client, refTable);
|
||||||
|
|
||||||
|
const sql = `SELECT ${q(refId)} AS id, ${q(labelCol)} AS label
|
||||||
|
FROM ${q(refTable)} ORDER BY ${q(labelCol)} LIMIT 1000`;
|
||||||
|
const result = await client.query(sql);
|
||||||
|
res.json(result.rows);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
res.status(400).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});*/
|
||||||
|
|
||||||
|
// GET /api/table/:table → preview de datos
|
||||||
|
/*router.get('/table/:table', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const table = ensureTable(req.params.table);
|
||||||
|
const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000);
|
||||||
|
await withTenantClient(req, async (client) => {
|
||||||
|
const pks = await loadPrimaryKey(client, table);
|
||||||
|
const orderBy = pks.length ? `ORDER BY ${pks.map(q).join(', ')} DESC` : '';
|
||||||
|
const sql = `SELECT * FROM ${q(table)} ${orderBy} LIMIT ${limit}`;
|
||||||
|
const { rows } = await client.query(sql);
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
res.status(400).json({ error: e.message, code: e.code, detail: e.detail });
|
||||||
|
}
|
||||||
|
});*/
|
||||||
|
|
||||||
|
// POST /api/table/:table → insertar fila
|
||||||
|
/*router.post('/table/:table', async (req, res) => {
|
||||||
|
const table = ensureTable(req.params.table);
|
||||||
|
const payload = req.body || {};
|
||||||
|
try {
|
||||||
|
const client = await poolTenants.getClient();
|
||||||
|
try {
|
||||||
|
const columns = await loadColumns(client, table);
|
||||||
|
const insertable = columns.filter(c =>
|
||||||
|
!c.is_primary &&
|
||||||
|
!c.is_identity &&
|
||||||
|
!(c.column_default || '').startsWith('nextval(')
|
||||||
|
);
|
||||||
|
const allowedCols = new Set(insertable.map(c => c.column_name));
|
||||||
|
|
||||||
|
const cols = [];
|
||||||
|
const vals = [];
|
||||||
|
const params = [];
|
||||||
|
let idx = 1;
|
||||||
|
for (const [k, v] of Object.entries(payload)) {
|
||||||
|
if (!allowedCols.has(k)) continue;
|
||||||
|
if (!VALID_IDENT.test(k)) continue;
|
||||||
|
cols.push(q(k));
|
||||||
|
vals.push(`$${idx++}`);
|
||||||
|
params.push(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows;
|
||||||
|
if (!cols.length) {
|
||||||
|
({ rows } = await client.query(
|
||||||
|
`INSERT INTO ${q(table)} DEFAULT VALUES RETURNING *`
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
({ rows } = await client.query(
|
||||||
|
`INSERT INTO ${q(table)} (${cols.join(', ')})
|
||||||
|
VALUES (${vals.join(', ')}) RETURNING *`,
|
||||||
|
params
|
||||||
|
));
|
||||||
|
}
|
||||||
|
res.status(201).json({ inserted: rows[0] });
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === '23503') return res.status(400).json({ error: 'Violación de clave foránea', detail: e.detail });
|
||||||
|
if (e.code === '23505') return res.status(400).json({ error: 'Violación de unicidad', detail: e.detail });
|
||||||
|
if (e.code === '23514') return res.status(400).json({ error: 'Violación de CHECK', detail: e.detail });
|
||||||
|
if (e.code === '23502') return res.status(400).json({ error: 'Campo NOT NULL faltante', detail: e.detail });
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
res.status(400).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// API Productos
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
|
||||||
|
// GET producto + receta
|
||||||
|
/*router.route('/rpc/get_producto/:id').get( async (req, res) => {
|
||||||
|
const client = await poolTenants.getClient()
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const { rows } = await client.query('SELECT public.get_producto($1) AS data', [id]);
|
||||||
|
res.json(rows[0]?.data || {});
|
||||||
|
client.release();
|
||||||
|
});*/
|
||||||
|
|
||||||
|
// POST guardar producto + receta
|
||||||
|
/*router.route('/rpc/save_producto').post(async (req, res) => {
|
||||||
|
try {
|
||||||
|
// console.debug('receta payload:', req.body?.receta); // habilitalo si lo necesitás
|
||||||
|
const client = await poolTenants.getClient()
|
||||||
|
const q = 'SELECT public.save_producto($1,$2,$3,$4,$5,$6,$7::jsonb) AS id_producto';
|
||||||
|
const { id_producto=null, nombre, img_producto=null, precio=0, activo=true, id_categoria=null, receta=[] } = req.body || {};
|
||||||
|
const params = [id_producto, nombre, img_producto, precio, activo, id_categoria, JSON.stringify(receta||[])];
|
||||||
|
const { rows } = await client.query(q, params);
|
||||||
|
res.json(rows[0] || {});
|
||||||
|
client.release();
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: 'save_producto failed' });
|
||||||
|
}
|
||||||
|
});*/
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// API Materias Primas
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
|
||||||
|
// GET MP + proveedores
|
||||||
|
/*router.route('/rpc/get_materia/:id').get(async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
try {
|
||||||
|
const client = await poolTenants.getClient()
|
||||||
|
const { rows } = await client.query('SELECT public.get_materia_prima($1) AS data', [id]);
|
||||||
|
res.json(rows[0]?.data || {});
|
||||||
|
client.release();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: 'get_materia failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// SAVE MP + proveedores (array)
|
||||||
|
router.route('/rpc/save_materia').post( async (req, res) => {
|
||||||
|
const { id_mat_prima=null, nombre, unidad, activo=true, proveedores=[] } = req.body || {};
|
||||||
|
try {
|
||||||
|
const q = 'SELECT public.save_materia_prima($1,$2,$3,$4,$5::jsonb) AS id_mat_prima';
|
||||||
|
const params = [id_mat_prima, nombre, unidad, activo, JSON.stringify(proveedores||[])];
|
||||||
|
const { rows } = await poolTenants.query(q, params);
|
||||||
|
res.json(rows[0] || {});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: 'save_materia failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// API Usuarios y Asistencias
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
|
||||||
|
// POST /api/rpc/find_usuarios_por_documentos { docs: ["12345678","09123456", ...] }
|
||||||
|
router.route('/rpc/find_usuarios_por_documentos').post( async (req, res) => {
|
||||||
|
try {
|
||||||
|
const docs = Array.isArray(req.body?.docs) ? req.body.docs : [];
|
||||||
|
const sql = 'SELECT public.find_usuarios_por_documentos($1::jsonb) AS data';
|
||||||
|
const { rows } = await poolTenants.query(sql, [JSON.stringify(docs)]);
|
||||||
|
res.json(rows[0]?.data || {});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: 'find_usuarios_por_documentos failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/rpc/import_asistencia { registros: [...], origen?: "AGL_001.txt" }
|
||||||
|
router.route('/rpc/import_asistencia').post( async (req, res) => {
|
||||||
|
try {
|
||||||
|
const registros = Array.isArray(req.body?.registros) ? req.body.registros : [];
|
||||||
|
const origen = req.body?.origen || null;
|
||||||
|
const sql = 'SELECT public.import_asistencia($1::jsonb,$2) AS data';
|
||||||
|
const { rows } = await poolTenants.query(sql, [JSON.stringify(registros), origen]);
|
||||||
|
res.json(rows[0]?.data || {});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: 'import_asistencia failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Consultar datos de asistencia (raw + pares) para un usuario y rango
|
||||||
|
router.route('/rpc/asistencia_get').post( async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { doc, desde, hasta } = req.body || {};
|
||||||
|
const sql = 'SELECT public.asistencia_get($1::text,$2::date,$3::date) AS data';
|
||||||
|
const { rows } = await poolTenants.query(sql, [doc, desde, hasta]);
|
||||||
|
res.json(rows[0]?.data || {});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e); res.status(500).json({ error: 'asistencia_get failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Editar un registro crudo y recalcular pares
|
||||||
|
router.route('/rpc/asistencia_update_raw').post( async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id_raw, fecha, hora, modo } = req.body || {};
|
||||||
|
const sql = 'SELECT public.asistencia_update_raw($1::bigint,$2::date,$3::text,$4::text) AS data';
|
||||||
|
const { rows } = await poolTenants.query(sql, [id_raw, fecha, hora, modo ?? null]);
|
||||||
|
res.json(rows[0]?.data || {});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e); res.status(500).json({ error: 'asistencia_update_raw failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Eliminar un registro crudo y recalcular pares
|
||||||
|
router.route('/rpc/asistencia_delete_raw').post( async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id_raw } = req.body || {};
|
||||||
|
const sql = 'SELECT public.asistencia_delete_raw($1::bigint) AS data';
|
||||||
|
const { rows } = await poolTenants.query(sql, [id_raw]);
|
||||||
|
res.json(rows[0]?.data || {});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e); res.status(500).json({ error: 'asistencia_delete_raw failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// API Reportes
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
|
||||||
|
// POST /api/rpc/report_tickets { year }
|
||||||
|
router.route('/rpc/report_tickets').post( async (req, res) => {
|
||||||
|
try {
|
||||||
|
const y = parseInt(req.body?.year ?? req.query?.year, 10);
|
||||||
|
const year = (Number.isFinite(y) && y >= 2000 && y <= 2100)
|
||||||
|
? y
|
||||||
|
: (new Date()).getFullYear();
|
||||||
|
|
||||||
|
const { rows } = await poolTenants.query(
|
||||||
|
'SELECT public.report_tickets_year($1::int) AS j', [year]
|
||||||
|
);
|
||||||
|
res.json(rows[0].j);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('report_tickets error:', e);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'report_tickets failed',
|
||||||
|
message: e.message, detail: e.detail, where: e.where, code: e.code
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/rpc/report_asistencia { desde: 'YYYY-MM-DD', hasta: 'YYYY-MM-DD' }
|
||||||
|
router.route('/rpc/report_asistencia').post( async (req, res) => {
|
||||||
|
try {
|
||||||
|
let { desde, hasta } = req.body || {};
|
||||||
|
// defaults si vienen vacíos/invalidos
|
||||||
|
const re = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
if (!re.test(desde) || !re.test(hasta)) {
|
||||||
|
const end = new Date();
|
||||||
|
const start = new Date(end); start.setDate(end.getDate()-30);
|
||||||
|
desde = start.toISOString().slice(0,10);
|
||||||
|
hasta = end.toISOString().slice(0,10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await poolTenants.query(
|
||||||
|
'SELECT public.report_asistencia($1::date,$2::date) AS j', [desde, hasta]
|
||||||
|
);
|
||||||
|
res.json(rows[0].j);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('report_asistencia error:', e);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'report_asistencia failed',
|
||||||
|
message: e.message, detail: e.detail, where: e.where, code: e.code
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// API Compras y Gastos
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
|
||||||
|
// Guardar (insert/update)
|
||||||
|
router.route('/rpc/save_compra').post( async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id_compra, id_proveedor, fec_compra, detalles } = req.body || {};
|
||||||
|
const sql = 'SELECT * FROM public.save_compra($1::int,$2::int,$3::timestamptz,$4::jsonb)';
|
||||||
|
const args = [id_compra ?? null, id_proveedor, fec_compra ? new Date(fec_compra) : null, JSON.stringify(detalles)];
|
||||||
|
const { rows } = await poolTenants.query(sql, args);
|
||||||
|
res.json(rows[0]); // { id_compra, total }
|
||||||
|
} catch (e) {
|
||||||
|
console.error('save_compra error:', e);
|
||||||
|
res.status(500).json({ error: 'save_compra failed', message: e.message, detail: e.detail, where: e.where, code: e.code });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Obtener para editar
|
||||||
|
router.route('/rpc/get_compra').post( async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id_compra } = req.body || {};
|
||||||
|
const sql = `SELECT public.get_compra($1::int) AS data`;
|
||||||
|
const { rows } = await poolTenants.query(sql, [id_compra]);
|
||||||
|
res.json(rows[0]?.data || {});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e); res.status(500).json({ error: 'get_compra failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Eliminar
|
||||||
|
router.route('/rpc/delete_compra').post( async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id_compra } = req.body || {};
|
||||||
|
await poolTenants.query(`SELECT public.delete_compra($1::int)`, [id_compra]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e); res.status(500).json({ error: 'delete_compra failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// POST /api/rpc/report_gastos { year: 2025 }
|
||||||
|
router.route('/rpc/report_gastos').post( async (req, res) => {
|
||||||
|
try {
|
||||||
|
const year = parseInt(req.body?.year ?? new Date().getFullYear(), 10);
|
||||||
|
const { rows } = await poolTenants.query(
|
||||||
|
'SELECT public.report_gastos($1::int) AS j', [year]
|
||||||
|
);
|
||||||
|
res.json(rows[0].j);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('report_gastos error:', e);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'report_gastos failed',
|
||||||
|
message: e.message, detail: e.detail, code: e.code
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});*/
|
||||||
|
|
||||||
|
|
||||||
|
export default router;
|
||||||
76
packages/api/v1/routes/utils/schemaInspector.mjs
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
// services/app/src/api/v1/routes/utils/schemaInspector.mjs
|
||||||
|
// Utilidades para inspeccionar columnas, claves y relaciones en PostgreSQL.
|
||||||
|
|
||||||
|
export async function loadColumns(client, table) {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
c.column_name,
|
||||||
|
c.data_type,
|
||||||
|
c.is_nullable = 'YES' AS is_nullable,
|
||||||
|
c.column_default,
|
||||||
|
(SELECT EXISTS (
|
||||||
|
SELECT 1 FROM pg_attribute a
|
||||||
|
JOIN pg_class t ON t.oid = a.attrelid
|
||||||
|
JOIN pg_index i ON i.indrelid = t.oid AND a.attnum = ANY(i.indkey)
|
||||||
|
WHERE t.relname = $1 AND i.indisprimary AND a.attname = c.column_name
|
||||||
|
)) AS is_primary,
|
||||||
|
(SELECT a.attgenerated = 's' OR a.attidentity IN ('a','d')
|
||||||
|
FROM pg_attribute a
|
||||||
|
JOIN pg_class t ON t.oid = a.attrelid
|
||||||
|
WHERE t.relname = $1 AND a.attname = c.column_name
|
||||||
|
) AS is_identity
|
||||||
|
FROM information_schema.columns c
|
||||||
|
WHERE c.table_schema='public' AND c.table_name=$1
|
||||||
|
ORDER BY c.ordinal_position
|
||||||
|
`;
|
||||||
|
const { rows } = await client.query(sql, [table]);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadForeignKeys(client, table) {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
kcu.column_name,
|
||||||
|
ccu.table_name AS foreign_table,
|
||||||
|
ccu.column_name AS foreign_column
|
||||||
|
FROM information_schema.table_constraints tc
|
||||||
|
JOIN information_schema.key_column_usage kcu
|
||||||
|
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
||||||
|
JOIN information_schema.constraint_column_usage ccu
|
||||||
|
ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
|
||||||
|
WHERE tc.table_schema='public' AND tc.table_name=$1 AND tc.constraint_type='FOREIGN KEY'
|
||||||
|
`;
|
||||||
|
const { rows } = await client.query(sql, [table]);
|
||||||
|
const map = {};
|
||||||
|
for (const r of rows)
|
||||||
|
map[r.column_name] = { foreign_table: r.foreign_table, foreign_column: r.foreign_column };
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPrimaryKey(client, table) {
|
||||||
|
const sql = `
|
||||||
|
SELECT a.attname AS column_name
|
||||||
|
FROM pg_index i
|
||||||
|
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||||
|
JOIN pg_class t ON t.oid = i.indrelid
|
||||||
|
WHERE t.relname = $1 AND i.indisprimary
|
||||||
|
`;
|
||||||
|
const { rows } = await client.query(sql, [table]);
|
||||||
|
return rows.map(r => r.column_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pickLabelColumn(client, refTable) {
|
||||||
|
const preferred = ['nombre','raz_social','apodo','documento','correo','telefono'];
|
||||||
|
const { rows } = await client.query(
|
||||||
|
`SELECT column_name, data_type
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema='public' AND table_name=$1
|
||||||
|
ORDER BY ordinal_position`, [refTable]
|
||||||
|
);
|
||||||
|
for (const cand of preferred)
|
||||||
|
if (rows.find(r => r.column_name === cand)) return cand;
|
||||||
|
|
||||||
|
const textish = rows.find(r => /text|character varying|varchar/i.test(r.data_type));
|
||||||
|
if (textish) return textish.column_name;
|
||||||
|
return rows[0]?.column_name || 'id';
|
||||||
|
}
|
||||||
22
packages/core/db/package.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "@suitecoffee/db",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./poolSingleton.mjs",
|
||||||
|
"types": "./poolSingleton.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./poolSingleton.d.ts",
|
||||||
|
"import": "./poolSingleton.mjs",
|
||||||
|
"default": "./poolSingleton.mjs"
|
||||||
|
},
|
||||||
|
"./package.json": "./package.json"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": "^8.16.3"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"poolSingleton.mjs",
|
||||||
|
"poolSingleton.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
68
packages/core/db/poolSingleton.d.ts
vendored
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
// packages/core/db/poolSingleton.d.ts
|
||||||
|
// Declaraciones de tipos para @suitecoffee/db
|
||||||
|
// Refleja el módulo ESM que expone poolCore y poolTenants (ambos Singletons)
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Pool,
|
||||||
|
PoolClient,
|
||||||
|
PoolConfig,
|
||||||
|
QueryResult,
|
||||||
|
QueryResultRow,
|
||||||
|
QueryConfig
|
||||||
|
} from 'pg';
|
||||||
|
|
||||||
|
export type { Pool, PoolClient, PoolConfig, QueryResult, QueryResultRow, QueryConfig };
|
||||||
|
|
||||||
|
// Clases modeladas según la implementación JS (no se exportan como valores en runtime,
|
||||||
|
// pero se exponen como tipos para el consumidor que quiera tipar sus variables).
|
||||||
|
export declare class DatabaseCore {
|
||||||
|
/** Instancia singleton interna (solo informativa para tipado). */
|
||||||
|
static instance?: DatabaseCore;
|
||||||
|
|
||||||
|
/** Pool real de `pg`. */
|
||||||
|
connection: Pool;
|
||||||
|
|
||||||
|
constructor();
|
||||||
|
|
||||||
|
/** Ejecuta una consulta utilizando el pool. */
|
||||||
|
query<T extends QueryResultRow = any>(
|
||||||
|
sql: string | QueryConfig<any[]>,
|
||||||
|
params?: any[]
|
||||||
|
): Promise<QueryResult<T>>;
|
||||||
|
|
||||||
|
/** Alias al `pool.connect()`; devuelve un `PoolClient`. */
|
||||||
|
connect(): Promise<PoolClient>;
|
||||||
|
|
||||||
|
/** Alias al `pool.connect()`; devuelve un `PoolClient`. */
|
||||||
|
getClient(): Promise<PoolClient>;
|
||||||
|
|
||||||
|
/** Cierra el pool subyacente. */
|
||||||
|
release(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare class DatabaseTenants {
|
||||||
|
static instance?: DatabaseTenants;
|
||||||
|
connection: Pool;
|
||||||
|
|
||||||
|
constructor();
|
||||||
|
|
||||||
|
query<T extends QueryResultRow = any>(
|
||||||
|
sql: string | QueryConfig<any[]>,
|
||||||
|
params?: any[]
|
||||||
|
): Promise<QueryResult<T>>;
|
||||||
|
|
||||||
|
connect(): Promise<PoolClient>;
|
||||||
|
getClient(): Promise<PoolClient>;
|
||||||
|
release(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Singletons creados por el módulo. */
|
||||||
|
export declare const poolCore: DatabaseCore;
|
||||||
|
export declare const poolTenants: DatabaseTenants;
|
||||||
|
|
||||||
|
/** Export por defecto del módulo: objeto con ambos pools. */
|
||||||
|
declare const _default: {
|
||||||
|
poolCore: DatabaseCore;
|
||||||
|
poolTenants: DatabaseTenants;
|
||||||
|
};
|
||||||
|
export default _default;
|
||||||
148
packages/core/db/poolSingleton.mjs
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
// poolSingleton.mjs
|
||||||
|
// Conexión Singleton a base de datos (pg/Pool) para CORE y TENANTS.
|
||||||
|
// Cambios mínimos respecto a tu versión original.
|
||||||
|
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
|
||||||
|
// Utilidad mínima para booleans
|
||||||
|
const isTrue = (v) => String(v).toLowerCase() === 'true';
|
||||||
|
|
||||||
|
// --------------------- CORE ---------------------
|
||||||
|
class DatabaseCore {
|
||||||
|
static instance = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (DatabaseCore.instance) {
|
||||||
|
return DatabaseCore.instance; // <-- corrección: antes devolvía Database.instance
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = process.env.CORE_DB_HOST;
|
||||||
|
const user = process.env.CORE_DB_USER;
|
||||||
|
const password = process.env.CORE_DB_PASS;
|
||||||
|
const database = process.env.CORE_DB_NAME;
|
||||||
|
const port = process.env.CORE_DB_PORT;
|
||||||
|
const ssl =
|
||||||
|
isTrue(process.env.CORE_PGSSL ?? process.env.PGSSL)
|
||||||
|
? { rejectUnauthorized: false }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
host,
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
database,
|
||||||
|
port: port ? Number(port) : undefined,
|
||||||
|
ssl,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.host = host;
|
||||||
|
this.dbName = database;
|
||||||
|
this.connection = new Pool(config);
|
||||||
|
|
||||||
|
DatabaseCore.instance = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async query(sql, params) {
|
||||||
|
return this.connection.query(sql, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect() { // idempotente a nivel de pool; retorna un client
|
||||||
|
return this.connection.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getClient() { // alias simple, conserva tu API
|
||||||
|
return this.connection.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async release() { // cierra TODO el pool (uso excepcional)
|
||||||
|
await this.connection.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------- TENANTS ---------------------
|
||||||
|
class DatabaseTenants {
|
||||||
|
static instance = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (DatabaseTenants.instance) {
|
||||||
|
return DatabaseTenants.instance; // <-- corrección: antes devolvía Database.instance
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = process.env.TENANTS_DB_HOST;
|
||||||
|
const user = process.env.TENANTS_DB_USER;
|
||||||
|
const password = process.env.TENANTS_DB_PASS;
|
||||||
|
const database = process.env.TENANTS_DB_NAME;
|
||||||
|
const port = process.env.TENANTS_DB_PORT;
|
||||||
|
const ssl =
|
||||||
|
isTrue(process.env.TENANTS_PGSSL ?? process.env.PGSSL)
|
||||||
|
? { rejectUnauthorized: false }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
host,
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
database,
|
||||||
|
port: port ? Number(port) : undefined,
|
||||||
|
ssl,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.host = host;
|
||||||
|
this.dbName = database;
|
||||||
|
this.connection = new Pool(config);
|
||||||
|
|
||||||
|
DatabaseTenants.instance = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async query(sql, params) {
|
||||||
|
return this.connection.query(sql, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect() { // idempotente a nivel de pool; retorna un client
|
||||||
|
return this.connection.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getClient() { // alias simple, conserva tu API
|
||||||
|
return this.connection.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async release() { // cierra TODO el pool (uso excepcional)
|
||||||
|
await this.connection.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instancias únicas por el cache de módulos de Node/ESM + guardas estáticas
|
||||||
|
const poolCore = new DatabaseCore();
|
||||||
|
const poolTenants = new DatabaseTenants();
|
||||||
|
|
||||||
|
// --------------------- Healthchecks aquí dentro ---------------------
|
||||||
|
async function verificarConexionCore() {
|
||||||
|
try {
|
||||||
|
console.log(`[ PG ] Comprobando accesibilidad a la db ${poolCore.dbName} del host ${poolCore.host} ...`);
|
||||||
|
const client = await poolCore.getClient();
|
||||||
|
const { rows } = await client.query('SELECT NOW() AS ahora');
|
||||||
|
console.log(`[ PG ] Conexión con ${poolCore.dbName} OK. Hora DB:`, rows[0].ahora);
|
||||||
|
client.release();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ PG ] Error al conectar con la base de datos al iniciar:', error.message);
|
||||||
|
console.error('[ PG ] Revisar credenciales, accesos de red y firewall.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verificarConexionTenants() {
|
||||||
|
try {
|
||||||
|
console.log(`[ PG ] Comprobando accesibilidad a la db ${poolTenants.dbName} del host ${poolTenants.host} ...`);
|
||||||
|
const client = await poolTenants.getClient();
|
||||||
|
const { rows } = await client.query('SELECT NOW() AS ahora');
|
||||||
|
console.log(`[ PG ] Conexión con ${poolTenants.dbName} OK. Hora DB:`, rows[0].ahora);
|
||||||
|
client.release();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ PG ] Error al conectar con la base de datos al iniciar:', error.message);
|
||||||
|
console.error('[ PG ] Revisar credenciales, accesos de red y firewall.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exports (mantengo tu patrón)
|
||||||
|
export default { poolCore, poolTenants, verificarConexionCore, verificarConexionTenants };
|
||||||
|
export { poolCore, poolTenants, verificarConexionCore, verificarConexionTenants };
|
||||||
|
// export { DatabaseCore, DatabaseTenants }; // si lo necesitás para tests
|
||||||
14
packages/core/middlewares/datosGlobales.mjs
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
// @suitecoffee/middlewares/datosGlobales.mjs
|
||||||
|
// packages/core/middlewares/datosGlobales.mjs
|
||||||
|
|
||||||
|
import { Router } from 'express';
|
||||||
|
export const datosGlobales = Router();
|
||||||
|
|
||||||
|
datosGlobales.use((req, res, next) => {
|
||||||
|
res.locals.currentPath = req.path;
|
||||||
|
res.locals.pageTitle = 'SuiteCoffee';
|
||||||
|
res.locals.pageId = '';
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default datosGlobales; // opcional, pero útil si alguien quiere import default
|
||||||
7
packages/core/middlewares/index.mjs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
// @suitecoffee/middlewares/src/index.mjs
|
||||||
|
// Punto de entrada general del paquete de middlewares.
|
||||||
|
|
||||||
|
export * from './requireAuth.mjs';
|
||||||
|
export * from './datosGlobales.mjs';
|
||||||
|
export * from './tenantContext.mjs';
|
||||||
|
export * from './resolveTenantFromCore.mjs';
|
||||||
16
packages/core/middlewares/package.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "@suitecoffee/middlewares",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": ".index.mjs",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./index.mjs",
|
||||||
|
"default": "./index.mjs"
|
||||||
|
},
|
||||||
|
"./package.json": "./package.json"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
".index.mjs"
|
||||||
|
]
|
||||||
|
}
|
||||||
43
packages/core/middlewares/requireAuth.mjs
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
// packages/core/middlewares/src/requireAuth.mjs
|
||||||
|
// @suitecoffee/middlewares/src/requireAuth.mjs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* requireAuth
|
||||||
|
* Verifica que exista una sesión válida en req.session.user (con `sub`).
|
||||||
|
* - Si hay sesión, llama a next().
|
||||||
|
* - Si no hay sesión:
|
||||||
|
* - Si se define `redirectTo`, redirige (302) cuando el cliente acepta HTML.
|
||||||
|
* - En caso contrario, responde 401 con { error: 'unauthenticated' }.
|
||||||
|
*
|
||||||
|
* @param {Object} [options]
|
||||||
|
* @param {string|null} [options.redirectTo=null] Ruta a la que redirigir si no hay sesión (p.ej. '/auth/login')
|
||||||
|
* @param {(req: import('express').Request) => any} [options.getSessionUser] Cómo leer el usuario de la sesión
|
||||||
|
* @returns {import('express').RequestHandler}
|
||||||
|
*
|
||||||
|
* Uso típico:
|
||||||
|
* import { requireAuth } from '@suitecoffee/middlewares';
|
||||||
|
* app.get('/me', requireAuth(), (req,res)=> res.json({ user: req.session.user }));
|
||||||
|
* app.get('/dashboard', requireAuth({ redirectTo: '/auth/login' }), handler);
|
||||||
|
*/
|
||||||
|
export function requireAuth(options = {}) {
|
||||||
|
const {
|
||||||
|
redirectTo = null,
|
||||||
|
getSessionUser = (req) => req?.session?.user,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
return function requireAuthMiddleware(req, res, next) {
|
||||||
|
const user = getSessionUser(req);
|
||||||
|
|
||||||
|
if (user && user.sub) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si el cliente acepta HTML y tenemos redirectTo, redirigimos (útil para front web)
|
||||||
|
if (redirectTo && req.accepts('html')) {
|
||||||
|
return res.redirect(302, redirectTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback JSON
|
||||||
|
return res.status(401).json({ error: 'unauthenticated' });
|
||||||
|
};
|
||||||
|
}
|
||||||
140
packages/core/middlewares/resolveTenantFromCore.mjs
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
// packages/core/middlewares/resolveTenantFromCore.mjs
|
||||||
|
import { poolCore, poolTenants } from '@suitecoffee/db';
|
||||||
|
|
||||||
|
const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si existe el esquema en la DB de tenants.
|
||||||
|
* No requiere setear search_path.
|
||||||
|
*/
|
||||||
|
async function schemaExists(schemaName) {
|
||||||
|
if (!schemaName) return false;
|
||||||
|
const q = `
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.schemata
|
||||||
|
WHERE schema_name = $1
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
const { rowCount } = await poolTenants.query(q, [schemaName]);
|
||||||
|
return rowCount === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Devuelve { id, schema } o null.
|
||||||
|
* Reglas:
|
||||||
|
* 1) Si el usuario tiene default_tenant => usarlo (y validar estado y existencia del schema).
|
||||||
|
* 2) Si no, buscar membresías:
|
||||||
|
* - si hay exactamente 1 => usarla (validando schema).
|
||||||
|
* - si hay 0 o >1 => devolver null (forzar selección explícita).
|
||||||
|
*
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @param {any} sess (req.session)
|
||||||
|
* @param {Object} [opts]
|
||||||
|
* @param {boolean} [opts.debug=false]
|
||||||
|
* @param {Console} [opts.logger=console]
|
||||||
|
* @param {string[]} [opts.acceptStates=['ready']] // estados de sc_tenants aceptados
|
||||||
|
* @returns {Promise<{id:string, schema:string} | null>}
|
||||||
|
*/
|
||||||
|
export async function resolveTenantFromCore(req, sess, opts = {}) {
|
||||||
|
const {
|
||||||
|
debug = false,
|
||||||
|
logger = console,
|
||||||
|
acceptStates = ['ready'],
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
const log = (msg, obj) => {
|
||||||
|
if (debug) logger.debug?.(`[resolveTenantFromCore] ${msg}`, obj ?? '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const sub = sess?.user?.sub;
|
||||||
|
if (!sub) {
|
||||||
|
log('no-sub-in-session');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1) sc_users: obtener user_id y default_tenant
|
||||||
|
const uSql = `
|
||||||
|
SELECT user_id, default_tenant
|
||||||
|
FROM sc_users
|
||||||
|
WHERE sub = $1
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
const ures = await poolCore.query(uSql, [sub]);
|
||||||
|
if (ures.rowCount === 0) {
|
||||||
|
log('user-not-found', { sub });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user_id, default_tenant } = ures.rows[0] ?? {};
|
||||||
|
|
||||||
|
// Helper para validar fila de tenant y existencia de schema
|
||||||
|
const validateTenantRow = async (row) => {
|
||||||
|
if (!row) return null;
|
||||||
|
const { tenant_id, schema_name, state } = row;
|
||||||
|
if (!UUID_RX.test(String(tenant_id))) return null;
|
||||||
|
if (!schema_name) return null;
|
||||||
|
if (acceptStates.length && !acceptStates.includes(String(state))) return null;
|
||||||
|
|
||||||
|
// Comprobar que el schema exista realmente en la DB de tenants
|
||||||
|
const exists = await schemaExists(schema_name);
|
||||||
|
if (!exists) {
|
||||||
|
log('schema-missing-in-tenants-db', { schema_name });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { id: String(tenant_id), schema: String(schema_name) };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2) Si hay default_tenant, cargar su schema y validar
|
||||||
|
if (default_tenant) {
|
||||||
|
const tSql = `
|
||||||
|
SELECT tenant_id, schema_name, state
|
||||||
|
FROM sc_tenants
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
const tres = await poolCore.query(tSql, [default_tenant]);
|
||||||
|
if (tres.rowCount === 1) {
|
||||||
|
const ok = await validateTenantRow(tres.rows[0]);
|
||||||
|
if (ok) {
|
||||||
|
sess.tenant = ok;
|
||||||
|
log('resolved-from-default_tenant', ok);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
// default_tenant roto → seguimos a membresías
|
||||||
|
log('default_tenant-invalid', { default_tenant });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Sin default_tenant válido: ver membresías (aceptando sólo tenants en estados permitidos)
|
||||||
|
const mSql = `
|
||||||
|
SELECT m.tenant_id, t.schema_name, t.state, t.created_at, m.role
|
||||||
|
FROM sc_memberships m
|
||||||
|
JOIN sc_tenants t USING (tenant_id)
|
||||||
|
WHERE m.user_id = $1
|
||||||
|
${acceptStates.length ? `AND t.state = ANY($2)` : ''}
|
||||||
|
ORDER BY (m.role = 'owner') DESC, t.created_at ASC
|
||||||
|
LIMIT 2
|
||||||
|
`;
|
||||||
|
const mParams = acceptStates.length ? [user_id, acceptStates] : [user_id];
|
||||||
|
const mres = await poolCore.query(mSql, mParams);
|
||||||
|
|
||||||
|
if (mres.rowCount === 1) {
|
||||||
|
const ok = await validateTenantRow(mres.rows[0]);
|
||||||
|
if (ok) {
|
||||||
|
sess.tenant = ok;
|
||||||
|
log('resolved-from-single-membership', ok);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
log('single-membership-invalid-row', mres.rows[0]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0 o >1 membresías → el usuario debe elegir explícitamente
|
||||||
|
log('ambiguous-or-no-memberships', { count: mres.rowCount });
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error?.('[resolveTenantFromCore] error', { message: err?.message });
|
||||||
|
return null; // preferimos no romper el request; el middleware decidirá
|
||||||
|
}
|
||||||
|
}
|
||||||
155
packages/core/middlewares/tenantContext.mjs
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
// packages/core/middlewares/src/tenantContext.mjs
|
||||||
|
|
||||||
|
const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/; // schema seguro
|
||||||
|
const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
function redact(obj) {
|
||||||
|
// Evita loggear datos sensibles; muestra sólo lo útil para diagnóstico
|
||||||
|
if (!obj || typeof obj !== 'object') return obj;
|
||||||
|
const out = {};
|
||||||
|
for (const k of Object.keys(obj)) {
|
||||||
|
if (['token', 'access_token', 'id_token', 'refresh_token'].includes(k)) {
|
||||||
|
out[k] = '[redacted]';
|
||||||
|
} else if (k === 'sub' || k === 'email' || k === 'name') {
|
||||||
|
out[k] = obj[k];
|
||||||
|
} else if (k === 'tenant') {
|
||||||
|
const t = obj[k] || {};
|
||||||
|
out[k] = { id: t.id ?? null, schema: t.schema ?? null };
|
||||||
|
} else if (k === 'user') {
|
||||||
|
const u = obj[k] || {};
|
||||||
|
out[k] = {
|
||||||
|
sub: u.sub ?? null,
|
||||||
|
email: u.email ?? null,
|
||||||
|
default_tenant: u.default_tenant ?? u.defaultTenant ?? null,
|
||||||
|
memberships: Array.isArray(u.memberships) ? `[${u.memberships.length}]` : null,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// no inundar el log; deja constancia de que existe
|
||||||
|
out[k] = '[present]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tenantContext(opts = {}) {
|
||||||
|
const {
|
||||||
|
requireUser = true,
|
||||||
|
debug = false,
|
||||||
|
log = console, // podés inyectar tu logger
|
||||||
|
autoDeriveFromDefault = true,
|
||||||
|
// callback opcional para buscar tenant (p.ej., en CORE) si no está en sesión
|
||||||
|
// Debe devolver { id: uuid, schema: string } o null
|
||||||
|
resolveTenant = null,
|
||||||
|
schemaPrefixes = [
|
||||||
|
process.env.TENANT_SCHEMA_PREFIX || 'empresa_',
|
||||||
|
].filter(Boolean),
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
const diag = (msg, data) => {
|
||||||
|
if (!debug) return;
|
||||||
|
try { log.debug?.(`[tenantContext] ${msg}`, data !== undefined ? redact(data) : ''); }
|
||||||
|
catch { /* noop */ }
|
||||||
|
};
|
||||||
|
const setDiagHeader = (res, kv) => {
|
||||||
|
if (!debug) return;
|
||||||
|
const cur = res.getHeader('X-Tenant-Diag');
|
||||||
|
const base = typeof cur === 'string' ? String(cur) + '; ' : '';
|
||||||
|
res.setHeader('X-Tenant-Diag', base + kv);
|
||||||
|
};
|
||||||
|
|
||||||
|
return async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
diag('incoming', { sid: req.sessionID, headers: { accept: req.headers.accept } });
|
||||||
|
|
||||||
|
const sess = req.session;
|
||||||
|
if (!sess) {
|
||||||
|
setDiagHeader(res, 'no-session');
|
||||||
|
return res.status(401).json({ error: 'unauthenticated' });
|
||||||
|
}
|
||||||
|
diag('session.present', { keys: Object.keys(sess) });
|
||||||
|
|
||||||
|
if (requireUser && !sess.user?.sub) {
|
||||||
|
diag('user.missing', { session: sess });
|
||||||
|
setDiagHeader(res, 'no-user');
|
||||||
|
return res.status(401).json({ error: 'unauthenticated' });
|
||||||
|
}
|
||||||
|
if (requireUser) diag('user.ok', sess.user);
|
||||||
|
|
||||||
|
// 1) Leer tenant desde sesión
|
||||||
|
let t = sess.tenant ?? null;
|
||||||
|
diag('session.tenant', t);
|
||||||
|
|
||||||
|
// 2) Derivar automáticamente si falta
|
||||||
|
if ((!t?.id || !t?.schema) && autoDeriveFromDefault) {
|
||||||
|
const fallbackId =
|
||||||
|
sess.user?.tenant?.id ||
|
||||||
|
sess.user?.default_tenant ||
|
||||||
|
sess.user?.defaultTenant ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
if (fallbackId && UUID_RX.test(String(fallbackId))) {
|
||||||
|
const prefix = String(schemaPrefixes[0] || 'empresa_');
|
||||||
|
const schema = `${prefix}${String(fallbackId).replace(/-/g, '').toLowerCase()}`;
|
||||||
|
t = { id: String(fallbackId), schema };
|
||||||
|
sess.tenant = t; // persistir para siguientes requests
|
||||||
|
diag('derived.fromDefault', t);
|
||||||
|
setDiagHeader(res, 'derived-default');
|
||||||
|
} else {
|
||||||
|
diag('derived.fromDefault.skipped', { fallbackId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Resolver con callback si aún falta
|
||||||
|
if ((!t?.id || !t?.schema) && typeof resolveTenant === 'function') {
|
||||||
|
try {
|
||||||
|
t = await resolveTenant(req, sess);
|
||||||
|
if (t) {
|
||||||
|
sess.tenant = t;
|
||||||
|
diag('derived.fromResolver', t);
|
||||||
|
setDiagHeader(res, 'derived-resolver');
|
||||||
|
} else {
|
||||||
|
diag('resolver.returned-null');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
diag('resolver.error', { message: e?.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Validaciones
|
||||||
|
if (!t?.id || !t?.schema) {
|
||||||
|
diag('missing-tenant.final');
|
||||||
|
setDiagHeader(res, 'missing-tenant');
|
||||||
|
return res.status(401).json({ error: 'Sesión inválida o tenant no seleccionado' });
|
||||||
|
}
|
||||||
|
if (!UUID_RX.test(String(t.id))) {
|
||||||
|
diag('invalid-tenant-id', t);
|
||||||
|
setDiagHeader(res, 'bad-tenant-id');
|
||||||
|
return res.status(400).json({ error: 'TenantID inválido' });
|
||||||
|
}
|
||||||
|
if (!VALID_IDENT.test(t.schema)) {
|
||||||
|
diag('invalid-schema', t);
|
||||||
|
setDiagHeader(res, 'bad-schema');
|
||||||
|
return res.status(400).json({ error: 'Schema inválido' });
|
||||||
|
}
|
||||||
|
const okPrefix = schemaPrefixes.some(p =>
|
||||||
|
t.schema.toLowerCase().startsWith(String(p).toLowerCase()),
|
||||||
|
);
|
||||||
|
if (!okPrefix) {
|
||||||
|
diag('schema-prefix.rejected', { schema: t.schema, schemaPrefixes });
|
||||||
|
setDiagHeader(res, 'schema-prefix-rejected');
|
||||||
|
return res.status(400).json({ error: 'Schema no permitido' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) OK
|
||||||
|
req.tenant = { id: String(t.id), schema: String(t.schema) };
|
||||||
|
res.locals.tenant = req.tenant;
|
||||||
|
setDiagHeader(res, `ok schema=${req.tenant.schema}`);
|
||||||
|
diag('attach.req.tenant', req.tenant);
|
||||||
|
|
||||||
|
return next();
|
||||||
|
} catch (err) {
|
||||||
|
diag('exception', { message: err?.message });
|
||||||
|
return next(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
22
packages/core/redis/package.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "@suitecoffee/redis",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./redisSingleton.mjs",
|
||||||
|
"types": "./redisSingleton.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./redisSingleton.d.ts",
|
||||||
|
"import": "./redisSingleton.mjs",
|
||||||
|
"default": "./redisSingleton.mjs"
|
||||||
|
},
|
||||||
|
"./package.json": "./package.json"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": "^8.16.3"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"redisSingleton.mjs",
|
||||||
|
"redisSingleton.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
0
packages/core/redis/redisSingleton.d.ts
vendored
Normal file
93
packages/core/redis/redisSingleton.mjs
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
// redisSingleton.mjs
|
||||||
|
// Conexión Singleton a Redis para Authentik (AK)
|
||||||
|
|
||||||
|
import { createClient } from 'redis';
|
||||||
|
|
||||||
|
class RedisAuthentik {
|
||||||
|
static instance = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (RedisAuthentik.instance) {
|
||||||
|
return RedisAuthentik.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = process.env.AK_REDIS_URL;
|
||||||
|
if (!url) {
|
||||||
|
throw new Error('Falta AK_REDIS_URL Ej: redis://:pass@host:6379/0');
|
||||||
|
}
|
||||||
|
if (!/^redis(s)?:\/\//i.test(url)) {
|
||||||
|
throw new Error('AK_REDIS_URL inválida: debe comenzar con "redis://" o "rediss://".');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.url = url;
|
||||||
|
this.client = createClient({
|
||||||
|
url: this.url,
|
||||||
|
socket: { connectTimeout: 5000 },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('connect', () => console.log(`[REDIS AK] Conectando a ${this.url}`));
|
||||||
|
this.client.on('ready', () => console.log('[REDIS AK] Conexión lista.'));
|
||||||
|
this.client.on('end', () => console.warn('[REDIS AK] Conexión cerrada.'));
|
||||||
|
this.client.on('reconnecting', () => console.warn('[REDIS AK] Reintentando conexión...'));
|
||||||
|
this.client.on('error', (err) => console.error('[REDIS AK] Error:', err?.message || err));
|
||||||
|
|
||||||
|
this._connectingPromise = null;
|
||||||
|
RedisAuthentik.instance = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect() {
|
||||||
|
if (this.client.isOpen) return this.client;
|
||||||
|
if (this._connectingPromise) return this._connectingPromise;
|
||||||
|
|
||||||
|
this._connectingPromise = this.client.connect()
|
||||||
|
.then(() => this.client)
|
||||||
|
.catch((err) => {
|
||||||
|
this._connectingPromise = null;
|
||||||
|
console.error('[REDIS AK] Falló la conexión inicial:', err?.message || err);
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
return this._connectingPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
getClient() {
|
||||||
|
return this.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async release() {
|
||||||
|
try {
|
||||||
|
if (this.client?.isOpen) await this.client.quit();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[REDIS AK] Error al cerrar:', e?.message || e);
|
||||||
|
} finally {
|
||||||
|
this._connectingPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instancia única
|
||||||
|
const redisAuthentik = new RedisAuthentik();
|
||||||
|
|
||||||
|
// --------------------- Healthcheck ---------------------
|
||||||
|
async function verificarConexionRedisAuthentik() {
|
||||||
|
try {
|
||||||
|
console.log(`[REDIS AK] Comprobando accesibilidad a Redis en ${redisAuthentik.url} ...`);
|
||||||
|
await redisAuthentik.connect();
|
||||||
|
const client = redisAuthentik.getClient();
|
||||||
|
|
||||||
|
const pong = await client.ping();
|
||||||
|
const timeArr = await client.sendCommand(['TIME']);
|
||||||
|
const serverDate = new Date(Number(timeArr?.[0] || 0) * 1000);
|
||||||
|
|
||||||
|
await client.set('hc:authentik', String(Date.now()), { EX: 10 });
|
||||||
|
|
||||||
|
console.log(`[REDIS AK] Conexión OK. PING=${pong}. Hora Redis:`, serverDate.toISOString());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[REDIS AK] Error al conectar:', error?.message || error);
|
||||||
|
console.error('[REDIS AK] Revisar AK_REDIS_URL, credenciales, red y firewall.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export al estilo de poolSingleton.mjs
|
||||||
|
export default { redisAuthentik, verificarConexionRedisAuthentik };
|
||||||
|
export { redisAuthentik, verificarConexionRedisAuthentik };
|
||||||
19
packages/core/scripts/package.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "@suitecoffee/scripts",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": ".src/index.mjs",
|
||||||
|
"types": ".src/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./src/index.d.ts",
|
||||||
|
"import": "./src/index.mjs",
|
||||||
|
"default": "./src/index.mjs"
|
||||||
|
},
|
||||||
|
"./package.json": "./package.json"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"srcindex.mjs",
|
||||||
|
"srcindex.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
4
packages/core/scripts/src/index.mjs
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
// @suitecoffee/scripts/src/index.mjs
|
||||||
|
// Punto de entrada general del paquete de utilidades.
|
||||||
|
|
||||||
|
export * from './utils/env.mjs';
|
||||||
24
packages/core/scripts/src/utils/env.mjs
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
// @suitecoffee/scripts/src/utils/env.mjs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* checkRequiredEnvVars
|
||||||
|
* Verifica que todas las variables de entorno requeridas existan en process.env.
|
||||||
|
* Muestra advertencias si alguna falta.
|
||||||
|
*
|
||||||
|
* @param {...string} requiredKeys - Lista de nombres de variables esperadas
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
export function checkRequiredEnvVars(...requiredKeys) {
|
||||||
|
const missingKeys = requiredKeys.filter((key) => !process.env[key]);
|
||||||
|
|
||||||
|
if (missingKeys.length > 0) {
|
||||||
|
console.warn(
|
||||||
|
`[ ENV ] No se encontraron las siguientes variables de entorno:\n\n` +
|
||||||
|
missingKeys.map((k) => `-> ${k}`).join('\n') +
|
||||||
|
`\n`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(`[ ENV ] Todas las variables de entorno requeridas están definidas.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
packages/devices/attendance/core/BaseFileDriver.mjs
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
// BaseFileDriver.mjs
|
||||||
|
import { DeviceInterface } from './DeviceInterface.mjs';
|
||||||
|
import { fmtHMSUTC, fmtHM } from '../utils/dates.mjs';
|
||||||
|
import * as intervalsCross from '../strategies/intervals/cross-day.mjs';
|
||||||
|
import * as intervalsSame from '../strategies/intervals/same-day.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template Method para drivers basados en archivos .txt
|
||||||
|
* Define el pipeline y delega el parseo de línea en this.parserStrategy.parseLine
|
||||||
|
*/
|
||||||
|
export class BaseFileDriver extends DeviceInterface {
|
||||||
|
constructor(opts = {}) {
|
||||||
|
super(opts);
|
||||||
|
if (!this.parserStrategy || typeof this.parserStrategy.parseLine !== 'function') {
|
||||||
|
throw new Error('BaseFileDriver requiere parserStrategy.parseLine(line)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} text contenido completo del .txt en UTF-8
|
||||||
|
*/
|
||||||
|
async processFile(text) {
|
||||||
|
if (!text || typeof text !== 'string') {
|
||||||
|
this.setStatus('Elegí un .txt válido');
|
||||||
|
return { parsedRows: [], pairs: [], payloadDB: [], missing_docs: [], error: 'Archivo vacío o inválido' };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setStatus('Leyendo archivo…');
|
||||||
|
|
||||||
|
// 1) Parseo línea a línea (Strategy)
|
||||||
|
const lines = text.split(/\n/);
|
||||||
|
const parsedRows = [];
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const r = this.parserStrategy.parseLine(lines[i]);
|
||||||
|
if (r) parsedRows.push(r);
|
||||||
|
if ((i & 511) === 0) this.emit('progress', { at: i, total: lines.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Resolver nombres por documento (inyectado)
|
||||||
|
const uniqueDocs = [...new Set(parsedRows.map(r => r.doc))];
|
||||||
|
this.setStatus(`Leyendo archivo… | consultando ${uniqueDocs.length} documentos…`);
|
||||||
|
const map = await this._safeNamesResolver(uniqueDocs);
|
||||||
|
|
||||||
|
// 3) Detectar documentos faltantes
|
||||||
|
const missing_docs = uniqueDocs.filter(d => {
|
||||||
|
const hit = map?.[d];
|
||||||
|
if (!hit) return true;
|
||||||
|
if (typeof hit.found === 'boolean') return !hit.found;
|
||||||
|
return !(hit?.nombre || '').trim() && !(hit?.apellido || '').trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (missing_docs.length) {
|
||||||
|
this.setStatus('Hay documentos sin usuario. Corrigí y volvé a procesar.');
|
||||||
|
return { parsedRows, pairs: [], payloadDB: [], missing_docs,
|
||||||
|
error: `No se encontraron ${missing_docs.length} documento(s) en la base` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Enriquecer nombre desde DB
|
||||||
|
parsedRows.forEach(r => {
|
||||||
|
const hit = map?.[r.doc];
|
||||||
|
if (hit && (hit.nombre || hit.apellido)) r.name = `${hit.nombre || ''} ${hit.apellido || ''}`.trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5) Construcción de intervalos (Strategy)
|
||||||
|
const pairs = (this.intervalBuilder === 'sameDay')
|
||||||
|
? intervalsSame.buildIntervals(parsedRows)
|
||||||
|
: intervalsCross.buildIntervalsCrossDay(parsedRows);
|
||||||
|
|
||||||
|
// 6) Payload "raw" para DB
|
||||||
|
const payloadDB = parsedRows.map(r => ({
|
||||||
|
doc: r.doc, isoDate: r.isoDate, time: r.time, mode: r.mode || null
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.setStatus(`${parsedRows.length} registros · ${pairs.length} intervalos`);
|
||||||
|
return { parsedRows, pairs, payloadDB, missing_docs: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
exportCSV(pairs) {
|
||||||
|
const list = Array.isArray(pairs) ? pairs : [];
|
||||||
|
if (!list.length) return '';
|
||||||
|
const head = ['documento','nombre','fecha','desde','hasta','duracion_hhmm','duracion_min','obs'];
|
||||||
|
const rows = list.map(p => {
|
||||||
|
const iso = p.isoDate || p.fecha || '';
|
||||||
|
const desdeStr = (p.desde_ms!=null) ? fmtHMSUTC(p.desde_ms) : '';
|
||||||
|
const hastaStr = (p.hasta_ms!=null) ? fmtHMSUTC(p.hasta_ms) : '';
|
||||||
|
const durStr = (p.durMins!=null) ? fmtHM(p.durMins) : '';
|
||||||
|
const durMin = (p.durMins!=null) ? Math.round(p.durMins) : '';
|
||||||
|
return [
|
||||||
|
p.doc, p.name || '', iso, desdeStr, hastaStr, durStr, durMin, p.obs || ''
|
||||||
|
].map(v => `"${String(v).replaceAll('"','""')}"`).join(',');
|
||||||
|
});
|
||||||
|
return head.join(',') + '\n' + rows.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async _safeNamesResolver(docs) {
|
||||||
|
try { return await this.namesResolver(docs); }
|
||||||
|
catch { return {}; }
|
||||||
|
}
|
||||||
|
}
|
||||||
0
packages/devices/attendance/core/BaseTcpDriver.mjs
Normal file
46
packages/devices/attendance/core/DeviceInterface.mjs
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
// DeviceInterface.mjs
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrato común que todos los drivers deben implementar.
|
||||||
|
* Drivers de archivo (.txt) pueden dejar connect/fetchLogs/parseLogData como no-op.
|
||||||
|
*/
|
||||||
|
export class DeviceInterface extends EventEmitter {
|
||||||
|
/**
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {(docs:string[])=>Promise<Record<string,{nombre?:string,apellido?:string,found?:boolean}>>} [opts.namesResolver]
|
||||||
|
* @param {'crossDay'|'sameDay'} [opts.intervalBuilder]
|
||||||
|
* @param {{ parseLine:(line:string)=>object|null }} [opts.parserStrategy]
|
||||||
|
*/
|
||||||
|
constructor(opts = {}) {
|
||||||
|
super();
|
||||||
|
this.namesResolver = typeof opts.namesResolver === 'function' ? opts.namesResolver : async () => ({});
|
||||||
|
this.intervalBuilder = opts.intervalBuilder || 'crossDay';
|
||||||
|
this.parserStrategy = opts.parserStrategy || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------- API esperada (drivers file) -------
|
||||||
|
/**
|
||||||
|
* Procesa el contenido completo de un .txt y devuelve:
|
||||||
|
* { parsedRows, pairs, payloadDB, missing_docs, error? }
|
||||||
|
*/
|
||||||
|
async processFile(/* text:string */) {
|
||||||
|
throw new Error('processFile not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna CSV como string (no descarga).
|
||||||
|
*/
|
||||||
|
exportCSV(/* pairs?:object[] */) {
|
||||||
|
throw new Error('exportCSV not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------- API opcional (drivers TCP/IP) ----
|
||||||
|
async connect() { /* no-op */ }
|
||||||
|
async disconnect() { /* no-op */ }
|
||||||
|
async fetchLogs() { throw new Error('fetchLogs not implemented'); }
|
||||||
|
async parseLogData(/* raw */) { throw new Error('parseLogData not implemented'); }
|
||||||
|
|
||||||
|
// ------- Utilidad: emitir estado -------
|
||||||
|
setStatus(text) { this.emit('status', text || ''); }
|
||||||
|
}
|
||||||
4
packages/devices/attendance/core/errors/DeviceErrors.mjs
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
// DeviceErrors.mjs
|
||||||
|
export class DeviceError extends Error { constructor(msg){ super(msg); this.name='DeviceError'; } }
|
||||||
|
export class DriverNotFoundError extends DeviceError { constructor(key){ super(`Driver no registrado: ${key}`); this.name='DriverNotFoundError'; } }
|
||||||
|
export class ParseError extends DeviceError { constructor(line){ super(`No se pudo parsear la línea: ${line}`); this.name='ParseError'; } }
|
||||||
22
packages/devices/attendance/core/factories/DeviceFactory.mjs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
// DeviceFactory.mjs
|
||||||
|
import { DriverRegistry } from './DriverRegistry.mjs';
|
||||||
|
|
||||||
|
export class DeviceFactory {
|
||||||
|
static register(key, ctor, manifest) {
|
||||||
|
DriverRegistry.register(key, ctor, manifest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} key "vendor:model"
|
||||||
|
* @param {object} opts opciones para el constructor del driver
|
||||||
|
*/
|
||||||
|
static create(key, opts = {}) {
|
||||||
|
const reg = DriverRegistry.get(key);
|
||||||
|
if (!reg) throw new Error(`DeviceFactory: driver no registrado: ${key}`);
|
||||||
|
return new reg.ctor(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
static listSupported() {
|
||||||
|
return DriverRegistry.list();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
// DriverRegistry.mjs
|
||||||
|
const _registry = new Map();
|
||||||
|
/**
|
||||||
|
* Clave: "vendor:model" en minúsculas
|
||||||
|
* Valor: { ctor: DriverClass, manifest?: object }
|
||||||
|
*/
|
||||||
|
export const DriverRegistry = {
|
||||||
|
register(key, ctor, manifest = null) {
|
||||||
|
const k = String(key || '').trim().toLowerCase();
|
||||||
|
if (!k) throw new Error('DriverRegistry.register: key vacío');
|
||||||
|
if (typeof ctor !== 'function') throw new Error('DriverRegistry.register: ctor inválido');
|
||||||
|
_registry.set(k, { ctor, manifest: manifest || {} });
|
||||||
|
},
|
||||||
|
get(key) {
|
||||||
|
return _registry.get(String(key || '').trim().toLowerCase()) || null;
|
||||||
|
},
|
||||||
|
list() {
|
||||||
|
return [..._registry.entries()].map(([k, v]) => ({ key: k, manifest: v.manifest || {} }));
|
||||||
|
}
|
||||||
|
};
|
||||||
18
packages/devices/attendance/core/index.mjs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
// index.mjs (Facade del dominio attendance)
|
||||||
|
export { DeviceInterface } from './DeviceInterface.mjs';
|
||||||
|
export { BaseFileDriver } from './BaseFileDriver.mjs';
|
||||||
|
export { DeviceFactory } from './factories/DeviceFactory.mjs';
|
||||||
|
export { DriverRegistry } from './factories/DriverRegistry.mjs';
|
||||||
|
|
||||||
|
// Facade helpers
|
||||||
|
import { DeviceFactory } from './factories/DeviceFactory.mjs';
|
||||||
|
|
||||||
|
export function registerDriver(key, Ctor, manifest) {
|
||||||
|
DeviceFactory.register(key, Ctor, manifest);
|
||||||
|
}
|
||||||
|
export function createDevice(key, opts) {
|
||||||
|
return DeviceFactory.create(key, opts);
|
||||||
|
}
|
||||||
|
export function listSupported() {
|
||||||
|
return DeviceFactory.listSupported();
|
||||||
|
}
|
||||||
14
packages/devices/attendance/core/schema/manifest.schema.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "Device Driver Manifest",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["vendor", "model", "transport", "version"],
|
||||||
|
"properties": {
|
||||||
|
"vendor": { "type": "string", "minLength": 1 },
|
||||||
|
"model": { "type": "string", "minLength": 1 },
|
||||||
|
"transport": { "type": "string", "enum": ["file", "tcp", "http"] },
|
||||||
|
"capabilities": { "type": "array", "items": { "type": "string" } },
|
||||||
|
"version": { "type": "string" }
|
||||||
|
},
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
// cross-day.mjs
|
||||||
|
// Pares ordenados para jornadas que pueden cruzar medianoche.
|
||||||
|
// rows: [{ doc, name, isoDate, dt_ms, ... }, ...]
|
||||||
|
export function buildIntervalsCrossDay(rows){
|
||||||
|
const byDoc = new Map();
|
||||||
|
rows.forEach(r => {
|
||||||
|
(byDoc.get(r.doc) || byDoc.set(r.doc, []).get(r.doc))
|
||||||
|
.push({ ms: r.dt_ms, date: r.isoDate, name: r.name });
|
||||||
|
});
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
for (const [doc, arr] of byDoc.entries()){
|
||||||
|
arr.sort((a,b)=>a.ms-b.ms);
|
||||||
|
for (let i=0;i<arr.length;i+=2){
|
||||||
|
const a = arr[i], b = arr[i+1];
|
||||||
|
if (!b){
|
||||||
|
out.push({doc, name:a.name, fecha:a.date, desde_ms:a.ms, hasta_ms:null, durMins:null, obs:'incompleto'});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const dur = Math.max(0,(b.ms-a.ms)/60000);
|
||||||
|
out.push({doc, name:a.name, fecha:a.date, desde_ms:a.ms, hasta_ms:b.ms, durMins:dur, obs:''});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.sort((x,y)=> x.doc.localeCompare(y.doc) ||
|
||||||
|
x.fecha.localeCompare(y.fecha) ||
|
||||||
|
(x.desde_ms - y.desde_ms));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
// same-day.mjs
|
||||||
|
// Agrupa por (doc, fecha) y arma pares 1-2, 3-4, ...
|
||||||
|
export function buildIntervals(rows) {
|
||||||
|
const nameByDoc = new Map();
|
||||||
|
const byKey = new Map(); // doc|isoDate -> [ms]
|
||||||
|
|
||||||
|
for (const r of rows) {
|
||||||
|
nameByDoc.set(r.doc, r.name);
|
||||||
|
const key = `${r.doc}|${r.isoDate}`;
|
||||||
|
(byKey.get(key) || byKey.set(key, []).get(key)).push(r.dt_ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
for (const [key, arr] of byKey.entries()) {
|
||||||
|
arr.sort((a,b)=>a-b);
|
||||||
|
const [doc, isoDate] = key.split('|');
|
||||||
|
const name = nameByDoc.get(doc) || '';
|
||||||
|
for (let i=0; i<arr.length; i+=2) {
|
||||||
|
const desde = arr[i];
|
||||||
|
const hasta = arr[i+1] ?? null;
|
||||||
|
let durMins = null, obs = '';
|
||||||
|
if (hasta != null) durMins = Math.max(0, (hasta - desde)/60000);
|
||||||
|
else obs = 'incompleto';
|
||||||
|
result.push({ doc, name, isoDate, desde_ms: desde, hasta_ms: hasta, durMins, obs });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.sort((a,b)=>{
|
||||||
|
if (a.doc !== b.doc) return a.doc.localeCompare(b.doc);
|
||||||
|
if (a.isoDate !== b.isoDate) return a.isoDate.localeCompare(b.isoDate);
|
||||||
|
return (a.desde_ms||0) - (b.desde_ms||0);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
// LineParserInterface.mjs
|
||||||
|
export class LineParserInterface {
|
||||||
|
parseLine(/* line:string */) {
|
||||||
|
throw new Error('parseLine not implemented');
|
||||||
|
}
|
||||||
|
}
|
||||||
31
packages/devices/attendance/core/utils/dates.mjs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
// dates.mjs
|
||||||
|
export const z2 = n => String(n).padStart(2,'0');
|
||||||
|
|
||||||
|
export function toUTCms(isoDate, time) {
|
||||||
|
const [Y,M,D] = isoDate.split('-').map(n=>parseInt(n,10));
|
||||||
|
const [h,m,s] = time.split(':').map(n=>parseInt(n,10));
|
||||||
|
return Date.UTC(Y, (M||1)-1, D||1, h||0, m||0, s||0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtHMSUTC(ms){
|
||||||
|
const d = new Date(ms);
|
||||||
|
const z = n => String(n).padStart(2,'0');
|
||||||
|
return `${z(d.getUTCHours())}:${z(d.getUTCMinutes())}:${z(d.getUTCSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fmtHM = mins => {
|
||||||
|
const h = Math.floor(mins/60); const m = Math.round(mins%60);
|
||||||
|
return `${z2(h)}:${z2(m)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// "YY/MM/DD" o "YYYY/MM/DD" (o '-') -> "YYYY-MM-DD"
|
||||||
|
export function normDateStr(s) {
|
||||||
|
const m = String(s || '').trim().match(/^(\d{2,4})[\/\-](\d{1,2})[\/\-](\d{1,2})$/);
|
||||||
|
if (!m) return null;
|
||||||
|
let [_, y, mo, d] = m;
|
||||||
|
let yy = parseInt(y, 10);
|
||||||
|
if (y.length === 2) yy = 2000 + yy;
|
||||||
|
const mm = parseInt(mo, 10), dd = parseInt(d, 10);
|
||||||
|
if (!(mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31)) return null;
|
||||||
|
return `${yy}-${String(mm).padStart(2,'0')}-${String(dd).padStart(2,'0')}`;
|
||||||
|
}
|
||||||
20
packages/devices/attendance/core/utils/docs.mjs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
// docs.mjs
|
||||||
|
import { z2 } from './dates.mjs';
|
||||||
|
|
||||||
|
export const normDoc = s => {
|
||||||
|
const v = String(s||'').replace(/\D/g,'').replace(/^0+/,'');
|
||||||
|
return v || '0';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cleanDoc = s => {
|
||||||
|
const v = String(s||'').trim().replace(/^0+/, '');
|
||||||
|
return v === '' ? '0' : v;
|
||||||
|
};
|
||||||
|
|
||||||
|
// HH:MM o HH:MM:SS -> HH:MM:SS
|
||||||
|
export const normTime = s => {
|
||||||
|
if (!s) return '';
|
||||||
|
const m = String(s).trim().match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
|
||||||
|
if (!m) return '';
|
||||||
|
return `${z2(+m[1])}:${z2(+m[2])}:${z2(+m[3]||0)}`;
|
||||||
|
};
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
// GenericI60Driver.mjs
|
||||||
|
import { BaseFileDriver } from '../../core/BaseFileDriver.mjs';
|
||||||
|
import * as Parser from './parser.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Driver genérico i60 (sin conectividad). Lee archivos .txt exportados del equipo.
|
||||||
|
* Implementa el "Template Method" heredado de BaseFileDriver.
|
||||||
|
*/
|
||||||
|
export default class GenericI60Driver extends BaseFileDriver {
|
||||||
|
constructor(opts = {}) {
|
||||||
|
super({
|
||||||
|
...opts,
|
||||||
|
parserStrategy: { parseLine: Parser.parseLine },
|
||||||
|
intervalBuilder: opts.intervalBuilder || 'crossDay'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
13
packages/devices/attendance/drivers/generic/i60/index.mjs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
// index.mjs
|
||||||
|
import GenericI60Driver from './GenericI60Driver.mjs';
|
||||||
|
|
||||||
|
export const manifest = {
|
||||||
|
vendor: 'generic',
|
||||||
|
model: 'i60',
|
||||||
|
transport: 'file',
|
||||||
|
capabilities: ['import', 'intervals:cross-day'],
|
||||||
|
version: '1.0.0'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GenericI60Driver;
|
||||||
|
export { manifest };
|
||||||
54
packages/devices/attendance/drivers/generic/i60/parser.mjs
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
// parser.mjs
|
||||||
|
import { normDateStr, toUTCms } from '../../core/utils/dates.mjs';
|
||||||
|
import { cleanDoc, normTime } from '../../core/utils/docs.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsea una línea con prioridad por TAB; si no hay, cae a espacios;
|
||||||
|
* separa fecha/hora si vienen juntas.
|
||||||
|
* Devuelve { doc, name, isoDate, time, dt_ms, mode } o null.
|
||||||
|
*/
|
||||||
|
export function parseLine(line) {
|
||||||
|
const raw = String(line || '').replace(/\r/g, '').trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
// omitir encabezados comunes
|
||||||
|
if (/^no[\t ]|^mchn[\t ]|^enno[\t ]|^name[\t ]|^datetime[\t ]/i.test(raw)) return null;
|
||||||
|
|
||||||
|
let parts = raw.split(/\t+/);
|
||||||
|
|
||||||
|
// Fallback: dos o más espacios + DateTime al final
|
||||||
|
if (parts.length < 7) {
|
||||||
|
const dtMatch = raw.match(/(\d{2,4}[\/-]\d{1,2}[\/-]\d{1,2})\s+(\d{1,2}:\d{2}:\d{2})$/);
|
||||||
|
if (dtMatch) {
|
||||||
|
const head = raw.slice(0, dtMatch.index).trim();
|
||||||
|
const headParts = head.split(/\t+|\s{2,}/).filter(Boolean);
|
||||||
|
parts = [...headParts, dtMatch[1], dtMatch[2]];
|
||||||
|
} else {
|
||||||
|
parts = raw.split(/\s{2,}/).filter(Boolean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parts.length < 7) return null;
|
||||||
|
|
||||||
|
// Indices "normales": 0:No, 1:Mchn, 2:EnNo(doc), 3:Name, 4:Mode, 5:Fecha, 6:Hora
|
||||||
|
const doc = cleanDoc(parts[2]);
|
||||||
|
const name = String(parts[3] || '').trim();
|
||||||
|
const mode = String(parts[4] || '').trim();
|
||||||
|
|
||||||
|
let dateStr = String(parts[5] || '').trim();
|
||||||
|
let timeStr = String(parts[6] || '').trim();
|
||||||
|
|
||||||
|
// Caso: la última columna es "YYYY/MM/DD HH:MM:SS"
|
||||||
|
const last = parts[parts.length - 1];
|
||||||
|
const dtBoth = /(\d{2,4}[\/-]\d{1,2}[\/-]\d{1,2})\s+(\d{1,2}:\d{2}:\d{2})/.exec(last);
|
||||||
|
if (dtBoth) { dateStr = dtBoth[1]; timeStr = dtBoth[2]; }
|
||||||
|
else if (!timeStr && /\d{1,2}:\d{2}:\d{2}/.test(dateStr)) {
|
||||||
|
const m = dateStr.match(/^(.+?)\s+(\d{1,2}:\d{2}:\d{2})$/);
|
||||||
|
if (m) { dateStr = m[1]; timeStr = m[2]; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const iso = normDateStr(dateStr);
|
||||||
|
const timeNorm = normTime(timeStr);
|
||||||
|
if (!iso || !timeNorm) return null;
|
||||||
|
|
||||||
|
return { doc, name, isoDate: iso, time: timeNorm, dt_ms: toUTCms(iso, timeNorm), mode };
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
import { GenericI60Driver } from './drivers/Generic/i60/GenericI60Driver';
|
||||||
|
|
||||||
|
export class DeviceFactory {
|
||||||
|
static create(model, config) {
|
||||||
|
switch (model) {
|
||||||
|
case 'Generic-i60': return new GenericI60Driver(config);
|
||||||
|
default:
|
||||||
|
throw new Error(`El modelo indicado no esta soportado. ${model}\n Porfavor ponerse en contacto con el equipo para implementarlo.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
// DeviceInterface.mjs
|
||||||
|
export class DeviceInterface {
|
||||||
|
async connect() { throw new Error('Not implemented'); }
|
||||||
|
async fetchLogs() { throw new Error('Not implemented'); }
|
||||||
|
async parseLogData(raw) { throw new Error('Not implemented'); }
|
||||||
|
}
|
||||||
13
packages/devices/i60/DriverFactory.mjs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { GenericDriver } from './GenericDriver.mjs';
|
||||||
|
|
||||||
|
export class DriverFactory {
|
||||||
|
static create(model = 'Generico'){
|
||||||
|
switch (String(model).toLowerCase()) {
|
||||||
|
case 'generico':
|
||||||
|
case 'generic':
|
||||||
|
default:
|
||||||
|
// El constructor de GenericDriver es Singleton; devolverá siempre la misma instancia
|
||||||
|
return new GenericDriver();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
packages/devices/i60/GenericDriver.mjs
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { parseLine } from './parsing.mjs';
|
||||||
|
import { buildIntervalsCrossDay } from './intervals.mjs';
|
||||||
|
import { exportCSV } from './csv.mjs';
|
||||||
|
import { NamesServiceProxy } from './namesProxy.mjs';
|
||||||
|
|
||||||
|
class GenericDriver {
|
||||||
|
constructor(){
|
||||||
|
if (GenericDriver._instance) return GenericDriver._instance;
|
||||||
|
/** @type {Array<Object>} */ this.parsedRows = [];
|
||||||
|
/** @type {Array<Object>} */ this.payloadDB = [];
|
||||||
|
/** @type {Array<Object>} */ this.pairs = [];
|
||||||
|
GenericDriver._instance = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orquesta el proceso a partir de texto plano
|
||||||
|
async processText(text, { fetchNamesForDocs } = {}){
|
||||||
|
const lines = String(text||'').split(/\n/);
|
||||||
|
const rows = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
const r = parseLine(line);
|
||||||
|
if (r) rows.push(r);
|
||||||
|
}
|
||||||
|
this.parsedRows = rows;
|
||||||
|
|
||||||
|
const uniqueDocs = [...new Set(this.parsedRows.map(r => r.doc))];
|
||||||
|
|
||||||
|
const namesProxy = new NamesServiceProxy(fetchNamesForDocs);
|
||||||
|
const map = await namesProxy.get(uniqueDocs);
|
||||||
|
|
||||||
|
const missingDocs = uniqueDocs.filter(d => {
|
||||||
|
const hit = map?.[d];
|
||||||
|
if (!hit) return true;
|
||||||
|
if (typeof hit.found === 'boolean') return !hit.found;
|
||||||
|
return !(hit?.nombre||'').trim() && !(hit?.apellido||'').trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
// sobreescribir nombre cuando DB provee
|
||||||
|
this.parsedRows.forEach(r => {
|
||||||
|
const hit = map?.[r.doc];
|
||||||
|
if (hit && (hit.nombre || hit.apellido)) {
|
||||||
|
r.name = `${hit.nombre || ''} ${hit.apellido || ''}`.trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pairs (permitiendo cruce de medianoche)
|
||||||
|
this.pairs = buildIntervalsCrossDay(this.parsedRows);
|
||||||
|
|
||||||
|
// Payload crudo para insertar
|
||||||
|
this.payloadDB = this.parsedRows.map(r => ({
|
||||||
|
doc: r.doc,
|
||||||
|
isoDate: r.isoDate,
|
||||||
|
time: r.time,
|
||||||
|
mode: r.mode || null
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { parsedRows: this.parsedRows, pairs: this.pairs, payloadDB: this.payloadDB, missingDocs };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conveniencia: leer desde ruta en disco
|
||||||
|
async processFileFromPath(filePath, opts = {}){
|
||||||
|
const txt = await readFile(filePath, 'utf8');
|
||||||
|
return await this.processText(txt, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSV server-side (devuelve string)
|
||||||
|
exportCSV(pairs = this.pairs){
|
||||||
|
return exportCSV(pairs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = new GenericDriver();
|
||||||
|
export default instance;
|
||||||
|
export { GenericDriver };
|
||||||
8
packages/devices/i60/GenericDriverFacade.mjs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { DriverFactory } from './DriverFactory.mjs';
|
||||||
|
|
||||||
|
export class GenericDriverFacade {
|
||||||
|
constructor(driver = DriverFactory.create('Generico')){ this.driver = driver; }
|
||||||
|
async processTxt(text, services = {}){ return await this.driver.processText(text, services); }
|
||||||
|
async processFile(filePath, services = {}){ return await this.driver.processFileFromPath(filePath, services); }
|
||||||
|
exportCSV(pairs){ return this.driver.exportCSV(pairs); }
|
||||||
|
}
|
||||||
17
packages/devices/i60/csv.mjs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { fmtHM, fmtHMSUTC } from './helpers.mjs';
|
||||||
|
|
||||||
|
// Genera CSV (server-side: retorna string) — nombre preservado
|
||||||
|
export function exportCSV(pairs) {
|
||||||
|
if (!pairs?.length) return '';
|
||||||
|
const head = ['documento','nombre','fecha','desde','hasta','duracion_hhmm','duracion_min','obs'];
|
||||||
|
const rows = pairs.map(p => {
|
||||||
|
const fecha = p.fecha || p.isoDate || '';
|
||||||
|
const desde = p.desde_ms!=null ? fmtHMSUTC(p.desde_ms) : '';
|
||||||
|
const hasta = p.hasta_ms!=null ? fmtHMSUTC(p.hasta_ms) : '';
|
||||||
|
const durHHMM = p.durMins!=null ? fmtHM(p.durMins) : '';
|
||||||
|
const durMin = p.durMins!=null ? Math.round(p.durMins) : '';
|
||||||
|
return [p.doc, p.name || '', fecha, desde, hasta, durHHMM, durMin, p.obs || '']
|
||||||
|
.map(v => `"${String(v).replaceAll('"','""')}"`).join(',');
|
||||||
|
});
|
||||||
|
return head.join(',') + '\n' + rows.join('\n');
|
||||||
|
}
|
||||||
40
packages/devices/i60/helpers.mjs
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
// Helpers comunes (nombres preservados)
|
||||||
|
export const z2 = n => String(n).padStart(2,'0');
|
||||||
|
export const pad2 = z2;
|
||||||
|
export const fmtHM = mins => { const h = Math.floor(mins/60); const m = Math.round(mins%60); return `${z2(h)}:${z2(m)}`; };
|
||||||
|
export const ymd = s => String(s||'').slice(0,10); // '2025-08-29T..' -> '2025-08-29'
|
||||||
|
|
||||||
|
// Normaliza fecha "YY/MM/DD" o "YYYY/MM/DD" a "YYYY-MM-DD"
|
||||||
|
export function normDateStr(s) {
|
||||||
|
const m = String(s || '').trim().match(/^(\d{2,4})[\/\-](\d{1,2})[\/\-](\d{1,2})$/);
|
||||||
|
if (!m) return null;
|
||||||
|
let [_, y, mo, d] = m;
|
||||||
|
let yy = parseInt(y, 10);
|
||||||
|
if (y.length === 2) yy = 2000 + yy; // 20YY
|
||||||
|
const mm = parseInt(mo, 10), dd = parseInt(d, 10);
|
||||||
|
if (!(mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31)) return null;
|
||||||
|
return `${yy}-${String(mm).padStart(2,'0')}-${String(dd).padStart(2,'0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normaliza documento quitando ceros a la izquierda
|
||||||
|
export const cleanDoc = s => {
|
||||||
|
const v = String(s||'').trim().replace(/^0+/, '');
|
||||||
|
return v === '' ? '0' : v;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compat alias (mantener nombre)
|
||||||
|
export const normDoc = s => {
|
||||||
|
const v = String(s||'').replace(/\D/g,'').replace(/^0+/,'');
|
||||||
|
return v || '0';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function toUTCms(isoDate, time) {
|
||||||
|
const [Y,M,D] = isoDate.split('-').map(n=>parseInt(n,10));
|
||||||
|
const [h,m,s] = time.split(':').map(n=>parseInt(n,10));
|
||||||
|
return Date.UTC(Y, (M||1)-1, D||1, h||0, m||0, s||0); // UTC fijo
|
||||||
|
}
|
||||||
|
export function fmtHMSUTC(ms){
|
||||||
|
const d = new Date(ms);
|
||||||
|
const z = n => String(n).padStart(2,'0');
|
||||||
|
return `${z(d.getUTCHours())}:${z(d.getUTCMinutes())}:${z(d.getUTCSeconds())}`;
|
||||||
|
}
|
||||||
32
packages/devices/i60/index.mjs
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
export { default as GenericDriverDefault, GenericDriver } from './GenericDriver.mjs';
|
||||||
|
export { DriverFactory } from './DriverFactory.mjs';
|
||||||
|
export { GenericDriverFacade } from './GenericDriverFacade.mjs';
|
||||||
|
export { NamesServiceProxy } from './namesProxy.mjs';
|
||||||
|
|
||||||
|
export * from './helpers.mjs';
|
||||||
|
export * from './parsing.mjs';
|
||||||
|
export * from './intervals.mjs';
|
||||||
|
export * from './csv.mjs';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
Uso mínimo (en tu servidor, al recibir un .txt subido):
|
||||||
|
|
||||||
|
// ejemplo en tu ruta de subida
|
||||||
|
import { GenericDriverFacade } from './drivers/generic/i60/GenericDriverFacade.mjs';
|
||||||
|
|
||||||
|
const facade = new GenericDriverFacade();
|
||||||
|
|
||||||
|
const { parsedRows, pairs, payloadDB, missingDocs } =
|
||||||
|
await facade.processFile(tempFilePath, {
|
||||||
|
// opcional: integra tu búsqueda de usuarios por documento
|
||||||
|
fetchNamesForDocs: async (docs) => {
|
||||||
|
// devuelve: { "12345678": { nombre, apellido, found:true } , ... }
|
||||||
|
return await dbFindUsuariosPorDocumentos(docs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// luego persistes payloadDB y/o pairs según tu lógica
|
||||||
|
|
||||||
|
*/
|
||||||
53
packages/devices/i60/intervals.mjs
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
// Agrupa por empleado, ordena cronológicamente y arma pares 1-2, 3-4, ... permitiendo cruzar medianoche.
|
||||||
|
export function buildIntervalsCrossDay(rows){
|
||||||
|
const byDoc = new Map();
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!byDoc.has(r.doc)) byDoc.set(r.doc, []);
|
||||||
|
byDoc.get(r.doc).push({ ms: r.dt_ms, date: r.isoDate, name: r.name });
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
for (const [doc, arr] of byDoc.entries()){
|
||||||
|
arr.sort((a,b)=>a.ms-b.ms);
|
||||||
|
for (let i=0;i<arr.length;i+=2){
|
||||||
|
const a = arr[i], b = arr[i+1];
|
||||||
|
if (!b){ out.push({doc, name:a.name, fecha:a.date, desde_ms:a.ms, hasta_ms:null, durMins:null, obs:'incompleto'}); break; }
|
||||||
|
const dur = Math.max(0,(b.ms-a.ms)/60000);
|
||||||
|
out.push({doc, name:a.name, fecha:a.date, desde_ms:a.ms, hasta_ms:b.ms, durMins:dur, obs:''});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ordenar por doc, fecha (inicio), desde
|
||||||
|
out.sort((x,y)=> x.doc.localeCompare(y.doc) || x.fecha.localeCompare(y.fecha) || (x.desde_ms - y.desde_ms));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alternativa por (doc, fecha) exacta (conservar nombre y firma)
|
||||||
|
export function buildIntervals(rows) {
|
||||||
|
const nameByDoc = new Map();
|
||||||
|
const byKey = new Map(); // doc|isoDate -> [ms]
|
||||||
|
for (const r of rows) {
|
||||||
|
nameByDoc.set(r.doc, r.name);
|
||||||
|
const key = `${r.doc}|${r.isoDate}`;
|
||||||
|
if (!byKey.has(key)) byKey.set(key, []);
|
||||||
|
byKey.get(key).push(r.dt_ms);
|
||||||
|
}
|
||||||
|
const result = [];
|
||||||
|
for (const [key, arr] of byKey.entries()) {
|
||||||
|
arr.sort((a,b)=>a-b);
|
||||||
|
const [doc, isoDate] = key.split('|');
|
||||||
|
const name = nameByDoc.get(doc) || '';
|
||||||
|
for (let i=0; i<arr.length; i+=2) {
|
||||||
|
const desde = arr[i];
|
||||||
|
const hasta = arr[i+1] ?? null;
|
||||||
|
let durMins = null, obs = '';
|
||||||
|
if (hasta != null) durMins = Math.max(0, (hasta - desde)/60000);
|
||||||
|
else obs = 'incompleto';
|
||||||
|
result.push({ doc, name, isoDate, desde_ms: desde, hasta_ms: hasta, durMins, obs });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.sort((a,b)=>{
|
||||||
|
if (a.doc !== b.doc) return a.doc.localeCompare(b.doc);
|
||||||
|
if (a.isoDate !== b.isoDate) return a.isoDate.localeCompare(b.isoDate);
|
||||||
|
return (a.desde_ms||0) - (b.desde_ms||0);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
18
packages/devices/i60/namesProxy.mjs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
// Proxy de servicio de nombres (caché + normalización)
|
||||||
|
export class NamesServiceProxy {
|
||||||
|
constructor(fetchNamesForDocs){
|
||||||
|
this._fetch = typeof fetchNamesForDocs === 'function' ? fetchNamesForDocs : async () => ({});
|
||||||
|
this._cache = new Map();
|
||||||
|
}
|
||||||
|
async get(docs){
|
||||||
|
const ask = [];
|
||||||
|
for (const d of docs) if (!this._cache.has(d)) ask.push(d);
|
||||||
|
if (ask.length){
|
||||||
|
const map = await this._fetch(ask);
|
||||||
|
for (const [k,v] of Object.entries(map || {})) this._cache.set(String(k), v || {});
|
||||||
|
}
|
||||||
|
const out = {};
|
||||||
|
for (const d of docs) out[d] = this._cache.get(d) || {};
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
packages/devices/i60/package.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "@suitecoffee/driver-i60",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"description": "Driver genérico para lector I60 (asistencia)",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.mjs"
|
||||||
|
},
|
||||||
|
"files": ["src"],
|
||||||
|
"dependencies": {
|
||||||
|
"@suitecoffee/db": "workspace:*",
|
||||||
|
"@suitecoffee/utils": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
64
packages/devices/i60/parsing.mjs
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { cleanDoc, normDateStr, toUTCms } from './helpers.mjs';
|
||||||
|
|
||||||
|
// Parsea una línea (nombres preservados)
|
||||||
|
export function parseLine(line) {
|
||||||
|
const raw = String(line || '').replace(/\r/g, '').trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
// omitir encabezado
|
||||||
|
if (/^no[\t ]|^mchn[\t ]|^enno[\t ]|^name[\t ]|^datetime[\t ]/i.test(raw)) return null;
|
||||||
|
|
||||||
|
let parts = raw.split(/\t+/);
|
||||||
|
|
||||||
|
// Si no alcanzan 7 campos, intentar fallback con dos o más espacios
|
||||||
|
if (parts.length < 7) {
|
||||||
|
const dtMatch = raw.match(/(\d{2,4}[\/-]\d{1,2}[\/-]\d{1,2})\s+(\d{1,2}:\d{2}:\d{2})$/);
|
||||||
|
if (dtMatch) {
|
||||||
|
const head = raw.slice(0, dtMatch.index).trim();
|
||||||
|
const headParts = head.split(/\t+|\s{2,}/).filter(Boolean);
|
||||||
|
parts = [...headParts, dtMatch[1], dtMatch[2]];
|
||||||
|
} else {
|
||||||
|
parts = raw.split(/\s{2,}/).filter(Boolean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parts.length < 7) return null;
|
||||||
|
|
||||||
|
// 0:No, 1:Mchn, 2:EnNo(doc), 3:Name, 4:Mode, 5:Fecha, 6:Hora
|
||||||
|
const DOC_IDX = 2;
|
||||||
|
const NAME_IDX = 3;
|
||||||
|
const MODE_IDX = 4;
|
||||||
|
|
||||||
|
const doc = cleanDoc(parts[DOC_IDX]);
|
||||||
|
const name = String(parts[NAME_IDX] || '').trim();
|
||||||
|
const mode = String(parts[MODE_IDX] || '').trim();
|
||||||
|
|
||||||
|
let dateStr = String(parts[5] || '').trim();
|
||||||
|
let timeStr = String(parts[6] || '').trim();
|
||||||
|
|
||||||
|
// Caso: 7 columnas y última es "DateTime"
|
||||||
|
const last = parts[parts.length - 1];
|
||||||
|
const dtBoth = /(\d{2,4}[\/-]\d{1,2}[\/-]\d{1,2})\s+(\d{1,2}:\d{2}:\d{2})/.exec(last);
|
||||||
|
if (dtBoth) {
|
||||||
|
dateStr = dtBoth[1];
|
||||||
|
timeStr = dtBoth[2];
|
||||||
|
} else if (!timeStr && /\d{1,2}:\d{2}:\d{2}/.test(dateStr)) {
|
||||||
|
const m = dateStr.match(/^(.+?)\s+(\d{1,2}:\d{2}:\d{2})$/);
|
||||||
|
if (m) { dateStr = m[1]; timeStr = m[2]; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const iso = normDateStr(dateStr); // YY/MM/DD o YYYY/MM/DD -> YYYY-MM-DD
|
||||||
|
if (!iso || !/^\d{1,2}:\d{2}:\d{2}$/.test(timeStr)) return null;
|
||||||
|
|
||||||
|
const [H, M, S] = timeStr.split(':').map(n => parseInt(n, 10));
|
||||||
|
// mantener construcción local solo por paridad con el snippet original
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
const dt = new Date(`${iso}T${String(H).padStart(2,'0')}:${String(M).padStart(2,'0')}:${String(S).padStart(2,'0')}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
doc, name,
|
||||||
|
isoDate: iso,
|
||||||
|
time: timeStr,
|
||||||
|
dt_ms: toUTCms(iso, timeStr), // ⬅️ clave
|
||||||
|
mode
|
||||||
|
};
|
||||||
|
}
|
||||||
16
packages/devices/package.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "@suitecoffee/devices",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Utilidades de acceso a Postgres para SuiteCoffee (pool por proceso + helpers multi-tenant).",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.mjs"
|
||||||
|
},
|
||||||
|
"main": "./src/index.mjs",
|
||||||
|
"files": ["src"],
|
||||||
|
"sideEffects": false,
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": "^8.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
packages/oidc/package.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@suitecoffee/oidc",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.mjs",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"openid-client": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
70
packages/oidc/src/index.mjs
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
// @suitecoffee/oidc/src/index.mjs
|
||||||
|
// OIDC minimal (ESM) — siempre usa discovery vía OIDC_CONFIG_URL
|
||||||
|
|
||||||
|
import { Issuer } from 'openid-client';
|
||||||
|
|
||||||
|
let _cached = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ENV requeridas:
|
||||||
|
* - OIDC_CONFIG_URL -> https://.../.well-known/openid-configuration
|
||||||
|
* - OIDC_CLIENT_ID
|
||||||
|
* - OIDC_CLIENT_SECRET -> opcional (si tu client es confidencial)
|
||||||
|
* - OIDC_REDIRECT_URI
|
||||||
|
*/
|
||||||
|
export async function initOIDCFromEnv() {
|
||||||
|
if (_cached) return _cached;
|
||||||
|
|
||||||
|
const configUrl = process.env.OIDC_CONFIG_URL;
|
||||||
|
const clientId = process.env.OIDC_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.OIDC_CLIENT_SECRET || undefined;
|
||||||
|
const redirectUri = process.env.OIDC_REDIRECT_URI;
|
||||||
|
|
||||||
|
// Discovery directo (assume OK)
|
||||||
|
const issuer = await Issuer.discover(configUrl);
|
||||||
|
|
||||||
|
const client = new issuer.Client({
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
redirect_uris: [redirectUri],
|
||||||
|
response_types: ['code'],
|
||||||
|
token_endpoint_auth_method: clientSecret ? 'client_secret_post' : 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
_cached = {
|
||||||
|
issuer,
|
||||||
|
client,
|
||||||
|
|
||||||
|
// Construye la URL de autorización (PKCE)
|
||||||
|
getAuthUrl({ state, nonce, code_challenge, scope = 'openid email profile' }) {
|
||||||
|
return client.authorizationUrl({
|
||||||
|
scope,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
code_challenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
state,
|
||||||
|
nonce,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Intercambia el authorization code en el callback
|
||||||
|
async handleCallback(req, expected) {
|
||||||
|
const params = client.callbackParams(req);
|
||||||
|
return client.callback(redirectUri, params, expected);
|
||||||
|
},
|
||||||
|
|
||||||
|
// URL de fin de sesión (si el OP la expone)
|
||||||
|
endSessionUrl({ id_token_hint, post_logout_redirect_uri }) {
|
||||||
|
return client.endSessionUrl
|
||||||
|
? client.endSessionUrl({ id_token_hint, post_logout_redirect_uri })
|
||||||
|
: null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return _cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOIDC() {
|
||||||
|
if (!_cached) throw new Error('[OIDC] initOIDCFromEnv() no fue llamado aún');
|
||||||
|
return _cached;
|
||||||
|
}
|
||||||
54
services/app/.env.development
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
# =======================================================
|
||||||
|
# Runtime
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=3030
|
||||||
|
APP_BASE_URL=https://dev.suitecoffee.uy
|
||||||
|
# =======================================================
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# =======================================================
|
||||||
|
# Configuración de Dases de Datos
|
||||||
|
CORE_DB_HOST=dev-db
|
||||||
|
CORE_DB_NAME=dev_suitecoffee_core
|
||||||
|
CORE_DB_PORT=5432
|
||||||
|
CORE_DB_USER=dev-user-suitecoffee
|
||||||
|
CORE_DB_PASS=dev-pass-suitecoffee
|
||||||
|
|
||||||
|
TENANTS_DB_HOST=dev-tenants
|
||||||
|
TENANTS_DB_NAME=dev_suitecoffee_tenants
|
||||||
|
TENANTS_DB_PORT=5432
|
||||||
|
TENANTS_DB_USER=suitecoffee
|
||||||
|
TENANTS_DB_PASS=suitecoffee
|
||||||
|
# =======================================================
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# =======================================================
|
||||||
|
# Sesiones
|
||||||
|
SESSION_SECRET=Neon*Mammal*Boaster*Ludicrous*Fender8*Crablike
|
||||||
|
SESSION_NAME=sc.sid
|
||||||
|
# COOKIE_DOMAIN=dev.suitecoffee.uy
|
||||||
|
# =======================================================
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# =======================================================
|
||||||
|
# Authentik y OIDC
|
||||||
|
AK_TOKEN=h2apVHbd3ApMcnnSwfQPXbvximkvP8HnUE25ot3zXWuEEtJFaNCcOzDHB6Xw
|
||||||
|
AK_REDIS_URL=redis://ak-redis:6379
|
||||||
|
|
||||||
|
OIDC_LOGIN_URL=https://sso.suitecoffee.uy
|
||||||
|
OIDC_REDIRECT_URI=https://suitecoffee.uy/auth/callback
|
||||||
|
|
||||||
|
OIDC_CLIENT_ID=1orMM8vOvf3WkN2FejXYvUFpPtONG0Lx1eMlwIpW
|
||||||
|
OIDC_CLIENT_SECRET=t5wx13qBcM0EFQ3cGnUIAmLzvbdsQrUVPv1OGWjszWkEp35pJQ55t7vZeeShqG49kuRAaiXv6PSGJLhRfGaponGaJl8gH1uCL7KIxdmm7UihgYoAXB2dFhZV4zRxfze2
|
||||||
|
|
||||||
|
OIDC_CONFIG_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/.well-known/openid-configuration
|
||||||
|
OIDC_AUTHORIZE_URL=https://sso.suitecoffee.uy/application/o/authorize/
|
||||||
|
OIDC_TOKEN_URL=https://sso.suitecoffee.uy/application/o/token/
|
||||||
|
OIDC_USERINFO_URL=https://sso.suitecoffee.uy/application/o/userinfo/
|
||||||
|
OIDC_LOGOUT_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/end-session/
|
||||||
|
OIDC_JWKS_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/jwks/
|
||||||
|
|
||||||
|
# =======================================================
|
||||||
@ -1,5 +1,5 @@
|
|||||||
# Dockerfile.dev
|
# Dockerfile.dev
|
||||||
FROM node:22.18
|
FROM node:20.19.5-bookworm
|
||||||
|
|
||||||
# Definir variables de entorno con valores predeterminados
|
# Definir variables de entorno con valores predeterminados
|
||||||
# ARG NODE_ENV=production
|
# ARG NODE_ENV=production
|
||||||
|
|||||||
1025
services/app/package-lock.json
generated
@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "aplication",
|
"name": "aplication",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "src/index.js",
|
"main": "src/index.mjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "NODE_ENV=production node ./src/index.js",
|
"start": "NODE_ENV=production node ./src/index.mjs",
|
||||||
"dev": "NODE_ENV=development npx nodemon ./src/index.js",
|
"dev": "NODE_ENV=development npx nodemon ./src/index.mjs",
|
||||||
"test": "NODE_ENV=stage node ./src/index.js"
|
"test": "NODE_ENV=stage node ./src/index.mjs"
|
||||||
},
|
},
|
||||||
"author": "Mateo Saldain",
|
"author": "Mateo Saldain",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
@ -15,14 +15,39 @@
|
|||||||
"nodemon": "^3.1.10"
|
"nodemon": "^3.1.10"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
||||||
|
"@suitecoffee/scripts": "file:../../packages/core/scripts",
|
||||||
|
"@suitecoffee/db": "file:../../packages/core/db",
|
||||||
|
"@suitecoffee/redis": "file:../../packages/core/redis",
|
||||||
|
"@suitecoffee/middlewares": "file:../../packages/core/middlewares",
|
||||||
|
|
||||||
|
"@suitecoffee/api": "file:../../packages/api/",
|
||||||
|
"@suitecoffee/repositories": "file:../../packages/core/repositories",
|
||||||
|
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"chalk": "^5.6.0",
|
"chalk": "^5.6.0",
|
||||||
|
"connect-redis": "^9.0.0",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.2.1",
|
"dotenv": "^17.2.1",
|
||||||
|
"ejs": "^3.1.10",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"express-ejs-layouts": "^2.5.1",
|
"express-ejs-layouts": "^2.5.1",
|
||||||
|
"express-session": "^1.18.2",
|
||||||
|
"ioredis": "^5.7.0",
|
||||||
|
"jose": "^6.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"jwks-rsa": "^3.2.0",
|
||||||
|
"mime": "^4.1.0",
|
||||||
|
"morgan": "^1.10.1",
|
||||||
|
"node-appwrite": "^20.2.1",
|
||||||
|
"node-fetch": "^3.3.2",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"pg-format": "^1.0.4"
|
"pg-format": "^1.0.4",
|
||||||
|
"redis": "^5.8.2",
|
||||||
|
"serve-favicon": "^2.5.1"
|
||||||
},
|
},
|
||||||
|
"imports": { },
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"description": ""
|
"description": ""
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,250 +0,0 @@
|
|||||||
// app/src/index.js
|
|
||||||
import chalk from 'chalk'; // Colores!
|
|
||||||
import express from 'express';
|
|
||||||
import expressLayouts from 'express-ejs-layouts';
|
|
||||||
import cors from 'cors';
|
|
||||||
import { Pool } from 'pg';
|
|
||||||
|
|
||||||
// Rutas
|
|
||||||
import path from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = path.dirname(__filename);
|
|
||||||
|
|
||||||
// Variables de Entorno
|
|
||||||
import dotenv, { config } from 'dotenv';
|
|
||||||
|
|
||||||
// Obtención de la ruta de la variable de entorno correspondiente a NODE_ENV
|
|
||||||
try {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
dotenv.config({ path: path.resolve(__dirname, '../.env.development' )});
|
|
||||||
console.log(`Activando entorno de ->${chalk.green(` DEVELOPMENT `)}`);
|
|
||||||
} else if (process.env.NODE_ENV === 'stage') {
|
|
||||||
dotenv.config({ path: path.resolve(__dirname, '../.env.test' )});
|
|
||||||
console.log(`Activando entorno de ->${chalk.yellow(` TESTING `)}`);
|
|
||||||
} else if (process.env.NODE_ENV === 'production') {
|
|
||||||
dotenv.config({ path: path.resolve(__dirname, '../.env.production' )});
|
|
||||||
console.log(`Activando entorno de ->${chalk.red(` PRODUCTION `)}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log("A ocurrido un error al seleccionar el entorno. \nError: " + error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Renderiado
|
|
||||||
const app = express();
|
|
||||||
app.use(cors());
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(express.static(path.join(__dirname, 'pages')));
|
|
||||||
|
|
||||||
// Configuración de conexión PostgreSQL
|
|
||||||
|
|
||||||
const dbConfig = {
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASS,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
port: process.env.DB_LOCAL_PORT
|
|
||||||
};
|
|
||||||
|
|
||||||
const pool = new Pool(dbConfig);
|
|
||||||
|
|
||||||
|
|
||||||
async function verificarConexion() {
|
|
||||||
try {
|
|
||||||
const client = await pool.connect();
|
|
||||||
const res = await client.query('SELECT NOW() AS hora');
|
|
||||||
console.log(`\nConexión con la base de datos ${chalk.green(process.env.DB_NAME)} fue exitosa.`);
|
|
||||||
console.log('Fecha y hora actual de la base de datos:', res.rows[0].hora);
|
|
||||||
client.release(); // liberar el cliente de nuevo al pool
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error al conectar con la base de datos al iniciar:', error.message);
|
|
||||||
console.error(`Troubleshooting:\n1. Compruebe que las bases de datos se iniciaron correctamente.\n2. Verifique las credenciales y puertos de acceso a la base de datos.\n3. Si está conectandose a una base de datos externa a localhost, verifique las reglas del firewal de entrada y salida de ambos dispositivos.`);
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// === Servir páginas estáticas ===
|
|
||||||
|
|
||||||
app.get('/roles', (req, res) => res.sendFile(path.join(__dirname, 'pages', 'roles.html')));
|
|
||||||
app.get('/usuarios', (req, res) => res.sendFile(path.join(__dirname, 'pages', 'usuarios.html')));
|
|
||||||
app.get('/categorias',(req, res) => res.sendFile(path.join(__dirname, 'pages', 'categorias.html')));
|
|
||||||
app.get('/productos', (req, res) => res.sendFile(path.join(__dirname, 'pages', 'productos.html')));
|
|
||||||
|
|
||||||
|
|
||||||
// Helper de consulta con acquire/release explícito
|
|
||||||
async function q(text, params) {
|
|
||||||
const client = await pool.connect();
|
|
||||||
try {
|
|
||||||
return await client.query(text, params);
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === API Roles ===
|
|
||||||
// GET: listar
|
|
||||||
app.get('/api/roles', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { rows } = await q('SELECT id_rol, nombre FROM roles ORDER BY id_rol ASC');
|
|
||||||
res.json(rows);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
res.status(500).json({ error: 'No se pudo listar roles' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST: crear
|
|
||||||
app.post('/api/roles', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { nombre } = req.body;
|
|
||||||
if (!nombre || !nombre.trim()) return res.status(400).json({ error: 'Nombre requerido' });
|
|
||||||
const { rows } = await q(
|
|
||||||
'INSERT INTO roles (nombre) VALUES ($1) RETURNING id_rol, nombre',
|
|
||||||
[nombre.trim()]
|
|
||||||
);
|
|
||||||
res.status(201).json(rows[0]);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
// Manejo de único/duplicado
|
|
||||||
if (e.code === '23505') return res.status(409).json({ error: 'El rol ya existe' });
|
|
||||||
res.status(500).json({ error: 'No se pudo crear el rol' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// === API Usuarios ===
|
|
||||||
// GET: listar
|
|
||||||
app.get('/api/usuarios', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { rows } = await q(`
|
|
||||||
SELECT id_usuario, documento, img_perfil, nombre, apellido, correo, telefono, fec_nacimiento, activo
|
|
||||||
FROM usuarios
|
|
||||||
ORDER BY id_usuario ASC
|
|
||||||
`);
|
|
||||||
res.json(rows);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
res.status(500).json({ error: 'No se pudo listar usuarios' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST: crear
|
|
||||||
app.post('/api/usuarios', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { documento, nombre, apellido, correo, telefono, fec_nacimiento } = req.body;
|
|
||||||
if (!nombre || !apellido) return res.status(400).json({ error: 'Nombre y apellido requeridos' });
|
|
||||||
|
|
||||||
const { rows } = await q(`
|
|
||||||
INSERT INTO usuarios (documento, nombre, apellido, correo, telefono, fec_nacimiento)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
RETURNING id_usuario, documento, nombre, apellido, correo, telefono, fec_nacimiento, activo
|
|
||||||
`, [
|
|
||||||
documento || null,
|
|
||||||
nombre.trim(),
|
|
||||||
apellido.trim(),
|
|
||||||
correo || null,
|
|
||||||
telefono || null,
|
|
||||||
fec_nacimiento || null
|
|
||||||
]);
|
|
||||||
|
|
||||||
res.status(201).json(rows[0]);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
if (e.code === '23505') return res.status(409).json({ error: 'Documento/Correo/Teléfono ya existe' });
|
|
||||||
res.status(500).json({ error: 'No se pudo crear el usuario' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// === API Categorías ===
|
|
||||||
// GET: listar
|
|
||||||
app.get('/api/categorias', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { rows } = await q('SELECT id_categoria, nombre, visible FROM categorias ORDER BY id_categoria ASC');
|
|
||||||
res.json(rows);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
res.status(500).json({ error: 'No se pudo listar categorías' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST: crear
|
|
||||||
app.post('/api/categorias', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { nombre, visible } = req.body;
|
|
||||||
if (!nombre || !nombre.trim()) return res.status(400).json({ error: 'Nombre requerido' });
|
|
||||||
const vis = (typeof visible === 'boolean') ? visible : true;
|
|
||||||
|
|
||||||
const { rows } = await q(`
|
|
||||||
INSERT INTO categorias (nombre, visible)
|
|
||||||
VALUES ($1, $2)
|
|
||||||
RETURNING id_categoria, nombre, visible
|
|
||||||
`, [nombre.trim(), vis]);
|
|
||||||
|
|
||||||
res.status(201).json(rows[0]);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
if (e.code === '23505') return res.status(409).json({ error: 'La categoría ya existe' });
|
|
||||||
res.status(500).json({ error: 'No se pudo crear la categoría' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// === API Productos ===
|
|
||||||
// GET: listar
|
|
||||||
app.get('/api/productos', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { rows } = await q(`
|
|
||||||
SELECT id_producto, nombre, img_producto, precio, activo, id_categoria
|
|
||||||
FROM productos
|
|
||||||
ORDER BY id_producto ASC
|
|
||||||
`);
|
|
||||||
res.json(rows);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
res.status(500).json({ error: 'No se pudo listar productos' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST: crear
|
|
||||||
app.post('/api/productos', async (req, res) => {
|
|
||||||
try {
|
|
||||||
let { nombre, id_categoria, precio } = req.body;
|
|
||||||
if (!nombre || !nombre.trim()) return res.status(400).json({ error: 'Nombre requerido' });
|
|
||||||
id_categoria = parseInt(id_categoria, 10);
|
|
||||||
precio = parseFloat(precio);
|
|
||||||
if (!Number.isInteger(id_categoria)) return res.status(400).json({ error: 'id_categoria inválido' });
|
|
||||||
if (!(precio >= 0)) return res.status(400).json({ error: 'precio inválido' });
|
|
||||||
|
|
||||||
const { rows } = await q(`
|
|
||||||
INSERT INTO productos (nombre, id_categoria, precio)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
RETURNING id_producto, nombre, precio, activo, id_categoria
|
|
||||||
`, [nombre.trim(), id_categoria, precio]);
|
|
||||||
|
|
||||||
res.status(201).json(rows[0]);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
// FK categories / checks
|
|
||||||
if (e.code === '23503') return res.status(400).json({ error: 'La categoría no existe' });
|
|
||||||
res.status(500).json({ error: 'No se pudo crear el producto' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// Colores personalizados
|
|
||||||
let primaryColor = chalk.hex('#'+`${process.env.COL_PRI}`);
|
|
||||||
let secondaryColor = chalk.hex('#'+`${process.env.COL_SEC}`);
|
|
||||||
// let backgroundColor = chalk.hex('#'+`${process.env.COL_BG}`);
|
|
||||||
|
|
||||||
|
|
||||||
app.use(expressLayouts);
|
|
||||||
// Iniciar servidor
|
|
||||||
app.listen( process.env.PORT, () => {
|
|
||||||
console.log(`Servidor de ${chalk.red('aplicación')} de ${secondaryColor('SuiteCoffee')} corriendo en ${chalk.yellow(`http://localhost:${process.env.PORT}\n`)}` );
|
|
||||||
console.log(chalk.grey(`Comprobando accesibilidad a la db ${chalk.green(process.env.DB_NAME)} del host ${chalk.white(`${process.env.DB_HOST}`)} ...`));
|
|
||||||
verificarConexion();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/health", async (req, res) => {
|
|
||||||
// Podés chequear DB aquí. 200 = healthy; 503 = not ready.
|
|
||||||
res.status(200).json({ status: "ok" });
|
|
||||||
});
|
|
||||||
146
services/app/src/index.mjs
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
// services/app/src/index.js
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// SuiteCoffee — Aplicación Principal (Express)
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
import 'dotenv/config';
|
||||||
|
import favicon from 'serve-favicon'; // Favicon
|
||||||
|
import session from 'express-session';
|
||||||
|
import express from 'express'; // Framework para enderizado de apps Web
|
||||||
|
import expressLayouts from 'express-ejs-layouts';
|
||||||
|
import { RedisStore } from 'connect-redis';
|
||||||
|
|
||||||
|
import { checkRequiredEnvVars } from '@suitecoffee/scripts';
|
||||||
|
import { verificarConexionCore, verificarConexionTenants } from '@suitecoffee/db'; // dbCore y dbTenants desde paquete
|
||||||
|
import { redisAuthentik, verificarConexionRedisAuthentik} from '@suitecoffee/redis';
|
||||||
|
import { requireAuth, datosGlobales, tenantContext, resolveTenantFromCore } from '@suitecoffee/middlewares';
|
||||||
|
import { apiv1 } from '@suitecoffee/api'; // Rutas API v1
|
||||||
|
|
||||||
|
import expressPages from './views/routes.js'; // Rutas "/", "/dashboard", ...
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url'; // Converts a file:// URL string or URL object into a platform-specific file
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Validación de entorno mínimo (ajusta nombres si difieren)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
checkRequiredEnvVars(
|
||||||
|
'PORT', 'APP_BASE_URL',
|
||||||
|
'SESSION_SECRET', 'SESSION_NAME', 'AK_REDIS_URL',
|
||||||
|
'OIDC_CLIENT_ID', 'OIDC_REDIRECT_URI',
|
||||||
|
'OIDC_CONFIG_URL' // o 'OIDC_ISSUER'
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// Variables del sistema
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
|
||||||
|
// De entorno
|
||||||
|
const PORT = process.env.PORT;
|
||||||
|
const SESSION_NAME = process.env.SESSION_NAME;
|
||||||
|
const SESSION_SECRET = process.env.SESSION_SECRET;
|
||||||
|
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// App + Motor de vistas EJS
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
const app = express();
|
||||||
|
app.disable("x-powered-by");
|
||||||
|
app.set('trust proxy', true);
|
||||||
|
app.set("views", path.join(__dirname, "views/pages"));
|
||||||
|
app.set("layout", path.join(__dirname, "views/layouts/main"));
|
||||||
|
// app.set("layout", "layouts/main");
|
||||||
|
app.set("view engine", "ejs");
|
||||||
|
app.use(favicon(path.join(__dirname, 'public', 'favicon', 'favicon.ico'), { maxAge: '1y' }));
|
||||||
|
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
app.use(express.static(path.join(__dirname,"public"), { etag: false, maxAge: 0, setHeaders: (res, path) => { res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); }}));
|
||||||
|
app.use(expressLayouts);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Redis
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
await redisAuthentik.connect();
|
||||||
|
const redisClient = redisAuthentik.getClient();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Cookies de sesión
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
app.use(cookieParser(SESSION_SECRET));
|
||||||
|
|
||||||
|
app.use(session({
|
||||||
|
name: SESSION_NAME,
|
||||||
|
store: new RedisStore({ client: redisClient, prefix: 'sess:' }),
|
||||||
|
secret: SESSION_SECRET,
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax', // 'none' si necesitás third-party estricto
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
...(COOKIE_DOMAIN ? { domain: COOKIE_DOMAIN } : {}), // ✅ compatibilidad subdominios
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// Renderizado de las páginas importadas desde '#pages' + configuración global
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// app.use(expressPages); // Renderizado trae las paginas desde ./services/manso/src/routes/routes.js
|
||||||
|
// app.use(requireAuth({ redirectTo: '/auth/login' }), expressPages); // Renderizado trae las paginas desde ./services/manso/src/routes/routes.js
|
||||||
|
// app.use(requireAuth({ redirectTo: '/auth/login' }), tenantContext({ debug: true }), expressPages); // Renderizado trae las paginas desde ./services/manso/src/routes/routes.js
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
requireAuth({ redirectTo: '/auth/login' }),
|
||||||
|
tenantContext({
|
||||||
|
debug: true,
|
||||||
|
resolveTenant: (req, sess) => resolveTenantFromCore(req, sess, { debug: true }),
|
||||||
|
// acceptStates: ['ready'] // (default) o ['ready','provisioning'] si querés permitir provisión
|
||||||
|
}),
|
||||||
|
expressPages
|
||||||
|
);
|
||||||
|
|
||||||
|
app.use(datosGlobales);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// API v1
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
app.use("/api/v1", requireAuth({ redirectTo: '/auth/login' }), tenantContext(), apiv1);
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// Inicio del servidor
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
|
||||||
|
app.listen(PORT, async () => {
|
||||||
|
console.log(`[APP] SuiteCoffee corriendo en http://localhost:${PORT}`);
|
||||||
|
await verificarConexionCore();
|
||||||
|
await verificarConexionTenants();
|
||||||
|
await verificarConexionRedisAuthentik();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Healthcheck
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
app.get('/health', (_req, res) => {
|
||||||
|
res.status(200).json({ status: 'ok'})
|
||||||
|
// console.log(`[APP] Saludable`)
|
||||||
|
});
|
||||||
@ -1,70 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="es">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<title>Categorías</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Categorías</h1>
|
|
||||||
|
|
||||||
<h2>Crear categoría</h2>
|
|
||||||
<form id="form-categoria">
|
|
||||||
<label>Nombre:
|
|
||||||
<input type="text" name="nombre" required />
|
|
||||||
</label>
|
|
||||||
<label>Visible:
|
|
||||||
<select name="visible">
|
|
||||||
<option value="true" selected>Sí</option>
|
|
||||||
<option value="false">No</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<button type="submit">Guardar</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<h2>Listado</h2>
|
|
||||||
<button id="btn-recargar">Recargar</button>
|
|
||||||
<table border="1" cellpadding="6">
|
|
||||||
<thead><tr><th>ID</th><th>Nombre</th><th>Visible</th></tr></thead>
|
|
||||||
<tbody id="tbody"></tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const API = '/api/categorias';
|
|
||||||
|
|
||||||
async function listar() {
|
|
||||||
const res = await fetch(API);
|
|
||||||
const data = await res.json();
|
|
||||||
const tbody = document.getElementById('tbody');
|
|
||||||
tbody.innerHTML = '';
|
|
||||||
data.forEach(c => {
|
|
||||||
const tr = document.createElement('tr');
|
|
||||||
tr.innerHTML = `<td>${c.id_categoria}</td><td>${c.nombre}</td><td>${c.visible ? 'Sí' : 'No'}</td>`;
|
|
||||||
tbody.appendChild(tr);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('form-categoria').addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const fd = new FormData(e.target);
|
|
||||||
const nombre = fd.get('nombre').trim();
|
|
||||||
const visible = fd.get('visible') === 'true';
|
|
||||||
if (!nombre) return;
|
|
||||||
const res = await fetch(API, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type':'application/json'},
|
|
||||||
body: JSON.stringify({ nombre, visible })
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json().catch(()=>({error:'Error'}));
|
|
||||||
alert('Error: ' + (err.error || res.statusText));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
e.target.reset();
|
|
||||||
await listar();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('btn-recargar').addEventListener('click', listar);
|
|
||||||
listar();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="es">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<title>Productos</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Productos</h1>
|
|
||||||
|
|
||||||
<h2>Crear producto</h2>
|
|
||||||
<form id="form-producto">
|
|
||||||
<div>
|
|
||||||
<label>Nombre:
|
|
||||||
<input name="nombre" type="text" required />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Precio:
|
|
||||||
<input name="precio" type="number" step="0.01" min="0" required />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Categoría:
|
|
||||||
<select name="id_categoria" id="sel-categoria" required></select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<button type="submit">Guardar</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<h2>Listado</h2>
|
|
||||||
<button id="btn-recargar">Recargar</button>
|
|
||||||
<table border="1" cellpadding="6">
|
|
||||||
<thead>
|
|
||||||
<tr><th>ID</th><th>Nombre</th><th>Precio</th><th>Activo</th><th>ID Categoría</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="tbody"></tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const API = '/api/productos';
|
|
||||||
const API_CAT = '/api/categorias';
|
|
||||||
|
|
||||||
async function cargarCategorias() {
|
|
||||||
const res = await fetch(API_CAT);
|
|
||||||
const data = await res.json();
|
|
||||||
const sel = document.getElementById('sel-categoria');
|
|
||||||
sel.innerHTML = '<option value="" disabled selected>Seleccione...</option>';
|
|
||||||
data.forEach(c => {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = c.id_categoria;
|
|
||||||
opt.textContent = `${c.id_categoria} - ${c.nombre}`;
|
|
||||||
sel.appendChild(opt);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listar() {
|
|
||||||
const res = await fetch(API);
|
|
||||||
const data = await res.json();
|
|
||||||
const tbody = document.getElementById('tbody');
|
|
||||||
tbody.innerHTML = '';
|
|
||||||
data.forEach(p => {
|
|
||||||
const tr = document.createElement('tr');
|
|
||||||
tr.innerHTML = `
|
|
||||||
<td>${p.id_producto}</td>
|
|
||||||
<td>${p.nombre}</td>
|
|
||||||
<td>${Number(p.precio).toFixed(2)}</td>
|
|
||||||
<td>${p.activo ? 'Sí' : 'No'}</td>
|
|
||||||
<td>${p.id_categoria}</td>
|
|
||||||
`;
|
|
||||||
tbody.appendChild(tr);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('form-producto').addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const fd = new FormData(e.target);
|
|
||||||
const payload = {
|
|
||||||
nombre: fd.get('nombre').trim(),
|
|
||||||
precio: parseFloat(fd.get('precio')),
|
|
||||||
id_categoria: parseInt(fd.get('id_categoria'), 10)
|
|
||||||
};
|
|
||||||
if (!payload.nombre || isNaN(payload.precio) || isNaN(payload.id_categoria)) return;
|
|
||||||
|
|
||||||
const res = await fetch(API, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type':'application/json'},
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json().catch(()=>({error:'Error'}));
|
|
||||||
alert('Error: ' + (err.error || res.statusText));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
e.target.reset();
|
|
||||||
await listar();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('btn-recargar').addEventListener('click', listar);
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
await cargarCategorias();
|
|
||||||
await listar();
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||