29 Commits

Author SHA1 Message Date
msaldain c4097bc737 . 2025-10-16 19:49:50 +00:00
msaldain ba6b4fef4f Todos los Microservicios saludables.
Nuevo microservicio Plugins + cambios a microservicios anteriores, creación de módulos para conexiones a bases de datos y ajustes en las variables de entorno.
2025-10-10 15:11:17 +00:00
msaldain a31b411437 Re estructuración de patrones de diseño con el código de Manso 2025-10-08 18:12:58 +00:00
msaldain b4c5d2af4f Puesta a punto 2025-09-22 16:59:29 +00:00
msaldain 69f5860b7f Inclusión del dominio suitecoffee.uy al NPM.
Se ajustaron los problemas de renderizado y redirección mayores de https://suitecoffee.uy/
Se re-creó el archivo ~/SuiteCoffee/services/app/src/index.js para mantener un orden adecuado
Las rutas exigen una cookie de seción para cargarse, de o contrario redireccionan a  https://suitecoffee.uy/auth/login para iniciar o crear sesión de usuario, excepto https://suitecoffee.uy/inicio que se mantene de esta manera con motivos de desarrollo
2025-09-09 14:20:05 +00:00
msaldain 5d078f3932 Carga completa 2025-09-06 11:19:42 +00:00
msaldain 237a5427dd Mucha cosa y es muy tarde.
- Anda parte del registro
2025-09-05 08:13:09 +00:00
msaldain 80778c0ed9 Pre-reordenación 2025-09-05 04:02:39 +00:00
msaldain 8522d02170 Intento de integrar Authentik 2025-09-05 01:33:52 +00:00
msaldain cbcea72848 Importación de feature/registration 2025-09-05 00:45:16 +00:00
msaldain 25876e733b Actualización de archivos para corresponder a las
funcionalidades de "Compras" y de "Reportes".
2025-09-01 20:32:43 +00:00
msaldain 93ac1db5f1 Creación de la sección "Reportes" y "Compras" 2025-09-01 20:32:39 +00:00
msaldain c9b4b4871d Creación de sección Usuarios para administrar las entradas y salidas del personal 2025-08-30 04:49:59 +00:00
msaldain 9c5219863b Modificación o agregado de productos y materias primas 2025-08-29 14:22:30 +00:00
msaldain ce3d01a180 Impresión de tickets correcta. 2025-08-29 06:22:10 +00:00
msaldain 57dbd5b1fa 290825-0209 2025-08-29 05:09:44 +00:00
msaldain 44d1adecdc Desarrollo de views + frontend 2025-08-29 02:27:28 +00:00
msaldain 09610df995 Conexión satisfactoria con la base de datos creada para el workarround, las tablas, columnas y filas se muestran en el bashboard 2025-08-25 18:41:51 +00:00
msaldain 922da441eb Creado y levantado del workaround 2025-08-25 17:21:27 +00:00
msaldain f7962f894d Actualización de función /planes en base de datos + primera versión del README 2025-08-25 16:05:12 +00:00
msaldain 5342fb489d Actualización de archivos compose para centralizar y tanto NPM como El gestó de db's dentro de un proyecto propio "suitecoffee_tools" con acceso a las redes de producción como de desarrollo 2025-08-21 17:34:53 +00:00
msaldain c42814f963 Varios cambios realizados en cuando a la organización de los compose de docker.
Se adoptó la versión actualmente recomandada por el equipo de docker el formado "compose.yaml"

También se crearon nuevos scripts y actualizaron algunos para adaptarse a los nuevos archivos.
2025-08-19 18:26:21 +00:00
msaldain 0d1de7f7e2 Varios cambios realizados en cuando a la organización de los compose de docker.
Se adoptó la versión actualmente recomandada por el equipo de docker el formado "compose.yaml"

También se crearon nuevos scripts y actualizaron algunos para adaptarse a los nuevos archivos.
2025-08-19 18:26:13 +00:00
msaldain b34433a71e mecanismo para respaldar los volumenes del proyecto 2025-08-18 21:31:28 +00:00
msaldain 492d844523 Todos los problemas de dependencias, credenciales y renderizado de raiz se solucionaron hasta quí 2025-08-18 20:35:47 +00:00
msaldain 8237e38164 Más dependencias solucionadas. Errores en los documentos docker-compose 2025-08-18 19:52:46 +00:00
msaldain e04be61952 Modificado el docker-compose L67.
Servicio auth. Estaba importandoce las dependencias de app no de auth...
2025-08-18 19:32:32 +00:00
msaldain 1b7e4f36e9 Más actualización de dependencias 2025-08-18 19:14:00 +00:00
msaldain d8cc6e9613 Puesta a punto de dependencias.
Falla renderización en la raíz del sistema
2025-08-18 19:13:56 +00:00
179 changed files with 22255 additions and 2387 deletions
+58
View 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 -1
View File
@@ -1,6 +1,9 @@
# Ignorar los directorios de dependencias # Ignorar los directorios de dependencias
node_modules/ node_modules/
# Ignorar los volumenes respaldados de docker compose
docker-volumes*
# Ignorar las carpetas de bases de datos # Ignorar las carpetas de bases de datos
.db/ .db/
@@ -30,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
View File
@@ -0,0 +1,303 @@
# SuiteCoffee — Sistema de gestión para cafeterías (Dockerizado y multiservicio)
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 multitenencia, 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 (Lets 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).
- **Multitenencia** 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 **ratelimit** 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 multitenant**: 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
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 1005 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 717 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 717 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1005 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

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
View File
@@ -0,0 +1,2 @@
-116
View File
@@ -1,116 +0,0 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Cargar Comanda</title>
</head>
<body>
<h2>Formulario de Carga de Comanda</h2>
<form id="formComanda">
<label for="mesaSelect">Mesa:</label>
<select id="mesaSelect" name="mesa_id" required></select>
<br><br>
<label for="productoSelect">Producto:</label>
<select id="productosSelect"></select>
<label for="cantidadInput">Cantidad:</label>
<input type="number" id="cantidadInput" min="1" value="1">
<button type="button" onclick="agregarProducto()">Agregar</button>
<ul id="listaProductos"></ul>
<input type="hidden" name="productos" id="productosJSON">
<br>
<button type="submit">Guardar Comanda</button>
</form>
<script>
// Cargar categorías y mesas (productos se maneja distinto por el precio)
async function cargarSelect(endpoint, selectId, mostrar) {
const res = await fetch(endpoint);
const data = await res.json();
const select = document.getElementById(selectId);
data.forEach(item => {
const option = document.createElement('option');
option.value = item.id;
option.textContent = mostrar(item);
select.appendChild(option);
});
}
async function cargarProductosConPrecio() {
const res = await fetch('/api/obtenerProductos');
const productos = await res.json();
const select = document.getElementById('productosSelect');
productos.forEach(prod => {
const option = document.createElement('option');
option.value = prod.id;
option.textContent = `${prod.nombre} ($${prod.precio})`;
option.setAttribute('data-precio', prod.precio);
select.appendChild(option);
});
}
const listaProductos = [];
function agregarProducto() {
const select = document.getElementById('productosSelect');
const cantidad = parseInt(document.getElementById('cantidadInput').value);
const productoId = select.value;
const nombre = select.options[select.selectedIndex].textContent;
const precioUnitario = parseFloat(select.options[select.selectedIndex].dataset.precio);
if (!productoId || isNaN(cantidad) || cantidad <= 0) {
alert("Producto o cantidad inválida");
return;
}
listaProductos.push({
producto_id: productoId,
cantidad,
precio_unitario: precioUnitario
});
// Mostrar en lista visual
const li = document.createElement('li');
li.textContent = `${nombre} x${cantidad} - $${(precioUnitario * cantidad).toFixed(2)}`;
document.getElementById('listaProductos').appendChild(li);
// Actualizar JSON oculto
document.getElementById('productosJSON').value = JSON.stringify(listaProductos);
}
document.getElementById('formComanda').addEventListener('submit', async function (e) {
e.preventDefault();
const mesaId = document.getElementById('mesaSelect').value;
const productos = listaProductos;
if (!mesaId || productos.length === 0) {
alert('Selecciona una mesa y al menos un producto');
return;
}
const res = await fetch('/api/cargarComandas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mesa_id: mesaId,
productos: productos
})
});
const resultado = await res.json();
alert(resultado.mensaje || 'Comanda cargada correctamente');
location.reload();
});
window.onload = () => {
cargarSelect('/api/obtenerMesas', 'mesaSelect', mesa => `Mesa ${mesa.numero}`);
cargarProductosConPrecio();
};
</script>
</body>
</html>
-195
View File
@@ -1,195 +0,0 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>Crear comanda</title>
</head>
<body>
<h1>Crear comanda</h1>
<section>
<label>
Mesa (ID o número):
<input type="number" id="mesaId" required />
</label>
<br />
<label>
Mozo (ID de usuario):
<input type="text" id="mozoId" required />
</label>
<br />
<label>
Notas:
<input type="text" id="notas" placeholder="Sin observaciones" />
</label>
</section>
<hr />
<section>
<h2>Agregar productos</h2>
<label>
Producto:
<select id="productoSelect"></select>
</label>
<label>
Cantidad:
<input type="number" id="cantidadInput" value="1" min="1" />
</label>
<button id="agregarBtn">Agregar</button>
<h3>Items de la comanda</h3>
<ul id="itemsList"></ul>
</section>
<hr />
<button id="enviarBtn">Enviar comanda</button>
<pre id="salida"></pre>
<script>
// === CONFIGURA AQUÍ SI ES NECESARIO ===
const API_BASE = "http://localhost:3000"; // Cambia al puerto/host de tu servidor Node
const PRODUCTOS_PATH = "/productos"; // GET
const COMANDAS_PATH = "/comandas"; // POST
// === ESTADO EN MEMORIA ===
const productosCache = new Map(); // id -> {id, nombre, ...}
const items = []; // {producto_id, cantidad}
// === ELEMENTOS DEL DOM ===
const productoSelect = document.getElementById("productoSelect");
const cantidadInput = document.getElementById("cantidadInput");
const agregarBtn = document.getElementById("agregarBtn");
const itemsList = document.getElementById("itemsList");
const enviarBtn = document.getElementById("enviarBtn");
const mesaIdInput = document.getElementById("mesaId");
const mozoIdInput = document.getElementById("mozoId");
const notasInput = document.getElementById("notas");
const salida = document.getElementById("salida");
// === UTILIDADES ===
function renderItems() {
itemsList.innerHTML = "";
items.forEach((it, idx) => {
const li = document.createElement("li");
const prod = productosCache.get(it.producto_id);
const nombre = prod ? (prod.nombre || prod.name || `Producto ${it.producto_id}`) : `ID ${it.producto_id}`;
li.textContent = `${nombre} × ${it.cantidad}`;
const btn = document.createElement("button");
btn.textContent = "Quitar";
btn.onclick = () => {
items.splice(idx, 1);
renderItems();
};
li.appendChild(document.createTextNode(" "));
li.appendChild(btn);
itemsList.appendChild(li);
});
}
function mostrarMensaje(obj) {
try {
salida.textContent = typeof obj === "string" ? obj : JSON.stringify(obj, null, 2);
} catch {
salida.textContent = String(obj);
}
}
function validarEnteroPositivo(valor) {
const n = Number(valor);
return Number.isInteger(n) && n > 0;
}
// === LOGICA ===
async function cargarProductos() {
try {
const res = await fetch(API_BASE + PRODUCTOS_PATH);
if (!res.ok) throw new Error("No se pudieron obtener los productos");
const data = await res.json();
// Espera un array de productos con al menos {id, nombre}
productoSelect.innerHTML = "";
data.forEach(p => {
productosCache.set(p.id, p);
const opt = document.createElement("option");
opt.value = p.id;
opt.textContent = p.nombre || p.name || `Producto ${p.id}`;
productoSelect.appendChild(opt);
});
if (data.length === 0) {
mostrarMensaje("No hay productos disponibles.");
}
} catch (e) {
mostrarMensaje("Error cargando productos: " + e.message);
}
}
agregarBtn.addEventListener("click", () => {
const prodId = Number(productoSelect.value);
const cant = Number(cantidadInput.value);
if (!validarEnteroPositivo(prodId)) {
return mostrarMensaje("Selecciona un producto válido.");
}
if (!validarEnteroPositivo(cant)) {
return mostrarMensaje("La cantidad debe ser un entero positivo.");
}
// Si ya existe el producto en la lista, acumula cantidad
const existente = items.find(i => i.producto_id === prodId);
if (existente) {
existente.cantidad += cant;
} else {
items.push({ producto_id: prodId, cantidad: cant });
}
renderItems();
cantidadInput.value = 1;
mostrarMensaje("Producto agregado.");
});
enviarBtn.addEventListener("click", async () => {
const mesa_id = Number(mesaIdInput.value);
const mozo_id = mozoIdInput.value.trim();
const notas = notasInput.value.trim();
if (!validarEnteroPositivo(mesa_id)) {
return mostrarMensaje("Debes indicar un número de mesa válido.");
}
if (!mozo_id) {
return mostrarMensaje("Debes indicar el ID del mozo.");
}
if (items.length === 0) {
return mostrarMensaje("Agrega al menos un producto a la comanda.");
}
const payload = { mesa_id, mozo_id, notas, items };
enviarBtn.disabled = true;
mostrarMensaje("Enviando comanda...");
try {
const res = await fetch(API_BASE + COMANDAS_PATH, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) {
mostrarMensaje({ error: "No se pudo crear la comanda", detalle: data });
} else {
mostrarMensaje({ ok: true, comanda: data });
// Limpia el estado
items.length = 0;
renderItems();
notasInput.value = "";
}
} catch (e) {
mostrarMensaje("Error al enviar comanda: " + e.message);
} finally {
enviarBtn.disabled = false;
}
});
// Cargar productos al iniciar
cargarProductos();
</script>
</body>
</html>
-43
View File
@@ -1,43 +0,0 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Dashboard</title>
</head>
<body>
<h2>Mesas</h2>
<select id="mesasSelect"></select>
<h2>Productos</h2>
<select id="productosSelect"></select>
<h2>Categorías</h2>
<select id="categoriasSelect"></select>
<h2>Comandas</h2>
<select id="comandasSelect"></select>
<script>
async function cargarDatos(endpoint, selectId, mostrar) {
const res = await fetch(endpoint); // Usar endpoint relativo
const data = await res.json();
const select = document.getElementById(selectId);
data.forEach(item => {
const option = document.createElement('option');
option.value = item.id;
option.textContent = mostrar(item);
select.appendChild(option);
});
}
// Al cargar la página, cargamos los datos
window.onload = () => {
cargarDatos('api/obtenerMesas', 'mesasSelect', mesa => `Mesa ${mesa.numero}`);
cargarDatos('api/obtenerProductos', 'productosSelect', prod => `${prod.nombre} ($${prod.precio})`);
cargarDatos('api/obtenerCategorias', 'categoriasSelect', cat => cat.nombre);
cargarDatos('api/obtenerComandas', 'comandasSelect', com => `Comanda ${com.id} - Mesa ${com.mesa_id} - $${com.total}`);
};
</script>
</body>
</html>
+633
View File
@@ -0,0 +1,633 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import argparse
import datetime
import json
import os
import pathlib
import shlex
import subprocess
import sys
from typing import List, Dict, Tuple, Optional, Set
PROJECT_ROOT = pathlib.Path.cwd()
COMPOSE_BASE = PROJECT_ROOT / "compose.yaml"
COMPOSE_DEV = PROJECT_ROOT / "compose.dev.yaml"
COMPOSE_PROD = PROJECT_ROOT / "compose.prod.yaml"
COMPOSE_NPM = PROJECT_ROOT / "compose.npm.yaml"
COMPOSE_DBVR = PROJECT_ROOT / "compose.dbeaver.yaml"
GLOBAL_DEFAULT_PROJECT = "suitecoffee" # proyecto global (NPM/DBeaver)
# ---------- Shell utils ----------
def run(cmd: List[str], check=True, capture_output=True, text=True) -> subprocess.CompletedProcess:
return subprocess.run(cmd, check=check, capture_output=capture_output, text=text)
def which(program: str) -> bool:
from shutil import which as _which
return _which(program) is not None
# ---------- Docker volume discovery ----------
def docker_volume_ls_json(filters: List[str]) -> List[Dict[str, str]]:
"""
Devuelve objetos de 'docker volume ls' (formato json por entrada).
Soporta filtros como '--filter label=...'.
"""
cmd = ["docker", "volume", "ls", "--format", "{{json .}}"]
for f in filters:
cmd += ["--filter", f]
try:
cp = run(cmd)
except subprocess.CalledProcessError:
return []
out = []
for line in cp.stdout.splitlines():
line = line.strip()
if not line:
continue
try:
out.append(json.loads(line))
except json.JSONDecodeError:
pass
return out
def docker_volume_ls_names(filters: List[str]) -> List[str]:
"""Devuelve solo los nombres (Name) con filtros aplicados."""
rows = docker_volume_ls_json(filters)
names = []
for v in rows:
name = v.get("Name")
if name:
names.append(name)
return names
def list_by_label_project(project: str) -> List[Dict[str, str]]:
return docker_volume_ls_json([f"label=com.docker.compose.project={project}"])
def list_by_name_prefix(prefix: str) -> List[Dict[str, str]]:
vols = docker_volume_ls_json([])
keep = []
for v in vols:
name = v.get("Name")
if not name:
continue
if name.startswith(prefix + "_") or name.startswith(prefix + "-") or name == prefix:
keep.append(v)
return keep
def normalize_project_name(p: str) -> str:
return (p or "").replace(" ", "_")
# ---------- Compose config parsing ----------
def compose_config_json(files: List[pathlib.Path]) -> Optional[dict]:
if not files or not all(p.exists() for p in files):
return None
cmd = ["docker", "compose"]
for f in files:
cmd += ["-f", str(f)]
cmd += ["config", "--format", "json"]
try:
cp = run(cmd)
return json.loads(cp.stdout or "{}")
except Exception:
return None
def extract_short_volume_names_from_config(cfg: dict) -> Set[str]:
"""
Extrae short names de volúmenes usados en services[].volumes (type: volume)
y las claves del toplevel 'volumes'.
"""
names: Set[str] = set()
if not cfg:
return names
# services[].volumes
services = cfg.get("services") or {}
for svc in services.values():
vols = svc.get("volumes") or []
for m in vols:
# en JSON canonical, cada mount es un dict con 'type', 'source', 'target', ...
if isinstance(m, dict) and m.get("type") == "volume":
src = m.get("source")
if isinstance(src, str) and src:
names.add(src)
# top-level volumes (claves)
top_vols = cfg.get("volumes") or {}
if isinstance(top_vols, dict):
for k in top_vols.keys():
if isinstance(k, str) and k:
names.add(k)
return names
def docker_compose_name_from(files: List[pathlib.Path]) -> Optional[str]:
cfg = compose_config_json(files)
if cfg and isinstance(cfg, dict):
name = cfg.get("name")
if name:
return name
return None
def read_compose_project_from_env(env_path: pathlib.Path) -> Optional[str]:
try:
if env_path.exists():
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("COMPOSE_PROJECT_NAME="):
return line.split("=", 1)[1].strip()
except Exception:
pass
return None
def base_folder_slug() -> str:
return PROJECT_ROOT.name.lower().replace(" ", "_")
def candidates_for_env(env: str) -> List[str]:
cand: List[str] = []
if env == "development":
n1 = docker_compose_name_from([COMPOSE_BASE, COMPOSE_DEV])
n2 = read_compose_project_from_env(PROJECT_ROOT / ".env.development")
n3 = f"{base_folder_slug()}_dev"
n4 = f"{base_folder_slug()}-dev"
cand.extend([n1, n2, n3, n4, base_folder_slug()])
elif env == "production":
n1 = docker_compose_name_from([COMPOSE_BASE, COMPOSE_PROD])
n2 = read_compose_project_from_env(PROJECT_ROOT / ".env.production")
n3 = f"{base_folder_slug()}_prod"
n4 = f"{base_folder_slug()}-prod"
cand.extend([n1, n2, n3, n4, base_folder_slug()])
# dedup preservando orden
seen = set(); ordered = []
for x in cand:
if x and x not in seen:
seen.add(x); ordered.append(x)
return ordered
def candidates_for_global() -> List[str]:
cand: List[str] = []
# nombres desde compose globales
if COMPOSE_NPM.exists():
n = docker_compose_name_from([COMPOSE_NPM])
if n: cand.append(n)
if COMPOSE_DBVR.exists():
n = docker_compose_name_from([COMPOSE_DBVR])
if n and n not in cand: cand.append(n)
# fallback esperados
if GLOBAL_DEFAULT_PROJECT not in cand: cand.append(GLOBAL_DEFAULT_PROJECT)
bf = base_folder_slug()
if bf not in cand: cand.append(bf)
return cand
# ---------- Nueva detección por grupo: COMPOSE + labels ----------
def detect_group_volumes_with_compose(filesets: List[List[pathlib.Path]],
project_candidates: List[str]) -> Tuple[Optional[str], str, List[str]]:
"""
filesets: lista de listas de archivos compose a considerar (dev=[base,dev], prod=[base,prod],
global=[[npm], [dbeaver]] -> dos sets para unir shortnames).
Devuelve (project_seleccionado, metodo, [nombres_de_volumen]).
"""
# 1) Unir shortnames de todos los filesets
shortnames: Set[str] = set()
for files in filesets:
cfg = compose_config_json(files)
shortnames |= extract_short_volume_names_from_config(cfg)
# 2) Si hay shortnames, probar a buscar por (project,label.volume)
if shortnames:
for proj in project_candidates:
# Buscar cada shortname con ambos labels
found: List[str] = []
for sn in sorted(shortnames):
names = docker_volume_ls_names([
f"label=com.docker.compose.project={proj}",
f"label=com.docker.compose.volume={sn}"
])
if names:
found.extend(names)
# dedup preservando orden
if found:
seen = set(); ordered = []
for n in found:
if n not in seen:
seen.add(n); ordered.append(n)
return proj, f"compose+labels:{proj}", ordered
# 3) Fallback: probar cualquier volumen del proyecto (label) o por prefijo
for proj in project_candidates:
method, rows = discover_volumes_for_project(proj)
if rows:
return proj, f"fallback:{method}", [r.get("Name") for r in rows if r.get("Name")]
# 4) Nada
first = project_candidates[0] if project_candidates else None
return first, "none", []
def discover_volumes_for_project(project_raw: str) -> Tuple[str, List[Dict[str, str]]]:
"""
Método previo de respaldo: por label de proyecto y prefijo (para CLI y fallback).
"""
project_norm = normalize_project_name(project_raw)
project_lower = project_norm.lower()
vols = list_by_label_project(project_norm)
if vols:
return f"label:{project_norm}", vols
vols2 = list_by_label_project(project_lower)
if vols2:
return f"label:{project_lower}", vols2
by_name = list_by_name_prefix(project_norm)
if by_name:
return f"name-prefix:{project_norm}", by_name
by_name2 = list_by_name_prefix(project_lower)
if by_name2:
return f"name-prefix:{project_lower}", by_name2
return "none", []
# ---------- Backup helpers ----------
def ensure_alpine_image():
try:
run(["docker", "image", "inspect", "alpine:latest"])
except subprocess.CalledProcessError:
print("Pulling alpine:latest ...")
run(["docker", "pull", "alpine:latest"], check=True, capture_output=False)
def build_archive_name(project: str, volume_name: str, ts: str) -> str:
"""
Construye el nombre del .tar.gz evitando duplicar el prefijo del proyecto.
- Si volume_name ya empieza con '<project>_' o '<project>-', se usa tal cual.
- Si no, se antepone '<project>_'.
Resultado: <project>_<shortname>-<ts>.tar.gz
"""
proj_token = project.lower().replace(" ", "_")
v_lower = volume_name.lower()
if v_lower.startswith(proj_token + "_") or v_lower.startswith(proj_token + "-"):
base = volume_name
else:
base = f"{proj_token}_{volume_name}"
return f"{base}-{ts}.tar.gz"
def backup_volume(volume_name: str, out_dir: pathlib.Path, archive_name: str, dry_run: bool = False) -> int:
out_dir.mkdir(parents=True, exist_ok=True)
out_dir_abs = out_dir.resolve()
out_path = out_dir_abs / archive_name
docker_cmd = [
"docker", "run", "--rm",
"-v", f"{volume_name}:/volume:ro",
"-v", f"{str(out_dir_abs)}:/backup",
# "--user", f"{os.getuid()}:{os.getgid()}",
"alpine:latest",
"sh", "-lc",
f"tar czf /backup/{shlex.quote(out_path.name)} -C /volume ."
]
if dry_run:
print("[DRY RUN] Would run:", " ".join(shlex.quote(c) for c in docker_cmd))
return 0
cp = subprocess.run(docker_cmd)
return cp.returncode
def backup_explicit(volume_names: List[str], ts: str, output_dir: Optional[str], dry_run: bool, prefix_project: Optional[str]) -> int:
"""
Respalda exactamente los volúmenes indicados.
- Directorio por defecto: ./docker-volumes-<ts>
- Nombre de archivo: build_archive_name(prefix_project, volume_name, ts)
"""
out_dir = pathlib.Path(output_dir) if output_dir else (PROJECT_ROOT / f"docker-volumes-{ts}")
if not dry_run:
ensure_alpine_image()
failures = []
for vname in volume_names:
if not vname:
continue
archive = build_archive_name(prefix_project or "", vname, ts)
print(f"Backing up volume: {vname} -> {archive}")
rc = backup_volume(vname, out_dir, archive, dry_run=dry_run)
if rc != 0:
print(f" ERROR: backup failed for volume '{vname}' (exit code {rc})", file=sys.stderr)
failures.append(vname)
if failures:
print("\nCompleted with errors. Failed volumes:", ", ".join(failures))
return 1
else:
print("\nAll done. Archives written to:", str(out_dir.resolve()))
return 0
def backup_group(project_name: str, ts: str, output_dir: Optional[str] = None,
dry_run: bool = False, excludes: Optional[List[str]] = None) -> int:
"""
Fallback legacy (label/prefix). Mantiene coherencia con nombres y directorio por defecto.
"""
method, rows = discover_volumes_for_project(project_name)
print_header(f"Proyecto '{project_name}': {len(rows)} volumen(es) detectado(s) (método: {method})")
for v in rows:
print(" -", v.get("Name"))
if not rows:
warn("No hay volúmenes para respaldar.")
return 0
vols = [v.get("Name") for v in rows if v.get("Name")]
if excludes:
excl = set(excludes)
vols = [n for n in vols if n not in excl]
if not vols:
warn("Tras aplicar exclusiones, no quedó nada por respaldar.")
return 0
out_dir = pathlib.Path(output_dir) if output_dir else (PROJECT_ROOT / f"docker-volumes-{ts}")
if not dry_run:
ensure_alpine_image()
failures = []
for vname in vols:
archive = build_archive_name(project_name, vname, ts)
print(f"Backing up volume: {vname} -> {archive}")
rc = backup_volume(vname, out_dir, archive, dry_run=dry_run)
if rc != 0:
print(f" ERROR: backup failed for volume '{vname}' (exit code {rc})", file=sys.stderr)
failures.append(vname)
if failures:
print("\nCompleted with errors. Failed volumes:", ", ".join(failures))
return 1
else:
print("\nAll done. Archives written to:", str(out_dir.resolve()))
return 0
# ---------- UI helpers ----------
def yes_no(prompt: str, default="n") -> bool:
default = default.lower()
hint = "[Y/n]" if default == "y" else "[y/N]"
while True:
resp = input(f"{prompt} {hint} ").strip().lower()
if not resp:
return default == "y"
if resp in ("y","yes","s","si",""):
return True
if resp in ("n","no"):
return False
print("Respuesta no reconocida. Por favor, responde con 'y' o 'n'.")
def print_header(title: str):
print("\n" + "=" * 60)
print(title)
print("=" * 60 + "\n")
def info(msg): print(f"{msg}")
def ok(msg): print(f"{msg}")
def warn(msg): print(f"! {msg}")
def fail(msg):
print(f"{msg}")
sys.exit(1)
# ---------- Menú interactivo ----------
def interactive_menu():
if not which("docker"):
fail("ERROR: 'docker' no está en el PATH.")
try:
run(["docker", "version"], check=True, capture_output=True)
except subprocess.CalledProcessError:
fail("ERROR: No se puede hablar con el daemon de Docker. ¿Está corriendo? ¿Tu usuario está en el grupo 'docker'?")
# DEV
dev_candidates = candidates_for_env("development") if COMPOSE_BASE.exists() and COMPOSE_DEV.exists() else []
dev_proj, dev_method, dev_names = detect_group_volumes_with_compose(
filesets=[[COMPOSE_BASE, COMPOSE_DEV]] if dev_candidates else [],
project_candidates=dev_candidates
)
# PROD
prod_candidates = candidates_for_env("production") if COMPOSE_BASE.exists() and COMPOSE_PROD.exists() else []
prod_proj, prod_method, prod_names = detect_group_volumes_with_compose(
filesets=[[COMPOSE_BASE, COMPOSE_PROD]] if prod_candidates else [],
project_candidates=prod_candidates
)
# GLOBAL = NPM + DBEAVER (unir shortnames de ambos)
global_candidates = candidates_for_global()
global_filesets = []
if COMPOSE_NPM.exists():
global_filesets.append([COMPOSE_NPM])
if COMPOSE_DBVR.exists():
global_filesets.append([COMPOSE_DBVR])
glob_proj, glob_method, glob_names = detect_group_volumes_with_compose(
filesets=global_filesets,
project_candidates=global_candidates
)
# Resumen
print_header("Resumen de volúmenes detectados")
if dev_proj:
info(f"DESARROLLO ({dev_proj}): {len(dev_names)} volumen(es) (método: {dev_method})")
else:
info("DESARROLLO: archivos compose no encontrados.")
if prod_proj:
info(f"PRODUCCIÓN ({prod_proj}): {len(prod_names)} volumen(es) (método: {prod_method})")
else:
info("PRODUCCIÓN: archivos compose no encontrados.")
if glob_proj:
info(f"GLOBALES ({glob_proj}): {len(glob_names)} volumen(es) (método: {glob_method})")
else:
info("GLOBALES: no se detectaron archivos compose globales.")
print()
# Menú
options = {}
key = 1
if dev_proj:
print(f" {key}) Respaldar volúmenes de DESARROLLO ({dev_proj})")
options[str(key)] = ("backup_explicit", dev_proj, dev_names); key += 1
if prod_proj:
print(f" {key}) Respaldar volúmenes de PRODUCCIÓN ({prod_proj})")
options[str(key)] = ("backup_explicit", prod_proj, prod_names); key += 1
if glob_proj:
print(f" {key}) Respaldar volúmenes GLOBALES ({glob_proj})")
options[str(key)] = ("backup_explicit", glob_proj, glob_names); key += 1
# TODOS: unión deduplicada por nombre (respalda 1 vez cada volumen)
groups = []
if dev_proj: groups.append( (dev_proj, dev_names) )
if prod_proj: groups.append( (prod_proj, prod_names) )
if glob_proj: groups.append( (glob_proj, glob_names) )
if len(groups) >= 2:
print(f" {key}) Respaldar TODOS los grupos detectados")
options[str(key)] = ("backup_all_explicit", groups); key += 1
print(f" {key}) Salir")
exit_key = str(key)
ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
while True:
choice = input("> ").strip()
if choice == exit_key:
ok("Saliendo.")
sys.exit(0)
if choice not in options:
print("Opción inválida.")
continue
action = options[choice][0]
dry = yes_no("¿Dry-run (no escribir archivos)?", default="n")
outd = input(f"Directorio de salida (vacío = ./docker-volumes-{ts}): ").strip() or None
excl_input = input("Excluir volúmenes (nombres separados por coma, vacío = ninguno): ").strip()
excludes = set(e.strip() for e in excl_input.split(",") if e.strip()) if excl_input else set()
if action == "backup_explicit":
_, proj, names = options[choice]
names = [n for n in names if n not in excludes]
if not names:
warn("No hay volúmenes para respaldar.")
sys.exit(0)
rc = backup_explicit(names, ts, output_dir=outd, dry_run=dry, prefix_project=proj)
sys.exit(rc)
elif action == "backup_all_explicit":
_, groups_payload = options[choice]
vol_to_proj: Dict[str, str] = {}
for proj, names in groups_payload:
for n in names:
if n not in excludes and n not in vol_to_proj:
vol_to_proj[n] = proj
if not vol_to_proj:
warn("No hay volúmenes para respaldar.")
sys.exit(0)
if not dry:
ensure_alpine_image()
out_dir = pathlib.Path(outd) if outd else (PROJECT_ROOT / f"docker-volumes-{ts}")
failures = []
for vname, proj in vol_to_proj.items():
archive = build_archive_name(proj, vname, ts)
print(f"Backing up volume: {vname} -> {archive}")
rc = backup_volume(vname, out_dir, archive, dry_run=dry)
if rc != 0:
print(f" ERROR: backup failed for volume '{vname}' (exit code {rc})", file=sys.stderr)
failures.append(vname)
if failures:
print("\nCompleted with errors. Failed volumes:", ", ".join(failures))
sys.exit(1)
else:
print("\nAll done. Archives written to:", str(out_dir.resolve()))
sys.exit(0)
# ---------- CLI legacy (se mantiene) ----------
def detect_project_name(args_project: Optional[str]) -> str:
if args_project:
return args_project
env_name = os.environ.get("COMPOSE_PROJECT_NAME")
if env_name:
return env_name
return PROJECT_ROOT.name.replace(" ", "_")
def cli_main():
parser = argparse.ArgumentParser(description="Export (compress) every Docker volume of a Docker Compose project.")
parser.add_argument("-p", "--project", help="Compose project or prefix (see --discovery).")
parser.add_argument("-o", "--output", help="Output directory (default: ./docker-volumes-<timestamp>).")
parser.add_argument("--exclude", nargs="*", default=[], help="Volume names to exclude (space-separated).")
parser.add_argument("--dry-run", action="store_true", help="Show what would be done without doing it.")
parser.add_argument("--timestamp", default=datetime.datetime.now().strftime("%Y%m%d-%H%M%S"),
help="Timestamp to embed into filenames (default: current time).")
parser.add_argument("--discovery", choices=["auto","label","name"], default="auto",
help="How to discover volumes: 'label' (strict), 'name' (prefix), or 'auto' (default).")
parser.add_argument("--list-only", action="store_true", help="Only list volumes that would be backed up and exit.")
parser.add_argument("--menu", action="store_true", help="Launch interactive menu instead of CLI behavior.")
args = parser.parse_args()
if args.menu or not args.project:
interactive_menu()
return
if not which("docker"):
print("ERROR: 'docker' not on PATH.", file=sys.stderr)
sys.exit(2)
project_raw = detect_project_name(args.project)
project_norm = normalize_project_name(project_raw)
project_lower = project_norm.lower()
ts = args.timestamp
out_dir = pathlib.Path(args.output) if args.output else (PROJECT_ROOT / f"docker-volumes-{ts}")
try:
run(["docker", "version"], check=True, capture_output=True)
except subprocess.CalledProcessError:
print("ERROR: Docker daemon not reachable.", file=sys.stderr)
sys.exit(2)
# Descubrimiento legacy por label/prefijo (se mantiene para compatibilidad)
selected = []
method_used = None
vols = list_by_label_project(project_norm)
if vols:
selected = vols; method_used = f"label:{project_norm}"
else:
vols2 = list_by_label_project(project_lower)
if vols2:
selected = vols2; method_used = f"label:{project_lower}"
if not selected:
by_name = list_by_name_prefix(project_norm)
if by_name:
selected = by_name; method_used = f"name-prefix:{project_norm}"
else:
by_name2 = list_by_name_prefix(project_lower)
if by_name2:
selected = by_name2; method_used = f"name-prefix:{project_lower}"
if not selected:
print(f"No volumes found for project/prefix '{project_raw}'.")
sys.exit(0)
exclude_set = set(args.exclude or [])
names = [v.get("Name") for v in selected if v.get("Name") not in exclude_set]
print(f"Discovery method: {method_used}")
print(f"Volumes discovered: {len(names)}")
for n in names:
print(" -", n)
if args.list_only:
return
if not args.dry_run:
ensure_alpine_image()
failures = []
for vname in names:
archive = build_archive_name(project_lower, vname, ts)
print(f"Backing up volume: {vname} -> {archive}")
rc = backup_volume(vname, out_dir, archive, dry_run=args.dry_run)
if rc != 0:
print(f" ERROR: backup failed for volume '{vname}' (exit code {rc})", file=sys.stderr)
failures.append(vname)
if failures:
print("\nCompleted with errors. Failed volumes:", ", ".join(failures))
sys.exit(1)
else:
print("\nAll done. Archives written to:", str(out_dir.resolve()))
# ---------- Entry point ----------
if __name__ == "__main__":
if len(sys.argv) == 1:
interactive_menu()
else:
cli_main()
+175
View File
@@ -0,0 +1,175 @@
# compose.dev.yaml
# Docker Compose para entorno de desarrollo.
services:
app:
image: node:20.19.5-bookworm
working_dir: /app
user: "${UID:-1000}:${GID:-1000}"
volumes:
- ./services/app:/app:rw
- ./services/app/node_modules:/app/node_modules
- ./packages:/packages
env_file:
- ./services/app/.env.development
environment:
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:
net:
aliases: [dev-app]
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:
image: node:20.19.5-bookworm
working_dir: /app
user: "${UID:-1000}:${GID:-1000}"
volumes:
- ./services/auth:/app:rw
- ./services/auth/node_modules:/app/node_modules
- ./packages:/packages
env_file:
- ./services/auth/.env.development
environment:
NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development
NODE_OPTIONS: --preserve-symlinks
expose:
- ${AUTH_PORT}
networks:
net:
aliases: [dev-auth]
command: npm run dev
dbCore:
image: postgres:16
environment:
POSTGRES_DB: ${CORE_DB_NAME}
POSTGRES_USER: ${CORE_DB_USER}
POSTGRES_PASSWORD: ${CORE_DB_PASS}
volumes:
- suitecoffee-db:/var/lib/postgresql/data
networks:
net:
aliases: [dev-db]
dbTenants:
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]
#################
### 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:
tenants-db:
suitecoffee-db:
authentik-db:
ak-redis:
networks:
net:
driver: bridge
+68
View 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
+89
View File
@@ -0,0 +1,89 @@
# compose.prod.yml
# Docker Comose para entorno de producción o production.
services:
app:
build:
context: ./services/app
dockerfile: Dockerfile.production
expose:
- ${APP_PORT}
volumes:
- ./services/app:/app
env_file:
- ./services/app/.env.production
environment:
- NODE_ENV: production
networks:
net:
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
auth:
build:
context: ./services/auth
dockerfile: Dockerfile.production
expose:
- ${AUTH_PORT}
volumes:
- ./services/auth:/app
env_file:
- ./services/auth/.env.production
environment:
- NODE_ENV: production
networks:
net:
aliases: [auth]
command: npm run start
dbCore:
image: postgres:16
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASS}
volumes:
- dbCore_data:/var/lib/postgresql/data
networks:
net:
aliases: [dbCore]
dbTenants:
image: postgres:16
environment:
POSTGRES_DB: ${TENANTS_DB_NAME}
POSTGRES_USER: ${TENANTS_DB_USER}
POSTGRES_PASSWORD: ${TENANTS_DB_PASS}
volumes:
- dbTenants_data:/var/lib/postgresql/data
networks:
net:
aliases: [dbTenants]
falta implementar authentik en compose.prod.yaml
volumes:
dbCore_data:
dbTenants_data:
networks:
net:
driver: bridge
+61
View File
@@ -0,0 +1,61 @@
# $ compose.tools.yaml
name: suitecoffee_tools
services:
dbeaver:
image: dbeaver/cloudbeaver:latest
profiles: [dbeaver]
ports:
- 8978:8978
environment:
TZ: America/Montevideo
volumes:
- dbeaver_logs:/opt/cloudbeaver/logs
- dbeaver_workspace:/opt/cloudbeaver/workspace
networks:
# suitecoffee_prod_net: {}
suitecoffee_dev_net: {}
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8978 || exit 1"]
interval: 10s
timeout: 3s
retries: 10
start_period: 20s
restart: unless-stopped
npm:
image: jc21/nginx-proxy-manager:latest
profiles: [npm]
restart: unless-stopped
ports:
- "80:80" # HTTP público
- "81:81" # UI de administración
environment:
TZ: America/Montevideo
volumes:
- npm_data:/data
- npm_letsencrypt:/etc/letsencrypt
networks:
# suitecoffee_prod_net: {}
suitecoffee_dev_net: {}
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:81 || exit 1"]
interval: 10s
timeout: 3s
retries: 10
start_period: 20s
networks:
suitecoffee_dev_net:
external: true
# suitecoffee_prod_net:
# external: true
volumes:
npm_data:
npm_letsencrypt:
dbeaver_logs:
dbeaver_workspace:
+109
View File
@@ -0,0 +1,109 @@
# compose.yml
# Compose base
name: ${COMPOSE_PROJECT_NAME:-suitecoffee}
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
plugins:
depends_on:
app:
condition: service_healthy
auth:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:${PLUGINS_PORT}/health || exit 1"]
interval: 10s
timeout: 3s
retries: 10
start_period: 20s
restart: unless-stopped
auth:
depends_on:
dbCore:
condition: service_healthy
ak:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:${AUTH_PORT}/health || exit 1"]
interval: 10s
timeout: 3s
retries: 10
start_period: 20s
restart: unless-stopped
dbCore:
image: postgres:16
environment:
TZ: America/Montevideo
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${CORE_DB_USER} -d ${CORE_DB_NAME}"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
restart: unless-stopped
dbTenants:
image: postgres:16
environment:
TZ: America/Montevideo
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${TENANTS_DB_USER} -d ${TENANTS_DB_NAME}"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
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
-152
View File
@@ -1,152 +0,0 @@
# docker-compose.overrride.yml
# Docker Comose para entorno de desarrollo o development.
services:
npm:
image: jc21/nginx-proxy-manager:latest
restart: unless-stopped
depends_on:
app:
condition: service_healthy
auth:
condition: service_healthy
ports:
- "80:80" # HTTP público
- "81:81" # UI de administración NPM
- "443:443" # HTTPS público
volumes:
- npm_data:/data # config + DB (SQLite)
- npm_letsencrypt:/etc/letsencrypt
networks:
- suitecoffee-net
app:
image: node:20-bookworm
depends_on:
db:
condition: service_healthy
tenants:
condition: service_healthy
ports:
- 3000:3000
working_dir: /app
user: "${UID:-1000}:${GID:-1000}"
volumes:
- ./services/app:/app:rw
- ./node_modules:/app/node_modules
env_file:
- ./services/app/.env.development
environment:
- NODE_ENV=${NODE_ENV}
command: npm run dev
healthcheck:
# IMPORTANTE: asegurate de tener curl instalado en la imagen de app (ver nota abajo)
test: ["CMD-SHELL", "curl -fsS http://localhost:${APP_DOCKER_PORT}/health || exit 1"]
interval: 10s
timeout: 3s
retries: 10
start_period: 20s
restart: unless-stopped
networks:
- suitecoffee-net
auth:
image: node:20-bookworm
depends_on:
db:
condition: service_healthy
ports:
- 4000:4000
working_dir: /app
user: "${UID:-1000}:${GID:-1000}"
volumes:
- ./services/app:/app:rw
- ./node_modules:/app/node_modules
env_file:
- ./services/auth/.env.development
environment:
- NODE_ENV=${NODE_ENV}
command: npm run dev
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:${AUTH_DOCKER_PORT}/health || exit 1"]
interval: 10s
timeout: 3s
retries: 10
start_period: 15s
networks:
- suitecoffee-net
db:
image: postgres:16
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASS}
ports:
- ${DB_LOCAL_PORT}:${DB_DOCKER_PORT}
volumes:
- suitecoffee-db:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
networks:
- suitecoffee-net
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
ports:
- ${TENANTS_DB_LOCAL_PORT}:${TENANTS_DB_DOCKER_PORT}
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${TENANTS_DB_USER} -d ${TENANTS_DB_NAME}"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
networks:
- suitecoffee-net
dbeaver:
image: dbeaver/cloudbeaver:latest
# depends_on:
# tenants:
# condition: service_healthy
# db:
# condition: service_healthy
restart: unless-stopped
ports:
- 8978:8978
volumes:
- dbeaver_logs:/opt/cloudbeaver/logs
- dbeaver_workspace:/opt/cloudbeaver/workspace
networks:
- suitecoffee-net
volumes:
tenants-db:
suitecoffee-db:
npm_data:
npm_letsencrypt:
dbeaver_logs:
dbeaver_workspace:
networks:
suitecoffee-net:
driver: bridge
-138
View File
@@ -1,138 +0,0 @@
# docker-compose.yml
# Docker Comose para entorno de producción o production.
name: ${COMPOSE_PROJECT_NAME:-suitecoffee}
services:
npm:
image: jc21/nginx-proxy-manager:latest
restart: unless-stopped
depends_on:
app:
condition: service_healthy
auth:
condition: service_healthy
ports:
- "80:80" # HTTP público
- "81:81" # UI de administración NPM
- "443:443" # HTTPS público
volumes:
- npm_data:/data # config + DB (SQLite)
- npm_letsencrypt:/etc/letsencrypt
networks:
- suitecoffee-net
app:
depends_on:
db:
condition: service_healthy
tenants:
condition: service_healthy
build:
context: ./services/app
dockerfile: Dockerfile.development
volumes:
- ./services/app:/app
env_file:
- ./services/app/.env.development
environment:
- NODE_ENV=${NODE_ENV}
command: npm run start
healthcheck:
# IMPORTANTE: asegurate de tener curl instalado en la imagen de app (ver nota abajo)
test: ["CMD-SHELL", "curl -fsS http://localhost:${APP_DOCKER_PORT}/health || exit 1"]
interval: 10s
timeout: 3s
retries: 10
start_period: 20s
restart: unless-stopped
networks:
- suitecoffee-net
auth:
depends_on:
db:
condition: service_healthy
build:
context: ./services/auth
dockerfile: Dockerfile.development
volumes:
- ./services/auth:/app
env_file:
- ./services/auth/.env.development
environment:
- NODE_ENV=${NODE_ENV}
command: npm run start
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:${AUTH_DOCKER_PORT}/health || exit 1"]
interval: 10s
timeout: 3s
retries: 10
start_period: 15s
networks:
- suitecoffee-net
db:
image: postgres:16
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASS}
volumes:
- suitecoffee-db:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
networks:
- suitecoffee-net
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
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${TENANTS_DB_USER} -d ${TENANTS_DB_NAME}"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
networks:
- suitecoffee-net
dbeaver:
image: dbeaver/cloudbeaver:latest
# depends_on:
# tenants:
# condition: service_healthy
# db:
# condition: service_healthy
restart: unless-stopped
ports:
- "8978:8978"
volumes:
- dbeaver_logs:/opt/cloudbeaver/logs
- dbeaver_workspace:/opt/cloudbeaver/workspace
networks:
- suitecoffee-net
volumes:
tenants-db:
suitecoffee-db:
npm_data:
npm_letsencrypt:
dbeaver_logs:
dbeaver_workspace:
networks:
suitecoffee-net:
driver: bridge
-6
View File
@@ -1,6 +0,0 @@
@startuml Modelo Entidad-Relacion
class Producto {
- idProducto: int
+ getId(): int
}
@enduml
+316
View 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);
```
---
View File
-2
View File
@@ -1,2 +0,0 @@
docker compose -f docker-compose.yml -f docker-compose.override.yml \
--env-file .env.development up -d
+20 -3
View File
@@ -1,6 +1,23 @@
{ {
"dependencies": { "name": "suitecoffee",
"app": "file:services/app", "version": "1.0.0",
"auth": "file:services/auth" "description": "Software para gestión de cafeterías",
"private": true,
"workspaces": [],
"keywords": [
"coffee",
"suite",
"suitecoffee"
],
"repository": {
"type": "git",
"url": "https://gitea.mateosaldain.uy/msaldain/SuiteCoffee.git"
},
"license": "ISC",
"author": "Mateo Saldain",
"type": "module",
"main": "suitecoffee.py",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
} }
} }
+5
View 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
View 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
View 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 };
@@ -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
View 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
View 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)
*/
@@ -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
View 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;
@@ -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
View 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
View 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
View 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
@@ -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
View 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
View 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
View 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' });
};
}
@@ -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
View 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
View 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"
]
}
View File
+93
View 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
View 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
View File
@@ -0,0 +1,4 @@
// @suitecoffee/scripts/src/index.mjs
// Punto de entrada general del paquete de utilidades.
export * from './utils/env.mjs';
+24
View 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.`);
}
}
@@ -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,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 || ''); }
}
@@ -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'; } }
@@ -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 || {} }));
}
};
@@ -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();
}
@@ -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');
}
}
@@ -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')}`;
}
@@ -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'
});
}
}
@@ -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 };
@@ -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
View 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
View 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 };
@@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}

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