🗄️ 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 consistencia

5. 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:

  1. Single Responsibility: Cada tabla = 1 responsabilidad
  2. Normalización: 3NF mínimo
  3. RLS First: Seguridad a nivel DB
  4. JSON solo cuando es flexible: No abusar de JSONB
  5. Í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_classificationclient_profiles.classification
  • is_super_adminroles table (Spatie Permission)
  • two_factor_*user_security table
  • bio, preferencesuser_profiles table (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_hoursteam_schedules table
  • booking_advance_days, cancellation_hours, etc. → team_booking_settings
  • accepts_cash, requires_prepayment, etc. → team_payment_settings
  • facebook_pixel_id, weekly_report_enabled, etc. → team_analytics_settings
  • show_services_page, welcome_text, etc. → team_website_settings
  • billing_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_scheduleworker_schedules table
  • hourly_rate, commission_rate, base_salaryworker_compensation table
  • specialties, blocked_slotsworker_profiles table
  • accepts_bookings, is_active_workerworker_availability table

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_phoneappointment_guests table (solo si customer_id es NULL)
  • promotion_id, discount_amountappointment_discounts table
  • meeting_url, appointment_typeappointment_online_data table (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 eficientemente

22-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 cambian

GRUPO 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étricaActualOptimizadoMejora
Tablas totales6538-42%
Tablas core~2015-25%
Campos en teams50+20-60%
Campos en user_teams228-64%
Campos en appointments2615-42%
Tablas de finanzas156-60%
RLS habilitado038+100%
Normalización2NF3NF
Índices desde día 1ParcialCompleto

🎯 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: Alto

Opció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: Bajo

Opció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

  1. dbdiagram.io ⭐⭐⭐⭐⭐

    • Web-based, gratis
    • Syntax simple
    • Export a SQL
  2. TablePlus ⭐⭐⭐⭐⭐

    • Cliente nativo macOS
    • ER Diagrams
    • Query editor
  3. DrawSQL ⭐⭐⭐⭐

    • Web-based
    • Drag & drop
    • Colaborativo

¿Quieres que genere el SQL completo del schema optimizado listo para copy-paste?