Creado y levantado del workaround
This commit is contained in:
parent
f7962f894d
commit
922da441eb
664
README_tmp.html
Normal file
664
README_tmp.html
Normal file
@ -0,0 +1,664 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>README.md</title>
|
||||
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
|
||||
|
||||
<style>
|
||||
/* https://github.com/microsoft/vscode/blob/master/extensions/markdown-language-features/media/markdown.css */
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
body {
|
||||
font-family: var(--vscode-markdown-font-family, -apple-system, BlinkMacSystemFont, "Segoe WPC", "Segoe UI", "Ubuntu", "Droid Sans", sans-serif);
|
||||
font-size: var(--vscode-markdown-font-size, 14px);
|
||||
padding: 0 26px;
|
||||
line-height: var(--vscode-markdown-line-height, 22px);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
#code-csp-warning {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
color: white;
|
||||
margin: 16px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-family: sans-serif;
|
||||
background-color:#444444;
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
box-shadow: 1px 1px 1px rgba(0,0,0,.25);
|
||||
}
|
||||
|
||||
#code-csp-warning:hover {
|
||||
text-decoration: none;
|
||||
background-color:#007acc;
|
||||
box-shadow: 2px 2px 2px rgba(0,0,0,.25);
|
||||
}
|
||||
|
||||
body.scrollBeyondLastLine {
|
||||
margin-bottom: calc(100vh - 22px);
|
||||
}
|
||||
|
||||
body.showEditorSelection .code-line {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body.showEditorSelection .code-active-line:before,
|
||||
body.showEditorSelection .code-line:hover:before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -12px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body.showEditorSelection li.code-active-line:before,
|
||||
body.showEditorSelection li.code-line:hover:before {
|
||||
left: -30px;
|
||||
}
|
||||
|
||||
.vscode-light.showEditorSelection .code-active-line:before {
|
||||
border-left: 3px solid rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.vscode-light.showEditorSelection .code-line:hover:before {
|
||||
border-left: 3px solid rgba(0, 0, 0, 0.40);
|
||||
}
|
||||
|
||||
.vscode-light.showEditorSelection .code-line .code-line:hover:before {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.vscode-dark.showEditorSelection .code-active-line:before {
|
||||
border-left: 3px solid rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.vscode-dark.showEditorSelection .code-line:hover:before {
|
||||
border-left: 3px solid rgba(255, 255, 255, 0.60);
|
||||
}
|
||||
|
||||
.vscode-dark.showEditorSelection .code-line .code-line:hover:before {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.vscode-high-contrast.showEditorSelection .code-active-line:before {
|
||||
border-left: 3px solid rgba(255, 160, 0, 0.7);
|
||||
}
|
||||
|
||||
.vscode-high-contrast.showEditorSelection .code-line:hover:before {
|
||||
border-left: 3px solid rgba(255, 160, 0, 1);
|
||||
}
|
||||
|
||||
.vscode-high-contrast.showEditorSelection .code-line .code-line:hover:before {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:focus,
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: 1px solid -webkit-focus-ring-color;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: 0;
|
||||
height: 2px;
|
||||
border-bottom: 2px solid;
|
||||
}
|
||||
|
||||
h1 {
|
||||
padding-bottom: 0.3em;
|
||||
line-height: 1.2;
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-style: solid;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
table > thead > tr > th {
|
||||
text-align: left;
|
||||
border-bottom: 1px solid;
|
||||
}
|
||||
|
||||
table > thead > tr > th,
|
||||
table > thead > tr > td,
|
||||
table > tbody > tr > th,
|
||||
table > tbody > tr > td {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
table > tbody > tr + tr > td {
|
||||
border-top: 1px solid;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 0 7px 0 5px;
|
||||
padding: 0 16px 0 10px;
|
||||
border-left-width: 5px;
|
||||
border-left-style: solid;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: Menlo, Monaco, Consolas, "Droid Sans Mono", "Courier New", monospace, "Droid Sans Fallback";
|
||||
font-size: 1em;
|
||||
line-height: 1.357em;
|
||||
}
|
||||
|
||||
body.wordWrap pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
pre:not(.hljs),
|
||||
pre.hljs code > div {
|
||||
padding: 16px;
|
||||
border-radius: 3px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
pre code {
|
||||
color: var(--vscode-editor-foreground);
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
/** Theming */
|
||||
|
||||
.vscode-light pre {
|
||||
background-color: rgba(220, 220, 220, 0.4);
|
||||
}
|
||||
|
||||
.vscode-dark pre {
|
||||
background-color: rgba(10, 10, 10, 0.4);
|
||||
}
|
||||
|
||||
.vscode-high-contrast pre {
|
||||
background-color: rgb(0, 0, 0);
|
||||
}
|
||||
|
||||
.vscode-high-contrast h1 {
|
||||
border-color: rgb(0, 0, 0);
|
||||
}
|
||||
|
||||
.vscode-light table > thead > tr > th {
|
||||
border-color: rgba(0, 0, 0, 0.69);
|
||||
}
|
||||
|
||||
.vscode-dark table > thead > tr > th {
|
||||
border-color: rgba(255, 255, 255, 0.69);
|
||||
}
|
||||
|
||||
.vscode-light h1,
|
||||
.vscode-light hr,
|
||||
.vscode-light table > tbody > tr + tr > td {
|
||||
border-color: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.vscode-dark h1,
|
||||
.vscode-dark hr,
|
||||
.vscode-dark table > tbody > tr + tr > td {
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* Tomorrow Theme */
|
||||
/* http://jmblog.github.com/color-themes-for-google-code-highlightjs */
|
||||
/* Original theme - https://github.com/chriskempson/tomorrow-theme */
|
||||
|
||||
/* Tomorrow Comment */
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: #8e908c;
|
||||
}
|
||||
|
||||
/* Tomorrow Red */
|
||||
.hljs-variable,
|
||||
.hljs-template-variable,
|
||||
.hljs-tag,
|
||||
.hljs-name,
|
||||
.hljs-selector-id,
|
||||
.hljs-selector-class,
|
||||
.hljs-regexp,
|
||||
.hljs-deletion {
|
||||
color: #c82829;
|
||||
}
|
||||
|
||||
/* Tomorrow Orange */
|
||||
.hljs-number,
|
||||
.hljs-built_in,
|
||||
.hljs-builtin-name,
|
||||
.hljs-literal,
|
||||
.hljs-type,
|
||||
.hljs-params,
|
||||
.hljs-meta,
|
||||
.hljs-link {
|
||||
color: #f5871f;
|
||||
}
|
||||
|
||||
/* Tomorrow Yellow */
|
||||
.hljs-attribute {
|
||||
color: #eab700;
|
||||
}
|
||||
|
||||
/* Tomorrow Green */
|
||||
.hljs-string,
|
||||
.hljs-symbol,
|
||||
.hljs-bullet,
|
||||
.hljs-addition {
|
||||
color: #718c00;
|
||||
}
|
||||
|
||||
/* Tomorrow Blue */
|
||||
.hljs-title,
|
||||
.hljs-section {
|
||||
color: #4271ae;
|
||||
}
|
||||
|
||||
/* Tomorrow Purple */
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag {
|
||||
color: #8959a8;
|
||||
}
|
||||
|
||||
.hljs {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
color: #4d4d4c;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
.hljs-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/*
|
||||
* Markdown PDF CSS
|
||||
*/
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe WPC", "Segoe UI", "Ubuntu", "Droid Sans", sans-serif, "Meiryo";
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: #f8f8f8;
|
||||
border: 1px solid #cccccc;
|
||||
border-radius: 3px;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
pre:not(.hljs) {
|
||||
padding: 23px;
|
||||
line-height: 19px;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
background: rgba(127, 127, 127, 0.1);
|
||||
border-color: rgba(0, 122, 204, 0.5);
|
||||
}
|
||||
|
||||
.emoji {
|
||||
height: 1.4em;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 14px;
|
||||
line-height: 19px;
|
||||
}
|
||||
|
||||
/* for inline code */
|
||||
:not(pre):not(.hljs) > code {
|
||||
color: #C9AE75; /* Change the old color so it seems less like an error */
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* Page Break : use <div class="page"/> to insert page break
|
||||
-------------------------------------------------------- */
|
||||
.page {
|
||||
page-break-after: always;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script src="https://unpkg.com/mermaid/dist/mermaid.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
mermaid.initialize({
|
||||
startOnLoad: true,
|
||||
theme: document.body.classList.contains('vscode-dark') || document.body.classList.contains('vscode-high-contrast')
|
||||
? 'dark'
|
||||
: 'default'
|
||||
});
|
||||
</script>
|
||||
<h1 id="suitecoffee--sistema-de-gesti%C3%B3n-para-cafeter%C3%ADas-dockerizado-y-multi%E2%80%91servicio">SuiteCoffee — Sistema de gestión para cafeterías (Dockerizado y multi‑servicio)</h1>
|
||||
<p>SuiteCoffee es un sistema modular pensado para la <strong>gestión de cafeterías</strong> (y negocios afines), con servicios Node.js para <strong>aplicación</strong> y <strong>autenticación</strong>, bases de datos <strong>PostgreSQL</strong> separadas para negocio y multi‑tenencia, y un <strong>stack Docker Compose</strong> que facilita levantar entornos de <strong>desarrollo</strong> y <strong>producción</strong>. Incluye herramientas auxiliares como <strong>Nginx Proxy Manager (NPM)</strong> y <strong>CloudBeaver</strong> para administrar bases de datos desde el navegador.</p>
|
||||
<blockquote>
|
||||
<p>Repositorio: https://gitea.mateosaldain.uy/msaldain/SuiteCoffee.git</p>
|
||||
</blockquote>
|
||||
<hr>
|
||||
<h2 id="tabla-de-contenidos">Tabla de contenidos</h2>
|
||||
<ul>
|
||||
<li><a href="#arquitectura">Arquitectura</a></li>
|
||||
<li><a href="#caracter%C3%ADsticas-principales">Características principales</a></li>
|
||||
<li><a href="#requisitos">Requisitos</a></li>
|
||||
<li><a href="#inicio-r%C3%A1pido">Inicio rápido</a></li>
|
||||
<li><a href="#variables-de-entorno">Variables de entorno</a></li>
|
||||
<li><a href="#endpoints">Endpoints</a></li>
|
||||
<li><a href="#estructura-del-proyecto">Estructura del proyecto</a></li>
|
||||
<li><a href="#herramientas-auxiliares-npm-y-cloudbeaver">Herramientas auxiliares (NPM y CloudBeaver)</a></li>
|
||||
<li><a href="#backups-y-restauraci%C3%B3n-de-vol%C3%BAmenes">Backups y restauración de volúmenes</a></li>
|
||||
<li><a href="#comandos-%C3%BAtiles">Comandos útiles</a></li>
|
||||
<li><a href="#licencia">Licencia</a></li>
|
||||
<li><a href="#sugerencias-de-mejora">Sugerencias de mejora</a></li>
|
||||
</ul>
|
||||
<hr>
|
||||
<h2 id="arquitectura">Arquitectura</h2>
|
||||
<p><strong>Servicios principales</strong></p>
|
||||
<ul>
|
||||
<li><strong>app</strong> (Node.js / Express): API de negocio y páginas simples para cargar y listar <em>roles, usuarios, categorías y productos</em>.</li>
|
||||
<li><strong>auth</strong> (Node.js / Express + bcrypt): endpoints de <strong>registro</strong> e <strong>inicio de sesión</strong>.</li>
|
||||
<li><strong>db</strong> (PostgreSQL 16): base de datos de la aplicación.</li>
|
||||
<li><strong>tenants</strong> (PostgreSQL 16): base de datos separada para <strong>multi-tenencia</strong> (aislar clientes/tiendas).</li>
|
||||
</ul>
|
||||
<p><strong>Herramientas</strong></p>
|
||||
<ul>
|
||||
<li><strong>Nginx Proxy Manager (NPM)</strong>: reverse proxy y certificados (Let’s Encrypt) para exponer servicios.</li>
|
||||
<li><strong>CloudBeaver (DBeaver)</strong>: administración de PostgreSQL vía web.</li>
|
||||
</ul>
|
||||
<p><strong>Redes & Volúmenes</strong></p>
|
||||
<ul>
|
||||
<li>Redes independientes por entorno (<code>suitecoffee_dev_net</code> / <code>suitecoffee_prod_net</code>).</li>
|
||||
<li>Volúmenes gestionados por Compose para persistencia: <code>suitecoffee-db</code>, <code>tenants-db</code>, etc.</li>
|
||||
</ul>
|
||||
<h3 id="diagrama-alto-nivel">Diagrama (alto nivel)</h3>
|
||||
<pre class="hljs"><code><div>@startuml
|
||||
skinparam componentStyle rectangle
|
||||
skinparam rectangle {
|
||||
BorderColor #555
|
||||
RoundCorner 10
|
||||
}
|
||||
actor Usuario
|
||||
|
||||
package "Entorno DEV/PROD" {
|
||||
[app (Express)] as APP
|
||||
[auth (Express + bcrypt)] as AUTH
|
||||
database "db (PostgreSQL)" as DB
|
||||
database "tenants (PostgreSQL)" as TENANTS
|
||||
APP -down-> DB : Pool PG
|
||||
APP -down-> TENANTS : Pool PG
|
||||
AUTH -down-> DB : Pool PG (usuarios)
|
||||
Usuario --> APP : UI / API
|
||||
Usuario --> AUTH : Login/Registro
|
||||
}
|
||||
|
||||
package "Herramientas" {
|
||||
[Nginx Proxy Manager] as NPM
|
||||
[CloudBeaver] as DBVR
|
||||
NPM ..> APP : proxy
|
||||
NPM ..> AUTH : proxy
|
||||
DBVR ..> DB : admin
|
||||
DBVR ..> TENANTS : admin
|
||||
}
|
||||
@enduml
|
||||
</div></code></pre>
|
||||
<hr>
|
||||
<h2 id="caracter%C3%ADsticas-principales">Características principales</h2>
|
||||
<ul>
|
||||
<li><strong>API REST</strong> para entidades clave (roles, usuarios, categorías y productos).</li>
|
||||
<li><strong>Autenticación básica</strong> (registro y login) con <strong>hash de contraseñas</strong> (bcrypt).</li>
|
||||
<li><strong>Multi‑tenencia</strong> con base <code>tenants</code> separada para aislar clientes/tiendas.</li>
|
||||
<li><strong>Docker Compose v2</strong> con entornos de <strong>desarrollo</strong> y <strong>producción</strong>.</li>
|
||||
<li><strong>Herramientas integradas</strong> (NPM + CloudBeaver) en un <code>compose.tools.yaml</code> aparte.</li>
|
||||
<li><strong>Scripts</strong> de <strong>backup/restauración de volúmenes</strong> y <strong>gestión de entornos</strong>.</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<h2 id="requisitos">Requisitos</h2>
|
||||
<ul>
|
||||
<li><strong>Docker</strong> y <strong>Docker Compose v2</strong> (recomendado).</li>
|
||||
<li><strong>Python 3.9+</strong> (para scripts <code>suitecoffee.py</code>, backups y utilidades).</li>
|
||||
<li><strong>Node.js 20+</strong> (sólo si vas a ejecutar servicios Node fuera de Docker).</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<h2 id="inicio-r%C3%A1pido">Inicio rápido</h2>
|
||||
<h3 id="opci%C3%B3n-a--gestor-interactivo-recomendado">Opción A — Gestor interactivo (recomendado)</h3>
|
||||
<ol>
|
||||
<li>Clona el repo y entra al directorio:<pre class="hljs"><code><div>git <span class="hljs-built_in">clone</span> https://gitea.mateosaldain.uy/msaldain/SuiteCoffee.git
|
||||
<span class="hljs-built_in">cd</span> SuiteCoffee
|
||||
</div></code></pre>
|
||||
</li>
|
||||
<li>(Opcional) Crea/copía tus archivos <code>.env</code> para <strong>app</strong> y <strong>auth</strong> en <code>./services/<service>/.env.development</code> (ver sección de variables).</li>
|
||||
<li>Ejecuta el gestor:<pre class="hljs"><code><div>python3 suitecoffee.py
|
||||
</div></code></pre>
|
||||
<ul>
|
||||
<li>Verás un <strong>menú</strong> para levantar <strong>DESARROLLO</strong> o <strong>PRODUCCIÓN</strong>.</li>
|
||||
<li>Desde ahí también puedes <strong>levantar/apagar</strong> las herramientas <strong>NPM</strong> y <strong>CloudBeaver</strong>.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Accede:
|
||||
<ul>
|
||||
<li>App (dev): suele estar disponible via NPM o directamente dentro de la red, según tu configuración.</li>
|
||||
<li>Páginas simples: <code>/roles</code>, <code>/usuarios</code>, <code>/categorias</code>, <code>/productos</code> (servidas por <code>app</code>).</li>
|
||||
<li>Salud: <code>/health</code> en <code>app</code> y <code>auth</code>.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
<blockquote>
|
||||
<p>Consejo: primero levanta <strong>desarrollo/producción</strong> y luego las <strong>herramientas</strong> para que existan las redes externas <code>suitecoffee_dev_net</code>/<code>suitecoffee_prod_net</code> que usa <code>compose.tools.yaml</code>.</p>
|
||||
</blockquote>
|
||||
<h3 id="opci%C3%B3n-b--comandos-docker-compose-avanzado">Opción B — Comandos Docker Compose (avanzado)</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<p><strong>Desarrollo</strong>:</p>
|
||||
<pre class="hljs"><code><div>docker compose -f compose.yaml -f compose.dev.yaml --env-file ./services/app/.env.development --env-file ./services/auth/.env.development -p suitecoffee_dev up -d
|
||||
</div></code></pre>
|
||||
</li>
|
||||
<li>
|
||||
<p><strong>Producción</strong>:</p>
|
||||
<pre class="hljs"><code><div>docker compose -f compose.yaml -f compose.prod.yaml --env-file ./services/app/.env.production --env-file ./services/auth/.env.production -p suitecoffee_prod up -d
|
||||
</div></code></pre>
|
||||
</li>
|
||||
</ul>
|
||||
<blockquote>
|
||||
<p>Los puertos se <strong>exponen</strong> para herramientas (NPM UI <code>:81</code>, CloudBeaver <code>:8978</code>); los servicios <code>app</code> y <code>auth</code> se <strong>exponen dentro de la red</strong> y se publican externamente a través de NPM.</p>
|
||||
</blockquote>
|
||||
<hr>
|
||||
<h2 id="variables-de-entorno">Variables de entorno</h2>
|
||||
<p>Crea un archivo <code>.env.development</code> (y uno <code>.env.production</code>) en <strong>cada servicio</strong> (<code>./services/app</code> y <code>./services/auth</code>). Variables comunes:</p>
|
||||
<pre class="hljs"><code><div># Servidor
|
||||
PORT=4000 # puerto HTTP del servicio
|
||||
NODE_ENV=development # development | production
|
||||
|
||||
# Base de datos
|
||||
DB_HOST=db # nombre del servicio postgres (o host)
|
||||
DB_LOCAL_PORT=5432 # puerto de PG al que conectarse
|
||||
DB_USER=postgres
|
||||
DB_PASS=postgres
|
||||
DB_NAME=suitecoffee_db # para 'db' (aplicación)
|
||||
TENANTS_DB_NAME=tenants_db # si el servicio necesita apuntar a 'tenants'
|
||||
</div></code></pre>
|
||||
<blockquote>
|
||||
<p>Ajusta <code>DB_HOST</code> a <code>db</code> o <code>tenants</code> según corresponda. En desarrollo, los alias útiles son <code>dev-db</code> y <code>dev-tenants</code>; en producción: <code>prod-db</code> y <code>prod-tenants</code>.</p>
|
||||
</blockquote>
|
||||
<hr>
|
||||
<h2 id="endpoints">Endpoints</h2>
|
||||
<h3 id="servicio-app-negocio">Servicio <strong>app</strong> (negocio)</h3>
|
||||
<ul>
|
||||
<li><code>GET /health</code></li>
|
||||
<li><code>GET /api/roles</code> — lista roles</li>
|
||||
<li><code>POST /api/roles</code> — crea un rol</li>
|
||||
<li><code>GET /api/usuarios</code> — lista usuarios</li>
|
||||
<li><code>POST /api/usuarios</code> — crea un usuario</li>
|
||||
<li><code>GET /api/categorias</code> — lista categorías</li>
|
||||
<li><code>POST /api/categorias</code> — crea una categoría</li>
|
||||
<li><code>GET /api/productos</code> — lista productos</li>
|
||||
<li><code>POST /api/productos</code> — crea un producto</li>
|
||||
<li>Páginas estáticas simples para probar: <code>/roles</code>, <code>/usuarios</code>, <code>/categorias</code>, <code>/productos</code></li>
|
||||
</ul>
|
||||
<h3 id="servicio-auth-autenticaci%C3%B3n">Servicio <strong>auth</strong> (autenticación)</h3>
|
||||
<ul>
|
||||
<li><code>GET /health</code></li>
|
||||
<li><code>POST /register</code> — registro de usuario (password con <strong>bcrypt</strong>)</li>
|
||||
<li><code>POST /auth/login</code> — inicio de sesión</li>
|
||||
</ul>
|
||||
<blockquote>
|
||||
<p><strong>Nota</strong>: En esta etapa los endpoints son <strong>básicos</strong> y pensados para desarrollo/PoC. Ver la sección <em>Sugerencias de mejora</em> para próximos pasos (JWT, autorización, etc.).</p>
|
||||
</blockquote>
|
||||
<hr>
|
||||
<h2 id="estructura-del-proyecto">Estructura del proyecto</h2>
|
||||
<pre class="hljs"><code><div>SuiteCoffee/
|
||||
├─ services/
|
||||
│ ├─ app/
|
||||
│ │ ├─ src/
|
||||
│ │ │ ├─ index.js # API y páginas simples
|
||||
│ │ │ └─ pages/ # roles.html, usuarios.html, categorias.html, productos.html
|
||||
│ │ ├─ .env.development # variables (ejemplo)
|
||||
│ │ └─ .env.production
|
||||
│ └─ auth/
|
||||
│ ├─ src/
|
||||
│ │ └─ index.js # /register y /auth/login
|
||||
│ ├─ .env.development
|
||||
│ └─ .env.production
|
||||
├─ compose.yaml # base (db, tenants)
|
||||
├─ compose.dev.yaml # entorno desarrollo (app, auth, db, tenants)
|
||||
├─ compose.prod.yaml # entorno producción (app, auth, db, tenants)
|
||||
├─ compose.tools.yaml # herramientas (NPM, CloudBeaver) con redes externas
|
||||
├─ suitecoffee.py # gestor interactivo (Docker Compose)
|
||||
├─ backup_compose_volumes.py # backups de volúmenes Compose
|
||||
└─ restore_compose_volumes.py# restauración de volúmenes Compose
|
||||
</div></code></pre>
|
||||
<hr>
|
||||
<h2 id="herramientas-auxiliares-npm-y-cloudbeaver">Herramientas auxiliares (NPM y CloudBeaver)</h2>
|
||||
<p>Los servicios de <strong>herramientas</strong> están separados para poder usarlos con <strong>ambos entornos</strong> (dev y prod) a la vez. Se levantan con <code>compose.tools.yaml</code> y se conectan a las <strong>redes externas</strong> <code>suitecoffee_dev_net</code> y <code>suitecoffee_prod_net</code>.</p>
|
||||
<ul>
|
||||
<li><strong>Nginx Proxy Manager (NPM)</strong><br>
|
||||
Puertos: <code>80</code> (HTTP), <code>81</code> (UI). Volúmenes: <code>npm_data</code>, <code>npm_letsencrypt</code>.</li>
|
||||
<li><strong>CloudBeaver</strong><br>
|
||||
Puerto: <code>8978</code>. Volúmenes: <code>dbeaver_logs</code>, <code>dbeaver_workspace</code>.</li>
|
||||
</ul>
|
||||
<blockquote>
|
||||
<p>Si es la primera vez, arranca un entorno (dev/prod) para que Compose cree las redes; luego levanta las herramientas:</p>
|
||||
<pre class="hljs"><code><div>docker compose -f compose.tools.yaml --profile npm -p suitecoffee up -d
|
||||
docker compose -f compose.tools.yaml --profile dbeaver -p suitecoffee up -d
|
||||
</div></code></pre>
|
||||
</blockquote>
|
||||
<hr>
|
||||
<h2 id="backups-y-restauraci%C3%B3n-de-vol%C3%BAmenes">Backups y restauración de volúmenes</h2>
|
||||
<p>Este repo incluye dos utilidades:</p>
|
||||
<ul>
|
||||
<li><code>backup_compose_volumes.py</code> — detecta volúmenes de un proyecto de Compose (por <strong>labels</strong> y nombres) y los exporta a <code>tar.gz</code> usando un contenedor <code>alpine</code> temporal.</li>
|
||||
<li><code>restore_compose_volumes.py</code> — permite restaurar esos <code>tar.gz</code> en volúmenes (útil para migraciones y pruebas).</li>
|
||||
</ul>
|
||||
<p><strong>Ejemplos básicos</strong></p>
|
||||
<pre class="hljs"><code><div><span class="hljs-comment"># Listar ayuda</span>
|
||||
python3 backup_compose_volumes.py --<span class="hljs-built_in">help</span>
|
||||
|
||||
<span class="hljs-comment"># Respaldar volúmenes asociados a "suitecoffee_dev" en ./backups</span>
|
||||
python3 backup_compose_volumes.py --project suitecoffee_dev --output ./backups
|
||||
|
||||
<span class="hljs-comment"># Restaurar un archivo a un volumen</span>
|
||||
python3 restore_compose_volumes.py --archive ./backups/suitecoffee_dev_suitecoffee-db-YYYYmmddHHMMSS.tar.gz --volume suitecoffee_dev_suitecoffee-db
|
||||
</div></code></pre>
|
||||
<blockquote>
|
||||
<p>Consejo: si migraste manualmente y ves advertencias tipo “volume ... already exists but was not created by Docker Compose”, considera marcar el volumen como <code>external: true</code> en el YAML o recrearlo para que Compose lo etiquete correctamente.</p>
|
||||
</blockquote>
|
||||
<hr>
|
||||
<h2 id="comandos-%C3%BAtiles">Comandos útiles</h2>
|
||||
<pre class="hljs"><code><div><span class="hljs-comment"># Ver estado (menú interactivo)</span>
|
||||
python3 suitecoffee.py
|
||||
|
||||
<span class="hljs-comment"># Levantar DEV/PROD por menú (con o sin --force-recreate)</span>
|
||||
python3 suitecoffee.py
|
||||
|
||||
<span class="hljs-comment"># Levantar herramientas (también desde menú)</span>
|
||||
docker compose -f compose.tools.yaml --profile npm -p suitecoffee up -d
|
||||
docker compose -f compose.tools.yaml --profile dbeaver -p suitecoffee up -d
|
||||
|
||||
<span class="hljs-comment"># Inspeccionar servicios/volúmenes que Compose detecta desde los YAML</span>
|
||||
docker compose -f compose.yaml -f compose.dev.yaml config --services
|
||||
docker compose -f compose.yaml -f compose.dev.yaml config --format json | jq .volumes
|
||||
</div></code></pre>
|
||||
<hr>
|
||||
<h2 id="licencia">Licencia</h2>
|
||||
<ul>
|
||||
<li><strong>ISC</strong> (ver <code>package.json</code>).</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<h2 id="sugerencias-de-mejora">Sugerencias de mejora</h2>
|
||||
<ul>
|
||||
<li><strong>Autenticación y seguridad</strong>
|
||||
<ul>
|
||||
<li>Emitir <strong>JWT</strong> en el login y proteger rutas (roles/autorización por perfil).</li>
|
||||
<li>Configurar <strong>CORS</strong> por orígenes (en dev está abierto; en prod restringir).</li>
|
||||
<li>Añadir <strong>rate‑limit</strong> y <strong>helmet</strong> en Express.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Esquema de datos y migraciones</strong>
|
||||
<ul>
|
||||
<li>Añadir migraciones automatizadas (p.ej. <strong>Prisma</strong>, <strong>Knex</strong>, <strong>Sequelize</strong> o SQL versionado) y seeds iniciales.</li>
|
||||
<li>Clarificar el <strong>modelo multi‑tenant</strong>: por <strong>BD por tenant</strong> o <strong>schema por tenant</strong>; documentar estrategia.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Calidad & DX</strong>
|
||||
<ul>
|
||||
<li>Tests (unitarios e integración) y <strong>CI</strong> básico.</li>
|
||||
<li>Validación de entrada (<strong>zod / joi</strong>), manejo de errores consistente y logs estructurados.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Docker/DevOps</strong>
|
||||
<ul>
|
||||
<li>Documentar variables <code>.env</code> completas por servicio.</li>
|
||||
<li>Publicar imágenes de producción y usar <code>IMAGE:TAG</code> en <code>compose.prod.yaml</code> (evitar build en servidor).</li>
|
||||
<li>Añadir <strong>healthchecks</strong> a <code>app</code>/<code>auth</code> (ya hay ejemplos comentados).</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Frontend</strong>
|
||||
<ul>
|
||||
<li>Reemplazar páginas HTML de prueba por un <strong>frontend</strong> (React/Vite) o una UI admin mínima.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Pequeños fixes</strong>
|
||||
<ul>
|
||||
<li>En los HTML de ejemplo corregir las referencias a campos <code>id_rol</code>, <code>id_categoria</code>, etc.</li>
|
||||
<li>Centralizar constantes (nombres de tablas/campos) y normalizar respuestas API.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<hr>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
55
compose.manso.yaml
Normal file
55
compose.manso.yaml
Normal file
@ -0,0 +1,55 @@
|
||||
# docker-compose.overrride.yml
|
||||
# Docker Comose para entorno de desarrollo o development.
|
||||
|
||||
|
||||
services:
|
||||
|
||||
manso:
|
||||
image: node:20-bookworm
|
||||
expose:
|
||||
- ${APP_LOCAL_PORT}
|
||||
working_dir: /app
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
volumes:
|
||||
- ./services/manso:/app:rw
|
||||
- ./services/manso/node_modules:/app/node_modules
|
||||
env_file:
|
||||
- ./services/manso/.env.development
|
||||
environment:
|
||||
- NODE_ENV=${NODE_ENV}
|
||||
networks:
|
||||
net:
|
||||
aliases: [manso]
|
||||
command: npm run dev
|
||||
|
||||
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
|
||||
13
compose.yaml
13
compose.yaml
@ -4,6 +4,19 @@ name: ${COMPOSE_PROJECT_NAME:-suitecoffee}
|
||||
|
||||
services:
|
||||
|
||||
manso:
|
||||
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
|
||||
# app:
|
||||
# depends_on:
|
||||
# db:
|
||||
|
||||
20
services/manso/Dockerfile.production
Normal file
20
services/manso/Dockerfile.production
Normal 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"]
|
||||
1485
services/manso/package-lock.json
generated
Normal file
1485
services/manso/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
services/manso/package.json
Normal file
28
services/manso/package.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "workarround",
|
||||
"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": "Workarround para tener un MVP que llegue al verano para usarse"
|
||||
}
|
||||
360
services/manso/src/index.js
Normal file
360
services/manso/src/index.js
Normal file
@ -0,0 +1,360 @@
|
||||
// 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('/',(req, res) => res.sendFile(path.join(__dirname, 'pages', 'index.html')));
|
||||
|
||||
app.get('/planes', async (req, res) => {
|
||||
try {
|
||||
const { rows: [row] } = await pool.query(
|
||||
'SELECT api.get_planes_json($1) AS data;',
|
||||
[true]
|
||||
);
|
||||
res.type('application/json').send(row.data);
|
||||
} 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' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
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
services/manso/src/pages/categorias.html
Normal file
70
services/manso/src/pages/categorias.html
Normal 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>Sí</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Guardar</button>
|
||||
</form>
|
||||
|
||||
<h2>Listado</h2>
|
||||
<button id="btn-recargar">Recargar</button>
|
||||
<table border="1" cellpadding="6">
|
||||
<thead><tr><th>ID</th><th>Nombre</th><th>Visible</th></tr></thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
|
||||
<script>
|
||||
const API = '/api/categorias';
|
||||
|
||||
async function listar() {
|
||||
const res = await fetch(API);
|
||||
const data = await res.json();
|
||||
const tbody = document.getElementById('tbody');
|
||||
tbody.innerHTML = '';
|
||||
data.forEach(c => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `<td>${c.id_categoria}</td><td>${c.nombre}</td><td>${c.visible ? 'Sí' : 'No'}</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('form-categoria').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
const nombre = fd.get('nombre').trim();
|
||||
const visible = fd.get('visible') === 'true';
|
||||
if (!nombre) return;
|
||||
const res = await fetch(API, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ nombre, visible })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(()=>({error:'Error'}));
|
||||
alert('Error: ' + (err.error || res.statusText));
|
||||
return;
|
||||
}
|
||||
e.target.reset();
|
||||
await listar();
|
||||
});
|
||||
|
||||
document.getElementById('btn-recargar').addEventListener('click', listar);
|
||||
listar();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
154
services/manso/src/pages/index.html
Normal file
154
services/manso/src/pages/index.html
Normal 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>
|
||||
106
services/manso/src/pages/productos.html
Normal file
106
services/manso/src/pages/productos.html
Normal 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
services/manso/src/pages/roles.html
Normal file
62
services/manso/src/pages/roles.html
Normal 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
services/manso/src/pages/usuarios.html
Normal file
104
services/manso/src/pages/usuarios.html
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user