20 Commits

Author SHA1 Message Date
msaldain 5342fb489d Actualización de archivos compose para centralizar y tanto NPM como El gestó de db's dentro de un proyecto propio "suitecoffee_tools" con acceso a las redes de producción como de desarrollo 2025-08-21 17:34:53 +00:00
msaldain c42814f963 Varios cambios realizados en cuando a la organización de los compose de docker.
Se adoptó la versión actualmente recomandada por el equipo de docker el formado "compose.yaml"

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

También se crearon nuevos scripts y actualizaron algunos para adaptarse a los nuevos archivos.
2025-08-19 18:26:13 +00:00
msaldain b34433a71e mecanismo para respaldar los volumenes del proyecto 2025-08-18 21:31:28 +00:00
msaldain 492d844523 Todos los problemas de dependencias, credenciales y renderizado de raiz se solucionaron hasta quí 2025-08-18 20:35:47 +00:00
msaldain 8237e38164 Más dependencias solucionadas. Errores en los documentos docker-compose 2025-08-18 19:52:46 +00:00
msaldain e04be61952 Modificado el docker-compose L67.
Servicio auth. Estaba importandoce las dependencias de app no de auth...
2025-08-18 19:32:32 +00:00
msaldain 1b7e4f36e9 Más actualización de dependencias 2025-08-18 19:14:00 +00:00
msaldain d8cc6e9613 Puesta a punto de dependencias.
Falla renderización en la raíz del sistema
2025-08-18 19:13:56 +00:00
msaldain aa04270550 Actualización de uso de entornos 2025-08-18 14:39:19 +00:00
msaldain 2b47faf66a Script suiecoffee.py detecta contenedores de un entorno u otro ene ejeccuón, también los inicia o deteniene desde un menú.
Se agregó dentro del  docker-compose.yml la variable

name: ${COMPOSE_PROJECT_NAME:-suitecoffee}

Para crear el nombre del proyecto, el nombre está defininido en los .env respectivos para cada entorno
2025-08-17 04:37:18 +00:00
msaldain 97db600b1f Actualizaciones de archivo.
Modificaciones pequeñas
2025-08-16 03:25:24 +00:00
msaldain f9bf5f4824 Intento de utilizar nodemon development para desarrollar son estropear production. 2025-08-16 02:18:50 +00:00
msaldain 1db2f11c19 Se crearon servicios de gestores de bases de datos que permitan gestionar las bases de una forma más práctica.
- dbeaver
- adminer (respaldo)
2025-08-15 21:29:53 +00:00
msaldain 511b370a2e Se configuró un Nginx Proxy Manager que permita adminitrad más códodamente los endpoints tanto de apis como de archivos .html servidor tanto por auth/ y app/ 2025-08-15 17:02:48 +00:00
msaldain 3d3ef3f002 Ahora Suitecoffee puede correr dentro de contenedores docker. Mediante docker compose sin necesidad de exponer puertos, únicamente se expone un puerto 80 del servicio 'gateway' que utiliza una imagen nginx:alpine.
el comando para levantar el servicio con el entorno de desarrollo (utilizando las variables desarrollo + docker.compose.override.yml) es:

docker compose -f docker-compose.yml -f docker-compose.override.yml --env-file .env.development up -d

Para desactivarlo:

docker compose -f docker-compose.yml -f docker-compose.override.yml --env-file .env.development down
2025-08-15 14:30:35 +00:00
msaldain abaf43b8d6 auth - registro... planes
Se pudo crear la tabla 'plan' donde se encuentran los plane que el usuario puede selecionar al registrarse en el sistema. verificado que el código funciona por que tráe los diferentes planes.

Aún no se pudo registrar un nuevo usuario. Se intentará resolver
2025-08-15 03:35:26 +00:00
msaldain aa6d4e84c0 Merge pull request 'msaldain' (#1) from msaldain into main
Reviewed-on: #1
2025-08-14 22:46:42 -03:00
msaldain f483058c2c Se pudo conectar satisfactoriamente a la base de datos tenantdb_1 y suitecoffee-db.
En ./services/app/

Se sirvierons los HTML de la carpeta /pages.

Se crearon los endpoints REST para crear y listar roles, usuarios, categorias, productos.
2025-08-14 23:37:38 +00:00
msaldain 656293b74c Re-organización de los archivos 2025-07-11 00:05:31 -03:00
33 changed files with 5092 additions and 1001 deletions
+3
View File
@@ -1,6 +1,9 @@
# Ignorar los directorios de dependencias # Ignorar los directorios de dependencias
node_modules/ node_modules/
# Ignorar los volumenes respaldados
docker-volumes*
# Ignorar las carpetas de bases de datos # Ignorar las carpetas de bases de datos
.db/ .db/
-28
View File
@@ -1,28 +0,0 @@
# Dockerfile.dev
FROM node:23-slim
# Definir variables de entorno con valores predeterminados
ARG NODE_ENV=development
ARG PORT=3000
# Definir las variables de entorno dentro del contenedor
ENV NODE_ENV=${NODE_ENV}
ENV PORT=${PORT}
# Crea directorio de trabajo
WORKDIR /app
# Copia archivos de configuración primero para aprovechar el cache
COPY package*.json ./
# Instala dependencias (incluye devDependencies)
RUN npm install
# Copia el resto de la app
COPY . .
# Expone el puerto
EXPOSE ${PORT}
# Usa nodemon para hot reload si lo tenés
CMD ["npx", "nodemon", "src/index.js"]
-31
View File
@@ -1,31 +0,0 @@
# Dockerfile.prod
FROM node:23-slim
# Definir variables de entorno con valores predeterminados
ARG NODE_ENV=production
ARG PORT=8080
# Definir las variables de entorno dentro del contenedor
ENV NODE_ENV=${NODE_ENV}
ENV PORT=${PORT}
# Crea directorio de trabajo
WORKDIR /app
# Copia solo archivos necesarios para prod
COPY package*.json ./
# Instala solo dependencias de producción
RUN npm install --omit=dev
# Copia el resto de la app
COPY . .
# Expone el puerto
EXPOSE ${PORT}
# Ejecutar el servidor con nodemon en desarrollo, o con node en producción
CMD ["npm", "start"]
# # Corre la app normalmente
# CMD ["node", "src/index.js"]
-110
View File
@@ -1,110 +0,0 @@
# Proyecto Node.js con Docker
Este proyecto es una aplicación Node.js containerizada usando Docker y Docker Compose, con entornos separados para desarrollo y producción. A continuación, se explica cómo configurar y ejecutar la aplicación en ambos entornos.
## Estructura del Proyecto
```
.
├── Dockerfile.dev # Dockerfile para entorno de desarrollo
├── Dockerfile.prod # Dockerfile para entorno de producción
├── docker-compose.dev.yml # Archivo de configuración para entorno de desarrollo
├── docker-compose.prod.yml # Archivo de configuración para entorno de producción
├── package.json # Dependencias del proyecto Node.js
├── src/ # Código fuente de la aplicación
└── ...
```
## Prerequisitos
Antes de comenzar, asegúrate de tener instalado:
- [Docker](https://www.docker.com/get-started)
- [Docker Compose](https://docs.docker.com/compose/install/)
## Configuración
### 1. Instalar dependencias
Primero, asegúrate de tener todas las dependencias necesarias para tu proyecto. Si aún no tienes el archivo `package.json`, ejecuta:
```bash
npm init -y
```
Luego, instala las dependencias necesarias para tu aplicación:
```bash
npm install express
npm install --save-dev nodemon
```
### 2. Estructura de los Dockerfiles
- **`Dockerfile.dev`**: Configuración para el entorno de desarrollo.
- **`Dockerfile.prod`**: Configuración para el entorno de producción.
### 3. Configuración de Docker Compose
- **`docker-compose.dev.yml`**: Configuración para el entorno de desarrollo. Utiliza `nodemon` para recargar la aplicación automáticamente.
- **`docker-compose.prod.yml`**: Configuración para el entorno de producción. Instala solo las dependencias necesarias para producción.
## Uso
### 1. Entorno de Desarrollo
Para levantar el entorno de desarrollo y comenzar a trabajar, ejecuta:
```bash
docker compose -f docker-compose.dev.yml up --build
```
Esto hará lo siguiente:
- Construirá la imagen usando el `Dockerfile.dev`.
- Levantará el contenedor en el puerto `3000`.
- Con `nodemon` instalado, cualquier cambio que realices en el código será automáticamente reflejado en el contenedor.
Puedes acceder a la aplicación en tu navegador en `http://localhost:3000`.
### 2. Entorno de Producción
Para ejecutar el entorno de producción (sin dependencias de desarrollo), ejecuta:
```bash
docker compose -f docker-compose.prod.yml up --build
```
Esto:
- Construirá la imagen usando el `Dockerfile.prod`.
- Levantará el contenedor en el puerto `3000`.
En este entorno no se instalarán dependencias de desarrollo y es más ligero para producción.
### 3. Detener los contenedores
Si necesitas detener los contenedores en ejecución, puedes usar:
```bash
docker compose down
```
Para eliminar también las imágenes, ejecuta:
```bash
docker compose down --rmi all
```
## Notas adicionales
- Si necesitas acceder al contenedor para depurar o inspeccionar archivos, puedes hacerlo con el siguiente comando:
```bash
docker exec -it <container_id> sh
```
- Si tienes alguna duda o problema, no dudes en contactarnos o abrir un issue en el repositorio.
---
¡Disfruta trabajando con tu aplicación Node.js containerizada!
+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()
+73
View File
@@ -0,0 +1,73 @@
# docker-compose.overrride.yml
# Docker Comose para entorno de desarrollo o development.
services:
app:
image: node:20-bookworm
expose:
- ${APP_LOCAL_PORT}
working_dir: /app
user: "${UID:-1000}:${GID:-1000}"
volumes:
- ./services/app:/app:rw
- ./services/app/node_modules:/app/node_modules
env_file:
- ./services/app/.env.development
environment:
- NODE_ENV=${NODE_ENV}
networks:
net:
aliases: [dev-app]
command: npm run dev
auth:
image: node:20-bookworm
expose:
- ${AUTH_LOCAL_PORT}
working_dir: /app
user: "${UID:-1000}:${GID:-1000}"
volumes:
- ./services/auth:/app:rw
- ./services/auth/node_modules:/app/node_modules
env_file:
- ./services/auth/.env.development
environment:
- NODE_ENV=${NODE_ENV}
command: npm run dev
networks:
net:
aliases: [dev-auth]
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
+71
View File
@@ -0,0 +1,71 @@
# compose.prod.yml
# Docker Comose para entorno de producción o production.
services:
app:
build:
context: ./services/app
dockerfile: Dockerfile.production
expose:
- ${APP_LOCAL_PORT}
volumes:
- ./services/app:/app
env_file:
- ./services/app/.env.production
environment:
- NODE_ENV=${NODE_ENV}
networks:
net:
aliases: [prod-app]
command: npm run start
auth:
build:
context: ./services/auth
dockerfile: Dockerfile.production
expose:
- ${AUTH_LOCAL_PORT}
volumes:
- ./services/auth:/app
env_file:
- ./services/auth/.env.production
environment:
- NODE_ENV=${NODE_ENV}
command: npm run start
networks:
net:
aliases: [prod-auth]
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: [prod-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: [prod-tenants]
volumes:
tenants-db:
suitecoffee-db:
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:
+55
View File
@@ -0,0 +1,55 @@
# compose.yml
# Comose base
name: ${COMPOSE_PROJECT_NAME:-suitecoffee}
services:
# app:
# depends_on:
# db:
# condition: service_healthy
# tenants:
# condition: service_healthy
# healthcheck:
# 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
# auth:
# depends_on:
# db:
# condition: service_healthy
# healthcheck:
# test: ["CMD-SHELL", "curl -fsS http://localhost:${AUTH_DOCKER_PORT}/health || exit 1"]
# interval: 10s
# timeout: 3s
# retries: 10
# start_period: 15s
# restart: unless-stopped
db:
image: postgres:16
environment:
TZ: America/Montevideo
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
restart: unless-stopped
tenants:
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
-73
View File
@@ -1,73 +0,0 @@
-- Crear la base de datos solo si no existe
CREATE DATABASE IF NOT EXISTS `suitecoffee`;
USE `suitecoffee`;
-- Crear tabla de categorías
CREATE TABLE IF NOT EXISTS categorias (
id INT AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(100) NOT NULL UNIQUE
);
-- Crear tabla de productos
CREATE TABLE IF NOT EXISTS productos (
id INT AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
precio DECIMAL(10,2) NOT NULL,
categoria_id INT,
FOREIGN KEY (categoria_id) REFERENCES categorias(id)
);
-- Crear tabla de mesas
CREATE TABLE IF NOT EXISTS mesas (
id INT AUTO_INCREMENT PRIMARY KEY,
numero INT NOT NULL UNIQUE
);
-- Crear tabla de comandas con productos en JSON
CREATE TABLE IF NOT EXISTS comandas (
id INT AUTO_INCREMENT PRIMARY KEY,
mesa_id INT NOT NULL,
productos JSON NOT NULL, -- Array de productos con cantidad y precio
fecha DATETIME DEFAULT CURRENT_TIMESTAMP,
total DECIMAL(10,2),
FOREIGN KEY (mesa_id) REFERENCES mesas(id)
);
-- Insertar categoría 'Café' en la tabla categorias
INSERT INTO categorias (nombre) VALUES ('cafe');
-- Insertar mesa '1, 2 y 3' en la tabla mesas
INSERT INTO mesas (numero)
VALUES
(1),
(2),
(3);
-- Insertar cappuccino en la tabla productos, asociándolo con la categoría 'Café'
INSERT INTO productos (nombre, precio, categoria_id)
VALUES
('Cappuccino', 200.00, (SELECT id FROM categorias WHERE nombre = 'Café')),
('Latte', 200.00, (SELECT id FROM categorias WHERE nombre = 'Café')),
('Espresso', 120.00, (SELECT id FROM categorias WHERE nombre = 'Café'));
('Frappe', 290.00, (SELECT id FROM categorias WHERE nombre = 'Café'));
-- Insertar una comanda en la tabla comandas para la mesa 1
INSERT INTO comandas (mesa_id, productos, total)
VALUES
(
2, -- mesa_id
JSON_ARRAY(
JSON_OBJECT('producto_id', (SELECT id FROM productos WHERE nombre = 'Expresso'), 'cantidad', 2, 'precio_unitario', 111.00),
JSON_OBJECT('producto_id', (SELECT id FROM productos WHERE nombre = 'Latte'), 'cantidad', 1, 'precio_unitario', 666.00)
),
208457935.00 -- total (2 Cappuccinos * 200 + 1 Latte * 220)
),
(
3, -- mesa_id
JSON_ARRAY(
JSON_OBJECT('producto_id', (SELECT id FROM productos WHERE nombre = 'Cappuccino'), 'cantidad', 2, 'precio_unitario', 444.00),
JSON_OBJECT('producto_id', (SELECT id FROM productos WHERE nombre = 'Frappe'), 'cantidad', 4, 'precio_unitario', 222.00)
),
93826.00 -- total (2 Cappuccinos * 200 + 1 Latte * 220)
);
-34
View File
@@ -1,34 +0,0 @@
# docker-compose.dev.yml
services:
suitecoffee-app:
container_name: suitecoffee-app
build:
context: .
dockerfile: Dockerfile.dev
volumes:
- .:/app
ports:
- "${PORT}:${PORT}" # Usa la variable de entorno PORT
environment:
- NODE_ENV=development
- PORT=${PORT}
command: npm run dev # Usa nodemon para desarrollo
restart: unless-stopped
suitecoffee-db:
container_name: suitecoffee-db
image: mysql:latest
env_file:
- .env.${NODE_ENV}
environment:
MYSQL_USER: $DB_USER
MYSQL_PASSWORD: $DB_PASS
MYSQL_ROOT_PASSWORD: $DB_ROOT_PASSWORD
MYSQL_DATABASE: $DB_NAME
volumes:
- ./db/dev-db:/var/lib/mysql
- ./db/init:/docker-entrypoint-initdb.d
ports:
- "$DB_LOCAL_PORT:$DB_DOCKER_PORT"
restart: unless-stopped
-32
View File
@@ -1,32 +0,0 @@
# docker-compose.prod.yml
services:
suitecoffee-app:
container_name: suitecoffee-app
build:
context: .
dockerfile: Dockerfile.prod
ports:
- "${PORT}:${PORT}" # Usa la variable de entorno PORT
environment:
- NODE_ENV=production
- PORT=${PORT}
command: npm start # Usa el comando de inicio en producción
restart: unless-stopped
suitecoffee-db:
container_name: suitecoffee-db
image: mysql:latest
env_file:
- .env.${NODE_ENV}
environment:
MYSQL_USER: $DB_USER
MYSQL_PASSWORD: $DB_PASS
MYSQL_ROOT_PASSWORD: $DB_ROOT_PASSWORD
MYSQL_DATABASE: $DB_NAME
volumes:
- ./db/app-db/mysql_prod:/var/lib/mysql
- ./db/init:/docker-entrypoint-initdb.d
ports:
- "$DB_LOCAL_PORT:$DB_DOCKER_PORT"
restart: unless-stopped
-250
View File
@@ -1,250 +0,0 @@
// index.js
const express = require('express');
const path = require('path');
const dotenv = require('dotenv');
const mysql = require('mysql2/promise');
const cors = require('cors');
const app = express();
const port = process.env.PORT || 3000;
// Cargar las variables de entorno dependiendo del entorno
const envFile = process.env.NODE_ENV === 'production' ? '.env.production' : '.env.development';
dotenv.config({ path: envFile });
// Configuración de conexión MySQL
const dbConfig = {
host: process.env.DB_HOST || 'db',
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: process.env.DB_DOCKER_PORT || 3306
};
app.use(cors());
app.use(express.json());
// Servir archivos estáticos de la carpeta 'src'
app.use(express.static(path.join(__dirname, 'src')));
// --------------------------------------------------------------------------------------
// --------------------------------- RENDERIZADO --------------------------------------
// --------------------------------------------------------------------------------------
// Ruta principal
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'src', 'index.html'));
});
// Ruta para comandas
app.get('/comandas', (req, res) => {
res.sendFile(path.join(__dirname, 'src', 'pages', 'comandas.html'));
});
// Ruta para dashboard
app.get('/lectura', (req, res) => {
res.sendFile(path.join(__dirname, 'src', 'pages', 'lectura.html'));
});
// Ruta para dashboard
app.get('/carga', (req, res) => {
res.sendFile(path.join(__dirname, 'src', 'pages', 'carga.html'));
});
// Ruta para obtener las tablas de la base de datos
app.get('/tablas', async (req, res) => {
let connection;
try {
connection = await mysql.createConnection(dbConfig);
const [rows] = await connection.execute(
`SHOW TABLES FROM \`${dbConfig.database}\``
);
const tablas = rows.map(row => Object.values(row)[0]);
res.json({ tablas });
} catch (error) {
console.error('Error al conectar o consultar la base de datos:', error);
res.status(500).json({ error: 'Error interno al consultar la base de datos' });
} finally {
if (connection) {
await connection.end();
}
}
});
// --------------------------------------------------------------------------------------
// ----------------------------------- LECTURAS ---------------------------------------
// --------------------------------------------------------------------------------------
// Obtener mesas
app.get('/api/obtenerMesas', async (req, res) => {
let connection;
try {
connection = await mysql.createConnection(dbConfig);
const [results] = await connection.query('SELECT * FROM mesas');
res.json(results);
} catch (error) {
console.error('Error al obtener mesas:', error);
res.status(500).json({ error: 'Error al obtener mesas' });
} finally {
if (connection) await connection.end();
}
});
// Obtener productos
app.get('/api/obtenerProductos', async (req, res) => {
let connection;
try {
connection = await mysql.createConnection(dbConfig);
const [results] = await connection.query('SELECT * FROM productos');
res.json(results);
} catch (error) {
console.error('Error al obtener productos:', error);
res.status(500).json({ error: 'Error al obtener productos' });
} finally {
if (connection) await connection.end();
}
});
// Obtener categorías
app.get('/api/obtenerCategorias', async (req, res) => {
let connection;
try {
connection = await mysql.createConnection(dbConfig);
const [results] = await connection.query('SELECT * FROM categorias');
res.json(results);
} catch (error) {
console.error('Error al obtener categorías:', error);
res.status(500).json({ error: 'Error al obtener categorías' });
} finally {
if (connection) await connection.end();
}
});
// Obtener comandas
app.get('/api/obtenerComandas', async (req, res) => {
let connection;
try {
connection = await mysql.createConnection(dbConfig);
const [results] = await connection.execute('SELECT * FROM comandas');
res.json(results);
} catch (error) {
console.error('Error al obtener comandas:', error);
res.status(500).json({ error: 'Error al obtener comandas' });
} finally {
if (connection) await connection.end();
}
});
// --------------------------------------------------------------------------------------
// ------------------------------------ CARGAS ----------------------------------------
// --------------------------------------------------------------------------------------
// Cargar nueva mesa
app.post('/api/cargarMesas', async (req, res) => {
const { numero } = req.body;
if (!numero) return res.status(400).json({ error: 'Falta el número de mesa' });
let connection;
try {
connection = await mysql.createConnection(dbConfig);
await connection.execute('INSERT INTO mesas (numero) VALUES (?)', [numero]);
res.status(201).json({ mensaje: 'Mesa cargada correctamente' });
} catch (error) {
console.error('Error al cargar mesa:', error);
res.status(500).json({ error: 'Error al cargar mesa' });
} finally {
if (connection) await connection.end();
}
});
// Cargar nuevo producto
app.post('/api/cargarProductos', async (req, res) => {
const { nombre, precio, categoria_id } = req.body;
if (!nombre || !precio || !categoria_id) {
return res.status(400).json({ error: 'Faltan datos para cargar el producto' });
}
let connection;
try {
connection = await mysql.createConnection(dbConfig);
await connection.execute(
'INSERT INTO productos (nombre, precio, categoria_id) VALUES (?, ?, ?)',
[nombre, precio, categoria_id]
);
res.status(201).json({ mensaje: 'Producto cargado correctamente' });
} catch (error) {
console.error('Error al cargar producto:', error);
res.status(500).json({ error: 'Error al cargar producto' });
} finally {
if (connection) await connection.end();
}
});
// Cargar nueva categoría
app.post('/api/cargarCategorias', async (req, res) => {
const { nombre } = req.body;
if (!nombre) return res.status(400).json({ error: 'Falta el nombre de la categoría' });
let connection;
try {
connection = await mysql.createConnection(dbConfig);
await connection.execute('INSERT INTO categorias (nombre) VALUES (?)', [nombre]);
res.status(201).json({ mensaje: 'Categoría cargada correctamente' });
} catch (error) {
console.error('Error al cargar categoría:', error);
res.status(500).json({ error: 'Error al cargar categoría' });
} finally {
if (connection) await connection.end();
}
});
// Cargar nueva comanda
app.post('/api/cargarComandas', async (req, res) => {
const { mesa_id, productos, total } = req.body;
if (!mesa_id || !productos || !Array.isArray(productos) || total == null) {
return res.status(400).json({ error: 'Datos inválidos para cargar comanda' });
}
let connection;
try {
connection = await mysql.createConnection(dbConfig);
await connection.execute(
'INSERT INTO comandas (mesa_id, productos, total, fecha) VALUES (?, ?, ?, NOW())',
[mesa_id, JSON.stringify(productos), total]
);
res.status(201).json({ mensaje: 'Comanda cargada correctamente' });
} catch (error) {
console.error('Error al cargar comanda:', error);
res.status(500).json({ error: 'Error al cargar comanda' });
} finally {
if (connection) await connection.end();
}
});
// --------------------------------------------------------------------------------------
// --------------------------------- VERIFICACIONES -----------------------------------
// --------------------------------------------------------------------------------------
async function verificarConexion() {
try {
const connection = await mysql.createConnection(dbConfig);
const [rows] = await connection.execute('SELECT NOW() AS hora');
console.log('Conexión con la base de datos fue exitosa.');
console.log('Fecha y hora actual de la base de datos:', rows[0].hora);
await connection.end();
} catch (error) {
console.error('Error al conectar con la base de datos al iniciar:', error.message);
}
}
// Iniciar servidor
app.listen(port, () => {
console.log(`Servidor corriendo en http://localhost:${port}`);
console.log('Estableciendo conexión...');
verificarConexion();
});
+14 -16
View File
@@ -1,23 +1,21 @@
{ {
"name": "suitecoffee", "name": "suitecoffee",
"version": "1.0.0", "version": "1.0.0",
"description": "", "description": "Software para gestión de cafeterías",
"main": "index.js", "keywords": [
"scripts": { "coffee",
"start": "node index.js", "suite",
"dev": "nodemon index.js" "suitecoffee"
],
"repository": {
"type": "git",
"url": "https://gitea.mateosaldain.uy/msaldain/SuiteCoffee.git"
}, },
"keywords": [],
"author": "",
"license": "ISC", "license": "ISC",
"type": "commonjs", "author": "Mateo Saldain",
"dependencies": { "type": "module",
"cors": "^2.8.5", "main": "suitecoffee.py",
"dotenv": "^16.5.0", "scripts": {
"express": "^5.1.0", "test": "echo \"Error: no test specified\" && exit 1"
"mysql2": "^3.14.0"
},
"devDependencies": {
"nodemon": "^3.1.9"
} }
} }
+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).")
+20
View File
@@ -0,0 +1,20 @@
# Dockerfile.dev
FROM node:22.18
# Definir variables de entorno con valores predeterminados
# ARG NODE_ENV=production
# ARG PORT=3000
RUN apt-get update
RUN apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# Copia archivos de configuración primero para aprovechar el cache
COPY package*.json ./
# Instala dependencias
RUN npm i
# Copia el resto de la app
COPY . .
CMD ["npm", "run", "start"]
+300 -128
View File
@@ -1,23 +1,34 @@
{ {
"name": "suitecoffee", "name": "aplication",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "suitecoffee", "name": "aplication",
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"chalk": "^5.6.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.5.0", "dotenv": "^17.2.1",
"express": "^5.1.0", "express": "^5.1.0",
"mysql2": "^3.14.0" "express-ejs-layouts": "^2.5.1",
"pg": "^8.16.3",
"pg-format": "^1.0.4"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.1.9" "cross-env": "^10.0.0",
"nodemon": "^3.1.10"
} }
}, },
"node_modules/@epic-web/invariant": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
"dev": true,
"license": "MIT"
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -45,15 +56,6 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -95,9 +97,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -156,6 +158,18 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/chalk": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz",
"integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -240,10 +254,43 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/cross-env": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.0.0.tgz",
"integrity": "sha512-aU8qlEK/nHYtVuN4p7UQgAwVljzMg8hB4YK5ThRqD2l/ziSnryncPNn7bMLt5cFYsKVKBh8HqLqyCoTupEUu7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@epic-web/invariant": "^1.0.0",
"cross-spawn": "^7.0.6"
},
"bin": {
"cross-env": "dist/bin/cross-env.js",
"cross-env-shell": "dist/bin/cross-env-shell.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.0", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@@ -257,15 +304,6 @@
} }
} }
}, },
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -276,9 +314,9 @@
} }
}, },
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "16.5.0", "version": "17.2.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz",
"integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", "integrity": "sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -403,6 +441,11 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/express-ejs-layouts": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/express-ejs-layouts/-/express-ejs-layouts-2.5.1.tgz",
"integrity": "sha512-IXROv9n3xKga7FowT06n1Qn927JR8ZWDn5Dc9CJQoiiaaDqbhW5PDmWShzbpAa2wjWT1vJqaIM1S6vJwwX11gA=="
},
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -475,15 +518,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
"license": "MIT",
"dependencies": {
"is-property": "^1.0.2"
}
},
"node_modules/get-intrinsic": { "node_modules/get-intrinsic": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -596,6 +630,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/http-errors/node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -682,41 +725,12 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/is-property": { "node_modules/isexe": {
"version": "1.0.2", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "MIT" "dev": true,
}, "license": "ISC"
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/lru-cache": {
"version": "7.18.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
"integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/lru.min": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.2.tgz",
"integrity": "sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=1.30.0",
"node": ">=8.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wellwelwel"
}
}, },
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
@@ -788,38 +802,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/mysql2": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.14.0.tgz",
"integrity": "sha512-8eMhmG6gt/hRkU1G+8KlGOdQi2w+CgtNoD1ksXZq9gQfkfDsX4LHaBwTe1SY0Imx//t2iZA03DFnyYKPinxSRw==",
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.1",
"denque": "^2.1.0",
"generate-function": "^2.3.1",
"iconv-lite": "^0.6.3",
"long": "^5.2.1",
"lru.min": "^1.0.0",
"named-placeholders": "^1.1.3",
"seq-queue": "^0.0.5",
"sqlstring": "^2.3.2"
},
"engines": {
"node": ">= 8.0"
}
},
"node_modules/named-placeholders": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz",
"integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==",
"license": "MIT",
"dependencies": {
"lru-cache": "^7.14.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/negotiator": { "node_modules/negotiator": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
@@ -830,9 +812,9 @@
} }
}, },
"node_modules/nodemon": { "node_modules/nodemon": {
"version": "3.1.9", "version": "3.1.10",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
"integrity": "sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg==", "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -919,6 +901,16 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "8.2.0", "version": "8.2.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
@@ -928,6 +920,104 @@
"node": ">=16" "node": ">=16"
} }
}, },
"node_modules/pg": {
"version": "8.16.3",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1",
"pg-protocol": "^1.10.3",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.2.7"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
"integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
"integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
"license": "MIT"
},
"node_modules/pg-format": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/pg-format/-/pg-format-1.0.4.tgz",
"integrity": "sha512-YyKEF78pEA6wwTAqOUaHIN/rWpfzzIuMh9KdAhc3rSLQ/7zkRFcCgYBAEGatDstLyZw4g0s9SNICmaTGnBVeyw==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
"integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
"integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
@@ -941,6 +1031,45 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1056,9 +1185,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.1", "version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
@@ -1090,11 +1219,6 @@
"node": ">= 18" "node": ">= 18"
} }
}, },
"node_modules/seq-queue": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
},
"node_modules/serve-static": { "node_modules/serve-static": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
@@ -1116,6 +1240,29 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/side-channel": { "node_modules/side-channel": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@@ -1201,19 +1348,19 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/sqlstring": { "node_modules/split2": {
"version": "2.3.3", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "MIT", "license": "ISC",
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 10.x"
} }
}, },
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.1", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.8" "node": ">= 0.8"
@@ -1303,11 +1450,36 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/wrappy": { "node_modules/wrappy": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "license": "ISC"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
} }
} }
} }
+28
View File
@@ -0,0 +1,28 @@
{
"name": "aplication",
"version": "1.0.0",
"main": "src/index.js",
"scripts": {
"start": "NODE_ENV=production node ./src/index.js",
"dev": "NODE_ENV=development npx nodemon ./src/index.js",
"test": "NODE_ENV=stage node ./src/index.js"
},
"author": "Mateo Saldain",
"license": "ISC",
"type": "module",
"devDependencies": {
"cross-env": "^10.0.0",
"nodemon": "^3.1.10"
},
"dependencies": {
"chalk": "^5.6.0",
"cors": "^2.8.5",
"dotenv": "^17.2.1",
"express": "^5.1.0",
"express-ejs-layouts": "^2.5.1",
"pg": "^8.16.3",
"pg-format": "^1.0.4"
},
"keywords": [],
"description": ""
}
+250
View File
@@ -0,0 +1,250 @@
// app/src/index.js
import chalk from 'chalk'; // Colores!
import express from 'express';
import expressLayouts from 'express-ejs-layouts';
import cors from 'cors';
import { Pool } from 'pg';
// Rutas
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Variables de Entorno
import dotenv, { config } from 'dotenv';
// Obtención de la ruta de la variable de entorno correspondiente a NODE_ENV
try {
if (process.env.NODE_ENV === 'development') {
dotenv.config({ path: path.resolve(__dirname, '../.env.development' )});
console.log(`Activando entorno de ->${chalk.green(` DEVELOPMENT `)}`);
} else if (process.env.NODE_ENV === 'stage') {
dotenv.config({ path: path.resolve(__dirname, '../.env.test' )});
console.log(`Activando entorno de ->${chalk.yellow(` TESTING `)}`);
} else if (process.env.NODE_ENV === 'production') {
dotenv.config({ path: path.resolve(__dirname, '../.env.production' )});
console.log(`Activando entorno de ->${chalk.red(` PRODUCTION `)}`);
}
} catch (error) {
console.log("A ocurrido un error al seleccionar el entorno. \nError: " + error);
}
// Renderiado
const app = express();
app.use(cors());
app.use(express.json());
app.use(express.static(path.join(__dirname, 'pages')));
// Configuración de conexión PostgreSQL
const dbConfig = {
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: process.env.DB_LOCAL_PORT
};
const pool = new Pool(dbConfig);
async function verificarConexion() {
try {
const client = await pool.connect();
const res = await client.query('SELECT NOW() AS hora');
console.log(`\nConexión con la base de datos ${chalk.green(process.env.DB_NAME)} fue exitosa.`);
console.log('Fecha y hora actual de la base de datos:', res.rows[0].hora);
client.release(); // liberar el cliente de nuevo al pool
} catch (error) {
console.error('Error al conectar con la base de datos al iniciar:', error.message);
console.error(`Troubleshooting:\n1. Compruebe que las bases de datos se iniciaron correctamente.\n2. Verifique las credenciales y puertos de acceso a la base de datos.\n3. Si está conectandose a una base de datos externa a localhost, verifique las reglas del firewal de entrada y salida de ambos dispositivos.`);
}
}
// === Servir páginas estáticas ===
app.get('/roles', (req, res) => res.sendFile(path.join(__dirname, 'pages', 'roles.html')));
app.get('/usuarios', (req, res) => res.sendFile(path.join(__dirname, 'pages', 'usuarios.html')));
app.get('/categorias',(req, res) => res.sendFile(path.join(__dirname, 'pages', 'categorias.html')));
app.get('/productos', (req, res) => res.sendFile(path.join(__dirname, 'pages', 'productos.html')));
// Helper de consulta con acquire/release explícito
async function q(text, params) {
const client = await pool.connect();
try {
return await client.query(text, params);
} finally {
client.release();
}
}
// === API Roles ===
// GET: listar
app.get('/api/roles', async (req, res) => {
try {
const { rows } = await q('SELECT id_rol, nombre FROM roles ORDER BY id_rol ASC');
res.json(rows);
} catch (e) {
console.error(e);
res.status(500).json({ error: 'No se pudo listar roles' });
}
});
// POST: crear
app.post('/api/roles', async (req, res) => {
try {
const { nombre } = req.body;
if (!nombre || !nombre.trim()) return res.status(400).json({ error: 'Nombre requerido' });
const { rows } = await q(
'INSERT INTO roles (nombre) VALUES ($1) RETURNING id_rol, nombre',
[nombre.trim()]
);
res.status(201).json(rows[0]);
} catch (e) {
console.error(e);
// Manejo de único/duplicado
if (e.code === '23505') return res.status(409).json({ error: 'El rol ya existe' });
res.status(500).json({ error: 'No se pudo crear el rol' });
}
});
// === API Usuarios ===
// GET: listar
app.get('/api/usuarios', async (req, res) => {
try {
const { rows } = await q(`
SELECT id_usuario, documento, img_perfil, nombre, apellido, correo, telefono, fec_nacimiento, activo
FROM usuarios
ORDER BY id_usuario ASC
`);
res.json(rows);
} catch (e) {
console.error(e);
res.status(500).json({ error: 'No se pudo listar usuarios' });
}
});
// POST: crear
app.post('/api/usuarios', async (req, res) => {
try {
const { documento, nombre, apellido, correo, telefono, fec_nacimiento } = req.body;
if (!nombre || !apellido) return res.status(400).json({ error: 'Nombre y apellido requeridos' });
const { rows } = await q(`
INSERT INTO usuarios (documento, nombre, apellido, correo, telefono, fec_nacimiento)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id_usuario, documento, nombre, apellido, correo, telefono, fec_nacimiento, activo
`, [
documento || null,
nombre.trim(),
apellido.trim(),
correo || null,
telefono || null,
fec_nacimiento || null
]);
res.status(201).json(rows[0]);
} catch (e) {
console.error(e);
if (e.code === '23505') return res.status(409).json({ error: 'Documento/Correo/Teléfono ya existe' });
res.status(500).json({ error: 'No se pudo crear el usuario' });
}
});
// === API Categorías ===
// GET: listar
app.get('/api/categorias', async (req, res) => {
try {
const { rows } = await q('SELECT id_categoria, nombre, visible FROM categorias ORDER BY id_categoria ASC');
res.json(rows);
} catch (e) {
console.error(e);
res.status(500).json({ error: 'No se pudo listar categorías' });
}
});
// POST: crear
app.post('/api/categorias', async (req, res) => {
try {
const { nombre, visible } = req.body;
if (!nombre || !nombre.trim()) return res.status(400).json({ error: 'Nombre requerido' });
const vis = (typeof visible === 'boolean') ? visible : true;
const { rows } = await q(`
INSERT INTO categorias (nombre, visible)
VALUES ($1, $2)
RETURNING id_categoria, nombre, visible
`, [nombre.trim(), vis]);
res.status(201).json(rows[0]);
} catch (e) {
console.error(e);
if (e.code === '23505') return res.status(409).json({ error: 'La categoría ya existe' });
res.status(500).json({ error: 'No se pudo crear la categoría' });
}
});
// === API Productos ===
// GET: listar
app.get('/api/productos', async (req, res) => {
try {
const { rows } = await q(`
SELECT id_producto, nombre, img_producto, precio, activo, id_categoria
FROM productos
ORDER BY id_producto ASC
`);
res.json(rows);
} catch (e) {
console.error(e);
res.status(500).json({ error: 'No se pudo listar productos' });
}
});
// POST: crear
app.post('/api/productos', async (req, res) => {
try {
let { nombre, id_categoria, precio } = req.body;
if (!nombre || !nombre.trim()) return res.status(400).json({ error: 'Nombre requerido' });
id_categoria = parseInt(id_categoria, 10);
precio = parseFloat(precio);
if (!Number.isInteger(id_categoria)) return res.status(400).json({ error: 'id_categoria inválido' });
if (!(precio >= 0)) return res.status(400).json({ error: 'precio inválido' });
const { rows } = await q(`
INSERT INTO productos (nombre, id_categoria, precio)
VALUES ($1, $2, $3)
RETURNING id_producto, nombre, precio, activo, id_categoria
`, [nombre.trim(), id_categoria, precio]);
res.status(201).json(rows[0]);
} catch (e) {
console.error(e);
// FK categories / checks
if (e.code === '23503') return res.status(400).json({ error: 'La categoría no existe' });
res.status(500).json({ error: 'No se pudo crear el producto' });
}
});
// Colores personalizados
let primaryColor = chalk.hex('#'+`${process.env.COL_PRI}`);
let secondaryColor = chalk.hex('#'+`${process.env.COL_SEC}`);
// let backgroundColor = chalk.hex('#'+`${process.env.COL_BG}`);
app.use(expressLayouts);
// Iniciar servidor
app.listen( process.env.PORT, () => {
console.log(`Servidor de ${chalk.red('aplicación')} de ${secondaryColor('SuiteCoffee')} corriendo en ${chalk.yellow(`http://localhost:${process.env.PORT}\n`)}` );
console.log(chalk.grey(`Comprobando accesibilidad a la db ${chalk.green(process.env.DB_NAME)} del host ${chalk.white(`${process.env.DB_HOST}`)} ...`));
verificarConexion();
});
app.get("/health", async (req, res) => {
// Podés chequear DB aquí. 200 = healthy; 503 = not ready.
res.status(200).json({ status: "ok" });
});
+70
View File
@@ -0,0 +1,70 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>Categorías</title>
</head>
<body>
<h1>Categorías</h1>
<h2>Crear categoría</h2>
<form id="form-categoria">
<label>Nombre:
<input type="text" name="nombre" required />
</label>
<label>Visible:
<select name="visible">
<option value="true" selected></option>
<option value="false">No</option>
</select>
</label>
<button type="submit">Guardar</button>
</form>
<h2>Listado</h2>
<button id="btn-recargar">Recargar</button>
<table border="1" cellpadding="6">
<thead><tr><th>ID</th><th>Nombre</th><th>Visible</th></tr></thead>
<tbody id="tbody"></tbody>
</table>
<script>
const API = '/api/categorias';
async function listar() {
const res = await fetch(API);
const data = await res.json();
const tbody = document.getElementById('tbody');
tbody.innerHTML = '';
data.forEach(c => {
const tr = document.createElement('tr');
tr.innerHTML = `<td>${c.id_categoria}</td><td>${c.nombre}</td><td>${c.visible ? 'Sí' : 'No'}</td>`;
tbody.appendChild(tr);
});
}
document.getElementById('form-categoria').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const nombre = fd.get('nombre').trim();
const visible = fd.get('visible') === 'true';
if (!nombre) return;
const res = await fetch(API, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ nombre, visible })
});
if (!res.ok) {
const err = await res.json().catch(()=>({error:'Error'}));
alert('Error: ' + (err.error || res.statusText));
return;
}
e.target.reset();
await listar();
});
document.getElementById('btn-recargar').addEventListener('click', listar);
listar();
</script>
</body>
</html>
+106
View File
@@ -0,0 +1,106 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>Productos</title>
</head>
<body>
<h1>Productos</h1>
<h2>Crear producto</h2>
<form id="form-producto">
<div>
<label>Nombre:
<input name="nombre" type="text" required />
</label>
</div>
<div>
<label>Precio:
<input name="precio" type="number" step="0.01" min="0" required />
</label>
</div>
<div>
<label>Categoría:
<select name="id_categoria" id="sel-categoria" required></select>
</label>
</div>
<button type="submit">Guardar</button>
</form>
<h2>Listado</h2>
<button id="btn-recargar">Recargar</button>
<table border="1" cellpadding="6">
<thead>
<tr><th>ID</th><th>Nombre</th><th>Precio</th><th>Activo</th><th>ID Categoría</th></tr>
</thead>
<tbody id="tbody"></tbody>
</table>
<script>
const API = '/api/productos';
const API_CAT = '/api/categorias';
async function cargarCategorias() {
const res = await fetch(API_CAT);
const data = await res.json();
const sel = document.getElementById('sel-categoria');
sel.innerHTML = '<option value="" disabled selected>Seleccione...</option>';
data.forEach(c => {
const opt = document.createElement('option');
opt.value = c.id_categoria;
opt.textContent = `${c.id_categoria} - ${c.nombre}`;
sel.appendChild(opt);
});
}
async function listar() {
const res = await fetch(API);
const data = await res.json();
const tbody = document.getElementById('tbody');
tbody.innerHTML = '';
data.forEach(p => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${p.id_producto}</td>
<td>${p.nombre}</td>
<td>${Number(p.precio).toFixed(2)}</td>
<td>${p.activo ? 'Sí' : 'No'}</td>
<td>${p.id_categoria}</td>
`;
tbody.appendChild(tr);
});
}
document.getElementById('form-producto').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const payload = {
nombre: fd.get('nombre').trim(),
precio: parseFloat(fd.get('precio')),
id_categoria: parseInt(fd.get('id_categoria'), 10)
};
if (!payload.nombre || isNaN(payload.precio) || isNaN(payload.id_categoria)) return;
const res = await fetch(API, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(payload)
});
if (!res.ok) {
const err = await res.json().catch(()=>({error:'Error'}));
alert('Error: ' + (err.error || res.statusText));
return;
}
e.target.reset();
await listar();
});
document.getElementById('btn-recargar').addEventListener('click', listar);
(async () => {
await cargarCategorias();
await listar();
})();
</script>
</body>
</html>
+62
View File
@@ -0,0 +1,62 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>Roles</title>
</head>
<body>
<h1>Roles</h1>
<h2>Crear rol</h2>
<form id="form-rol">
<label>Nombre del rol:
<input type="text" name="nombre" required />
</label>
<button type="submit">Guardar</button>
</form>
<h2>Listado</h2>
<button id="btn-recargar">Recargar</button>
<table border="1" cellpadding="6">
<thead><tr><th>ID</th><th>Nombre</th></tr></thead>
<tbody id="tbody"></tbody>
</table>
<script>
const API = '/api/roles';
async function listar() {
const res = await fetch(API);
const data = await res.json();
const tbody = document.getElementById('tbody');
tbody.innerHTML = '';
data.forEach(r => {
const tr = document.createElement('tr');
tr.innerHTML = `<td>${r.id_rol}</td><td>${r.nombre}</td>`;
tbody.appendChild(tr);
});
}
document.getElementById('form-rol').addEventListener('submit', async (e) => {
e.preventDefault();
const nombre = e.target.nombre.value.trim();
if (!nombre) return;
const res = await fetch(API, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ nombre })
});
if (!res.ok) {
const err = await res.json().catch(()=>({error:'Error'}));
alert('Error: ' + (err.error || res.statusText));
return;
}
e.target.reset();
await listar();
});
document.getElementById('btn-recargar').addEventListener('click', listar);
listar();
</script>
</body>
</html>
+104
View File
@@ -0,0 +1,104 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>Usuarios</title>
</head>
<body>
<h1>Usuarios</h1>
<h2>Crear usuario</h2>
<form id="form-usuario">
<div>
<label>Documento:
<input name="documento" type="text" />
</label>
</div>
<div>
<label>Nombre:
<input name="nombre" type="text" required />
</label>
</div>
<div>
<label>Apellido:
<input name="apellido" type="text" required />
</label>
</div>
<div>
<label>Correo:
<input name="correo" type="email" />
</label>
</div>
<div>
<label>Teléfono:
<input name="telefono" type="text" />
</label>
</div>
<div>
<label>Fecha de nacimiento:
<input name="fec_nacimiento" type="date" />
</label>
</div>
<button type="submit">Guardar</button>
</form>
<h2>Listado</h2>
<button id="btn-recargar">Recargar</button>
<table border="1" cellpadding="6">
<thead>
<tr>
<th>ID</th><th>Documento</th><th>Nombre</th><th>Apellido</th>
<th>Correo</th><th>Teléfono</th><th>Nacimiento</th><th>Activo</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
<script>
const API = '/api/usuarios';
async function listar() {
const res = await fetch(API);
const data = await res.json();
const tbody = document.getElementById('tbody');
tbody.innerHTML = '';
data.forEach(u => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${u.id_usuario}</td>
<td>${u.documento ?? ''}</td>
<td>${u.nombre}</td>
<td>${u.apellido}</td>
<td>${u.correo ?? ''}</td>
<td>${u.telefono ?? ''}</td>
<td>${u.fec_nacimiento ? u.fec_nacimiento.substring(0,10) : ''}</td>
<td>${u.activo ? 'Sí' : 'No'}</td>
`;
tbody.appendChild(tr);
});
}
document.getElementById('form-usuario').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const payload = Object.fromEntries(fd.entries());
if (payload.fec_nacimiento === '') delete payload.fec_nacimiento;
const res = await fetch(API, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(payload)
});
if (!res.ok) {
const err = await res.json().catch(()=>({error:'Error'}));
alert('Error: ' + (err.error || res.statusText));
return;
}
e.target.reset();
await listar();
});
document.getElementById('btn-recargar').addEventListener('click', listar);
listar();
</script>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
# Dockerfile.dev
FROM node:22.18
# Definir variables de entorno con valores predeterminados
# ARG NODE_ENV=production
# ARG PORT=4000
RUN apt-get update
RUN apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# Copia archivos de configuración primero para aprovechar el cache
COPY package*.json ./
# Instala dependencias
RUN npm i
# Copia el resto de la app
COPY . .
CMD ["npm", "run", "start"]
+1988
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "authentication",
"version": "1.0.0",
"main": "src/index.js",
"scripts": {
"start": "NODE_ENV=production node ./src/index.js",
"dev": "NODE_ENV=development npx nodemon ./src/index.js",
"test": "NODE_ENV=stage node ./src/index.js"
},
"author": "Mateo Saldain",
"license": "ISC",
"type": "module",
"devDependencies": {
"cross-env": "^10.0.0",
"nodemon": "^3.1.10"
},
"dependencies": {
"bcrypt": "^5.1.1",
"chalk": "^5.6.0",
"cors": "^2.8.5",
"dotenv": "^17.2.1",
"express": "^5.1.0",
"express-ejs-layouts": "^2.5.1",
"pg": "^8.16.3",
"pg-format": "^1.0.4"
},
"keywords": [],
"description": ""
}
+199
View File
@@ -0,0 +1,199 @@
// auth/src/index.js
import chalk from 'chalk';
import express from 'express';
import expressLayouts from 'express-ejs-layouts';
import cors from 'cors';
import { Pool } from 'pg';
import bcrypt from'bcrypt';
// Rutas
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Variables de Entorno
import dotenv, { config } from 'dotenv';
// Obtención de la ruta de la variable de entorno correspondiente a NODE_ENV
try {
if (process.env.NODE_ENV === 'development') {
dotenv.config({ path: path.resolve(__dirname, '../.env.development' )});
console.log(`Activando entorno de ->${chalk.green(` DEVELOPMENT `)}`);
} else if (process.env.NODE_ENV === 'stage') {
dotenv.config({ path: path.resolve(__dirname, '../.env.test' )});
console.log(`Activando entorno de ->${chalk.yellow(` TESTING `)}`);
} else if (process.env.NODE_ENV === 'production') {
dotenv.config({ path: path.resolve(__dirname, '../.env.production' )});
console.log(`Activando entorno de ->${chalk.red(` PRODUCTION `)}`);
}
} catch (error) {
console.log("A ocurrido un error al seleccionar el entorno. \nError: " + error);
}
// Configuración de renderizado
const app = express();
app.use(cors());
app.use(express.json());
app.set('trust proxy', true);
app.use(express.static(path.join(__dirname, 'pages')));
// Configuración de conexión PostgreSQL
const dbConfig = {
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: process.env.DB_LOCAL_PORT
};
const pool = new Pool(dbConfig);
async function verificarConexion() {
try {
const client = await pool.connect();
const res = await client.query('SELECT NOW() AS hora');
console.log(`\nConexión con la base de datos ${chalk.green(process.env.DB_NAME)} fue exitosa.`);
console.log('Fecha y hora actual de la base de datos:', res.rows[0].hora);
client.release(); // liberar el cliente de nuevo al pool
} catch (error) {
console.error('Error al conectar con la base de datos al iniciar:', error.message);
console.error(`Troubleshooting:\n1. Compruebe que las bases de datos se iniciaron correctamente.\n2. Verifique las credenciales y puertos de acceso a la base de datos.\n3. Si está conectandose a una base de datos externa a localhost, verifique las reglas del firewal de entrada y salida de ambos dispositivos.`);
}
}
// === Servir páginas estáticas ===
app.get('/',(req, res) => res.sendFile(path.join(__dirname, 'pages', 'index.html')));
app.get('/planes', async (req, res) => {
try {
const result = await pool.query(`
SELECT id, nombre, descripcion, precio
FROM plan
WHERE activo = true
ORDER BY id
`);
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Error al cargar planes' });
}
});
app.post('/api/registro', async (req, res) => {
const {
nombre_empresa,
rut,
correo,
telefono,
direccion,
logo,
clave_acceso,
plan_id
} = req.body;
try {
const client = await pool.connect();
// 1. Hashear la contraseña
const hash = await bcrypt.hash(clave_acceso, 10);
// 2. Insertar el tenant
const result = await client.query(`
INSERT INTO tenant (
nombre_empresa, rut, correo, telefono, direccion, logo,
clave_acceso, plan_id, nombre_base_datos
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, 'TEMPORAL'
)
RETURNING uuid;
`, [
nombre_empresa, rut, correo, telefono, direccion, logo,
hash, plan_id
]);
const uuid = result.rows[0].uuid;
const nombre_base_datos = `tenantdb_${uuid}`.replace(/-/g, '').substring(0, 24); // ajustamos para longitud segura
// 3. Actualizar el campo nombre_base_datos
await client.query(`
UPDATE tenant SET nombre_base_datos = $1 WHERE uuid = $2
`, [nombre_base_datos, uuid]);
client.release();
return res.status(201).json({
message: 'Tenant registrado correctamente',
uuid,
nombre_base_datos
});
} catch (err) {
console.error(err);
return res.status(500).json({ error: 'Error al registrar tenant' });
}
});
app.post('/api/login', async (req, res) => {
const { correo, clave_acceso } = req.body;
try {
const client = await pool.connect();
const result = await client.query(`
SELECT uuid, clave_acceso, nombre_empresa, nombre_base_datos
FROM tenant
WHERE correo = $1 AND estado = true
`, [correo]);
client.release();
if (result.rows.length === 0) {
return res.status(401).json({ error: 'Correo no registrado o inactivo' });
}
const tenant = result.rows[0];
const coincide = await bcrypt.compare(clave_acceso, tenant.clave_acceso);
if (!coincide) {
return res.status(401).json({ error: 'Clave incorrecta' });
}
return res.status(200).json({
message: 'Login correcto',
uuid: tenant.uuid,
nombre_empresa: tenant.nombre_empresa,
base_datos: tenant.nombre_base_datos
});
} catch (err) {
console.error(err);
return res.status(500).json({ error: 'Error al validar login' });
}
});
// Colores personalizados
let primaryColor = chalk.hex('#'+`${process.env.COL_PRI}`);
let secondaryColor = chalk.hex('#'+`${process.env.COL_SEC}`);
// let backgroundColor = chalk.hex('#'+`${process.env.COL_BG}`);
app.use(expressLayouts);
// Iniciar servidor
app.listen( process.env.PORT, () => {
console.log(`Servidor de ${chalk.yellow('autenticación')} de ${secondaryColor('SuiteCoffee')} corriendo en ${chalk.yellow(`http://localhost:${process.env.PORT}\n`)}` );
console.log(chalk.grey(`Comprobando accesibilidad a la db ${chalk.green(process.env.DB_NAME)} del host ${chalk.white(`${process.env.DB_HOST}`)} ...`));
verificarConexion();
});
app.get("/health", async (req, res) => {
// Podés chequear DB aquí. 200 = healthy; 503 = not ready.
res.status(200).json({ status: "ok" });
});
+154
View File
@@ -0,0 +1,154 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>SuiteCoffee - Autenticación</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light d-flex justify-content-center align-items-center vh-100">
<div class="card shadow p-4" style="width: 100%; max-width: 400px;">
<h4 class="text-center mb-3" id="form-title">Iniciar Sesión</h4>
<!-- Mensajes -->
<div id="mensaje" class="alert d-none" role="alert"></div>
<!-- Formulario compartido -->
<form id="formulario">
<div id="registro-extra" style="display: none;">
<div class="mb-2">
<input type="text" class="form-control" id="nombre_empresa" placeholder="Nombre de la empresa" required>
</div>
<div class="mb-2">
<input type="text" class="form-control" id="rut" placeholder="RUT (opcional)" required>
</div>
<div class="mb-2">
<input type="text" class="form-control" id="telefono" placeholder="Teléfono">
</div>
<div class="mb-2">
<input type="text" class="form-control" id="direccion" placeholder="Dirección">
</div>
<div class="mb-2">
<input type="text" class="form-control" id="logo" placeholder="Logo URL (opcional)">
</div>
<div class="mb-2">
<select class="form-select" id="plan_id" required>
<option value="">Cargando planes...</option>
</select>
</div>
</div>
<div class="mb-2">
<input type="email" class="form-control" id="correo" placeholder="Correo" required>
</div>
<div class="mb-3">
<input type="password" class="form-control" id="clave" placeholder="Contraseña" required>
</div>
<button type="submit" class="btn btn-primary w-100" id="btn-submit">Entrar</button>
</form>
<div class="text-center mt-3">
<button class="btn btn-link btn-sm" id="toggle-mode">¿No tienes cuenta? Regístrate</button>
</div>
</div>
<script>
const form = document.getElementById('formulario');
const mensaje = document.getElementById('mensaje');
const toggleModeBtn = document.getElementById('toggle-mode');
const registroExtra = document.getElementById('registro-extra');
const formTitle = document.getElementById('form-title');
const btnSubmit = document.getElementById('btn-submit');
let modoRegistro = false;
toggleModeBtn.addEventListener('click', () => {
modoRegistro = !modoRegistro;
registroExtra.style.display = modoRegistro ? 'block' : 'none';
formTitle.textContent = modoRegistro ? 'Registrar Cuenta' : 'Iniciar Sesión';
btnSubmit.textContent = modoRegistro ? 'Registrarse' : 'Entrar';
toggleModeBtn.textContent = modoRegistro ? '¿Ya tienes cuenta? Inicia sesión' : '¿No tienes cuenta? Regístrate';
if (modoRegistro) {
cargarPlanes(); // ✅ ahora sí se ejecutará correctamente
}
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
mensaje.classList.add('d-none');
const datos = {
correo: document.getElementById('correo').value,
clave_acceso: document.getElementById('clave').value
};
if (modoRegistro) {
Object.assign(datos, {
nombre_empresa: document.getElementById('nombre_empresa').value,
rut: document.getElementById('rut').value,
telefono: document.getElementById('telefono').value,
direccion: document.getElementById('direccion').value,
logo: document.getElementById('logo').value,
plan_id: document.getElementById('plan_id').value
});
}
try {
const url = modoRegistro ? '/api/registro' : '/api/login';
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(datos)
});
const resultado = await res.json();
if (!res.ok) {
throw new Error(resultado.error || 'Error inesperado');
}
mensaje.className = 'alert alert-success';
mensaje.textContent = resultado.message || (modoRegistro ? 'Registro exitoso' : 'Inicio exitoso');
mensaje.classList.remove('d-none');
if (!modoRegistro) {
// Redirigir a dashboard, por ejemplo
// window.location.href = `/dashboard?tenant=${resultado.uuid}`;
}
} catch (err) {
mensaje.className = 'alert alert-danger';
mensaje.textContent = err.message;
mensaje.classList.remove('d-none');
}
});
// ✅ Ahora la función está declarada correctamente
async function cargarPlanes() {
const select = document.getElementById('plan_id');
select.innerHTML = '<option value="">Cargando planes...</option>';
try {
const res = await fetch('/planes');
const planes = await res.json();
select.innerHTML = '<option value="">Seleccione un plan</option>';
planes.forEach(plan => {
const opt = document.createElement('option');
opt.value = plan.id;
opt.textContent = plan.nombre.charAt(0).toUpperCase() + plan.nombre.slice(1);
select.appendChild(opt);
});
} catch (err) {
select.innerHTML = '<option value="">Error al cargar planes</option>';
console.error('Error cargando planes:', err);
}
}
</script>
</body>
</html>
-40
View File
@@ -1,40 +0,0 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Login Bootstrap</title>
<!-- Bootstrap CDN -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</head>
<body class="bg-light d-flex justify-content-center align-items-center vh-100">
<div class="card shadow p-4" style="width: 100%; max-width: 350px;">
<h4 class="text-center mb-4">Iniciar Sesión</h4>
<form>
<div class="mb-3">
<label for="usuario" class="form-label">Usuario</label>
<input type="text" class="form-control" id="usuario" placeholder="Ingrese su usuario" required>
</div>
<div class="mb-3">
<label for="clave" class="form-label">Contraseña</label>
<input type="password" class="form-control" id="clave" placeholder="Ingrese su contraseña" required>
</div>
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="recordarme">
<label class="form-check-label" for="recordarme">
Recordarme
</label>
</div>
<a href="#" class="small">¿Olvidaste tu contraseña?</a>
</div>
<button type="submit" class="btn btn-primary w-100">Entrar</button>
</form>
</div>
<!-- Bootstrap JS (opcional) -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
-116
View File
@@ -1,116 +0,0 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Cargar Comanda</title>
</head>
<body>
<h2>Formulario de Carga de Comanda</h2>
<form id="formComanda">
<label for="mesaSelect">Mesa:</label>
<select id="mesaSelect" name="mesa_id" required></select>
<br><br>
<label for="productoSelect">Producto:</label>
<select id="productosSelect"></select>
<label for="cantidadInput">Cantidad:</label>
<input type="number" id="cantidadInput" min="1" value="1">
<button type="button" onclick="agregarProducto()">Agregar</button>
<ul id="listaProductos"></ul>
<input type="hidden" name="productos" id="productosJSON">
<br>
<button type="submit">Guardar Comanda</button>
</form>
<script>
// Cargar categorías y mesas (productos se maneja distinto por el precio)
async function cargarSelect(endpoint, selectId, mostrar) {
const res = await fetch(endpoint);
const data = await res.json();
const select = document.getElementById(selectId);
data.forEach(item => {
const option = document.createElement('option');
option.value = item.id;
option.textContent = mostrar(item);
select.appendChild(option);
});
}
async function cargarProductosConPrecio() {
const res = await fetch('/api/obtenerProductos');
const productos = await res.json();
const select = document.getElementById('productosSelect');
productos.forEach(prod => {
const option = document.createElement('option');
option.value = prod.id;
option.textContent = `${prod.nombre} ($${prod.precio})`;
option.setAttribute('data-precio', prod.precio);
select.appendChild(option);
});
}
const listaProductos = [];
function agregarProducto() {
const select = document.getElementById('productosSelect');
const cantidad = parseInt(document.getElementById('cantidadInput').value);
const productoId = select.value;
const nombre = select.options[select.selectedIndex].textContent;
const precioUnitario = parseFloat(select.options[select.selectedIndex].dataset.precio);
if (!productoId || isNaN(cantidad) || cantidad <= 0) {
alert("Producto o cantidad inválida");
return;
}
listaProductos.push({
producto_id: productoId,
cantidad,
precio_unitario: precioUnitario
});
// Mostrar en lista visual
const li = document.createElement('li');
li.textContent = `${nombre} x${cantidad} - $${(precioUnitario * cantidad).toFixed(2)}`;
document.getElementById('listaProductos').appendChild(li);
// Actualizar JSON oculto
document.getElementById('productosJSON').value = JSON.stringify(listaProductos);
}
document.getElementById('formComanda').addEventListener('submit', async function (e) {
e.preventDefault();
const mesaId = document.getElementById('mesaSelect').value;
const productos = listaProductos;
if (!mesaId || productos.length === 0) {
alert('Selecciona una mesa y al menos un producto');
return;
}
const res = await fetch('/api/cargarComandas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mesa_id: mesaId,
productos: productos
})
});
const resultado = await res.json();
alert(resultado.mensaje || 'Comanda cargada correctamente');
location.reload();
});
window.onload = () => {
cargarSelect('/api/obtenerMesas', 'mesaSelect', mesa => `Mesa ${mesa.numero}`);
cargarProductosConPrecio();
};
</script>
</body>
</html>
-100
View File
@@ -1,100 +0,0 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Comanda - Cafetería</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<div class="container my-4">
<div class="card shadow">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">🧾 Comanda de Cafetería</h4>
</div>
<div class="card-body">
<form>
<!-- Datos generales -->
<div class="row g-3 mb-4">
<div class="col-12 col-md-6">
<label for="mesa" class="form-label">Número de Mesa</label>
<select class="form-select" id="mesa" required>
<option value="">Seleccionar mesa</option>
<option>Mesa 1</option>
<option>Mesa 2</option>
<option>Mesa 3</option>
<option>Mesa 4</option>
<option>Mesa 5</option>
</select>
</div>
<div class="col-12 col-md-6">
<label for="mozo" class="form-label">Mozo/a</label>
<input type="text" class="form-control" id="mozo" placeholder="Nombre del mozo/a" required>
</div>
</div>
<!-- Productos -->
<div class="mb-4">
<label class="form-label">Productos</label>
<!-- Producto 1 -->
<div class="row g-2 mb-2">
<div class="col-12 col-md-6">
<select class="form-select">
<option value="">Seleccionar producto</option>
<option>Café</option>
<option>Café con leche</option>
<option>Capuccino</option>
<option>Medialuna</option>
<option>Jugo natural</option>
<option>Tostado</option>
</select>
</div>
<div class="col-6 col-md-3">
<input type="number" class="form-control" placeholder="Cant.">
</div>
<div class="col-6 col-md-3">
<input type="text" class="form-control" placeholder="Notas">
</div>
</div>
<!-- Producto 2 -->
<div class="row g-2 mb-2">
<div class="col-12 col-md-6">
<select class="form-select">
<option value="">Seleccionar producto</option>
<option>Café</option>
<option>Café con leche</option>
<option>Capuccino</option>
<option>Medialuna</option>
<option>Jugo natural</option>
<option>Tostado</option>
</select>
</div>
<div class="col-6 col-md-3">
<input type="number" class="form-control" placeholder="Cant.">
</div>
<div class="col-6 col-md-3">
<input type="text" class="form-control" placeholder="Notas">
</div>
</div>
</div>
<!-- Observaciones -->
<div class="mb-4">
<label for="observaciones" class="form-label">Observaciones</label>
<textarea class="form-control" id="observaciones" rows="3" placeholder="Ej: Sin azúcar, entregar cuando esté completo"></textarea>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-success">Enviar Comanda</button>
</div>
</form>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
-43
View File
@@ -1,43 +0,0 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Dashboard</title>
</head>
<body>
<h2>Mesas</h2>
<select id="mesasSelect"></select>
<h2>Productos</h2>
<select id="productosSelect"></select>
<h2>Categorías</h2>
<select id="categoriasSelect"></select>
<h2>Comandas</h2>
<select id="comandasSelect"></select>
<script>
async function cargarDatos(endpoint, selectId, mostrar) {
const res = await fetch(endpoint); // Usar endpoint relativo
const data = await res.json();
const select = document.getElementById(selectId);
data.forEach(item => {
const option = document.createElement('option');
option.value = item.id;
option.textContent = mostrar(item);
select.appendChild(option);
});
}
// Al cargar la página, cargamos los datos
window.onload = () => {
cargarDatos('api/obtenerMesas', 'mesasSelect', mesa => `Mesa ${mesa.numero}`);
cargarDatos('api/obtenerProductos', 'productosSelect', prod => `${prod.nombre} ($${prod.precio})`);
cargarDatos('api/obtenerCategorias', 'categoriasSelect', cat => cat.nombre);
cargarDatos('api/obtenerComandas', 'comandasSelect', com => `Comanda ${com.id} - Mesa ${com.mesa_id} - $${com.total}`);
};
</script>
</body>
</html>
+386
View File
@@ -0,0 +1,386 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os
import sys
import subprocess
from shutil import which
PROJECT_ROOT = os.path.abspath(os.getcwd())
# === Archivos Compose (entornos) ===
BASE_COMPOSE = os.path.join(PROJECT_ROOT, "compose.yaml")
DEV_COMPOSE = os.path.join(PROJECT_ROOT, "compose.dev.yaml")
PROD_COMPOSE = os.path.join(PROJECT_ROOT, "compose.prod.yaml")
# === Archivos Compose (globales) ===
NPM_COMPOSE = os.path.join(PROJECT_ROOT, "compose.npm.yaml")
DBEAVER_COMPOSE = os.path.join(PROJECT_ROOT, "compose.dbeaver.yaml")
# Archivos .env
ENV_FILES = {
"development": ".env.development",
"production": ".env.production",
}
# Nombres de proyecto para permitir DEV y PROD simultáneos
def _base_project():
return os.path.basename(PROJECT_ROOT).lower() or "composeproj"
PROJECT_NAMES = {
"development": f"{_base_project()}_dev",
"production": f"{_base_project()}_prod",
}
# Nombre de proyecto global (ambos yaml globales usan name: suitecoffee)
GLOBAL_PROJECT_NAME = "suitecoffee"
# ---------- Utilidades ----------
def check_prereqs():
if which("docker") is None:
fail("No se encontró 'docker' en el PATH.")
try:
run(["docker", "compose", "version"], check=True, capture_output=True)
except Exception:
fail("No se pudo ejecutar 'docker compose'. Asegúrate de tener Docker Compose v2.")
def run(cmd, check=False, capture_output=False):
return subprocess.run(cmd, check=check, capture_output=capture_output, text=True)
def compose_files_args(env_key):
"""
Devuelve los -f correctos según el entorno (dev/prod) + base.
"""
if not os.path.exists(BASE_COMPOSE):
fail("No se encontró compose.yaml en la raíz del proyecto.")
args = ["-f", BASE_COMPOSE]
if env_key == "development":
if not os.path.exists(DEV_COMPOSE):
fail("No se encontró compose.dev.yaml.")
args += ["-f", DEV_COMPOSE]
elif env_key == "production":
if not os.path.exists(PROD_COMPOSE):
fail("No se encontró compose.prod.yaml.")
args += ["-f", PROD_COMPOSE]
else:
fail(f"Entorno desconocido: {env_key}")
return args
def compose_files_args_global(kind):
"""
Devuelve los -f correctos para servicios globales (npm/dbeaver).
"""
if kind == "npm":
if not os.path.exists(NPM_COMPOSE):
fail("No se encontró compose.npm.yaml.")
return ["-f", NPM_COMPOSE]
elif kind == "dbeaver":
if not os.path.exists(DBEAVER_COMPOSE):
fail("No se encontró compose.dbeaver.yaml.")
return ["-f", DBEAVER_COMPOSE]
else:
fail(f"Servicio global desconocido: {kind}")
def env_file_path(env_key):
fname = ENV_FILES.get(env_key)
if not fname:
return None
path = os.path.join(PROJECT_ROOT, fname)
return path if os.path.exists(path) else None
def compose_cmd(base_args, env_key, env_file=None, project_name=None):
"""
Construye: docker compose -f base -f env --env-file ... -p <name> <COMANDO> [OPCIONES]
(importante: --env-file y -p son opciones globales y van antes del subcomando)
"""
cmd = ["docker", "compose"]
cmd += compose_files_args(env_key)
if env_file:
cmd += ["--env-file", env_file]
if project_name:
cmd += ["-p", project_name]
cmd += base_args # ["up","-d","--force-recreate"] o ["ps","--status","running","-q"]
return cmd
def compose_cmd_global(base_args, kind, project_name=GLOBAL_PROJECT_NAME):
"""
Comandos para servicios globales (npm/dbeaver):
docker compose -f compose.<kind>.yaml -p suitecoffee <COMANDO> ...
"""
cmd = ["docker", "compose"]
cmd += compose_files_args_global(kind)
if project_name:
cmd += ["-p", project_name]
cmd += base_args
return cmd
def yes_no(prompt, default="n"):
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):
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)
# ---------- Helpers globales (servicios por archivo y ps por labels) ----------
def list_services_from_compose_file(compose_path):
"""Obtiene la lista de servicios definidos en un archivo compose específico."""
cmd = ["docker", "compose", "-f", compose_path, "config", "--services"]
proc = run(cmd, capture_output=True)
if proc.returncode != 0:
return []
return [s.strip() for s in proc.stdout.splitlines() if s.strip()]
def list_services_global(kind):
compose_path = NPM_COMPOSE if kind == "npm" else DBEAVER_COMPOSE
return list_services_from_compose_file(compose_path)
def docker_ps_by_labels(project, service=None, running_only=True):
"""Lista contenedores por labels de compose (project/service)."""
cmd = ["docker", "ps"]
if running_only:
# por defecto docker ps ya lista solo running; se deja explícito por claridad
pass
cmd += ["--filter", f"label=com.docker.compose.project={project}"]
if service:
cmd += ["--filter", f"label=com.docker.compose.service={service}"]
cmd += ["-q"]
proc = run(cmd, capture_output=True)
if proc.returncode != 0:
return []
return [l.strip() for l in proc.stdout.splitlines() if l.strip()]
# ---------- Acciones (entornos) ----------
def bring_up(env_key, force_recreate=False):
env_path = env_file_path(env_key)
if not env_path:
warn(f"No se encontró archivo de entorno para '{env_key}'. Continuando sin --env-file.")
pname = PROJECT_NAMES.get(env_key)
base_args = ["up", "-d"]
if force_recreate:
base_args.append("--force-recreate")
cmd = compose_cmd(base_args, env_key=env_key, env_file=env_path, project_name=pname)
info("Ejecutando: " + " ".join(cmd))
proc = run(cmd)
if proc.returncode == 0:
ok(f"Entorno '{env_key}' levantado correctamente.")
else:
fail(f"Fallo al levantar entorno '{env_key}'. Código: {proc.returncode}")
def bring_down(env_key):
env_path = env_file_path(env_key)
pname = PROJECT_NAMES.get(env_key)
cmd = compose_cmd(["down"], env_key=env_key, env_file=env_path, project_name=pname)
info("Ejecutando: " + " ".join(cmd))
proc = run(cmd)
if proc.returncode == 0:
ok(f"Entorno '{env_key}' detenido correctamente.")
else:
fail(f"Fallo al detener entorno '{env_key}'. Código: {proc.returncode}")
def running_ids(env_key):
env_path = env_file_path(env_key)
pname = PROJECT_NAMES.get(env_key)
cmd = compose_cmd(["ps", "--status", "running", "-q"], env_key=env_key, env_file=env_path, project_name=pname)
proc = run(cmd, capture_output=True)
if proc.returncode != 0:
return []
return [l.strip() for l in proc.stdout.splitlines() if l.strip()]
# ---------- Acciones (globales) ----------
def bring_up_global(kind, force_recreate=False):
base_args = ["up", "-d"]
if force_recreate:
base_args.append("--force-recreate")
cmd = compose_cmd_global(base_args, kind=kind)
info("Ejecutando: " + " ".join(cmd))
proc = run(cmd)
if proc.returncode == 0:
ok(f"Servicio global '{kind}' levantado correctamente.")
else:
fail(f"Fallo al levantar servicio global '{kind}'. Código: {proc.returncode}")
def bring_down_global(kind, remove=False):
"""
Apaga SOLO los servicios definidos en el compose global indicado.
- Primero 'stop <servicios>'.
- Opcionalmente, 'rm -f <servicios>' si remove=True.
"""
services = list_services_global(kind)
if not services:
warn(f"No se encontraron servicios en compose.{kind}.yaml.")
return
# stop específico
cmd = compose_cmd_global(["stop"] + services, kind=kind)
info("Ejecutando: " + " ".join(cmd))
proc = run(cmd)
if proc.returncode != 0:
fail(f"Fallo al detener servicios de '{kind}'. Código: {proc.returncode}")
if remove:
cmd_rm = compose_cmd_global(["rm", "-f"] + services, kind=kind)
info("Ejecutando: " + " ".join(cmd_rm))
proc_rm = run(cmd_rm)
if proc_rm.returncode != 0:
fail(f"Fallo al remover servicios de '{kind}'. Código: {proc_rm.returncode}")
ok(f"Servicio(s) global(es) '{kind}' detenido(s).")
def running_ids_global(kind):
"""
Detecta si el compose global está corriendo revisando contenedores por servicio,
filtrando por labels (project+service).
"""
services = list_services_global(kind)
ids = []
for svc in services:
ids += docker_ps_by_labels(GLOBAL_PROJECT_NAME, service=svc, running_only=True)
# eliminar duplicados
return list(dict.fromkeys(ids))
# ---------- Estado y flujo ----------
def detect_status_summary():
dev_running = running_ids("development")
prod_running = running_ids("production")
npm_running = running_ids_global("npm")
dbeaver_running = running_ids_global("dbeaver")
print_header("Estado actual")
info(f"DESARROLLO: {len(dev_running)} contenedor(es) en ejecución.")
info(f"PRODUCCIÓN: {len(prod_running)} contenedor(es) en ejecución.")
info(f"NPM (global): {len(npm_running)} contenedor(es) en ejecución.")
info(f"DBEAVER (global): {len(dbeaver_running)} contenedor(es) en ejecución.\n")
return bool(dev_running), bool(prod_running), bool(npm_running), bool(dbeaver_running)
def detect_and_optionally_shutdown():
"""Muestra estado y ofrece (opcional) apagar dev/prod.
Los servicios globales se gestionan desde el menú principal (levantar/apagar).
"""
dev_on, prod_on, _npm_on, _dbeaver_on = detect_status_summary()
options = []
if dev_on:
options.append(("1", "Apagar entorno de DESARROLLO", "development"))
if prod_on:
options.append(("2", "Apagar entorno de PRODUCCIÓN", "production"))
options.append(("3", "Continuar sin detener nada", None))
if len(options) == 1:
return
print("Selecciona una opción:")
for opt in options:
key, label = opt[0], opt[1]
print(f" {key}) {label}")
while True:
choice = input("> ").strip()
selected = next((opt for opt in options if opt[0] == choice), None)
if not selected:
print("Opción inválida.")
continue
if choice == "3" or selected[2] is None:
ok("Continuamos sin detener nada.")
return
env_key = selected[2]
bring_down(env_key)
return
def main_menu():
# Consultar estado de globales para decidir si mostrar opciones de "Levantar" o "Apagar"
_dev_on, _prod_on, npm_on, dbeaver_on = detect_status_summary()
print_header("Gestor de entornos Docker Compose")
print("Selecciona una opción:")
print(" 1) Levantar entorno de DESARROLLO")
print(" 2) Levantar entorno de PRODUCCIÓN")
dynamic_keys = {}
next_key = 3
# NPM: opción según estado
if not npm_on:
print(f" {next_key}) Levantar NPM (compose.npm.yaml)")
dynamic_keys[str(next_key)] = ("global_up", "npm")
else:
print(f" {next_key}) Apagar NPM (compose.npm.yaml)")
dynamic_keys[str(next_key)] = ("global_down", "npm")
next_key += 1
# DBEAVER: opción según estado
if not dbeaver_on:
print(f" {next_key}) Levantar DBEAVER (compose.dbeaver.yaml)")
dynamic_keys[str(next_key)] = ("global_up", "dbeaver")
else:
print(f" {next_key}) Apagar DBEAVER (compose.dbeaver.yaml)")
dynamic_keys[str(next_key)] = ("global_down", "dbeaver")
next_key += 1
# Salir
print(f" {next_key}) Salir")
exit_key = str(next_key)
while True:
choice = input("> ").strip()
if choice == "1":
force = yes_no("¿Usar --force-recreate para DESARROLLO?", default="n")
bring_up("development", force_recreate=force)
return
elif choice == "2":
force = yes_no("¿Usar --force-recreate para PRODUCCIÓN?", default="n")
bring_up("production", force_recreate=force)
return
elif choice in dynamic_keys:
action, kind = dynamic_keys[choice]
if action == "global_up":
force = yes_no(f"¿Usar --force-recreate para {kind.upper()}?", default="n")
bring_up_global(kind, force_recreate=force)
return
elif action == "global_down":
remove = yes_no(f"¿También remover contenedores de {kind.upper()}? (rm -f)", default="n")
bring_down_global(kind, remove=remove)
return
elif choice == exit_key:
ok("Saliendo.")
sys.exit(0)
else:
print("Opción inválida. Elige una de las opciones listadas.")
def main():
try:
check_prereqs()
# Mostrar estado y permitir opcionalmente apagar dev/prod
detect_and_optionally_shutdown()
# Menú de gestión (incluye globales: levantar o apagar según estado)
main_menu()
except KeyboardInterrupt:
print("\n")
ok("Interrumpido por el usuario (Ctrl+C). Saliendo.")
sys.exit(0)
except Exception as e:
fail(f"Ocurrió un error inesperado: {e}")
if __name__ == "__main__":
main()