# DDL-SPEC: Schema core_notifications ## Identificacion | Campo | Valor | |-------|-------| | **Schema** | core_notifications | | **Modulo** | MGN-008 | | **Version** | 1.0 | | **Estado** | En Diseno | | **Autor** | Requirements-Analyst | | **Fecha** | 2025-12-05 | --- ## Descripcion General El schema `core_notifications` gestiona notificaciones multi-canal (in-app, email, push), templates de mensajes y preferencias de usuario. Soporta notificaciones en tiempo real via WebSocket y cola asíncrona para email/push. ### RF Cubiertos | RF | Titulo | Tablas | |----|--------|--------| | RF-NOTIF-001 | Notificaciones In-App | notifications | | RF-NOTIF-002 | Email | email_templates, email_jobs | | RF-NOTIF-003 | Push | push_subscriptions, push_jobs | | RF-NOTIF-004 | Preferencias | notification_preferences | --- ## Diagrama ER ```mermaid erDiagram notifications { uuid id PK uuid tenant_id FK uuid user_id FK varchar type varchar title text body jsonb data varchar[] channels boolean is_read timestamptz read_at timestamptz created_at } email_templates { uuid id PK uuid tenant_id varchar key UK varchar name varchar subject text body_html text body_text jsonb variables boolean is_active } email_jobs { uuid id PK uuid tenant_id uuid template_id FK varchar to_email varchar subject text body_html jsonb variables varchar status int attempts timestamptz scheduled_at timestamptz sent_at } push_subscriptions { uuid id PK uuid user_id FK varchar endpoint UK jsonb keys varchar device_type boolean is_active timestamptz last_used_at } push_jobs { uuid id PK uuid subscription_id FK varchar title text body jsonb data varchar status int attempts timestamptz sent_at } notification_preferences { uuid id PK uuid user_id FK varchar notification_type boolean in_app boolean email boolean push varchar email_frequency } email_templates ||--o{ email_jobs : "usa" push_subscriptions ||--o{ push_jobs : "recibe" ``` --- ## Tablas ### 1. notifications Notificaciones in-app para usuarios. | Columna | Tipo | Nullable | Default | Descripcion | |---------|------|----------|---------|-------------| | `id` | UUID | NOT NULL | gen_random_uuid() | PK | | `tenant_id` | UUID | NOT NULL | - | FK a tenants | | `user_id` | UUID | NOT NULL | - | FK a users | | `type` | VARCHAR(50) | NOT NULL | - | Tipo de notificacion | | `title` | VARCHAR(255) | NOT NULL | - | Titulo | | `body` | TEXT | NOT NULL | - | Contenido | | `data` | JSONB | NULL | '{}' | Datos adicionales | | `action_url` | VARCHAR(500) | NULL | - | URL de accion | | `action_label` | VARCHAR(100) | NULL | - | Texto del boton | | `icon` | VARCHAR(50) | NULL | - | Icono | | `channels` | VARCHAR(20)[] | NOT NULL | '{in_app}' | Canales enviados | | `is_read` | BOOLEAN | NOT NULL | false | Leida | | `read_at` | TIMESTAMPTZ | NULL | - | Fecha lectura | | `is_archived` | BOOLEAN | NOT NULL | false | Archivada | | `expires_at` | TIMESTAMPTZ | NULL | - | Expiracion | | `created_at` | TIMESTAMPTZ | NOT NULL | NOW() | Fecha creacion | ```sql CREATE TABLE core_notifications.notifications ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, user_id UUID NOT NULL, type VARCHAR(50) NOT NULL, title VARCHAR(255) NOT NULL, body TEXT NOT NULL, data JSONB DEFAULT '{}', action_url VARCHAR(500), action_label VARCHAR(100), icon VARCHAR(50), channels VARCHAR(20)[] NOT NULL DEFAULT '{in_app}', is_read BOOLEAN NOT NULL DEFAULT false, read_at TIMESTAMPTZ, is_archived BOOLEAN NOT NULL DEFAULT false, expires_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT fk_notifications_tenant FOREIGN KEY (tenant_id) REFERENCES core_tenants.tenants(id) ON DELETE CASCADE, CONSTRAINT fk_notifications_user FOREIGN KEY (user_id) REFERENCES core_users.users(id) ON DELETE CASCADE ); -- Indices CREATE INDEX idx_notifications_user ON core_notifications.notifications(user_id, created_at DESC); CREATE INDEX idx_notifications_user_unread ON core_notifications.notifications(user_id, is_read) WHERE is_read = false; CREATE INDEX idx_notifications_type ON core_notifications.notifications(type, created_at DESC); CREATE INDEX idx_notifications_expires ON core_notifications.notifications(expires_at) WHERE expires_at IS NOT NULL; -- RLS ALTER TABLE core_notifications.notifications ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON core_notifications.notifications FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::uuid); CREATE POLICY user_own ON core_notifications.notifications FOR SELECT USING (user_id = current_setting('app.current_user_id')::uuid); ``` --- ### 2. email_templates Templates de email reutilizables. | Columna | Tipo | Nullable | Default | Descripcion | |---------|------|----------|---------|-------------| | `id` | UUID | NOT NULL | gen_random_uuid() | PK | | `tenant_id` | UUID | NULL | - | NULL = global | | `key` | VARCHAR(100) | NOT NULL | - | Clave unica | | `name` | VARCHAR(255) | NOT NULL | - | Nombre descriptivo | | `subject` | VARCHAR(255) | NOT NULL | - | Asunto (con variables) | | `body_html` | TEXT | NOT NULL | - | Cuerpo HTML | | `body_text` | TEXT | NULL | - | Cuerpo texto plano | | `variables` | JSONB | NOT NULL | '[]' | Variables disponibles | | `category` | VARCHAR(50) | NOT NULL | 'transactional' | Categoria | | `is_active` | BOOLEAN | NOT NULL | true | Activo | | `created_at` | TIMESTAMPTZ | NOT NULL | NOW() | Fecha creacion | | `updated_at` | TIMESTAMPTZ | NOT NULL | NOW() | Fecha actualizacion | ```sql CREATE TABLE core_notifications.email_templates ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID, key VARCHAR(100) NOT NULL, name VARCHAR(255) NOT NULL, subject VARCHAR(255) NOT NULL, body_html TEXT NOT NULL, body_text TEXT, variables JSONB NOT NULL DEFAULT '[]', category VARCHAR(50) NOT NULL DEFAULT 'transactional', is_active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uk_email_templates_key UNIQUE (tenant_id, key), CONSTRAINT chk_category CHECK (category IN ( 'transactional', 'marketing', 'digest', 'alert', 'welcome' )) ); CREATE INDEX idx_email_templates_tenant ON core_notifications.email_templates(tenant_id); CREATE INDEX idx_email_templates_category ON core_notifications.email_templates(category); ``` --- ### 3. email_jobs Cola de trabajos de email. | Columna | Tipo | Nullable | Default | Descripcion | |---------|------|----------|---------|-------------| | `id` | UUID | NOT NULL | gen_random_uuid() | PK | | `tenant_id` | UUID | NOT NULL | - | FK a tenants | | `template_id` | UUID | NULL | - | FK a email_templates | | `to_email` | VARCHAR(255) | NOT NULL | - | Destinatario | | `to_name` | VARCHAR(255) | NULL | - | Nombre destinatario | | `cc` | VARCHAR(255)[] | NULL | - | Copia | | `bcc` | VARCHAR(255)[] | NULL | - | Copia oculta | | `subject` | VARCHAR(255) | NOT NULL | - | Asunto renderizado | | `body_html` | TEXT | NOT NULL | - | Cuerpo HTML renderizado | | `body_text` | TEXT | NULL | - | Cuerpo texto | | `variables` | JSONB | NULL | - | Variables usadas | | `attachments` | JSONB | NULL | '[]' | Adjuntos | | `status` | VARCHAR(20) | NOT NULL | 'pending' | Estado | | `priority` | INTEGER | NOT NULL | 5 | Prioridad (1-10) | | `attempts` | INTEGER | NOT NULL | 0 | Intentos realizados | | `max_attempts` | INTEGER | NOT NULL | 3 | Intentos maximos | | `error` | TEXT | NULL | - | Ultimo error | | `scheduled_at` | TIMESTAMPTZ | NOT NULL | NOW() | Programado para | | `sent_at` | TIMESTAMPTZ | NULL | - | Fecha envio | | `opened_at` | TIMESTAMPTZ | NULL | - | Fecha apertura | | `clicked_at` | TIMESTAMPTZ | NULL | - | Fecha click | | `created_at` | TIMESTAMPTZ | NOT NULL | NOW() | Fecha creacion | ```sql CREATE TABLE core_notifications.email_jobs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, template_id UUID, to_email VARCHAR(255) NOT NULL, to_name VARCHAR(255), cc VARCHAR(255)[], bcc VARCHAR(255)[], subject VARCHAR(255) NOT NULL, body_html TEXT NOT NULL, body_text TEXT, variables JSONB, attachments JSONB DEFAULT '[]', status VARCHAR(20) NOT NULL DEFAULT 'pending', priority INTEGER NOT NULL DEFAULT 5, attempts INTEGER NOT NULL DEFAULT 0, max_attempts INTEGER NOT NULL DEFAULT 3, error TEXT, scheduled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), sent_at TIMESTAMPTZ, opened_at TIMESTAMPTZ, clicked_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT fk_email_jobs_template FOREIGN KEY (template_id) REFERENCES core_notifications.email_templates(id), CONSTRAINT chk_status CHECK (status IN ( 'pending', 'processing', 'sent', 'failed', 'cancelled' )), CONSTRAINT chk_priority CHECK (priority BETWEEN 1 AND 10) ); -- Indices para procesamiento de cola CREATE INDEX idx_email_jobs_pending ON core_notifications.email_jobs(scheduled_at, priority DESC) WHERE status = 'pending'; CREATE INDEX idx_email_jobs_status ON core_notifications.email_jobs(status, created_at); CREATE INDEX idx_email_jobs_tenant ON core_notifications.email_jobs(tenant_id, created_at DESC); ``` --- ### 4. push_subscriptions Dispositivos registrados para push notifications. | Columna | Tipo | Nullable | Default | Descripcion | |---------|------|----------|---------|-------------| | `id` | UUID | NOT NULL | gen_random_uuid() | PK | | `user_id` | UUID | NOT NULL | - | FK a users | | `endpoint` | VARCHAR(500) | NOT NULL | - | URL endpoint | | `p256dh` | VARCHAR(255) | NOT NULL | - | Public key | | `auth` | VARCHAR(255) | NOT NULL | - | Auth secret | | `device_type` | VARCHAR(20) | NOT NULL | - | web/android/ios | | `device_name` | VARCHAR(100) | NULL | - | Nombre dispositivo | | `browser` | VARCHAR(50) | NULL | - | Browser (para web) | | `is_active` | BOOLEAN | NOT NULL | true | Activa | | `last_used_at` | TIMESTAMPTZ | NULL | - | Ultimo uso | | `created_at` | TIMESTAMPTZ | NOT NULL | NOW() | Fecha registro | ```sql CREATE TABLE core_notifications.push_subscriptions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, endpoint VARCHAR(500) NOT NULL, p256dh VARCHAR(255) NOT NULL, auth VARCHAR(255) NOT NULL, device_type VARCHAR(20) NOT NULL, device_name VARCHAR(100), browser VARCHAR(50), is_active BOOLEAN NOT NULL DEFAULT true, last_used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uk_push_subscriptions_endpoint UNIQUE (endpoint), CONSTRAINT fk_push_subscriptions_user FOREIGN KEY (user_id) REFERENCES core_users.users(id) ON DELETE CASCADE, CONSTRAINT chk_device_type CHECK (device_type IN ('web', 'android', 'ios')) ); CREATE INDEX idx_push_subscriptions_user ON core_notifications.push_subscriptions(user_id) WHERE is_active = true; ``` --- ### 5. push_jobs Cola de trabajos de push notification. | Columna | Tipo | Nullable | Default | Descripcion | |---------|------|----------|---------|-------------| | `id` | UUID | NOT NULL | gen_random_uuid() | PK | | `subscription_id` | UUID | NOT NULL | - | FK a push_subscriptions | | `title` | VARCHAR(255) | NOT NULL | - | Titulo | | `body` | TEXT | NOT NULL | - | Mensaje | | `icon` | VARCHAR(500) | NULL | - | URL icono | | `badge` | VARCHAR(500) | NULL | - | URL badge | | `image` | VARCHAR(500) | NULL | - | URL imagen | | `data` | JSONB | NULL | '{}' | Datos payload | | `action_url` | VARCHAR(500) | NULL | - | URL al hacer click | | `status` | VARCHAR(20) | NOT NULL | 'pending' | Estado | | `attempts` | INTEGER | NOT NULL | 0 | Intentos | | `error` | TEXT | NULL | - | Error | | `sent_at` | TIMESTAMPTZ | NULL | - | Fecha envio | | `created_at` | TIMESTAMPTZ | NOT NULL | NOW() | Fecha creacion | ```sql CREATE TABLE core_notifications.push_jobs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), subscription_id UUID NOT NULL, title VARCHAR(255) NOT NULL, body TEXT NOT NULL, icon VARCHAR(500), badge VARCHAR(500), image VARCHAR(500), data JSONB DEFAULT '{}', action_url VARCHAR(500), status VARCHAR(20) NOT NULL DEFAULT 'pending', attempts INTEGER NOT NULL DEFAULT 0, error TEXT, sent_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT fk_push_jobs_subscription FOREIGN KEY (subscription_id) REFERENCES core_notifications.push_subscriptions(id) ON DELETE CASCADE, CONSTRAINT chk_status CHECK (status IN ('pending', 'processing', 'sent', 'failed')) ); CREATE INDEX idx_push_jobs_pending ON core_notifications.push_jobs(created_at) WHERE status = 'pending'; ``` --- ### 6. notification_preferences Preferencias de notificacion por usuario. | Columna | Tipo | Nullable | Default | Descripcion | |---------|------|----------|---------|-------------| | `id` | UUID | NOT NULL | gen_random_uuid() | PK | | `user_id` | UUID | NOT NULL | - | FK a users | | `notification_type` | VARCHAR(50) | NOT NULL | - | Tipo de notificacion | | `in_app` | BOOLEAN | NOT NULL | true | Recibir in-app | | `email` | BOOLEAN | NOT NULL | true | Recibir email | | `push` | BOOLEAN | NOT NULL | true | Recibir push | | `email_frequency` | VARCHAR(20) | NOT NULL | 'instant' | Frecuencia email | | `created_at` | TIMESTAMPTZ | NOT NULL | NOW() | Fecha creacion | | `updated_at` | TIMESTAMPTZ | NOT NULL | NOW() | Fecha actualizacion | ```sql CREATE TABLE core_notifications.notification_preferences ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, notification_type VARCHAR(50) NOT NULL, in_app BOOLEAN NOT NULL DEFAULT true, email BOOLEAN NOT NULL DEFAULT true, push BOOLEAN NOT NULL DEFAULT true, email_frequency VARCHAR(20) NOT NULL DEFAULT 'instant', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uk_preferences_user_type UNIQUE (user_id, notification_type), CONSTRAINT fk_preferences_user FOREIGN KEY (user_id) REFERENCES core_users.users(id) ON DELETE CASCADE, CONSTRAINT chk_email_frequency CHECK (email_frequency IN ( 'instant', 'hourly', 'daily', 'weekly', 'never' )) ); CREATE INDEX idx_preferences_user ON core_notifications.notification_preferences(user_id); ``` --- ## Tipos de Notificacion ```sql -- Tipos de notificacion predefinidos CREATE TYPE core_notifications.notification_type AS ENUM ( -- Seguridad 'security.login_new_device', 'security.password_changed', 'security.session_expired', -- Sistema 'system.maintenance', 'system.update', 'system.announcement', -- Usuarios 'user.mention', 'user.assignment', 'user.invitation', -- Tareas 'task.assigned', 'task.completed', 'task.due_soon', 'task.overdue', -- Documentos 'document.shared', 'document.comment', 'document.approved', 'document.rejected', -- Pagos 'payment.received', 'payment.failed', 'payment.due', -- Reportes 'report.ready', 'report.scheduled' ); ``` --- ## Funciones de Utilidad ### Enviar Notificacion ```sql CREATE OR REPLACE FUNCTION core_notifications.send_notification( p_tenant_id UUID, p_user_id UUID, p_type VARCHAR, p_title VARCHAR, p_body TEXT, p_data JSONB DEFAULT '{}', p_action_url VARCHAR DEFAULT NULL, p_channels VARCHAR[] DEFAULT '{in_app}' ) RETURNS UUID AS $$ DECLARE v_notification_id UUID; v_preferences RECORD; v_effective_channels VARCHAR[]; BEGIN -- Obtener preferencias del usuario SELECT * INTO v_preferences FROM core_notifications.notification_preferences WHERE user_id = p_user_id AND notification_type = p_type; -- Aplicar preferencias a canales v_effective_channels := ARRAY[]::VARCHAR[]; IF 'in_app' = ANY(p_channels) AND (v_preferences IS NULL OR v_preferences.in_app) THEN v_effective_channels := array_append(v_effective_channels, 'in_app'); END IF; IF 'email' = ANY(p_channels) AND (v_preferences IS NULL OR v_preferences.email) THEN v_effective_channels := array_append(v_effective_channels, 'email'); END IF; IF 'push' = ANY(p_channels) AND (v_preferences IS NULL OR v_preferences.push) THEN v_effective_channels := array_append(v_effective_channels, 'push'); END IF; -- Crear notificacion in-app si aplica IF 'in_app' = ANY(v_effective_channels) THEN INSERT INTO core_notifications.notifications ( tenant_id, user_id, type, title, body, data, action_url, channels ) VALUES ( p_tenant_id, p_user_id, p_type, p_title, p_body, p_data, p_action_url, v_effective_channels ) RETURNING id INTO v_notification_id; END IF; RETURN v_notification_id; END; $$ LANGUAGE plpgsql; ``` ### Marcar Como Leidas ```sql CREATE OR REPLACE FUNCTION core_notifications.mark_as_read( p_user_id UUID, p_notification_ids UUID[] DEFAULT NULL ) RETURNS INTEGER AS $$ DECLARE v_count INTEGER; BEGIN IF p_notification_ids IS NULL THEN -- Marcar todas UPDATE core_notifications.notifications SET is_read = true, read_at = NOW() WHERE user_id = p_user_id AND is_read = false; ELSE -- Marcar especificas UPDATE core_notifications.notifications SET is_read = true, read_at = NOW() WHERE user_id = p_user_id AND id = ANY(p_notification_ids) AND is_read = false; END IF; GET DIAGNOSTICS v_count = ROW_COUNT; RETURN v_count; END; $$ LANGUAGE plpgsql; ``` ### Obtener Conteo de No Leidas ```sql CREATE OR REPLACE FUNCTION core_notifications.get_unread_count( p_user_id UUID ) RETURNS JSONB AS $$ BEGIN RETURN ( SELECT jsonb_build_object( 'total', COUNT(*), 'by_type', jsonb_object_agg(type, count) ) FROM ( SELECT type, COUNT(*) as count FROM core_notifications.notifications WHERE user_id = p_user_id AND is_read = false GROUP BY type ) counts ); END; $$ LANGUAGE plpgsql STABLE; ``` --- ## Seed Data ### Email Templates ```sql INSERT INTO core_notifications.email_templates (tenant_id, key, name, subject, body_html, variables, category) VALUES (NULL, 'welcome', 'Bienvenida', 'Bienvenido a {{app_name}}, {{user_name}}!', '

Hola {{user_name}}

Gracias por registrarte en {{app_name}}.

', '[{"name": "user_name", "required": true}, {"name": "app_name", "required": true}]', 'welcome'), (NULL, 'password_reset', 'Restablecer Contrasena', 'Solicitud de restablecimiento de contrasena', '

Haz click en el siguiente enlace para restablecer tu contrasena:

Restablecer', '[{"name": "reset_url", "required": true}]', 'transactional'), (NULL, 'email_verification', 'Verificar Email', 'Verifica tu direccion de email', '

Haz click para verificar tu email:

Verificar', '[{"name": "verify_url", "required": true}]', 'transactional'), (NULL, 'invoice', 'Factura', 'Factura #{{invoice_number}}', '

Adjuntamos la factura #{{invoice_number}} por {{amount}}.

', '[{"name": "invoice_number", "required": true}, {"name": "amount", "required": true}]', 'transactional'), (NULL, 'security_alert', 'Alerta de Seguridad', 'Alerta de seguridad: {{alert_title}}', '

Se ha detectado una actividad sospechosa:

{{alert_description}}

', '[{"name": "alert_title", "required": true}, {"name": "alert_description", "required": true}]', 'alert'); ``` --- ## Mantenimiento ### Limpiar Notificaciones Antiguas ```sql CREATE OR REPLACE FUNCTION core_notifications.cleanup_old_notifications( p_days_read INTEGER DEFAULT 30, p_days_unread INTEGER DEFAULT 90 ) RETURNS INTEGER AS $$ DECLARE v_deleted INTEGER := 0; BEGIN -- Eliminar leidas antiguas DELETE FROM core_notifications.notifications WHERE is_read = true AND read_at < NOW() - (p_days_read || ' days')::INTERVAL; GET DIAGNOSTICS v_deleted = ROW_COUNT; -- Eliminar no leidas muy antiguas DELETE FROM core_notifications.notifications WHERE is_read = false AND created_at < NOW() - (p_days_unread || ' days')::INTERVAL; v_deleted := v_deleted + ROW_COUNT; -- Eliminar expiradas DELETE FROM core_notifications.notifications WHERE expires_at IS NOT NULL AND expires_at < NOW(); v_deleted := v_deleted + ROW_COUNT; RETURN v_deleted; END; $$ LANGUAGE plpgsql; ``` ### Limpiar Jobs Completados ```sql CREATE OR REPLACE FUNCTION core_notifications.cleanup_completed_jobs( p_days INTEGER DEFAULT 7 ) RETURNS JSONB AS $$ DECLARE v_email_deleted INTEGER; v_push_deleted INTEGER; BEGIN DELETE FROM core_notifications.email_jobs WHERE status IN ('sent', 'cancelled') AND sent_at < NOW() - (p_days || ' days')::INTERVAL; GET DIAGNOSTICS v_email_deleted = ROW_COUNT; DELETE FROM core_notifications.push_jobs WHERE status = 'sent' AND sent_at < NOW() - (p_days || ' days')::INTERVAL; GET DIAGNOSTICS v_push_deleted = ROW_COUNT; RETURN jsonb_build_object( 'email_jobs_deleted', v_email_deleted, 'push_jobs_deleted', v_push_deleted ); END; $$ LANGUAGE plpgsql; ``` --- ## Historial | Version | Fecha | Autor | Cambios | |---------|-------|-------|---------| | 1.0 | 2025-12-05 | Requirements-Analyst | Creacion inicial |