-- --------------------------------------------------------------------------- -- Núcleo de la plataforma. Nombres de tabla y columna en inglés a propósito: -- son los que shared/types.ts y el frontend ya consumen. -- --------------------------------------------------------------------------- CREATE TABLE businesses ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL, industry text NOT NULL DEFAULT 'Estética y Spa', currency text NOT NULL DEFAULT 'MXN', currency_symbol text NOT NULL DEFAULT '$', phone text, address text, slug text UNIQUE, timezone text NOT NULL DEFAULT 'America/Mexico_City', working_hours jsonb NOT NULL, status text NOT NULL DEFAULT 'active', created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE employees ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE, name text NOT NULL, email text, phone text, color text NOT NULL DEFAULT '#3b66ff', role text NOT NULL DEFAULT 'specialist', active boolean NOT NULL DEFAULT true, working_hours jsonb, -- NULL = hereda del negocio commission_pct numeric(5,2) NOT NULL DEFAULT 0, created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE services ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE, name text NOT NULL, description text, category text NOT NULL DEFAULT 'General', duration_min integer NOT NULL DEFAULT 60, price numeric(10,2) NOT NULL DEFAULT 0, color text NOT NULL DEFAULT '#3b66ff', commission_pct numeric(5,2) NOT NULL DEFAULT 0, active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE employee_services ( employee_id bigint NOT NULL REFERENCES employees(id) ON DELETE CASCADE, service_id bigint NOT NULL REFERENCES services(id) ON DELETE CASCADE, PRIMARY KEY (employee_id, service_id) ); CREATE TABLE users ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, business_id bigint REFERENCES businesses(id) ON DELETE CASCADE, email text NOT NULL UNIQUE, password text NOT NULL, name text NOT NULL, role text NOT NULL CHECK (role IN ('admin','owner','employee')), employee_id bigint REFERENCES employees(id), avatar_color text NOT NULL DEFAULT '#3b66ff', created_at timestamptz NOT NULL DEFAULT now() ); -- La clienta. `phone_e164` es la clave de identidad: es lo único que puede -- reconciliar el mismo número que llega por canales distintos, y el índice -- parcial de abajo es lo que impide el duplicado. CREATE TABLE clients ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE, name text NOT NULL, email text, phone text, -- lo que tecleó la persona, tal cual phone_e164 text, -- lo normalizado; NULL si no se pudo contactable boolean GENERATED ALWAYS AS (phone_e164 IS NOT NULL) STORED, birth_date date, notes text, tags text, source_channel text, -- whatsapp|facebook|instagram|mostrador|referido -- Se declara desde el día uno aunque la Fase 2 aún no exista: es el ancla de -- correlación con Bucéfalo CRM, y añadirla después obliga a un backfill que -- no se puede hacer sin releer el CRM entero. crm_contact_id text, crm_synced_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), deleted_at timestamptz -- baja lógica: la clienta nunca se borra ); -- Un mismo teléfono no puede repetirse dentro de un negocio. Es parcial porque -- el 40.8 % del histórico medido no tiene teléfono y esas filas deben convivir. CREATE UNIQUE INDEX clients_phone_unique ON clients (business_id, phone_e164) WHERE phone_e164 IS NOT NULL AND deleted_at IS NULL; CREATE INDEX clients_business_name ON clients (business_id, name); CREATE TABLE appointments ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE, client_id bigint NOT NULL REFERENCES clients(id), employee_id bigint NOT NULL REFERENCES employees(id), service_id bigint NOT NULL REFERENCES services(id), start_at timestamptz NOT NULL, -- `end_at` se materializa, no se deriva: si mañana cambia la duración del -- servicio, las citas ya agendadas no deben moverse. end_at timestamptz NOT NULL, during tstzrange GENERATED ALWAYS AS (tstzrange(start_at, end_at, '[)')) STORED, status text NOT NULL DEFAULT 'scheduled' CHECK (status IN ('scheduled','completed','cancelled','no_show')), cancelled_by text CHECK (cancelled_by IN ('client','business')), cancel_reason text, price numeric(10,2) NOT NULL DEFAULT 0, notes text, source_channel text, created_by_user_id bigint REFERENCES users(id), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT appointments_end_after_start CHECK (end_at > start_at), CONSTRAINT appointments_cancelled_by_only_when_cancelled CHECK (cancelled_by IS NULL OR status = 'cancelled'), -- Aquí está la diferencia con el backend de SQLite: la doble reserva deja de -- ser una validación que alguien puede saltarse y pasa a ser el motor -- rechazando la fila. Las canceladas no reservan hueco. CONSTRAINT appointments_no_overlap EXCLUDE USING gist ( employee_id WITH =, during WITH && ) WHERE (status <> 'cancelled') ); CREATE INDEX appointments_business_start ON appointments (business_id, start_at); CREATE INDEX appointments_employee_start ON appointments (employee_id, start_at); CREATE INDEX appointments_client ON appointments (client_id); -- La visita es el hecho consumado, y está separada de la cita a propósito: -- una cita es una intención. Fusionarlas es el error que dejó 3 002 -- oportunidades congeladas en el CRM — un registro que sirve para planear y -- para cerrar termina sin cerrarse nunca. CREATE TABLE visits ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE, appointment_id bigint UNIQUE REFERENCES appointments(id), client_id bigint NOT NULL REFERENCES clients(id), employee_id bigint NOT NULL REFERENCES employees(id), occurred_at timestamptz NOT NULL, total_charged numeric(10,2), payment_method text CHECK (payment_method IN ('cash','card','transfer','other')), recorded_by_user_id bigint REFERENCES users(id), recorded_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX visits_business_occurred ON visits (business_id, occurred_at); CREATE INDEX visits_client ON visits (client_id); -- Historial de la cita. Append-only: nunca se actualiza ni se borra. CREATE TABLE appointment_events ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, appointment_id bigint NOT NULL REFERENCES appointments(id) ON DELETE CASCADE, actor_user_id bigint REFERENCES users(id), action text NOT NULL, -- created|rescheduled|cancelled|attended|no_show from_status text, to_status text, detail jsonb, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX appointment_events_appointment ON appointment_events (appointment_id, created_at); -- Quién cambió qué, cuándo y desde dónde. CREATE TABLE audit_log ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, business_id bigint, actor_user_id bigint REFERENCES users(id), entity text NOT NULL, entity_id bigint, action text NOT NULL, before jsonb, after jsonb, ip text, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX audit_log_business_created ON audit_log (business_id, created_at DESC); CREATE INDEX audit_log_entity ON audit_log (entity, entity_id); -- El cierre de día. Una fila por día cerrado; la restricción única es lo que -- hace que cerrar dos veces no sea posible. CREATE TABLE day_closures ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE, business_date date NOT NULL, closed_by_user_id bigint NOT NULL REFERENCES users(id), closed_at timestamptz NOT NULL DEFAULT now(), attended_count integer NOT NULL, no_show_count integer NOT NULL, cancelled_count integer NOT NULL, UNIQUE (business_id, business_date) );