-- ============================================================================ -- SCHEMA: education -- TABLE: course_reviews -- DESCRIPTION: Resenas y calificaciones de cursos -- VERSION: 1.0.0 -- CREATED: 2026-01-16 -- SPRINT: Sprint 2 - DDL Implementation Roadmap Q1-2026 -- ============================================================================ -- Tabla de Resenas de Cursos CREATE TABLE IF NOT EXISTS education.course_reviews ( -- Identificadores id UUID PRIMARY KEY DEFAULT gen_random_uuid(), course_id UUID NOT NULL REFERENCES education.courses(id) ON DELETE CASCADE, enrollment_id UUID NOT NULL REFERENCES education.enrollments(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE, tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, -- Calificacion rating INTEGER NOT NULL CHECK (rating BETWEEN 1 AND 5), -- Calificaciones detalladas rating_content INTEGER CHECK (rating_content BETWEEN 1 AND 5), rating_instructor INTEGER CHECK (rating_instructor BETWEEN 1 AND 5), rating_value INTEGER CHECK (rating_value BETWEEN 1 AND 5), -- Resena title VARCHAR(200), review_text TEXT, -- Estado status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected', 'flagged')), rejection_reason TEXT, flagged_reason TEXT, -- Moderacion is_verified_purchase BOOLEAN NOT NULL DEFAULT TRUE, is_featured BOOLEAN NOT NULL DEFAULT FALSE, moderated_by UUID REFERENCES users.users(id), moderated_at TIMESTAMPTZ, -- Utilidad helpful_count INTEGER NOT NULL DEFAULT 0, not_helpful_count INTEGER NOT NULL DEFAULT 0, -- Respuesta del instructor instructor_response TEXT, instructor_response_at TIMESTAMPTZ, -- Edicion is_edited BOOLEAN NOT NULL DEFAULT FALSE, edited_at TIMESTAMPTZ, original_review_text TEXT, -- Progreso al momento de la resena progress_at_review DECIMAL(5, 2), -- Metadata metadata JSONB DEFAULT '{}'::JSONB, -- Timestamps created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- Constraints CONSTRAINT course_reviews_unique_user UNIQUE (user_id, course_id) ); COMMENT ON TABLE education.course_reviews IS 'Resenas y calificaciones de cursos por estudiantes'; COMMENT ON COLUMN education.course_reviews.is_verified_purchase IS 'TRUE si el usuario realmente completo/compro el curso'; -- Indices CREATE INDEX IF NOT EXISTS idx_course_reviews_course ON education.course_reviews(course_id, status) WHERE status = 'approved'; CREATE INDEX IF NOT EXISTS idx_course_reviews_user ON education.course_reviews(user_id); CREATE INDEX IF NOT EXISTS idx_course_reviews_tenant ON education.course_reviews(tenant_id); CREATE INDEX IF NOT EXISTS idx_course_reviews_rating ON education.course_reviews(course_id, rating DESC) WHERE status = 'approved'; CREATE INDEX IF NOT EXISTS idx_course_reviews_featured ON education.course_reviews(course_id, is_featured) WHERE is_featured = TRUE AND status = 'approved'; CREATE INDEX IF NOT EXISTS idx_course_reviews_helpful ON education.course_reviews(course_id, helpful_count DESC) WHERE status = 'approved'; CREATE INDEX IF NOT EXISTS idx_course_reviews_pending ON education.course_reviews(status, created_at) WHERE status = 'pending'; -- Trigger para updated_at DROP TRIGGER IF EXISTS course_review_updated_at ON education.course_reviews; CREATE TRIGGER course_review_updated_at BEFORE UPDATE ON education.course_reviews FOR EACH ROW EXECUTE FUNCTION education.update_education_timestamp(); -- Trigger para actualizar rating promedio del curso CREATE OR REPLACE FUNCTION education.update_course_rating() RETURNS TRIGGER AS $$ DECLARE v_avg_rating DECIMAL(3, 2); v_review_count INTEGER; BEGIN -- Calcular nuevo promedio SELECT COALESCE(AVG(rating), 0), COUNT(*) INTO v_avg_rating, v_review_count FROM education.course_reviews WHERE course_id = COALESCE(NEW.course_id, OLD.course_id) AND status = 'approved'; -- Actualizar curso UPDATE education.courses SET average_rating = v_avg_rating, review_count = v_review_count WHERE id = COALESCE(NEW.course_id, OLD.course_id); RETURN COALESCE(NEW, OLD); END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS course_review_rating ON education.course_reviews; CREATE TRIGGER course_review_rating AFTER INSERT OR UPDATE OF rating, status OR DELETE ON education.course_reviews FOR EACH ROW EXECUTE FUNCTION education.update_course_rating(); -- Trigger para guardar texto original al editar CREATE OR REPLACE FUNCTION education.save_original_review() RETURNS TRIGGER AS $$ BEGIN IF NEW.review_text IS DISTINCT FROM OLD.review_text AND OLD.original_review_text IS NULL THEN NEW.original_review_text := OLD.review_text; NEW.is_edited := TRUE; NEW.edited_at := NOW(); END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS course_review_edit ON education.course_reviews; CREATE TRIGGER course_review_edit BEFORE UPDATE OF review_text ON education.course_reviews FOR EACH ROW EXECUTE FUNCTION education.save_original_review(); -- Tabla auxiliar para votos de utilidad CREATE TABLE IF NOT EXISTS education.review_votes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), review_id UUID NOT NULL REFERENCES education.course_reviews(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE, is_helpful BOOLEAN NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT review_votes_unique UNIQUE (review_id, user_id) ); CREATE INDEX IF NOT EXISTS idx_review_votes_review ON education.review_votes(review_id); CREATE INDEX IF NOT EXISTS idx_review_votes_user ON education.review_votes(user_id); -- Trigger para actualizar contadores de votos CREATE OR REPLACE FUNCTION education.update_review_vote_counts() RETURNS TRIGGER AS $$ BEGIN IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN UPDATE education.course_reviews SET helpful_count = ( SELECT COUNT(*) FROM education.review_votes WHERE review_id = NEW.review_id AND is_helpful = TRUE ), not_helpful_count = ( SELECT COUNT(*) FROM education.review_votes WHERE review_id = NEW.review_id AND is_helpful = FALSE ) WHERE id = NEW.review_id; ELSIF TG_OP = 'DELETE' THEN UPDATE education.course_reviews SET helpful_count = ( SELECT COUNT(*) FROM education.review_votes WHERE review_id = OLD.review_id AND is_helpful = TRUE ), not_helpful_count = ( SELECT COUNT(*) FROM education.review_votes WHERE review_id = OLD.review_id AND is_helpful = FALSE ) WHERE id = OLD.review_id; END IF; RETURN COALESCE(NEW, OLD); END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS review_vote_counts ON education.review_votes; CREATE TRIGGER review_vote_counts AFTER INSERT OR UPDATE OR DELETE ON education.review_votes FOR EACH ROW EXECUTE FUNCTION education.update_review_vote_counts(); -- Vista de resenas de un curso CREATE OR REPLACE VIEW education.v_course_reviews AS SELECT r.id, r.course_id, r.user_id, u.first_name, u.last_name, u.avatar_url, r.rating, r.rating_content, r.rating_instructor, r.rating_value, r.title, r.review_text, r.is_verified_purchase, r.is_featured, r.helpful_count, r.instructor_response, r.instructor_response_at, r.is_edited, r.progress_at_review, r.created_at FROM education.course_reviews r JOIN users.users u ON r.user_id = u.id WHERE r.status = 'approved' ORDER BY r.is_featured DESC, r.helpful_count DESC, r.created_at DESC; -- Vista de distribucion de ratings CREATE OR REPLACE VIEW education.v_course_rating_distribution AS SELECT course_id, rating, COUNT(*) AS count, (COUNT(*)::DECIMAL / SUM(COUNT(*)) OVER (PARTITION BY course_id) * 100) AS percentage FROM education.course_reviews WHERE status = 'approved' GROUP BY course_id, rating ORDER BY course_id, rating DESC; -- RLS Policy para multi-tenancy ALTER TABLE education.course_reviews ENABLE ROW LEVEL SECURITY; CREATE POLICY course_reviews_tenant_isolation ON education.course_reviews FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); ALTER TABLE education.review_votes ENABLE ROW LEVEL SECURITY; CREATE POLICY review_votes_user_isolation ON education.review_votes FOR ALL USING (user_id = current_setting('app.current_user_id', true)::UUID); -- Grants GRANT SELECT, INSERT, UPDATE ON education.course_reviews TO trading_app; GRANT SELECT ON education.course_reviews TO trading_readonly; GRANT SELECT, INSERT, UPDATE, DELETE ON education.review_votes TO trading_app; GRANT SELECT ON education.v_course_reviews TO trading_app; GRANT SELECT ON education.v_course_rating_distribution TO trading_app;