-- ============================================================================ -- SCHEMA: education -- TABLE: certificates -- DESCRIPTION: Certificados de finalizacion de cursos -- VERSION: 1.0.0 -- CREATED: 2026-01-16 -- SPRINT: Sprint 2 - DDL Implementation Roadmap Q1-2026 -- ============================================================================ -- Enum para tipo de certificado DO $$ BEGIN CREATE TYPE education.certificate_type AS ENUM ( 'completion', -- Certificado de finalizacion 'achievement', -- Certificado de logro especifico 'participation', -- Certificado de participacion 'excellence', -- Certificado de excelencia (alta puntuacion) 'professional' -- Certificacion profesional ); EXCEPTION WHEN duplicate_object THEN null; END $$; -- Tabla de Certificados CREATE TABLE IF NOT EXISTS education.certificates ( -- Identificadores id UUID PRIMARY KEY DEFAULT gen_random_uuid(), certificate_number VARCHAR(50) NOT NULL UNIQUE, -- Numero unico verificable enrollment_id UUID NOT NULL REFERENCES education.enrollments(id) ON DELETE CASCADE, course_id UUID NOT NULL REFERENCES education.courses(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, -- Tipo type education.certificate_type NOT NULL DEFAULT 'completion', -- Datos del certificado title VARCHAR(200) NOT NULL, description TEXT, recipient_name VARCHAR(200) NOT NULL, -- Nombre como aparece en certificado -- Logro final_score DECIMAL(5, 2), grade VARCHAR(2), completion_date DATE NOT NULL DEFAULT CURRENT_DATE, hours_completed INTEGER, -- Template y diseƱo template_id UUID, template_data JSONB DEFAULT '{}'::JSONB, -- Datos para el template background_url TEXT, -- Archivos generados pdf_url TEXT, image_url TEXT, linkedin_url TEXT, -- URL para compartir en LinkedIn -- Verificacion verification_code VARCHAR(20) NOT NULL, -- Codigo corto para verificar verification_url TEXT, qr_code_url TEXT, -- Blockchain (opcional) blockchain_hash VARCHAR(66), -- Hash de verificacion en blockchain blockchain_network VARCHAR(50), blockchain_tx_id VARCHAR(66), -- Firmantes signed_by JSONB DEFAULT '[]'::JSONB, -- [{ "name": "...", "title": "...", "signature_url": "..." }] -- Expiracion (para certificaciones profesionales) expires_at TIMESTAMPTZ, is_expired BOOLEAN NOT NULL DEFAULT FALSE, renewal_available BOOLEAN NOT NULL DEFAULT FALSE, -- Estado is_valid BOOLEAN NOT NULL DEFAULT TRUE, revoked_at TIMESTAMPTZ, revocation_reason TEXT, -- Compartido is_public BOOLEAN NOT NULL DEFAULT FALSE, public_url TEXT, share_count INTEGER NOT NULL DEFAULT 0, view_count INTEGER NOT NULL DEFAULT 0, -- Metadata metadata JSONB DEFAULT '{}'::JSONB, -- Timestamps issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- Constraints CONSTRAINT certificates_unique_enrollment UNIQUE (enrollment_id, type) ); COMMENT ON TABLE education.certificates IS 'Certificados de finalizacion emitidos a estudiantes'; COMMENT ON COLUMN education.certificates.certificate_number IS 'Numero unico para verificacion publica del certificado'; COMMENT ON COLUMN education.certificates.verification_code IS 'Codigo corto (6-8 caracteres) para verificacion rapida'; -- Indices CREATE INDEX IF NOT EXISTS idx_certificates_enrollment ON education.certificates(enrollment_id); CREATE INDEX IF NOT EXISTS idx_certificates_course ON education.certificates(course_id); CREATE INDEX IF NOT EXISTS idx_certificates_user ON education.certificates(user_id); CREATE INDEX IF NOT EXISTS idx_certificates_tenant ON education.certificates(tenant_id); CREATE INDEX IF NOT EXISTS idx_certificates_number ON education.certificates(certificate_number); CREATE INDEX IF NOT EXISTS idx_certificates_verification ON education.certificates(verification_code); CREATE INDEX IF NOT EXISTS idx_certificates_type ON education.certificates(type); CREATE INDEX IF NOT EXISTS idx_certificates_valid ON education.certificates(is_valid) WHERE is_valid = TRUE; CREATE INDEX IF NOT EXISTS idx_certificates_public ON education.certificates(is_public, issued_at DESC) WHERE is_public = TRUE; CREATE INDEX IF NOT EXISTS idx_certificates_expires ON education.certificates(expires_at) WHERE expires_at IS NOT NULL AND is_valid = TRUE; -- Trigger para updated_at DROP TRIGGER IF EXISTS certificate_updated_at ON education.certificates; CREATE TRIGGER certificate_updated_at BEFORE UPDATE ON education.certificates FOR EACH ROW EXECUTE FUNCTION education.update_education_timestamp(); -- Funcion para generar numero de certificado CREATE OR REPLACE FUNCTION education.generate_certificate_number() RETURNS TEXT AS $$ DECLARE v_year TEXT := TO_CHAR(NOW(), 'YYYY'); v_seq TEXT; BEGIN -- Formato: CERT-YYYY-XXXXXX (ej: CERT-2026-000123) SELECT LPAD(COALESCE(MAX( NULLIF(SUBSTRING(certificate_number FROM 11), '')::INTEGER ), 0) + 1, 6, '0') INTO v_seq FROM education.certificates WHERE certificate_number LIKE 'CERT-' || v_year || '-%'; RETURN 'CERT-' || v_year || '-' || v_seq; END; $$ LANGUAGE plpgsql; -- Funcion para generar codigo de verificacion CREATE OR REPLACE FUNCTION education.generate_verification_code() RETURNS TEXT AS $$ DECLARE v_chars TEXT := 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; -- Sin I, O, 0, 1 para evitar confusion v_code TEXT := ''; i INTEGER; BEGIN FOR i IN 1..8 LOOP v_code := v_code || SUBSTRING(v_chars FROM FLOOR(RANDOM() * LENGTH(v_chars) + 1)::INTEGER FOR 1); END LOOP; RETURN v_code; END; $$ LANGUAGE plpgsql; -- Trigger para generar numeros automaticamente CREATE OR REPLACE FUNCTION education.set_certificate_numbers() RETURNS TRIGGER AS $$ BEGIN IF NEW.certificate_number IS NULL THEN NEW.certificate_number := education.generate_certificate_number(); END IF; IF NEW.verification_code IS NULL THEN NEW.verification_code := education.generate_verification_code(); END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS certificate_numbers ON education.certificates; CREATE TRIGGER certificate_numbers BEFORE INSERT ON education.certificates FOR EACH ROW EXECUTE FUNCTION education.set_certificate_numbers(); -- Funcion para emitir certificado CREATE OR REPLACE FUNCTION education.issue_certificate( p_enrollment_id UUID, p_type education.certificate_type DEFAULT 'completion' ) RETURNS UUID AS $$ DECLARE v_enrollment RECORD; v_course RECORD; v_user RECORD; v_cert_id UUID; BEGIN -- Obtener enrollment SELECT * INTO v_enrollment FROM education.enrollments WHERE id = p_enrollment_id; IF v_enrollment IS NULL THEN RAISE EXCEPTION 'Enrollment not found'; END IF; -- Verificar que el curso este completado IF v_enrollment.status != 'completed' AND p_type = 'completion' THEN RAISE EXCEPTION 'Course not completed'; END IF; -- Obtener curso SELECT * INTO v_course FROM education.courses WHERE id = v_enrollment.course_id; -- Obtener usuario SELECT * INTO v_user FROM users.users WHERE id = v_enrollment.user_id; -- Verificar si ya existe IF EXISTS ( SELECT 1 FROM education.certificates WHERE enrollment_id = p_enrollment_id AND type = p_type ) THEN RAISE EXCEPTION 'Certificate already issued'; END IF; -- Crear certificado INSERT INTO education.certificates ( enrollment_id, course_id, user_id, tenant_id, type, title, recipient_name, final_score, grade, hours_completed ) VALUES ( p_enrollment_id, v_enrollment.course_id, v_enrollment.user_id, v_enrollment.tenant_id, p_type, v_course.title || ' - Certificate of ' || CASE p_type WHEN 'completion' THEN 'Completion' WHEN 'excellence' THEN 'Excellence' WHEN 'participation' THEN 'Participation' ELSE 'Achievement' END, COALESCE(v_user.first_name || ' ' || v_user.last_name, v_user.email), v_enrollment.final_score, v_enrollment.final_grade, v_course.estimated_duration_minutes / 60 ) RETURNING id INTO v_cert_id; -- Actualizar enrollment UPDATE education.enrollments SET certificate_issued = TRUE, certificate_id = v_cert_id, certificate_issued_at = NOW() WHERE id = p_enrollment_id; RETURN v_cert_id; END; $$ LANGUAGE plpgsql; -- Funcion para verificar certificado CREATE OR REPLACE FUNCTION education.verify_certificate( p_code VARCHAR(50) ) RETURNS TABLE ( is_valid BOOLEAN, certificate_number VARCHAR(50), recipient_name VARCHAR(200), course_title VARCHAR(200), completion_date DATE, grade VARCHAR(2), issued_at TIMESTAMPTZ, expires_at TIMESTAMPTZ ) AS $$ BEGIN RETURN QUERY SELECT c.is_valid AND (c.expires_at IS NULL OR c.expires_at > NOW()), c.certificate_number, c.recipient_name, co.title, c.completion_date, c.grade, c.issued_at, c.expires_at FROM education.certificates c JOIN education.courses co ON c.course_id = co.id WHERE c.certificate_number = p_code OR c.verification_code = p_code; END; $$ LANGUAGE plpgsql; -- Vista de mis certificados CREATE OR REPLACE VIEW education.v_my_certificates AS SELECT c.id, c.certificate_number, c.course_id, co.title AS course_title, co.thumbnail_url AS course_thumbnail, c.type, c.title AS certificate_title, c.recipient_name, c.final_score, c.grade, c.completion_date, c.pdf_url, c.image_url, c.verification_code, c.is_public, c.public_url, c.expires_at, c.is_valid, c.issued_at FROM education.certificates c JOIN education.courses co ON c.course_id = co.id WHERE c.is_valid = TRUE ORDER BY c.issued_at DESC; -- Vista de certificados publicos de un curso CREATE OR REPLACE VIEW education.v_course_certificates AS SELECT c.id, c.course_id, c.recipient_name, c.type, c.grade, c.completion_date, c.issued_at FROM education.certificates c WHERE c.is_public = TRUE AND c.is_valid = TRUE ORDER BY c.issued_at DESC; -- RLS Policy para multi-tenancy ALTER TABLE education.certificates ENABLE ROW LEVEL SECURITY; CREATE POLICY certificates_tenant_isolation ON education.certificates FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); CREATE POLICY certificates_user_isolation ON education.certificates FOR SELECT USING (user_id = current_setting('app.current_user_id', true)::UUID); -- Policy especial para verificacion publica CREATE POLICY certificates_public_verify ON education.certificates FOR SELECT USING (is_public = TRUE OR is_valid = TRUE); -- Grants GRANT SELECT, INSERT, UPDATE ON education.certificates TO trading_app; GRANT SELECT ON education.certificates TO trading_readonly; GRANT SELECT ON education.v_my_certificates TO trading_app; GRANT SELECT ON education.v_course_certificates TO trading_app; GRANT EXECUTE ON FUNCTION education.issue_certificate TO trading_app; GRANT EXECUTE ON FUNCTION education.verify_certificate TO trading_app; GRANT EXECUTE ON FUNCTION education.generate_certificate_number TO trading_app; GRANT EXECUTE ON FUNCTION education.generate_verification_code TO trading_app;