27 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
175 changed files with 23560 additions and 1981 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
node_modules/
# Ignorar los volumenes respaldados de docker compose
docker-volumes*
# Ignorar las carpetas de bases de datos
.db/
@@ -30,6 +33,6 @@ tests/
.gitmodules
# Ignorar archivos personales o privados (si existen)
.env.*
# .env.*
*.pem
*.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 @@
+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
-151
View File
@@ -1,151 +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:
depends_on:
db:
condition: service_healthy
tenants:
condition: service_healthy
image: node:20-bookworm
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
+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
+2
View File
@@ -2,6 +2,8 @@
"name": "suitecoffee",
"version": "1.0.0",
"description": "Software para gestión de cafeterías",
"private": true,
"workspaces": [],
"keywords": [
"coffee",
"suite",
+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;
}
+466
View File
@@ -0,0 +1,466 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
restore_compose_volumes.py
--------------------------
Restaura volúmenes desde backups generados por backup_compose_volumes.py.
- Busca carpetas ./docker-volumes-<timestamp>
- Lee .tar.gz (nombres: <volume_name>-<YYYYMMDD-HHMMSS>.tar.gz)
- Dos modos:
1) Tradicional (sin labels)
2) Reconocido por Compose (aplica labels com.docker.compose.* para evitar el warning)
Además:
- Si un volumen existe y está en uso, ofrece detener y eliminar contenedores que lo usan
para poder recrearlo con labels correctos (solo en modo 2).
"""
import os
import re
import sys
import json
import shlex
import pathlib
import subprocess
from typing import List, Tuple, Dict, Optional
PROJECT_ROOT = pathlib.Path.cwd()
BACKUP_DIR_PATTERN = re.compile(r"^docker-volumes-\d{8}-\d{6}$")
ARCHIVE_PATTERN = re.compile(r"^(?P<basename>.+)-(?P<ts>\d{8}-\d{6})\.tar\.gz$")
# ---------- utils ----------
def run(cmd: List[str], check: bool = False, capture_output: bool = True, text: bool = True) -> subprocess.CompletedProcess:
return subprocess.run(cmd, check=check, capture_output=capture_output, text=text)
def which(prog: str) -> bool:
from shutil import which as _w
return _w(prog) is not None
def fail(msg: str):
print(f"{msg}")
sys.exit(1)
def ok(msg: str):
print(f"{msg}")
def info(msg: str):
print(f"{msg}")
def warn(msg: str):
print(f"! {msg}")
def yes_no(prompt: str, default: str = "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. Responde 'y' o 'n'.")
# ---------- docker helpers ----------
def ensure_alpine_image():
try:
run(["docker", "image", "inspect", "alpine:latest"], check=True)
except subprocess.CalledProcessError:
info("Descargando alpine:latest ...")
run(["docker", "pull", "alpine:latest"], check=True, capture_output=False, text=True)
def volume_exists(name: str) -> bool:
try:
run(["docker", "volume", "inspect", name], check=True)
return True
except subprocess.CalledProcessError:
return False
def inspect_volume_labels(name: str) -> Dict[str, str]:
try:
cp = run(["docker", "volume", "inspect", name, "--format", "{{json .Labels}}"], check=True)
return json.loads(cp.stdout or "null") or {}
except subprocess.CalledProcessError:
return {}
def containers_using_volume(name: str) -> List[str]:
# docker ps soporta --filter volume=<name>
try:
cp = run(["docker", "ps", "-a", "--filter", f"volume={name}", "-q"], check=True)
return [l.strip() for l in cp.stdout.splitlines() if l.strip()]
except subprocess.CalledProcessError:
return []
def stop_containers(ids: List[str]) -> None:
if not ids:
return
info("Deteniendo contenedores que usan el volumen...")
run(["docker", "stop"] + ids, check=False, capture_output=False)
def remove_containers(ids: List[str]) -> None:
if not ids:
return
info("Eliminando contenedores detenidos que usan el volumen...")
run(["docker", "rm"] + ids, check=False, capture_output=False)
def remove_volume(name: str) -> bool:
try:
run(["docker", "volume", "rm", "-f", name], check=True, capture_output=False)
return True
except subprocess.CalledProcessError as e:
warn(f"No se pudo eliminar volumen '{name}': {e}")
return False
def create_volume(name: str, labels: Optional[Dict[str,str]] = None) -> bool:
cmd = ["docker", "volume", "create"]
if labels:
for k, v in labels.items():
cmd += ["--label", f"{k}={v}"]
cmd.append(name)
try:
run(cmd, check=True, capture_output=False)
return True
except subprocess.CalledProcessError as e:
warn(f"Fallo creando volumen '{name}': {e}")
return False
def restore_into_volume(volume_name: str, backup_dir: pathlib.Path, archive_file: pathlib.Path) -> int:
bdir_abs = backup_dir.resolve()
docker_cmd = [
"docker", "run", "--rm",
"-v", f"{volume_name}:/volume",
"-v", f"{str(bdir_abs)}:/backup",
"alpine:latest",
"sh", "-lc",
f"tar xzf /backup/{shlex.quote(archive_file.name)} -C /volume"
]
proc = subprocess.run(docker_cmd)
return proc.returncode
# ---------- parsing helpers ----------
def find_backup_dirs(root: pathlib.Path) -> List[pathlib.Path]:
dirs = [p for p in root.iterdir() if p.is_dir() and BACKUP_DIR_PATTERN.match(p.name)]
dirs.sort(key=lambda p: p.stat().st_mtime, reverse=True)
return dirs
def find_archives(dirpath: pathlib.Path) -> List[pathlib.Path]:
files = [p for p in dirpath.iterdir() if p.is_file() and p.name.endswith(".tar.gz")]
files.sort(key=lambda p: p.name)
return files
def parse_archive_basename(archive_name: str) -> Optional[str]:
m = ARCHIVE_PATTERN.match(archive_name)
if not m:
return None
return m.group("basename")
# ---------- compose label helpers ----------
def derive_labels_auto(volume_name: str) -> Tuple[Optional[str], Optional[str]]:
"""
project = parte antes del primer '_' o '-'
short = resto luego del separador
"""
for sep in ("_", "-"):
if sep in volume_name:
idx = volume_name.find(sep)
return volume_name[:idx], volume_name[idx+1:]
return None, None
def derive_labels_with_fixed_project(volume_name: str, project: str) -> Tuple[str, str]:
"""
Usa project fijo. Si volume_name empieza con '<project>_' o '<project>-', recorta.
"""
p = project
if volume_name.startswith(p + "_"):
return p, volume_name[len(p) + 1:]
if volume_name.startswith(p + "-"):
return p, volume_name[len(p) + 1:]
return p, volume_name
def labels_match_compose(name: str, project: str, short: str) -> bool:
labels = inspect_volume_labels(name)
return (
labels.get("com.docker.compose.project") == project and
labels.get("com.docker.compose.volume") == short
)
# ---------- UI flows ----------
def pick_backup_dir(dirs: List[pathlib.Path]) -> Optional[pathlib.Path]:
if not dirs:
warn("No se encontraron carpetas de backup 'docker-volumes-<timestamp>'.")
return None
print("\nCarpetas de backup encontradas:")
for i, d in enumerate(dirs, 1):
print(f" {i}) {d.name}")
while True:
sel = input("> Elige una carpeta (número) o Enter para cancelar: ").strip()
if not sel:
return None
if sel.isdigit() and 1 <= int(sel) <= len(dirs):
return dirs[int(sel) - 1]
print("Opción inválida.")
def pick_archives(files: List[pathlib.Path]) -> List[pathlib.Path]:
if not files:
warn("No hay archivos .tar.gz en esa carpeta.")
return []
print("\nBackups disponibles:")
for i, f in enumerate(files, 1):
base = parse_archive_basename(f.name) or f.name
print(f" {i}) {f.name} -> volumen: {base}")
print("\nOpciones:")
print(" a) Restaurar TODOS")
print(" s) Seleccionar algunos (ej: 1,3,5)")
while True:
sel = input("> Elige 'a' o 's': ").strip().lower()
if sel == "a":
return files
if sel == "s":
picks = input("> Números separados por coma: ").strip()
idxs = []
try:
for tok in picks.split(","):
tok = tok.strip()
if tok:
idx = int(tok)
idxs.append(idx - 1)
chosen = [files[i] for i in sorted(set(i for i in idxs if 0 <= i < len(files)))]
if chosen:
return chosen
except Exception:
pass
print("Selección inválida.")
else:
print("Opción inválida.")
def pick_restore_mode() -> str:
print("\nModo de restauración:")
print(" 1) Tradicional (sin labels)")
print(" 2) Reconocido por Compose (aplica labels para evitar el warning)")
while True:
sel = input("> Elige 1 o 2: ").strip()
if sel in ("1", "2"):
return sel
print("Opción inválida.")
def confirm_overwrite(volume_name: str) -> bool:
return yes_no(f"El volumen '{volume_name}' ya existe. ¿Sobrescribir (recrear)?", default="n")
# ---------- restore flows ----------
def restore_traditional(backup_dir: pathlib.Path, archives: List[pathlib.Path]):
ensure_alpine_image()
print("\n=== Restauración TRADICIONAL ===\n")
for arch in archives:
vname = parse_archive_basename(arch.name)
if not vname:
warn(f"Nombre de backup no reconocible: {arch.name}, se omite.")
continue
info(f"Volumen: {vname}")
# Tradicional: no cambiamos labels; si existe, restauramos sobre volumen nuevo (recreándolo)
if volume_exists(vname):
# Intentar eliminar: si está en uso, ofrecer detener/remover contenedores
if not confirm_overwrite(vname):
info(" → Omitido (ya existe).")
continue
ids = containers_using_volume(vname)
if ids:
info(f"Contenedores que usan '{vname}': {', '.join(ids)}")
if yes_no("¿Detener y eliminar esos contenedores para continuar?", default="y"):
stop_containers(ids)
remove_containers(ids)
else:
warn(" → No se puede recrear el volumen en uso. Omitido.")
continue
if not remove_volume(vname):
warn(" → No se pudo eliminar el volumen. Omitido.")
continue
if not create_volume(vname):
warn(" → No se pudo crear el volumen, se omite.")
continue
rc = restore_into_volume(vname, backup_dir, arch)
if rc == 0:
ok(" Restaurado.")
else:
warn(f" Falló la restauración (rc={rc}).")
def restore_with_compose_labels(backup_dir: pathlib.Path, archives: List[pathlib.Path]):
"""
Restaura creando volúmenes con labels de Compose para que NO aparezca el warning:
"volume ... already exists but was not created by Docker Compose..."
"""
ensure_alpine_image()
print("\n=== Restauración RECONOCIDA POR COMPOSE (con labels) ===\n")
print("Estrategia de etiquetado:")
print(" 1) Auto (project = prefijo de <vol> antes de '_' o '-', short = resto)")
print(" 2) Fijar un 'project' para todos (p. ej. suitecoffee, suitecoffee_dev, suitecoffee_prod)")
mode = ""
while mode not in ("1", "2"):
mode = input("> Elige 1 o 2: ").strip()
fixed_project = None
if mode == "2":
fixed_project = input("> Indica el 'project' de Compose (exacto): ").strip()
if not fixed_project:
warn("Project vacío, cancelado.")
return
# Previsualización de etiquetas
preview = []
for arch in archives:
vname = parse_archive_basename(arch.name)
if not vname:
continue
if mode == "1":
proj, short = derive_labels_auto(vname)
else:
proj, short = derive_labels_with_fixed_project(vname, fixed_project)
preview.append((arch, vname, proj, short))
print("\nVista previa de etiquetas (project / volume):")
for _, vname, proj, short in preview:
if proj and short:
print(f" {vname} → project='{proj}', volume='{short}'")
else:
print(f" {vname} → (no derivado; se pedirá manualmente)")
if not yes_no("\n¿Confirmar restauración con estas etiquetas?", default="y"):
warn("Cancelado por el usuario.")
return
# Restaurar con labels
for arch, vname, proj, short in preview:
# completar manual si falta
if not proj or not short:
print(f"\nDefinir etiquetas para: {vname}")
proj = input(" project = ").strip()
short = input(" volume = ").strip()
if not proj or not short:
warn(" → Etiquetas incompletas; se omite.")
continue
info(f"\nVolumen: {vname} (labels: project='{proj}', volume='{short}')")
if volume_exists(vname):
# ¿ya tiene labels correctas? entonces solo restauramos datos sin recrear
if labels_match_compose(vname, proj, short):
info(" Volumen ya tiene labels de Compose correctas. Sobrescribiendo datos...")
rc = restore_into_volume(vname, backup_dir, arch)
if rc == 0:
ok(" Restaurado (labels ya correctas).")
else:
warn(f" Falló la restauración (rc={rc}).")
continue
# Pedir permiso para detener/eliminar contenedores y recrear volumen con labels correctas
if not yes_no(" El volumen existe sin labels correctas. ¿Detener/eliminar contenedores y recrearlo con labels para evitar el warning?", default="y"):
warn(" → Omitido (mantiene warning de Compose).")
continue
ids = containers_using_volume(vname)
if ids:
info(f" Contenedores que usan '{vname}': {', '.join(ids)}")
stop_containers(ids)
remove_containers(ids)
if not remove_volume(vname):
warn(" → No se pudo eliminar el volumen. Omitido.")
continue
labels = {
"com.docker.compose.project": proj,
"com.docker.compose.volume": short,
}
if not create_volume(vname, labels=labels):
warn(" → No se pudo crear el volumen con labels. Omitido.")
continue
rc = restore_into_volume(vname, backup_dir, arch)
if rc == 0:
ok(" Restaurado con labels de Compose (warning resuelto).")
else:
warn(f" Falló la restauración (rc={rc}).")
# ---------- main ----------
def main():
if not which("docker"):
fail("No se encontró 'docker' en el PATH.")
try:
run(["docker", "version"], check=True)
except subprocess.CalledProcessError:
fail("No se puede comunicar con el daemon de Docker. ¿Está corriendo?")
# Elegir carpeta docker-volumes-<ts>
dirs = [p for p in PROJECT_ROOT.iterdir() if p.is_dir() and BACKUP_DIR_PATTERN.match(p.name)]
dirs.sort(key=lambda p: p.stat().st_mtime, reverse=True)
if not dirs:
warn("No hay carpetas de backup 'docker-volumes-<timestamp>'.")
return
print("\nCarpetas de backup encontradas:")
for i, d in enumerate(dirs, 1):
print(f" {i}) {d.name}")
chosen = None
while not chosen:
sel = input("> Elige una carpeta (número) o Enter para salir: ").strip()
if not sel:
warn("Cancelado."); return
if sel.isdigit() and 1 <= int(sel) <= len(dirs):
chosen = dirs[int(sel)-1]
else:
print("Opción inválida.")
# Archivos en carpeta
archives = [p for p in chosen.iterdir() if p.is_file() and p.name.endswith(".tar.gz")]
archives.sort(key=lambda p: p.name)
if not archives:
warn("No hay .tar.gz en esa carpeta."); return
print("\nBackups disponibles:")
for i, f in enumerate(archives, 1):
base = parse_archive_basename(f.name) or f.name
print(f" {i}) {f.name} -> volumen: {base}")
print("\nOpciones de selección:")
print(" a) Restaurar TODOS")
print(" s) Elegir algunos (ej: 1,3,5)")
selected: List[pathlib.Path] = []
while not selected:
mode = input("> Elige 'a' o 's': ").strip().lower()
if mode == "a":
selected = archives
elif mode == "s":
picks = input("> Números separados por coma: ").strip()
try:
idxs = [int(x.strip())-1 for x in picks.split(",") if x.strip()]
selected = [archives[i] for i in sorted(set(i for i in idxs if 0 <= i < len(archives)))]
except Exception:
selected = []
else:
print("Opción inválida.")
# Modo de restauración
choice = pick_restore_mode()
if choice == "1":
restore_traditional(chosen, selected)
else:
restore_with_compose_labels(chosen, selected)
ok("\nProceso finalizado.")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n✓ Interrumpido por el usuario (Ctrl+C).")
+54
View File
@@ -0,0 +1,54 @@
# =======================================================
# Runtime
NODE_ENV=development
PORT=3030
APP_BASE_URL=https://dev.suitecoffee.uy
# =======================================================
# =======================================================
# Configuración de Dases de Datos
CORE_DB_HOST=dev-db
CORE_DB_NAME=dev_suitecoffee_core
CORE_DB_PORT=5432
CORE_DB_USER=dev-user-suitecoffee
CORE_DB_PASS=dev-pass-suitecoffee
TENANTS_DB_HOST=dev-tenants
TENANTS_DB_NAME=dev_suitecoffee_tenants
TENANTS_DB_PORT=5432
TENANTS_DB_USER=suitecoffee
TENANTS_DB_PASS=suitecoffee
# =======================================================
# =======================================================
# Sesiones
SESSION_SECRET=Neon*Mammal*Boaster*Ludicrous*Fender8*Crablike
SESSION_NAME=sc.sid
# COOKIE_DOMAIN=dev.suitecoffee.uy
# =======================================================
# =======================================================
# Authentik y OIDC
AK_TOKEN=h2apVHbd3ApMcnnSwfQPXbvximkvP8HnUE25ot3zXWuEEtJFaNCcOzDHB6Xw
AK_REDIS_URL=redis://ak-redis:6379
OIDC_LOGIN_URL=https://sso.suitecoffee.uy
OIDC_REDIRECT_URI=https://suitecoffee.uy/auth/callback
OIDC_CLIENT_ID=1orMM8vOvf3WkN2FejXYvUFpPtONG0Lx1eMlwIpW
OIDC_CLIENT_SECRET=t5wx13qBcM0EFQ3cGnUIAmLzvbdsQrUVPv1OGWjszWkEp35pJQ55t7vZeeShqG49kuRAaiXv6PSGJLhRfGaponGaJl8gH1uCL7KIxdmm7UihgYoAXB2dFhZV4zRxfze2
OIDC_CONFIG_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/.well-known/openid-configuration
OIDC_AUTHORIZE_URL=https://sso.suitecoffee.uy/application/o/authorize/
OIDC_TOKEN_URL=https://sso.suitecoffee.uy/application/o/token/
OIDC_USERINFO_URL=https://sso.suitecoffee.uy/application/o/userinfo/
OIDC_LOGOUT_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/end-session/
OIDC_JWKS_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/jwks/
# =======================================================
@@ -1,9 +1,9 @@
# Dockerfile.dev
FROM node:20.17
FROM node:20.19.5-bookworm
# Definir variables de entorno con valores predeterminados
ARG NODE_ENV=development
ARG PORT=4000
# ARG NODE_ENV=production
# ARG PORT=3000
RUN apt-get update
RUN apt-get install -y curl && rm -rf /var/lib/apt/lists/*
@@ -17,8 +17,4 @@ RUN npm i
# Copia el resto de la app
COPY . .
# Expone el puerto
EXPOSE 4000
# Usa nodemon para hot reload si lo tenés
CMD ["npm", "run", "dev"]
CMD ["npm", "run", "start"]
+793 -234
View File
File diff suppressed because it is too large Load Diff

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