-- ============================================================================ -- SCHEMA: education -- TABLE: courses -- DESCRIPTION: Cursos de trading y educacion financiera -- VERSION: 1.0.0 -- CREATED: 2026-01-16 -- SPRINT: Sprint 2 - DDL Implementation Roadmap Q1-2026 -- ============================================================================ -- Enum para tipo de curso DO $$ BEGIN CREATE TYPE education.course_type AS ENUM ( 'course', -- Curso completo 'workshop', -- Taller practico 'masterclass', -- Masterclass 'webinar', -- Webinar grabado 'bootcamp', -- Bootcamp intensivo 'certification' -- Programa de certificacion ); EXCEPTION WHEN duplicate_object THEN null; END $$; -- Enum para modelo de acceso DO $$ BEGIN CREATE TYPE education.access_model AS ENUM ( 'free', -- Gratis 'freemium', -- Gratis con contenido premium 'paid', -- De pago 'subscription', -- Requiere suscripcion VIP 'bundle' -- Parte de un bundle ); EXCEPTION WHEN duplicate_object THEN null; END $$; -- Tabla de Cursos CREATE TABLE IF NOT EXISTS education.courses ( -- Identificadores id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, category_id UUID REFERENCES education.categories(id) ON DELETE SET NULL, -- Informacion basica title VARCHAR(200) NOT NULL, slug VARCHAR(200) NOT NULL, subtitle VARCHAR(255), description TEXT, short_description VARCHAR(500), -- Clasificacion type education.course_type NOT NULL DEFAULT 'course', difficulty education.difficulty_level NOT NULL DEFAULT 'beginner', -- Instructor/Autor instructor_id UUID REFERENCES users.users(id), instructor_name VARCHAR(100), instructor_bio TEXT, instructor_avatar_url TEXT, -- Media thumbnail_url TEXT, banner_url TEXT, preview_video_url TEXT, trailer_url TEXT, -- Contenido objectives JSONB DEFAULT '[]'::JSONB, -- Array de objetivos de aprendizaje requirements JSONB DEFAULT '[]'::JSONB, -- Prerrequisitos target_audience JSONB DEFAULT '[]'::JSONB, -- Publico objetivo syllabus JSONB DEFAULT '[]'::JSONB, -- Temario resumido -- Duracion y estructura estimated_duration_minutes INTEGER, -- Duracion total estimada total_lessons INTEGER NOT NULL DEFAULT 0, total_quizzes INTEGER NOT NULL DEFAULT 0, total_modules INTEGER NOT NULL DEFAULT 0, -- Acceso y precio access_model education.access_model NOT NULL DEFAULT 'free', price DECIMAL(10, 2) DEFAULT 0, currency VARCHAR(3) DEFAULT 'USD', discount_price DECIMAL(10, 2), discount_ends_at TIMESTAMPTZ, -- Requisitos de suscripcion required_vip_tier VARCHAR(50), -- NULL = no requiere VIP credits_cost INTEGER DEFAULT 0, -- Costo en creditos del wallet -- Estado status education.publish_status NOT NULL DEFAULT 'draft', is_featured BOOLEAN NOT NULL DEFAULT FALSE, is_new BOOLEAN NOT NULL DEFAULT TRUE, is_bestseller BOOLEAN NOT NULL DEFAULT FALSE, -- SEO meta_title VARCHAR(100), meta_description VARCHAR(255), meta_keywords VARCHAR(255)[], -- Estadisticas (cache) enrollment_count INTEGER NOT NULL DEFAULT 0, completion_count INTEGER NOT NULL DEFAULT 0, average_rating DECIMAL(3, 2) DEFAULT 0, review_count INTEGER NOT NULL DEFAULT 0, view_count INTEGER NOT NULL DEFAULT 0, -- Certificacion has_certificate BOOLEAN NOT NULL DEFAULT FALSE, certificate_template_id UUID, passing_score INTEGER DEFAULT 70, -- % minimo para certificado -- Gamificacion xp_reward INTEGER DEFAULT 0, -- XP al completar badge_id UUID, -- Badge al completar -- Configuracion allow_reviews BOOLEAN NOT NULL DEFAULT TRUE, allow_discussions BOOLEAN NOT NULL DEFAULT TRUE, enforce_sequence BOOLEAN NOT NULL DEFAULT FALSE, -- Forzar orden de lecciones -- Metadata tags VARCHAR(50)[], metadata JSONB DEFAULT '{}'::JSONB, -- Timestamps created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), published_at TIMESTAMPTZ, last_content_update TIMESTAMPTZ, -- Constraints CONSTRAINT courses_unique_slug_per_tenant UNIQUE (tenant_id, slug), CONSTRAINT courses_price_check CHECK (price >= 0), CONSTRAINT courses_rating_check CHECK (average_rating >= 0 AND average_rating <= 5) ); COMMENT ON TABLE education.courses IS 'Cursos de trading y educacion financiera'; COMMENT ON COLUMN education.courses.enforce_sequence IS 'Si TRUE, el usuario debe completar lecciones en orden'; -- Indices CREATE INDEX IF NOT EXISTS idx_courses_tenant ON education.courses(tenant_id); CREATE INDEX IF NOT EXISTS idx_courses_category ON education.courses(category_id); CREATE INDEX IF NOT EXISTS idx_courses_instructor ON education.courses(instructor_id); CREATE INDEX IF NOT EXISTS idx_courses_slug ON education.courses(tenant_id, slug); CREATE INDEX IF NOT EXISTS idx_courses_status ON education.courses(status) WHERE status = 'published'; CREATE INDEX IF NOT EXISTS idx_courses_featured ON education.courses(tenant_id, is_featured) WHERE is_featured = TRUE AND status = 'published'; CREATE INDEX IF NOT EXISTS idx_courses_difficulty ON education.courses(difficulty); CREATE INDEX IF NOT EXISTS idx_courses_access ON education.courses(access_model); CREATE INDEX IF NOT EXISTS idx_courses_rating ON education.courses(average_rating DESC) WHERE status = 'published'; CREATE INDEX IF NOT EXISTS idx_courses_popular ON education.courses(enrollment_count DESC) WHERE status = 'published'; -- GIN index para tags CREATE INDEX IF NOT EXISTS idx_courses_tags_gin ON education.courses USING GIN (tags); -- Trigger para updated_at DROP TRIGGER IF EXISTS course_updated_at ON education.courses; CREATE TRIGGER course_updated_at BEFORE UPDATE ON education.courses FOR EACH ROW EXECUTE FUNCTION education.update_education_timestamp(); -- Trigger para actualizar conteo en categoria CREATE OR REPLACE FUNCTION education.update_category_course_count() RETURNS TRIGGER AS $$ BEGIN -- Decrementar categoria anterior IF OLD IS NOT NULL AND OLD.category_id IS NOT NULL THEN UPDATE education.categories SET course_count = course_count - 1 WHERE id = OLD.category_id; END IF; -- Incrementar categoria nueva IF NEW IS NOT NULL AND NEW.category_id IS NOT NULL AND NEW.status = 'published' THEN UPDATE education.categories SET course_count = course_count + 1 WHERE id = NEW.category_id; END IF; RETURN COALESCE(NEW, OLD); END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS course_category_count ON education.courses; CREATE TRIGGER course_category_count AFTER INSERT OR UPDATE OF category_id, status OR DELETE ON education.courses FOR EACH ROW EXECUTE FUNCTION education.update_category_course_count(); -- Vista de cursos publicados CREATE OR REPLACE VIEW education.v_published_courses AS SELECT c.id, c.tenant_id, c.category_id, cat.name AS category_name, c.title, c.slug, c.subtitle, c.short_description, c.type, c.difficulty, c.instructor_name, c.thumbnail_url, c.estimated_duration_minutes, c.total_lessons, c.access_model, c.price, c.discount_price, c.is_featured, c.is_new, c.is_bestseller, c.enrollment_count, c.average_rating, c.review_count, c.has_certificate, c.tags FROM education.courses c LEFT JOIN education.categories cat ON c.category_id = cat.id WHERE c.status = 'published' ORDER BY c.is_featured DESC, c.enrollment_count DESC; -- Vista de cursos populares CREATE OR REPLACE VIEW education.v_popular_courses AS SELECT * FROM education.v_published_courses ORDER BY enrollment_count DESC LIMIT 20; -- RLS Policy para multi-tenancy ALTER TABLE education.courses ENABLE ROW LEVEL SECURITY; CREATE POLICY courses_tenant_isolation ON education.courses FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); -- Grants GRANT SELECT, INSERT, UPDATE, DELETE ON education.courses TO trading_app; GRANT SELECT ON education.courses TO trading_readonly; GRANT SELECT ON education.v_published_courses TO trading_app; GRANT SELECT ON education.v_popular_courses TO trading_app;