🗄️ Rediseño de Base de Datos AgendaLibre (desde CERO)
📊 Estado Actual de la Base de Datos
Resumen:
- 65 tablas totales
- 53 modelos Laravel
- 209 migraciones acumuladas
- Complejidad: Alta (muchas features agregadas incrementalmente)
Tablas Actuales (agrupadas por dominio):
-- CORE (Multi-tenancy)
users (27 campos)
teams (50+ campos)
user_teams (22 campos - pivot con esteroides)
-- BOOKING
appointments (26 campos)
services (27 campos)
reviews
invitations
worker_invitations
-- FINANZAS
payments (24 campos)
cash_registers (15 campos)
cash_movements
transactions
transaction_logs
worker_earnings
worker_payments
worker_payment_bonuses
worker_loans
worker_loan_payments
worker_compensation_configs
commission_configs
payroll_logs
payroll_bonuses
expense_categories
expenses
-- E-COMMERCE
products
cart_items
sales
sale_items
-- MARKETING
promotions
promotion_uses
marketing_campaigns
marketing_automations
upsell_opportunities
affiliates
affiliate_commissions
affiliate_referrals
-- CLIENTES
client_profiles
client_notes
client_reviews
user_favorites
client_tags
client_tag_assignments
client_segments
-- SUSCRIPCIONES
subscriptions
subscription_plans
team_subscriptions
-- CATEGORÍAS/PERFILES
categories
category_modules
category_profiles
-- COMUNICACIÓN
whatsapp_messages
whatsapp_templates
-- SISTEMA
analytics_cache
business_tasks
health_check_result_history_items
security_logs
team_member_history
telescope_* (3 tablas)
worker_time_entries
cache, cache_locks, jobs, job_batches
failed_jobs, sessions
migrations, password_reset_tokens
personal_access_tokens🔴 Problemas del Diseño Actual
1. Tabla user_teams sobrecargada (22 campos)
-- Mezcla múltiples responsabilidades:
- Pivot (user ↔ team) ✅
- Worker schedule ❌ (debería ser tabla separada)
- Worker compensation ❌ (debería ser tabla separada)
- Worker specialties ❌ (debería ser tabla separada)
- Work availability ❌ (debería ser tabla separada)2. Tabla teams sobrecargada (50+ campos)
-- Mezcla:
- Info básica del negocio ✅
- Configuración de booking ❌ (tabla separada)
- Configuración de pagos ❌ (tabla separada)
- Configuración de notificaciones ❌ (JSON gigante)
- Analytics settings ❌ (tabla separada)
- Website settings ❌ (tabla separada)
- Billing info ❌ (tabla separada)3. Tabla users con campos redundantes
-- Mezcla:
- Usuario base ✅
- Cliente profile ❌ (client_classification, etc.)
- Security fields innecesarios en base ❌4. Sistema de finanzas fragmentado
-- 15 tablas de finanzas:
payments, cash_registers, cash_movements, transactions,
transaction_logs, worker_earnings, worker_payments,
worker_payment_bonuses, worker_loans, worker_loan_payments,
worker_compensation_configs, commission_configs,
payroll_logs, payroll_bonuses, expenses, expense_categories
-- ⚠️ Demasiada fragmentación
-- ⚠️ Difícil de mantener consistencia5. Appointments con demasiada lógica de negocio
-- 26 campos mezclando:
- Datos de cita ✅
- Datos de cliente guest (client_name, client_email, client_phone) ❌
- Features premium (promotion_id, waiting_list_id, location_id) ❌
- Category-specific (appointment_type, meeting_url, category_data) ❌6. Falta Row-Level Security (RLS)
-- ❌ Multi-tenancy solo con scopes Laravel
-- ❌ Riesgo de data leaks si olvidas scope
-- ✅ Debería usar PostgreSQL RLS desde día 1✅ Diseño Optimizado desde CERO
Principios del rediseño:
- Single Responsibility: Cada tabla = 1 responsabilidad
- Normalización: 3NF mínimo
- RLS First: Seguridad a nivel DB
- JSON solo cuando es flexible: No abusar de JSONB
- Índices desde día 1: Performance crítica
🎯 Schema Optimizado (38 tablas core)
GRUPO 1: Core / Multi-Tenancy (4 tablas)
1. users (simplificado a 15 campos)
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
phone VARCHAR(50),
avatar_url TEXT,
date_of_birth DATE,
gender VARCHAR(20),
timezone VARCHAR(50) DEFAULT 'America/Santiago',
locale VARCHAR(5) DEFAULT 'es',
email_verified_at TIMESTAMP,
phone_verified_at TIMESTAMP,
is_active BOOLEAN DEFAULT TRUE,
last_activity TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_phone ON users(phone);
CREATE INDEX idx_users_last_activity ON users(last_activity);Campos removidos (movidos a otras tablas):
client_classification→client_profiles.classificationis_super_admin→rolestable (Spatie Permission)two_factor_*→user_securitytablebio,preferences→user_profilestable (si realmente se necesita)
2. teams (simplificado a 20 campos)
CREATE TABLE teams (
id BIGSERIAL PRIMARY KEY,
slug VARCHAR(100) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
business_type VARCHAR(100), -- 'barberia', 'salon', 'spa', 'gym', 'clinic'
-- Contact
email VARCHAR(255),
phone VARCHAR(50),
website VARCHAR(255),
-- Location
address JSONB, -- {street, number, commune, city, region, country, lat, lng}
-- Branding
logo_url TEXT,
cover_url TEXT,
brand_color VARCHAR(7) DEFAULT '#3B82F6',
-- Status
status VARCHAR(50) DEFAULT 'active', -- active, suspended, closed
is_verified BOOLEAN DEFAULT FALSE,
-- Stats (denormalizados para performance)
rating DECIMAL(3,2) DEFAULT 0.00,
total_reviews INT DEFAULT 0,
total_appointments INT DEFAULT 0,
-- Timestamps
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Índices
CREATE UNIQUE INDEX idx_teams_slug ON teams(slug);
CREATE INDEX idx_teams_business_type ON teams(business_type);
CREATE INDEX idx_teams_status ON teams(status);
CREATE INDEX idx_teams_rating ON teams(rating DESC);
-- Full-text search
CREATE INDEX idx_teams_search ON teams USING GIN (
to_tsvector('spanish', name || ' ' || COALESCE(description, ''))
);
-- RLS
ALTER TABLE teams ENABLE ROW LEVEL SECURITY;
CREATE POLICY team_isolation ON teams
USING (id = current_setting('app.current_team_id')::bigint);Campos removidos (movidos a tablas específicas):
business_hours→team_schedulestablebooking_advance_days,cancellation_hours, etc. →team_booking_settingsaccepts_cash,requires_prepayment, etc. →team_payment_settingsfacebook_pixel_id,weekly_report_enabled, etc. →team_analytics_settingsshow_services_page,welcome_text, etc. →team_website_settingsbilling_name,billing_rut, etc. →team_billing_info
3. user_teams (simplificado a 8 campos - SOLO pivot)
CREATE TABLE user_teams (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
role VARCHAR(50) NOT NULL, -- owner, manager, worker, user
-- Status
status VARCHAR(50) DEFAULT 'active', -- active, inactive, suspended
-- Timestamps
joined_at TIMESTAMP DEFAULT NOW(),
left_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, team_id)
);
-- Índices
CREATE INDEX idx_user_teams_user_id ON user_teams(user_id);
CREATE INDEX idx_user_teams_team_id ON user_teams(team_id);
CREATE INDEX idx_user_teams_role ON user_teams(role);
CREATE INDEX idx_user_teams_status ON user_teams(status);
-- RLS
ALTER TABLE user_teams ENABLE ROW LEVEL SECURITY;
CREATE POLICY team_isolation ON user_teams
USING (team_id = current_setting('app.current_team_id')::bigint);Campos removidos (movidos a tablas dedicadas):
work_schedule→worker_schedulestablehourly_rate,commission_rate,base_salary→worker_compensationtablespecialties,blocked_slots→worker_profilestableaccepts_bookings,is_active_worker→worker_availabilitytable
4. team_settings (1 tabla genérica JSONB)
CREATE TABLE team_settings (
id BIGSERIAL PRIMARY KEY,
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
category VARCHAR(100) NOT NULL, -- booking, payment, notifications, analytics, website
settings JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(team_id, category)
);
-- Índices
CREATE INDEX idx_team_settings_team_id ON team_settings(team_id);
CREATE INDEX idx_team_settings_category ON team_settings(category);
CREATE INDEX idx_team_settings_jsonb ON team_settings USING GIN (settings);
-- RLS
ALTER TABLE team_settings ENABLE ROW LEVEL SECURITY;
CREATE POLICY team_isolation ON team_settings
USING (team_id = current_setting('app.current_team_id')::bigint);
-- Ejemplo de uso:
INSERT INTO team_settings (team_id, category, settings) VALUES (1, 'booking', '{
"advance_days": 30,
"advance_hours": 2,
"cancellation_hours": 24,
"requires_approval": false,
"buffer_time_default": 0
}');
INSERT INTO team_settings (team_id, category, settings) VALUES (1, 'payment', '{
"accepts_cash": true,
"accepts_cards": true,
"requires_prepayment": false,
"deposit_percentage": 0
}');GRUPO 2: Booking Core (5 tablas)
5. services
CREATE TABLE services (
id BIGSERIAL PRIMARY KEY,
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
-- Basic info
name VARCHAR(255) NOT NULL,
description TEXT,
-- Pricing & Duration
price INT NOT NULL, -- CLP sin decimales
duration_minutes INT NOT NULL,
-- Buffers
buffer_before_minutes INT DEFAULT 0,
buffer_after_minutes INT DEFAULT 0,
-- UI
color VARCHAR(7) DEFAULT '#3B82F6',
icon VARCHAR(50),
-- Status & Display
is_active BOOLEAN DEFAULT TRUE,
is_online_bookable BOOLEAN DEFAULT TRUE,
sort_order INT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_services_team_id ON services(team_id);
CREATE INDEX idx_services_is_active ON services(is_active);
CREATE INDEX idx_services_sort_order ON services(sort_order);
-- RLS
ALTER TABLE services ENABLE ROW LEVEL SECURITY;
CREATE POLICY team_isolation ON services
USING (team_id = current_setting('app.current_team_id')::bigint);6. appointments (simplificado a 15 campos)
CREATE TABLE appointments (
id BIGSERIAL PRIMARY KEY,
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
service_id BIGINT NOT NULL REFERENCES services(id),
worker_id BIGINT REFERENCES users(id),
customer_id BIGINT REFERENCES users(id), -- NULL si es guest
-- Timing
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP NOT NULL, -- Calculado: start_time + duration
-- Pricing
price INT NOT NULL, -- Snapshot del precio al momento de crear
-- Status
status VARCHAR(50) DEFAULT 'pending', -- pending, confirmed, in_progress, completed, cancelled, no_show
-- Notes
notes TEXT, -- Notas del cliente
internal_notes TEXT, -- Notas del worker (privadas)
-- Timestamps de estado
confirmed_at TIMESTAMP,
completed_at TIMESTAMP,
cancelled_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_appointments_team_id ON appointments(team_id);
CREATE INDEX idx_appointments_service_id ON appointments(service_id);
CREATE INDEX idx_appointments_worker_id ON appointments(worker_id);
CREATE INDEX idx_appointments_customer_id ON appointments(customer_id);
CREATE INDEX idx_appointments_start_time ON appointments(start_time);
CREATE INDEX idx_appointments_status ON appointments(status);
CREATE INDEX idx_appointments_date_range ON appointments(start_time, end_time);
-- Índice compuesto para queries comunes
CREATE INDEX idx_appointments_worker_date ON appointments(worker_id, start_time) WHERE status NOT IN ('cancelled');
-- RLS
ALTER TABLE appointments ENABLE ROW LEVEL SECURITY;
CREATE POLICY team_isolation ON appointments
USING (team_id = current_setting('app.current_team_id')::bigint);
-- Constraint: end_time debe ser después de start_time
ALTER TABLE appointments ADD CONSTRAINT appointments_end_after_start
CHECK (end_time > start_time);Campos removidos:
client_name,client_email,client_phone→appointment_gueststable (solo si customer_id es NULL)promotion_id,discount_amount→appointment_discountstablemeeting_url,appointment_type→appointment_online_datatable (si se necesita)no_show_fee,cancellation_fee→ calculados on-the-fly desde settings
7. appointment_guests (solo para clientes sin cuenta)
CREATE TABLE appointment_guests (
id BIGSERIAL PRIMARY KEY,
appointment_id BIGINT NOT NULL REFERENCES appointments(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
email VARCHAR(255),
phone VARCHAR(50),
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(appointment_id)
);
-- Índices
CREATE INDEX idx_appointment_guests_email ON appointment_guests(email);
CREATE INDEX idx_appointment_guests_phone ON appointment_guests(phone);8. service_workers (qué workers ofrecen qué servicios)
CREATE TABLE service_workers (
id BIGSERIAL PRIMARY KEY,
service_id BIGINT NOT NULL REFERENCES services(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(service_id, user_id)
);
-- Índices
CREATE INDEX idx_service_workers_service_id ON service_workers(service_id);
CREATE INDEX idx_service_workers_user_id ON service_workers(user_id);
CREATE INDEX idx_service_workers_team_id ON service_workers(team_id);
-- RLS
ALTER TABLE service_workers ENABLE ROW LEVEL SECURITY;
CREATE POLICY team_isolation ON service_workers
USING (team_id = current_setting('app.current_team_id')::bigint);9. worker_schedules (horarios de trabajo)
CREATE TABLE worker_schedules (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
day_of_week INT NOT NULL, -- 0=Sunday, 6=Saturday
start_time TIME NOT NULL,
end_time TIME NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT worker_schedules_valid_day CHECK (day_of_week BETWEEN 0 AND 6),
CONSTRAINT worker_schedules_valid_time CHECK (end_time > start_time)
);
-- Índices
CREATE INDEX idx_worker_schedules_user_id ON worker_schedules(user_id);
CREATE INDEX idx_worker_schedules_team_id ON worker_schedules(team_id);
CREATE INDEX idx_worker_schedules_day ON worker_schedules(day_of_week);
-- RLS
ALTER TABLE worker_schedules ENABLE ROW LEVEL SECURITY;
CREATE POLICY team_isolation ON worker_schedules
USING (team_id = current_setting('app.current_team_id')::bigint);GRUPO 3: Finanzas Simplificado (6 tablas)
10. transactions (tabla unificada de movimientos financieros)
CREATE TABLE transactions (
id BIGSERIAL PRIMARY KEY,
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
-- Tipo y concepto
type VARCHAR(50) NOT NULL, -- income, expense, refund, adjustment
category VARCHAR(100), -- payment, salary, commission, rent, supplies, etc.
-- Monto
amount INT NOT NULL, -- CLP sin decimales (puede ser negativo para expenses)
-- Método de pago
payment_method VARCHAR(50) NOT NULL, -- cash, card, transfer, wallet
-- Relaciones opcionales
appointment_id BIGINT REFERENCES appointments(id),
user_id BIGINT REFERENCES users(id), -- Worker/customer involucrado
-- Metadata flexible
description TEXT,
metadata JSONB DEFAULT '{}',
-- Tracking
recorded_by BIGINT REFERENCES users(id),
recorded_at TIMESTAMP DEFAULT NOW(),
-- Caja (si aplica)
cash_register_id BIGINT REFERENCES cash_registers(id),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_transactions_team_id ON transactions(team_id);
CREATE INDEX idx_transactions_type ON transactions(type);
CREATE INDEX idx_transactions_category ON transactions(category);
CREATE INDEX idx_transactions_appointment_id ON transactions(appointment_id);
CREATE INDEX idx_transactions_user_id ON transactions(user_id);
CREATE INDEX idx_transactions_date ON transactions(recorded_at);
CREATE INDEX idx_transactions_cash_register ON transactions(cash_register_id);
CREATE INDEX idx_transactions_metadata ON transactions USING GIN (metadata);
-- RLS
ALTER TABLE transactions ENABLE ROW LEVEL SECURITY;
CREATE POLICY team_isolation ON transactions
USING (team_id = current_setting('app.current_team_id')::bigint);
-- Constraint: amount no puede ser 0
ALTER TABLE transactions ADD CONSTRAINT transactions_amount_not_zero
CHECK (amount != 0);Esta tabla reemplaza:
payments(type=‘income’, appointment_id not null)expenses(type=‘expense’)worker_earnings(type=‘income’, category=‘commission’, user_id=worker)cash_movements(cash_register_id not null)
11. cash_registers (arqueos de caja)
CREATE TABLE cash_registers (
id BIGSERIAL PRIMARY KEY,
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
-- Apertura
opened_at TIMESTAMP NOT NULL,
opened_by BIGINT NOT NULL REFERENCES users(id),
opening_balance INT DEFAULT 0,
opening_notes TEXT,
-- Cierre
closed_at TIMESTAMP,
closed_by BIGINT REFERENCES users(id),
expected_balance INT, -- Calculado de transacciones
actual_balance INT, -- Conteo físico
difference INT, -- actual - expected
closing_notes TEXT,
-- Estado
status VARCHAR(50) DEFAULT 'open', -- open, closed
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
-- Solo 1 caja abierta por team a la vez
CONSTRAINT unique_open_cash_register UNIQUE(team_id) WHERE (status = 'open')
);
-- Índices
CREATE INDEX idx_cash_registers_team_id ON cash_registers(team_id);
CREATE INDEX idx_cash_registers_status ON cash_registers(status);
CREATE INDEX idx_cash_registers_opened_at ON cash_registers(opened_at);
-- RLS
ALTER TABLE cash_registers ENABLE ROW LEVEL SECURITY;
CREATE POLICY team_isolation ON cash_registers
USING (team_id = current_setting('app.current_team_id')::bigint);12. worker_compensations (estructura de pago de workers)
CREATE TABLE worker_compensations (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
-- Tipo de compensación
type VARCHAR(50) NOT NULL, -- salary, commission, mixed
-- Sueldo fijo (si aplica)
base_salary INT DEFAULT 0,
-- Comisión (si aplica)
commission_rate DECIMAL(5,2) DEFAULT 0, -- Porcentaje (ej: 40.00)
commission_type VARCHAR(50), -- per_service, monthly_target
-- Meta mensual (opcional)
monthly_target INT,
-- Vigencia
effective_from DATE NOT NULL,
effective_until DATE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_worker_compensations_user_id ON worker_compensations(user_id);
CREATE INDEX idx_worker_compensations_team_id ON worker_compensations(team_id);
CREATE INDEX idx_worker_compensations_effective ON worker_compensations(effective_from, effective_until);
-- RLS
ALTER TABLE worker_compensations ENABLE ROW LEVEL SECURITY;
CREATE POLICY team_isolation ON worker_compensations
USING (team_id = current_setting('app.current_team_id')::bigint);13. worker_payouts (pagos realizados a workers)
CREATE TABLE worker_payouts (
id BIGSERIAL PRIMARY KEY,
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id), -- Worker
-- Período
period_start DATE NOT NULL,
period_end DATE NOT NULL,
-- Montos
base_amount INT DEFAULT 0, -- Sueldo base
commission_amount INT DEFAULT 0, -- Comisiones
bonus_amount INT DEFAULT 0, -- Bonos
deduction_amount INT DEFAULT 0, -- Descuentos (préstamos, etc.)
total_amount INT NOT NULL, -- Total a pagar
-- Pago
payment_method VARCHAR(50),
payment_date DATE,
payment_reference VARCHAR(255),
-- Status
status VARCHAR(50) DEFAULT 'pending', -- pending, paid, cancelled
-- Notes
notes TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_worker_payouts_team_id ON worker_payouts(team_id);
CREATE INDEX idx_worker_payouts_user_id ON worker_payouts(user_id);
CREATE INDEX idx_worker_payouts_period ON worker_payouts(period_start, period_end);
CREATE INDEX idx_worker_payouts_status ON worker_payouts(status);
-- RLS
ALTER TABLE worker_payouts ENABLE ROW LEVEL SECURITY;
CREATE POLICY team_isolation ON worker_payouts
USING (team_id = current_setting('app.current_team_id')::bigint);14. expense_categories
CREATE TABLE expense_categories (
id BIGSERIAL PRIMARY KEY,
team_id BIGINT REFERENCES teams(id) ON DELETE CASCADE, -- NULL = categorías globales
name VARCHAR(255) NOT NULL,
description TEXT,
icon VARCHAR(50),
color VARCHAR(7),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_expense_categories_team_id ON expense_categories(team_id);
-- RLS (solo para categories de teams)
ALTER TABLE expense_categories ENABLE ROW LEVEL SECURITY;
CREATE POLICY team_isolation ON expense_categories
USING (team_id IS NULL OR team_id = current_setting('app.current_team_id')::bigint);15. promotions (promociones y descuentos)
CREATE TABLE promotions (
id BIGSERIAL PRIMARY KEY,
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
code VARCHAR(50) NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
-- Tipo de descuento
discount_type VARCHAR(50) NOT NULL, -- percentage, fixed_amount
discount_value INT NOT NULL, -- Porcentaje (0-100) o monto fijo
-- Aplicabilidad
applicable_to VARCHAR(50) DEFAULT 'all', -- all, specific_services
service_ids JSONB, -- Array de service_ids si applicable_to='specific_services'
-- Límites
max_uses INT, -- NULL = ilimitado
uses_count INT DEFAULT 0,
max_uses_per_user INT DEFAULT 1,
-- Vigencia
starts_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(team_id, code)
);
-- Índices
CREATE INDEX idx_promotions_team_id ON promotions(team_id);
CREATE INDEX idx_promotions_code ON promotions(code);
CREATE INDEX idx_promotions_dates ON promotions(starts_at, expires_at);
-- RLS
ALTER TABLE promotions ENABLE ROW LEVEL SECURITY;
CREATE POLICY team_isolation ON promotions
USING (team_id = current_setting('app.current_team_id')::bigint);GRUPO 4: Clientes (3 tablas)
16. client_profiles (perfil extendido de clientes)
CREATE TABLE client_profiles (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
-- Clasificación
classification VARCHAR(50), -- vip, regular, new, inactive
classification_updated_at TIMESTAMP,
-- Stats
total_appointments INT DEFAULT 0,
total_spent INT DEFAULT 0,
total_cancelled INT DEFAULT 0,
last_appointment_at TIMESTAMP,
-- Preferencias
preferred_worker_id BIGINT REFERENCES users(id),
preferred_services JSONB, -- Array de service_ids
-- Notas
notes TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, team_id)
);
-- Índices
CREATE INDEX idx_client_profiles_user_id ON client_profiles(user_id);
CREATE INDEX idx_client_profiles_team_id ON client_profiles(team_id);
CREATE INDEX idx_client_profiles_classification ON client_profiles(classification);
CREATE INDEX idx_client_profiles_last_appointment ON client_profiles(last_appointment_at);
-- RLS
ALTER TABLE client_profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY team_isolation ON client_profiles
USING (team_id = current_setting('app.current_team_id')::bigint);17. reviews (reseñas de clientes)
CREATE TABLE reviews (
id BIGSERIAL PRIMARY KEY,
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
appointment_id BIGINT REFERENCES appointments(id) ON DELETE SET NULL,
user_id BIGINT NOT NULL REFERENCES users(id),
rating INT NOT NULL CHECK (rating BETWEEN 1 AND 5),
comment TEXT,
is_visible BOOLEAN DEFAULT TRUE,
response TEXT, -- Respuesta del negocio
responded_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_reviews_team_id ON reviews(team_id);
CREATE INDEX idx_reviews_user_id ON reviews(user_id);
CREATE INDEX idx_reviews_appointment_id ON reviews(appointment_id);
CREATE INDEX idx_reviews_rating ON reviews(rating);
-- RLS
ALTER TABLE reviews ENABLE ROW LEVEL SECURITY;
CREATE POLICY team_isolation ON reviews
USING (team_id = current_setting('app.current_team_id')::bigint);18. user_favorites (negocios favoritos del usuario)
CREATE TABLE user_favorites (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, team_id)
);
-- Índices
CREATE INDEX idx_user_favorites_user_id ON user_favorites(user_id);
CREATE INDEX idx_user_favorites_team_id ON user_favorites(team_id);GRUPO 5: Sistema (7 tablas)
19. invitations (invitaciones pendientes)
CREATE TABLE invitations (
id BIGSERIAL PRIMARY KEY,
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
email VARCHAR(255) NOT NULL,
role VARCHAR(50) NOT NULL,
token VARCHAR(255) UNIQUE NOT NULL,
invited_by BIGINT NOT NULL REFERENCES users(id),
expires_at TIMESTAMP NOT NULL,
accepted_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_invitations_team_id ON invitations(team_id);
CREATE INDEX idx_invitations_email ON invitations(email);
CREATE INDEX idx_invitations_token ON invitations(token);
-- RLS
ALTER TABLE invitations ENABLE ROW LEVEL SECURITY;
CREATE POLICY team_isolation ON invitations
USING (team_id = current_setting('app.current_team_id')::bigint);20. notifications
CREATE TABLE notifications (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
team_id BIGINT REFERENCES teams(id) ON DELETE CASCADE,
type VARCHAR(100) NOT NULL, -- appointment_confirmed, payment_received, etc.
title VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
data JSONB DEFAULT '{}', -- Data adicional (appointment_id, etc.)
read_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_notifications_user_id ON notifications(user_id);
CREATE INDEX idx_notifications_read_at ON notifications(read_at);
CREATE INDEX idx_notifications_created_at ON notifications(created_at DESC);21. activity_logs (audit trail)
CREATE TABLE activity_logs (
id BIGSERIAL PRIMARY KEY,
team_id BIGINT REFERENCES teams(id) ON DELETE SET NULL,
user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(100) NOT NULL, -- created, updated, deleted
model VARCHAR(100) NOT NULL, -- Appointment, Payment, etc.
model_id BIGINT,
changes JSONB, -- Before/after values
ip_address VARCHAR(45),
user_agent TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_activity_logs_team_id ON activity_logs(team_id);
CREATE INDEX idx_activity_logs_user_id ON activity_logs(user_id);
CREATE INDEX idx_activity_logs_model ON activity_logs(model, model_id);
CREATE INDEX idx_activity_logs_created_at ON activity_logs(created_at DESC);
-- Partición por fecha (opcional pero recomendado)
-- Permite borrar logs antiguos eficientemente22-25. Laravel defaults (cache, jobs, sessions, tokens)
-- cache, cache_locks (Laravel cache)
-- jobs, job_batches, failed_jobs (Laravel queues)
-- sessions (Laravel sessions)
-- personal_access_tokens (Sanctum)
-- password_reset_tokens
-- Estas no cambianGRUPO 6: Features Opcionales (8 tablas - solo si se necesitan)
26. products (si venden productos físicos)
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
description TEXT,
price INT NOT NULL,
stock INT DEFAULT 0,
sku VARCHAR(100),
image_url TEXT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);27. sales (ventas de productos)
CREATE TABLE sales (
id BIGSERIAL PRIMARY KEY,
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
customer_id BIGINT REFERENCES users(id),
total_amount INT NOT NULL,
payment_method VARCHAR(50),
sold_by BIGINT REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW()
);28. sale_items (items de venta)
CREATE TABLE sale_items (
id BIGSERIAL PRIMARY KEY,
sale_id BIGINT NOT NULL REFERENCES sales(id) ON DELETE CASCADE,
product_id BIGINT REFERENCES products(id),
quantity INT NOT NULL,
unit_price INT NOT NULL,
subtotal INT NOT NULL
);29. subscriptions (suscripciones del negocio a planes)
CREATE TABLE subscriptions (
id BIGSERIAL PRIMARY KEY,
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
plan VARCHAR(100) NOT NULL, -- free, basic, pro, enterprise
status VARCHAR(50) DEFAULT 'active',
starts_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);30. whatsapp_messages (si usan WhatsApp API)
CREATE TABLE whatsapp_messages (
id BIGSERIAL PRIMARY KEY,
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
appointment_id BIGINT REFERENCES appointments(id),
to_phone VARCHAR(50) NOT NULL,
message TEXT NOT NULL,
status VARCHAR(50) DEFAULT 'pending',
sent_at TIMESTAMP,
delivered_at TIMESTAMP,
read_at TIMESTAMP,
error_message TEXT,
created_at TIMESTAMP DEFAULT NOW()
);31. marketing_campaigns (campañas de email/SMS)
CREATE TABLE marketing_campaigns (
id BIGSERIAL PRIMARY KEY,
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL, -- email, sms, whatsapp
status VARCHAR(50) DEFAULT 'draft',
scheduled_at TIMESTAMP,
sent_at TIMESTAMP,
recipients_count INT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);32. affiliates (programa de afiliados - opcional)
CREATE TABLE affiliates (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code VARCHAR(50) UNIQUE NOT NULL,
commission_rate DECIMAL(5,2) DEFAULT 10.00,
total_referred INT DEFAULT 0,
total_earned INT DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);33. team_locations (sucursales múltiples - opcional)
CREATE TABLE team_locations (
id BIGSERIAL PRIMARY KEY,
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
address JSONB NOT NULL,
phone VARCHAR(50),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);📊 Comparación: Actual vs Optimizado
| Métrica | Actual | Optimizado | Mejora |
|---|---|---|---|
| Tablas totales | 65 | 38 | -42% |
| Tablas core | ~20 | 15 | -25% |
Campos en teams | 50+ | 20 | -60% |
Campos en user_teams | 22 | 8 | -64% |
Campos en appointments | 26 | 15 | -42% |
| Tablas de finanzas | 15 | 6 | -60% |
| RLS habilitado | 0 | 38 | +100% |
| Normalización | 2NF | 3NF | ✅ |
| Índices desde día 1 | Parcial | Completo | ✅ |
🎯 Ventajas del Diseño Optimizado
1. Simplicidad
- 38 tablas vs 65 (42% menos)
- Responsabilidades claras
- Fácil de entender para nuevos devs
2. Seguridad
- RLS en todas las tablas multi-tenant
- Imposible leakear datos entre teams
- Seguridad a nivel DB (no solo app)
3. Performance
- Índices desde día 1
- Queries optimizadas
- Full-text search nativo
4. Mantenibilidad
- Single Responsibility Principle
- Fácil agregar features (nuevas tablas, no nuevos campos)
- Migrations más limpias
5. Escalabilidad
- Tablas particionables (activity_logs por fecha)
- Sharding por team_id posible
- Queries predecibles
🚀 Plan de Migración (si decides refactorizar)
Opción A: Big Bang (riesgoso)
-- 1. Crear todas las tablas nuevas
-- 2. Migrar datos con scripts
-- 3. Cambiar código Laravel
-- 4. Deploy
-- ⚠️ Downtime: 2-4 horas
-- ⚠️ Riesgo: AltoOpción B: Incremental (recomendado)
-- Fase 1: Crear tablas nuevas en paralelo
-- Fase 2: Dual write (escribir en ambas)
-- Fase 3: Migrar reads gradualmente
-- Fase 4: Eliminar tablas viejas
-- ✅ Downtime: 0
-- ✅ Riesgo: BajoOpción C: Fresh Start (solo si empiezas de 0)
-- Implementar schema optimizado directamente
-- ✅ Downtime: N/A
-- ✅ Riesgo: 0📝 Recomendación Final
Si estás empezando DESDE CERO:
✅ USA el schema optimizado (38 tablas)
- Más simple
- Más seguro (RLS)
- Más mantenible
- Preparado para escala
Si ya tienes el sistema en producción:
⚠️ MANTÉN el schema actual
- Refactorizar = alto riesgo
- 65 tablas funcionan (aunque no sean perfectas)
- Mejor invertir tiempo en features
Solo refactoriza si:
- Tienes data leaks frecuentes
- Performance es crítico problema
- Vas a escalar a 10x+ usuarios
🗄️ Herramientas Recomendadas para Modelar
-
dbdiagram.io ⭐⭐⭐⭐⭐
- Web-based, gratis
- Syntax simple
- Export a SQL
-
TablePlus ⭐⭐⭐⭐⭐
- Cliente nativo macOS
- ER Diagrams
- Query editor
-
DrawSQL ⭐⭐⭐⭐
- Web-based
- Drag & drop
- Colaborativo
¿Quieres que genere el SQL completo del schema optimizado listo para copy-paste?