trading-platform-database-v2/ddl/schemas/education/tables/010_course_reviews.sql
rckrdmrd cd6590ec25 [DDL] feat: Sprint 2 - Add education schema with 11 tables
## Schema: education (Complete Learning Management System)

### Course Structure (3 tables):
- categories: Hierarchical course categories with materialized paths
- courses: Full course catalog with pricing, access models, gamification
- modules: Course sections/modules with sequencing

### Content (3 tables):
- lessons: Individual lessons (video, article, interactive)
- quizzes: Assessments with configurable rules
- quiz_questions: Question bank with multiple types

### Progress Tracking (3 tables):
- enrollments: User enrollments with progress tracking
- lesson_progress: Detailed per-lesson progress
- quiz_attempts: Quiz attempt history with grading

### Completion (2 tables):
- course_reviews: Student reviews with moderation
- certificates: Verifiable completion certificates

## Features:
- 8 custom ENUMs for education domain
- Multi-tenancy with RLS policies
- Automatic progress calculation triggers
- Quiz grading and statistics
- Certificate generation with verification codes
- Rating aggregation for courses
- Gamification support (XP, badges)

Total: 11 tables, ~95KB of DDL

Roadmap: orchestration/planes/ROADMAP-IMPLEMENTACION-DDL-2026-Q1.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 19:48:39 -06:00

272 lines
9.0 KiB
PL/PgSQL

-- ============================================================================
-- 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;