## 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>
272 lines
9.0 KiB
PL/PgSQL
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;
|