Compare commits

..

No commits in common. "0226275d47b379496f069cd30d59bf4be945e3ee" and "9cb1c3e32b26510f58e9269d837c4f25e3250b5f" have entirely different histories.

29 changed files with 187 additions and 1077 deletions

View File

@ -1,316 +0,0 @@
-- ======================================================
-- Complete Course Management Seed Data
-- Covers: categories, courses, sub-courses, videos,
-- question sets, questions, options, prerequisites,
-- and user progress for admin panel integration
-- ======================================================
-- ======================================================
-- Course Categories (supplement existing 3 categories)
-- Existing: 1=Programming, 2=Data Science, 3=Web Development
-- ======================================================
INSERT INTO course_categories (id, name, is_active, created_at) VALUES
(4, 'Mobile Development', TRUE, CURRENT_TIMESTAMP),
(5, 'DevOps & Cloud', TRUE, CURRENT_TIMESTAMP),
(6, 'Cybersecurity', FALSE, CURRENT_TIMESTAMP)
ON CONFLICT (id) DO NOTHING;
-- ======================================================
-- Courses (supplement existing 7 courses)
-- Existing: 1-7 in categories 1-3
-- ======================================================
INSERT INTO courses (id, category_id, title, description, thumbnail, intro_video_url, is_active) VALUES
(8, 4, 'Flutter App Development', 'Build cross-platform mobile apps with Flutter and Dart', 'https://example.com/thumbnails/flutter.jpg', 'https://example.com/intro/flutter.mp4', TRUE),
(9, 4, 'React Native Essentials', 'Create native mobile apps using React Native', 'https://example.com/thumbnails/react-native.jpg', NULL, TRUE),
(10, 5, 'Docker & Kubernetes', 'Container orchestration and deployment strategies', 'https://example.com/thumbnails/docker-k8s.jpg', 'https://example.com/intro/docker.mp4', TRUE),
(11, 5, 'CI/CD Pipeline Mastery', 'Automate your build, test, and deployment workflows', 'https://example.com/thumbnails/cicd.jpg', NULL, FALSE),
(12, 6, 'Ethical Hacking Fundamentals', 'Learn penetration testing and security analysis', 'https://example.com/thumbnails/ethical-hacking.jpg', NULL, FALSE)
ON CONFLICT (id) DO NOTHING;
-- ======================================================
-- Sub-courses (supplement existing 17 sub-courses: IDs 1-17)
-- ======================================================
INSERT INTO sub_courses (id, course_id, title, description, thumbnail, display_order, level, is_active) VALUES
-- Flutter sub-courses (course 8) — IDs 18-21
(18, 8, 'Dart Language Basics', 'Learn Dart programming language fundamentals', NULL, 1, 'BEGINNER', TRUE),
(19, 8, 'Flutter UI Widgets', 'Build beautiful UIs with Flutter widgets', NULL, 2, 'BEGINNER', TRUE),
(20, 8, 'State Management', 'Manage app state with Provider and Riverpod', NULL, 3, 'INTERMEDIATE', TRUE),
(21, 8, 'Flutter Networking & APIs', 'HTTP requests, REST APIs, and data persistence', NULL, 4, 'ADVANCED', TRUE),
-- React Native sub-courses (course 9) — IDs 22-24
(22, 9, 'React Native Setup', 'Environment setup and first app', NULL, 1, 'BEGINNER', TRUE),
(23, 9, 'Navigation & Routing', 'React Navigation and screen management', NULL, 2, 'INTERMEDIATE', TRUE),
(24, 9, 'Native Modules', 'Bridge native code with React Native', NULL, 3, 'ADVANCED', TRUE),
-- Docker & Kubernetes sub-courses (course 10) — IDs 25-27
(25, 10, 'Docker Fundamentals', 'Containers, images, and Dockerfiles', NULL, 1, 'BEGINNER', TRUE),
(26, 10, 'Docker Compose', 'Multi-container applications', NULL, 2, 'INTERMEDIATE', TRUE),
(27, 10, 'Kubernetes Basics', 'Pods, services, and deployments', NULL, 3, 'ADVANCED', TRUE),
-- CI/CD sub-courses (course 11) — IDs 28-29
(28, 11, 'Git Workflows', 'Branching strategies and pull requests', NULL, 1, 'BEGINNER', TRUE),
(29, 11, 'GitHub Actions', 'Automate workflows with GitHub Actions', NULL, 2, 'INTERMEDIATE', TRUE),
-- Cybersecurity sub-courses (course 12) — IDs 30-31
(30, 12, 'Network Security Basics', 'Firewalls, VPNs, and network protocols', NULL, 1, 'BEGINNER', TRUE),
(31, 12, 'Penetration Testing', 'Tools and techniques for pen testing', NULL, 2, 'ADVANCED', TRUE)
ON CONFLICT (id) DO NOTHING;
-- ======================================================
-- Sub-course Videos (supplement existing 5 videos: IDs 1-5)
-- ======================================================
INSERT INTO sub_course_videos (
id, sub_course_id, title, description, video_url,
duration, resolution, visibility, display_order, status,
video_host_provider, vimeo_id, vimeo_embed_url, vimeo_status
) VALUES
-- Dart Language Basics videos (sub_course 18)
(6, 18, 'Introduction to Dart', 'Overview of Dart programming language', 'https://example.com/dart-intro.mp4', 720, '1080p', 'public', 1, 'PUBLISHED', 'DIRECT', NULL, NULL, NULL),
(7, 18, 'Variables and Data Types', 'Dart variables, constants, and types', 'https://example.com/dart-variables.mp4', 900, '1080p', 'public', 2, 'PUBLISHED', 'DIRECT', NULL, NULL, NULL),
(8, 18, 'Control Flow in Dart', 'If/else, loops, and switch statements', 'https://example.com/dart-control.mp4', 1100, '720p', 'public', 3, 'DRAFT', 'DIRECT', NULL, NULL, NULL),
-- Flutter UI Widgets videos (sub_course 19)
(9, 19, 'Widget Tree Basics', 'Understanding the Flutter widget tree', 'https://player.vimeo.com/video/100000001', 1500, '1080p', 'public', 1, 'PUBLISHED', 'VIMEO', '100000001', 'https://player.vimeo.com/video/100000001', 'available'),
(10, 19, 'Layout Widgets', 'Row, Column, Stack, and Container widgets', 'https://player.vimeo.com/video/100000002', 1800, '1080p', 'public', 2, 'PUBLISHED', 'VIMEO', '100000002', 'https://player.vimeo.com/video/100000002', 'available'),
(11, 19, 'Custom Widgets', 'Building reusable custom widgets', 'https://player.vimeo.com/video/100000003', 2100, '1080p', 'public', 3, 'DRAFT', 'VIMEO', '100000003', 'https://player.vimeo.com/video/100000003', 'transcoding'),
-- State Management videos (sub_course 20)
(12, 20, 'setState and Stateful Widgets', 'Managing local state in Flutter', 'https://example.com/flutter-setstate.mp4', 1200, '1080p', 'public', 1, 'PUBLISHED', 'DIRECT', NULL, NULL, NULL),
(13, 20, 'Provider Pattern', 'Global state management with Provider', 'https://example.com/flutter-provider.mp4', 1600, '1080p', 'public', 2, 'PUBLISHED', 'DIRECT', NULL, NULL, NULL),
-- Docker Fundamentals videos (sub_course 25)
(14, 25, 'What is Docker?', 'Introduction to containerization', 'https://example.com/docker-intro.mp4', 600, '1080p', 'public', 1, 'PUBLISHED', 'DIRECT', NULL, NULL, NULL),
(15, 25, 'Building Docker Images', 'Writing Dockerfiles and building images', 'https://example.com/docker-images.mp4', 1400, '1080p', 'public', 2, 'PUBLISHED', 'DIRECT', NULL, NULL, NULL),
-- Docker Compose videos (sub_course 26)
(16, 26, 'Docker Compose Basics', 'Defining multi-container applications', 'https://example.com/compose-basics.mp4', 1300, '1080p', 'public', 1, 'PUBLISHED', 'DIRECT', NULL, NULL, NULL),
-- React Native Setup videos (sub_course 22)
(17, 22, 'Setting Up React Native', 'Installing React Native CLI and Expo', 'https://example.com/rn-setup.mp4', 900, '1080p', 'public', 1, 'PUBLISHED', 'DIRECT', NULL, NULL, NULL),
(18, 22, 'Your First React Native App', 'Creating and running a basic app', 'https://example.com/rn-first-app.mp4', 1100, '1080p', 'public', 2, 'PUBLISHED', 'DIRECT', NULL, NULL, NULL)
ON CONFLICT (id) DO NOTHING;
-- ======================================================
-- Question Options for existing practice questions (17-20)
-- These were missing from the initial seed
-- ======================================================
INSERT INTO question_options (question_id, option_text, option_order, is_correct) VALUES
-- Q17: What is the correct way to print "Hello World" in Python?
(17, 'print("Hello World")', 1, TRUE),
(17, 'echo "Hello World"', 2, FALSE),
(17, 'console.log("Hello World")', 3, FALSE),
(17, 'System.out.println("Hello World")', 4, FALSE),
-- Q18: Which is a valid Python variable name?
(18, '2name', 1, FALSE),
(18, 'my_name', 2, TRUE),
(18, 'my-name', 3, FALSE),
(18, 'class', 4, FALSE),
-- Q19: How do you convert "123" to an integer?
(19, 'int("123")', 1, TRUE),
(19, 'integer("123")', 2, FALSE),
(19, 'str(123)', 3, FALSE),
(19, 'toInt("123")', 4, FALSE),
-- Q20: How many times does range(3) loop run?
(20, '2', 1, FALSE),
(20, '3', 2, TRUE),
(20, '4', 3, FALSE),
(20, '1', 4, FALSE);
-- ======================================================
-- Additional Practice Questions for new sub-courses
-- ======================================================
INSERT INTO questions (id, question_text, question_type, tips, status) VALUES
(21, 'What keyword is used to declare a variable in Dart?', 'MCQ', 'Dart uses var, final, or const', 'PUBLISHED'),
(22, 'Which widget is the root of every Flutter app?', 'MCQ', 'Think about the main() function', 'PUBLISHED'),
(23, 'What is a StatefulWidget?', 'MCQ', 'Consider mutable state', 'PUBLISHED'),
(24, 'What command creates a Docker container from an image?', 'MCQ', 'Think about docker run', 'PUBLISHED'),
(25, 'What file defines a Docker Compose application?', 'MCQ', 'It is a YAML file', 'PUBLISHED'),
(26, 'Which tool is used to create a new React Native project?', 'MCQ', 'Consider npx or expo', 'PUBLISHED')
ON CONFLICT (id) DO NOTHING;
INSERT INTO question_options (question_id, option_text, option_order, is_correct) VALUES
-- Q21: Dart variable declaration
(21, 'var', 1, TRUE),
(21, 'let', 2, FALSE),
(21, 'dim', 3, FALSE),
(21, 'define', 4, FALSE),
-- Q22: Root Flutter widget
(22, 'MaterialApp', 1, TRUE),
(22, 'Container', 2, FALSE),
(22, 'Scaffold', 3, FALSE),
(22, 'AppBar', 4, FALSE),
-- Q23: StatefulWidget
(23, 'A widget that can change its state during its lifetime', 1, TRUE),
(23, 'A widget that never changes', 2, FALSE),
(23, 'A widget for static content only', 3, FALSE),
(23, 'A widget that cannot have children', 4, FALSE),
-- Q24: Docker container creation
(24, 'docker run', 1, TRUE),
(24, 'docker create', 2, FALSE),
(24, 'docker start', 3, FALSE),
(24, 'docker build', 4, FALSE),
-- Q25: Docker Compose file
(25, 'docker-compose.yml', 1, TRUE),
(25, 'Dockerfile', 2, FALSE),
(25, 'docker.json', 3, FALSE),
(25, 'compose.xml', 4, FALSE),
-- Q26: React Native project creation
(26, 'npx react-native init', 1, TRUE),
(26, 'npm create react-native', 2, FALSE),
(26, 'react-native new', 3, FALSE),
(26, 'rn init', 4, FALSE);
-- ======================================================
-- Question Sets for new sub-courses
-- ======================================================
INSERT INTO question_sets (id, title, description, set_type, owner_type, owner_id, persona, status) VALUES
(5, 'Dart Basics Quiz', 'Test your Dart fundamentals', 'PRACTICE', 'SUB_COURSE', 18, 'beginner', 'PUBLISHED'),
(6, 'Flutter Widgets Assessment', 'Assess Flutter widget knowledge', 'PRACTICE', 'SUB_COURSE', 19, 'beginner', 'PUBLISHED'),
(7, 'State Management Quiz', 'Test state management concepts', 'PRACTICE', 'SUB_COURSE', 20, 'intermediate', 'DRAFT'),
(8, 'Docker Fundamentals Quiz', 'Test Docker basics', 'PRACTICE', 'SUB_COURSE', 25, 'beginner', 'PUBLISHED'),
(9, 'Docker Compose Assessment', 'Assess Docker Compose skills', 'PRACTICE', 'SUB_COURSE', 26, 'intermediate', 'PUBLISHED'),
(10, 'React Native Setup Quiz', 'Test React Native setup knowledge', 'PRACTICE', 'SUB_COURSE', 22, 'beginner', 'PUBLISHED')
ON CONFLICT (id) DO NOTHING;
-- Link questions to question sets
INSERT INTO question_set_items (set_id, question_id, display_order) VALUES
(5, 21, 1),
(6, 22, 1),
(7, 23, 1),
(8, 24, 1),
(9, 25, 1),
(10, 26, 1)
ON CONFLICT (set_id, question_id) DO NOTHING;
-- Link personas to question sets
INSERT INTO question_set_personas (question_set_id, user_id, display_order) VALUES
(5, 10, 1), (5, 11, 2),
(6, 10, 1), (6, 12, 2),
(8, 11, 1),
(10, 10, 1)
ON CONFLICT (question_set_id, user_id) DO NOTHING;
-- ======================================================
-- Sub-course Prerequisites
-- Defines the learning path / dependency graph
-- ======================================================
INSERT INTO sub_course_prerequisites (sub_course_id, prerequisite_sub_course_id) VALUES
-- Python course (IDs 1-5): linear progression
-- "Python Basics - Data Types" requires "Python Basics - Getting Started"
(2, 1),
-- "Python Intermediate - Functions" requires "Python Basics - Data Types"
(3, 2),
-- "Python Intermediate - Collections" requires "Python Intermediate - Functions"
(4, 3),
-- "Python Advanced - Best Practices" requires "Python Intermediate - Collections"
(5, 4),
-- JavaScript course (IDs 6-7): linear
-- "DOM Manipulation Basics" requires "JavaScript Fundamentals"
(7, 6),
-- Java course (IDs 8-9): linear
-- "Spring Framework Intro" requires "Java Core Concepts"
(9, 8),
-- Data Science course (IDs 10-11): linear
-- "Advanced Data Analysis" requires "Data Analysis Fundamentals"
(11, 10),
-- ML course (IDs 12-13): linear
-- "ML Algorithms" requires "ML Basics"
(13, 12),
-- Full Stack course (IDs 14-15): linear
-- "Backend Development" requires "Frontend Fundamentals"
(15, 14),
-- React course (IDs 16-17): linear
-- "React Advanced Patterns" requires "React Basics"
(17, 16),
-- Flutter course (IDs 18-21): structured path
-- "Flutter UI Widgets" requires "Dart Language Basics"
(19, 18),
-- "State Management" requires "Flutter UI Widgets"
(20, 19),
-- "Flutter Networking & APIs" requires "State Management"
(21, 20),
-- React Native course (IDs 22-24): linear
-- "Navigation & Routing" requires "React Native Setup"
(23, 22),
-- "Native Modules" requires "Navigation & Routing"
(24, 23),
-- Docker & Kubernetes course (IDs 25-27): structured
-- "Docker Compose" requires "Docker Fundamentals"
(26, 25),
-- "Kubernetes Basics" requires "Docker Compose"
(27, 26),
-- CI/CD course (IDs 28-29): linear
-- "GitHub Actions" requires "Git Workflows"
(29, 28),
-- Cybersecurity course (IDs 30-31): linear
-- "Penetration Testing" requires "Network Security Basics"
(31, 30)
ON CONFLICT (sub_course_id, prerequisite_sub_course_id) DO NOTHING;
-- ======================================================
-- User Sub-course Progress
-- Simulate realistic student progress for admin panel
-- ======================================================
INSERT INTO user_sub_course_progress (user_id, sub_course_id, status, progress_percentage, started_at, completed_at) VALUES
-- Student 10 (Demo Student): working through Python course
(10, 1, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '30 days', CURRENT_TIMESTAMP - INTERVAL '20 days'),
(10, 2, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '20 days', CURRENT_TIMESTAMP - INTERVAL '12 days'),
(10, 3, 'IN_PROGRESS', 65, CURRENT_TIMESTAMP - INTERVAL '12 days', NULL),
-- Student 10: started Flutter
(10, 18, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '15 days', CURRENT_TIMESTAMP - INTERVAL '8 days'),
(10, 19, 'IN_PROGRESS', 40, CURRENT_TIMESTAMP - INTERVAL '8 days', NULL),
-- Student 11 (Abebe): completed Python, started JavaScript
(11, 1, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '45 days', CURRENT_TIMESTAMP - INTERVAL '35 days'),
(11, 2, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '35 days', CURRENT_TIMESTAMP - INTERVAL '25 days'),
(11, 3, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '25 days', CURRENT_TIMESTAMP - INTERVAL '15 days'),
(11, 4, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '15 days', CURRENT_TIMESTAMP - INTERVAL '5 days'),
(11, 5, 'IN_PROGRESS', 30, CURRENT_TIMESTAMP - INTERVAL '5 days', NULL),
(11, 6, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '20 days', CURRENT_TIMESTAMP - INTERVAL '10 days'),
(11, 7, 'IN_PROGRESS', 50, CURRENT_TIMESTAMP - INTERVAL '10 days', NULL),
-- Student 11: Docker course
(11, 25, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '10 days', CURRENT_TIMESTAMP - INTERVAL '3 days'),
(11, 26, 'IN_PROGRESS', 20, CURRENT_TIMESTAMP - INTERVAL '3 days', NULL),
-- Student 12 (Sara): just started
(12, 1, 'IN_PROGRESS', 25, CURRENT_TIMESTAMP - INTERVAL '7 days', NULL),
(12, 18, 'IN_PROGRESS', 10, CURRENT_TIMESTAMP - INTERVAL '3 days', NULL),
(12, 22, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '14 days', CURRENT_TIMESTAMP - INTERVAL '7 days'),
(12, 23, 'IN_PROGRESS', 60, CURRENT_TIMESTAMP - INTERVAL '7 days', NULL)
ON CONFLICT (user_id, sub_course_id) DO NOTHING;
-- ======================================================
-- Reset sequences to avoid ID conflicts after seeding
-- ======================================================
SELECT setval(pg_get_serial_sequence('course_categories', 'id'), COALESCE((SELECT MAX(id) FROM course_categories), 1), true);
SELECT setval(pg_get_serial_sequence('courses', 'id'), COALESCE((SELECT MAX(id) FROM courses), 1), true);
SELECT setval(pg_get_serial_sequence('sub_courses', 'id'), COALESCE((SELECT MAX(id) FROM sub_courses), 1), true);
SELECT setval(pg_get_serial_sequence('sub_course_videos', 'id'), COALESCE((SELECT MAX(id) FROM sub_course_videos), 1), true);
SELECT setval(pg_get_serial_sequence('questions', 'id'), COALESCE((SELECT MAX(id) FROM questions), 1), true);
SELECT setval(pg_get_serial_sequence('question_options', 'id'), COALESCE((SELECT MAX(id) FROM question_options), 1), true);
SELECT setval(pg_get_serial_sequence('question_sets', 'id'), COALESCE((SELECT MAX(id) FROM question_sets), 1), true);
SELECT setval(pg_get_serial_sequence('question_set_items', 'id'), COALESCE((SELECT MAX(id) FROM question_set_items), 1), true);
SELECT setval(pg_get_serial_sequence('question_set_personas', 'id'), COALESCE((SELECT MAX(id) FROM question_set_personas), 1), true);
SELECT setval(pg_get_serial_sequence('sub_course_prerequisites', 'id'), COALESCE((SELECT MAX(id) FROM sub_course_prerequisites), 1), true);
SELECT setval(pg_get_serial_sequence('user_sub_course_progress', 'id'), COALESCE((SELECT MAX(id) FROM user_sub_course_progress), 1), true);

View File

@ -1,15 +0,0 @@
-- Revert AUDIO question type changes
-- 1. Drop question_audio_answers table
DROP TABLE IF EXISTS question_audio_answers CASCADE;
-- 2. Remove image_url column
ALTER TABLE questions DROP COLUMN IF EXISTS image_url;
-- 3. Revert question_type CHECK constraint
ALTER TABLE questions
DROP CONSTRAINT IF EXISTS questions_question_type_check;
ALTER TABLE questions
ADD CONSTRAINT questions_question_type_check
CHECK (question_type IN ('MCQ', 'TRUE_FALSE', 'SHORT_ANSWER'));

View File

@ -1,24 +0,0 @@
-- Add AUDIO question type and image_url support
-- 1. Extend question_type CHECK constraint to include AUDIO
ALTER TABLE questions
DROP CONSTRAINT IF EXISTS questions_question_type_check;
ALTER TABLE questions
ADD CONSTRAINT questions_question_type_check
CHECK (question_type IN ('MCQ', 'TRUE_FALSE', 'SHORT_ANSWER', 'AUDIO'));
-- 2. Add image_url column to questions
ALTER TABLE questions
ADD COLUMN IF NOT EXISTS image_url TEXT;
-- 3. Create question_audio_answers table for storing correct answer text
CREATE TABLE IF NOT EXISTS question_audio_answers (
id BIGSERIAL PRIMARY KEY,
question_id BIGINT NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
correct_answer_text TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_question_audio_answers_question_id
ON question_audio_answers(question_id);

View File

@ -9,49 +9,3 @@ FROM courses c
LEFT JOIN sub_courses sc ON sc.course_id = c.id AND sc.is_active = true
WHERE c.is_active = true
ORDER BY c.id, sc.display_order, sc.id;
-- name: GetCourseLearningPath :many
SELECT
c.id AS course_id,
c.title AS course_title,
c.description AS course_description,
c.thumbnail AS course_thumbnail,
c.intro_video_url AS course_intro_video_url,
cc.id AS category_id,
cc.name AS category_name,
sc.id AS sub_course_id,
sc.title AS sub_course_title,
sc.description AS sub_course_description,
sc.thumbnail AS sub_course_thumbnail,
sc.display_order AS sub_course_display_order,
sc.level AS sub_course_level,
(SELECT COUNT(*) FROM sub_course_prerequisites WHERE sub_course_id = sc.id) AS prerequisite_count,
(SELECT COUNT(*) FROM sub_course_videos WHERE sub_course_id = sc.id AND status = 'PUBLISHED') AS video_count,
(SELECT COUNT(*) FROM question_sets WHERE set_type = 'PRACTICE' AND owner_type = 'SUB_COURSE' AND owner_id = sc.id AND status = 'PUBLISHED') AS practice_count
FROM courses c
JOIN course_categories cc ON cc.id = c.category_id
LEFT JOIN sub_courses sc ON sc.course_id = c.id AND sc.is_active = true
WHERE c.id = $1
ORDER BY sc.display_order, sc.id;
-- name: GetSubCourseVideosForLearningPath :many
SELECT id, title, description, video_url, duration, resolution, display_order,
vimeo_id, vimeo_embed_url, video_host_provider
FROM sub_course_videos
WHERE sub_course_id = $1 AND status = 'PUBLISHED'
ORDER BY display_order, id;
-- name: GetSubCoursePracticesForLearningPath :many
SELECT id, title, description, persona, status,
(SELECT COUNT(*) FROM question_set_items WHERE set_id = qs.id) AS question_count
FROM question_sets qs
WHERE qs.owner_type = 'SUB_COURSE' AND qs.owner_id = $1
AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED'
ORDER BY qs.created_at;
-- name: GetSubCoursePrerequisitesForLearningPath :many
SELECT p.prerequisite_sub_course_id, sc.title, sc.level
FROM sub_course_prerequisites p
JOIN sub_courses sc ON sc.id = p.prerequisite_sub_course_id
WHERE p.sub_course_id = $1
ORDER BY sc.display_order;

View File

@ -1,18 +0,0 @@
-- name: CreateQuestionAudioAnswer :one
INSERT INTO question_audio_answers (question_id, correct_answer_text)
VALUES ($1, $2)
RETURNING *;
-- name: GetAudioAnswerByQuestionID :one
SELECT *
FROM question_audio_answers
WHERE question_id = $1;
-- name: GetAudioAnswersByQuestionIDs :many
SELECT *
FROM question_audio_answers
WHERE question_id = ANY($1::BIGINT[]);
-- name: DeleteAudioAnswerByQuestionID :exec
DELETE FROM question_audio_answers
WHERE question_id = $1;

View File

@ -21,7 +21,6 @@ SELECT
q.explanation,
q.tips,
q.voice_prompt,
q.image_url,
q.status as question_status
FROM question_set_items qsi
JOIN questions q ON q.id = qsi.question_id
@ -41,8 +40,7 @@ SELECT
q.points,
q.explanation,
q.tips,
q.voice_prompt,
q.image_url
q.voice_prompt
FROM question_set_items qsi
JOIN questions q ON q.id = qsi.question_id
WHERE qsi.set_id = $1

View File

@ -8,10 +8,9 @@ INSERT INTO questions (
tips,
voice_prompt,
sample_answer_voice_prompt,
image_url,
status
)
VALUES ($1, $2, $3, COALESCE($4, 1), $5, $6, $7, $8, $9, COALESCE($10, 'DRAFT'))
VALUES ($1, $2, $3, COALESCE($4, 1), $5, $6, $7, $8, COALESCE($9, 'DRAFT'))
RETURNING *;
-- name: GetQuestionByID :one
@ -60,10 +59,9 @@ SET
tips = COALESCE($6, tips),
voice_prompt = COALESCE($7, voice_prompt),
sample_answer_voice_prompt = COALESCE($8, sample_answer_voice_prompt),
image_url = COALESCE($9, image_url),
status = COALESCE($10, status),
status = COALESCE($9, status),
updated_at = CURRENT_TIMESTAMP
WHERE id = $11;
WHERE id = $10;
-- name: ArchiveQuestion :exec
UPDATE questions

View File

@ -11,87 +11,6 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const GetCourseLearningPath = `-- name: GetCourseLearningPath :many
SELECT
c.id AS course_id,
c.title AS course_title,
c.description AS course_description,
c.thumbnail AS course_thumbnail,
c.intro_video_url AS course_intro_video_url,
cc.id AS category_id,
cc.name AS category_name,
sc.id AS sub_course_id,
sc.title AS sub_course_title,
sc.description AS sub_course_description,
sc.thumbnail AS sub_course_thumbnail,
sc.display_order AS sub_course_display_order,
sc.level AS sub_course_level,
(SELECT COUNT(*) FROM sub_course_prerequisites WHERE sub_course_id = sc.id) AS prerequisite_count,
(SELECT COUNT(*) FROM sub_course_videos WHERE sub_course_id = sc.id AND status = 'PUBLISHED') AS video_count,
(SELECT COUNT(*) FROM question_sets WHERE set_type = 'PRACTICE' AND owner_type = 'SUB_COURSE' AND owner_id = sc.id AND status = 'PUBLISHED') AS practice_count
FROM courses c
JOIN course_categories cc ON cc.id = c.category_id
LEFT JOIN sub_courses sc ON sc.course_id = c.id AND sc.is_active = true
WHERE c.id = $1
ORDER BY sc.display_order, sc.id
`
type GetCourseLearningPathRow struct {
CourseID int64 `json:"course_id"`
CourseTitle string `json:"course_title"`
CourseDescription pgtype.Text `json:"course_description"`
CourseThumbnail pgtype.Text `json:"course_thumbnail"`
CourseIntroVideoUrl pgtype.Text `json:"course_intro_video_url"`
CategoryID int64 `json:"category_id"`
CategoryName string `json:"category_name"`
SubCourseID pgtype.Int8 `json:"sub_course_id"`
SubCourseTitle pgtype.Text `json:"sub_course_title"`
SubCourseDescription pgtype.Text `json:"sub_course_description"`
SubCourseThumbnail pgtype.Text `json:"sub_course_thumbnail"`
SubCourseDisplayOrder pgtype.Int4 `json:"sub_course_display_order"`
SubCourseLevel pgtype.Text `json:"sub_course_level"`
PrerequisiteCount int64 `json:"prerequisite_count"`
VideoCount int64 `json:"video_count"`
PracticeCount int64 `json:"practice_count"`
}
func (q *Queries) GetCourseLearningPath(ctx context.Context, id int64) ([]GetCourseLearningPathRow, error) {
rows, err := q.db.Query(ctx, GetCourseLearningPath, id)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetCourseLearningPathRow
for rows.Next() {
var i GetCourseLearningPathRow
if err := rows.Scan(
&i.CourseID,
&i.CourseTitle,
&i.CourseDescription,
&i.CourseThumbnail,
&i.CourseIntroVideoUrl,
&i.CategoryID,
&i.CategoryName,
&i.SubCourseID,
&i.SubCourseTitle,
&i.SubCourseDescription,
&i.SubCourseThumbnail,
&i.SubCourseDisplayOrder,
&i.SubCourseLevel,
&i.PrerequisiteCount,
&i.VideoCount,
&i.PracticeCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetFullLearningTree = `-- name: GetFullLearningTree :many
SELECT
c.id AS course_id,
@ -138,134 +57,3 @@ func (q *Queries) GetFullLearningTree(ctx context.Context) ([]GetFullLearningTre
}
return items, nil
}
const GetSubCoursePracticesForLearningPath = `-- name: GetSubCoursePracticesForLearningPath :many
SELECT id, title, description, persona, status,
(SELECT COUNT(*) FROM question_set_items WHERE set_id = qs.id) AS question_count
FROM question_sets qs
WHERE qs.owner_type = 'SUB_COURSE' AND qs.owner_id = $1
AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED'
ORDER BY qs.created_at
`
type GetSubCoursePracticesForLearningPathRow struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Persona pgtype.Text `json:"persona"`
Status string `json:"status"`
QuestionCount int64 `json:"question_count"`
}
func (q *Queries) GetSubCoursePracticesForLearningPath(ctx context.Context, ownerID pgtype.Int8) ([]GetSubCoursePracticesForLearningPathRow, error) {
rows, err := q.db.Query(ctx, GetSubCoursePracticesForLearningPath, ownerID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetSubCoursePracticesForLearningPathRow
for rows.Next() {
var i GetSubCoursePracticesForLearningPathRow
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Description,
&i.Persona,
&i.Status,
&i.QuestionCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetSubCoursePrerequisitesForLearningPath = `-- name: GetSubCoursePrerequisitesForLearningPath :many
SELECT p.prerequisite_sub_course_id, sc.title, sc.level
FROM sub_course_prerequisites p
JOIN sub_courses sc ON sc.id = p.prerequisite_sub_course_id
WHERE p.sub_course_id = $1
ORDER BY sc.display_order
`
type GetSubCoursePrerequisitesForLearningPathRow struct {
PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"`
Title string `json:"title"`
Level string `json:"level"`
}
func (q *Queries) GetSubCoursePrerequisitesForLearningPath(ctx context.Context, subCourseID int64) ([]GetSubCoursePrerequisitesForLearningPathRow, error) {
rows, err := q.db.Query(ctx, GetSubCoursePrerequisitesForLearningPath, subCourseID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetSubCoursePrerequisitesForLearningPathRow
for rows.Next() {
var i GetSubCoursePrerequisitesForLearningPathRow
if err := rows.Scan(&i.PrerequisiteSubCourseID, &i.Title, &i.Level); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetSubCourseVideosForLearningPath = `-- name: GetSubCourseVideosForLearningPath :many
SELECT id, title, description, video_url, duration, resolution, display_order,
vimeo_id, vimeo_embed_url, video_host_provider
FROM sub_course_videos
WHERE sub_course_id = $1 AND status = 'PUBLISHED'
ORDER BY display_order, id
`
type GetSubCourseVideosForLearningPathRow struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
VideoUrl string `json:"video_url"`
Duration int32 `json:"duration"`
Resolution pgtype.Text `json:"resolution"`
DisplayOrder int32 `json:"display_order"`
VimeoID pgtype.Text `json:"vimeo_id"`
VimeoEmbedUrl pgtype.Text `json:"vimeo_embed_url"`
VideoHostProvider pgtype.Text `json:"video_host_provider"`
}
func (q *Queries) GetSubCourseVideosForLearningPath(ctx context.Context, subCourseID int64) ([]GetSubCourseVideosForLearningPathRow, error) {
rows, err := q.db.Query(ctx, GetSubCourseVideosForLearningPath, subCourseID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetSubCourseVideosForLearningPathRow
for rows.Next() {
var i GetSubCourseVideosForLearningPathRow
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Description,
&i.VideoUrl,
&i.Duration,
&i.Resolution,
&i.DisplayOrder,
&i.VimeoID,
&i.VimeoEmbedUrl,
&i.VideoHostProvider,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View File

@ -135,14 +135,6 @@ type Question struct {
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ImageUrl pgtype.Text `json:"image_url"`
}
type QuestionAudioAnswer struct {
ID int64 `json:"id"`
QuestionID int64 `json:"question_id"`
CorrectAnswerText string `json:"correct_answer_text"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type QuestionOption struct {

View File

@ -1,92 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: question_audio_answers.sql
package dbgen
import (
"context"
)
const CreateQuestionAudioAnswer = `-- name: CreateQuestionAudioAnswer :one
INSERT INTO question_audio_answers (question_id, correct_answer_text)
VALUES ($1, $2)
RETURNING id, question_id, correct_answer_text, created_at
`
type CreateQuestionAudioAnswerParams struct {
QuestionID int64 `json:"question_id"`
CorrectAnswerText string `json:"correct_answer_text"`
}
func (q *Queries) CreateQuestionAudioAnswer(ctx context.Context, arg CreateQuestionAudioAnswerParams) (QuestionAudioAnswer, error) {
row := q.db.QueryRow(ctx, CreateQuestionAudioAnswer, arg.QuestionID, arg.CorrectAnswerText)
var i QuestionAudioAnswer
err := row.Scan(
&i.ID,
&i.QuestionID,
&i.CorrectAnswerText,
&i.CreatedAt,
)
return i, err
}
const DeleteAudioAnswerByQuestionID = `-- name: DeleteAudioAnswerByQuestionID :exec
DELETE FROM question_audio_answers
WHERE question_id = $1
`
func (q *Queries) DeleteAudioAnswerByQuestionID(ctx context.Context, questionID int64) error {
_, err := q.db.Exec(ctx, DeleteAudioAnswerByQuestionID, questionID)
return err
}
const GetAudioAnswerByQuestionID = `-- name: GetAudioAnswerByQuestionID :one
SELECT id, question_id, correct_answer_text, created_at
FROM question_audio_answers
WHERE question_id = $1
`
func (q *Queries) GetAudioAnswerByQuestionID(ctx context.Context, questionID int64) (QuestionAudioAnswer, error) {
row := q.db.QueryRow(ctx, GetAudioAnswerByQuestionID, questionID)
var i QuestionAudioAnswer
err := row.Scan(
&i.ID,
&i.QuestionID,
&i.CorrectAnswerText,
&i.CreatedAt,
)
return i, err
}
const GetAudioAnswersByQuestionIDs = `-- name: GetAudioAnswersByQuestionIDs :many
SELECT id, question_id, correct_answer_text, created_at
FROM question_audio_answers
WHERE question_id = ANY($1::BIGINT[])
`
func (q *Queries) GetAudioAnswersByQuestionIDs(ctx context.Context, dollar_1 []int64) ([]QuestionAudioAnswer, error) {
rows, err := q.db.Query(ctx, GetAudioAnswersByQuestionIDs, dollar_1)
if err != nil {
return nil, err
}
defer rows.Close()
var items []QuestionAudioAnswer
for rows.Next() {
var i QuestionAudioAnswer
if err := rows.Scan(
&i.ID,
&i.QuestionID,
&i.CorrectAnswerText,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View File

@ -68,8 +68,7 @@ SELECT
q.points,
q.explanation,
q.tips,
q.voice_prompt,
q.image_url
q.voice_prompt
FROM question_set_items qsi
JOIN questions q ON q.id = qsi.question_id
WHERE qsi.set_id = $1
@ -89,7 +88,6 @@ type GetPublishedQuestionsInSetRow struct {
Explanation pgtype.Text `json:"explanation"`
Tips pgtype.Text `json:"tips"`
VoicePrompt pgtype.Text `json:"voice_prompt"`
ImageUrl pgtype.Text `json:"image_url"`
}
func (q *Queries) GetPublishedQuestionsInSet(ctx context.Context, setID int64) ([]GetPublishedQuestionsInSetRow, error) {
@ -113,7 +111,6 @@ func (q *Queries) GetPublishedQuestionsInSet(ctx context.Context, setID int64) (
&i.Explanation,
&i.Tips,
&i.VoicePrompt,
&i.ImageUrl,
); err != nil {
return nil, err
}
@ -138,7 +135,6 @@ SELECT
q.explanation,
q.tips,
q.voice_prompt,
q.image_url,
q.status as question_status
FROM question_set_items qsi
JOIN questions q ON q.id = qsi.question_id
@ -159,7 +155,6 @@ type GetQuestionSetItemsRow struct {
Explanation pgtype.Text `json:"explanation"`
Tips pgtype.Text `json:"tips"`
VoicePrompt pgtype.Text `json:"voice_prompt"`
ImageUrl pgtype.Text `json:"image_url"`
QuestionStatus string `json:"question_status"`
}
@ -184,7 +179,6 @@ func (q *Queries) GetQuestionSetItems(ctx context.Context, setID int64) ([]GetQu
&i.Explanation,
&i.Tips,
&i.VoicePrompt,
&i.ImageUrl,
&i.QuestionStatus,
); err != nil {
return nil, err

View File

@ -32,11 +32,10 @@ INSERT INTO questions (
tips,
voice_prompt,
sample_answer_voice_prompt,
image_url,
status
)
VALUES ($1, $2, $3, COALESCE($4, 1), $5, $6, $7, $8, $9, COALESCE($10, 'DRAFT'))
RETURNING id, question_text, question_type, difficulty_level, points, explanation, tips, voice_prompt, sample_answer_voice_prompt, status, created_at, updated_at, image_url
VALUES ($1, $2, $3, COALESCE($4, 1), $5, $6, $7, $8, COALESCE($9, 'DRAFT'))
RETURNING id, question_text, question_type, difficulty_level, points, explanation, tips, voice_prompt, sample_answer_voice_prompt, status, created_at, updated_at
`
type CreateQuestionParams struct {
@ -48,8 +47,7 @@ type CreateQuestionParams struct {
Tips pgtype.Text `json:"tips"`
VoicePrompt pgtype.Text `json:"voice_prompt"`
SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"`
ImageUrl pgtype.Text `json:"image_url"`
Column10 interface{} `json:"column_10"`
Column9 interface{} `json:"column_9"`
}
func (q *Queries) CreateQuestion(ctx context.Context, arg CreateQuestionParams) (Question, error) {
@ -62,8 +60,7 @@ func (q *Queries) CreateQuestion(ctx context.Context, arg CreateQuestionParams)
arg.Tips,
arg.VoicePrompt,
arg.SampleAnswerVoicePrompt,
arg.ImageUrl,
arg.Column10,
arg.Column9,
)
var i Question
err := row.Scan(
@ -79,7 +76,6 @@ func (q *Queries) CreateQuestion(ctx context.Context, arg CreateQuestionParams)
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.ImageUrl,
)
return i, err
}
@ -95,7 +91,7 @@ func (q *Queries) DeleteQuestion(ctx context.Context, id int64) error {
}
const GetQuestionByID = `-- name: GetQuestionByID :one
SELECT id, question_text, question_type, difficulty_level, points, explanation, tips, voice_prompt, sample_answer_voice_prompt, status, created_at, updated_at, image_url
SELECT id, question_text, question_type, difficulty_level, points, explanation, tips, voice_prompt, sample_answer_voice_prompt, status, created_at, updated_at
FROM questions
WHERE id = $1
`
@ -116,7 +112,6 @@ func (q *Queries) GetQuestionByID(ctx context.Context, id int64) (Question, erro
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.ImageUrl,
)
return i, err
}
@ -193,7 +188,7 @@ func (q *Queries) GetQuestionWithOptions(ctx context.Context, id int64) ([]GetQu
}
const GetQuestionsByIDs = `-- name: GetQuestionsByIDs :many
SELECT id, question_text, question_type, difficulty_level, points, explanation, tips, voice_prompt, sample_answer_voice_prompt, status, created_at, updated_at, image_url
SELECT id, question_text, question_type, difficulty_level, points, explanation, tips, voice_prompt, sample_answer_voice_prompt, status, created_at, updated_at
FROM questions
WHERE id = ANY($1::BIGINT[])
ORDER BY id
@ -221,7 +216,6 @@ func (q *Queries) GetQuestionsByIDs(ctx context.Context, dollar_1 []int64) ([]Qu
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.ImageUrl,
); err != nil {
return nil, err
}
@ -236,7 +230,7 @@ func (q *Queries) GetQuestionsByIDs(ctx context.Context, dollar_1 []int64) ([]Qu
const ListQuestions = `-- name: ListQuestions :many
SELECT
COUNT(*) OVER () AS total_count,
q.id, q.question_text, q.question_type, q.difficulty_level, q.points, q.explanation, q.tips, q.voice_prompt, q.sample_answer_voice_prompt, q.status, q.created_at, q.updated_at, q.image_url
q.id, q.question_text, q.question_type, q.difficulty_level, q.points, q.explanation, q.tips, q.voice_prompt, q.sample_answer_voice_prompt, q.status, q.created_at, q.updated_at
FROM questions q
WHERE status != 'ARCHIVED'
AND ($1::VARCHAR IS NULL OR $1 = '' OR question_type = $1)
@ -269,7 +263,6 @@ type ListQuestionsRow struct {
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ImageUrl pgtype.Text `json:"image_url"`
}
func (q *Queries) ListQuestions(ctx context.Context, arg ListQuestionsParams) ([]ListQuestionsRow, error) {
@ -301,7 +294,6 @@ func (q *Queries) ListQuestions(ctx context.Context, arg ListQuestionsParams) ([
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.ImageUrl,
); err != nil {
return nil, err
}
@ -316,7 +308,7 @@ func (q *Queries) ListQuestions(ctx context.Context, arg ListQuestionsParams) ([
const SearchQuestions = `-- name: SearchQuestions :many
SELECT
COUNT(*) OVER () AS total_count,
q.id, q.question_text, q.question_type, q.difficulty_level, q.points, q.explanation, q.tips, q.voice_prompt, q.sample_answer_voice_prompt, q.status, q.created_at, q.updated_at, q.image_url
q.id, q.question_text, q.question_type, q.difficulty_level, q.points, q.explanation, q.tips, q.voice_prompt, q.sample_answer_voice_prompt, q.status, q.created_at, q.updated_at
FROM questions q
WHERE status != 'ARCHIVED'
AND question_text ILIKE '%' || $1 || '%'
@ -345,7 +337,6 @@ type SearchQuestionsRow struct {
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ImageUrl pgtype.Text `json:"image_url"`
}
func (q *Queries) SearchQuestions(ctx context.Context, arg SearchQuestionsParams) ([]SearchQuestionsRow, error) {
@ -371,7 +362,6 @@ func (q *Queries) SearchQuestions(ctx context.Context, arg SearchQuestionsParams
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.ImageUrl,
); err != nil {
return nil, err
}
@ -394,10 +384,9 @@ SET
tips = COALESCE($6, tips),
voice_prompt = COALESCE($7, voice_prompt),
sample_answer_voice_prompt = COALESCE($8, sample_answer_voice_prompt),
image_url = COALESCE($9, image_url),
status = COALESCE($10, status),
status = COALESCE($9, status),
updated_at = CURRENT_TIMESTAMP
WHERE id = $11
WHERE id = $10
`
type UpdateQuestionParams struct {
@ -409,7 +398,6 @@ type UpdateQuestionParams struct {
Tips pgtype.Text `json:"tips"`
VoicePrompt pgtype.Text `json:"voice_prompt"`
SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"`
ImageUrl pgtype.Text `json:"image_url"`
Status string `json:"status"`
ID int64 `json:"id"`
}
@ -424,7 +412,6 @@ func (q *Queries) UpdateQuestion(ctx context.Context, arg UpdateQuestionParams)
arg.Tips,
arg.VoicePrompt,
arg.SampleAnswerVoicePrompt,
arg.ImageUrl,
arg.Status,
arg.ID,
)

4
go.mod
View File

@ -6,12 +6,14 @@ toolchain go1.24.11
require (
firebase.google.com/go/v4 v4.19.0
github.com/amanuelabay/afrosms-go v1.0.6
github.com/go-playground/validator/v10 v10.29.0
github.com/joho/godotenv v1.5.1
github.com/resend/resend-go/v2 v2.28.0
github.com/shopspring/decimal v1.4.0
github.com/swaggo/fiber-swagger v1.3.0
github.com/swaggo/swag v1.16.6
github.com/twilio/twilio-go v1.30.0
golang.org/x/crypto v0.47.0
golang.org/x/oauth2 v0.34.0
google.golang.org/api v0.239.0
@ -111,6 +113,7 @@ require (
github.com/bytedance/sonic v1.14.2
github.com/gofiber/fiber/v2 v2.52.10
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/golang/mock v1.6.0 // indirect
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
// github.com/jackc/pgtype v1.14.4
@ -119,6 +122,7 @@ require (
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0

15
go.sum
View File

@ -42,6 +42,8 @@ github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbt
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/agiledragon/gomonkey/v2 v2.3.1/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY=
github.com/amanuelabay/afrosms-go v1.0.6 h1:/B9upckMqzr5/de7dbXPqIfmJm4utbQq0QUQePxmXtk=
github.com/amanuelabay/afrosms-go v1.0.6/go.mod h1:5mzzZtWSCDdvQsA0OyYf5CtbdGpl9lXyrcpl8/DyBj0=
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
@ -109,6 +111,8 @@ github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXe
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
@ -157,6 +161,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275 h1:IZycmTpoUtQK3PD60UYBwjaCUHUP7cML494ao9/O8+Q=
github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275/go.mod h1:zt6UU74K6Z6oMOYJbJzYpYucqdcQwSMPBEdSvGiaUMw=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
@ -176,6 +182,8 @@ github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJ
github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
github.com/otiai10/mint v1.3.3/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@ -215,6 +223,8 @@ github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe/go.mod h1:lKJPbtWzJ9J
github.com/swaggo/swag v1.8.1/go.mod h1:ugemnJsPZm/kRwFUnzBlbHRd0JY9zE1M4F+uy2pAaPQ=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/twilio/twilio-go v1.30.0 h1:86FBso7jFqpSZ0XC0GKJcEY2KOeUNOFh6zLhTbUMlnc=
github.com/twilio/twilio-go v1.30.0/go.mod h1:QbitvbvtkV77Jn4BABAKVmxabYSjMyQG4tHey9gfPqg=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
@ -234,6 +244,7 @@ github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
@ -282,6 +293,7 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
@ -299,8 +311,10 @@ golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -325,6 +339,7 @@ golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=

View File

@ -27,7 +27,9 @@ var (
ErrMissingResendApiKey = errors.New("missing Resend Api key")
ErrMissingResendSenderEmail = errors.New("missing Resend sender name")
ErrMissingTwilioAccountSid = errors.New("missing twilio account sid")
ErrMissingTwilioAuthToken = errors.New("missing twilio auth token")
ErrMissingTwilioSenderPhoneNumber = errors.New("missing twilio sender phone number")
)
type AFROSMSConfig struct {
@ -125,6 +127,9 @@ type Config struct {
TELEBIRR TELEBIRRConfig `mapstructure:"telebirr_config"`
ResendApiKey string
ResendSenderEmail string
TwilioAccountSid string
TwilioAuthToken string
TwilioSenderPhoneNumber string
RedisAddr string
KafkaBrokers []string
FCMServiceAccountKey string
@ -457,6 +462,24 @@ func (c *Config) loadEnv() error {
}
c.ResendSenderEmail = resendSenderEmail
twilioAccountSid := os.Getenv("TWILIO_ACCOUNT_SID")
if twilioAccountSid == "" {
return ErrMissingTwilioAccountSid
}
c.TwilioAccountSid = twilioAccountSid
twilioAuthToken := os.Getenv("TWILIO_AUTH_TOKEN")
if twilioAuthToken == "" {
return ErrMissingTwilioAuthToken
}
c.TwilioAuthToken = twilioAuthToken
twilioSenderPhoneNumber := os.Getenv("TWILIO_SENDER_PHONE_NUMBER")
if twilioSenderPhoneNumber == "" {
return ErrMissingTwilioSenderPhoneNumber
}
c.TwilioSenderPhoneNumber = twilioSenderPhoneNumber
c.FCMServiceAccountKey = os.Getenv("FCM_SERVICE_ACCOUNT_KEY")
// Vimeo configuration

View File

@ -87,58 +87,3 @@ const (
VideoHostProviderDirect VideoHostProvider = "DIRECT"
VideoHostProviderVimeo VideoHostProvider = "VIMEO"
)
// Learning Path types — full nested structure for a course
type LearningPathVideo struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
VideoURL string `json:"video_url"`
Duration int32 `json:"duration"`
Resolution *string `json:"resolution,omitempty"`
DisplayOrder int32 `json:"display_order"`
VimeoID *string `json:"vimeo_id,omitempty"`
VimeoEmbedURL *string `json:"vimeo_embed_url,omitempty"`
VideoHostProvider *string `json:"video_host_provider,omitempty"`
}
type LearningPathPractice struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
Persona *string `json:"persona,omitempty"`
Status string `json:"status"`
QuestionCount int64 `json:"question_count"`
}
type LearningPathPrerequisite struct {
SubCourseID int64 `json:"sub_course_id"`
Title string `json:"title"`
Level string `json:"level"`
}
type LearningPathSubCourse struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
DisplayOrder int32 `json:"display_order"`
Level string `json:"level"`
PrerequisiteCount int64 `json:"prerequisite_count"`
VideoCount int64 `json:"video_count"`
PracticeCount int64 `json:"practice_count"`
Prerequisites []LearningPathPrerequisite `json:"prerequisites"`
Videos []LearningPathVideo `json:"videos"`
Practices []LearningPathPractice `json:"practices"`
}
type LearningPath struct {
CourseID int64 `json:"course_id"`
CourseTitle string `json:"course_title"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
IntroVideoURL *string `json:"intro_video_url,omitempty"`
CategoryID int64 `json:"category_id"`
CategoryName string `json:"category_name"`
SubCourses []LearningPathSubCourse `json:"sub_courses"`
}

View File

@ -8,7 +8,6 @@ const (
QuestionTypeMCQ QuestionType = "MCQ"
QuestionTypeTrueFalse QuestionType = "TRUE_FALSE"
QuestionTypeShortAnswer QuestionType = "SHORT_ANSWER"
QuestionTypeAudio QuestionType = "AUDIO"
)
type DifficultyLevel string
@ -47,24 +46,15 @@ type Question struct {
Tips *string
VoicePrompt *string
SampleAnswerVoicePrompt *string
ImageURL *string
Status string
CreatedAt time.Time
UpdatedAt *time.Time
}
type QuestionAudioAnswer struct {
ID int64
QuestionID int64
CorrectAnswerText string
CreatedAt time.Time
}
type QuestionWithDetails struct {
Question
Options []QuestionOption
ShortAnswers []QuestionShortAnswer
AudioAnswer *QuestionAudioAnswer
}
type QuestionOption struct {
@ -120,7 +110,6 @@ type QuestionSetItemWithQuestion struct {
Explanation *string
Tips *string
VoicePrompt *string
ImageURL *string
QuestionStatus string
}
@ -133,11 +122,9 @@ type CreateQuestionInput struct {
Tips *string
VoicePrompt *string
SampleAnswerVoicePrompt *string
ImageURL *string
Status *string
Options []CreateQuestionOptionInput
ShortAnswers []CreateShortAnswerInput
AudioCorrectAnswerText *string
}
type CreateQuestionOptionInput struct {

View File

@ -3,13 +3,14 @@ package domain
type SMSProvider string
const (
TwilioSms SMSProvider = "twilio"
AfroMessage SMSProvider = "afro_message"
)
// IsValid checks if the SMSProvider is a valid enum value
func (s SMSProvider) IsValid() bool {
switch s {
case AfroMessage:
case TwilioSms, AfroMessage:
return true
default:
return false

View File

@ -172,9 +172,6 @@ type CourseStore interface {
// Learning Tree
GetFullLearningTree(ctx context.Context) ([]domain.TreeCourse, error)
// Learning Path (full nested structure for a course)
GetCourseLearningPath(ctx context.Context, courseID int64) (domain.LearningPath, error)
}
type ProgressionStore interface {

View File

@ -3,9 +3,6 @@ package repository
import (
"Yimaru-Backend/internal/domain"
"context"
"fmt"
"github.com/jackc/pgx/v5/pgtype"
)
func (s *Store) GetFullLearningTree(ctx context.Context) ([]domain.TreeCourse, error) {
@ -44,115 +41,3 @@ func (s *Store) GetFullLearningTree(ctx context.Context) ([]domain.TreeCourse, e
return courses, nil
}
func (s *Store) GetCourseLearningPath(ctx context.Context, courseID int64) (domain.LearningPath, error) {
rows, err := s.queries.GetCourseLearningPath(ctx, courseID)
if err != nil {
return domain.LearningPath{}, err
}
if len(rows) == 0 {
return domain.LearningPath{}, fmt.Errorf("course not found")
}
first := rows[0]
path := domain.LearningPath{
CourseID: first.CourseID,
CourseTitle: first.CourseTitle,
Description: ptrString(first.CourseDescription),
Thumbnail: ptrString(first.CourseThumbnail),
IntroVideoURL: ptrString(first.CourseIntroVideoUrl),
CategoryID: first.CategoryID,
CategoryName: first.CategoryName,
SubCourses: []domain.LearningPathSubCourse{},
}
for _, row := range rows {
if !row.SubCourseID.Valid {
continue
}
scID := row.SubCourseID.Int64
// Fetch prerequisites, videos, practices for this sub-course
prerequisites, _ := s.getSubCoursePrerequisitesForPath(ctx, scID)
videos, _ := s.getSubCourseVideosForPath(ctx, scID)
practices, _ := s.getSubCoursePracticesForPath(ctx, scID)
sc := domain.LearningPathSubCourse{
ID: scID,
Title: row.SubCourseTitle.String,
Description: ptrString(row.SubCourseDescription),
Thumbnail: ptrString(row.SubCourseThumbnail),
DisplayOrder: row.SubCourseDisplayOrder.Int32,
Level: row.SubCourseLevel.String,
PrerequisiteCount: row.PrerequisiteCount,
VideoCount: row.VideoCount,
PracticeCount: row.PracticeCount,
Prerequisites: prerequisites,
Videos: videos,
Practices: practices,
}
path.SubCourses = append(path.SubCourses, sc)
}
return path, nil
}
func (s *Store) getSubCoursePrerequisitesForPath(ctx context.Context, subCourseID int64) ([]domain.LearningPathPrerequisite, error) {
rows, err := s.queries.GetSubCoursePrerequisitesForLearningPath(ctx, subCourseID)
if err != nil {
return nil, err
}
result := make([]domain.LearningPathPrerequisite, len(rows))
for i, row := range rows {
result[i] = domain.LearningPathPrerequisite{
SubCourseID: row.PrerequisiteSubCourseID,
Title: row.Title,
Level: row.Level,
}
}
return result, nil
}
func (s *Store) getSubCourseVideosForPath(ctx context.Context, subCourseID int64) ([]domain.LearningPathVideo, error) {
rows, err := s.queries.GetSubCourseVideosForLearningPath(ctx, subCourseID)
if err != nil {
return nil, err
}
result := make([]domain.LearningPathVideo, len(rows))
for i, row := range rows {
result[i] = domain.LearningPathVideo{
ID: row.ID,
Title: row.Title,
Description: ptrString(row.Description),
VideoURL: row.VideoUrl,
Duration: row.Duration,
Resolution: ptrString(row.Resolution),
DisplayOrder: row.DisplayOrder,
VimeoID: ptrString(row.VimeoID),
VimeoEmbedURL: ptrString(row.VimeoEmbedUrl),
VideoHostProvider: ptrString(row.VideoHostProvider),
}
}
return result, nil
}
func (s *Store) getSubCoursePracticesForPath(ctx context.Context, subCourseID int64) ([]domain.LearningPathPractice, error) {
ownerID := pgtype.Int8{Int64: subCourseID, Valid: true}
rows, err := s.queries.GetSubCoursePracticesForLearningPath(ctx, ownerID)
if err != nil {
return nil, err
}
result := make([]domain.LearningPathPractice, len(rows))
for i, row := range rows {
result[i] = domain.LearningPathPractice{
ID: row.ID,
Title: row.Title,
Description: ptrString(row.Description),
Persona: ptrString(row.Persona),
Status: row.Status,
QuestionCount: row.QuestionCount,
}
}
return result, nil
}

View File

@ -70,7 +70,6 @@ func questionToDomain(q dbgen.Question) domain.Question {
Tips: fromPgText(q.Tips),
VoicePrompt: fromPgText(q.VoicePrompt),
SampleAnswerVoicePrompt: fromPgText(q.SampleAnswerVoicePrompt),
ImageURL: fromPgText(q.ImageUrl),
Status: q.Status,
CreatedAt: q.CreatedAt.Time,
UpdatedAt: timePtr(q.UpdatedAt),
@ -98,15 +97,6 @@ func questionShortAnswerToDomain(a dbgen.QuestionShortAnswer) domain.QuestionSho
}
}
func questionAudioAnswerToDomain(a dbgen.QuestionAudioAnswer) domain.QuestionAudioAnswer {
return domain.QuestionAudioAnswer{
ID: a.ID,
QuestionID: a.QuestionID,
CorrectAnswerText: a.CorrectAnswerText,
CreatedAt: a.CreatedAt.Time,
}
}
func questionSetToDomain(qs dbgen.QuestionSet) domain.QuestionSet {
return domain.QuestionSet{
ID: qs.ID,
@ -162,8 +152,7 @@ func (s *Store) CreateQuestion(ctx context.Context, input domain.CreateQuestionI
Tips: toPgText(input.Tips),
VoicePrompt: toPgText(input.VoicePrompt),
SampleAnswerVoicePrompt: toPgText(input.SampleAnswerVoicePrompt),
ImageUrl: toPgText(input.ImageURL),
Column10: status,
Column9: status,
})
if err != nil {
return domain.Question{}, err
@ -200,16 +189,6 @@ func (s *Store) CreateQuestion(ctx context.Context, input domain.CreateQuestionI
}
}
if input.AudioCorrectAnswerText != nil && *input.AudioCorrectAnswerText != "" {
_, err = q.CreateQuestionAudioAnswer(ctx, dbgen.CreateQuestionAudioAnswerParams{
QuestionID: question.ID,
CorrectAnswerText: *input.AudioCorrectAnswerText,
})
if err != nil {
return domain.Question{}, err
}
}
if err = tx.Commit(ctx); err != nil {
return domain.Question{}, err
}
@ -251,18 +230,10 @@ func (s *Store) GetQuestionWithDetails(ctx context.Context, id int64) (domain.Qu
answers[i] = questionShortAnswerToDomain(a)
}
var audioAnswer *domain.QuestionAudioAnswer
aa, err := s.queries.GetAudioAnswerByQuestionID(ctx, id)
if err == nil {
mapped := questionAudioAnswerToDomain(aa)
audioAnswer = &mapped
}
return domain.QuestionWithDetails{
Question: questionToDomain(q),
Options: options,
ShortAnswers: answers,
AudioAnswer: audioAnswer,
}, nil
}
@ -305,7 +276,6 @@ func (s *Store) ListQuestions(ctx context.Context, questionType, difficulty, sta
Tips: fromPgText(r.Tips),
VoicePrompt: fromPgText(r.VoicePrompt),
SampleAnswerVoicePrompt: fromPgText(r.SampleAnswerVoicePrompt),
ImageURL: fromPgText(r.ImageUrl),
Status: r.Status,
CreatedAt: r.CreatedAt.Time,
UpdatedAt: timePtr(r.UpdatedAt),
@ -341,7 +311,6 @@ func (s *Store) SearchQuestions(ctx context.Context, query string, limit, offset
Tips: fromPgText(r.Tips),
VoicePrompt: fromPgText(r.VoicePrompt),
SampleAnswerVoicePrompt: fromPgText(r.SampleAnswerVoicePrompt),
ImageURL: fromPgText(r.ImageUrl),
Status: r.Status,
CreatedAt: r.CreatedAt.Time,
UpdatedAt: timePtr(r.UpdatedAt),
@ -361,7 +330,7 @@ func (s *Store) UpdateQuestion(ctx context.Context, id int64, input domain.Creat
status = *input.Status
}
err := s.queries.UpdateQuestion(ctx, dbgen.UpdateQuestionParams{
return s.queries.UpdateQuestion(ctx, dbgen.UpdateQuestionParams{
ID: id,
QuestionText: input.QuestionText,
QuestionType: input.QuestionType,
@ -371,27 +340,8 @@ func (s *Store) UpdateQuestion(ctx context.Context, id int64, input domain.Creat
Tips: toPgText(input.Tips),
VoicePrompt: toPgText(input.VoicePrompt),
SampleAnswerVoicePrompt: toPgText(input.SampleAnswerVoicePrompt),
ImageUrl: toPgText(input.ImageURL),
Status: status,
})
if err != nil {
return err
}
if input.AudioCorrectAnswerText != nil {
_ = s.queries.DeleteAudioAnswerByQuestionID(ctx, id)
if *input.AudioCorrectAnswerText != "" {
_, err = s.queries.CreateQuestionAudioAnswer(ctx, dbgen.CreateQuestionAudioAnswerParams{
QuestionID: id,
CorrectAnswerText: *input.AudioCorrectAnswerText,
})
if err != nil {
return err
}
}
}
return nil
}
func (s *Store) ArchiveQuestion(ctx context.Context, id int64) error {
@ -703,7 +653,6 @@ func (s *Store) GetQuestionSetItems(ctx context.Context, setID int64) ([]domain.
Explanation: fromPgText(r.Explanation),
Tips: fromPgText(r.Tips),
VoicePrompt: fromPgText(r.VoicePrompt),
ImageURL: fromPgText(r.ImageUrl),
QuestionStatus: r.QuestionStatus,
}
}
@ -732,7 +681,6 @@ func (s *Store) GetPublishedQuestionsInSet(ctx context.Context, setID int64) ([]
Explanation: fromPgText(r.Explanation),
Tips: fromPgText(r.Tips),
VoicePrompt: fromPgText(r.VoicePrompt),
ImageURL: fromPgText(r.ImageUrl),
QuestionStatus: "PUBLISHED",
}
}

View File

@ -8,7 +8,3 @@ import (
func (s *Service) GetFullLearningTree(ctx context.Context) ([]domain.TreeCourse, error) {
return s.courseStore.GetFullLearningTree(ctx)
}
func (s *Service) GetCourseLearningPath(ctx context.Context, courseID int64) (domain.LearningPath, error) {
return s.courseStore.GetCourseLearningPath(ctx, courseID)
}

View File

@ -1,17 +1,68 @@
package messenger
import (
"Yimaru-Backend/internal/domain"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"
afro "github.com/amanuelabay/afrosms-go"
"github.com/twilio/twilio-go"
twilioApi "github.com/twilio/twilio-go/rest/api/v2010"
)
var (
ErrSMSProviderNotFound = errors.New("SMS Provider Not Found")
)
// If the company id is valid, it is a company based notification else its a global notification (by the super admin)
func (s *Service) SendSMS(ctx context.Context, receiverPhone, message string) error {
return s.SendAfroMessageSMSLatest(ctx, receiverPhone, message, nil)
var settingsList domain.SettingList
switch settingsList.SMSProvider {
case domain.AfroMessage:
return s.SendAfroMessageSMS(ctx, receiverPhone, message)
case domain.TwilioSms:
return s.SendTwilioSMS(ctx, receiverPhone, message)
default:
return ErrSMSProviderNotFound
}
}
func (s *Service) SendAfroMessageSMS(ctx context.Context, receiverPhone, message string) error {
apiKey := s.config.AFRO_SMS_API_KEY
senderName := s.config.AFRO_SMS_SENDER_NAME
hostURL := s.config.AFRO_SMS_HOST_URL
endpoint := "/api/send"
// API endpoint has been updated
// TODO: no need for package for the afro message operations (pretty simple stuff)
request := afro.GetRequest(apiKey, endpoint, hostURL)
request.BaseURL = "https://api.afromessage.com"
request.Method = "GET"
request.Sender(senderName)
request.To(receiverPhone, message)
fmt.Printf("the afro SMS request is: %v", request)
response, err := afro.MakeRequestWithContext(ctx, request)
if err != nil {
return err
}
if response["acknowledge"] == "success" {
return nil
} else {
fmt.Println(response["response"].(map[string]interface{}))
return errors.New("SMS delivery failed")
}
}
func (s *Service) SendAfroMessageSMSLatest(
@ -39,7 +90,7 @@ func (s *Service) SendAfroMessageSMSLatest(
}
// Construct full URL
reqURL := fmt.Sprintf("%s/api/send?%s", baseURL, params.Encode())
reqURL := fmt.Sprintf("%s?%s", baseURL+"/api/send", params.Encode())
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
@ -86,3 +137,26 @@ func (s *Service) SendAfroMessageSMSLatest(
return nil
}
func (s *Service) SendTwilioSMS(ctx context.Context, receiverPhone, message string) error {
accountSid := s.config.TwilioAccountSid
authToken := s.config.TwilioAuthToken
senderPhone := s.config.TwilioSenderPhoneNumber
client := twilio.NewRestClientWithParams(twilio.ClientParams{
Username: accountSid,
Password: authToken,
})
params := &twilioApi.CreateMessageParams{}
params.SetTo(receiverPhone)
params.SetFrom(senderPhone)
params.SetBody(message)
_, err := client.Api.CreateMessage(params)
if err != nil {
return fmt.Errorf("%s", "Error sending SMS message: %s"+err.Error())
}
return nil
}

View File

@ -23,8 +23,10 @@ import (
// "github.com/segmentio/kafka-go"
"go.uber.org/zap"
// afro "github.com/amanuelabay/afrosms-go"
firebase "firebase.google.com/go/v4"
"firebase.google.com/go/v4/messaging"
afro "github.com/amanuelabay/afrosms-go"
"github.com/gorilla/websocket"
"github.com/resend/resend-go/v2"
"google.golang.org/api/option"
@ -110,6 +112,38 @@ func (s *Service) initFCMClient() error {
return nil
}
func (s *Service) SendAfroMessageSMS(ctx context.Context, receiverPhone, message string) error {
apiKey := s.config.AFRO_SMS_API_KEY
senderName := s.config.AFRO_SMS_SENDER_NAME
baseURL := "https://api.afromessage.com"
endpoint := "/api/send"
request := afro.GetRequest(apiKey, endpoint, baseURL)
// MUST be POST
request.Method = "POST"
request.Sender(senderName)
request.To(receiverPhone, message)
response, err := afro.MakeRequestWithContext(ctx, request)
if err != nil {
return err
}
ack, ok := response["acknowledge"].(string)
if !ok {
return fmt.Errorf("unexpected SMS response format: %v", response)
}
if ack != "success" {
return fmt.Errorf("SMS delivery failed: %v", response)
}
return nil
}
func (s *Service) SendAfroMessageSMSTemp(
ctx context.Context,
receiverPhone string,
@ -709,7 +743,7 @@ func (s *Service) SendBulkPushNotification(ctx context.Context, userIDs []int64,
// It sends sequentially and returns the count of successful and failed deliveries.
func (s *Service) SendBulkSMS(ctx context.Context, recipients []string, message string) (sent int, failed int) {
for _, phone := range recipients {
if err := s.SendAfroMessageSMSTemp(ctx, phone, message, nil); err != nil {
if err := s.SendAfroMessageSMS(ctx, phone, message); err != nil {
s.mongoLogger.Error("[NotificationSvc.SendBulkSMS] Failed to send SMS",
zap.String("phone", phone),
zap.Error(err),

View File

@ -35,7 +35,7 @@ func (s *Service) ResendOtp(
// Broadcast OTP (same logic as SendOtp)
switch otp.Medium {
case domain.OtpMediumSms:
if err := s.messengerSvc.SendAfroMessageSMSLatest(ctx, otp.SentTo, message, nil); err != nil {
if err := s.messengerSvc.SendAfroMessageSMS(ctx, otp.SentTo, message); err != nil {
return err
}
case domain.OtpMediumEmail:
@ -68,8 +68,17 @@ func (s *Service) SendOtp(ctx context.Context, userID int64, sentTo string, otpF
switch medium {
case domain.OtpMediumSms:
if err := s.messengerSvc.SendAfroMessageSMSLatest(ctx, sentTo, message, nil); err != nil {
return err
switch provider {
case domain.TwilioSms:
if err := s.messengerSvc.SendTwilioSMS(ctx, sentTo, message); err != nil {
return err
}
case domain.AfroMessage:
if err := s.messengerSvc.SendAfroMessageSMS(ctx, sentTo, message); err != nil {
return err
}
default:
return fmt.Errorf("invalid sms provider: %s", provider)
}
case domain.OtpMediumEmail:
if err := s.messengerSvc.SendEmail(ctx, sentTo, message, message, "Yimaru - One Time Password"); err != nil {

View File

@ -99,7 +99,7 @@ func (s *Service) RegisterUser(ctx context.Context, registerReq domain.RegisterU
return domain.User{}, err
}
if err := s.SendOtp(ctx, user.ID, sentTo, domain.OtpRegister, registerReq.OtpMedium, domain.AfroMessage); err != nil {
if err := s.SendOtp(ctx, user.ID, sentTo, domain.OtpRegister, registerReq.OtpMedium, domain.TwilioSms); err != nil {
return domain.User{}, err
}

View File

@ -1420,41 +1420,6 @@ func (h *Handler) GetFullLearningTree(c *fiber.Ctx) error {
})
}
// GetCourseLearningPath godoc
// @Summary Get course learning path
// @Description Returns the complete learning path for a course including sub-courses (by level),
// @Description video lessons, practices, prerequisites, and counts — structured for admin panel flexible path configuration
// @Tags learning-tree
// @Produce json
// @Param courseId path int true "Course ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/courses/{courseId}/learning-path [get]
func (h *Handler) GetCourseLearningPath(c *fiber.Ctx) error {
courseID, err := strconv.ParseInt(c.Params("courseId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid course ID",
Error: err.Error(),
})
}
path, err := h.courseMgmtSvc.GetCourseLearningPath(c.Context(), courseID)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Course not found or has no learning path",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Learning path retrieved successfully",
Data: path,
})
}
// UploadSubCourseVideo godoc
// @Summary Upload a video file and create sub-course video
// @Description Accepts a video file upload, uploads it to Vimeo via TUS, and creates a sub-course video record

View File

@ -25,18 +25,16 @@ type shortAnswerInput struct {
type createQuestionReq struct {
QuestionText string `json:"question_text" validate:"required"`
QuestionType string `json:"question_type" validate:"required,oneof=MCQ TRUE_FALSE SHORT_ANSWER AUDIO"`
QuestionType string `json:"question_type" validate:"required,oneof=MCQ TRUE_FALSE SHORT_ANSWER"`
DifficultyLevel *string `json:"difficulty_level"`
Points *int32 `json:"points"`
Explanation *string `json:"explanation"`
Tips *string `json:"tips"`
VoicePrompt *string `json:"voice_prompt"`
SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt"`
ImageURL *string `json:"image_url"`
Status *string `json:"status"`
Options []optionInput `json:"options"`
ShortAnswers []shortAnswerInput `json:"short_answers"`
AudioCorrectAnswerText *string `json:"audio_correct_answer_text"`
}
type optionRes struct {
@ -62,12 +60,10 @@ type questionRes struct {
Tips *string `json:"tips,omitempty"`
VoicePrompt *string `json:"voice_prompt,omitempty"`
SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt,omitempty"`
ImageURL *string `json:"image_url,omitempty"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
Options []optionRes `json:"options,omitempty"`
ShortAnswers []shortAnswerRes `json:"short_answers,omitempty"`
AudioCorrectAnswerText *string `json:"audio_correct_answer_text,omitempty"`
}
type listQuestionsRes struct {
@ -123,11 +119,9 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
Tips: req.Tips,
VoicePrompt: req.VoicePrompt,
SampleAnswerVoicePrompt: req.SampleAnswerVoicePrompt,
ImageURL: req.ImageURL,
Status: req.Status,
Options: options,
ShortAnswers: shortAnswers,
AudioCorrectAnswerText: req.AudioCorrectAnswerText,
}
question, err := h.questionsSvc.CreateQuestion(c.Context(), input)
@ -157,7 +151,6 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
Tips: question.Tips,
VoicePrompt: question.VoicePrompt,
SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt,
ImageURL: question.ImageURL,
Status: question.Status,
CreatedAt: question.CreatedAt.String(),
},
@ -211,11 +204,6 @@ func (h *Handler) GetQuestionByID(c *fiber.Ctx) error {
})
}
var audioCorrectAnswerText *string
if question.AudioAnswer != nil {
audioCorrectAnswerText = &question.AudioAnswer.CorrectAnswerText
}
return c.JSON(domain.Response{
Message: "Question retrieved successfully",
Data: questionRes{
@ -228,12 +216,10 @@ func (h *Handler) GetQuestionByID(c *fiber.Ctx) error {
Tips: question.Tips,
VoicePrompt: question.VoicePrompt,
SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt,
ImageURL: question.ImageURL,
Status: question.Status,
CreatedAt: question.CreatedAt.String(),
Options: options,
ShortAnswers: shortAnswers,
AudioCorrectAnswerText: audioCorrectAnswerText,
},
})
}
@ -370,11 +356,9 @@ type updateQuestionReq struct {
Tips *string `json:"tips"`
VoicePrompt *string `json:"voice_prompt"`
SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt"`
ImageURL *string `json:"image_url"`
Status *string `json:"status"`
Options []optionInput `json:"options"`
ShortAnswers []shortAnswerInput `json:"short_answers"`
AudioCorrectAnswerText *string `json:"audio_correct_answer_text"`
}
// UpdateQuestion godoc
@ -442,11 +426,9 @@ func (h *Handler) UpdateQuestion(c *fiber.Ctx) error {
Tips: req.Tips,
VoicePrompt: req.VoicePrompt,
SampleAnswerVoicePrompt: req.SampleAnswerVoicePrompt,
ImageURL: req.ImageURL,
Status: req.Status,
Options: options,
ShortAnswers: shortAnswers,
AudioCorrectAnswerText: req.AudioCorrectAnswerText,
}
err = h.questionsSvc.UpdateQuestion(c.Context(), id, input)

View File

@ -111,7 +111,6 @@ func (a *App) initAppRoutes() {
// Learning Tree
groupV1.Get("/course-management/learning-tree", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetFullLearningTree)
groupV1.Get("/course-management/courses/:courseId/learning-path", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetCourseLearningPath)
// Questions
groupV1.Post("/questions", a.authMiddleware, a.RequirePermission("questions.create"), h.CreateQuestion)