Initial commit: Estructura backend y frontend con estándar VPS

- Backend migrado a estructura VPS (src/ subfolder)
- Frontend con estructura Vite + React 19 + Tailwind
- Configuración PostgreSQL con Pool
- API service con interceptores JWT
- Ambos servidores funcionando (backend:3001, frontend:5173)
This commit is contained in:
2025-12-09 00:35:46 -03:00
commit 2a88b4a71b
106 changed files with 22508 additions and 0 deletions

6
backend/.config Normal file
View File

@@ -0,0 +1,6 @@
DOMAIN=ofertaweb.cl
PORT=3006
DB_TYPE=postgresql
BACKEND_TYPE=express
PM2_APP_NAME=api-ofertaweb.cl
INSTALL_DATE=Mon Dec 8 21:28:49 CET 2025

3
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
.env
logs/

1
backend/.port Normal file
View File

@@ -0,0 +1 @@
3006

229
backend/README.md Normal file
View File

@@ -0,0 +1,229 @@
# OfertaWeb Backend
Backend API para tienda web multi-canal con integración a MercadoLibre, Instagram Shopping y BlueExpress.
## 📁 Estructura del Proyecto
```
backend/
├── server.js # Punto de entrada del servidor
├── package.json # Dependencias y scripts
├── .env # Variables de entorno
├── .gitignore # Archivos ignorados por git
├── src/ # Código fuente
│ ├── config/ # Configuraciones (DB, etc)
│ ├── controllers/ # Controladores de rutas
│ ├── middlewares/ # Middlewares (auth, validation, etc)
│ ├── models/ # Modelos de datos
│ ├── routes/ # Definición de rutas
│ ├── services/ # Lógica de negocio
│ └── utils/ # Utilidades (logger, helpers)
├── migrations/ # Migraciones de base de datos
├── scripts/ # Scripts de utilidad
├── public/ # Archivos públicos
│ └── uploads/ # Imágenes subidas
├── logs/ # Logs del servidor
└── tests/ # Tests unitarios e integración
```
## 🚀 Inicio Rápido
### Requisitos Previos
- Node.js >= 18.x
- PostgreSQL >= 12
- npm o yarn
### Instalación
1. **Instalar dependencias:**
```bash
npm install
```
2. **Configurar variables de entorno:**
```bash
cp .env.example .env
# Editar .env con tus credenciales
```
3. **Ejecutar migraciones:**
```bash
psql -U postgres -d ofertaweb -f migrations/001_schema_completo.sql
psql -U postgres -d ofertaweb -f migrations/002_initial_data.sql
psql -U postgres -d ofertaweb -f migrations/003_create_admin_user.sql
psql -U postgres -d ofertaweb -f migrations/004_add_codigo_to_categories.sql
```
4. **Iniciar servidor:**
```bash
# Desarrollo
npm run dev
# Producción
npm start
```
El servidor estará disponible en `http://localhost:3001`
## 📋 Variables de Entorno
```env
# Puerto del servidor
PORT=3001
# Entorno
NODE_ENV=development
# Base de datos
DB_HOST=localhost
DB_PORT=5432
DB_NAME=ofertaweb
DB_USER=ofertaweb_user
DB_PASSWORD=tu_password
# JWT
JWT_SECRET=tu_clave_secreta
JWT_EXPIRES_IN=7d
# CORS
FRONTEND_URL=http://localhost:5173
ADMIN_URL=http://localhost:5174
```
## 🛣️ API Endpoints
### Autenticación
- `POST /api/auth/login` - Iniciar sesión
- `POST /api/auth/register` - Registrar usuario
- `GET /api/auth/me` - Obtener usuario actual
### Productos
- `GET /api/products` - Listar productos
- `GET /api/products/:id` - Obtener producto
- `POST /api/products` - Crear producto (admin)
- `PUT /api/products/:id` - Actualizar producto (admin)
- `DELETE /api/products/:id` - Eliminar producto (admin)
### Categorías
- `GET /api/categories` - Listar categorías
- `GET /api/categories/tree` - Árbol de categorías
- `POST /api/categories` - Crear categoría (admin)
- `PUT /api/categories/:id` - Actualizar categoría (admin)
### Inventario
- `GET /api/inventory/:productId` - Ver inventario
- `POST /api/inventory/adjust` - Ajustar stock (admin)
- `GET /api/inventory/movements/:productId` - Historial de movimientos
### Órdenes
- `GET /api/orders` - Listar órdenes
- `GET /api/orders/:id` - Detalle de orden
- `POST /api/orders` - Crear orden
- `PATCH /api/orders/:id/status` - Actualizar estado (admin)
### Carrito
- `GET /api/cart` - Ver carrito
- `POST /api/cart/items` - Agregar producto
- `PUT /api/cart/items/:productId` - Actualizar cantidad
- `DELETE /api/cart/items/:productId` - Eliminar del carrito
### Uploads
- `POST /api/upload/product/:productId` - Subir imagen de producto
- `DELETE /api/upload/image/:imageId` - Eliminar imagen
## 🗄️ Base de Datos
### Módulos
1. **Usuarios y Autenticación** - users, user_addresses
2. **Catálogo** - categories, products, product_images
3. **Multi-Canal** - channels, channel_products
4. **Inventario** - inventory, inventory_movements
5. **Carrito** - carts, cart_items
6. **Órdenes** - orders, order_items, order_status_history
7. **Pagos** - payments
8. **Envíos** - shipments
## 🔧 Scripts Disponibles
```bash
# Desarrollo con auto-reload
npm run dev
# Iniciar en producción
npm start
# Ejecutar tests
npm test
# Generar hash de contraseña
node scripts/generate_password_hash.js
```
## 📝 Logs
Los logs se almacenan en la carpeta `logs/`:
- `error.log` - Solo errores
- `combined.log` - Todos los logs
## 🔐 Seguridad
- Autenticación mediante JWT
- Contraseñas hasheadas con bcrypt
- Validación de datos con Joi
- Protección CORS configurada
- Rate limiting implementado
- SQL injection prevention con prepared statements
## 🚧 Desarrollo
### Agregar nueva ruta
1. Crear controlador en `src/controllers/`
2. Crear modelo en `src/models/` (si aplica)
3. Definir ruta en `src/routes/`
4. Registrar en `server.js`
### Ejemplo:
```javascript
// src/controllers/ejemploController.js
const db = require('../config/db');
exports.getEjemplo = async (req, res) => {
const result = await db.query('SELECT * FROM tabla');
res.json(result.rows);
};
// src/routes/ejemploRoutes.js
const router = require('express').Router();
const controller = require('../controllers/ejemploController');
router.get('/', controller.getEjemplo);
module.exports = router;
// server.js
const ejemploRoutes = require('./src/routes/ejemploRoutes');
app.use('/api/ejemplo', ejemploRoutes);
```
## 📦 Dependencias Principales
- **express** - Framework web
- **pg** - Cliente PostgreSQL
- **jsonwebtoken** - Autenticación JWT
- **bcryptjs** - Hash de contraseñas
- **multer** - Upload de archivos
- **winston** - Sistema de logs
- **joi** - Validación de datos
- **cors** - Configuración CORS
## 🤝 Contribuir
1. Crear rama feature: `git checkout -b feature/nueva-funcionalidad`
2. Hacer cambios y commit: `git commit -m 'Agregar nueva funcionalidad'`
3. Push a la rama: `git push origin feature/nueva-funcionalidad`
4. Crear Pull Request
## 📄 Licencia
ISC

View File

@@ -0,0 +1,419 @@
-- ================================================
-- OFERTAWEB.CL - ESQUEMA DE BASE DE DATOS
-- Sistema E-Commerce Multi-Canal
-- PostgreSQL 12+
-- ================================================
-- ================================================
-- 1. MÓDULO: USUARIOS Y AUTENTICACIÓN
-- ================================================
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
nombre VARCHAR(100) NOT NULL,
apellido VARCHAR(100),
telefono VARCHAR(20),
role VARCHAR(20) DEFAULT 'cliente' CHECK (role IN ('cliente', 'admin', 'repartidor')),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE user_addresses (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
direccion TEXT NOT NULL,
comuna VARCHAR(100) NOT NULL,
ciudad VARCHAR(100) NOT NULL,
region VARCHAR(100) NOT NULL,
codigo_postal VARCHAR(20),
referencia TEXT,
is_default BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_user_addresses_user_id ON user_addresses(user_id);
-- ================================================
-- 2. MÓDULO: CATÁLOGO DE PRODUCTOS
-- ================================================
CREATE TABLE categories (
id SERIAL PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
descripcion TEXT,
parent_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
orden INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE products (
id SERIAL PRIMARY KEY,
sku VARCHAR(50) UNIQUE NOT NULL,
nombre VARCHAR(255) NOT NULL,
descripcion TEXT,
precio_base DECIMAL(10, 2) NOT NULL,
peso_gramos INTEGER,
alto_cm DECIMAL(6, 2),
ancho_cm DECIMAL(6, 2),
largo_cm DECIMAL(6, 2),
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE product_images (
id SERIAL PRIMARY KEY,
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
url TEXT NOT NULL,
alt_text VARCHAR(255),
orden INTEGER DEFAULT 0,
is_primary BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_product_images_product_id ON product_images(product_id);
CREATE INDEX idx_products_category_id ON products(category_id);
CREATE INDEX idx_products_sku ON products(sku);
-- ================================================
-- 3. MÓDULO: MULTI-CANAL
-- ================================================
CREATE TABLE channels (
id SERIAL PRIMARY KEY,
nombre VARCHAR(50) NOT NULL CHECK (nombre IN ('tienda', 'mercadolibre', 'instagram')),
slug VARCHAR(50) UNIQUE NOT NULL,
config JSONB DEFAULT '{}',
is_active BOOLEAN DEFAULT true,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Insertar canales por defecto
INSERT INTO channels (nombre, slug) VALUES
('tienda', 'tienda'),
('mercadolibre', 'mercadolibre'),
('instagram', 'instagram');
CREATE TABLE channel_products (
id SERIAL PRIMARY KEY,
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
external_id VARCHAR(100),
precio_ajustado DECIMAL(10, 2),
precio_factor DECIMAL(5, 2) DEFAULT 1.00,
estado VARCHAR(20) DEFAULT 'publicado' CHECK (estado IN ('publicado', 'pausado', 'agotado')),
last_sync_at TIMESTAMP,
sync_errors JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(product_id, channel_id)
);
CREATE TABLE channel_sync_logs (
id SERIAL PRIMARY KEY,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
product_id INTEGER REFERENCES products(id) ON DELETE SET NULL,
tipo VARCHAR(20) CHECK (tipo IN ('stock', 'precio', 'publicacion')),
status VARCHAR(20) CHECK (status IN ('success', 'error')),
mensaje TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_channel_products_product_id ON channel_products(product_id);
CREATE INDEX idx_channel_products_channel_id ON channel_products(channel_id);
CREATE INDEX idx_channel_sync_logs_created_at ON channel_sync_logs(created_at);
-- ================================================
-- 4. MÓDULO: INVENTARIO (STOCK UNIFICADO)
-- ================================================
CREATE TABLE inventory (
id SERIAL PRIMARY KEY,
product_id INTEGER UNIQUE NOT NULL REFERENCES products(id) ON DELETE CASCADE,
stock_actual INTEGER DEFAULT 0 CHECK (stock_actual >= 0),
stock_minimo INTEGER DEFAULT 5,
stock_reservado INTEGER DEFAULT 0 CHECK (stock_reservado >= 0),
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE inventory_movements (
id SERIAL PRIMARY KEY,
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
tipo VARCHAR(20) CHECK (tipo IN ('entrada', 'salida', 'ajuste', 'reserva', 'liberacion')),
cantidad INTEGER NOT NULL,
cantidad_anterior INTEGER NOT NULL,
cantidad_nueva INTEGER NOT NULL,
referencia VARCHAR(100),
notas TEXT,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_inventory_product_id ON inventory(product_id);
CREATE INDEX idx_inventory_movements_product_id ON inventory_movements(product_id);
CREATE INDEX idx_inventory_movements_created_at ON inventory_movements(created_at);
-- ================================================
-- 5. MÓDULO: CARRITO DE COMPRAS
-- ================================================
CREATE TABLE carts (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
session_id VARCHAR(100),
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE cart_items (
id SERIAL PRIMARY KEY,
cart_id INTEGER NOT NULL REFERENCES carts(id) ON DELETE CASCADE,
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
cantidad INTEGER NOT NULL CHECK (cantidad > 0),
precio_unitario DECIMAL(10, 2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_carts_user_id ON carts(user_id);
CREATE INDEX idx_carts_session_id ON carts(session_id);
CREATE INDEX idx_cart_items_cart_id ON cart_items(cart_id);
-- ================================================
-- 6. MÓDULO: ÓRDENES DE COMPRA
-- ================================================
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
order_number VARCHAR(50) UNIQUE NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE RESTRICT,
estado VARCHAR(30) DEFAULT 'pendiente_pago'
CHECK (estado IN ('pendiente_pago', 'pagada', 'preparando', 'enviada', 'entregada', 'cancelada')),
subtotal DECIMAL(10, 2) NOT NULL,
descuento DECIMAL(10, 2) DEFAULT 0,
costo_envio DECIMAL(10, 2) DEFAULT 0,
total DECIMAL(10, 2) NOT NULL,
notas_cliente TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE order_items (
id SERIAL PRIMARY KEY,
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE RESTRICT,
cantidad INTEGER NOT NULL CHECK (cantidad > 0),
precio_unitario DECIMAL(10, 2) NOT NULL,
subtotal DECIMAL(10, 2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_order_number ON orders(order_number);
CREATE INDEX idx_orders_estado ON orders(estado);
CREATE INDEX idx_order_items_order_id ON order_items(order_id);
-- ================================================
-- 7. MÓDULO: PAGOS
-- ================================================
CREATE TABLE payments (
id SERIAL PRIMARY KEY,
order_id INTEGER UNIQUE NOT NULL REFERENCES orders(id) ON DELETE RESTRICT,
metodo VARCHAR(30) CHECK (metodo IN ('webpay', 'transferencia', 'mercadopago', 'efectivo')),
estado VARCHAR(20) DEFAULT 'pendiente'
CHECK (estado IN ('pendiente', 'aprobado', 'rechazado', 'reembolsado')),
monto DECIMAL(10, 2) NOT NULL,
transaction_id VARCHAR(100),
response_data JSONB,
paid_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE payment_transactions (
id SERIAL PRIMARY KEY,
payment_id INTEGER NOT NULL REFERENCES payments(id) ON DELETE CASCADE,
tipo VARCHAR(20) CHECK (tipo IN ('authorization', 'capture', 'refund')),
status VARCHAR(20),
request_data JSONB,
response_data JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_payments_order_id ON payments(order_id);
CREATE INDEX idx_payment_transactions_payment_id ON payment_transactions(payment_id);
-- ================================================
-- 8. MÓDULO: DESPACHO Y ENVÍOS
-- ================================================
CREATE TABLE shipments (
id SERIAL PRIMARY KEY,
order_id INTEGER UNIQUE NOT NULL REFERENCES orders(id) ON DELETE RESTRICT,
tracking_number VARCHAR(100),
courier VARCHAR(30) CHECK (courier IN ('bluexpress', 'chilexpress', 'starken', 'retiro')),
estado VARCHAR(30) DEFAULT 'pendiente'
CHECK (estado IN ('pendiente', 'preparando', 'en_transito', 'en_reparto', 'entregado', 'fallido')),
direccion_id INTEGER REFERENCES user_addresses(id) ON DELETE RESTRICT,
costo_calculado DECIMAL(10, 2),
peso_total INTEGER,
dimensiones JSONB,
fecha_estimada_entrega DATE,
fecha_entrega_real TIMESTAMP,
repartidor_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
notas_despacho TEXT,
comprobante_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE shipment_tracking (
id SERIAL PRIMARY KEY,
shipment_id INTEGER NOT NULL REFERENCES shipments(id) ON DELETE CASCADE,
estado VARCHAR(30) NOT NULL,
ubicacion VARCHAR(255),
descripcion TEXT,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE shipping_quotes (
id SERIAL PRIMARY KEY,
order_id INTEGER REFERENCES orders(id) ON DELETE CASCADE,
courier VARCHAR(30) NOT NULL,
servicio VARCHAR(50),
origen_comuna VARCHAR(100),
destino_comuna VARCHAR(100),
peso_gramos INTEGER NOT NULL,
costo DECIMAL(10, 2) NOT NULL,
dias_estimados INTEGER,
response_data JSONB,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_shipments_order_id ON shipments(order_id);
CREATE INDEX idx_shipments_estado ON shipments(estado);
CREATE INDEX idx_shipment_tracking_shipment_id ON shipment_tracking(shipment_id);
-- ================================================
-- 9. MÓDULO: CONFIGURACIÓN DEL SISTEMA
-- ================================================
CREATE TABLE settings (
id SERIAL PRIMARY KEY,
key VARCHAR(100) UNIQUE NOT NULL,
value TEXT,
tipo VARCHAR(20) DEFAULT 'string' CHECK (tipo IN ('string', 'number', 'boolean', 'json')),
descripcion TEXT,
is_secret BOOLEAN DEFAULT false,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ================================================
-- FUNCIONES Y TRIGGERS
-- ================================================
-- Trigger para actualizar updated_at automáticamente
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
-- Aplicar trigger a las tablas relevantes
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_products_updated_at BEFORE UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_orders_updated_at BEFORE UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_inventory_updated_at BEFORE UPDATE ON inventory
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Función para generar número de orden
CREATE OR REPLACE FUNCTION generate_order_number()
RETURNS TEXT AS $$
DECLARE
new_number TEXT;
date_part TEXT;
sequence_part TEXT;
count_today INTEGER;
BEGIN
date_part := TO_CHAR(CURRENT_DATE, 'YYYYMMDD');
SELECT COUNT(*) INTO count_today
FROM orders
WHERE order_number LIKE 'ORD-' || date_part || '-%';
sequence_part := LPAD((count_today + 1)::TEXT, 4, '0');
new_number := 'ORD-' || date_part || '-' || sequence_part;
RETURN new_number;
END;
$$ LANGUAGE plpgsql;
-- ================================================
-- VISTAS ÚTILES
-- ================================================
-- Vista de productos con stock disponible
CREATE VIEW v_products_with_stock AS
SELECT
p.id,
p.sku,
p.nombre,
p.precio_base,
p.is_active,
i.stock_actual,
i.stock_reservado,
(i.stock_actual - i.stock_reservado) as stock_disponible,
c.nombre as categoria
FROM products p
LEFT JOIN inventory i ON p.id = i.product_id
LEFT JOIN categories c ON p.category_id = c.id;
-- Vista de órdenes con información completa
CREATE VIEW v_orders_complete AS
SELECT
o.id,
o.order_number,
o.estado,
o.total,
o.created_at,
u.nombre || ' ' || COALESCE(u.apellido, '') as cliente,
u.email,
ch.nombre as canal,
p.estado as estado_pago,
s.tracking_number,
s.estado as estado_envio
FROM orders o
JOIN users u ON o.user_id = u.id
JOIN channels ch ON o.channel_id = ch.id
LEFT JOIN payments p ON o.id = p.order_id
LEFT JOIN shipments s ON o.id = s.order_id;
-- ================================================
-- DATOS DE PRUEBA (OPCIONAL)
-- ================================================
-- Usuario admin por defecto (password: admin123)
INSERT INTO users (email, password_hash, nombre, apellido, role) VALUES
('admin@ofertaweb.cl', '$2a$10$X6vE7F.QqP8F5F5F5F5F5eK9Z1Z1Z1Z1Z1Z1Z1Z1Z1Z1Z1Z1Z1Z1Z', 'Admin', 'Sistema', 'admin');
-- Categorías de ejemplo
INSERT INTO categories (nombre, slug, descripcion, orden) VALUES
('Electrónica', 'electronica', 'Productos electrónicos y tecnología', 1),
('Ropa', 'ropa', 'Vestimenta y accesorios', 2),
('Hogar', 'hogar', 'Artículos para el hogar', 3);
COMMIT;

View File

@@ -0,0 +1,25 @@
-- ================================================
-- CREAR USUARIO Y DAR PERMISOS
-- Ejecutar como usuario postgres en pgAdmin4
-- ================================================
-- 1. Crear usuario
CREATE USER ofertaweb_user WITH PASSWORD 'OfertaWeb2024!';
-- 2. Dar permisos sobre la base de datos
GRANT ALL PRIVILEGES ON DATABASE ofertaweb TO ofertaweb_user;
-- 3. Conectarse a la base de datos ofertaweb y ejecutar:
-- (En pgAdmin4: click derecho en base "ofertaweb" -> Query Tool y ejecutar lo siguiente)
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ofertaweb_user;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ofertaweb_user;
GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO ofertaweb_user;
-- 4. Para tablas futuras
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO ofertaweb_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO ofertaweb_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON FUNCTIONS TO ofertaweb_user;
-- Verificar que se creó el usuario
SELECT usename FROM pg_user WHERE usename = 'ofertaweb_user';

View File

@@ -0,0 +1,38 @@
-- ================================================
-- CREAR USUARIO ADMINISTRADOR
-- Ejecutar en la base de datos ofertaweb
-- ================================================
-- Usuario: admin@ofertaweb.cl
-- Contraseña: admin123
-- Hash bcrypt generado con bcryptjs (10 rounds)
INSERT INTO users (
email,
password_hash,
nombre,
apellido,
telefono,
role,
is_active,
email_verificado
) VALUES (
'admin@ofertaweb.cl',
'$2a$10$zt.nWuEczQFQAwFFjuuPX.cx6cgSqSYZpYmcaEZdRSn5XRNVFI4YS',
'Administrador',
'Sistema',
'+56912345678',
'admin',
true,
true
)
ON CONFLICT (email) DO UPDATE SET
password_hash = EXCLUDED.password_hash,
role = EXCLUDED.role,
is_active = EXCLUDED.is_active,
email_verificado = EXCLUDED.email_verificado;
-- Verificar que se creó
SELECT id, email, nombre, apellido, role, is_active, email_verificado, created_at
FROM users
WHERE email = 'admin@ofertaweb.cl';

View File

@@ -0,0 +1,13 @@
-- Agregar campo codigo a categorías para generar SKUs automáticos
ALTER TABLE categories
ADD COLUMN IF NOT EXISTS codigo VARCHAR(10);
-- Crear índice para búsquedas rápidas
CREATE INDEX IF NOT EXISTS idx_categories_codigo ON categories(codigo);
-- Generar códigos automáticos para categorías existentes
UPDATE categories
SET codigo = UPPER(LEFT(nombre, 4))
WHERE codigo IS NULL;
COMMENT ON COLUMN categories.codigo IS 'Código corto para generar SKUs de productos (ej: ELEC, ROPA)';

6261
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
backend/package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "ofertaweb-backend",
"version": "1.0.0",
"description": "Backend API para tienda web multi-canal",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"test": "jest --coverage"
},
"keywords": [
"ecommerce",
"api",
"mercadolibre",
"instagram"
],
"author": "",
"license": "ISC",
"dependencies": {
"axios": "^1.6.2",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.2.1",
"joi": "^17.11.0",
"jsonwebtoken": "^9.0.2",
"mercadolibre": "^0.0.13",
"multer": "^1.4.5-lts.1",
"node-cron": "^3.0.3",
"nodemailer": "^6.9.7",
"pg": "^8.16.3",
"sharp": "^0.33.1",
"winston": "^3.11.0",
"helmet": "^7.1.0"
},
"devDependencies": {
"jest": "^29.7.0",
"nodemon": "^3.0.2",
"supertest": "^6.3.3"
}
}

View File

@@ -0,0 +1 @@
# Este archivo mantiene el directorio en git

View File

@@ -0,0 +1,75 @@
-- Agregar campo codigo si no existe
ALTER TABLE categories
ADD COLUMN IF NOT EXISTS codigo VARCHAR(10);
-- Crear categoría de ejemplo si no existe
INSERT INTO categories (nombre, slug, descripcion, codigo, orden)
VALUES ('Electrónica', 'electronica', 'Productos electrónicos y tecnología', 'ELEC', 1)
ON CONFLICT (slug) DO NOTHING;
-- Obtener el ID de la categoría
DO $$
DECLARE
cat_id INTEGER;
prod_id INTEGER;
BEGIN
-- Buscar categoría
SELECT id INTO cat_id FROM categories WHERE slug = 'electronica';
-- Crear producto de ejemplo
INSERT INTO products (
sku,
nombre,
descripcion,
category_id,
precio_base,
peso_gramos,
alto_cm,
ancho_cm,
largo_cm,
is_active
)
VALUES (
'ELEC-001',
'Mouse Inalámbrico Logitech M185',
'Mouse inalámbrico con sensor óptico de alta precisión, batería de larga duración (hasta 12 meses), receptor nano USB. Color negro.',
cat_id,
12990,
85,
3.8,
6.2,
10.5,
true
)
ON CONFLICT (sku) DO UPDATE
SET nombre = EXCLUDED.nombre,
descripcion = EXCLUDED.descripcion,
precio_base = EXCLUDED.precio_base
RETURNING id INTO prod_id;
-- Crear inventario inicial
INSERT INTO inventory (product_id, stock_actual, stock_minimo)
VALUES (prod_id, 25, 5)
ON CONFLICT (product_id) DO UPDATE
SET stock_actual = 25;
-- Registrar movimiento de inventario
INSERT INTO inventory_movements (
product_id,
tipo,
cantidad,
cantidad_anterior,
cantidad_nueva,
notas
)
VALUES (
prod_id,
'entrada',
25,
0,
25,
'Stock inicial de ejemplo'
);
RAISE NOTICE 'Producto creado exitosamente: ELEC-001 con 25 unidades en stock';
END $$;

View File

@@ -0,0 +1,34 @@
// Script para generar hash de contraseña
// Uso: node generate_password_hash.js <password>
const bcrypt = require('bcryptjs');
const password = process.argv[2] || 'admin123';
const saltRounds = 10;
bcrypt.hash(password, saltRounds, (err, hash) => {
if (err) {
console.error('Error generando hash:', err);
process.exit(1);
}
console.log('\nContraseña:', password);
console.log('Hash bcrypt:', hash);
console.log('\nSQL para insertar usuario:');
console.log(`
INSERT INTO users (email, password_hash, nombre, apellido, telefono, role, is_active, email_verificado)
VALUES (
'admin@ofertaweb.cl',
'${hash}',
'Administrador',
'Sistema',
'+56912345678',
'admin',
true,
true
)
ON CONFLICT (email) DO UPDATE SET
password_hash = EXCLUDED.password_hash,
role = EXCLUDED.role,
is_active = EXCLUDED.is_active;
`);
});

86
backend/server.js Normal file
View File

@@ -0,0 +1,86 @@
const express = require('express');
const cors = require('cors');
require('dotenv').config({ path: __dirname + '/.env' });
const errorHandler = require('./src/middlewares/errorHandler');
const logger = require('./src/utils/logger');
const app = express();
// Middlewares globales
app.use(cors({
origin: [process.env.FRONTEND_URL, process.env.ADMIN_URL],
credentials: true
}));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Servir archivos estáticos (imágenes de productos)
app.use('/uploads', express.static('public/uploads'));
// Logger de requests
app.use((req, res, next) => {
logger.info(`${req.method} ${req.path}`, {
ip: req.ip,
userAgent: req.get('user-agent')
});
next();
});
// Rutas
const authRoutes = require('./src/routes/authRoutes');
const productRoutes = require('./src/routes/productRoutes');
const categoryRoutes = require('./src/routes/categoryRoutes');
const inventoryRoutes = require('./src/routes/inventoryRoutes');
const orderRoutes = require('./src/routes/orderRoutes');
const cartRoutes = require('./src/routes/cartRoutes');
const uploadRoutes = require('./src/routes/uploadRoutes');
app.use('/api/auth', authRoutes);
app.use('/api/products', productRoutes);
app.use('/api/categories', categoryRoutes);
app.use('/api/inventory', inventoryRoutes);
app.use('/api/orders', orderRoutes);
app.use('/api/cart', cartRoutes);
app.use('/api/upload', uploadRoutes);
// Health check
app.get('/health', (req, res) => {
res.json({
status: 'OK',
environment: process.env.NODE_ENV,
timestamp: new Date().toISOString()
});
});
// Ruta raíz
app.get('/', (req, res) => {
res.json({
name: 'OfertaWeb API',
version: '1.0.0',
endpoints: {
auth: '/api/auth',
products: '/api/products',
categories: '/api/categories',
inventory: '/api/inventory',
orders: '/api/orders',
cart: '/api/cart',
upload: '/api/upload'
}
});
});
// Manejo de rutas no encontradas
app.use((req, res) => {
res.status(404).json({ error: 'Ruta no encontrada' });
});
// Middleware de manejo de errores (debe estar al final)
app.use(errorHandler);
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`🚀 Servidor OfertaWeb corriendo en puerto ${PORT}`);
console.log(`📝 Ambiente: ${process.env.NODE_ENV || 'development'}`);
logger.info(`Servidor iniciado en puerto ${PORT}`);
});

View File

@@ -0,0 +1,89 @@
// Constantes del sistema
module.exports = {
// Roles de usuario
ROLES: {
ADMIN: 'admin',
CLIENTE: 'cliente',
REPARTIDOR: 'repartidor'
},
// Estados de órdenes
ORDER_STATUS: {
PENDIENTE_PAGO: 'pendiente_pago',
PAGADA: 'pagada',
PREPARANDO: 'preparando',
ENVIADA: 'enviada',
ENTREGADA: 'entregada',
CANCELADA: 'cancelada'
},
// Estados de despacho
SHIPMENT_STATUS: {
PENDIENTE: 'pendiente',
PREPARANDO: 'preparando',
EN_TRANSITO: 'en_transito',
EN_REPARTO: 'en_reparto',
ENTREGADO: 'entregado',
FALLIDO: 'fallido'
},
// Estados de pago
PAYMENT_STATUS: {
PENDIENTE: 'pendiente',
APROBADO: 'aprobado',
RECHAZADO: 'rechazado',
REEMBOLSADO: 'reembolsado'
},
// Métodos de pago
PAYMENT_METHODS: {
WEBPAY: 'webpay',
TRANSFERENCIA: 'transferencia',
MERCADOPAGO: 'mercadopago',
EFECTIVO: 'efectivo'
},
// Canales de venta
CHANNELS: {
TIENDA: 'tienda',
MERCADOLIBRE: 'mercadolibre',
INSTAGRAM: 'instagram'
},
// Couriers
COURIERS: {
BLUEXPRESS: 'bluexpress',
CHILEXPRESS: 'chilexpress',
STARKEN: 'starken',
RETIRO: 'retiro'
},
// Tipos de movimiento de inventario
INVENTORY_MOVEMENT_TYPES: {
ENTRADA: 'entrada',
SALIDA: 'salida',
AJUSTE: 'ajuste',
RESERVA: 'reserva',
LIBERACION: 'liberacion'
},
// Configuración de stock
STOCK: {
MIN_STOCK_ALERT: 5,
RESERVATION_TIMEOUT_MINUTES: 15
},
// Configuración de imágenes
IMAGES: {
MAX_SIZE: 5 * 1024 * 1024, // 5MB
ALLOWED_TYPES: ['image/jpeg', 'image/png', 'image/webp'],
MAX_PER_PRODUCT: 10
},
// Paginación
PAGINATION: {
DEFAULT_PAGE: 1,
DEFAULT_LIMIT: 20,
MAX_LIMIT: 100
}
};

23
backend/src/config/db.js Normal file
View File

@@ -0,0 +1,23 @@
const { Pool } = require('pg');
const pool = new Pool({
user: process.env.DB_USER,
host: process.env.DB_HOST,
database: process.env.DB_NAME,
password: process.env.DB_PASSWORD,
port: process.env.DB_PORT,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
pool.on('connect', () => {
console.log('Conexión PostgreSQL exitosa');
});
pool.on('error', (err) => {
console.error('Error inesperado en PostgreSQL:', err);
process.exit(-1);
});
module.exports = pool;

View File

@@ -0,0 +1,42 @@
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { IMAGES } = require('./constants');
// Crear directorio de uploads si no existe
const uploadDir = path.join(__dirname, '..', 'public', 'uploads', 'products');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// Configuración de almacenamiento
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, uploadDir);
},
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const ext = path.extname(file.originalname);
cb(null, 'product-' + uniqueSuffix + ext);
}
});
// Filtro de archivos
const fileFilter = (req, file, cb) => {
if (IMAGES.ALLOWED_TYPES.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Tipo de archivo no permitido. Solo se aceptan imágenes JPEG, PNG y WebP.'), false);
}
};
// Configuración de multer
const upload = multer({
storage: storage,
limits: {
fileSize: IMAGES.MAX_SIZE
},
fileFilter: fileFilter
});
module.exports = upload;

View File

@@ -0,0 +1,116 @@
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const { sanitizeUser } = require('../utils/helpers');
const { asyncHandler } = require('../utils/helpers');
// Registro de usuario
exports.register = asyncHandler(async (req, res) => {
const { email, password, nombre, apellido, telefono } = req.body;
// Verificar si el email ya existe
const existingUser = await User.findByEmail(email);
if (existingUser) {
return res.status(409).json({ error: 'El email ya está registrado' });
}
// Crear usuario
const user = await User.create({
email,
password,
nombre,
apellido,
telefono,
role: 'cliente'
});
// Generar token
const token = jwt.sign(
{ id: user.id, email: user.email, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN }
);
res.status(201).json({
message: 'Usuario registrado exitosamente',
user: sanitizeUser(user),
token
});
});
// Login
exports.login = asyncHandler(async (req, res) => {
const { email, password } = req.body;
// Buscar usuario
const user = await User.findByEmail(email);
if (!user) {
return res.status(401).json({ error: 'Credenciales inválidas' });
}
// Verificar contraseña
const isValidPassword = await User.verifyPassword(password, user.password_hash);
if (!isValidPassword) {
return res.status(401).json({ error: 'Credenciales inválidas' });
}
// Verificar si está activo
if (!user.is_active) {
return res.status(403).json({ error: 'Usuario inactivo. Contacta a soporte.' });
}
// Generar token
const token = jwt.sign(
{ id: user.id, email: user.email, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN }
);
res.json({
message: 'Login exitoso',
user: sanitizeUser(user),
token
});
});
// Obtener perfil del usuario autenticado
exports.getProfile = asyncHandler(async (req, res) => {
const user = await User.findById(req.user.id);
if (!user) {
return res.status(404).json({ error: 'Usuario no encontrado' });
}
res.json({ user });
});
// Actualizar perfil
exports.updateProfile = asyncHandler(async (req, res) => {
const { nombre, apellido, telefono } = req.body;
const updatedUser = await User.update(req.user.id, {
nombre,
apellido,
telefono
});
res.json({
message: 'Perfil actualizado exitosamente',
user: updatedUser
});
});
// Obtener direcciones del usuario
exports.getAddresses = asyncHandler(async (req, res) => {
const addresses = await User.getAddresses(req.user.id);
res.json({ addresses });
});
// Agregar dirección
exports.addAddress = asyncHandler(async (req, res) => {
const address = await User.addAddress(req.user.id, req.body);
res.status(201).json({
message: 'Dirección agregada exitosamente',
address
});
});

View File

@@ -0,0 +1,237 @@
const db = require('../config/db');
const { asyncHandler } = require('../utils/helpers');
// Obtener carrito del usuario
exports.getCart = asyncHandler(async (req, res) => {
const userId = req.user.id;
// Buscar carrito activo
let cartResult = await db.query(
`SELECT * FROM carts
WHERE user_id = $1 AND expires_at > NOW()
ORDER BY created_at DESC LIMIT 1`,
[userId]
);
let cart = cartResult.rows[0];
// Si no existe, crear uno nuevo
if (!cart) {
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24); // Expira en 24 horas
const newCartResult = await db.query(
`INSERT INTO carts (user_id, expires_at) VALUES ($1, $2) RETURNING *`,
[userId, expiresAt]
);
cart = newCartResult.rows[0];
}
// Obtener items del carrito con información de productos
const itemsResult = await db.query(
`SELECT ci.*, p.nombre, p.precio_base, p.sku,
(ci.cantidad * ci.precio_unitario) as subtotal,
i.stock_actual, i.stock_reservado,
(i.stock_actual - i.stock_reservado) as stock_disponible,
(SELECT url FROM product_images WHERE product_id = p.id AND is_primary = true LIMIT 1) as imagen
FROM cart_items ci
JOIN products p ON ci.product_id = p.id
LEFT JOIN inventory i ON p.id = i.product_id
WHERE ci.cart_id = $1`,
[cart.id]
);
// Calcular total
const total = itemsResult.rows.reduce((sum, item) => sum + parseFloat(item.subtotal), 0);
res.json({
cart: {
...cart,
items: itemsResult.rows,
total
}
});
});
// Agregar producto al carrito
exports.addToCart = asyncHandler(async (req, res) => {
const { product_id, cantidad } = req.body;
const userId = req.user.id;
// Verificar que el producto existe y tiene stock
const productResult = await db.query(
`SELECT p.*, i.stock_actual, i.stock_reservado,
(i.stock_actual - i.stock_reservado) as stock_disponible
FROM products p
LEFT JOIN inventory i ON p.id = i.product_id
WHERE p.id = $1 AND p.is_active = true`,
[product_id]
);
const product = productResult.rows[0];
if (!product) {
return res.status(404).json({ error: 'Producto no encontrado' });
}
if (product.stock_disponible < cantidad) {
return res.status(400).json({
error: 'Stock insuficiente',
disponible: product.stock_disponible
});
}
// Obtener o crear carrito
let cartResult = await db.query(
`SELECT * FROM carts
WHERE user_id = $1 AND expires_at > NOW()
ORDER BY created_at DESC LIMIT 1`,
[userId]
);
let cart = cartResult.rows[0];
if (!cart) {
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24);
const newCartResult = await db.query(
`INSERT INTO carts (user_id, expires_at) VALUES ($1, $2) RETURNING *`,
[userId, expiresAt]
);
cart = newCartResult.rows[0];
}
// Verificar si el producto ya está en el carrito
const existingItemResult = await db.query(
'SELECT * FROM cart_items WHERE cart_id = $1 AND product_id = $2',
[cart.id, product_id]
);
let item;
if (existingItemResult.rows.length > 0) {
// Actualizar cantidad
const nuevaCantidad = existingItemResult.rows[0].cantidad + cantidad;
if (product.stock_disponible < nuevaCantidad) {
return res.status(400).json({
error: 'Stock insuficiente para la cantidad solicitada',
disponible: product.stock_disponible
});
}
const updateResult = await db.query(
`UPDATE cart_items SET cantidad = $1 WHERE id = $2 RETURNING *`,
[nuevaCantidad, existingItemResult.rows[0].id]
);
item = updateResult.rows[0];
} else {
// Agregar nuevo item
const insertResult = await db.query(
`INSERT INTO cart_items (cart_id, product_id, cantidad, precio_unitario)
VALUES ($1, $2, $3, $4) RETURNING *`,
[cart.id, product_id, cantidad, product.precio_base]
);
item = insertResult.rows[0];
}
res.status(201).json({
message: 'Producto agregado al carrito',
item
});
});
// Actualizar cantidad de item en el carrito
exports.updateCartItem = asyncHandler(async (req, res) => {
const { cantidad } = req.body;
const itemId = req.params.itemId;
// Verificar que el item pertenece al usuario
const itemResult = await db.query(
`SELECT ci.*, c.user_id, p.precio_base,
i.stock_actual, i.stock_reservado,
(i.stock_actual - i.stock_reservado) as stock_disponible
FROM cart_items ci
JOIN carts c ON ci.cart_id = c.id
JOIN products p ON ci.product_id = p.id
LEFT JOIN inventory i ON p.id = i.product_id
WHERE ci.id = $1`,
[itemId]
);
const item = itemResult.rows[0];
if (!item) {
return res.status(404).json({ error: 'Item no encontrado' });
}
if (item.user_id !== req.user.id) {
return res.status(403).json({ error: 'No autorizado' });
}
if (cantidad > item.stock_disponible) {
return res.status(400).json({
error: 'Stock insuficiente',
disponible: item.stock_disponible
});
}
// Actualizar cantidad
const updateResult = await db.query(
`UPDATE cart_items SET cantidad = $1 WHERE id = $2 RETURNING *`,
[cantidad, itemId]
);
res.json({
message: 'Cantidad actualizada',
item: updateResult.rows[0]
});
});
// Eliminar item del carrito
exports.removeFromCart = asyncHandler(async (req, res) => {
const itemId = req.params.itemId;
// Verificar que el item pertenece al usuario
const itemResult = await db.query(
`SELECT ci.*, c.user_id FROM cart_items ci
JOIN carts c ON ci.cart_id = c.id
WHERE ci.id = $1`,
[itemId]
);
const item = itemResult.rows[0];
if (!item) {
return res.status(404).json({ error: 'Item no encontrado' });
}
if (item.user_id !== req.user.id) {
return res.status(403).json({ error: 'No autorizado' });
}
// Eliminar item
await db.query('DELETE FROM cart_items WHERE id = $1', [itemId]);
res.json({ message: 'Producto eliminado del carrito' });
});
// Vaciar carrito
exports.clearCart = asyncHandler(async (req, res) => {
const userId = req.user.id;
// Obtener carrito activo
const cartResult = await db.query(
`SELECT id FROM carts
WHERE user_id = $1 AND expires_at > NOW()
ORDER BY created_at DESC LIMIT 1`,
[userId]
);
if (cartResult.rows.length > 0) {
await db.query('DELETE FROM cart_items WHERE cart_id = $1', [cartResult.rows[0].id]);
}
res.json({ message: 'Carrito vaciado' });
});

View File

@@ -0,0 +1,85 @@
const Category = require('../models/Category');
const { asyncHandler } = require('../utils/helpers');
// Listar categorías
exports.listCategories = asyncHandler(async (req, res) => {
const { is_active, parent_id } = req.query;
const categories = await Category.list({
is_active: is_active !== undefined ? is_active === 'true' : undefined,
parent_id: parent_id ? parseInt(parent_id) : undefined
});
res.json({ categories });
});
// Obtener árbol de categorías
exports.getCategoryTree = asyncHandler(async (req, res) => {
const tree = await Category.getTree();
res.json({ categories: tree });
});
// Obtener categoría por ID
exports.getCategory = asyncHandler(async (req, res) => {
const category = await Category.findById(req.params.id);
if (!category) {
return res.status(404).json({ error: 'Categoría no encontrada' });
}
res.json({ category });
});
// Crear categoría (solo admin)
exports.createCategory = asyncHandler(async (req, res) => {
const category = await Category.create(req.body);
res.status(201).json({
message: 'Categoría creada exitosamente',
category
});
});
// Actualizar categoría (solo admin)
exports.updateCategory = asyncHandler(async (req, res) => {
const category = await Category.update(req.params.id, req.body);
if (!category) {
return res.status(404).json({ error: 'Categoría no encontrada' });
}
res.json({
message: 'Categoría actualizada exitosamente',
category
});
});
// Eliminar categoría (solo admin)
exports.deleteCategory = asyncHandler(async (req, res) => {
try {
await Category.delete(req.params.id);
res.json({ message: 'Categoría eliminada exitosamente' });
} catch (error) {
return res.status(400).json({ error: error.message });
}
});
// Obtener productos de la categoría
exports.getCategoryProducts = asyncHandler(async (req, res) => {
const { page = 1, limit = 20 } = req.query;
const offset = (page - 1) * limit;
const { products, total } = await Category.getProducts(
req.params.id,
{ limit: parseInt(limit), offset: parseInt(offset) }
);
res.json({
products,
pagination: {
currentPage: parseInt(page),
totalPages: Math.ceil(total / limit),
totalItems: total
}
});
});

View File

@@ -0,0 +1,73 @@
const Inventory = require('../models/Inventory');
const { asyncHandler } = require('../utils/helpers');
// Obtener inventario de un producto
exports.getInventory = asyncHandler(async (req, res) => {
const inventory = await Inventory.getByProductId(req.params.productId);
if (!inventory) {
return res.status(404).json({ error: 'Inventario no encontrado' });
}
res.json({ inventory });
});
// Ajustar stock (solo admin)
exports.adjustStock = asyncHandler(async (req, res) => {
const { product_id, cantidad, tipo, notas } = req.body;
const result = await Inventory.adjustStock(
product_id,
cantidad,
tipo,
`Manual - User ${req.user.id}`,
notas,
req.user.id
);
res.json({
message: 'Stock ajustado exitosamente',
...result
});
});
// Obtener movimientos de inventario
exports.getMovements = asyncHandler(async (req, res) => {
const { limit = 50, offset = 0 } = req.query;
const { movements, total } = await Inventory.getMovements(
req.params.productId,
{ limit: parseInt(limit), offset: parseInt(offset) }
);
res.json({
movements,
total
});
});
// Obtener productos con stock bajo (solo admin)
exports.getLowStock = asyncHandler(async (req, res) => {
const { threshold } = req.query;
const products = await Inventory.getLowStock(
threshold ? parseInt(threshold) : null
);
res.json({ products });
});
// Actualizar stock mínimo (solo admin)
exports.updateMinStock = asyncHandler(async (req, res) => {
const { stock_minimo } = req.body;
const inventory = await Inventory.updateMinStock(
req.params.productId,
stock_minimo
);
res.json({
message: 'Stock mínimo actualizado',
inventory
});
});

View File

@@ -0,0 +1,153 @@
const Order = require('../models/Order');
const { asyncHandler, getPaginationParams, formatPaginatedResponse } = require('../utils/helpers');
// Crear orden
exports.createOrder = asyncHandler(async (req, res) => {
const { items, address_id, notas_cliente, metodo_pago } = req.body;
// El channel_id por defecto es 'tienda' (id: 1)
const order = await Order.create({
user_id: req.user.id,
channel_id: 1, // tienda
items,
address_id,
notas_cliente
});
// TODO: Iniciar proceso de pago según metodo_pago
res.status(201).json({
message: 'Orden creada exitosamente',
order
});
});
// Obtener orden por ID
exports.getOrder = asyncHandler(async (req, res) => {
const order = await Order.findById(req.params.id);
if (!order) {
return res.status(404).json({ error: 'Orden no encontrada' });
}
// Verificar que sea el dueño o admin
if (order.user_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'No tienes permiso para ver esta orden' });
}
// Obtener items
const items = await Order.getItems(order.id);
// Obtener información de envío
const shipment = await Order.getShipment(order.id);
res.json({
order,
items,
shipment
});
});
// Buscar orden por número
exports.getOrderByNumber = asyncHandler(async (req, res) => {
const order = await Order.findByOrderNumber(req.params.orderNumber);
if (!order) {
return res.status(404).json({ error: 'Orden no encontrada' });
}
// Verificar que sea el dueño o admin
if (order.user_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'No tienes permiso para ver esta orden' });
}
const items = await Order.getItems(order.id);
const shipment = await Order.getShipment(order.id);
res.json({
order,
items,
shipment
});
});
// Listar órdenes
exports.listOrders = asyncHandler(async (req, res) => {
const { page, limit, offset } = getPaginationParams(req.query.page, req.query.limit);
const { estado, channel_id, fecha_desde, fecha_hasta } = req.query;
const filters = {
estado,
channel_id: channel_id ? parseInt(channel_id) : undefined,
fecha_desde,
fecha_hasta,
limit,
offset
};
// Si no es admin, solo ver sus propias órdenes
if (req.user.role !== 'admin') {
filters.user_id = req.user.id;
}
const { orders, total } = await Order.list(filters);
res.json(formatPaginatedResponse(orders, total, page, limit));
});
// Actualizar estado de orden (solo admin)
exports.updateOrderStatus = asyncHandler(async (req, res) => {
const { estado } = req.body;
const order = await Order.updateStatus(req.params.id, estado);
if (!order) {
return res.status(404).json({ error: 'Orden no encontrada' });
}
res.json({
message: 'Estado de orden actualizado',
order
});
});
// Cancelar orden
exports.cancelOrder = asyncHandler(async (req, res) => {
const order = await Order.findById(req.params.id);
if (!order) {
return res.status(404).json({ error: 'Orden no encontrada' });
}
// Solo el dueño o admin pueden cancelar
if (order.user_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'No tienes permiso para cancelar esta orden' });
}
// Solo se puede cancelar si está en estado pendiente_pago
if (order.estado !== 'pendiente_pago') {
return res.status(400).json({
error: 'Solo se pueden cancelar órdenes en estado pendiente de pago'
});
}
const cancelledOrder = await Order.cancel(req.params.id);
res.json({
message: 'Orden cancelada exitosamente',
order: cancelledOrder
});
});
// Mis órdenes (cliente)
exports.getMyOrders = asyncHandler(async (req, res) => {
const { page, limit, offset } = getPaginationParams(req.query.page, req.query.limit);
const { orders, total } = await Order.list({
user_id: req.user.id,
limit,
offset
});
res.json(formatPaginatedResponse(orders, total, page, limit));
});

View File

@@ -0,0 +1,121 @@
const Product = require('../models/Product');
const { asyncHandler, getPaginationParams, formatPaginatedResponse } = require('../utils/helpers');
// Listar productos
exports.listProducts = asyncHandler(async (req, res) => {
const { page, limit, offset } = getPaginationParams(req.query.page, req.query.limit);
const { search, category_id, is_active, min_price, max_price } = req.query;
const { products, total } = await Product.list({
search,
category_id: category_id ? parseInt(category_id) : undefined,
is_active: is_active !== undefined ? is_active === 'true' : undefined,
min_price: min_price ? parseFloat(min_price) : undefined,
max_price: max_price ? parseFloat(max_price) : undefined,
limit,
offset
});
// Agregar imágenes a cada producto
for (const product of products) {
product.images = await Product.getImages(product.id);
}
res.json(formatPaginatedResponse(products, total, page, limit));
});
// Obtener producto por ID
exports.getProduct = asyncHandler(async (req, res) => {
const product = await Product.findById(req.params.id);
if (!product) {
return res.status(404).json({ error: 'Producto no encontrado' });
}
// Agregar imágenes
product.images = await Product.getImages(product.id);
res.json({ product });
});
// Crear producto (solo admin)
exports.createProduct = asyncHandler(async (req, res) => {
const product = await Product.create(req.body);
res.status(201).json({
message: 'Producto creado exitosamente',
product
});
});
// Actualizar producto (solo admin)
exports.updateProduct = asyncHandler(async (req, res) => {
const product = await Product.update(req.params.id, req.body);
if (!product) {
return res.status(404).json({ error: 'Producto no encontrado' });
}
res.json({
message: 'Producto actualizado exitosamente',
product
});
});
// Eliminar producto (solo admin)
exports.deleteProduct = asyncHandler(async (req, res) => {
const product = await Product.delete(req.params.id);
if (!product) {
return res.status(404).json({ error: 'Producto no encontrado' });
}
res.json({ message: 'Producto eliminado exitosamente' });
});
// Agregar imagen al producto (solo admin)
exports.addImage = asyncHandler(async (req, res) => {
const productId = req.params.id;
if (!req.file) {
return res.status(400).json({ error: 'No se proporcionó ninguna imagen' });
}
const imageUrl = `/uploads/products/${req.file.filename}`;
const image = await Product.addImage(productId, {
url: imageUrl,
alt_text: req.body.alt_text || '',
orden: req.body.orden ? parseInt(req.body.orden) : 0,
is_primary: req.body.is_primary === 'true'
});
res.status(201).json({
message: 'Imagen agregada exitosamente',
image
});
});
// Eliminar imagen (solo admin)
exports.deleteImage = asyncHandler(async (req, res) => {
const image = await Product.deleteImage(req.params.imageId);
if (!image) {
return res.status(404).json({ error: 'Imagen no encontrada' });
}
// TODO: Eliminar archivo físico del servidor
res.json({ message: 'Imagen eliminada exitosamente' });
});
// Obtener stock del producto
exports.getStock = asyncHandler(async (req, res) => {
const stock = await Product.getStock(req.params.id);
if (!stock) {
return res.status(404).json({ error: 'Producto no encontrado' });
}
res.json({ stock });
});

View File

@@ -0,0 +1,36 @@
const db = require('../config/db');
exports.listUsers = async (req, res) => {
if (process.env.DBTYPE === 'postgres') {
try {
const result = await db.query('SELECT id, nombre, correo FROM prueba ORDER BY id');
res.json(result.rows);
} catch (err) {
res.status(500).send('Error consultando tabla: ' + err.message);
}
} else {
db.query('SELECT id, nombre, correo FROM prueba ORDER BY id', (err, results) => {
if (err) res.status(500).send('Error consultando tabla: ' + err.message);
else res.json(results);
});
}
};
exports.createUser = async (req, res) => {
const { nombre, correo } = req.body;
if (!nombre || !correo) return res.status(400).send('Faltan campos');
if (process.env.DBTYPE === 'postgres') {
try {
await db.query('INSERT INTO prueba (nombre, correo) VALUES ($1, $2)', [nombre, correo]);
res.send(`Usuario ${nombre} agregado correctamente`);
} catch (err) {
res.status(500).send('Error insertando usuario: ' + err.message);
}
} else {
db.query('INSERT INTO prueba (nombre, correo) VALUES (?, ?)', [nombre, correo], (err) => {
if (err) res.status(500).send('Error insertando usuario: ' + err.message);
else res.send(`Usuario ${nombre} agregado correctamente`);
});
}
};

View File

@@ -0,0 +1,40 @@
const jwt = require('jsonwebtoken');
const authMiddleware = (req, res, next) => {
try {
// Obtener token del header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
error: 'No autorizado. Token no proporcionado.'
});
}
const token = authHeader.split(' ')[1];
// Verificar token
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Agregar datos del usuario al request
req.user = {
id: decoded.id,
email: decoded.email,
role: decoded.role
};
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
error: 'Token expirado. Por favor inicia sesión nuevamente.'
});
}
return res.status(401).json({
error: 'Token inválido.'
});
}
};
module.exports = authMiddleware;

View File

@@ -0,0 +1,51 @@
const errorHandler = (err, req, res, next) => {
console.error('Error:', err);
// Error de validación de Joi
if (err.isJoi) {
return res.status(400).json({
error: 'Error de validación',
details: err.details.map(d => ({
field: d.path.join('.'),
message: d.message
}))
});
}
// Error de Multer (archivos)
if (err.name === 'MulterError') {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({
error: 'El archivo es demasiado grande. Máximo 5MB.'
});
}
return res.status(400).json({
error: 'Error al subir archivo: ' + err.message
});
}
// Error de PostgreSQL
if (err.code && err.code.startsWith('23')) {
if (err.code === '23505') { // Unique violation
return res.status(409).json({
error: 'El registro ya existe.',
detail: err.detail
});
}
if (err.code === '23503') { // Foreign key violation
return res.status(400).json({
error: 'Referencia inválida.',
detail: err.detail
});
}
}
// Error por defecto
const statusCode = err.statusCode || 500;
res.status(statusCode).json({
error: err.message || 'Error interno del servidor',
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
});
};
module.exports = errorHandler;

View File

@@ -0,0 +1,23 @@
const { ROLES } = require('../config/constants');
const roleCheck = (...allowedRoles) => {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({
error: 'No autenticado.'
});
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({
error: 'No tienes permisos para realizar esta acción.',
requiredRoles: allowedRoles,
yourRole: req.user.role
});
}
next();
};
};
module.exports = roleCheck;

View File

@@ -0,0 +1,19 @@
const Joi = require('joi');
const validate = (schema) => {
return (req, res, next) => {
const { error } = schema.validate(req.body, {
abortEarly: false,
stripUnknown: true
});
if (error) {
error.isJoi = true;
return next(error);
}
next();
};
};
module.exports = validate;

View File

@@ -0,0 +1,163 @@
const db = require('../config/db');
class Category {
// Crear categoría
static async create({ nombre, slug, descripcion, parent_id, orden = 0, codigo }) {
// Si no se proporciona código, generar uno automáticamente
const generatedCodigo = codigo || nombre.substring(0, 4).toUpperCase();
const result = await db.query(
`INSERT INTO categories (nombre, slug, descripcion, parent_id, orden, codigo)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[nombre, slug, descripcion, parent_id, orden, generatedCodigo]
);
return result.rows[0];
}
// Buscar por ID
static async findById(id) {
const result = await db.query(
'SELECT * FROM categories WHERE id = $1',
[id]
);
return result.rows[0];
}
// Buscar por slug
static async findBySlug(slug) {
const result = await db.query(
'SELECT * FROM categories WHERE slug = $1',
[slug]
);
return result.rows[0];
}
// Listar todas las categorías
static async list({ is_active, parent_id } = {}) {
let query = 'SELECT * FROM categories WHERE 1=1';
const params = [];
let paramCount = 1;
if (is_active !== undefined) {
query += ` AND is_active = $${paramCount}`;
params.push(is_active);
paramCount++;
}
if (parent_id !== undefined) {
if (parent_id === null) {
query += ' AND parent_id IS NULL';
} else {
query += ` AND parent_id = $${paramCount}`;
params.push(parent_id);
paramCount++;
}
}
query += ' ORDER BY orden ASC, nombre ASC';
const result = await db.query(query, params);
return result.rows;
}
// Obtener árbol de categorías
static async getTree() {
const categories = await this.list();
const buildTree = (parentId = null) => {
return categories
.filter(cat => cat.parent_id === parentId)
.map(cat => ({
...cat,
children: buildTree(cat.id)
}));
};
return buildTree();
}
// Actualizar categoría
static async update(id, data) {
const fields = [];
const values = [];
let counter = 1;
Object.entries(data).forEach(([key, value]) => {
if (value !== undefined && key !== 'id') {
fields.push(`${key} = $${counter}`);
values.push(value);
counter++;
}
});
if (fields.length === 0) return null;
values.push(id);
const query = `
UPDATE categories
SET ${fields.join(', ')}
WHERE id = $${counter}
RETURNING *
`;
const result = await db.query(query, values);
return result.rows[0];
}
// Eliminar categoría
static async delete(id) {
// Verificar si tiene productos
const productsCheck = await db.query(
'SELECT COUNT(*) FROM products WHERE category_id = $1',
[id]
);
if (parseInt(productsCheck.rows[0].count) > 0) {
throw new Error('No se puede eliminar una categoría con productos asociados');
}
// Verificar si tiene subcategorías
const subcatsCheck = await db.query(
'SELECT COUNT(*) FROM categories WHERE parent_id = $1',
[id]
);
if (parseInt(subcatsCheck.rows[0].count) > 0) {
throw new Error('No se puede eliminar una categoría con subcategorías');
}
const result = await db.query(
'DELETE FROM categories WHERE id = $1 RETURNING id',
[id]
);
return result.rows[0];
}
// Obtener productos de la categoría
static async getProducts(categoryId, { limit = 20, offset = 0 } = {}) {
const result = await db.query(
`SELECT p.*, i.stock_actual, i.stock_reservado,
(i.stock_actual - i.stock_reservado) as stock_disponible
FROM products p
LEFT JOIN inventory i ON p.id = i.product_id
WHERE p.category_id = $1 AND p.is_active = true
ORDER BY p.nombre ASC
LIMIT $2 OFFSET $3`,
[categoryId, limit, offset]
);
const countResult = await db.query(
'SELECT COUNT(*) FROM products WHERE category_id = $1 AND is_active = true',
[categoryId]
);
return {
products: result.rows,
total: parseInt(countResult.rows[0].count)
};
}
}
module.exports = Category;

View File

@@ -0,0 +1,263 @@
const db = require('../config/db');
const { INVENTORY_MOVEMENT_TYPES } = require('../config/constants');
class Inventory {
// Obtener inventario de un producto
static async getByProductId(productId) {
const result = await db.query(
`SELECT i.*, p.nombre as product_nombre, p.sku
FROM inventory i
JOIN products p ON i.product_id = p.id
WHERE i.product_id = $1`,
[productId]
);
return result.rows[0];
}
// Ajustar stock (entrada, salida, ajuste)
static async adjustStock(productId, cantidad, tipo, referencia = null, notas = null, createdBy = null) {
const client = await db.connect();
try {
await client.query('BEGIN');
// Obtener stock actual
const inventoryResult = await client.query(
'SELECT stock_actual FROM inventory WHERE product_id = $1 FOR UPDATE',
[productId]
);
if (inventoryResult.rows.length === 0) {
throw new Error('Producto no tiene registro de inventario');
}
const stockAnterior = inventoryResult.rows[0].stock_actual;
let stockNuevo;
// Calcular nuevo stock según tipo
switch (tipo) {
case INVENTORY_MOVEMENT_TYPES.ENTRADA:
stockNuevo = stockAnterior + cantidad;
break;
case INVENTORY_MOVEMENT_TYPES.SALIDA:
stockNuevo = stockAnterior - cantidad;
if (stockNuevo < 0) {
throw new Error('Stock insuficiente');
}
break;
case INVENTORY_MOVEMENT_TYPES.AJUSTE:
stockNuevo = cantidad; // En ajuste, la cantidad es el valor final
break;
default:
throw new Error('Tipo de movimiento inválido');
}
// Actualizar inventory
await client.query(
'UPDATE inventory SET stock_actual = $1 WHERE product_id = $2',
[stockNuevo, productId]
);
// Registrar movimiento
const movementResult = await client.query(
`INSERT INTO inventory_movements
(product_id, tipo, cantidad, cantidad_anterior, cantidad_nueva, referencia, notas, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[productId, tipo, cantidad, stockAnterior, stockNuevo, referencia, notas, createdBy]
);
await client.query('COMMIT');
return {
inventory: { stock_actual: stockNuevo, stock_anterior: stockAnterior },
movement: movementResult.rows[0]
};
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
// Reservar stock (para carrito/checkout)
static async reserveStock(productId, cantidad) {
const client = await db.connect();
try {
await client.query('BEGIN');
const inventoryResult = await client.query(
'SELECT stock_actual, stock_reservado FROM inventory WHERE product_id = $1 FOR UPDATE',
[productId]
);
if (inventoryResult.rows.length === 0) {
throw new Error('Producto no encontrado');
}
const { stock_actual, stock_reservado } = inventoryResult.rows[0];
const stockDisponible = stock_actual - stock_reservado;
if (stockDisponible < cantidad) {
throw new Error(`Stock insuficiente. Disponible: ${stockDisponible}`);
}
// Incrementar stock reservado
await client.query(
'UPDATE inventory SET stock_reservado = stock_reservado + $1 WHERE product_id = $2',
[cantidad, productId]
);
// Registrar movimiento
await client.query(
`INSERT INTO inventory_movements
(product_id, tipo, cantidad, cantidad_anterior, cantidad_nueva)
VALUES ($1, 'reserva', $2, $3, $4)`,
[productId, cantidad, stock_reservado, stock_reservado + cantidad]
);
await client.query('COMMIT');
return true;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
// Liberar stock reservado
static async releaseStock(productId, cantidad) {
const client = await db.connect();
try {
await client.query('BEGIN');
const inventoryResult = await client.query(
'SELECT stock_reservado FROM inventory WHERE product_id = $1 FOR UPDATE',
[productId]
);
if (inventoryResult.rows.length === 0) {
throw new Error('Producto no encontrado');
}
const stock_reservado = inventoryResult.rows[0].stock_reservado;
// Disminuir stock reservado
await client.query(
'UPDATE inventory SET stock_reservado = GREATEST(stock_reservado - $1, 0) WHERE product_id = $2',
[cantidad, productId]
);
// Registrar movimiento
await client.query(
`INSERT INTO inventory_movements
(product_id, tipo, cantidad, cantidad_anterior, cantidad_nueva)
VALUES ($1, 'liberacion', $2, $3, $4)`,
[productId, cantidad, stock_reservado, Math.max(stock_reservado - cantidad, 0)]
);
await client.query('COMMIT');
return true;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
// Confirmar venta (reducir stock actual y reservado)
static async confirmSale(productId, cantidad, referencia = null) {
const client = await db.connect();
try {
await client.query('BEGIN');
const inventoryResult = await client.query(
'SELECT stock_actual, stock_reservado FROM inventory WHERE product_id = $1 FOR UPDATE',
[productId]
);
const { stock_actual, stock_reservado } = inventoryResult.rows[0];
// Reducir stock actual y reservado
await client.query(
`UPDATE inventory
SET stock_actual = stock_actual - $1,
stock_reservado = GREATEST(stock_reservado - $1, 0)
WHERE product_id = $2`,
[cantidad, productId]
);
// Registrar movimiento
await client.query(
`INSERT INTO inventory_movements
(product_id, tipo, cantidad, cantidad_anterior, cantidad_nueva, referencia)
VALUES ($1, 'salida', $2, $3, $4, $5)`,
[productId, cantidad, stock_actual, stock_actual - cantidad, referencia]
);
await client.query('COMMIT');
return true;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
// Obtener movimientos de inventario
static async getMovements(productId, { limit = 50, offset = 0 } = {}) {
const result = await db.query(
`SELECT m.*, u.nombre as created_by_nombre
FROM inventory_movements m
LEFT JOIN users u ON m.created_by = u.id
WHERE m.product_id = $1
ORDER BY m.created_at DESC
LIMIT $2 OFFSET $3`,
[productId, limit, offset]
);
const countResult = await db.query(
'SELECT COUNT(*) FROM inventory_movements WHERE product_id = $1',
[productId]
);
return {
movements: result.rows,
total: parseInt(countResult.rows[0].count)
};
}
// Listar productos con stock bajo
static async getLowStock(threshold = null) {
const query = threshold
? 'SELECT * FROM inventory WHERE stock_actual <= $1 ORDER BY stock_actual ASC'
: 'SELECT * FROM inventory WHERE stock_actual <= stock_minimo ORDER BY stock_actual ASC';
const params = threshold ? [threshold] : [];
const result = await db.query(query, params);
return result.rows;
}
// Actualizar stock mínimo
static async updateMinStock(productId, stockMinimo) {
const result = await db.query(
'UPDATE inventory SET stock_minimo = $1 WHERE product_id = $2 RETURNING *',
[stockMinimo, productId]
);
return result.rows[0];
}
}
module.exports = Inventory;

266
backend/src/models/Order.js Normal file
View File

@@ -0,0 +1,266 @@
const db = require('../config/db');
class Order {
// Crear orden
static async create(orderData) {
const {
user_id, channel_id, items, address_id,
notas_cliente, descuento = 0
} = orderData;
const client = await db.connect();
try {
await client.query('BEGIN');
// Generar número de orden
const orderNumberResult = await client.query('SELECT generate_order_number() as number');
const orderNumber = orderNumberResult.rows[0].number;
// Calcular subtotal
let subtotal = 0;
for (const item of items) {
const productResult = await client.query(
'SELECT precio_base FROM products WHERE id = $1',
[item.product_id]
);
const precio = productResult.rows[0].precio_base;
subtotal += precio * item.cantidad;
}
// Por ahora, costo_envio = 0 (se calculará después con BlueExpress)
const costo_envio = 0;
const total = subtotal - descuento + costo_envio;
// Crear orden
const orderResult = await client.query(
`INSERT INTO orders (order_number, user_id, channel_id, estado, subtotal, descuento, costo_envio, total, notas_cliente)
VALUES ($1, $2, $3, 'pendiente_pago', $4, $5, $6, $7, $8)
RETURNING *`,
[orderNumber, user_id, channel_id, subtotal, descuento, costo_envio, total, notas_cliente]
);
const order = orderResult.rows[0];
// Crear items de la orden
for (const item of items) {
const productResult = await client.query(
'SELECT precio_base FROM products WHERE id = $1',
[item.product_id]
);
const precio_unitario = productResult.rows[0].precio_base;
const item_subtotal = precio_unitario * item.cantidad;
await client.query(
`INSERT INTO order_items (order_id, product_id, cantidad, precio_unitario, subtotal)
VALUES ($1, $2, $3, $4, $5)`,
[order.id, item.product_id, item.cantidad, precio_unitario, item_subtotal]
);
}
// Crear registro de envío
if (address_id) {
await client.query(
`INSERT INTO shipments (order_id, direccion_id, estado)
VALUES ($1, $2, 'pendiente')`,
[order.id, address_id]
);
}
await client.query('COMMIT');
return order;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
// Buscar orden por ID
static async findById(orderId) {
const result = await db.query(
`SELECT o.*,
u.nombre || ' ' || COALESCE(u.apellido, '') as cliente_nombre,
u.email as cliente_email,
ch.nombre as canal
FROM orders o
JOIN users u ON o.user_id = u.id
JOIN channels ch ON o.channel_id = ch.id
WHERE o.id = $1`,
[orderId]
);
return result.rows[0];
}
// Buscar por número de orden
static async findByOrderNumber(orderNumber) {
const result = await db.query(
`SELECT o.*,
u.nombre || ' ' || COALESCE(u.apellido, '') as cliente_nombre,
u.email as cliente_email
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.order_number = $1`,
[orderNumber]
);
return result.rows[0];
}
// Obtener items de la orden
static async getItems(orderId) {
const result = await db.query(
`SELECT oi.*, p.nombre as producto_nombre, p.sku
FROM order_items oi
JOIN products p ON oi.product_id = p.id
WHERE oi.order_id = $1`,
[orderId]
);
return result.rows;
}
// Listar órdenes con filtros
static async list({
user_id, estado, channel_id,
fecha_desde, fecha_hasta,
limit = 20, offset = 0
} = {}) {
let query = `
SELECT o.*,
u.nombre || ' ' || COALESCE(u.apellido, '') as cliente_nombre,
ch.nombre as canal,
p.estado as estado_pago
FROM orders o
JOIN users u ON o.user_id = u.id
JOIN channels ch ON o.channel_id = ch.id
LEFT JOIN payments p ON o.id = p.order_id
WHERE 1=1
`;
const params = [];
let paramCount = 1;
if (user_id) {
query += ` AND o.user_id = $${paramCount}`;
params.push(user_id);
paramCount++;
}
if (estado) {
query += ` AND o.estado = $${paramCount}`;
params.push(estado);
paramCount++;
}
if (channel_id) {
query += ` AND o.channel_id = $${paramCount}`;
params.push(channel_id);
paramCount++;
}
if (fecha_desde) {
query += ` AND o.created_at >= $${paramCount}`;
params.push(fecha_desde);
paramCount++;
}
if (fecha_hasta) {
query += ` AND o.created_at <= $${paramCount}`;
params.push(fecha_hasta);
paramCount++;
}
query += ` ORDER BY o.created_at DESC LIMIT $${paramCount} OFFSET $${paramCount + 1}`;
params.push(limit, offset);
const result = await db.query(query, params);
// Contar total
const countParams = params.slice(0, -2);
let countQuery = 'SELECT COUNT(*) FROM orders o WHERE 1=1';
if (user_id) countQuery += ' AND o.user_id = $1';
if (estado) countQuery += ` AND o.estado = $${user_id ? 2 : 1}`;
const countResult = await db.query(countQuery, countParams);
return {
orders: result.rows,
total: parseInt(countResult.rows[0].count)
};
}
// Actualizar estado de la orden
static async updateStatus(orderId, nuevoEstado) {
const result = await db.query(
'UPDATE orders SET estado = $1 WHERE id = $2 RETURNING *',
[nuevoEstado, orderId]
);
return result.rows[0];
}
// Actualizar costo de envío
static async updateShippingCost(orderId, costoEnvio) {
const result = await db.query(
`UPDATE orders
SET costo_envio = $1,
total = subtotal - descuento + $1
WHERE id = $2
RETURNING *`,
[costoEnvio, orderId]
);
return result.rows[0];
}
// Cancelar orden
static async cancel(orderId) {
const client = await db.connect();
try {
await client.query('BEGIN');
// Obtener items para liberar stock
const itemsResult = await client.query(
'SELECT product_id, cantidad FROM order_items WHERE order_id = $1',
[orderId]
);
// Liberar stock reservado
for (const item of itemsResult.rows) {
await client.query(
'UPDATE inventory SET stock_reservado = GREATEST(stock_reservado - $1, 0) WHERE product_id = $2',
[item.cantidad, item.product_id]
);
}
// Actualizar estado
const orderResult = await client.query(
'UPDATE orders SET estado = \'cancelada\' WHERE id = $1 RETURNING *',
[orderId]
);
await client.query('COMMIT');
return orderResult.rows[0];
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
// Obtener despacho de la orden
static async getShipment(orderId) {
const result = await db.query(
`SELECT s.*, a.direccion, a.comuna, a.ciudad, a.region
FROM shipments s
LEFT JOIN user_addresses a ON s.direccion_id = a.id
WHERE s.order_id = $1`,
[orderId]
);
return result.rows[0];
}
}
module.exports = Order;

View File

@@ -0,0 +1,283 @@
const db = require('../config/db');
class Product {
// Crear producto
static async create(productData) {
const {
sku, nombre, descripcion, precio_base, peso_gramos,
alto_cm, ancho_cm, largo_cm, category_id, stock_inicial = 0
} = productData;
const client = await db.connect();
try {
await client.query('BEGIN');
// Crear producto
const productResult = await client.query(
`INSERT INTO products (sku, nombre, descripcion, precio_base, peso_gramos, alto_cm, ancho_cm, largo_cm, category_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *`,
[sku, nombre, descripcion, precio_base, peso_gramos, alto_cm, ancho_cm, largo_cm, category_id]
);
const product = productResult.rows[0];
// Crear registro de inventario
await client.query(
`INSERT INTO inventory (product_id, stock_actual)
VALUES ($1, $2)`,
[product.id, stock_inicial]
);
// Registrar movimiento inicial si hay stock
if (stock_inicial > 0) {
await client.query(
`INSERT INTO inventory_movements (product_id, tipo, cantidad, cantidad_anterior, cantidad_nueva, referencia)
VALUES ($1, 'entrada', $2, 0, $2, 'Stock inicial')`,
[product.id, stock_inicial]
);
}
await client.query('COMMIT');
return product;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
// Buscar por ID con stock
static async findById(id) {
// Obtener producto con stock
const result = await db.query(
`SELECT p.*, i.stock_actual, i.stock_reservado, i.stock_minimo,
(i.stock_actual - i.stock_reservado) as stock_disponible,
c.nombre as categoria_nombre
FROM products p
LEFT JOIN inventory i ON p.id = i.product_id
LEFT JOIN categories c ON p.category_id = c.id
WHERE p.id = $1`,
[id]
);
if (result.rows.length === 0) return null;
const product = result.rows[0];
// Cargar imágenes
const imagesResult = await db.query(
`SELECT * FROM product_images
WHERE product_id = $1
ORDER BY is_primary DESC, orden ASC`,
[id]
);
product.images = imagesResult.rows;
return product;
}
// Buscar por SKU
static async findBySku(sku) {
const result = await db.query(
`SELECT p.*, i.stock_actual, i.stock_reservado, i.stock_minimo,
(i.stock_actual - i.stock_reservado) as stock_disponible
FROM products p
LEFT JOIN inventory i ON p.id = i.product_id
WHERE p.sku = $1`,
[sku]
);
return result.rows[0];
}
// Listar productos con filtros y búsqueda
static async list({
search, category_id, is_active,
min_price, max_price,
limit = 20, offset = 0
} = {}) {
let query = `
SELECT p.*, i.stock_actual, i.stock_reservado,
(i.stock_actual - i.stock_reservado) as stock_disponible,
c.nombre as categoria_nombre
FROM products p
LEFT JOIN inventory i ON p.id = i.product_id
LEFT JOIN categories c ON p.category_id = c.id
WHERE 1=1
`;
const params = [];
let paramCount = 1;
if (search) {
query += ` AND (p.nombre ILIKE $${paramCount} OR p.descripcion ILIKE $${paramCount} OR p.sku ILIKE $${paramCount})`;
params.push(`%${search}%`);
paramCount++;
}
if (category_id) {
query += ` AND p.category_id = $${paramCount}`;
params.push(category_id);
paramCount++;
}
if (is_active !== undefined) {
query += ` AND p.is_active = $${paramCount}`;
params.push(is_active);
paramCount++;
}
if (min_price) {
query += ` AND p.precio_base >= $${paramCount}`;
params.push(min_price);
paramCount++;
}
if (max_price) {
query += ` AND p.precio_base <= $${paramCount}`;
params.push(max_price);
paramCount++;
}
query += ` ORDER BY p.created_at DESC LIMIT $${paramCount} OFFSET $${paramCount + 1}`;
params.push(limit, offset);
const result = await db.query(query, params);
// Cargar imágenes para cada producto
const productIds = result.rows.map(p => p.id);
let imagesMap = {};
if (productIds.length > 0) {
const imagesResult = await db.query(
`SELECT * FROM product_images
WHERE product_id = ANY($1::int[])
ORDER BY is_primary DESC, orden ASC`,
[productIds]
);
// Agrupar imágenes por product_id
imagesResult.rows.forEach(img => {
if (!imagesMap[img.product_id]) {
imagesMap[img.product_id] = [];
}
imagesMap[img.product_id].push(img);
});
}
// Agregar imágenes a cada producto
const products = result.rows.map(p => ({
...p,
images: imagesMap[p.id] || []
}));
// Contar total
let countQuery = 'SELECT COUNT(*) FROM products p WHERE 1=1';
const countParams = params.slice(0, -2);
if (search) countQuery += ' AND (p.nombre ILIKE $1 OR p.descripcion ILIKE $1 OR p.sku ILIKE $1)';
if (category_id) countQuery += ` AND p.category_id = $${search ? 2 : 1}`;
if (is_active !== undefined) countQuery += ` AND p.is_active = $${countParams.length + 1}`;
const countResult = await db.query(countQuery, countParams);
return {
products: products,
total: parseInt(countResult.rows[0].count)
};
}
// Actualizar producto
static async update(id, data) {
const fields = [];
const values = [];
let counter = 1;
const allowedFields = [
'nombre', 'descripcion', 'precio_base', 'peso_gramos',
'alto_cm', 'ancho_cm', 'largo_cm', 'category_id', 'is_active'
];
Object.entries(data).forEach(([key, value]) => {
if (value !== undefined && allowedFields.includes(key)) {
fields.push(`${key} = $${counter}`);
values.push(value);
counter++;
}
});
if (fields.length === 0) return null;
values.push(id);
const query = `
UPDATE products
SET ${fields.join(', ')}
WHERE id = $${counter}
RETURNING *
`;
const result = await db.query(query, values);
return result.rows[0];
}
// Eliminar producto (soft delete)
static async delete(id) {
const result = await db.query(
'UPDATE products SET is_active = false WHERE id = $1 RETURNING id',
[id]
);
return result.rows[0];
}
// Obtener imágenes del producto
static async getImages(productId) {
const result = await db.query(
'SELECT * FROM product_images WHERE product_id = $1 ORDER BY is_primary DESC, orden ASC',
[productId]
);
return result.rows;
}
// Agregar imagen
static async addImage(productId, { url, alt_text, orden = 0, is_primary = false }) {
// Si es imagen principal, quitar el flag de las demás
if (is_primary) {
await db.query(
'UPDATE product_images SET is_primary = false WHERE product_id = $1',
[productId]
);
}
const result = await db.query(
`INSERT INTO product_images (product_id, url, alt_text, orden, is_primary)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[productId, url, alt_text, orden, is_primary]
);
return result.rows[0];
}
// Eliminar imagen
static async deleteImage(imageId) {
const result = await db.query(
'DELETE FROM product_images WHERE id = $1 RETURNING *',
[imageId]
);
return result.rows[0];
}
// Obtener stock
static async getStock(productId) {
const result = await db.query(
'SELECT * FROM inventory WHERE product_id = $1',
[productId]
);
return result.rows[0];
}
}
module.exports = Product;

147
backend/src/models/User.js Normal file
View File

@@ -0,0 +1,147 @@
const db = require('../config/db');
const bcrypt = require('bcryptjs');
class User {
// Crear usuario
static async create({ email, password, nombre, apellido, telefono, role = 'cliente' }) {
const passwordHash = await bcrypt.hash(password, 10);
const result = await db.query(
`INSERT INTO users (email, password_hash, nombre, apellido, telefono, role)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, email, nombre, apellido, telefono, role, is_active, created_at`,
[email, passwordHash, nombre, apellido, telefono, role]
);
return result.rows[0];
}
// Buscar por email
static async findByEmail(email) {
const result = await db.query(
'SELECT * FROM users WHERE email = $1',
[email]
);
return result.rows[0];
}
// Buscar por ID
static async findById(id) {
const result = await db.query(
'SELECT id, email, nombre, apellido, telefono, role, is_active, created_at FROM users WHERE id = $1',
[id]
);
return result.rows[0];
}
// Verificar contraseña
static async verifyPassword(plainPassword, hashedPassword) {
return await bcrypt.compare(plainPassword, hashedPassword);
}
// Actualizar usuario
static async update(id, data) {
const fields = [];
const values = [];
let counter = 1;
Object.entries(data).forEach(([key, value]) => {
if (value !== undefined && key !== 'password_hash' && key !== 'id') {
fields.push(`${key} = $${counter}`);
values.push(value);
counter++;
}
});
if (fields.length === 0) return null;
values.push(id);
const query = `
UPDATE users
SET ${fields.join(', ')}
WHERE id = $${counter}
RETURNING id, email, nombre, apellido, telefono, role, is_active, updated_at
`;
const result = await db.query(query, values);
return result.rows[0];
}
// Listar usuarios con filtros
static async list({ role, is_active, limit = 20, offset = 0 }) {
let query = 'SELECT id, email, nombre, apellido, telefono, role, is_active, created_at FROM users WHERE 1=1';
const params = [];
let paramCount = 1;
if (role) {
query += ` AND role = $${paramCount}`;
params.push(role);
paramCount++;
}
if (is_active !== undefined) {
query += ` AND is_active = $${paramCount}`;
params.push(is_active);
paramCount++;
}
query += ` ORDER BY created_at DESC LIMIT $${paramCount} OFFSET $${paramCount + 1}`;
params.push(limit, offset);
const result = await db.query(query, params);
// Contar total
const countResult = await db.query('SELECT COUNT(*) FROM users WHERE 1=1' +
(role ? ' AND role = $1' : '') +
(is_active !== undefined ? ` AND is_active = $${role ? 2 : 1}` : ''),
params.slice(0, -2)
);
return {
users: result.rows,
total: parseInt(countResult.rows[0].count)
};
}
// Eliminar usuario (soft delete)
static async delete(id) {
const result = await db.query(
'UPDATE users SET is_active = false WHERE id = $1 RETURNING id',
[id]
);
return result.rows[0];
}
// Direcciones del usuario
static async getAddresses(userId) {
const result = await db.query(
'SELECT * FROM user_addresses WHERE user_id = $1 ORDER BY is_default DESC, created_at DESC',
[userId]
);
return result.rows;
}
// Agregar dirección
static async addAddress(userId, addressData) {
const { direccion, comuna, ciudad, region, codigo_postal, referencia, is_default } = addressData;
// Si es dirección por defecto, quitar el default de las demás
if (is_default) {
await db.query(
'UPDATE user_addresses SET is_default = false WHERE user_id = $1',
[userId]
);
}
const result = await db.query(
`INSERT INTO user_addresses (user_id, direccion, comuna, ciudad, region, codigo_postal, referencia, is_default)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[userId, direccion, comuna, ciudad, region, codigo_postal, referencia, is_default]
);
return result.rows[0];
}
}
module.exports = User;

View File

@@ -0,0 +1,18 @@
const express = require('express');
const router = express.Router();
const authController = require('../controllers/authController');
const authMiddleware = require('../middlewares/auth');
const validate = require('../middlewares/validation');
const { registerSchema, loginSchema, createAddressSchema } = require('../utils/validators');
// Rutas públicas
router.post('/register', validate(registerSchema), authController.register);
router.post('/login', validate(loginSchema), authController.login);
// Rutas protegidas
router.get('/profile', authMiddleware, authController.getProfile);
router.put('/profile', authMiddleware, authController.updateProfile);
router.get('/addresses', authMiddleware, authController.getAddresses);
router.post('/addresses', authMiddleware, validate(createAddressSchema), authController.addAddress);
module.exports = router;

View File

@@ -0,0 +1,17 @@
const express = require('express');
const router = express.Router();
const cartController = require('../controllers/cartController');
const authMiddleware = require('../middlewares/auth');
const validate = require('../middlewares/validation');
const { addToCartSchema, updateCartItemSchema } = require('../utils/validators');
// Todas las rutas requieren autenticación
router.use(authMiddleware);
router.get('/', cartController.getCart);
router.post('/items', validate(addToCartSchema), cartController.addToCart);
router.put('/items/:itemId', validate(updateCartItemSchema), cartController.updateCartItem);
router.delete('/items/:itemId', cartController.removeFromCart);
router.delete('/clear', cartController.clearCart);
module.exports = router;

View File

@@ -0,0 +1,35 @@
const express = require('express');
const router = express.Router();
const categoryController = require('../controllers/categoryController');
const authMiddleware = require('../middlewares/auth');
const roleCheck = require('../middlewares/roleCheck');
const validate = require('../middlewares/validation');
const { createCategorySchema } = require('../utils/validators');
// Rutas públicas
router.get('/', categoryController.listCategories);
router.get('/tree', categoryController.getCategoryTree);
router.get('/:id', categoryController.getCategory);
router.get('/:id/products', categoryController.getCategoryProducts);
// Rutas protegidas (solo admin)
router.post('/',
authMiddleware,
roleCheck('admin'),
validate(createCategorySchema),
categoryController.createCategory
);
router.put('/:id',
authMiddleware,
roleCheck('admin'),
categoryController.updateCategory
);
router.delete('/:id',
authMiddleware,
roleCheck('admin'),
categoryController.deleteCategory
);
module.exports = router;

View File

@@ -0,0 +1,19 @@
const express = require('express');
const router = express.Router();
const inventoryController = require('../controllers/inventoryController');
const authMiddleware = require('../middlewares/auth');
const roleCheck = require('../middlewares/roleCheck');
const validate = require('../middlewares/validation');
const { adjustStockSchema } = require('../utils/validators');
// Todas las rutas requieren autenticación de admin
router.use(authMiddleware);
router.use(roleCheck('admin'));
router.get('/product/:productId', inventoryController.getInventory);
router.post('/adjust', validate(adjustStockSchema), inventoryController.adjustStock);
router.get('/product/:productId/movements', inventoryController.getMovements);
router.get('/low-stock', inventoryController.getLowStock);
router.put('/product/:productId/min-stock', inventoryController.updateMinStock);
module.exports = router;

View File

@@ -0,0 +1,23 @@
const express = require('express');
const router = express.Router();
const orderController = require('../controllers/orderController');
const authMiddleware = require('../middlewares/auth');
const roleCheck = require('../middlewares/roleCheck');
const validate = require('../middlewares/validation');
const { createOrderSchema } = require('../utils/validators');
// Todas las rutas requieren autenticación
router.use(authMiddleware);
// Rutas de cliente
router.get('/my-orders', orderController.getMyOrders);
router.post('/', validate(createOrderSchema), orderController.createOrder);
router.get('/:id', orderController.getOrder);
router.get('/number/:orderNumber', orderController.getOrderByNumber);
router.post('/:id/cancel', orderController.cancelOrder);
// Rutas de admin
router.get('/', roleCheck('admin'), orderController.listOrders);
router.put('/:id/status', roleCheck('admin'), orderController.updateOrderStatus);
module.exports = router;

View File

@@ -0,0 +1,50 @@
const express = require('express');
const router = express.Router();
const productController = require('../controllers/productController');
const authMiddleware = require('../middlewares/auth');
const roleCheck = require('../middlewares/roleCheck');
const validate = require('../middlewares/validation');
const upload = require('../config/multer');
const { createProductSchema, updateProductSchema } = require('../utils/validators');
// Rutas públicas
router.get('/', productController.listProducts);
router.get('/:id', productController.getProduct);
router.get('/:id/stock', productController.getStock);
// Rutas protegidas (solo admin)
router.post('/',
authMiddleware,
roleCheck('admin'),
validate(createProductSchema),
productController.createProduct
);
router.put('/:id',
authMiddleware,
roleCheck('admin'),
validate(updateProductSchema),
productController.updateProduct
);
router.delete('/:id',
authMiddleware,
roleCheck('admin'),
productController.deleteProduct
);
// Imágenes
router.post('/:id/images',
authMiddleware,
roleCheck('admin'),
upload.single('image'),
productController.addImage
);
router.delete('/:id/images/:imageId',
authMiddleware,
roleCheck('admin'),
productController.deleteImage
);
module.exports = router;

View File

@@ -0,0 +1,130 @@
const express = require('express');
const router = express.Router();
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const db = require('../config/db');
// Configuración de multer para subir imágenes
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const uploadDir = path.join(__dirname, '../public/uploads/products');
// Crear directorio si no existe
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, uniqueSuffix + path.extname(file.originalname));
}
});
const upload = multer({
storage: storage,
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB máximo
fileFilter: (req, file, cb) => {
const allowedTypes = /jpeg|jpg|png|gif|webp/;
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const mimetype = allowedTypes.test(file.mimetype);
if (mimetype && extname) {
return cb(null, true);
} else {
cb(new Error('Solo se permiten imágenes (jpeg, jpg, png, gif, webp)'));
}
}
});
// POST /api/upload/product/:productId - Subir imagen de producto
router.post('/product/:productId', upload.single('image'), async (req, res) => {
try {
const { productId } = req.params;
const { alt_text, is_primary } = req.body;
if (!req.file) {
return res.status(400).json({ error: 'No se recibió ninguna imagen' });
}
// Verificar que el producto existe
const productCheck = await db.query('SELECT id FROM products WHERE id = $1', [productId]);
if (productCheck.rows.length === 0) {
// Eliminar archivo subido
fs.unlinkSync(req.file.path);
return res.status(404).json({ error: 'Producto no encontrado' });
}
// Si es imagen principal, marcar las demás como no principales
if (is_primary === 'true' || is_primary === true) {
await db.query(
'UPDATE product_images SET is_primary = false WHERE product_id = $1',
[productId]
);
}
// Obtener el siguiente orden
const ordenResult = await db.query(
'SELECT COALESCE(MAX(orden), -1) + 1 as next_orden FROM product_images WHERE product_id = $1',
[productId]
);
const nextOrden = ordenResult.rows[0].next_orden;
// Guardar en la base de datos
const imageUrl = `/uploads/products/${req.file.filename}`;
const result = await db.query(
`INSERT INTO product_images (product_id, url, alt_text, orden, is_primary)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[productId, imageUrl, alt_text || req.file.originalname, nextOrden, is_primary === 'true' || is_primary === true]
);
res.status(201).json({
message: 'Imagen subida exitosamente',
image: result.rows[0]
});
} catch (error) {
console.error('Error al subir imagen:', error);
// Eliminar archivo si hubo error
if (req.file) {
fs.unlinkSync(req.file.path);
}
res.status(500).json({ error: 'Error al subir imagen' });
}
});
// DELETE /api/upload/image/:imageId - Eliminar imagen
router.delete('/image/:imageId', async (req, res) => {
try {
const { imageId } = req.params;
// Obtener información de la imagen
const result = await db.query('SELECT * FROM product_images WHERE id = $1', [imageId]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Imagen no encontrada' });
}
const image = result.rows[0];
const filePath = path.join(__dirname, '../public', image.url);
// Eliminar de la base de datos
await db.query('DELETE FROM product_images WHERE id = $1', [imageId]);
// Eliminar archivo físico
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
res.json({ message: 'Imagen eliminada exitosamente' });
} catch (error) {
console.error('Error al eliminar imagen:', error);
res.status(500).json({ error: 'Error al eliminar imagen' });
}
});
module.exports = router;

View File

@@ -0,0 +1,8 @@
const express = require('express');
const router = express.Router();
const userController = require('../controllers/userController');
router.get('/', userController.listUsers);
router.post('/', userController.createUser);
module.exports = router;

View File

@@ -0,0 +1,139 @@
// Formatear precio a formato chileno
const formatPrice = (price) => {
return new Intl.NumberFormat('es-CL', {
style: 'currency',
currency: 'CLP'
}).format(price);
};
// Generar slug desde texto
const generateSlug = (text) => {
return text
.toString()
.toLowerCase()
.trim()
.replace(/[áàäâ]/g, 'a')
.replace(/[éèëê]/g, 'e')
.replace(/[íìïî]/g, 'i')
.replace(/[óòöô]/g, 'o')
.replace(/[úùüû]/g, 'u')
.replace(/ñ/g, 'n')
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
};
// Calcular precio con factor (ej: +10% para MercadoLibre)
const calculateAdjustedPrice = (basePrice, factor = 1.0) => {
return Math.round(basePrice * factor);
};
// Formatear fecha a formato chileno
const formatDate = (date) => {
return new Intl.DateTimeFormat('es-CL', {
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(date));
};
// Generar código aleatorio
const generateCode = (length = 8) => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
};
// Calcular peso total de productos
const calculateTotalWeight = (items) => {
return items.reduce((total, item) => {
const weight = item.peso_gramos || 0;
const quantity = item.cantidad || 1;
return total + (weight * quantity);
}, 0);
};
// Paginación helper
const getPaginationParams = (page = 1, limit = 20) => {
const pageNum = parseInt(page) || 1;
const limitNum = Math.min(parseInt(limit) || 20, 100);
const offset = (pageNum - 1) * limitNum;
return {
page: pageNum,
limit: limitNum,
offset
};
};
// Formatear respuesta paginada
const formatPaginatedResponse = (data, total, page, limit) => {
const totalPages = Math.ceil(total / limit);
return {
data,
pagination: {
currentPage: page,
totalPages,
totalItems: total,
itemsPerPage: limit,
hasNextPage: page < totalPages,
hasPrevPage: page > 1
}
};
};
// Sanitizar datos de usuario (remover password)
const sanitizeUser = (user) => {
const { password_hash, ...sanitized } = user;
return sanitized;
};
// Validar RUT chileno
const validateRUT = (rut) => {
if (!rut || typeof rut !== 'string') return false;
const cleanRut = rut.replace(/[.-]/g, '');
const rutDigits = cleanRut.slice(0, -1);
const verifier = cleanRut.slice(-1).toUpperCase();
let sum = 0;
let multiplier = 2;
for (let i = rutDigits.length - 1; i >= 0; i--) {
sum += parseInt(rutDigits[i]) * multiplier;
multiplier = multiplier === 7 ? 2 : multiplier + 1;
}
const expectedVerifier = 11 - (sum % 11);
const calculatedVerifier = expectedVerifier === 11 ? '0' : expectedVerifier === 10 ? 'K' : expectedVerifier.toString();
return verifier === calculatedVerifier;
};
// Delay helper (para rate limiting)
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// Manejo de errores async
const asyncHandler = (fn) => {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
module.exports = {
formatPrice,
generateSlug,
calculateAdjustedPrice,
formatDate,
generateCode,
calculateTotalWeight,
getPaginationParams,
formatPaginatedResponse,
sanitizeUser,
validateRUT,
delay,
asyncHandler
};

View File

@@ -0,0 +1,40 @@
const winston = require('winston');
const path = require('path');
// Configurar formato
const logFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.splat(),
winston.format.json()
);
// Crear logger
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: logFormat,
defaultMeta: { service: 'ofertaweb-api' },
transports: [
// Escribir logs de error a archivo
new winston.transports.File({
filename: path.join(__dirname, '../logs/error.log'),
level: 'error'
}),
// Escribir todos los logs a archivo
new winston.transports.File({
filename: path.join(__dirname, '../logs/combined.log')
})
]
});
// En desarrollo, también mostrar en consola
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
}));
}
module.exports = logger;

View File

@@ -0,0 +1,131 @@
const Joi = require('joi');
// Validador de email
const emailSchema = Joi.string().email().required();
// Validador de password (mínimo 6 caracteres)
const passwordSchema = Joi.string().min(6).required();
// Validador de SKU (alfanumérico con guiones)
const skuSchema = Joi.string().pattern(/^[A-Za-z0-9-]+$/).required();
// Validador de precio
const priceSchema = Joi.number().positive().precision(2).required();
// Validador de cantidad
const quantitySchema = Joi.number().integer().positive().required();
// Validador de paginación
const paginationSchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20)
});
// Schemas de validación completos
// Auth
const registerSchema = Joi.object({
email: emailSchema,
password: passwordSchema,
nombre: Joi.string().min(2).max(100).required(),
apellido: Joi.string().max(100).optional(),
telefono: Joi.string().pattern(/^[0-9+\-\s()]+$/).optional()
});
const loginSchema = Joi.object({
email: emailSchema,
password: Joi.string().required()
});
// Productos
const createProductSchema = Joi.object({
sku: skuSchema,
nombre: Joi.string().min(3).max(255).required(),
descripcion: Joi.string().allow('').optional(),
precio_base: priceSchema,
peso_gramos: Joi.number().integer().positive().optional(),
alto_cm: Joi.number().positive().optional(),
ancho_cm: Joi.number().positive().optional(),
largo_cm: Joi.number().positive().optional(),
category_id: Joi.number().integer().positive().optional(),
stock_inicial: Joi.number().integer().min(0).default(0)
});
const updateProductSchema = Joi.object({
nombre: Joi.string().min(3).max(255).optional(),
descripcion: Joi.string().allow('').optional(),
precio_base: Joi.number().positive().precision(2).optional(),
peso_gramos: Joi.number().integer().positive().optional(),
alto_cm: Joi.number().positive().optional(),
ancho_cm: Joi.number().positive().optional(),
largo_cm: Joi.number().positive().optional(),
category_id: Joi.number().integer().positive().allow(null).optional(),
is_active: Joi.boolean().optional()
});
// Categorías
const createCategorySchema = Joi.object({
nombre: Joi.string().min(2).max(100).required(),
slug: Joi.string().pattern(/^[a-z0-9-]+$/).required(),
descripcion: Joi.string().allow('').optional(),
parent_id: Joi.number().integer().positive().allow(null).optional(),
orden: Joi.number().integer().default(0)
});
// Carrito
const addToCartSchema = Joi.object({
product_id: Joi.number().integer().positive().required(),
cantidad: quantitySchema
});
const updateCartItemSchema = Joi.object({
cantidad: quantitySchema
});
// Dirección
const createAddressSchema = Joi.object({
direccion: Joi.string().required(),
comuna: Joi.string().required(),
ciudad: Joi.string().required(),
region: Joi.string().required(),
codigo_postal: Joi.string().optional(),
referencia: Joi.string().allow('').optional(),
is_default: Joi.boolean().default(false)
});
// Orden
const createOrderSchema = Joi.object({
address_id: Joi.number().integer().positive().required(),
notas_cliente: Joi.string().allow('').optional(),
metodo_pago: Joi.string().valid('webpay', 'transferencia', 'mercadopago', 'efectivo').required()
});
// Inventario
const adjustStockSchema = Joi.object({
product_id: Joi.number().integer().positive().required(),
cantidad: Joi.number().integer().required(),
tipo: Joi.string().valid('entrada', 'salida', 'ajuste').required(),
notas: Joi.string().allow('').optional()
});
module.exports = {
// Componentes
emailSchema,
passwordSchema,
skuSchema,
priceSchema,
quantitySchema,
paginationSchema,
// Schemas completos
registerSchema,
loginSchema,
createProductSchema,
updateProductSchema,
createCategorySchema,
addToCartSchema,
updateCartItemSchema,
createAddressSchema,
createOrderSchema,
adjustStockSchema
};