= Напреден развој на базата = == Правила за запишување на курс (Enrollment) == === Опис на барањата за податочни ограничувања === Системот мора да обезбеди дека: * Кога корисникот се запишува на курс по даден course_id, автоматски се избира и запишува моментално активната верзија на курсот (course_version.active = true) за тој курс. * Не е дозволено запишување на курс за кој воопшто нема активна верзија. * Секоја верзија се третира независно: корисникот може да има повеќе запишувања на различни верзии на истиот курс, без разлика дали претходните се завршени или не. === Имплементација === ==== Тригери ==== {{{ CREATE OR REPLACE FUNCTION set_active_course_version_on_enrollment() RETURNS TRIGGER AS $$ DECLARE v_course_version_id INTEGER; BEGIN SELECT cv.id INTO v_course_version_id FROM course_version cv WHERE cv.course_id = NEW.course_id AND cv.active = TRUE ORDER BY cv.version_number DESC LIMIT 1; IF v_course_version_id IS NULL THEN RAISE EXCEPTION 'No active course version found for course_id=%', NEW.course_id; END IF; NEW.course_version_id := v_course_version_id; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_set_active_course_version_on_enrollment BEFORE INSERT ON enrollment FOR EACH ROW EXECUTE FUNCTION set_active_course_version_on_enrollment(); }}} ==== Функции / Stored Procedures ==== {{{ CREATE OR REPLACE FUNCTION create_enrollment_for_active_version(p_user_id INTEGER, p_course_id INTEGER) RETURNS INTEGER AS $$ DECLARE v_course_version_id INTEGER; v_enrollment_id INTEGER; BEGIN SELECT cv.id INTO v_course_version_id FROM course_version cv WHERE cv.course_id = p_course_id AND cv.active = TRUE ORDER BY cv.version_number DESC LIMIT 1; IF v_course_version_id IS NULL THEN RAISE EXCEPTION 'No active course version found for course_id=%', p_course_id; END IF; INSERT INTO enrollment (user_id, course_id, course_version_id, enrollment_purchase_date, status) VALUES (p_user_id, p_course_id, v_course_version_id, NOW(), 'PENDING') RETURNING id INTO v_enrollment_id; RETURN v_enrollment_id; END; $$ LANGUAGE plpgsql; }}} ==== Погледи (Views) ==== {{{ CREATE OR REPLACE VIEW enrollments_with_active_version AS SELECT e.id AS enrollment_id, u.id AS user_id, u.name AS user_name, c.id AS course_id, ct.title_short AS course_title, cv.id AS course_version_id, cv.version_number, cv.active, e.enrollment_purchase_date, e.status FROM enrollment e JOIN "user" u ON e.user_id = u.id JOIN course c ON e.course_id = c.id JOIN course_version cv ON e.course_version_id = cv.id JOIN course_translate ct ON c.id = ct.course_id AND ct.language = 'mk'; }}} ---- == Менаџирање на верзии на курс (Course Version Management) == === Опис на барањата за податочни ограничувања === Системот мора да обезбеди дека: * Само една верзија може да биде активна (active = true) по курс во исто време * Кога се активира нова верзија, претходните активни верзии на истиот курс автоматски се деактивираат * Не смее да се избрише верзија која има активни (незавршени) запишувања (enrollments) === Имплементација === ==== Тригери ==== {{{ CREATE OR REPLACE FUNCTION ensure_single_active_version() RETURNS TRIGGER AS $$ BEGIN IF NEW.active = TRUE THEN UPDATE course_version SET active = FALSE WHERE course_id = NEW.course_id AND id != NEW.id AND active = TRUE; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_ensure_single_active_version BEFORE UPDATE OF active ON course_version FOR EACH ROW WHEN (NEW.active = TRUE) EXECUTE FUNCTION ensure_single_active_version(); }}} {{{ CREATE OR REPLACE FUNCTION prevent_version_deletion_with_active_enrollments() RETURNS TRIGGER AS $$ DECLARE v_active_enrollments INTEGER; BEGIN SELECT COUNT(*) INTO v_active_enrollments FROM enrollment WHERE course_version_id = OLD.id; IF v_active_enrollments > 0 THEN RAISE EXCEPTION 'Cannot delete course version with % active enrollments', v_active_enrollments; END IF; RETURN OLD; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_prevent_version_deletion BEFORE DELETE ON course_version FOR EACH ROW EXECUTE FUNCTION prevent_version_deletion_with_active_enrollments(); }}} ==== Погледи (Views) ==== {{{ CREATE OR REPLACE VIEW course_latest_versions AS SELECT c.id AS course_id, ct.title_short AS course_title, cv.id AS version_id, cv.version_number, cv.active, cv.version_creation_date, COUNT(DISTINCT e.id) FILTER (WHERE e.completion_date IS NULL) AS active_enrollments, COUNT(DISTINCT e.id) AS total_enrollments FROM course c JOIN course_version cv ON c.id = cv.course_id JOIN course_translate ct ON c.id = ct.course_id AND ct.language = 'mk' LEFT JOIN enrollment e ON cv.id = e.course_version_id WHERE cv.version_number = ( SELECT MAX(version_number) FROM course_version WHERE course_id = c.id ) GROUP BY c.id, ct.title_short, cv.id, cv.version_number, cv.active, cv.version_creation_date; }}} ---- == Валидација на рецензии и рејтинг (Review Validation & Rating) == === Опис на барањата за податочни ограничувања === Системот мора да обезбеди дека: * Корисникот може да остави рецензија само ако enrollment е завршен (completion_date IS NOT NULL) * Еден корисник може да остави само една рецензија по enrollment * Рејтингот мора да биде валиден број од 1 до 5 * Сите промени на рецензии се евидентираат во audit табела === Имплементација === ==== Прилагодени домени (Custom Domains) ==== {{{ CREATE DOMAIN rating_scale AS INTEGER CHECK (VALUE >= 1 AND VALUE <= 5); ALTER TABLE review ALTER COLUMN rating TYPE rating_scale; }}} ==== Тригери ==== {{{ CREATE OR REPLACE FUNCTION validate_review_before_insert() RETURNS TRIGGER AS $$ DECLARE v_completion_date TIMESTAMP; v_existing_review_count INTEGER; BEGIN SELECT completion_date INTO v_completion_date FROM enrollment WHERE id = NEW.enrollment_id; IF v_completion_date IS NULL THEN RAISE EXCEPTION 'Cannot create review for incomplete enrollment'; END IF; SELECT COUNT(*) INTO v_existing_review_count FROM review WHERE enrollment_id = NEW.enrollment_id; IF v_existing_review_count > 0 THEN RAISE EXCEPTION 'Review already exists for this enrollment'; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_validate_review BEFORE INSERT ON review FOR EACH ROW EXECUTE FUNCTION validate_review_before_insert(); }}} ==== Погледи (Views) ==== {{{ CREATE OR REPLACE VIEW course_average_ratings AS SELECT c.id AS course_id, ct.title_short AS course_title, COUNT(r.id) AS total_reviews, AVG(r.rating)::NUMERIC(3,2) AS average_rating, COUNT(r.id) FILTER (WHERE r.rating = 5) AS five_star_count, COUNT(r.id) FILTER (WHERE r.rating = 4) AS four_star_count, COUNT(r.id) FILTER (WHERE r.rating = 3) AS three_star_count, COUNT(r.id) FILTER (WHERE r.rating = 2) AS two_star_count, COUNT(r.id) FILTER (WHERE r.rating = 1) AS one_star_count FROM course c JOIN course_translate ct ON c.id = ct.course_id AND ct.language = 'mk' LEFT JOIN course_version cv ON c.id = cv.course_id LEFT JOIN enrollment e ON cv.id = e.course_version_id LEFT JOIN review r ON e.id = r.enrollment_id GROUP BY c.id, ct.title_short; }}} ---- == Следење и ограничување на бесплатни консултации == === Опис на барањата за податочни ограничувања === Системот мора да обезбеди дека: * Корисникот може да користи само една бесплатна консултација * Може да се креира meeting_reminder за бесплатна консултација само ако корисникот сè уште не ја искористил * После креирање на meeting_reminder за бесплатна консултација, флагот user_used_free_consultation автоматски се поставува на TRUE === Имплементација === ==== Тригери ==== {{{ CREATE OR REPLACE FUNCTION check_free_consultation_eligibility() RETURNS TRIGGER AS $$ DECLARE v_used_free_consultation BOOLEAN; BEGIN SELECT user_used_free_consultation INTO v_used_free_consultation FROM "user" WHERE id = NEW.user_id; IF v_used_free_consultation = TRUE THEN RAISE EXCEPTION 'User has already used their free consultation'; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_check_free_consultation_before_meeting BEFORE INSERT ON meeting_reminder FOR EACH ROW EXECUTE FUNCTION check_free_consultation_eligibility(); }}} {{{ CREATE OR REPLACE FUNCTION mark_free_consultation_as_used() RETURNS TRIGGER AS $$ BEGIN UPDATE "user" SET user_used_free_consultation = TRUE WHERE id = NEW.user_id AND user_used_free_consultation = FALSE; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_mark_free_consultation_used AFTER INSERT ON meeting_reminder FOR EACH ROW EXECUTE FUNCTION mark_free_consultation_as_used(); }}} ==== Функции / Stored Procedures ==== {{{ CREATE OR REPLACE FUNCTION can_user_schedule_free_consultation(p_user_id INTEGER) RETURNS TABLE( can_schedule BOOLEAN, reason TEXT ) AS $$ DECLARE v_used_free_consultation BOOLEAN; v_pending_meetings INTEGER; BEGIN SELECT user_used_free_consultation INTO v_used_free_consultation FROM "user" WHERE id = p_user_id; IF v_used_free_consultation = TRUE THEN RETURN QUERY SELECT FALSE, 'Free consultation already used'; RETURN; END IF; SELECT COUNT(*) INTO v_pending_meetings FROM meeting_reminder WHERE user_id = p_user_id AND meeting_at > NOW(); IF v_pending_meetings > 0 THEN RETURN QUERY SELECT FALSE, 'Already has pending meeting scheduled'; RETURN; END IF; RETURN QUERY SELECT TRUE, 'Eligible for free consultation'; END; $$ LANGUAGE plpgsql; }}}