diff --git a/README.md b/README.md index 47a9f34..d248eb0 100644 --- a/README.md +++ b/README.md @@ -114,19 +114,19 @@ Relationships: Belongs to one Course Category -Has many Programs +Has many Sub-courses Course Category └── Course - └── Programs[] + └── Sub-courses[] -3. Program +3. Sub-course -Table: programs +Table: sub_courses Purpose: -A structured learning track or syllabus within a course -(e.g., Beginner Track, Advanced Track). +A learning unit within a course representing different skill levels +(e.g., Beginner, Intermediate, Advanced). Key Fields: @@ -138,98 +138,33 @@ thumbnail display_order +level – BEGINNER | INTERMEDIATE | ADVANCED + is_active Relationships: Belongs to one Course -Has many Levels +Has many Sub-course Videos + +Has many Practices Course -└── Program - └── Levels[] +└── Sub-course + ├── Sub-course Videos[] + └── Practices[] -4. Level +4. Sub-course Video -Table: levels +Table: sub_course_videos Purpose: -Represents a progression stage inside a program (Level 1, Level 2, etc.). +Video learning content attached to a sub-course. Key Fields: -program_id – FK → programs.id - -title, description - -level_index - -Aggregates: - -number_of_modules - -number_of_practices - -number_of_videos - -is_active - -Relationships: - -Belongs to one Program - -Has many Modules - -Can directly own Practices - -Program -└── Level - ├── Modules[] - └── Practices[] (owner_type = LEVEL) - -5. Module - -Table: modules - -Purpose: -A lesson or unit inside a level. - -Key Fields: - -level_id – FK → levels.id - -title - -content - -display_order - -is_active - -Relationships: - -Belongs to one Level - -Has many Videos - -Can directly own Practices - -Level -└── Module - ├── Module Videos[] - └── Practices[] (owner_type = MODULE) - -6. Module Video - -Table: module_videos - -Purpose: -Actual video learning content attached to a module. - -Key Fields: - -module_id – FK → modules.id +sub_course_id – FK → sub_courses.id title, description @@ -249,27 +184,27 @@ instructor_id thumbnail +display_order + is_active Relationships: -Belongs to one Module +Belongs to one Sub-course -Module -└── Module Video +Sub-course +└── Sub-course Video -7. Practice (Polymorphic Ownership) +5. Practice Table: practices Purpose: -Exercises or assessments that can belong to either a Level or a Module. +Exercises or assessments that belong to a sub-course. Key Fields: -owner_type – LEVEL | MODULE - -owner_id – ID of level or module +sub_course_id – FK → sub_courses.id title, description @@ -279,21 +214,17 @@ persona is_active -Constraint: - -Enforced by CHECK (owner_type IN ('LEVEL', 'MODULE')) - -Ownership enforced at the application layer - Relationships: +Belongs to one Sub-course + One Practice → Many Practice Questions -Level or Module +Sub-course └── Practice └── Practice Questions[] -8. Practice Question (Lowest Level) +6. Practice Question Table: practice_questions @@ -328,25 +259,19 @@ Practice Complete Hierarchical Flow (Compact View) Course Category └── Course - └── Program - └── Level - ├── Module - │ ├── Module Video - │ └── Practice (MODULE) - │ └── Practice Question - └── Practice (LEVEL) - └── Practice Question + └── Sub-course (with levels: BEGINNER, INTERMEDIATE, ADVANCED) + ├── Sub-course Video + └── Practice + └── Practice Question Architectural Observations -Strict top-down hierarchy until Level +Simple three-level hierarchy: Category → Course → Sub-course -Polymorphic design for practices allows reuse without table duplication +Level is now a property of sub-course, not a separate entity Cascade deletes ensure referential integrity -Aggregated counters in levels support fast analytics and UI summaries - Schema is well-suited for: LMS platforms diff --git a/cmd.exe b/cmd.exe new file mode 100644 index 0000000..a1e0698 Binary files /dev/null and b/cmd.exe differ diff --git a/cmd/main.go b/cmd/main.go index 734cffa..ba6ec75 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -17,8 +17,12 @@ import ( issuereporting "Yimaru-Backend/internal/services/issue_reporting" "Yimaru-Backend/internal/services/messenger" notificationservice "Yimaru-Backend/internal/services/notification" + "Yimaru-Backend/internal/services/questions" "Yimaru-Backend/internal/services/recommendation" "Yimaru-Backend/internal/services/settings" + "Yimaru-Backend/internal/services/subscriptions" + "Yimaru-Backend/internal/services/team" + vimeoservice "Yimaru-Backend/internal/services/vimeo" "context" // referralservice "Yimaru-Backend/internal/services/referal" @@ -106,6 +110,8 @@ func main() { repository.NewTokenStore(store), cfg.RefreshExpiry, ) + + authSvc.InitGoogleOAuth(cfg.GoogleOAuthClientID, cfg.GoogleOAuthClientSecret, cfg.GoogleOAuthRedirectURL) // leagueSvc := league.New(repository.NewLeagueStore(store)) // eventSvc := event.New( // cfg.Bet365Token, @@ -332,11 +338,20 @@ func main() { assessmentSvc := assessment.NewService( repository.NewUserStore(store), - repository.NewInitialAssessmentStore(store), + store, // Use store directly as it implements QuestionStore notificationSvc, cfg, ) + // Vimeo service for video hosting + var vimeoSvc *vimeoservice.Service + if cfg.Vimeo.Enabled && cfg.Vimeo.AccessToken != "" { + vimeoSvc = vimeoservice.NewService(cfg.Vimeo.AccessToken, domain.MongoDBLogger) + logger.Info("Vimeo service initialized") + } else { + logger.Info("Vimeo service disabled (VIMEO_ENABLED not set or missing access token)") + } + // Course management service courseSvc := course_management.NewService( repository.NewUserStore(store), @@ -344,9 +359,27 @@ func main() { notificationSvc, cfg, ) + // Wire up Vimeo service to course management + if vimeoSvc != nil { + courseSvc.SetVimeoService(vimeoSvc) + } - arifpaySvc := arifpay.NewArifpayService(cfg, *transactionSvc, &http.Client{ - Timeout: 30 * time.Second}) + // Questions service (unified questions system) + questionsSvc := questions.NewService(store) + + // Subscriptions service + subscriptionsSvc := subscriptions.NewService(store) + + // ArifPay service with payment and subscription stores + arifpaySvc := arifpay.NewArifpayService( + cfg, + &http.Client{Timeout: 30 * time.Second}, + store, // implements PaymentStore + store, // implements SubscriptionStore + ) + + // Team management service + teamSvc := team.NewService(repository.NewTeamStore(store)) // santimpayClient := santimpay.NewSantimPayClient(cfg) @@ -357,8 +390,12 @@ func main() { app := httpserver.NewApp( assessmentSvc, courseSvc, + questionsSvc, + subscriptionsSvc, arifpaySvc, issueReportingSvc, + vimeoSvc, + teamSvc, cfg.Port, v, settingSvc, diff --git a/db/data/001_initial_seed_data.sql b/db/data/001_initial_seed_data.sql index 0309166..f5f2cb0 100644 --- a/db/data/001_initial_seed_data.sql +++ b/db/data/001_initial_seed_data.sql @@ -147,19 +147,19 @@ ON CONFLICT (key) DO NOTHING; -- ====================================================== -- ====================================================== --- Assessment Questions – Level A2 (EASY) +-- Questions - Level A2 (EASY) -- ====================================================== -INSERT INTO assessment_questions (id, title, question_type, difficulty_level, points, is_active) +INSERT INTO questions (id, question_text, question_type, difficulty_level, points, status) VALUES -(1, 'What would you say to greet someone before lunchtime?', 'MULTIPLE_CHOICE', 'EASY', 1, TRUE), -(2, 'Which question is correct to ask about your routine?', 'MULTIPLE_CHOICE', 'EASY', 1, TRUE), -(3, 'She ___ like pizza.', 'MULTIPLE_CHOICE', 'EASY', 1, TRUE), -(4, 'I usually go to school and start class ____ eight o’clock.', 'MULTIPLE_CHOICE', 'EASY', 1, TRUE), -(5, 'Someone says, “Here is the book you asked for.” What is the best response?', 'MULTIPLE_CHOICE', 'EASY', 1, TRUE) +(1, 'What would you say to greet someone before lunchtime?', 'MCQ', 'EASY', 1, 'PUBLISHED'), +(2, 'Which question is correct to ask about your routine?', 'MCQ', 'EASY', 1, 'PUBLISHED'), +(3, 'She ___ like pizza.', 'MCQ', 'EASY', 1, 'PUBLISHED'), +(4, 'I usually go to school and start class ____ eight o''clock.', 'MCQ', 'EASY', 1, 'PUBLISHED'), +(5, 'Someone says, "Here is the book you asked for." What is the best response?', 'MCQ', 'EASY', 1, 'PUBLISHED') ON CONFLICT (id) DO NOTHING; -INSERT INTO assessment_question_options (question_id, option_text, option_order, is_correct) +INSERT INTO question_options (question_id, option_text, option_order, is_correct) VALUES -- Q1 (1, 'Good morning.', 1, TRUE), @@ -192,19 +192,19 @@ VALUES (5, 'Thank you.', 4, TRUE); -- ====================================================== --- Assessment Questions – Level B1 (MEDIUM) +-- Questions - Level B1 (MEDIUM) -- ====================================================== -INSERT INTO assessment_questions (id, title, question_type, difficulty_level, points, is_active) +INSERT INTO questions (id, question_text, question_type, difficulty_level, points, status) VALUES -(6, 'How do you introduce your friend to another person?', 'MULTIPLE_CHOICE', 'MEDIUM', 1, TRUE), -(7, 'How would you ask for the price of an item in a shop?', 'MULTIPLE_CHOICE', 'MEDIUM', 1, TRUE), -(8, 'Which sentence correctly gives simple directions?', 'MULTIPLE_CHOICE', 'MEDIUM', 1, TRUE), -(9, 'The watch shows 10:50, but the real time is 10:45. What can you say?', 'MULTIPLE_CHOICE', 'MEDIUM', 1, TRUE), -(10, 'Which instruction is correct when giving directions?', 'MULTIPLE_CHOICE', 'MEDIUM', 1, TRUE) +(6, 'How do you introduce your friend to another person?', 'MCQ', 'MEDIUM', 1, 'PUBLISHED'), +(7, 'How would you ask for the price of an item in a shop?', 'MCQ', 'MEDIUM', 1, 'PUBLISHED'), +(8, 'Which sentence correctly gives simple directions?', 'MCQ', 'MEDIUM', 1, 'PUBLISHED'), +(9, 'The watch shows 10:50, but the real time is 10:45. What can you say?', 'MCQ', 'MEDIUM', 1, 'PUBLISHED'), +(10, 'Which instruction is correct when giving directions?', 'MCQ', 'MEDIUM', 1, 'PUBLISHED') ON CONFLICT (id) DO NOTHING; -INSERT INTO assessment_question_options (question_id, option_text, option_order, is_correct) +INSERT INTO question_options (question_id, option_text, option_order, is_correct) VALUES -- Q6 (6, 'Hello, my name is Samson.', 1, FALSE), @@ -221,7 +221,7 @@ VALUES -- Q8 (8, 'Thank you very much for asking.', 1, FALSE), (8, 'Turn left and walk two blocks.', 2, TRUE), -(8, 'Why don’t you eat out.', 3, FALSE), +(8, 'Why don''t you eat out.', 3, FALSE), (8, 'Take the bus to the park.', 4, FALSE), -- Q9 @@ -237,20 +237,20 @@ VALUES (10, 'Turn to straight.', 4, FALSE); -- ====================================================== --- Assessment Questions – Level B2 (HARD) +-- Questions - Level B2 (HARD) -- ====================================================== -INSERT INTO assessment_questions (id, title, question_type, difficulty_level, points, is_active) +INSERT INTO questions (id, question_text, question_type, difficulty_level, points, status) VALUES -(11, 'What is the most polite way to ask to speak to someone on the phone?', 'MULTIPLE_CHOICE', 'HARD', 1, TRUE), -(12, 'How do you correctly state the age of a person who is 30 years old?', 'MULTIPLE_CHOICE', 'HARD', 1, TRUE), -(13, 'When asking for help with a new Yimaru App feature, which option is most appropriate?', 'MULTIPLE_CHOICE', 'HARD', 1, TRUE), -(14, 'Which word has the unvoiced “th” sound?', 'MULTIPLE_CHOICE', 'HARD', 1, TRUE), -(15, 'Which sentence sounds like a warning, not friendly advice?', 'MULTIPLE_CHOICE', 'HARD', 1, TRUE), -(16, 'What does this sentence mean? “I will definitely be there on time.”', 'MULTIPLE_CHOICE', 'HARD', 1, TRUE) +(11, 'What is the most polite way to ask to speak to someone on the phone?', 'MCQ', 'HARD', 1, 'PUBLISHED'), +(12, 'How do you correctly state the age of a person who is 30 years old?', 'MCQ', 'HARD', 1, 'PUBLISHED'), +(13, 'When asking for help with a new Yimaru App feature, which option is most appropriate?', 'MCQ', 'HARD', 1, 'PUBLISHED'), +(14, 'Which word has the unvoiced "th" sound?', 'MCQ', 'HARD', 1, 'PUBLISHED'), +(15, 'Which sentence sounds like a warning, not friendly advice?', 'MCQ', 'HARD', 1, 'PUBLISHED'), +(16, 'What does this sentence mean? "I will definitely be there on time."', 'MCQ', 'HARD', 1, 'PUBLISHED') ON CONFLICT (id) DO NOTHING; -INSERT INTO assessment_question_options (question_id, option_text, option_order, is_correct) +INSERT INTO question_options (question_id, option_text, option_order, is_correct) VALUES -- Q11 (11, 'May I speak to Mr. Tesfaye, please?', 1, TRUE), @@ -268,7 +268,7 @@ VALUES (13, 'Are you familiar with how this feature works?', 1, FALSE), (13, 'Could you walk me through how this feature works?', 2, TRUE), (13, 'I believe I understand how this feature works.', 3, FALSE), -(13, 'I’ve tried similar features before.', 4, FALSE), +(13, 'I''ve tried similar features before.', 4, FALSE), -- Q14 (14, 'That', 1, FALSE), @@ -278,9 +278,9 @@ VALUES -- Q15 (15, 'You might want to plan your time better.', 1, FALSE), -(15, 'If I were you, I’d start earlier.', 2, FALSE), -(15, 'You’d better meet the deadline this time.', 3, TRUE), -(15, 'Why don’t you try using a planner?', 4, FALSE), +(15, 'If I were you, I''d start earlier.', 2, FALSE), +(15, 'You''d better meet the deadline this time.', 3, TRUE), +(15, 'Why don''t you try using a planner?', 4, FALSE), -- Q16 (16, 'The speaker is unsure about arriving.', 1, FALSE), @@ -288,6 +288,22 @@ VALUES (16, 'The speaker might arrive late.', 3, FALSE), (16, 'The speaker has already arrived.', 4, FALSE); +-- ====================================================== +-- Initial Assessment Question Set +-- ====================================================== + +INSERT INTO question_sets (id, title, description, set_type, owner_type, status) +VALUES +(1, 'Initial Assessment', 'Default initial assessment for new users', 'INITIAL_ASSESSMENT', 'STANDALONE', 'PUBLISHED') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO question_set_items (set_id, question_id, display_order) +VALUES +(1, 1, 1), (1, 2, 2), (1, 3, 3), (1, 4, 4), (1, 5, 5), +(1, 6, 6), (1, 7, 7), (1, 8, 8), (1, 9, 9), (1, 10, 10), +(1, 11, 11), (1, 12, 12), (1, 13, 13), (1, 14, 14), (1, 15, 15), (1, 16, 16) +ON CONFLICT (set_id, question_id) DO NOTHING; + -- ====================================================== -- Course Management Seed Data -- ====================================================== @@ -299,81 +315,98 @@ INSERT INTO course_categories (name, is_active, created_at) VALUES ('Web Development', TRUE, CURRENT_TIMESTAMP); -- Courses -INSERT INTO courses (category_id, title, description, is_active) VALUES -(1, 'Python Programming Fundamentals', 'Learn Python from basics to advanced concepts', TRUE), -(1, 'JavaScript for Beginners', 'Master JavaScript programming language', TRUE), -(1, 'Advanced Java Development', 'Deep dive into Java enterprise development', TRUE), -(2, 'Data Analysis with Python', 'Learn data manipulation and analysis using pandas', TRUE), -(2, 'Machine Learning Basics', 'Introduction to machine learning algorithms', TRUE), -(3, 'Full Stack Web Development', 'Complete guide to modern web development', TRUE), -(3, 'React.js Masterclass', 'Build dynamic user interfaces with React', TRUE); +INSERT INTO courses (category_id, title, description, thumbnail, is_active) VALUES +(1, 'Python Programming Fundamentals', 'Learn Python from basics to advanced concepts', 'https://example.com/thumbnails/python.jpg', TRUE), +(1, 'JavaScript for Beginners', 'Master JavaScript programming language', 'https://example.com/thumbnails/javascript.jpg', TRUE), +(1, 'Advanced Java Development', 'Deep dive into Java enterprise development', 'https://example.com/thumbnails/java.jpg', TRUE), +(2, 'Data Analysis with Python', 'Learn data manipulation and analysis using pandas', 'https://example.com/thumbnails/data-analysis.jpg', TRUE), +(2, 'Machine Learning Basics', 'Introduction to machine learning algorithms', 'https://example.com/thumbnails/ml.jpg', TRUE), +(3, 'Full Stack Web Development', 'Complete guide to modern web development', 'https://example.com/thumbnails/fullstack.jpg', TRUE), +(3, 'React.js Masterclass', 'Build dynamic user interfaces with React', 'https://example.com/thumbnails/react.jpg', TRUE); --- Programs -INSERT INTO programs (course_id, title, description, thumbnail, display_order, is_active) VALUES -(1, 'Python Basics', 'Fundamental concepts of Python programming', NULL, 1, TRUE), -(1, 'Python Intermediate', 'Object-oriented programming and data structures', NULL, 2, TRUE), -(1, 'Python Advanced', 'Advanced Python concepts and best practices', NULL, 3, TRUE), -(2, 'JavaScript Fundamentals', 'Core JavaScript concepts and syntax', NULL, 1, TRUE), -(2, 'DOM Manipulation', 'Working with the Document Object Model', NULL, 2, TRUE), -(3, 'Java Core Concepts', 'Essential Java programming principles', NULL, 1, TRUE), -(3, 'Spring Framework', 'Building enterprise applications with Spring', NULL, 2, TRUE); +-- Sub-courses (replacing Programs/Levels hierarchy) +INSERT INTO sub_courses (course_id, title, description, thumbnail, display_order, level, is_active) VALUES +-- Python Programming Fundamentals sub-courses +(1, 'Python Basics - Getting Started', 'Introduction to Python and basic syntax', NULL, 1, 'BEGINNER', TRUE), +(1, 'Python Basics - Data Types', 'Understanding Python data types and variables', NULL, 2, 'BEGINNER', TRUE), +(1, 'Python Intermediate - Functions', 'Writing and using functions in Python', NULL, 3, 'INTERMEDIATE', TRUE), +(1, 'Python Intermediate - Collections', 'Working with Python collections', NULL, 4, 'INTERMEDIATE', TRUE), +(1, 'Python Advanced - Best Practices', 'Advanced Python concepts and best practices', NULL, 5, 'ADVANCED', TRUE), --- Levels -INSERT INTO levels (program_id, title, description, level_index, is_active) VALUES -(1, 'Getting Started', 'Introduction to Python and basic syntax', 1, TRUE), -(1, 'Data Types & Variables', 'Understanding Python data types and variables', 2, TRUE), -(1, 'Control Flow', 'Conditional statements and loops', 3, TRUE), +-- JavaScript sub-courses +(2, 'JavaScript Fundamentals', 'Core JavaScript concepts and syntax', NULL, 1, 'BEGINNER', TRUE), +(2, 'DOM Manipulation Basics', 'Working with the Document Object Model', NULL, 2, 'INTERMEDIATE', TRUE), -(2, 'Functions', 'Writing and using functions in Python', 1, TRUE), -(2, 'Lists & Dictionaries', 'Working with Python collections', 2, TRUE), -(2, 'File Operations', 'Reading and writing files', 3, TRUE); +-- Java sub-courses +(3, 'Java Core Concepts', 'Essential Java programming principles', NULL, 1, 'BEGINNER', TRUE), +(3, 'Spring Framework Intro', 'Building enterprise applications with Spring', NULL, 2, 'ADVANCED', TRUE), --- Modules -INSERT INTO modules (level_id, title, content, display_order, is_active) VALUES -(1, 'Installing Python', 'Setting up Python development environment', 1, TRUE), -(1, 'Your First Python Program', 'Writing and running your first Python script', 2, TRUE), -(2, 'Numbers and Strings', 'Working with numeric and text data types', 1, TRUE), -(2, 'Variables and Assignment', 'Understanding variables and assignment operators', 2, TRUE), -(3, 'Conditional Statements', 'Using if, elif, and else statements', 1, TRUE), -(3, 'Loops in Python', 'For and while loops with examples', 2, TRUE); +-- Data Science sub-courses +(4, 'Data Analysis Fundamentals', 'Learn data manipulation with pandas', NULL, 1, 'BEGINNER', TRUE), +(4, 'Advanced Data Analysis', 'Complex data transformations', NULL, 2, 'ADVANCED', TRUE), --- Module Videos -INSERT INTO module_videos ( - module_id, +-- Machine Learning sub-courses +(5, 'ML Basics', 'Introduction to machine learning concepts', NULL, 1, 'BEGINNER', TRUE), +(5, 'ML Algorithms', 'Understanding common ML algorithms', NULL, 2, 'INTERMEDIATE', TRUE), + +-- Full Stack Web Development sub-courses +(6, 'Frontend Fundamentals', 'HTML, CSS, and JavaScript basics', NULL, 1, 'BEGINNER', TRUE), +(6, 'Backend Development', 'Server-side programming', NULL, 2, 'INTERMEDIATE', TRUE), + +-- React.js sub-courses +(7, 'React Basics', 'Core React concepts and JSX', NULL, 1, 'BEGINNER', TRUE), +(7, 'React Advanced Patterns', 'Hooks, context, and performance', NULL, 2, 'ADVANCED', TRUE); + +-- Sub-course Videos +INSERT INTO sub_course_videos ( + sub_course_id, title, description, video_url, duration, resolution, visibility, - is_active + display_order, + status ) VALUES -(1, 'Python Installation Guide', 'Installing Python', 'https://example.com/python-install.mp4', 900, '1080p', 'public', TRUE), -(2, 'Hello World in Python', 'First Python program', 'https://example.com/python-hello.mp4', 1200, '1080p', 'public', TRUE), -(3, 'Numbers and Math', 'Numeric types in Python', 'https://example.com/python-numbers.mp4', 1500, '720p', 'public', TRUE); +(1, 'Python Installation Guide', 'Installing Python', 'https://example.com/python-install.mp4', 900, '1080p', 'public', 1, 'PUBLISHED'), +(1, 'Your First Python Program', 'Writing and running your first Python script', 'https://example.com/python-hello.mp4', 1200, '1080p', 'public', 2, 'PUBLISHED'), +(2, 'Numbers and Math', 'Numeric types in Python', 'https://example.com/python-numbers.mp4', 1500, '720p', 'public', 1, 'PUBLISHED'), +(2, 'Strings in Python', 'Working with text data', 'https://example.com/python-strings.mp4', 1300, '1080p', 'public', 2, 'DRAFT'), +(3, 'Writing Functions', 'Creating reusable code with functions', 'https://example.com/python-functions.mp4', 1800, '1080p', 'public', 1, 'PUBLISHED'); --- Practices -INSERT INTO practices ( - owner_type, - owner_id, - title, - description, - persona, - is_active -) VALUES -('LEVEL', 1, 'Python Basics Assessment', 'Test Python basics', 'beginner', TRUE), -('LEVEL', 2, 'Data Types Practice', 'Practice Python data types', 'beginner', TRUE), -('MODULE', 3, 'Control Flow Quiz', 'Assess control flow knowledge', 'beginner', TRUE); +-- Practice Question Sets (replacing practices table) +INSERT INTO question_sets (id, title, description, set_type, owner_type, owner_id, persona, status) VALUES +(2, 'Python Basics Assessment', 'Test Python basics', 'PRACTICE', 'SUB_COURSE', 1, 'beginner', 'PUBLISHED'), +(3, 'Data Types Practice', 'Practice Python data types', 'PRACTICE', 'SUB_COURSE', 2, 'beginner', 'PUBLISHED'), +(4, 'Functions Quiz', 'Assess function knowledge', 'PRACTICE', 'SUB_COURSE', 3, 'intermediate', 'DRAFT') +ON CONFLICT (id) DO NOTHING; --- Practice Questions -INSERT INTO practice_questions ( - practice_id, - question, - sample_answer, - tips, - type -) VALUES -(1, 'What is the correct way to print "Hello World" in Python?', 'print("Hello World")', 'Use print()', 'MCQ'), -(1, 'Which is a valid Python variable name?', 'my_variable', 'Variables cannot start with numbers', 'MCQ'), -(2, 'How do you convert "123" to an integer?', 'int("123")', 'Use int()', 'MCQ'), -(3, 'How many times does range(3) loop run?', '3', 'Starts from zero', 'MCQ'); +-- Practice Questions (using unified questions table) +INSERT INTO questions (id, question_text, question_type, tips, status) +VALUES +(17, 'What is the correct way to print "Hello World" in Python?', 'MCQ', 'Use print()', 'PUBLISHED'), +(18, 'Which is a valid Python variable name?', 'MCQ', 'Variables cannot start with numbers', 'PUBLISHED'), +(19, 'How do you convert "123" to an integer?', 'MCQ', 'Use int()', 'PUBLISHED'), +(20, 'How many times does range(3) loop run?', 'MCQ', 'Starts from zero', 'PUBLISHED') +ON CONFLICT (id) DO NOTHING; + +-- Link practice questions to question sets +INSERT INTO question_set_items (set_id, question_id, display_order) +VALUES +(2, 17, 1), (2, 18, 2), +(3, 19, 1), +(4, 20, 1) +ON CONFLICT (set_id, question_id) DO NOTHING; + +-- ====================================================== +-- User Personas for Practice Sessions +-- Link existing users as personas to practice question sets +-- ====================================================== + +INSERT INTO question_set_personas (question_set_id, user_id, display_order) +VALUES +(2, 10, 1), (2, 11, 2), +(3, 12, 1), +(4, 10, 1), (4, 12, 2) +ON CONFLICT (question_set_id, user_id) DO NOTHING; diff --git a/db/data/003_fix_autoincrement_desync.sql b/db/data/003_fix_autoincrement_desync.sql index c14ae8d..a1a7b60 100644 --- a/db/data/003_fix_autoincrement_desync.sql +++ b/db/data/003_fix_autoincrement_desync.sql @@ -9,45 +9,38 @@ SELECT setval( true ); --- assessment_questions.id (BIGSERIAL) +-- questions.id (BIGSERIAL) SELECT setval( - pg_get_serial_sequence('assessment_questions', 'id'), - COALESCE((SELECT MAX(id) FROM assessment_questions), 1), + pg_get_serial_sequence('questions', 'id'), + COALESCE((SELECT MAX(id) FROM questions), 1), true ); --- assessment_question_options.id (BIGSERIAL) +-- question_options.id (BIGSERIAL) SELECT setval( - pg_get_serial_sequence('assessment_question_options', 'id'), - COALESCE((SELECT MAX(id) FROM assessment_question_options), 1), + pg_get_serial_sequence('question_options', 'id'), + COALESCE((SELECT MAX(id) FROM question_options), 1), true ); --- assessment_short_answers.id (BIGSERIAL) +-- question_short_answers.id (BIGSERIAL) SELECT setval( - pg_get_serial_sequence('assessment_short_answers', 'id'), - COALESCE((SELECT MAX(id) FROM assessment_short_answers), 1), + pg_get_serial_sequence('question_short_answers', 'id'), + COALESCE((SELECT MAX(id) FROM question_short_answers), 1), true ); --- assessment_attempts.id (BIGSERIAL) +-- question_sets.id (BIGSERIAL) SELECT setval( - pg_get_serial_sequence('assessment_attempts', 'id'), - COALESCE((SELECT MAX(id) FROM assessment_attempts), 1), + pg_get_serial_sequence('question_sets', 'id'), + COALESCE((SELECT MAX(id) FROM question_sets), 1), true ); --- assessment_attempt_questions.id (BIGSERIAL) +-- question_set_items.id (BIGSERIAL) SELECT setval( - pg_get_serial_sequence('assessment_attempt_questions', 'id'), - COALESCE((SELECT MAX(id) FROM assessment_attempt_questions), 1), - true -); - --- assessment_attempt_answers.id (BIGSERIAL) -SELECT setval( - pg_get_serial_sequence('assessment_attempt_answers', 'id'), - COALESCE((SELECT MAX(id) FROM assessment_attempt_answers), 1), + pg_get_serial_sequence('question_set_items', 'id'), + COALESCE((SELECT MAX(id) FROM question_set_items), 1), true ); @@ -93,44 +86,23 @@ SELECT setval( true ); --- programs.id (BIGSERIAL) +-- sub_courses.id (BIGSERIAL) SELECT setval( - pg_get_serial_sequence('programs', 'id'), - COALESCE((SELECT MAX(id) FROM programs), 1), + pg_get_serial_sequence('sub_courses', 'id'), + COALESCE((SELECT MAX(id) FROM sub_courses), 1), true ); --- levels.id (BIGSERIAL) +-- sub_course_videos.id (BIGSERIAL) SELECT setval( - pg_get_serial_sequence('levels', 'id'), - COALESCE((SELECT MAX(id) FROM levels), 1), + pg_get_serial_sequence('sub_course_videos', 'id'), + COALESCE((SELECT MAX(id) FROM sub_course_videos), 1), true ); --- modules.id (BIGSERIAL) +-- question_set_personas.id (BIGSERIAL) SELECT setval( - pg_get_serial_sequence('modules', 'id'), - COALESCE((SELECT MAX(id) FROM modules), 1), - true -); - --- module_videos.id (BIGSERIAL) -SELECT setval( - pg_get_serial_sequence('module_videos', 'id'), - COALESCE((SELECT MAX(id) FROM module_videos), 1), - true -); - --- practices.id (BIGSERIAL) -SELECT setval( - pg_get_serial_sequence('practices', 'id'), - COALESCE((SELECT MAX(id) FROM practices), 1), - true -); - --- practice_questions.id (BIGSERIAL) -SELECT setval( - pg_get_serial_sequence('practice_questions', 'id'), - COALESCE((SELECT MAX(id) FROM practice_questions), 1), + pg_get_serial_sequence('question_set_personas', 'id'), + COALESCE((SELECT MAX(id) FROM question_set_personas), 1), true ); diff --git a/db/migrations/000003_simplify_courses.down.sql b/db/migrations/000003_simplify_courses.down.sql new file mode 100644 index 0000000..7a78981 --- /dev/null +++ b/db/migrations/000003_simplify_courses.down.sql @@ -0,0 +1,72 @@ +-- Rollback: Restore old course hierarchy +-- Note: This will lose any new data created with the simplified structure + +-- Step 1: Recreate old tables +CREATE TABLE IF NOT EXISTS programs ( + id BIGSERIAL PRIMARY KEY, + course_id BIGINT NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + thumbnail TEXT, + display_order INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE +); + +CREATE TABLE IF NOT EXISTS levels ( + id BIGSERIAL PRIMARY KEY, + program_id BIGINT NOT NULL REFERENCES programs(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + level_index INT NOT NULL, + number_of_modules INT NOT NULL DEFAULT 0, + number_of_practices INT NOT NULL DEFAULT 0, + number_of_videos INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE +); + +CREATE TABLE IF NOT EXISTS modules ( + id BIGSERIAL PRIMARY KEY, + level_id BIGINT NOT NULL REFERENCES levels(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + content TEXT, + display_order INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE +); + +CREATE TABLE IF NOT EXISTS module_videos ( + id BIGSERIAL PRIMARY KEY, + module_id BIGINT NOT NULL REFERENCES modules(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + video_url TEXT NOT NULL, + duration INT NOT NULL, + resolution VARCHAR(20), + is_published BOOLEAN NOT NULL DEFAULT FALSE, + publish_date TIMESTAMPTZ, + visibility VARCHAR(50), + instructor_id VARCHAR(100), + thumbnail TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE +); + +-- Step 2: Restore practices polymorphic columns +ALTER TABLE practices ADD COLUMN IF NOT EXISTS owner_type VARCHAR(50); +ALTER TABLE practices ADD COLUMN IF NOT EXISTS owner_id BIGINT; + +-- Step 3: Recreate old indexes +CREATE INDEX IF NOT EXISTS idx_programs_course_id ON programs(course_id); +CREATE INDEX IF NOT EXISTS idx_levels_program_id ON levels(program_id); +CREATE INDEX IF NOT EXISTS idx_modules_level_id ON modules(level_id); +CREATE INDEX IF NOT EXISTS idx_videos_module_id ON module_videos(module_id); +CREATE INDEX IF NOT EXISTS idx_practices_owner ON practices(owner_type, owner_id); + +-- Step 4: Drop new tables +ALTER TABLE practices DROP CONSTRAINT IF EXISTS practices_sub_course_id_fkey; +DROP INDEX IF EXISTS idx_practices_sub_course_id; +ALTER TABLE practices DROP COLUMN IF EXISTS sub_course_id; + +DROP TABLE IF EXISTS sub_course_videos CASCADE; +DROP TABLE IF EXISTS sub_courses CASCADE; + +-- Step 5: Add back constraint on practices +ALTER TABLE practices ADD CONSTRAINT practices_owner_type_check CHECK (owner_type IN ('LEVEL', 'MODULE')); diff --git a/db/migrations/000003_simplify_courses.up.sql b/db/migrations/000003_simplify_courses.up.sql new file mode 100644 index 0000000..007e588 --- /dev/null +++ b/db/migrations/000003_simplify_courses.up.sql @@ -0,0 +1,145 @@ +-- Migration: Simplify course hierarchy +-- OLD: Course Category → Course → Program → Level → Module → (Video, Practice) +-- NEW: Course Category → Course → Sub-course (with level) → (Video, Practice) + +-- Step 1: Create new tables +CREATE TABLE IF NOT EXISTS sub_courses ( + id BIGSERIAL PRIMARY KEY, + course_id BIGINT NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + thumbnail TEXT, + display_order INT NOT NULL DEFAULT 0, + level VARCHAR(50) NOT NULL, -- BEGINNER, INTERMEDIATE, ADVANCED + is_active BOOLEAN NOT NULL DEFAULT TRUE, + + CHECK (level IN ('BEGINNER', 'INTERMEDIATE', 'ADVANCED')) +); + +CREATE INDEX IF NOT EXISTS idx_sub_courses_course_id ON sub_courses(course_id); + +CREATE TABLE IF NOT EXISTS sub_course_videos ( + id BIGSERIAL PRIMARY KEY, + sub_course_id BIGINT NOT NULL REFERENCES sub_courses(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + video_url TEXT NOT NULL, + duration INT NOT NULL, -- seconds + resolution VARCHAR(20), -- "720p", "1080p" + is_published BOOLEAN NOT NULL DEFAULT FALSE, + publish_date TIMESTAMPTZ, + visibility VARCHAR(50), -- public, private, unlisted + instructor_id VARCHAR(100), + thumbnail TEXT, + display_order INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE +); + +CREATE INDEX IF NOT EXISTS idx_sub_course_videos_sub_course_id ON sub_course_videos(sub_course_id); + +-- Step 2: Add sub_course_id to practices (nullable during migration) +ALTER TABLE practices ADD COLUMN IF NOT EXISTS sub_course_id BIGINT NULL; +CREATE INDEX IF NOT EXISTS idx_practices_sub_course_id ON practices(sub_course_id); + +-- Step 3: Migrate data from old structure to new structure + +-- Insert sub-courses from (program, level) combinations +INSERT INTO sub_courses (course_id, title, description, thumbnail, display_order, level, is_active) +SELECT + p.course_id, + (p.title || ' - ' || l.title) as title, + COALESCE(l.description, p.description) as description, + p.thumbnail, + (p.display_order * 100 + l.level_index) as display_order, + CASE l.level_index + WHEN 1 THEN 'BEGINNER' + WHEN 2 THEN 'INTERMEDIATE' + WHEN 3 THEN 'ADVANCED' + ELSE 'BEGINNER' + END as level, + (l.is_active AND p.is_active) as is_active +FROM levels l +JOIN programs p ON p.id = l.program_id; + +-- Create temporary mapping table for migration +CREATE TEMP TABLE level_to_sub_course AS +SELECT + l.id as level_id, + sc.id as sub_course_id +FROM levels l +JOIN programs p ON p.id = l.program_id +JOIN sub_courses sc + ON sc.course_id = p.course_id + AND sc.title = (p.title || ' - ' || l.title); + +-- Create temporary mapping for modules to sub-courses +CREATE TEMP TABLE module_to_sub_course AS +SELECT + m.id as module_id, + lsc.sub_course_id +FROM modules m +JOIN level_to_sub_course lsc ON lsc.level_id = m.level_id; + +-- Migrate videos from module_videos to sub_course_videos +INSERT INTO sub_course_videos ( + sub_course_id, title, description, video_url, duration, resolution, + is_published, publish_date, visibility, instructor_id, thumbnail, display_order, is_active +) +SELECT + msc.sub_course_id, + mv.title, + mv.description, + mv.video_url, + mv.duration, + mv.resolution, + mv.is_published, + mv.publish_date, + mv.visibility, + mv.instructor_id, + mv.thumbnail, + (m.display_order * 100 + mv.id) as display_order, + mv.is_active +FROM module_videos mv +JOIN modules m ON m.id = mv.module_id +JOIN module_to_sub_course msc ON msc.module_id = m.id; + +-- Migrate practices owned by LEVEL +UPDATE practices pr +SET sub_course_id = lsc.sub_course_id +FROM level_to_sub_course lsc +WHERE pr.owner_type = 'LEVEL' + AND pr.owner_id = lsc.level_id; + +-- Migrate practices owned by MODULE +UPDATE practices pr +SET sub_course_id = msc.sub_course_id +FROM module_to_sub_course msc +WHERE pr.owner_type = 'MODULE' + AND pr.owner_id = msc.module_id; + +-- Step 4: Enforce integrity on practices +ALTER TABLE practices + ADD CONSTRAINT practices_sub_course_id_fkey + FOREIGN KEY (sub_course_id) REFERENCES sub_courses(id) ON DELETE CASCADE; + +-- Make sub_course_id NOT NULL (only if there's data to migrate) +-- If practices table has rows without sub_course_id after migration, this will fail +-- ALTER TABLE practices ALTER COLUMN sub_course_id SET NOT NULL; + +-- Step 5: Drop old columns from practices +ALTER TABLE practices DROP CONSTRAINT IF EXISTS practices_owner_type_check; +ALTER TABLE practices DROP COLUMN IF EXISTS owner_type; +ALTER TABLE practices DROP COLUMN IF EXISTS owner_id; + +-- Step 6: Drop old indexes +DROP INDEX IF EXISTS idx_videos_module_id; +DROP INDEX IF EXISTS idx_modules_level_id; +DROP INDEX IF EXISTS idx_levels_program_id; +DROP INDEX IF EXISTS idx_programs_course_id; +DROP INDEX IF EXISTS idx_practices_owner; + +-- Step 7: Drop old tables (CASCADE to remove FK references) +DROP TABLE IF EXISTS module_videos CASCADE; +DROP TABLE IF EXISTS modules CASCADE; +DROP TABLE IF EXISTS levels CASCADE; +DROP TABLE IF EXISTS programs CASCADE; diff --git a/db/migrations/000004_add_course_thumbnail.down.sql b/db/migrations/000004_add_course_thumbnail.down.sql new file mode 100644 index 0000000..22f042a --- /dev/null +++ b/db/migrations/000004_add_course_thumbnail.down.sql @@ -0,0 +1,2 @@ +-- Remove thumbnail column from courses table +ALTER TABLE courses DROP COLUMN IF EXISTS thumbnail; diff --git a/db/migrations/000004_add_course_thumbnail.up.sql b/db/migrations/000004_add_course_thumbnail.up.sql new file mode 100644 index 0000000..ac1b0bd --- /dev/null +++ b/db/migrations/000004_add_course_thumbnail.up.sql @@ -0,0 +1,2 @@ +-- Add thumbnail column to courses table +ALTER TABLE courses ADD COLUMN IF NOT EXISTS thumbnail TEXT; diff --git a/db/migrations/000005_add_status_field.down.sql b/db/migrations/000005_add_status_field.down.sql new file mode 100644 index 0000000..6ce9afd --- /dev/null +++ b/db/migrations/000005_add_status_field.down.sql @@ -0,0 +1,33 @@ +-- Revert status field changes + +-- Drop indexes +DROP INDEX IF EXISTS idx_sub_course_videos_status; +DROP INDEX IF EXISTS idx_practices_status; + +-- Add back is_active to sub_course_videos +ALTER TABLE sub_course_videos +ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT TRUE; + +-- Migrate data back +UPDATE sub_course_videos +SET is_active = CASE + WHEN status IN ('PUBLISHED', 'DRAFT') THEN true + ELSE false +END; + +-- Drop status from sub_course_videos +ALTER TABLE sub_course_videos DROP COLUMN IF EXISTS status; + +-- Add back is_active to practices +ALTER TABLE practices +ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT TRUE; + +-- Migrate data back +UPDATE practices +SET is_active = CASE + WHEN status IN ('PUBLISHED', 'DRAFT') THEN true + ELSE false +END; + +-- Drop status from practices +ALTER TABLE practices DROP COLUMN IF EXISTS status; diff --git a/db/migrations/000005_add_status_field.up.sql b/db/migrations/000005_add_status_field.up.sql new file mode 100644 index 0000000..dad31d4 --- /dev/null +++ b/db/migrations/000005_add_status_field.up.sql @@ -0,0 +1,38 @@ +-- Add status field to sub_course_videos and practices +-- Status values: DRAFT, PUBLISHED, INACTIVE, ARCHIVED +-- ARCHIVED is used for soft deletes + +-- Add status column to sub_course_videos +ALTER TABLE sub_course_videos +ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'DRAFT' +CHECK (status IN ('DRAFT', 'PUBLISHED', 'INACTIVE', 'ARCHIVED')); + +-- Migrate existing data based on is_active and is_published +UPDATE sub_course_videos +SET status = CASE + WHEN is_published = true AND is_active = true THEN 'PUBLISHED' + WHEN is_active = false THEN 'INACTIVE' + ELSE 'DRAFT' +END; + +-- Drop is_active column from sub_course_videos (keep is_published for publish_date tracking) +ALTER TABLE sub_course_videos DROP COLUMN IF EXISTS is_active; + +-- Add status column to practices +ALTER TABLE practices +ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'DRAFT' +CHECK (status IN ('DRAFT', 'PUBLISHED', 'INACTIVE', 'ARCHIVED')); + +-- Migrate existing data based on is_active +UPDATE practices +SET status = CASE + WHEN is_active = true THEN 'PUBLISHED' + ELSE 'INACTIVE' +END; + +-- Drop is_active column from practices +ALTER TABLE practices DROP COLUMN IF EXISTS is_active; + +-- Create indexes for status queries +CREATE INDEX IF NOT EXISTS idx_sub_course_videos_status ON sub_course_videos(status); +CREATE INDEX IF NOT EXISTS idx_practices_status ON practices(status); diff --git a/db/migrations/000006_unified_questions.down.sql b/db/migrations/000006_unified_questions.down.sql new file mode 100644 index 0000000..813b737 --- /dev/null +++ b/db/migrations/000006_unified_questions.down.sql @@ -0,0 +1,95 @@ +-- Revert unified questions migration +-- This will recreate the old tables but data migration back is not supported + +-- Recreate assessment tables +CREATE TABLE IF NOT EXISTS assessment_questions ( + id BIGSERIAL PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + question_type VARCHAR(30) NOT NULL CHECK (question_type IN ('MULTIPLE_CHOICE', 'SHORT_ANSWER', 'TRUE_FALSE')), + difficulty_level TEXT, + points INT NOT NULL DEFAULT 1, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS assessment_question_options ( + id BIGSERIAL PRIMARY KEY, + question_id BIGINT NOT NULL REFERENCES assessment_questions(id) ON DELETE CASCADE, + option_text TEXT NOT NULL, + option_order INT NOT NULL DEFAULT 0, + is_correct BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS assessment_short_answers ( + id BIGSERIAL PRIMARY KEY, + question_id BIGINT NOT NULL REFERENCES assessment_questions(id) ON DELETE CASCADE, + correct_answer TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS assessment_attempts ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + total_questions INT NOT NULL, + total_points INT NOT NULL, + score INT, + percentage NUMERIC(5,2), + status VARCHAR(20) NOT NULL DEFAULT 'IN_PROGRESS', + started_at TIMESTAMPTZ, + submitted_at TIMESTAMPTZ, + evaluated_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS assessment_attempt_questions ( + id BIGSERIAL PRIMARY KEY, + attempt_id BIGINT NOT NULL REFERENCES assessment_attempts(id) ON DELETE CASCADE, + question_id BIGINT NOT NULL REFERENCES assessment_questions(id), + question_type VARCHAR(30) NOT NULL, + points INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS assessment_attempt_answers ( + id BIGSERIAL PRIMARY KEY, + attempt_id BIGINT NOT NULL REFERENCES assessment_attempts(id) ON DELETE CASCADE, + question_id BIGINT NOT NULL REFERENCES assessment_questions(id), + selected_option_id BIGINT, + submitted_text TEXT, + is_correct BOOLEAN, + awarded_points INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Recreate practices tables +CREATE TABLE IF NOT EXISTS practices ( + id BIGSERIAL PRIMARY KEY, + sub_course_id BIGINT, + title VARCHAR(255) NOT NULL, + description TEXT, + banner_image TEXT, + persona VARCHAR(100), + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT' +); + +CREATE TABLE IF NOT EXISTS practice_questions ( + id BIGSERIAL PRIMARY KEY, + practice_id BIGINT NOT NULL REFERENCES practices(id) ON DELETE CASCADE, + question TEXT NOT NULL, + question_voice_prompt TEXT, + sample_answer_voice_prompt TEXT, + sample_answer TEXT, + tips TEXT, + type VARCHAR(50) NOT NULL +); + +-- Drop new unified tables +DROP TABLE IF EXISTS question_set_items CASCADE; +DROP TABLE IF EXISTS question_sets CASCADE; +DROP TABLE IF EXISTS question_short_answers CASCADE; +DROP TABLE IF EXISTS question_options CASCADE; +DROP TABLE IF EXISTS questions CASCADE; diff --git a/db/migrations/000006_unified_questions.up.sql b/db/migrations/000006_unified_questions.up.sql new file mode 100644 index 0000000..d7c0311 --- /dev/null +++ b/db/migrations/000006_unified_questions.up.sql @@ -0,0 +1,195 @@ +-- Unified Question System Migration +-- Replaces: practice_questions, assessment_questions, assessment_question_options, assessment_short_answers + +-- 1. Create unified questions table +CREATE TABLE IF NOT EXISTS questions ( + id BIGSERIAL PRIMARY KEY, + question_text TEXT NOT NULL, + question_type VARCHAR(20) NOT NULL CHECK (question_type IN ('MCQ', 'TRUE_FALSE', 'SHORT_ANSWER')), + difficulty_level VARCHAR(20) CHECK (difficulty_level IN ('EASY', 'MEDIUM', 'HARD')), + points INT NOT NULL DEFAULT 1, + explanation TEXT, + tips TEXT, + voice_prompt TEXT, + sample_answer_voice_prompt TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT' CHECK (status IN ('DRAFT', 'PUBLISHED', 'INACTIVE', 'ARCHIVED')), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ +); + +CREATE INDEX idx_questions_type ON questions(question_type); +CREATE INDEX idx_questions_status ON questions(status); +CREATE INDEX idx_questions_difficulty ON questions(difficulty_level); + +-- 2. Create question options table (for MCQ and TRUE_FALSE) +CREATE TABLE IF NOT EXISTS question_options ( + id BIGSERIAL PRIMARY KEY, + question_id BIGINT NOT NULL REFERENCES questions(id) ON DELETE CASCADE, + option_text TEXT NOT NULL, + option_order INT NOT NULL DEFAULT 0, + is_correct BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_question_options_question_id ON question_options(question_id); + +-- 3. Create question short answers table (for SHORT_ANSWER type) +CREATE TABLE IF NOT EXISTS question_short_answers ( + id BIGSERIAL PRIMARY KEY, + question_id BIGINT NOT NULL REFERENCES questions(id) ON DELETE CASCADE, + acceptable_answer TEXT NOT NULL, + match_type VARCHAR(20) NOT NULL DEFAULT 'EXACT' CHECK (match_type IN ('EXACT', 'CONTAINS', 'CASE_INSENSITIVE')), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_question_short_answers_question_id ON question_short_answers(question_id); + +-- 4. Create question sets table (replaces practices for grouping questions) +CREATE TABLE IF NOT EXISTS question_sets ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT, + set_type VARCHAR(30) NOT NULL CHECK (set_type IN ('PRACTICE', 'INITIAL_ASSESSMENT', 'QUIZ', 'EXAM', 'SURVEY')), + owner_type VARCHAR(30), -- SUB_COURSE, COURSE, CATEGORY, STANDALONE + owner_id BIGINT, -- References the owning entity + banner_image TEXT, + persona VARCHAR(100), + time_limit_minutes INT, -- Optional time limit + passing_score INT, -- Optional passing percentage + shuffle_questions BOOLEAN NOT NULL DEFAULT FALSE, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT' CHECK (status IN ('DRAFT', 'PUBLISHED', 'INACTIVE', 'ARCHIVED')), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ +); + +CREATE INDEX idx_question_sets_type ON question_sets(set_type); +CREATE INDEX idx_question_sets_owner ON question_sets(owner_type, owner_id); +CREATE INDEX idx_question_sets_status ON question_sets(status); + +-- 5. Create question set items table (links questions to sets) +CREATE TABLE IF NOT EXISTS question_set_items ( + id BIGSERIAL PRIMARY KEY, + set_id BIGINT NOT NULL REFERENCES question_sets(id) ON DELETE CASCADE, + question_id BIGINT NOT NULL REFERENCES questions(id) ON DELETE CASCADE, + display_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(set_id, question_id) +); + +CREATE INDEX idx_question_set_items_set_id ON question_set_items(set_id); +CREATE INDEX idx_question_set_items_question_id ON question_set_items(question_id); + +-- 6. Migrate data from assessment_questions to new questions table +INSERT INTO questions (id, question_text, question_type, difficulty_level, points, status, created_at) +SELECT + id, + title, + CASE question_type + WHEN 'MULTIPLE_CHOICE' THEN 'MCQ' + WHEN 'SHORT_ANSWER' THEN 'SHORT_ANSWER' + WHEN 'TRUE_FALSE' THEN 'TRUE_FALSE' + ELSE 'MCQ' + END, + CASE difficulty_level + WHEN 'EASY' THEN 'EASY' + WHEN 'MEDIUM' THEN 'MEDIUM' + WHEN 'HARD' THEN 'HARD' + ELSE 'MEDIUM' + END, + points, + CASE WHEN is_active THEN 'PUBLISHED' ELSE 'INACTIVE' END, + created_at +FROM assessment_questions; + +-- 7. Migrate assessment_question_options to question_options +INSERT INTO question_options (question_id, option_text, option_order, is_correct, created_at) +SELECT question_id, option_text, option_order, is_correct, created_at +FROM assessment_question_options; + +-- 8. Migrate assessment_short_answers to question_short_answers +INSERT INTO question_short_answers (question_id, acceptable_answer, match_type, created_at) +SELECT question_id, correct_answer, 'EXACT', created_at +FROM assessment_short_answers; + +-- 9. Create initial assessment question set from existing data +INSERT INTO question_sets (title, description, set_type, owner_type, status, created_at) +VALUES ('Initial Assessment', 'Default initial assessment for new users', 'INITIAL_ASSESSMENT', 'STANDALONE', 'PUBLISHED', CURRENT_TIMESTAMP); + +-- Link existing assessment questions to the initial assessment set +INSERT INTO question_set_items (set_id, question_id, display_order) +SELECT + (SELECT id FROM question_sets WHERE set_type = 'INITIAL_ASSESSMENT' LIMIT 1), + id, + id -- Use ID as initial display order +FROM questions +WHERE id IN (SELECT id FROM assessment_questions); + +-- 10. Migrate practice_questions to new structure +-- First, get max ID from questions to avoid conflicts +DO $$ +DECLARE + max_q_id BIGINT; + practice_rec RECORD; + new_set_id BIGINT; + new_question_id BIGINT; +BEGIN + SELECT COALESCE(MAX(id), 0) INTO max_q_id FROM questions; + + -- For each practice in the old system, create a question_set + FOR practice_rec IN + SELECT DISTINCT p.id, p.sub_course_id, p.title, p.description, p.banner_image, p.persona, p.status + FROM practices p + LOOP + -- Create question set for this practice + INSERT INTO question_sets (title, description, set_type, owner_type, owner_id, banner_image, persona, status, created_at) + VALUES ( + practice_rec.title, + practice_rec.description, + 'PRACTICE', + 'SUB_COURSE', + practice_rec.sub_course_id, + practice_rec.banner_image, + practice_rec.persona, + practice_rec.status, + CURRENT_TIMESTAMP + ) + RETURNING id INTO new_set_id; + + -- Migrate questions from this practice + FOR new_question_id IN + INSERT INTO questions (question_text, question_type, tips, sample_answer_voice_prompt, voice_prompt, status, created_at) + SELECT + pq.question, + pq.type, + pq.tips, + pq.sample_answer_voice_prompt, + pq.question_voice_prompt, + 'PUBLISHED', + CURRENT_TIMESTAMP + FROM practice_questions pq + WHERE pq.practice_id = practice_rec.id + RETURNING id + LOOP + -- Link question to set + INSERT INTO question_set_items (set_id, question_id, display_order) + VALUES (new_set_id, new_question_id, new_question_id); + END LOOP; + END LOOP; +END $$; + +-- 11. Reset sequences +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_short_answers', 'id'), COALESCE((SELECT MAX(id) FROM question_short_answers), 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); + +-- 12. Drop old tables +DROP TABLE IF EXISTS practice_questions CASCADE; +DROP TABLE IF EXISTS practices CASCADE; +DROP TABLE IF EXISTS assessment_attempt_answers CASCADE; +DROP TABLE IF EXISTS assessment_attempt_questions CASCADE; +DROP TABLE IF EXISTS assessment_attempts CASCADE; +DROP TABLE IF EXISTS assessment_short_answers CASCADE; +DROP TABLE IF EXISTS assessment_question_options CASCADE; +DROP TABLE IF EXISTS assessment_questions CASCADE; diff --git a/db/migrations/000007_personas_and_practice_enhancements.down.sql b/db/migrations/000007_personas_and_practice_enhancements.down.sql new file mode 100644 index 0000000..823ff33 --- /dev/null +++ b/db/migrations/000007_personas_and_practice_enhancements.down.sql @@ -0,0 +1,5 @@ +-- Remove video link from question_sets +ALTER TABLE question_sets DROP COLUMN IF EXISTS sub_course_video_id; + +-- Drop junction table +DROP TABLE IF EXISTS question_set_personas; diff --git a/db/migrations/000007_personas_and_practice_enhancements.up.sql b/db/migrations/000007_personas_and_practice_enhancements.up.sql new file mode 100644 index 0000000..a434f50 --- /dev/null +++ b/db/migrations/000007_personas_and_practice_enhancements.up.sql @@ -0,0 +1,18 @@ +-- Junction table to link users (as personas) to a question_set (practice) +CREATE TABLE question_set_personas ( + id BIGSERIAL PRIMARY KEY, + question_set_id BIGINT NOT NULL REFERENCES question_sets(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + display_order INT DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + UNIQUE(question_set_id, user_id) +); + +-- Add video context to question_sets for practice-video linking +ALTER TABLE question_sets + ADD COLUMN sub_course_video_id BIGINT REFERENCES sub_course_videos(id) ON DELETE SET NULL; + +-- Indexes +CREATE INDEX idx_question_set_personas_question_set_id ON question_set_personas(question_set_id); +CREATE INDEX idx_question_set_personas_user_id ON question_set_personas(user_id); +CREATE INDEX idx_question_sets_sub_course_video_id ON question_sets(sub_course_video_id); diff --git a/db/migrations/000008_subscriptions.down.sql b/db/migrations/000008_subscriptions.down.sql new file mode 100644 index 0000000..aa3b9fc --- /dev/null +++ b/db/migrations/000008_subscriptions.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS user_subscriptions; +DROP TABLE IF EXISTS subscription_plans; diff --git a/db/migrations/000008_subscriptions.up.sql b/db/migrations/000008_subscriptions.up.sql new file mode 100644 index 0000000..25d3a63 --- /dev/null +++ b/db/migrations/000008_subscriptions.up.sql @@ -0,0 +1,36 @@ +-- Subscription Plans table +CREATE TABLE subscription_plans ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT, + duration_value INT NOT NULL, + duration_unit VARCHAR(10) NOT NULL CHECK (duration_unit IN ('DAY', 'WEEK', 'MONTH', 'YEAR')), + price DECIMAL(10, 2) NOT NULL, + currency VARCHAR(3) NOT NULL DEFAULT 'ETB', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ +); + +CREATE INDEX idx_subscription_plans_active ON subscription_plans(is_active); + +-- User Subscriptions table +CREATE TABLE user_subscriptions ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + plan_id BIGINT NOT NULL REFERENCES subscription_plans(id) ON DELETE RESTRICT, + starts_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMPTZ NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('PENDING', 'ACTIVE', 'EXPIRED', 'CANCELLED')), + payment_reference VARCHAR(255), + payment_method VARCHAR(50), + auto_renew BOOLEAN NOT NULL DEFAULT FALSE, + cancelled_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ +); + +CREATE INDEX idx_user_subscriptions_user_id ON user_subscriptions(user_id); +CREATE INDEX idx_user_subscriptions_status ON user_subscriptions(status); +CREATE INDEX idx_user_subscriptions_expires_at ON user_subscriptions(expires_at); +CREATE INDEX idx_user_subscriptions_user_status ON user_subscriptions(user_id, status); diff --git a/db/migrations/000009_payments.down.sql b/db/migrations/000009_payments.down.sql new file mode 100644 index 0000000..14b4316 --- /dev/null +++ b/db/migrations/000009_payments.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS payments; diff --git a/db/migrations/000009_payments.up.sql b/db/migrations/000009_payments.up.sql new file mode 100644 index 0000000..2732539 --- /dev/null +++ b/db/migrations/000009_payments.up.sql @@ -0,0 +1,35 @@ +-- Payments table for tracking all payment transactions +CREATE TABLE payments ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + plan_id BIGINT REFERENCES subscription_plans(id) ON DELETE SET NULL, + subscription_id BIGINT REFERENCES user_subscriptions(id) ON DELETE SET NULL, + + -- ArifPay specific fields + session_id VARCHAR(100), + transaction_id VARCHAR(100), + nonce VARCHAR(255) NOT NULL UNIQUE, + + -- Payment details + amount DECIMAL(10, 2) NOT NULL, + currency VARCHAR(3) NOT NULL DEFAULT 'ETB', + payment_method VARCHAR(50), + + -- Status tracking + status VARCHAR(20) NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'PROCESSING', 'SUCCESS', 'FAILED', 'CANCELLED', 'EXPIRED')), + + -- URLs for redirect + payment_url TEXT, + + -- Timestamps + paid_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ +); + +CREATE INDEX idx_payments_user_id ON payments(user_id); +CREATE INDEX idx_payments_session_id ON payments(session_id); +CREATE INDEX idx_payments_nonce ON payments(nonce); +CREATE INDEX idx_payments_status ON payments(status); +CREATE INDEX idx_payments_subscription_id ON payments(subscription_id); diff --git a/db/migrations/000010_vimeo_video_hosting.down.sql b/db/migrations/000010_vimeo_video_hosting.down.sql new file mode 100644 index 0000000..44005f5 --- /dev/null +++ b/db/migrations/000010_vimeo_video_hosting.down.sql @@ -0,0 +1,9 @@ +-- Remove Vimeo video hosting fields from sub_course_videos table +DROP INDEX IF EXISTS idx_sub_course_videos_vimeo_id; + +ALTER TABLE sub_course_videos +DROP COLUMN IF EXISTS vimeo_id, +DROP COLUMN IF EXISTS vimeo_embed_url, +DROP COLUMN IF EXISTS vimeo_player_html, +DROP COLUMN IF EXISTS vimeo_status, +DROP COLUMN IF EXISTS video_host_provider; diff --git a/db/migrations/000010_vimeo_video_hosting.up.sql b/db/migrations/000010_vimeo_video_hosting.up.sql new file mode 100644 index 0000000..6e23577 --- /dev/null +++ b/db/migrations/000010_vimeo_video_hosting.up.sql @@ -0,0 +1,17 @@ +-- Add Vimeo video hosting fields to sub_course_videos table +ALTER TABLE sub_course_videos +ADD COLUMN IF NOT EXISTS vimeo_id TEXT, +ADD COLUMN IF NOT EXISTS vimeo_embed_url TEXT, +ADD COLUMN IF NOT EXISTS vimeo_player_html TEXT, +ADD COLUMN IF NOT EXISTS vimeo_status TEXT DEFAULT 'pending', +ADD COLUMN IF NOT EXISTS video_host_provider TEXT DEFAULT 'DIRECT'; + +-- Create index on vimeo_id for faster lookups +CREATE INDEX IF NOT EXISTS idx_sub_course_videos_vimeo_id ON sub_course_videos(vimeo_id) WHERE vimeo_id IS NOT NULL; + +-- Add comment for documentation +COMMENT ON COLUMN sub_course_videos.vimeo_id IS 'Vimeo video ID for videos hosted on Vimeo'; +COMMENT ON COLUMN sub_course_videos.vimeo_embed_url IS 'Vimeo player embed URL'; +COMMENT ON COLUMN sub_course_videos.vimeo_player_html IS 'Vimeo iframe embed HTML code'; +COMMENT ON COLUMN sub_course_videos.vimeo_status IS 'Vimeo video status: pending, uploading, transcoding, available, error'; +COMMENT ON COLUMN sub_course_videos.video_host_provider IS 'Video hosting provider: DIRECT or VIMEO'; diff --git a/db/migrations/000011_team_management.down.sql b/db/migrations/000011_team_management.down.sql new file mode 100644 index 0000000..a23704f --- /dev/null +++ b/db/migrations/000011_team_management.down.sql @@ -0,0 +1,5 @@ +DROP INDEX IF EXISTS idx_team_members_status; +DROP INDEX IF EXISTS idx_team_members_department; +DROP INDEX IF EXISTS idx_team_members_team_role; +DROP INDEX IF EXISTS idx_team_members_email; +DROP TABLE IF EXISTS team_members; diff --git a/db/migrations/000011_team_management.up.sql b/db/migrations/000011_team_management.up.sql new file mode 100644 index 0000000..09c587f --- /dev/null +++ b/db/migrations/000011_team_management.up.sql @@ -0,0 +1,72 @@ +-- Team members table for managing internal LMS company staff +CREATE TABLE IF NOT EXISTS team_members ( + id BIGSERIAL PRIMARY KEY, + + -- Basic info + first_name VARCHAR(255) NOT NULL, + last_name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL UNIQUE, + phone_number VARCHAR(20), + + -- Authentication + password BYTEA NOT NULL, + + -- Role within the team (different from learner roles) + team_role VARCHAR(50) NOT NULL CHECK ( + team_role IN ( + 'super_admin', -- Full system access + 'admin', -- Administrative tasks + 'content_manager', -- Manages courses, content + 'support_agent', -- Customer support + 'instructor', -- Creates/manages courses + 'finance', -- Payment/subscription management + 'hr', -- Team member management + 'analyst' -- Reports and analytics + ) + ), + + -- Department + department VARCHAR(100), + + -- Job title + job_title VARCHAR(150), + + -- Employment details + employment_type VARCHAR(50) CHECK ( + employment_type IN ('full_time', 'part_time', 'contract', 'intern') + ), + hire_date DATE, + + -- Profile + profile_picture_url TEXT, + bio TEXT, + + -- Contact + work_phone VARCHAR(20), + emergency_contact VARCHAR(255), + + -- Status + status VARCHAR(50) NOT NULL DEFAULT 'active' CHECK ( + status IN ('active', 'inactive', 'suspended', 'terminated') + ), + + -- Verification + email_verified BOOLEAN NOT NULL DEFAULT FALSE, + + -- Permissions (JSON array of permission strings) + permissions JSONB DEFAULT '[]'::jsonb, + + -- Tracking + last_login TIMESTAMPTZ, + created_by BIGINT REFERENCES team_members(id), + updated_by BIGINT REFERENCES team_members(id), + + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_team_members_email ON team_members(email); +CREATE INDEX IF NOT EXISTS idx_team_members_team_role ON team_members(team_role); +CREATE INDEX IF NOT EXISTS idx_team_members_department ON team_members(department); +CREATE INDEX IF NOT EXISTS idx_team_members_status ON team_members(status); diff --git a/db/migrations/000012_profile_completion.down.sql b/db/migrations/000012_profile_completion.down.sql new file mode 100644 index 0000000..a9f3a8b --- /dev/null +++ b/db/migrations/000012_profile_completion.down.sql @@ -0,0 +1,8 @@ +-- Drop trigger +DROP TRIGGER IF EXISTS trg_update_profile_completion ON users; + +-- Drop function +DROP FUNCTION IF EXISTS calculate_profile_completion(); + +-- Drop column +ALTER TABLE users DROP COLUMN IF EXISTS profile_completion_percentage; diff --git a/db/migrations/000012_profile_completion.up.sql b/db/migrations/000012_profile_completion.up.sql new file mode 100644 index 0000000..9da668a --- /dev/null +++ b/db/migrations/000012_profile_completion.up.sql @@ -0,0 +1,76 @@ +-- Add profile_completion_percentage column +ALTER TABLE users ADD COLUMN profile_completion_percentage SMALLINT NOT NULL DEFAULT 0; + +-- Create function to calculate profile completion +CREATE OR REPLACE FUNCTION calculate_profile_completion() +RETURNS TRIGGER AS $$ +DECLARE + filled_count INTEGER := 0; +BEGIN + -- Check first_name + IF NULLIF(TRIM(NEW.first_name), '') IS NOT NULL THEN + filled_count := filled_count + 1; + END IF; + + -- Check last_name + IF NULLIF(TRIM(NEW.last_name), '') IS NOT NULL THEN + filled_count := filled_count + 1; + END IF; + + -- Check email OR phone_number (counts as 1 if either is filled) + IF NULLIF(TRIM(NEW.email), '') IS NOT NULL OR NULLIF(TRIM(NEW.phone_number), '') IS NOT NULL THEN + filled_count := filled_count + 1; + END IF; + + -- Check preferred_language + IF NULLIF(TRIM(NEW.preferred_language), '') IS NOT NULL THEN + filled_count := filled_count + 1; + END IF; + + -- Check country + IF NULLIF(TRIM(NEW.country), '') IS NOT NULL THEN + filled_count := filled_count + 1; + END IF; + + -- Check age_group + IF NULLIF(TRIM(NEW.age_group), '') IS NOT NULL THEN + filled_count := filled_count + 1; + END IF; + + -- Check knowledge_level + IF NULLIF(TRIM(NEW.knowledge_level), '') IS NOT NULL THEN + filled_count := filled_count + 1; + END IF; + + -- Check learning_goal + IF NULLIF(TRIM(NEW.learning_goal), '') IS NOT NULL THEN + filled_count := filled_count + 1; + END IF; + + -- Check language_goal + IF NULLIF(TRIM(NEW.language_goal), '') IS NOT NULL THEN + filled_count := filled_count + 1; + END IF; + + -- Calculate percentage (9 total required fields) + NEW.profile_completion_percentage := (filled_count * 100 / 9)::SMALLINT; + + -- Set profile_completed if 100% + IF NEW.profile_completion_percentage = 100 THEN + NEW.profile_completed := true; + ELSE + NEW.profile_completed := false; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Create trigger +CREATE TRIGGER trg_update_profile_completion + BEFORE INSERT OR UPDATE ON users + FOR EACH ROW + EXECUTE FUNCTION calculate_profile_completion(); + +-- Backfill existing rows +UPDATE users SET updated_at = updated_at; diff --git a/db/migrations/000013_devices_constraints.down.sql b/db/migrations/000013_devices_constraints.down.sql new file mode 100644 index 0000000..a15bad4 --- /dev/null +++ b/db/migrations/000013_devices_constraints.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE devices DROP CONSTRAINT IF EXISTS devices_user_fk; +ALTER TABLE devices DROP CONSTRAINT IF EXISTS devices_user_id_device_token_uniq; diff --git a/db/migrations/000013_devices_constraints.up.sql b/db/migrations/000013_devices_constraints.up.sql new file mode 100644 index 0000000..a108727 --- /dev/null +++ b/db/migrations/000013_devices_constraints.up.sql @@ -0,0 +1,8 @@ +-- Add unique constraint for ON CONFLICT to work in CreateDevice query +ALTER TABLE devices + ADD CONSTRAINT devices_user_id_device_token_uniq UNIQUE (user_id, device_token); + +-- Add foreign key to users table for data integrity and cascade deletion +ALTER TABLE devices + ADD CONSTRAINT devices_user_fk + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; diff --git a/db/query/course_programs.sql b/db/query/course_programs.sql deleted file mode 100644 index 1145c16..0000000 --- a/db/query/course_programs.sql +++ /dev/null @@ -1,113 +0,0 @@ --- name: CreateProgram :one -INSERT INTO programs ( - course_id, - title, - description, - thumbnail, - display_order, - is_active -) -VALUES ($1, $2, $3, $4, COALESCE($5, 0), COALESCE($6, true)) -RETURNING *; - - --- name: GetProgramsByCourse :many -SELECT - COUNT(*) OVER () AS total_count, - id, - course_id, - title, - description, - thumbnail, - display_order, - is_active -FROM programs -WHERE course_id = $1 -ORDER BY display_order ASC; - --- name: UpdateProgramPartial :exec -UPDATE programs -SET - title = COALESCE($1, title), - description = COALESCE($2, description), - thumbnail = COALESCE($3, thumbnail), - display_order = COALESCE($4, display_order), - is_active = COALESCE($5, is_active) -WHERE id = $6; - --- name: DeleteProgram :one -DELETE FROM programs -WHERE id = $1 -RETURNING - id, - course_id, - title, - description, - thumbnail, - display_order, - is_active; - - --- name: GetProgramByID :one -SELECT - id, - course_id, - title, - description, - thumbnail, - display_order, - is_active -FROM programs -WHERE id = $1; - --- name: ListProgramsByCourse :many -SELECT - id, - course_id, - title, - description, - thumbnail, - display_order, - is_active -FROM programs -WHERE course_id = $1 - AND is_active = TRUE -ORDER BY display_order ASC, id ASC; - --- name: ListActivePrograms :many -SELECT - id, - course_id, - title, - description, - thumbnail, - display_order, - is_active -FROM programs -WHERE is_active = TRUE -ORDER BY display_order ASC; - --- name: UpdateProgramFull :one -UPDATE programs -SET - course_id = $2, - title = $3, - description = $4, - thumbnail = $5, - display_order = $6, - is_active = $7 -WHERE id = $1 -RETURNING - id, - course_id, - title, - description, - thumbnail, - display_order, - is_active; - - --- name: DeactivateProgram :exec -UPDATE programs -SET is_active = FALSE -WHERE id = $1; diff --git a/db/query/courses.sql b/db/query/courses.sql index b65661b..75a8152 100644 --- a/db/query/courses.sql +++ b/db/query/courses.sql @@ -3,9 +3,10 @@ INSERT INTO courses ( category_id, title, description, + thumbnail, is_active ) -VALUES ($1, $2, $3, COALESCE($4, true)) +VALUES ($1, $2, $3, $4, COALESCE($5, true)) RETURNING *; @@ -22,6 +23,7 @@ SELECT category_id, title, description, + thumbnail, is_active FROM courses WHERE category_id = $1 @@ -35,8 +37,9 @@ UPDATE courses SET title = COALESCE($1, title), description = COALESCE($2, description), - is_active = COALESCE($3, is_active) -WHERE id = $4; + thumbnail = COALESCE($3, thumbnail), + is_active = COALESCE($4, is_active) +WHERE id = $5; -- name: DeleteCourse :exec diff --git a/db/query/device.sql b/db/query/device.sql index 22a0337..adb515a 100644 --- a/db/query/device.sql +++ b/db/query/device.sql @@ -36,4 +36,9 @@ WHERE user_id = $1; -- name: GetActiveDeviceTokens :many SELECT device_token FROM devices -WHERE user_id = $1 AND is_active = true AND platform IN ('android', 'ios'); \ No newline at end of file +WHERE user_id = $1 AND is_active = true AND platform IN ('android', 'ios'); + +-- name: DeactivateDeviceByToken :exec +UPDATE devices +SET is_active = false +WHERE user_id = $1 AND device_token = $2; \ No newline at end of file diff --git a/db/query/initial_assessment.sql b/db/query/initial_assessment.sql deleted file mode 100644 index 2c06d4f..0000000 --- a/db/query/initial_assessment.sql +++ /dev/null @@ -1,251 +0,0 @@ --- name: CreateAssessmentQuestion :one -INSERT INTO assessment_questions ( - title, - description, - question_type, - difficulty_level, - points, - is_active -) -VALUES ( - $1, -- title - $2, -- description - $3, -- question_type - $4, -- difficulty_level - $5, -- points - $6 -- is_active -) -RETURNING *; - --- name: GetAssessmentQuestionByID :one -SELECT * -FROM assessment_questions -WHERE id = $1; - --- name: GetActiveAssessmentQuestions :many -SELECT * -FROM assessment_questions -WHERE is_active = true -ORDER BY created_at DESC; - --- name: GetAssessmentQuestionsPaginated :many -SELECT - COUNT(*) OVER () AS total_count, - id, - title, - description, - question_type, - difficulty_level, - points, - is_active, - created_at, - updated_at -FROM assessment_questions -WHERE ($1 IS NULL OR question_type = $1) - AND ($2 IS NULL OR difficulty_level = $2) - AND ($3 IS NULL OR is_active = $3) -LIMIT $4 -OFFSET $5; - --- name: UpdateAssessmentQuestion :exec -UPDATE assessment_questions -SET - title = COALESCE($1, title), - description = COALESCE($2, description), - question_type = COALESCE($3, question_type), - difficulty_level = COALESCE($4, difficulty_level), - points = COALESCE($5, points), - is_active = COALESCE($6, is_active), - updated_at = CURRENT_TIMESTAMP -WHERE id = $7; - --- name: DeleteAssessmentQuestion :exec -DELETE FROM assessment_questions -WHERE id = $1; - --- name: CreateQuestionOption :one -INSERT INTO assessment_question_options ( - question_id, - option_text, - option_order, - is_correct -) -VALUES ( - $1, -- question_id - $2, -- option_text - $3, -- option_order - $4 -- is_correct -) -RETURNING *; - --- name: GetQuestionOptions :many -SELECT * -FROM assessment_question_options -WHERE question_id = $1 -ORDER BY option_order; - --- name: DeleteQuestionOptionsByQuestionID :exec -DELETE FROM assessment_question_options -WHERE question_id = $1; - --- name: CreateShortAnswer :one -INSERT INTO assessment_short_answers ( - question_id, - correct_answer -) -VALUES ( - $1, -- question_id - $2 -- correct_answer -) -RETURNING *; - --- name: GetShortAnswersByQuestionID :many -SELECT * -FROM assessment_short_answers -WHERE question_id = $1; - --------------------------------------------------------------------------------------- - --- name: CreateAssessmentAttempt :one -INSERT INTO assessment_attempts ( - user_id, - total_questions, - total_points, - status -) -VALUES ( - $1, -- user_id - $2, -- total_questions - $3, -- total_points - 'IN_PROGRESS' -) -RETURNING *; - --- name: GetAssessmentAttemptByID :one -SELECT * -FROM assessment_attempts -WHERE id = $1; - --- name: GetUserAssessmentAttempts :many -SELECT - id, - user_id, - total_questions, - total_points, - score, - percentage, - status, - started_at, - submitted_at, - evaluated_at -FROM assessment_attempts -WHERE user_id = $1 -ORDER BY started_at DESC; - --- name: SubmitAssessmentAttempt :exec -UPDATE assessment_attempts -SET - status = 'SUBMITTED', - submitted_at = CURRENT_TIMESTAMP, - updated_at = CURRENT_TIMESTAMP -WHERE id = $1; - --- name: AddAttemptQuestion :exec -INSERT INTO assessment_attempt_questions ( - attempt_id, - question_id, - question_type, - points -) -VALUES ( - $1, -- attempt_id - $2, -- question_id - $3, -- question_type - $4 -- points -); - --- name: GetAttemptQuestions :many -SELECT - aq.question_id, - aq.question_type, - aq.points, - q.title, - q.description -FROM assessment_attempt_questions aq -JOIN assessment_questions q ON q.id = aq.question_id -WHERE aq.attempt_id = $1; - --- name: UpsertAttemptAnswer :exec -INSERT INTO assessment_attempt_answers ( - attempt_id, - question_id, - selected_option_id, - submitted_text -) -VALUES ( - $1, -- attempt_id - $2, -- question_id - $3, -- selected_option_id - $4 -- submitted_text -) -ON CONFLICT (attempt_id, question_id) -DO UPDATE SET - selected_option_id = EXCLUDED.selected_option_id, - submitted_text = EXCLUDED.submitted_text; - --- name: GetAttemptAnswers :many -SELECT * -FROM assessment_attempt_answers -WHERE attempt_id = $1; - --- name: EvaluateMCQAnswer :exec -UPDATE assessment_attempt_answers a -SET - is_correct = o.is_correct, - awarded_points = CASE WHEN o.is_correct THEN q.points ELSE 0 END -FROM assessment_question_options o -JOIN assessment_questions q ON q.id = a.question_id -WHERE a.selected_option_id = o.id - AND a.attempt_id = $1; - --- name: EvaluateShortAnswer :exec -UPDATE assessment_attempt_answers a -SET - is_correct = EXISTS ( - SELECT 1 - FROM assessment_short_answers s - WHERE s.question_id = a.question_id - AND LOWER(TRIM(s.correct_answer)) = LOWER(TRIM(a.submitted_text)) - ), - awarded_points = CASE - WHEN EXISTS ( - SELECT 1 - FROM assessment_short_answers s - WHERE s.question_id = a.question_id - AND LOWER(TRIM(s.correct_answer)) = LOWER(TRIM(a.submitted_text)) - ) - THEN q.points - ELSE 0 - END -FROM assessment_questions q -WHERE a.question_id = q.id - AND a.attempt_id = $1; - --- name: FinalizeAssessmentAttempt :exec -UPDATE assessment_attempts -SET - score = sub.total_score, - percentage = ROUND((sub.total_score::NUMERIC / total_points) * 100, 2), - status = 'EVALUATED', - evaluated_at = CURRENT_TIMESTAMP, - updated_at = CURRENT_TIMESTAMP -FROM ( - SELECT attempt_id, SUM(awarded_points) AS total_score - FROM assessment_attempt_answers - WHERE attempt_id = $1 - GROUP BY attempt_id -) sub -WHERE assessment_attempts.id = sub.attempt_id; - - - diff --git a/db/query/learning_tree.sql b/db/query/learning_tree.sql index fc072bb..275e983 100644 --- a/db/query/learning_tree.sql +++ b/db/query/learning_tree.sql @@ -2,15 +2,10 @@ SELECT c.id AS course_id, c.title AS course_title, - p.id AS program_id, - p.title AS program_title, - l.id AS level_id, - l.title AS level_title, - m.id AS module_id, - m.title AS module_title + sc.id AS sub_course_id, + sc.title AS sub_course_title, + sc.level AS sub_course_level FROM courses c -JOIN programs p ON p.course_id = c.id -JOIN levels l ON l.program_id = p.id -LEFT JOIN modules m ON m.level_id = l.id +LEFT JOIN sub_courses sc ON sc.course_id = c.id AND sc.is_active = true WHERE c.is_active = true -ORDER BY p.display_order, l.level_index, m.display_order; +ORDER BY c.id, sc.display_order, sc.id; diff --git a/db/query/level_modules.sql b/db/query/level_modules.sql deleted file mode 100644 index 3fab852..0000000 --- a/db/query/level_modules.sql +++ /dev/null @@ -1,39 +0,0 @@ --- name: CreateModule :one -INSERT INTO modules ( - level_id, - title, - content, - display_order, - is_active -) -VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, true)) -RETURNING *; - - --- name: GetModulesByLevel :many -SELECT - COUNT(*) OVER () AS total_count, - id, - level_id, - title, - content, - display_order, - is_active -FROM modules -WHERE level_id = $1 -ORDER BY display_order ASC; - - --- name: UpdateModule :exec -UPDATE modules -SET - title = COALESCE($1, title), - content = COALESCE($2, content), - display_order = COALESCE($3, display_order), - is_active = COALESCE($4, is_active) -WHERE id = $5; - - --- name: DeleteModule :exec -DELETE FROM modules -WHERE id = $1; diff --git a/db/query/module_videos.sql b/db/query/module_videos.sql deleted file mode 100644 index d040752..0000000 --- a/db/query/module_videos.sql +++ /dev/null @@ -1,55 +0,0 @@ --- name: CreateModuleVideo :one -INSERT INTO module_videos ( - module_id, - title, - description, - video_url, - duration, - resolution, - instructor_id, - thumbnail, - visibility, - is_active -) -VALUES ( - $1, $2, $3, $4, $5, $6, - $7, $8, $9, - COALESCE($10, true) -) -RETURNING *; - - --- name: PublishModuleVideo :exec -UPDATE module_videos -SET - is_published = true, - publish_date = CURRENT_TIMESTAMP -WHERE id = $1; - - --- name: GetPublishedVideosByModule :many -SELECT * -FROM module_videos -WHERE module_id = $1 - AND is_published = true - AND is_active = true -ORDER BY publish_date ASC; - - --- name: UpdateModuleVideo :exec -UPDATE module_videos -SET - title = COALESCE($1, title), - description = COALESCE($2, description), - video_url = COALESCE($3, video_url), - duration = COALESCE($4, duration), - resolution = COALESCE($5, resolution), - visibility = COALESCE($6, visibility), - thumbnail = COALESCE($7, thumbnail), - is_active = COALESCE($8, is_active) -WHERE id = $9; - - --- name: DeleteModuleVideo :exec -DELETE FROM module_videos -WHERE id = $1; diff --git a/db/query/payments.sql b/db/query/payments.sql new file mode 100644 index 0000000..8812d40 --- /dev/null +++ b/db/query/payments.sql @@ -0,0 +1,95 @@ +-- ===================== +-- Payments +-- ===================== + +-- name: CreatePayment :one +INSERT INTO payments ( + user_id, plan_id, subscription_id, session_id, transaction_id, nonce, + amount, currency, payment_method, status, payment_url, expires_at +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, 'PENDING'), $11, $12) +RETURNING *; + +-- name: GetPaymentByID :one +SELECT * FROM payments WHERE id = $1; + +-- name: GetPaymentBySessionID :one +SELECT * FROM payments WHERE session_id = $1; + +-- name: GetPaymentByNonce :one +SELECT * FROM payments WHERE nonce = $1; + +-- name: GetPaymentByTransactionID :one +SELECT * FROM payments WHERE transaction_id = $1; + +-- name: GetPaymentsByUserID :many +SELECT p.*, sp.name AS plan_name +FROM payments p +LEFT JOIN subscription_plans sp ON sp.id = p.plan_id +WHERE p.user_id = $1 +ORDER BY p.created_at DESC +LIMIT sqlc.narg('limit')::INT +OFFSET sqlc.narg('offset')::INT; + +-- name: GetPendingPaymentsByUserID :many +SELECT * FROM payments +WHERE user_id = $1 AND status = 'PENDING' +ORDER BY created_at DESC; + +-- name: UpdatePaymentStatus :exec +UPDATE payments +SET + status = $1, + updated_at = CURRENT_TIMESTAMP +WHERE id = $2; + +-- name: UpdatePaymentStatusBySessionID :exec +UPDATE payments +SET + status = $1, + transaction_id = COALESCE($2, transaction_id), + payment_method = COALESCE($3, payment_method), + paid_at = CASE WHEN $1 = 'SUCCESS' THEN CURRENT_TIMESTAMP ELSE paid_at END, + updated_at = CURRENT_TIMESTAMP +WHERE session_id = $4; + +-- name: UpdatePaymentStatusByNonce :exec +UPDATE payments +SET + status = $1, + transaction_id = COALESCE($2, transaction_id), + payment_method = COALESCE($3, payment_method), + paid_at = CASE WHEN $1 = 'SUCCESS' THEN CURRENT_TIMESTAMP ELSE paid_at END, + updated_at = CURRENT_TIMESTAMP +WHERE nonce = $4; + +-- name: UpdatePaymentSessionID :exec +UPDATE payments +SET + session_id = $1, + payment_url = $2, + updated_at = CURRENT_TIMESTAMP +WHERE id = $3; + +-- name: LinkPaymentToSubscription :exec +UPDATE payments +SET + subscription_id = $1, + updated_at = CURRENT_TIMESTAMP +WHERE id = $2; + +-- name: GetExpiredPendingPayments :many +SELECT * FROM payments +WHERE status = 'PENDING' + AND expires_at IS NOT NULL + AND expires_at <= CURRENT_TIMESTAMP; + +-- name: ExpirePayment :exec +UPDATE payments +SET + status = 'EXPIRED', + updated_at = CURRENT_TIMESTAMP +WHERE id = $1; + +-- name: CountUserPayments :one +SELECT COUNT(*) FROM payments WHERE user_id = $1; diff --git a/db/query/practice_questions.sql b/db/query/practice_questions.sql deleted file mode 100644 index d9591ad..0000000 --- a/db/query/practice_questions.sql +++ /dev/null @@ -1,34 +0,0 @@ --- name: CreatePracticeQuestion :one -INSERT INTO practice_questions ( - practice_id, - question, - question_voice_prompt, - sample_answer_voice_prompt, - sample_answer, - tips, - type -) -VALUES ($1, $2, $3, $4, $5, $6, $7) -RETURNING *; - - --- name: GetQuestionsByPractice :many -SELECT * -FROM practice_questions -WHERE practice_id = $1 -ORDER BY id ASC; - - --- name: UpdatePracticeQuestion :exec -UPDATE practice_questions -SET - question = COALESCE($1, question), - sample_answer = COALESCE($2, sample_answer), - tips = COALESCE($3, tips), - type = COALESCE($4, type) -WHERE id = $5; - - --- name: DeletePracticeQuestion :exec -DELETE FROM practice_questions -WHERE id = $1; diff --git a/db/query/practices.sql b/db/query/practices.sql deleted file mode 100644 index 6d797d6..0000000 --- a/db/query/practices.sql +++ /dev/null @@ -1,36 +0,0 @@ --- name: CreatePractice :one -INSERT INTO practices ( - owner_type, - owner_id, - title, - description, - banner_image, - persona, - is_active -) -VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, true)) -RETURNING *; - - --- name: GetPracticesByOwner :many -SELECT * -FROM practices -WHERE owner_type = $1 - AND owner_id = $2 - AND is_active = true; - - --- name: UpdatePractice :exec -UPDATE practices -SET - title = COALESCE($1, title), - description = COALESCE($2, description), - banner_image = COALESCE($3, banner_image), - persona = COALESCE($4, persona), - is_active = COALESCE($5, is_active) -WHERE id = $6; - - --- name: DeletePractice :exec -DELETE FROM practices -WHERE id = $1; diff --git a/db/query/program_levels.sql b/db/query/program_levels.sql deleted file mode 100644 index 3e16374..0000000 --- a/db/query/program_levels.sql +++ /dev/null @@ -1,60 +0,0 @@ --- name: CreateLevel :one -INSERT INTO levels ( - program_id, - title, - description, - level_index, - is_active -) -VALUES ($1, $2, $3, $4, COALESCE($5, true)) -RETURNING *; - - --- name: GetLevelsByProgram :many -SELECT - COUNT(*) OVER () AS total_count, - id, - program_id, - title, - description, - level_index, - number_of_modules, - number_of_practices, - number_of_videos, - is_active -FROM levels -WHERE program_id = $1 -ORDER BY level_index ASC; - - --- name: UpdateLevel :exec -UPDATE levels -SET - title = COALESCE($1, title), - description = COALESCE($2, description), - level_index = COALESCE($3, level_index), - is_active = COALESCE($4, is_active) -WHERE id = $5; - - --- name: IncrementLevelModuleCount :exec -UPDATE levels -SET number_of_modules = number_of_modules + 1 -WHERE id = $1; - - --- name: IncrementLevelPracticeCount :exec -UPDATE levels -SET number_of_practices = number_of_practices + 1 -WHERE id = $1; - - --- name: IncrementLevelVideoCount :exec -UPDATE levels -SET number_of_videos = number_of_videos + 1 -WHERE id = $1; - - --- name: DeleteLevel :exec -DELETE FROM levels -WHERE id = $1; diff --git a/db/query/question_options.sql b/db/query/question_options.sql new file mode 100644 index 0000000..2cb0a63 --- /dev/null +++ b/db/query/question_options.sql @@ -0,0 +1,39 @@ +-- name: CreateQuestionOption :one +INSERT INTO question_options ( + question_id, + option_text, + option_order, + is_correct +) +VALUES ($1, $2, COALESCE($3, 0), COALESCE($4, false)) +RETURNING *; + +-- name: GetOptionsByQuestionID :many +SELECT * +FROM question_options +WHERE question_id = $1 +ORDER BY option_order; + +-- name: UpdateQuestionOption :exec +UPDATE question_options +SET + option_text = COALESCE($1, option_text), + option_order = COALESCE($2, option_order), + is_correct = COALESCE($3, is_correct) +WHERE id = $4; + +-- name: DeleteQuestionOption :exec +DELETE FROM question_options +WHERE id = $1; + +-- name: DeleteOptionsByQuestionID :exec +DELETE FROM question_options +WHERE question_id = $1; + +-- name: BulkCreateQuestionOptions :copyfrom +INSERT INTO question_options ( + question_id, + option_text, + option_order, + is_correct +) VALUES ($1, $2, $3, $4); diff --git a/db/query/question_set_items.sql b/db/query/question_set_items.sql new file mode 100644 index 0000000..1e6e189 --- /dev/null +++ b/db/query/question_set_items.sql @@ -0,0 +1,71 @@ +-- name: AddQuestionToSet :one +INSERT INTO question_set_items ( + set_id, + question_id, + display_order +) +VALUES ($1, $2, COALESCE($3, 0)) +ON CONFLICT (set_id, question_id) DO UPDATE SET display_order = EXCLUDED.display_order +RETURNING *; + +-- name: GetQuestionSetItems :many +SELECT + qsi.id, + qsi.set_id, + qsi.question_id, + qsi.display_order, + q.question_text, + q.question_type, + q.difficulty_level, + q.points, + q.explanation, + q.tips, + q.voice_prompt, + q.status as question_status +FROM question_set_items qsi +JOIN questions q ON q.id = qsi.question_id +WHERE qsi.set_id = $1 + AND q.status != 'ARCHIVED' +ORDER BY qsi.display_order; + +-- name: GetPublishedQuestionsInSet :many +SELECT + qsi.id, + qsi.set_id, + qsi.question_id, + qsi.display_order, + q.question_text, + q.question_type, + q.difficulty_level, + q.points, + q.explanation, + q.tips, + q.voice_prompt +FROM question_set_items qsi +JOIN questions q ON q.id = qsi.question_id +WHERE qsi.set_id = $1 + AND q.status = 'PUBLISHED' +ORDER BY qsi.display_order; + +-- name: RemoveQuestionFromSet :exec +DELETE FROM question_set_items +WHERE set_id = $1 AND question_id = $2; + +-- name: UpdateQuestionOrder :exec +UPDATE question_set_items +SET display_order = $1 +WHERE set_id = $2 AND question_id = $3; + +-- name: CountQuestionsInSet :one +SELECT COUNT(*) as count +FROM question_set_items qsi +JOIN questions q ON q.id = qsi.question_id +WHERE qsi.set_id = $1 + AND q.status != 'ARCHIVED'; + +-- name: GetQuestionSetsContainingQuestion :many +SELECT qs.* +FROM question_sets qs +JOIN question_set_items qsi ON qsi.set_id = qs.id +WHERE qsi.question_id = $1 + AND qs.status != 'ARCHIVED'; diff --git a/db/query/question_sets.sql b/db/query/question_sets.sql new file mode 100644 index 0000000..295a103 --- /dev/null +++ b/db/query/question_sets.sql @@ -0,0 +1,116 @@ +-- name: CreateQuestionSet :one +INSERT INTO question_sets ( + title, + description, + set_type, + owner_type, + owner_id, + banner_image, + persona, + time_limit_minutes, + passing_score, + shuffle_questions, + status, + sub_course_video_id +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, false), COALESCE($11, 'DRAFT'), $12) +RETURNING *; + +-- name: GetQuestionSetByID :one +SELECT * +FROM question_sets +WHERE id = $1; + +-- name: GetQuestionSetsByOwner :many +SELECT * +FROM question_sets +WHERE owner_type = $1 + AND owner_id = $2 + AND status != 'ARCHIVED' +ORDER BY created_at DESC; + +-- name: GetQuestionSetsByType :many +SELECT + COUNT(*) OVER () AS total_count, + qs.* +FROM question_sets qs +WHERE set_type = $1 + AND status != 'ARCHIVED' +ORDER BY created_at DESC +LIMIT sqlc.narg('limit')::INT +OFFSET sqlc.narg('offset')::INT; + +-- name: GetPublishedQuestionSetsByOwner :many +SELECT * +FROM question_sets +WHERE owner_type = $1 + AND owner_id = $2 + AND status = 'PUBLISHED' +ORDER BY created_at DESC; + +-- name: UpdateQuestionSet :exec +UPDATE question_sets +SET + title = COALESCE($1, title), + description = COALESCE($2, description), + banner_image = COALESCE($3, banner_image), + persona = COALESCE($4, persona), + time_limit_minutes = COALESCE($5, time_limit_minutes), + passing_score = COALESCE($6, passing_score), + shuffle_questions = COALESCE($7, shuffle_questions), + status = COALESCE($8, status), + sub_course_video_id = COALESCE($9, sub_course_video_id), + updated_at = CURRENT_TIMESTAMP +WHERE id = $10; + +-- name: ArchiveQuestionSet :exec +UPDATE question_sets +SET status = 'ARCHIVED', updated_at = CURRENT_TIMESTAMP +WHERE id = $1; + +-- name: DeleteQuestionSet :exec +DELETE FROM question_sets +WHERE id = $1; + +-- name: GetInitialAssessmentSet :one +SELECT * +FROM question_sets +WHERE set_type = 'INITIAL_ASSESSMENT' + AND status = 'PUBLISHED' +ORDER BY created_at DESC +LIMIT 1; + +-- name: AddUserPersonaToQuestionSet :one +INSERT INTO question_set_personas ( + question_set_id, + user_id, + display_order +) +VALUES ($1, $2, COALESCE($3, 0)) +RETURNING *; + +-- name: RemoveUserPersonaFromQuestionSet :exec +DELETE FROM question_set_personas +WHERE question_set_id = $1 + AND user_id = $2; + +-- name: GetUserPersonasByQuestionSetID :many +SELECT + u.id, + u.first_name, + u.last_name, + u.nick_name, + u.profile_picture_url, + u.role, + qsp.display_order +FROM users u +INNER JOIN question_set_personas qsp ON qsp.user_id = u.id +WHERE qsp.question_set_id = $1 +ORDER BY qsp.display_order ASC; + +-- name: UpdateQuestionSetVideoLink :exec +UPDATE question_sets +SET + sub_course_video_id = $1, + updated_at = CURRENT_TIMESTAMP +WHERE id = $2; diff --git a/db/query/question_short_answers.sql b/db/query/question_short_answers.sql new file mode 100644 index 0000000..6141fe8 --- /dev/null +++ b/db/query/question_short_answers.sql @@ -0,0 +1,28 @@ +-- name: CreateQuestionShortAnswer :one +INSERT INTO question_short_answers ( + question_id, + acceptable_answer, + match_type +) +VALUES ($1, $2, COALESCE($3, 'EXACT')) +RETURNING *; + +-- name: GetShortAnswersByQuestionID :many +SELECT * +FROM question_short_answers +WHERE question_id = $1; + +-- name: UpdateQuestionShortAnswer :exec +UPDATE question_short_answers +SET + acceptable_answer = COALESCE($1, acceptable_answer), + match_type = COALESCE($2, match_type) +WHERE id = $3; + +-- name: DeleteQuestionShortAnswer :exec +DELETE FROM question_short_answers +WHERE id = $1; + +-- name: DeleteShortAnswersByQuestionID :exec +DELETE FROM question_short_answers +WHERE question_id = $1; diff --git a/db/query/questions.sql b/db/query/questions.sql new file mode 100644 index 0000000..3c21744 --- /dev/null +++ b/db/query/questions.sql @@ -0,0 +1,93 @@ +-- name: CreateQuestion :one +INSERT INTO questions ( + question_text, + question_type, + difficulty_level, + points, + explanation, + tips, + voice_prompt, + sample_answer_voice_prompt, + status +) +VALUES ($1, $2, $3, COALESCE($4, 1), $5, $6, $7, $8, COALESCE($9, 'DRAFT')) +RETURNING *; + +-- name: GetQuestionByID :one +SELECT * +FROM questions +WHERE id = $1; + +-- name: GetQuestionsByIDs :many +SELECT * +FROM questions +WHERE id = ANY($1::BIGINT[]) +ORDER BY id; + +-- name: ListQuestions :many +SELECT + COUNT(*) OVER () AS total_count, + q.* +FROM questions q +WHERE status != 'ARCHIVED' + AND ($1::VARCHAR IS NULL OR $1 = '' OR question_type = $1) + AND ($2::VARCHAR IS NULL OR $2 = '' OR difficulty_level = $2) + AND ($3::VARCHAR IS NULL OR $3 = '' OR status = $3) +ORDER BY created_at DESC +LIMIT sqlc.narg('limit')::INT +OFFSET sqlc.narg('offset')::INT; + +-- name: SearchQuestions :many +SELECT + COUNT(*) OVER () AS total_count, + q.* +FROM questions q +WHERE status != 'ARCHIVED' + AND question_text ILIKE '%' || $1 || '%' +ORDER BY created_at DESC +LIMIT sqlc.narg('limit')::INT +OFFSET sqlc.narg('offset')::INT; + +-- name: UpdateQuestion :exec +UPDATE questions +SET + question_text = COALESCE($1, question_text), + question_type = COALESCE($2, question_type), + difficulty_level = COALESCE($3, difficulty_level), + points = COALESCE($4, points), + explanation = COALESCE($5, explanation), + tips = COALESCE($6, tips), + voice_prompt = COALESCE($7, voice_prompt), + sample_answer_voice_prompt = COALESCE($8, sample_answer_voice_prompt), + status = COALESCE($9, status), + updated_at = CURRENT_TIMESTAMP +WHERE id = $10; + +-- name: ArchiveQuestion :exec +UPDATE questions +SET status = 'ARCHIVED', updated_at = CURRENT_TIMESTAMP +WHERE id = $1; + +-- name: DeleteQuestion :exec +DELETE FROM questions +WHERE id = $1; + +-- name: GetQuestionWithOptions :many +SELECT + q.id as question_id, + q.question_text, + q.question_type, + q.difficulty_level, + q.points, + q.explanation, + q.tips, + q.voice_prompt, + q.status, + qo.id as option_id, + qo.option_text, + qo.option_order, + qo.is_correct +FROM questions q +LEFT JOIN question_options qo ON qo.question_id = q.id +WHERE q.id = $1 +ORDER BY qo.option_order; diff --git a/db/query/sub_course_videos.sql b/db/query/sub_course_videos.sql new file mode 100644 index 0000000..0f2caf0 --- /dev/null +++ b/db/query/sub_course_videos.sql @@ -0,0 +1,114 @@ +-- name: CreateSubCourseVideo :one +INSERT INTO sub_course_videos ( + sub_course_id, + title, + description, + video_url, + duration, + resolution, + instructor_id, + thumbnail, + visibility, + display_order, + status, + vimeo_id, + vimeo_embed_url, + vimeo_player_html, + vimeo_status, + video_host_provider +) +VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, + COALESCE($10, 0), + COALESCE($11, 'DRAFT'), + $12, $13, $14, + COALESCE($15, 'pending'), + COALESCE($16, 'DIRECT') +) +RETURNING *; + +-- name: GetSubCourseVideoByID :one +SELECT * +FROM sub_course_videos +WHERE id = $1; + +-- name: GetVideosBySubCourse :many +SELECT + COUNT(*) OVER () AS total_count, + id, + sub_course_id, + title, + description, + video_url, + duration, + resolution, + is_published, + publish_date, + visibility, + instructor_id, + thumbnail, + display_order, + status, + vimeo_id, + vimeo_embed_url, + vimeo_player_html, + vimeo_status, + video_host_provider +FROM sub_course_videos +WHERE sub_course_id = $1 + AND status != 'ARCHIVED' +ORDER BY display_order ASC, id ASC; + +-- name: GetPublishedVideosBySubCourse :many +SELECT * +FROM sub_course_videos +WHERE sub_course_id = $1 + AND status = 'PUBLISHED' +ORDER BY display_order ASC, publish_date ASC; + +-- name: PublishSubCourseVideo :exec +UPDATE sub_course_videos +SET + is_published = true, + publish_date = CURRENT_TIMESTAMP, + status = 'PUBLISHED' +WHERE id = $1; + +-- name: UpdateSubCourseVideo :exec +UPDATE sub_course_videos +SET + title = COALESCE($1, title), + description = COALESCE($2, description), + video_url = COALESCE($3, video_url), + duration = COALESCE($4, duration), + resolution = COALESCE($5, resolution), + visibility = COALESCE($6, visibility), + thumbnail = COALESCE($7, thumbnail), + display_order = COALESCE($8, display_order), + status = COALESCE($9, status), + vimeo_id = COALESCE($10, vimeo_id), + vimeo_embed_url = COALESCE($11, vimeo_embed_url), + vimeo_player_html = COALESCE($12, vimeo_player_html), + vimeo_status = COALESCE($13, vimeo_status), + video_host_provider = COALESCE($14, video_host_provider) +WHERE id = $15; + +-- name: UpdateVimeoStatus :exec +UPDATE sub_course_videos +SET + vimeo_status = $1 +WHERE id = $2; + +-- name: GetVideosByVimeoID :one +SELECT * FROM sub_course_videos +WHERE vimeo_id = $1; + +-- name: ArchiveSubCourseVideo :exec +UPDATE sub_course_videos +SET status = 'ARCHIVED' +WHERE id = $1; + +-- name: DeleteSubCourseVideo :exec +DELETE FROM sub_course_videos +WHERE id = $1; diff --git a/db/query/sub_courses.sql b/db/query/sub_courses.sql new file mode 100644 index 0000000..0af0348 --- /dev/null +++ b/db/query/sub_courses.sql @@ -0,0 +1,82 @@ +-- name: CreateSubCourse :one +INSERT INTO sub_courses ( + course_id, + title, + description, + thumbnail, + display_order, + level, + is_active +) +VALUES ($1, $2, $3, $4, COALESCE($5, 0), $6, COALESCE($7, true)) +RETURNING *; + +-- name: GetSubCourseByID :one +SELECT * +FROM sub_courses +WHERE id = $1; + +-- name: GetSubCoursesByCourse :many +SELECT + COUNT(*) OVER () AS total_count, + id, + course_id, + title, + description, + thumbnail, + display_order, + level, + is_active +FROM sub_courses +WHERE course_id = $1 +ORDER BY display_order ASC, id ASC; + +-- name: ListSubCoursesByCourse :many +SELECT + id, + course_id, + title, + description, + thumbnail, + display_order, + level, + is_active +FROM sub_courses +WHERE course_id = $1 + AND is_active = TRUE +ORDER BY display_order ASC, id ASC; + +-- name: ListActiveSubCourses :many +SELECT + id, + course_id, + title, + description, + thumbnail, + display_order, + level, + is_active +FROM sub_courses +WHERE is_active = TRUE +ORDER BY display_order ASC; + +-- name: UpdateSubCourse :exec +UPDATE sub_courses +SET + title = COALESCE($1, title), + description = COALESCE($2, description), + thumbnail = COALESCE($3, thumbnail), + display_order = COALESCE($4, display_order), + level = COALESCE($5, level), + is_active = COALESCE($6, is_active) +WHERE id = $7; + +-- name: DeleteSubCourse :one +DELETE FROM sub_courses +WHERE id = $1 +RETURNING *; + +-- name: DeactivateSubCourse :exec +UPDATE sub_courses +SET is_active = FALSE +WHERE id = $1; diff --git a/db/query/subscriptions.sql b/db/query/subscriptions.sql new file mode 100644 index 0000000..2159937 --- /dev/null +++ b/db/query/subscriptions.sql @@ -0,0 +1,161 @@ +-- ===================== +-- Subscription Plans +-- ===================== + +-- name: CreateSubscriptionPlan :one +INSERT INTO subscription_plans ( + name, description, duration_value, duration_unit, price, currency, is_active +) +VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, true)) +RETURNING *; + +-- name: GetSubscriptionPlanByID :one +SELECT * FROM subscription_plans WHERE id = $1; + +-- name: ListSubscriptionPlans :many +SELECT * FROM subscription_plans +WHERE ($1::BOOLEAN IS NULL OR $1 = true AND is_active = true OR $1 = false) +ORDER BY price ASC; + +-- name: ListActiveSubscriptionPlans :many +SELECT * FROM subscription_plans +WHERE is_active = true +ORDER BY price ASC; + +-- name: UpdateSubscriptionPlan :exec +UPDATE subscription_plans +SET + name = COALESCE($1, name), + description = COALESCE($2, description), + duration_value = COALESCE($3, duration_value), + duration_unit = COALESCE($4, duration_unit), + price = COALESCE($5, price), + currency = COALESCE($6, currency), + is_active = COALESCE($7, is_active), + updated_at = CURRENT_TIMESTAMP +WHERE id = $8; + +-- name: DeleteSubscriptionPlan :exec +DELETE FROM subscription_plans WHERE id = $1; + +-- ===================== +-- User Subscriptions +-- ===================== + +-- name: CreateUserSubscription :one +INSERT INTO user_subscriptions ( + user_id, plan_id, starts_at, expires_at, status, payment_reference, payment_method, auto_renew +) +VALUES ($1, $2, COALESCE($3, CURRENT_TIMESTAMP), $4, COALESCE($5, 'ACTIVE'), $6, $7, COALESCE($8, false)) +RETURNING *; + +-- name: GetUserSubscriptionByID :one +SELECT + us.*, + sp.name AS plan_name, + sp.duration_value, + sp.duration_unit, + sp.price, + sp.currency +FROM user_subscriptions us +JOIN subscription_plans sp ON sp.id = us.plan_id +WHERE us.id = $1; + +-- name: GetActiveSubscriptionByUserID :one +SELECT + us.*, + sp.name AS plan_name, + sp.duration_value, + sp.duration_unit, + sp.price, + sp.currency +FROM user_subscriptions us +JOIN subscription_plans sp ON sp.id = us.plan_id +WHERE us.user_id = $1 + AND us.status = 'ACTIVE' + AND us.expires_at > CURRENT_TIMESTAMP +ORDER BY us.expires_at DESC +LIMIT 1; + +-- name: GetUserSubscriptionHistory :many +SELECT + us.*, + sp.name AS plan_name, + sp.duration_value, + sp.duration_unit, + sp.price, + sp.currency +FROM user_subscriptions us +JOIN subscription_plans sp ON sp.id = us.plan_id +WHERE us.user_id = $1 +ORDER BY us.created_at DESC +LIMIT sqlc.narg('limit')::INT +OFFSET sqlc.narg('offset')::INT; + +-- name: CountUserSubscriptions :one +SELECT COUNT(*) FROM user_subscriptions WHERE user_id = $1; + +-- name: UpdateUserSubscriptionStatus :exec +UPDATE user_subscriptions +SET + status = $1, + updated_at = CURRENT_TIMESTAMP +WHERE id = $2; + +-- name: CancelUserSubscription :exec +UPDATE user_subscriptions +SET + status = 'CANCELLED', + cancelled_at = CURRENT_TIMESTAMP, + auto_renew = false, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1; + +-- name: ExpireUserSubscription :exec +UPDATE user_subscriptions +SET + status = 'EXPIRED', + updated_at = CURRENT_TIMESTAMP +WHERE id = $1; + +-- name: UpdateAutoRenew :exec +UPDATE user_subscriptions +SET + auto_renew = $1, + updated_at = CURRENT_TIMESTAMP +WHERE id = $2; + +-- name: GetExpiredSubscriptions :many +SELECT us.*, sp.name AS plan_name +FROM user_subscriptions us +JOIN subscription_plans sp ON sp.id = us.plan_id +WHERE us.status = 'ACTIVE' + AND us.expires_at <= CURRENT_TIMESTAMP; + +-- name: GetExpiringSubscriptions :many +SELECT + us.*, + sp.name AS plan_name, + u.email, + u.first_name +FROM user_subscriptions us +JOIN subscription_plans sp ON sp.id = us.plan_id +JOIN users u ON u.id = us.user_id +WHERE us.status = 'ACTIVE' + AND us.expires_at > CURRENT_TIMESTAMP + AND us.expires_at <= CURRENT_TIMESTAMP + INTERVAL '7 days'; + +-- name: HasActiveSubscription :one +SELECT EXISTS( + SELECT 1 FROM user_subscriptions + WHERE user_id = $1 + AND status = 'ACTIVE' + AND expires_at > CURRENT_TIMESTAMP +) AS has_subscription; + +-- name: ExtendSubscription :exec +UPDATE user_subscriptions +SET + expires_at = $1, + updated_at = CURRENT_TIMESTAMP +WHERE id = $2; diff --git a/db/query/team.sql b/db/query/team.sql new file mode 100644 index 0000000..23bde2f --- /dev/null +++ b/db/query/team.sql @@ -0,0 +1,200 @@ +-- name: CreateTeamMember :one +INSERT INTO team_members ( + first_name, + last_name, + email, + phone_number, + password, + team_role, + department, + job_title, + employment_type, + hire_date, + profile_picture_url, + bio, + work_phone, + emergency_contact, + status, + email_verified, + permissions, + created_by, + updated_at +) +VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11, $12, $13, $14, $15, $16, $17, $18, CURRENT_TIMESTAMP +) +RETURNING *; + +-- name: GetTeamMemberByID :one +SELECT * FROM team_members +WHERE id = $1; + +-- name: GetTeamMemberByEmail :one +SELECT * FROM team_members +WHERE email = $1 +LIMIT 1; + +-- name: GetAllTeamMembers :many +SELECT + COUNT(*) OVER () AS total_count, + id, + first_name, + last_name, + email, + phone_number, + team_role, + department, + job_title, + employment_type, + hire_date, + profile_picture_url, + bio, + work_phone, + status, + email_verified, + permissions, + last_login, + created_at, + updated_at +FROM team_members +WHERE (team_role = sqlc.narg('team_role') OR sqlc.narg('team_role') IS NULL) + AND (department = sqlc.narg('department') OR sqlc.narg('department') IS NULL) + AND (status = sqlc.narg('status') OR sqlc.narg('status') IS NULL) +ORDER BY created_at DESC +LIMIT sqlc.narg('limit')::INT +OFFSET sqlc.narg('offset')::INT; + +-- name: SearchTeamMembers :many +SELECT + id, + first_name, + last_name, + email, + phone_number, + team_role, + department, + job_title, + employment_type, + hire_date, + profile_picture_url, + bio, + status, + email_verified, + permissions, + last_login, + created_at, + updated_at +FROM team_members +WHERE ( + first_name ILIKE '%' || $1 || '%' + OR last_name ILIKE '%' || $1 || '%' + OR email ILIKE '%' || $1 || '%' + OR phone_number ILIKE '%' || $1 || '%' + ) + AND (team_role = sqlc.narg('team_role') OR sqlc.narg('team_role') IS NULL) + AND (status = sqlc.narg('status') OR sqlc.narg('status') IS NULL); + +-- name: UpdateTeamMember :exec +UPDATE team_members +SET + first_name = COALESCE($1, first_name), + last_name = COALESCE($2, last_name), + phone_number = COALESCE($3, phone_number), + team_role = COALESCE($4, team_role), + department = COALESCE($5, department), + job_title = COALESCE($6, job_title), + employment_type = COALESCE($7, employment_type), + hire_date = COALESCE($8, hire_date), + profile_picture_url = COALESCE($9, profile_picture_url), + bio = COALESCE($10, bio), + work_phone = COALESCE($11, work_phone), + emergency_contact = COALESCE($12, emergency_contact), + permissions = COALESCE($13, permissions), + updated_by = $14, + updated_at = CURRENT_TIMESTAMP +WHERE id = $15; + +-- name: UpdateTeamMemberStatus :exec +UPDATE team_members +SET + status = $1, + updated_by = $2, + updated_at = CURRENT_TIMESTAMP +WHERE id = $3; + +-- name: UpdateTeamMemberPassword :exec +UPDATE team_members +SET + password = $1, + updated_at = CURRENT_TIMESTAMP +WHERE id = $2; + +-- name: UpdateTeamMemberLastLogin :exec +UPDATE team_members +SET + last_login = CURRENT_TIMESTAMP +WHERE id = $1; + +-- name: DeleteTeamMember :exec +DELETE FROM team_members +WHERE id = $1; + +-- name: CheckTeamMemberEmailExists :one +SELECT EXISTS ( + SELECT 1 FROM team_members WHERE email = $1 +) AS email_exists; + +-- name: GetTeamMembersByDepartment :many +SELECT + id, + first_name, + last_name, + email, + phone_number, + team_role, + department, + job_title, + employment_type, + profile_picture_url, + status, + created_at +FROM team_members +WHERE department = $1 + AND status = 'active' +ORDER BY first_name, last_name; + +-- name: GetTeamMembersByRole :many +SELECT + id, + first_name, + last_name, + email, + phone_number, + team_role, + department, + job_title, + employment_type, + profile_picture_url, + status, + created_at +FROM team_members +WHERE team_role = $1 + AND status = 'active' +ORDER BY first_name, last_name; + +-- name: CountTeamMembersByStatus :one +SELECT + COUNT(*) FILTER (WHERE status = 'active') AS active_count, + COUNT(*) FILTER (WHERE status = 'inactive') AS inactive_count, + COUNT(*) FILTER (WHERE status = 'suspended') AS suspended_count, + COUNT(*) FILTER (WHERE status = 'terminated') AS terminated_count, + COUNT(*) AS total_count +FROM team_members; + +-- name: UpdateTeamMemberEmailVerified :exec +UPDATE team_members +SET + email_verified = $1, + updated_at = CURRENT_TIMESTAMP +WHERE id = $2; diff --git a/db/query/user.sql b/db/query/user.sql index 60bf10a..91f93ae 100644 --- a/db/query/user.sql +++ b/db/query/user.sql @@ -8,11 +8,10 @@ INSERT INTO users ( role, status, email_verified, - profile_picture_url, - profile_completed + profile_picture_url ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, true, $8, false + $1, $2, $3, $4, $5, $6, $7, true, $8 ) RETURNING *; @@ -32,9 +31,10 @@ WHERE id = $1 LIMIT 1; --- name: IsProfileCompleted :one +-- name: GetProfileCompletionStatus :one SELECT - CASE WHEN profile_completed = true THEN true ELSE false END AS is_pending + profile_completed, + profile_completion_percentage FROM users WHERE id = $1 LIMIT 1; @@ -240,6 +240,7 @@ WHERE ( -- name: UpdateUser :exec +-- Note: profile_completed and profile_completion_percentage are computed by database trigger UPDATE users SET first_name = COALESCE($1, first_name), @@ -256,13 +257,12 @@ SET language_challange = COALESCE($12, language_challange), favourite_topic = COALESCE($13, favourite_topic), initial_assessment_completed = COALESCE($14, initial_assessment_completed), - profile_completed = COALESCE($15, profile_completed), - profile_picture_url = COALESCE($16, profile_picture_url), - preferred_language = COALESCE($17, preferred_language), - gender = COALESCE($18, gender), - birth_day = COALESCE($19, birth_day), + profile_picture_url = COALESCE($15, profile_picture_url), + preferred_language = COALESCE($16, preferred_language), + gender = COALESCE($17, gender), + birth_day = COALESCE($18, birth_day), updated_at = CURRENT_TIMESTAMP -WHERE id = $20; +WHERE id = $19; -- name: DeleteUser :exec DELETE FROM users diff --git a/docs/ARIFPAY_INTEGRATION.md b/docs/ARIFPAY_INTEGRATION.md new file mode 100644 index 0000000..3e538ab --- /dev/null +++ b/docs/ARIFPAY_INTEGRATION.md @@ -0,0 +1,346 @@ +# ArifPay Payment Gateway Integration + +This document describes the ArifPay payment gateway integration for subscription payments in the Yimaru LMS application. + +## Overview + +The integration **coordinates payment with subscriptions** - users cannot create subscriptions without completing payment. Only admins can bypass this restriction for special cases (e.g., promotional subscriptions). + +### Key Features: +- **Payment-first approach**: Subscriptions are only created after successful payment +- **Multiple payment flows**: Checkout redirect or direct OTP-based payment +- **Webhook handling**: Automatic subscription creation on payment success +- **Role-based access**: Regular users must pay; admins can grant free subscriptions + +The integration supports multiple Ethiopian payment methods including: +- Telebirr +- CBE (Commercial Bank of Ethiopia) +- Awash Bank +- Amole +- HelloCash +- M-Pesa +- And more + +## Environment Variables + +Add the following environment variables to your `.env` file: + +```env +# ArifPay Configuration +ARIFPAY_API_KEY=your_arifpay_api_key +ARIFPAY_BASE_URL=https://gateway.arifpay.net +ARIFPAY_CANCEL_URL=https://yourdomain.com/payment/cancelled +ARIFPAY_SUCCESS_URL=https://yourdomain.com/payment/success +ARIFPAY_ERROR_URL=https://yourdomain.com/payment/error +ARIFPAY_C2B_NOTIFY_URL=https://yourdomain.com/api/v1/payments/webhook +ARIFPAY_B2C_NOTIFY_URL=https://yourdomain.com/api/v1/payments/b2c-webhook +ARIFPAY_BANK=AWINETAA +ARIFPAY_BENEFICIARY_ACCOUNT_NUMBER=your_account_number +ARIFPAY_DESCRIPTION=Yimaru LMS Subscription +ARIFPAY_ITEM_NAME=Subscription +``` + +## Database Migration + +Run the migration to create the payments table: + +```bash +migrate -path db/migrations -database "postgres://..." up +``` + +Or manually run: +```sql +-- See db/migrations/000009_payments.up.sql +``` + +## API Endpoints + +### Subscription Endpoints + +#### Subscribe with Payment (Recommended) + +**POST** `/api/v1/subscriptions/checkout` + +The primary endpoint for users to subscribe. Initiates payment and returns checkout URL. + +**Request Body:** +```json +{ + "plan_id": 1, + "phone": "0912345678", + "email": "user@example.com" +} +``` + +**Response:** +```json +{ + "message": "Payment initiated. Complete payment to activate subscription.", + "data": { + "payment_id": 123, + "session_id": "ABC123DEF456", + "payment_url": "https://checkout.arifpay.net/...", + "amount": 299.99, + "currency": "ETB", + "expires_at": "2024-01-15T18:30:00Z" + } +} +``` + +#### Direct Subscribe (Admin Only) + +**POST** `/api/v1/subscriptions` + +Creates subscription without payment. Only accessible by admin/super_admin roles. + +--- + +### Payment Endpoints + +#### Initiate Subscription Payment + +**POST** `/api/v1/payments/subscribe` + +Creates a payment session for a subscription plan. + +**Request Body:** +```json +{ + "plan_id": 1, + "phone": "0912345678", + "email": "user@example.com" +} +``` + +**Response:** +```json +{ + "message": "Payment initiated successfully", + "data": { + "payment_id": 123, + "session_id": "ABC123DEF456", + "payment_url": "https://checkout.arifpay.net/...", + "amount": 299.99, + "currency": "ETB", + "expires_at": "2024-01-15T18:30:00Z" + } +} +``` + +### Verify Payment Status + +**GET** `/api/v1/payments/verify/:session_id` + +Checks the payment status with ArifPay and updates local records. + +**Response:** +```json +{ + "message": "Payment status retrieved", + "data": { + "id": 123, + "status": "SUCCESS", + "subscription_id": 456, + ... + } +} +``` + +### Get Payment History + +**GET** `/api/v1/payments` + +Returns the authenticated user's payment history. + +**Query Parameters:** +- `limit` (default: 20) +- `offset` (default: 0) + +### Get Payment Details + +**GET** `/api/v1/payments/:id` + +Returns details of a specific payment. + +### Cancel Payment + +**POST** `/api/v1/payments/:id/cancel` + +Cancels a pending payment. + +### Payment Webhook + +**POST** `/api/v1/payments/webhook` + +Webhook endpoint called by ArifPay when payment status changes. + +**Note:** This endpoint does not require authentication as it's called by ArifPay servers. + +### Get Available Payment Methods + +**GET** `/api/v1/payments/methods` + +Returns list of supported payment methods. + +--- + +## Direct Payment Endpoints (OTP-based) + +Direct payments allow users to pay without being redirected to a payment page. Instead, the payment is processed via OTP verification. + +### Initiate Direct Payment + +**POST** `/api/v1/payments/direct` + +Initiates a direct payment with a specific payment method. + +**Request Body:** +```json +{ + "plan_id": 1, + "phone": "0912345678", + "email": "user@example.com", + "payment_method": "AMOLE" +} +``` + +**Supported Payment Methods:** +- `TELEBIRR` - Telebirr (push notification) +- `TELEBIRR_USSD` - Telebirr USSD +- `CBE` - Commercial Bank of Ethiopia +- `AMOLE` - Amole (requires OTP) +- `HELLOCASH` - HelloCash (requires OTP) +- `AWASH` - Awash Bank (requires OTP) +- `MPESA` - M-Pesa + +**Response:** +```json +{ + "message": "OTP sent to your phone. Please verify to complete payment.", + "data": { + "payment_id": 123, + "session_id": "ABC123DEF456", + "requires_otp": true, + "amount": 299.99, + "currency": "ETB" + } +} +``` + +### Verify OTP + +**POST** `/api/v1/payments/direct/verify-otp` + +Verifies the OTP for direct payment methods (Amole, HelloCash, Awash). + +**Request Body:** +```json +{ + "session_id": "ABC123DEF456", + "otp": "123456" +} +``` + +**Response (Success):** +```json +{ + "message": "Payment completed successfully", + "data": { + "success": true, + "transaction_id": "TXN123456", + "payment_id": 123 + } +} +``` + +**Response (Failed):** +```json +{ + "message": "Invalid OTP" +} +``` + +### Get Direct Payment Methods + +**GET** `/api/v1/payments/direct/methods` + +Returns list of payment methods that support direct payment. + +--- + +## Payment Flows + +### Flow 1: Checkout Session (Redirect-based) + +1. **User selects a subscription plan** and initiates payment via `/payments/subscribe` +2. **Backend creates a payment record** with status `PENDING` +3. **Backend calls ArifPay** to create a checkout session +4. **User is redirected** to ArifPay payment page (using `payment_url`) +5. **User completes payment** on ArifPay +6. **ArifPay sends webhook** to notify payment status +7. **Backend processes webhook:** + - Updates payment status + - If successful, creates subscription + - Links payment to subscription +8. **User can verify** payment status via `/payments/verify/:session_id` + +### Flow 2: Direct Payment (OTP-based) + +1. **User selects plan and payment method** via `/payments/direct` +2. **Backend creates payment record** and checkout session +3. **Backend initiates direct transfer** with selected payment method +4. **For OTP-required methods (Amole, HelloCash, Awash):** + - User receives OTP via SMS + - User submits OTP via `/payments/direct/verify-otp` + - Backend verifies OTP with ArifPay + - On success, creates subscription +5. **For push-based methods (Telebirr, CBE):** + - User receives push notification on their app + - User approves payment in their app + - ArifPay sends webhook notification + - Backend creates subscription + +## Statuses + +### Payment Statuses +- `PENDING` - Payment initiated, waiting for user action +- `PROCESSING` - Payment is being processed +- `SUCCESS` - Payment completed successfully +- `FAILED` - Payment failed +- `CANCELLED` - Payment cancelled by user +- `EXPIRED` - Payment session expired + +### Subscription Statuses +- `PENDING` - Subscription pending payment +- `ACTIVE` - Subscription is active +- `EXPIRED` - Subscription has expired +- `CANCELLED` - Subscription was cancelled + +## Error Handling + +The integration handles various error scenarios: +- User already has an active subscription +- Plan not found or inactive +- Payment verification failures +- Webhook processing errors + +## Security Considerations + +1. Webhook endpoint validates requests from ArifPay +2. Payment verification double-checks with ArifPay API +3. User can only access their own payment records +4. Sensitive data (API keys) stored in environment variables + +## Testing + +For sandbox testing, use: +- Base URL: `https://gateway.arifpay.net` (sandbox mode enabled via API key) +- Test phone numbers provided by ArifPay +- Sandbox credentials from ArifPay developer portal + +## Support + +For ArifPay-specific issues: +- Developer Portal: https://developer.arifpay.net +- Telegram: https://t.me/arifochet +- Support: info@arifpay.com diff --git a/docs/docs.go b/docs/docs.go index 085447b..27f244e 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -231,7 +231,7 @@ const docTemplate = `{ }, "/api/v1/assessment/questions": { "get": { - "description": "Returns all active assessment questions with their options or answers", + "description": "Returns all active assessment questions from the initial assessment set", "produces": [ "application/json" ], @@ -258,7 +258,7 @@ const docTemplate = `{ } }, "post": { - "description": "Creates a new assessment question with options or short answer depending on question type", + "description": "Creates a new assessment question using the unified questions system", "consumes": [ "application/json" ], @@ -276,7 +276,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/domain.CreateAssessmentQuestionInput" + "$ref": "#/definitions/handlers.createQuestionReq" } } ], @@ -821,16 +821,16 @@ const docTemplate = `{ } } }, - "/api/v1/course-management/courses/{courseId}/programs": { + "/api/v1/course-management/courses/{courseId}/sub-courses": { "get": { - "description": "Returns all programs under a specific course with total count", + "description": "Returns all sub-courses under a specific course", "produces": [ "application/json" ], "tags": [ - "programs" + "sub-courses" ], - "summary": "Get programs by course", + "summary": "Get sub-courses by course", "parameters": [ { "type": "integer", @@ -862,16 +862,16 @@ const docTemplate = `{ } } }, - "/api/v1/course-management/courses/{courseId}/programs/list": { + "/api/v1/course-management/courses/{courseId}/sub-courses/list": { "get": { - "description": "Returns a simple list of programs under a specific course", + "description": "Returns a list of active sub-courses under a specific course", "produces": [ "application/json" ], "tags": [ - "programs" + "sub-courses" ], - "summary": "List programs by course", + "summary": "List active sub-courses by course", "parameters": [ { "type": "integer", @@ -1042,7 +1042,7 @@ const docTemplate = `{ }, "/api/v1/course-management/learning-tree": { "get": { - "description": "Returns the complete learning tree structure with courses, programs, levels, and modules", + "description": "Returns the complete learning tree structure with courses and sub-courses", "produces": [ "application/json" ], @@ -1066,9 +1066,9 @@ const docTemplate = `{ } } }, - "/api/v1/course-management/levels": { + "/api/v1/course-management/sub-courses": { "post": { - "description": "Creates a new level under a specific program", + "description": "Creates a new sub-course under a specific course", "consumes": [ "application/json" ], @@ -1076,17 +1076,17 @@ const docTemplate = `{ "application/json" ], "tags": [ - "levels" + "sub-courses" ], - "summary": "Create a new level", + "summary": "Create a new sub-course", "parameters": [ { - "description": "Create level payload", + "description": "Create sub-course payload", "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.createLevelReq" + "$ref": "#/definitions/handlers.createSubCourseReq" } } ], @@ -1112,242 +1112,16 @@ const docTemplate = `{ } } }, - "/api/v1/course-management/levels/{id}": { - "put": { - "description": "Updates a level's title, description, index, and/or active status", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "levels" - ], - "summary": "Update level", - "parameters": [ - { - "type": "integer", - "description": "Level ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Update level payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.updateLevelReq" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/levels/{levelId}": { - "delete": { - "description": "Deletes a level by its ID", - "produces": [ - "application/json" - ], - "tags": [ - "levels" - ], - "summary": "Delete level", - "parameters": [ - { - "type": "integer", - "description": "Level ID", - "name": "levelId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/levels/{levelId}/increment-module": { - "put": { - "description": "Increments the module count for a specific level", - "produces": [ - "application/json" - ], - "tags": [ - "levels" - ], - "summary": "Increment level module count", - "parameters": [ - { - "type": "integer", - "description": "Level ID", - "name": "levelId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/levels/{levelId}/increment-practice": { - "put": { - "description": "Increments the practice count for a specific level", - "produces": [ - "application/json" - ], - "tags": [ - "levels" - ], - "summary": "Increment level practice count", - "parameters": [ - { - "type": "integer", - "description": "Level ID", - "name": "levelId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/levels/{levelId}/increment-video": { - "put": { - "description": "Increments the video count for a specific level", - "produces": [ - "application/json" - ], - "tags": [ - "levels" - ], - "summary": "Increment level video count", - "parameters": [ - { - "type": "integer", - "description": "Level ID", - "name": "levelId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/levels/{levelId}/modules": { + "/api/v1/course-management/sub-courses/active": { "get": { - "description": "Returns a paginated list of modules under a specific level", + "description": "Returns a list of all active sub-courses", "produces": [ "application/json" ], "tags": [ - "modules" - ], - "summary": "Get modules by level", - "parameters": [ - { - "type": "integer", - "description": "Level ID", - "name": "levelId", - "in": "path", - "required": true - } + "sub-courses" ], + "summary": "List all active sub-courses", "responses": { "200": { "description": "OK", @@ -1355,12 +1129,6 @@ const docTemplate = `{ "$ref": "#/definitions/domain.Response" } }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, "500": { "description": "Internal Server Error", "schema": { @@ -1370,498 +1138,20 @@ const docTemplate = `{ } } }, - "/api/v1/course-management/modules": { - "post": { - "description": "Creates a new module under a specific level", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "modules" - ], - "summary": "Create a new module", - "parameters": [ - { - "description": "Create module payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.createModuleReq" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/modules/{id}": { - "put": { - "description": "Updates a module's title, content, display order, and/or active status", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "modules" - ], - "summary": "Update module", - "parameters": [ - { - "type": "integer", - "description": "Module ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Update module payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.updateModuleReq" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - }, - "delete": { - "description": "Deletes a module by its ID", - "produces": [ - "application/json" - ], - "tags": [ - "modules" - ], - "summary": "Delete module", - "parameters": [ - { - "type": "integer", - "description": "Module ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/modules/{moduleId}/videos/published": { + "/api/v1/course-management/sub-courses/{id}": { "get": { - "description": "Returns all published videos under a specific module", + "description": "Returns a single sub-course by its ID", "produces": [ "application/json" ], "tags": [ - "module-videos" + "sub-courses" ], - "summary": "Get published videos by module", + "summary": "Get sub-course by ID", "parameters": [ { "type": "integer", - "description": "Module ID", - "name": "moduleId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/owners/{ownerType}/{ownerId}/practices": { - "get": { - "description": "Returns all practices for a specific owner type and ID", - "produces": [ - "application/json" - ], - "tags": [ - "practices" - ], - "summary": "Get practices by owner", - "parameters": [ - { - "type": "string", - "description": "Owner Type", - "name": "ownerType", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "Owner ID", - "name": "ownerId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/practices": { - "post": { - "description": "Creates a new practice for a specific owner (module or level)", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "practices" - ], - "summary": "Create a new practice", - "parameters": [ - { - "description": "Create practice payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.createPracticeReq" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/practices/{id}": { - "put": { - "description": "Updates a practice's fields", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "practices" - ], - "summary": "Update practice", - "parameters": [ - { - "type": "integer", - "description": "Practice ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Update practice payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.updatePracticeReq" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - }, - "delete": { - "description": "Deletes a practice by its ID", - "produces": [ - "application/json" - ], - "tags": [ - "practices" - ], - "summary": "Delete practice", - "parameters": [ - { - "type": "integer", - "description": "Practice ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/practices/{practiceId}/questions": { - "get": { - "description": "Returns all questions under a specific practice", - "produces": [ - "application/json" - ], - "tags": [ - "practice-questions" - ], - "summary": "Get questions by practice", - "parameters": [ - { - "type": "integer", - "description": "Practice ID", - "name": "practiceId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/programs": { - "post": { - "description": "Creates a new program under a specific course", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "programs" - ], - "summary": "Create a new program", - "parameters": [ - { - "description": "Create program payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.createProgramReq" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/programs/active": { - "get": { - "description": "Returns all active programs across all courses", - "produces": [ - "application/json" - ], - "tags": [ - "programs" - ], - "summary": "List active programs", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/programs/{id}": { - "get": { - "description": "Returns a single program by its ID", - "produces": [ - "application/json" - ], - "tags": [ - "programs" - ], - "summary": "Get program by ID", - "parameters": [ - { - "type": "integer", - "description": "Program ID", + "description": "Sub-course ID", "name": "id", "in": "path", "required": true @@ -1895,18 +1185,18 @@ const docTemplate = `{ } }, "delete": { - "description": "Deletes a program by its ID", + "description": "Deletes a sub-course by its ID", "produces": [ "application/json" ], "tags": [ - "programs" + "sub-courses" ], - "summary": "Delete program", + "summary": "Delete sub-course", "parameters": [ { "type": "integer", - "description": "Program ID", + "description": "Sub-course ID", "name": "id", "in": "path", "required": true @@ -1934,7 +1224,7 @@ const docTemplate = `{ } }, "patch": { - "description": "Updates selected fields of a program", + "description": "Updates a sub-course's fields", "consumes": [ "application/json" ], @@ -1942,24 +1232,24 @@ const docTemplate = `{ "application/json" ], "tags": [ - "programs" + "sub-courses" ], - "summary": "Update program partially", + "summary": "Update sub-course", "parameters": [ { "type": "integer", - "description": "Program ID", + "description": "Sub-course ID", "name": "id", "in": "path", "required": true }, { - "description": "Update program payload", + "description": "Update sub-course payload", "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.updateProgramPartialReq" + "$ref": "#/definitions/handlers.updateSubCourseReq" } } ], @@ -1985,20 +1275,20 @@ const docTemplate = `{ } } }, - "/api/v1/course-management/programs/{id}/deactivate": { + "/api/v1/course-management/sub-courses/{id}/deactivate": { "put": { - "description": "Deactivates a program by setting is_active to false", + "description": "Deactivates a sub-course by its ID", "produces": [ "application/json" ], "tags": [ - "programs" + "sub-courses" ], - "summary": "Deactivate program", + "summary": "Deactivate sub-course", "parameters": [ { "type": "integer", - "description": "Program ID", + "description": "Sub-course ID", "name": "id", "in": "path", "required": true @@ -2026,74 +1316,21 @@ const docTemplate = `{ } } }, - "/api/v1/course-management/programs/{id}/full": { - "put": { - "description": "Updates all fields of a program", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "programs" - ], - "summary": "Update program fully", - "parameters": [ - { - "type": "integer", - "description": "Program ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Update program payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.updateProgramFullReq" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/programs/{programId}/levels": { + "/api/v1/course-management/sub-courses/{subCourseId}/videos": { "get": { - "description": "Returns all levels under a specific program", + "description": "Returns all videos under a specific sub-course", "produces": [ "application/json" ], "tags": [ - "levels" + "sub-course-videos" ], - "summary": "Get levels by program", + "summary": "Get videos by sub-course", "parameters": [ { "type": "integer", - "description": "Program ID", - "name": "programId", + "description": "Sub-course ID", + "name": "subCourseId", "in": "path", "required": true } @@ -2120,118 +1357,21 @@ const docTemplate = `{ } } }, - "/api/v1/course-management/questions": { - "post": { - "description": "Creates a new question under a specific practice", - "consumes": [ - "application/json" - ], + "/api/v1/course-management/sub-courses/{subCourseId}/videos/published": { + "get": { + "description": "Returns all published videos under a specific sub-course", "produces": [ "application/json" ], "tags": [ - "practice-questions" + "sub-course-videos" ], - "summary": "Create a new practice question", - "parameters": [ - { - "description": "Create question payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.createPracticeQuestionReq" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/questions/{id}": { - "put": { - "description": "Updates a practice question's fields", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "practice-questions" - ], - "summary": "Update practice question", + "summary": "Get published videos by sub-course", "parameters": [ { "type": "integer", - "description": "Question ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Update question payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.updatePracticeQuestionReq" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - }, - "delete": { - "description": "Deletes a practice question by its ID", - "produces": [ - "application/json" - ], - "tags": [ - "practice-questions" - ], - "summary": "Delete practice question", - "parameters": [ - { - "type": "integer", - "description": "Question ID", - "name": "id", + "description": "Sub-course ID", + "name": "subCourseId", "in": "path", "required": true } @@ -2260,7 +1400,7 @@ const docTemplate = `{ }, "/api/v1/course-management/videos": { "post": { - "description": "Creates a new video under a specific module", + "description": "Creates a new video under a specific sub-course", "consumes": [ "application/json" ], @@ -2268,9 +1408,9 @@ const docTemplate = `{ "application/json" ], "tags": [ - "module-videos" + "sub-course-videos" ], - "summary": "Create a new module video", + "summary": "Create a new sub-course video", "parameters": [ { "description": "Create video payload", @@ -2278,7 +1418,99 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.createModuleVideoReq" + "$ref": "#/definitions/handlers.createSubCourseVideoReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/course-management/videos/vimeo": { + "post": { + "description": "Creates a video by uploading to Vimeo from a source URL", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "sub-course-videos" + ], + "summary": "Create a new sub-course video with Vimeo upload", + "parameters": [ + { + "description": "Create Vimeo video payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createVimeoVideoReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/course-management/videos/vimeo/import": { + "post": { + "description": "Creates a video record from an existing Vimeo video ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "sub-course-videos" + ], + "summary": "Create a sub-course video from existing Vimeo video", + "parameters": [ + { + "description": "Create from Vimeo ID payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createVideoFromVimeoIDReq" } } ], @@ -2305,8 +1537,47 @@ const docTemplate = `{ } }, "/api/v1/course-management/videos/{id}": { + "get": { + "description": "Returns a single video by its ID", + "produces": [ + "application/json" + ], + "tags": [ + "sub-course-videos" + ], + "summary": "Get sub-course video by ID", + "parameters": [ + { + "type": "integer", + "description": "Video ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, "put": { - "description": "Updates a module video's fields", + "description": "Updates a video's fields", "consumes": [ "application/json" ], @@ -2314,9 +1585,9 @@ const docTemplate = `{ "application/json" ], "tags": [ - "module-videos" + "sub-course-videos" ], - "summary": "Update module video", + "summary": "Update sub-course video", "parameters": [ { "type": "integer", @@ -2331,7 +1602,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.updateModuleVideoReq" + "$ref": "#/definitions/handlers.updateSubCourseVideoReq" } } ], @@ -2357,14 +1628,14 @@ const docTemplate = `{ } }, "delete": { - "description": "Deletes a module video by its ID", + "description": "Archives a video by its ID (soft delete)", "produces": [ "application/json" ], "tags": [ - "module-videos" + "sub-course-videos" ], - "summary": "Delete module video", + "summary": "Delete sub-course video", "parameters": [ { "type": "integer", @@ -2396,21 +1667,21 @@ const docTemplate = `{ } } }, - "/api/v1/course-management/videos/{videoId}/publish": { + "/api/v1/course-management/videos/{id}/publish": { "put": { - "description": "Publishes a module video by setting publish date", + "description": "Publishes a video by its ID", "produces": [ "application/json" ], "tags": [ - "module-videos" + "sub-course-videos" ], - "summary": "Publish module video", + "summary": "Publish sub-course video", "parameters": [ { "type": "integer", "description": "Video ID", - "name": "videoId", + "name": "id", "in": "path", "required": true } @@ -2497,6 +1768,1345 @@ const docTemplate = `{ } } }, + "/api/v1/notifications/test-push": { + "post": { + "description": "Sends a test push notification to all registered devices of the current user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notifications" + ], + "summary": "Send test push notification", + "parameters": [ + { + "description": "Test notification content", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "title": { + "type": "string" + } + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/payments": { + "get": { + "description": "Returns the authenticated user's payment history", + "produces": [ + "application/json" + ], + "tags": [ + "payments" + ], + "summary": "Get payment history", + "parameters": [ + { + "type": "integer", + "default": 20, + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, + "/api/v1/payments/direct": { + "post": { + "description": "Creates a payment session and initiates direct payment (OTP-based)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "payments" + ], + "summary": "Initiate direct payment", + "parameters": [ + { + "description": "Direct payment request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.initiateDirectPaymentReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/payments/direct/methods": { + "get": { + "description": "Returns list of payment methods that support direct payment (OTP-based)", + "produces": [ + "application/json" + ], + "tags": [ + "payments" + ], + "summary": "Get direct payment methods", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, + "/api/v1/payments/direct/verify-otp": { + "post": { + "description": "Verifies the OTP sent for direct payment methods (Amole, HelloCash, etc.)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "payments" + ], + "summary": "Verify OTP for direct payment", + "parameters": [ + { + "description": "OTP verification request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.verifyOTPReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/payments/methods": { + "get": { + "description": "Returns list of supported ArifPay payment methods", + "produces": [ + "application/json" + ], + "tags": [ + "payments" + ], + "summary": "Get available payment methods", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, + "/api/v1/payments/subscribe": { + "post": { + "description": "Creates a payment session for a subscription plan", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "payments" + ], + "summary": "Initiate subscription payment", + "parameters": [ + { + "description": "Payment request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.initiatePaymentReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/payments/verify/{session_id}": { + "get": { + "description": "Checks the payment status with the payment provider", + "produces": [ + "application/json" + ], + "tags": [ + "payments" + ], + "summary": "Verify payment status", + "parameters": [ + { + "type": "string", + "description": "Session ID", + "name": "session_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/payments/webhook": { + "post": { + "description": "Processes payment notifications from ArifPay", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "payments" + ], + "summary": "Handle ArifPay webhook", + "parameters": [ + { + "description": "Webhook payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.WebhookRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/payments/{id}": { + "get": { + "description": "Returns details of a specific payment", + "produces": [ + "application/json" + ], + "tags": [ + "payments" + ], + "summary": "Get payment details", + "parameters": [ + { + "type": "integer", + "description": "Payment ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/payments/{id}/cancel": { + "post": { + "description": "Cancels a payment that is still pending", + "produces": [ + "application/json" + ], + "tags": [ + "payments" + ], + "summary": "Cancel a pending payment", + "parameters": [ + { + "type": "integer", + "description": "Payment ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/question-sets": { + "get": { + "description": "Returns a paginated list of question sets filtered by type", + "produces": [ + "application/json" + ], + "tags": [ + "question-sets" + ], + "summary": "Get question sets by type", + "parameters": [ + { + "type": "string", + "description": "Set type (PRACTICE, INITIAL_ASSESSMENT, QUIZ, EXAM, SURVEY)", + "name": "set_type", + "in": "query", + "required": true + }, + { + "type": "integer", + "default": 10, + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "post": { + "description": "Creates a new question set (practice, assessment, quiz, exam, or survey)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "question-sets" + ], + "summary": "Create a new question set", + "parameters": [ + { + "description": "Create question set payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createQuestionSetReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/question-sets/by-owner": { + "get": { + "description": "Returns question sets for a specific owner (e.g., sub-course)", + "produces": [ + "application/json" + ], + "tags": [ + "question-sets" + ], + "summary": "Get question sets by owner", + "parameters": [ + { + "type": "string", + "description": "Owner type (SUB_COURSE, COURSE, CATEGORY, STANDALONE)", + "name": "owner_type", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "Owner ID", + "name": "owner_id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/question-sets/{id}": { + "get": { + "description": "Returns a question set with question count", + "produces": [ + "application/json" + ], + "tags": [ + "question-sets" + ], + "summary": "Get question set by ID", + "parameters": [ + { + "type": "integer", + "description": "Question Set ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "put": { + "description": "Updates a question set's properties", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "question-sets" + ], + "summary": "Update a question set", + "parameters": [ + { + "type": "integer", + "description": "Question Set ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Update question set payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateQuestionSetReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Archives a question set (soft delete)", + "produces": [ + "application/json" + ], + "tags": [ + "question-sets" + ], + "summary": "Delete a question set", + "parameters": [ + { + "type": "integer", + "description": "Question Set ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/question-sets/{setId}/personas": { + "get": { + "description": "Returns all users assigned as personas to a question set (practice)", + "produces": [ + "application/json" + ], + "tags": [ + "question-sets" + ], + "summary": "Get user personas for a question set", + "parameters": [ + { + "type": "integer", + "description": "Question Set ID", + "name": "setId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "post": { + "description": "Links a user as a persona to a question set (practice)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "question-sets" + ], + "summary": "Add a user as persona to a question set", + "parameters": [ + { + "type": "integer", + "description": "Question Set ID", + "name": "setId", + "in": "path", + "required": true + }, + { + "description": "Add user persona payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.addUserPersonaReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/question-sets/{setId}/personas/{userId}": { + "delete": { + "description": "Unlinks a user as persona from a question set (practice)", + "produces": [ + "application/json" + ], + "tags": [ + "question-sets" + ], + "summary": "Remove a user persona from a question set", + "parameters": [ + { + "type": "integer", + "description": "Question Set ID", + "name": "setId", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "User ID", + "name": "userId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/question-sets/{setId}/questions": { + "get": { + "description": "Returns all questions in a question set with details", + "produces": [ + "application/json" + ], + "tags": [ + "question-set-items" + ], + "summary": "Get questions in set", + "parameters": [ + { + "type": "integer", + "description": "Question Set ID", + "name": "setId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "post": { + "description": "Links a question to a question set", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "question-set-items" + ], + "summary": "Add question to set", + "parameters": [ + { + "type": "integer", + "description": "Question Set ID", + "name": "setId", + "in": "path", + "required": true + }, + { + "description": "Add question to set payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.addQuestionToSetReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/question-sets/{setId}/questions/{questionId}": { + "delete": { + "description": "Unlinks a question from a question set", + "produces": [ + "application/json" + ], + "tags": [ + "question-set-items" + ], + "summary": "Remove question from set", + "parameters": [ + { + "type": "integer", + "description": "Question Set ID", + "name": "setId", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Question ID", + "name": "questionId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/question-sets/{setId}/questions/{questionId}/order": { + "put": { + "description": "Updates the display order of a question in a set", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "question-set-items" + ], + "summary": "Update question order in set", + "parameters": [ + { + "type": "integer", + "description": "Question Set ID", + "name": "setId", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Question ID", + "name": "questionId", + "in": "path", + "required": true + }, + { + "description": "Update order payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateQuestionOrderReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/questions": { + "get": { + "description": "Returns a paginated list of questions with optional filters", + "produces": [ + "application/json" + ], + "tags": [ + "questions" + ], + "summary": "List questions", + "parameters": [ + { + "type": "string", + "description": "Question type filter (MCQ, TRUE_FALSE, SHORT_ANSWER)", + "name": "question_type", + "in": "query" + }, + { + "type": "string", + "description": "Difficulty level filter (EASY, MEDIUM, HARD)", + "name": "difficulty", + "in": "query" + }, + { + "type": "string", + "description": "Status filter (DRAFT, PUBLISHED, INACTIVE)", + "name": "status", + "in": "query" + }, + { + "type": "integer", + "default": 10, + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "post": { + "description": "Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "questions" + ], + "summary": "Create a new question", + "parameters": [ + { + "description": "Create question payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createQuestionReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/questions/search": { + "get": { + "description": "Search questions by text", + "produces": [ + "application/json" + ], + "tags": [ + "questions" + ], + "summary": "Search questions", + "parameters": [ + { + "type": "string", + "description": "Search query", + "name": "q", + "in": "query", + "required": true + }, + { + "type": "integer", + "default": 10, + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/questions/{id}": { + "get": { + "description": "Returns a question with its options/short answers", + "produces": [ + "application/json" + ], + "tags": [ + "questions" + ], + "summary": "Get question by ID", + "parameters": [ + { + "type": "integer", + "description": "Question ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "put": { + "description": "Updates a question and optionally replaces its options/short answers", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "questions" + ], + "summary": "Update a question", + "parameters": [ + { + "type": "integer", + "description": "Question ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Update question payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateQuestionReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Archives a question (soft delete)", + "produces": [ + "application/json" + ], + "tags": [ + "questions" + ], + "summary": "Delete a question", + "parameters": [ + { + "type": "integer", + "description": "Question ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/sendSMS": { "post": { "description": "Sends an SMS message to a single phone number using AfroMessage", @@ -2543,6 +3153,466 @@ const docTemplate = `{ } } }, + "/api/v1/subscription-plans": { + "get": { + "description": "Returns all subscription plans", + "produces": [ + "application/json" + ], + "tags": [ + "subscriptions" + ], + "summary": "List subscription plans", + "parameters": [ + { + "type": "boolean", + "description": "Return only active plans", + "name": "active_only", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "post": { + "description": "Creates a new subscription plan (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "subscriptions" + ], + "summary": "Create a subscription plan", + "parameters": [ + { + "description": "Create plan payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createPlanReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/subscription-plans/{id}": { + "get": { + "description": "Returns a single subscription plan by ID", + "produces": [ + "application/json" + ], + "tags": [ + "subscriptions" + ], + "summary": "Get a subscription plan", + "parameters": [ + { + "type": "integer", + "description": "Plan ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "put": { + "description": "Updates a subscription plan (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "subscriptions" + ], + "summary": "Update a subscription plan", + "parameters": [ + { + "type": "integer", + "description": "Plan ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Update plan payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updatePlanReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Deletes a subscription plan (admin only)", + "produces": [ + "application/json" + ], + "tags": [ + "subscriptions" + ], + "summary": "Delete a subscription plan", + "parameters": [ + { + "type": "integer", + "description": "Plan ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/subscriptions": { + "post": { + "description": "Creates a new subscription for the authenticated user. For regular users, use /payments/subscribe instead.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "subscriptions" + ], + "summary": "Subscribe to a plan (Admin only - bypasses payment)", + "deprecated": true, + "parameters": [ + { + "description": "Subscribe payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.subscribeReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/subscriptions/checkout": { + "post": { + "description": "Initiates payment for a subscription plan. Returns payment URL for checkout.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "subscriptions" + ], + "summary": "Subscribe to a plan with payment", + "parameters": [ + { + "description": "Subscribe with payment payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.subscribeWithPaymentReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "409": { + "description": "User already has active subscription", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/subscriptions/history": { + "get": { + "description": "Returns the authenticated user's subscription history", + "produces": [ + "application/json" + ], + "tags": [ + "subscriptions" + ], + "summary": "Get subscription history", + "parameters": [ + { + "type": "integer", + "default": 20, + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, + "/api/v1/subscriptions/me": { + "get": { + "description": "Returns the authenticated user's active subscription", + "produces": [ + "application/json" + ], + "tags": [ + "subscriptions" + ], + "summary": "Get current subscription", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/subscriptions/status": { + "get": { + "description": "Returns whether the authenticated user has an active subscription", + "produces": [ + "application/json" + ], + "tags": [ + "subscriptions" + ], + "summary": "Check subscription status", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, + "/api/v1/subscriptions/{id}/auto-renew": { + "put": { + "description": "Enables or disables auto-renewal for a subscription", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "subscriptions" + ], + "summary": "Set auto-renew", + "parameters": [ + { + "type": "integer", + "description": "Subscription ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Auto-renew payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.autoRenewReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/subscriptions/{id}/cancel": { + "post": { + "description": "Cancels the user's subscription", + "produces": [ + "application/json" + ], + "tags": [ + "subscriptions" + ], + "summary": "Cancel subscription", + "parameters": [ + { + "type": "integer", + "description": "Subscription ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/super-login": { "post": { "description": "Login super-admin", @@ -2695,6 +3765,725 @@ const docTemplate = `{ } } }, + "/api/v1/team/login": { + "post": { + "description": "Authenticate a team member (internal staff) with email and password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "Login team member", + "parameters": [ + { + "description": "Team member login credentials", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.TeamMemberLoginReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/handlers.teamMemberLoginRes" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/team/me": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Get the authenticated team member's own profile", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "Get my team profile", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/domain.TeamMemberResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/team/members": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Get a paginated list of team members with optional filtering", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "List all team members", + "parameters": [ + { + "type": "string", + "description": "Filter by team role (super_admin, admin, content_manager, support_agent, instructor, finance, hr, analyst)", + "name": "team_role", + "in": "query" + }, + { + "type": "string", + "description": "Filter by department", + "name": "department", + "in": "query" + }, + { + "type": "string", + "description": "Filter by status (active, inactive, suspended, terminated)", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Search by name, email, or phone number", + "name": "search", + "in": "query" + }, + { + "type": "integer", + "description": "Page number (default: 1)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page (default: 10, max: 100)", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.TeamMemberResponse" + } + }, + "metadata": { + "$ref": "#/definitions/domain.Pagination" + } + } + } + ] + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Create a new internal team member (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "Create a new team member", + "parameters": [ + { + "description": "Team member creation payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.CreateTeamMemberReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/domain.TeamMemberResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/team/members/{id}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Retrieve a team member's details by their ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "Get team member by ID", + "parameters": [ + { + "type": "integer", + "description": "Team Member ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/domain.TeamMemberResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Update an existing team member's details (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "Update team member", + "parameters": [ + { + "type": "integer", + "description": "Team Member ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Team member update payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.UpdateTeamMemberReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Delete a team member (super admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "Delete team member", + "parameters": [ + { + "type": "integer", + "description": "Team Member ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/team/members/{id}/change-password": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Change a team member's password (requires current password)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "Change team member password", + "parameters": [ + { + "type": "integer", + "description": "Team Member ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Password change payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.changePasswordReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/team/members/{id}/status": { + "patch": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Update a team member's status (active, inactive, suspended, terminated)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "Update team member status", + "parameters": [ + { + "type": "integer", + "description": "Team Member ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Status update payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.UpdateTeamMemberStatusReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/team/stats": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Get statistics about team members by status", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "Get team member statistics", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/domain.TeamMemberStats" + } + } + } + ] + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/tenant": { "get": { "description": "Check if phone number or email exist", @@ -3186,7 +4975,7 @@ const docTemplate = `{ }, "/api/v1/user/{user_id}/is-profile-completed": { "get": { - "description": "Returns whether the specified user's profile is completed", + "description": "Returns the profile completion status and percentage for the specified user", "consumes": [ "application/json" ], @@ -3307,6 +5096,366 @@ const docTemplate = `{ } } }, + "/api/v1/vimeo/oembed": { + "get": { + "description": "Fetches oEmbed metadata for a Vimeo video URL", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Vimeo" + ], + "summary": "Get oEmbed data for a Vimeo URL", + "parameters": [ + { + "type": "string", + "description": "Vimeo video URL", + "name": "url", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "Desired width", + "name": "width", + "in": "query" + }, + { + "type": "integer", + "description": "Desired height", + "name": "height", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/vimeo.OEmbedResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/vimeo/uploads/pull": { + "post": { + "description": "Initiates a pull upload where Vimeo fetches the video from a URL", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Vimeo" + ], + "summary": "Create a pull upload to Vimeo", + "parameters": [ + { + "description": "Pull Upload Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.CreatePullUploadRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handlers.VimeoUploadResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/vimeo/uploads/tus": { + "post": { + "description": "Initiates a TUS resumable upload and returns the upload link", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Vimeo" + ], + "summary": "Create a TUS resumable upload to Vimeo", + "parameters": [ + { + "description": "TUS Upload Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.CreateTusUploadRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handlers.VimeoUploadResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/vimeo/videos/{video_id}": { + "get": { + "description": "Retrieves video details from Vimeo by video ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Vimeo" + ], + "summary": "Get video information from Vimeo", + "parameters": [ + { + "type": "string", + "description": "Vimeo Video ID", + "name": "video_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.VimeoVideoResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Deletes a video from the Vimeo account", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Vimeo" + ], + "summary": "Delete a video from Vimeo", + "parameters": [ + { + "type": "string", + "description": "Vimeo Video ID", + "name": "video_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/vimeo/videos/{video_id}/embed": { + "get": { + "description": "Generates an embeddable player iframe for the video", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Vimeo" + ], + "summary": "Get embed code for a Vimeo video", + "parameters": [ + { + "type": "string", + "description": "Vimeo Video ID", + "name": "video_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "default": 640, + "description": "Player width", + "name": "width", + "in": "query" + }, + { + "type": "integer", + "default": 360, + "description": "Player height", + "name": "height", + "in": "query" + }, + { + "type": "boolean", + "description": "Autoplay video", + "name": "autoplay", + "in": "query" + }, + { + "type": "boolean", + "description": "Loop video", + "name": "loop", + "in": "query" + }, + { + "type": "boolean", + "description": "Mute video", + "name": "muted", + "in": "query" + }, + { + "type": "boolean", + "description": "Background mode (no controls)", + "name": "background", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.VimeoEmbedResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/vimeo/videos/{video_id}/status": { + "get": { + "description": "Returns the current transcoding status of a video", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Vimeo" + ], + "summary": "Get transcode status of a Vimeo video", + "parameters": [ + { + "type": "string", + "description": "Vimeo Video ID", + "name": "video_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/{tenant_slug}/admin-login": { "post": { "description": "Login user", @@ -3849,87 +5998,82 @@ const docTemplate = `{ "Age55Plus" ] }, - "domain.AssessmentQuestion": { + "domain.CreateTeamMemberReq": { "type": "object", + "required": [ + "email", + "first_name", + "last_name", + "password", + "team_role" + ], "properties": { - "created_at": { + "bio": { "type": "string" }, - "description": { + "department": { "type": "string" }, - "difficulty_level": { + "email": { "type": "string" }, - "id": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "points": { - "type": "integer" - }, - "question_type": { + "emergency_contact": { "type": "string" }, - "title": { + "employment_type": { "type": "string" }, - "updated_at": { - "type": "string" - } - } - }, - "domain.CreateAssessmentQuestionInput": { - "type": "object", - "properties": { - "correctAnswer": { - "description": "Short Answer only", + "first_name": { "type": "string" }, - "description": { + "hire_date": { + "description": "YYYY-MM-DD", "type": "string" }, - "difficultyLevel": { + "job_title": { "type": "string" }, - "isActive": { - "type": "boolean" + "last_name": { + "type": "string" }, - "options": { - "description": "Multiple Choice only", + "password": { + "type": "string", + "minLength": 8 + }, + "permissions": { "type": "array", "items": { - "$ref": "#/definitions/domain.CreateQuestionOptionInput" + "type": "string" } }, - "points": { - "type": "integer", - "format": "int32" + "phone_number": { + "type": "string" }, - "questionType": { - "$ref": "#/definitions/domain.QuestionType" + "profile_picture_url": { + "type": "string" }, - "title": { + "team_role": { + "type": "string" + }, + "work_phone": { "type": "string" } } }, - "domain.CreateQuestionOptionInput": { - "type": "object", - "properties": { - "isCorrect": { - "type": "boolean" - }, - "order": { - "type": "integer", - "format": "int32" - }, - "text": { - "type": "string" - } - } + "domain.EmploymentType": { + "type": "string", + "enum": [ + "full_time", + "part_time", + "contract", + "intern" + ], + "x-enum-varnames": [ + "EmploymentTypeFullTime", + "EmploymentTypePartTime", + "EmploymentTypeContract", + "EmploymentTypeIntern" + ] }, "domain.ErrorResponse": { "type": "object", @@ -4037,38 +6181,103 @@ const docTemplate = `{ "domain.QuestionOption": { "type": "object", "properties": { - "option_text": { + "createdAt": { "type": "string" }, - "question_id": { - "type": "integer" + "id": { + "type": "integer", + "format": "int64" + }, + "isCorrect": { + "type": "boolean" + }, + "optionOrder": { + "type": "integer", + "format": "int32" + }, + "optionText": { + "type": "string" + }, + "questionID": { + "type": "integer", + "format": "int64" } } }, - "domain.QuestionType": { - "type": "string", - "enum": [ - "MULTIPLE_CHOICE", - "TRUE_FALSE", - "SHORT_ANSWER" - ], - "x-enum-varnames": [ - "MultipleChoice", - "TrueFalse", - "ShortAnswer" - ] + "domain.QuestionShortAnswer": { + "type": "object", + "properties": { + "acceptableAnswer": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "matchType": { + "type": "string" + }, + "questionID": { + "type": "integer", + "format": "int64" + } + } }, "domain.QuestionWithDetails": { "type": "object", "properties": { + "createdAt": { + "type": "string" + }, + "difficultyLevel": { + "type": "string" + }, + "explanation": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int64" + }, "options": { "type": "array", "items": { "$ref": "#/definitions/domain.QuestionOption" } }, - "question": { - "$ref": "#/definitions/domain.AssessmentQuestion" + "points": { + "type": "integer", + "format": "int32" + }, + "questionText": { + "type": "string" + }, + "questionType": { + "type": "string" + }, + "sampleAnswerVoicePrompt": { + "type": "string" + }, + "shortAnswers": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.QuestionShortAnswer" + } + }, + "status": { + "type": "string" + }, + "tips": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "voicePrompt": { + "type": "string" } } }, @@ -4136,6 +6345,144 @@ const docTemplate = `{ "RoleSupport" ] }, + "domain.TeamMemberLoginReq": { + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "domain.TeamMemberResponse": { + "type": "object", + "properties": { + "bio": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "department": { + "type": "string" + }, + "email": { + "type": "string" + }, + "email_verified": { + "type": "boolean" + }, + "employment_type": { + "$ref": "#/definitions/domain.EmploymentType" + }, + "first_name": { + "type": "string" + }, + "hire_date": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "job_title": { + "type": "string" + }, + "last_login": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "permissions": { + "type": "array", + "items": { + "type": "string" + } + }, + "phone_number": { + "type": "string" + }, + "profile_picture_url": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/domain.TeamMemberStatus" + }, + "team_role": { + "$ref": "#/definitions/domain.TeamRole" + }, + "updated_at": { + "type": "string" + }, + "work_phone": { + "type": "string" + } + } + }, + "domain.TeamMemberStats": { + "type": "object", + "properties": { + "active_count": { + "type": "integer" + }, + "inactive_count": { + "type": "integer" + }, + "suspended_count": { + "type": "integer" + }, + "terminated_count": { + "type": "integer" + }, + "total_count": { + "type": "integer" + } + } + }, + "domain.TeamMemberStatus": { + "type": "string", + "enum": [ + "active", + "inactive", + "suspended", + "terminated" + ], + "x-enum-varnames": [ + "TeamMemberStatusActive", + "TeamMemberStatusInactive", + "TeamMemberStatusSuspended", + "TeamMemberStatusTerminated" + ] + }, + "domain.TeamRole": { + "type": "string", + "enum": [ + "super_admin", + "admin", + "content_manager", + "support_agent", + "instructor", + "finance", + "hr", + "analyst" + ], + "x-enum-varnames": [ + "TeamRoleSuperAdmin", + "TeamRoleAdmin", + "TeamRoleContentManager", + "TeamRoleSupportAgent", + "TeamRoleInstructor", + "TeamRoleFinance", + "TeamRoleHR", + "TeamRoleAnalyst" + ] + }, "domain.UpdateKnowledgeLevelReq": { "type": "object", "properties": { @@ -4148,6 +6495,64 @@ const docTemplate = `{ } } }, + "domain.UpdateTeamMemberReq": { + "type": "object", + "properties": { + "bio": { + "type": "string" + }, + "department": { + "type": "string" + }, + "emergency_contact": { + "type": "string" + }, + "employment_type": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "hire_date": { + "type": "string" + }, + "job_title": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "permissions": { + "type": "array", + "items": { + "type": "string" + } + }, + "phone_number": { + "type": "string" + }, + "profile_picture_url": { + "type": "string" + }, + "team_role": { + "type": "string" + }, + "work_phone": { + "type": "string" + } + } + }, + "domain.UpdateTeamMemberStatusReq": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string" + } + } + }, "domain.UpdateUserReq": { "type": "object", "properties": { @@ -4200,9 +6605,6 @@ const docTemplate = `{ "preferred_language": { "type": "string" }, - "profile_completed": { - "type": "boolean" - }, "profile_picture_url": { "type": "string" }, @@ -4286,6 +6688,9 @@ const docTemplate = `{ "profile_completed": { "type": "boolean" }, + "profile_completion_percentage": { + "type": "integer" + }, "profile_picture_url": { "type": "string" }, @@ -4335,6 +6740,46 @@ const docTemplate = `{ } } }, + "domain.WebhookRequest": { + "type": "object", + "properties": { + "nonce": { + "type": "string" + }, + "notificationUrl": { + "type": "string" + }, + "paymentMethod": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "totalAmount": { + "type": "integer" + }, + "transaction": { + "type": "object", + "properties": { + "transactionId": { + "type": "string" + }, + "transactionStatus": { + "type": "string" + } + } + }, + "transactionStatus": { + "type": "string" + }, + "uuid": { + "type": "string" + } + } + }, "handlers.AdminProfileRes": { "type": "object", "properties": { @@ -4472,6 +6917,46 @@ const docTemplate = `{ } } }, + "handlers.CreatePullUploadRequest": { + "type": "object", + "required": [ + "file_size", + "name", + "source_url" + ], + "properties": { + "description": { + "type": "string" + }, + "file_size": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "source_url": { + "type": "string" + } + } + }, + "handlers.CreateTusUploadRequest": { + "type": "object", + "required": [ + "file_size", + "name" + ], + "properties": { + "description": { + "type": "string" + }, + "file_size": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + }, "handlers.LoginAdminRes": { "type": "object", "properties": { @@ -4543,6 +7028,138 @@ const docTemplate = `{ } } }, + "handlers.VimeoEmbedResponse": { + "type": "object", + "properties": { + "embed_html": { + "type": "string" + }, + "embed_url": { + "type": "string" + }, + "video_id": { + "type": "string" + } + } + }, + "handlers.VimeoUploadResponse": { + "type": "object", + "properties": { + "link": { + "type": "string" + }, + "status": { + "type": "string" + }, + "upload_link": { + "type": "string" + }, + "uri": { + "type": "string" + }, + "vimeo_id": { + "type": "string" + } + } + }, + "handlers.VimeoVideoResponse": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "duration": { + "type": "integer" + }, + "embed_html": { + "type": "string" + }, + "embed_url": { + "type": "string" + }, + "height": { + "type": "integer" + }, + "link": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "thumbnail_url": { + "type": "string" + }, + "transcode_status": { + "type": "string" + }, + "uri": { + "type": "string" + }, + "vimeo_id": { + "type": "string" + }, + "width": { + "type": "integer" + } + } + }, + "handlers.addQuestionToSetReq": { + "type": "object", + "required": [ + "question_id" + ], + "properties": { + "display_order": { + "type": "integer" + }, + "question_id": { + "type": "integer" + } + } + }, + "handlers.addUserPersonaReq": { + "type": "object", + "required": [ + "user_id" + ], + "properties": { + "display_order": { + "type": "integer" + }, + "user_id": { + "type": "integer" + } + } + }, + "handlers.autoRenewReq": { + "type": "object", + "properties": { + "auto_renew": { + "type": "boolean" + } + } + }, + "handlers.changePasswordReq": { + "type": "object", + "required": [ + "current_password", + "new_password" + ], + "properties": { + "current_password": { + "type": "string", + "example": "oldpassword123" + }, + "new_password": { + "type": "string", + "minLength": 8, + "example": "newpassword123" + } + } + }, "handlers.createCourseCategoryReq": { "type": "object", "required": [ @@ -4567,29 +7184,153 @@ const docTemplate = `{ "description": { "type": "string" }, + "thumbnail": { + "type": "string" + }, "title": { "type": "string" } } }, - "handlers.createLevelReq": { + "handlers.createPlanReq": { "type": "object", "required": [ - "level_index", - "program_id", - "title" + "currency", + "duration_unit", + "duration_value", + "name", + "price" ], "properties": { + "currency": { + "type": "string" + }, "description": { "type": "string" }, + "duration_unit": { + "type": "string", + "enum": [ + "DAY", + "WEEK", + "MONTH", + "YEAR" + ] + }, + "duration_value": { + "type": "integer", + "minimum": 1 + }, "is_active": { "type": "boolean" }, - "level_index": { + "name": { + "type": "string" + }, + "price": { + "type": "number", + "minimum": 0 + } + } + }, + "handlers.createQuestionReq": { + "type": "object", + "required": [ + "question_text", + "question_type" + ], + "properties": { + "difficulty_level": { + "type": "string" + }, + "explanation": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.optionInput" + } + }, + "points": { "type": "integer" }, - "program_id": { + "question_text": { + "type": "string" + }, + "question_type": { + "type": "string", + "enum": [ + "MCQ", + "TRUE_FALSE", + "SHORT_ANSWER" + ] + }, + "sample_answer_voice_prompt": { + "type": "string" + }, + "short_answers": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.shortAnswerInput" + } + }, + "status": { + "type": "string" + }, + "tips": { + "type": "string" + }, + "voice_prompt": { + "type": "string" + } + } + }, + "handlers.createQuestionSetReq": { + "type": "object", + "required": [ + "set_type", + "title" + ], + "properties": { + "banner_image": { + "type": "string" + }, + "description": { + "type": "string" + }, + "owner_id": { + "type": "integer" + }, + "owner_type": { + "type": "string" + }, + "passing_score": { + "type": "integer" + }, + "persona": { + "type": "string" + }, + "set_type": { + "type": "string", + "enum": [ + "PRACTICE", + "INITIAL_ASSESSMENT", + "QUIZ", + "EXAM", + "SURVEY" + ] + }, + "shuffle_questions": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "sub_course_video_id": { + "type": "integer" + }, + "time_limit_minutes": { "type": "integer" }, "title": { @@ -4597,32 +7338,40 @@ const docTemplate = `{ } } }, - "handlers.createModuleReq": { + "handlers.createSubCourseReq": { "type": "object", "required": [ - "level_id", + "course_id", + "level", "title" ], "properties": { - "content": { + "course_id": { + "type": "integer" + }, + "description": { "type": "string" }, "display_order": { "type": "integer" }, - "level_id": { - "type": "integer" + "level": { + "description": "BEGINNER, INTERMEDIATE, ADVANCED", + "type": "string" + }, + "thumbnail": { + "type": "string" }, "title": { "type": "string" } } }, - "handlers.createModuleVideoReq": { + "handlers.createSubCourseVideoReq": { "type": "object", "required": [ "duration", - "module_id", + "sub_course_id", "title", "video_url" ], @@ -4630,18 +7379,25 @@ const docTemplate = `{ "description": { "type": "string" }, + "display_order": { + "type": "integer" + }, "duration": { "type": "integer" }, "instructor_id": { "type": "string" }, - "module_id": { - "type": "integer" - }, "resolution": { "type": "string" }, + "status": { + "description": "DRAFT, PUBLISHED, INACTIVE, ARCHIVED", + "type": "string" + }, + "sub_course_id": { + "type": "integer" + }, "thumbnail": { "type": "string" }, @@ -4656,89 +7412,117 @@ const docTemplate = `{ } } }, - "handlers.createPracticeQuestionReq": { + "handlers.createVideoFromVimeoIDReq": { "type": "object", "required": [ - "practice_id", - "q_type", - "question" + "sub_course_id", + "title", + "vimeo_video_id" ], "properties": { - "practice_id": { - "type": "integer" - }, - "q_type": { - "type": "string" - }, - "question": { - "type": "string" - }, - "question_voice_prompt": { - "type": "string" - }, - "sample_answer": { - "type": "string" - }, - "sample_answer_voice_prompt": { - "type": "string" - }, - "tips": { - "type": "string" - } - } - }, - "handlers.createPracticeReq": { - "type": "object", - "required": [ - "owner_id", - "owner_type", - "title" - ], - "properties": { - "banner_image": { - "type": "string" - }, - "description": { - "type": "string" - }, - "is_active": { - "type": "boolean" - }, - "owner_id": { - "type": "integer" - }, - "owner_type": { - "type": "string" - }, - "persona": { - "type": "string" - }, - "title": { - "type": "string" - } - } - }, - "handlers.createProgramReq": { - "type": "object", - "required": [ - "course_id", - "title" - ], - "properties": { - "course_id": { - "type": "integer" - }, "description": { "type": "string" }, "display_order": { "type": "integer" }, + "instructor_id": { + "type": "string" + }, + "sub_course_id": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "vimeo_video_id": { + "type": "string" + } + } + }, + "handlers.createVimeoVideoReq": { + "type": "object", + "required": [ + "file_size", + "source_url", + "sub_course_id", + "title" + ], + "properties": { + "description": { + "type": "string" + }, + "display_order": { + "type": "integer" + }, + "duration": { + "type": "integer" + }, + "file_size": { + "type": "integer" + }, + "instructor_id": { + "type": "string" + }, + "resolution": { + "type": "string" + }, + "source_url": { + "type": "string" + }, + "sub_course_id": { + "type": "integer" + }, "thumbnail": { "type": "string" }, "title": { "type": "string" + }, + "visibility": { + "type": "string" + } + } + }, + "handlers.initiateDirectPaymentReq": { + "type": "object", + "required": [ + "email", + "payment_method", + "phone", + "plan_id" + ], + "properties": { + "email": { + "type": "string" + }, + "payment_method": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "plan_id": { + "type": "integer" + } + } + }, + "handlers.initiatePaymentReq": { + "type": "object", + "required": [ + "email", + "phone", + "plan_id" + ], + "properties": { + "email": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "plan_id": { + "type": "integer" } } }, @@ -4765,12 +7549,33 @@ const docTemplate = `{ "refresh_token" ], "properties": { + "device_token": { + "type": "string", + "example": "\u003cfcm-device-token\u003e" + }, "refresh_token": { "type": "string", "example": "\u003crefresh-token\u003e" } } }, + "handlers.optionInput": { + "type": "object", + "required": [ + "option_text" + ], + "properties": { + "is_correct": { + "type": "boolean" + }, + "option_order": { + "type": "integer" + }, + "option_text": { + "type": "string" + } + } + }, "handlers.refreshToken": { "type": "object", "required": [ @@ -4788,6 +7593,77 @@ const docTemplate = `{ } } }, + "handlers.shortAnswerInput": { + "type": "object", + "required": [ + "acceptable_answer" + ], + "properties": { + "acceptable_answer": { + "type": "string" + }, + "match_type": { + "type": "string" + } + } + }, + "handlers.subscribeReq": { + "type": "object", + "required": [ + "plan_id" + ], + "properties": { + "payment_method": { + "type": "string" + }, + "payment_reference": { + "type": "string" + }, + "plan_id": { + "type": "integer" + } + } + }, + "handlers.subscribeWithPaymentReq": { + "type": "object", + "required": [ + "email", + "phone", + "plan_id" + ], + "properties": { + "email": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "plan_id": { + "type": "integer" + } + } + }, + "handlers.teamMemberLoginRes": { + "type": "object", + "properties": { + "access_token": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + }, + "member_id": { + "type": "integer", + "example": 1 + }, + "refresh_token": { + "type": "string", + "example": "" + }, + "team_role": { + "type": "string", + "example": "admin" + } + } + }, "handlers.updateAdminReq": { "type": "object", "properties": { @@ -4825,21 +7701,120 @@ const docTemplate = `{ "is_active": { "type": "boolean" }, + "thumbnail": { + "type": "string" + }, "title": { "type": "string" } } }, - "handlers.updateLevelReq": { + "handlers.updatePlanReq": { "type": "object", "properties": { + "currency": { + "type": "string" + }, "description": { "type": "string" }, + "duration_unit": { + "type": "string" + }, + "duration_value": { + "type": "integer" + }, "is_active": { "type": "boolean" }, - "level_index": { + "name": { + "type": "string" + }, + "price": { + "type": "number" + } + } + }, + "handlers.updateQuestionOrderReq": { + "type": "object", + "required": [ + "display_order" + ], + "properties": { + "display_order": { + "type": "integer" + } + } + }, + "handlers.updateQuestionReq": { + "type": "object", + "properties": { + "difficulty_level": { + "type": "string" + }, + "explanation": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.optionInput" + } + }, + "points": { + "type": "integer" + }, + "question_text": { + "type": "string" + }, + "question_type": { + "type": "string" + }, + "sample_answer_voice_prompt": { + "type": "string" + }, + "short_answers": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.shortAnswerInput" + } + }, + "status": { + "type": "string" + }, + "tips": { + "type": "string" + }, + "voice_prompt": { + "type": "string" + } + } + }, + "handlers.updateQuestionSetReq": { + "type": "object", + "properties": { + "banner_image": { + "type": "string" + }, + "description": { + "type": "string" + }, + "passing_score": { + "type": "integer" + }, + "persona": { + "type": "string" + }, + "shuffle_questions": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "sub_course_video_id": { + "type": "integer" + }, + "time_limit_minutes": { "type": "integer" }, "title": { @@ -4847,10 +7822,10 @@ const docTemplate = `{ } } }, - "handlers.updateModuleReq": { + "handlers.updateSubCourseReq": { "type": "object", "properties": { - "content": { + "description": { "type": "string" }, "display_order": { @@ -4859,26 +7834,36 @@ const docTemplate = `{ "is_active": { "type": "boolean" }, + "level": { + "type": "string" + }, + "thumbnail": { + "type": "string" + }, "title": { "type": "string" } } }, - "handlers.updateModuleVideoReq": { + "handlers.updateSubCourseVideoReq": { "type": "object", "properties": { "description": { "type": "string" }, + "display_order": { + "type": "integer" + }, "duration": { "type": "integer" }, - "is_active": { - "type": "boolean" - }, "resolution": { "type": "string" }, + "status": { + "description": "DRAFT, PUBLISHED, INACTIVE, ARCHIVED", + "type": "string" + }, "thumbnail": { "type": "string" }, @@ -4893,86 +7878,17 @@ const docTemplate = `{ } } }, - "handlers.updatePracticeQuestionReq": { - "type": "object", - "properties": { - "q_type": { - "type": "string" - }, - "question": { - "type": "string" - }, - "sample_answer": { - "type": "string" - }, - "tips": { - "type": "string" - } - } - }, - "handlers.updatePracticeReq": { - "type": "object", - "properties": { - "banner_image": { - "type": "string" - }, - "description": { - "type": "string" - }, - "is_active": { - "type": "boolean" - }, - "persona": { - "type": "string" - }, - "title": { - "type": "string" - } - } - }, - "handlers.updateProgramFullReq": { + "handlers.verifyOTPReq": { "type": "object", "required": [ - "course_id", - "title" + "otp", + "session_id" ], "properties": { - "course_id": { - "type": "integer" - }, - "description": { + "otp": { "type": "string" }, - "display_order": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "thumbnail": { - "type": "string" - }, - "title": { - "type": "string" - } - } - }, - "handlers.updateProgramPartialReq": { - "type": "object", - "properties": { - "description": { - "type": "string" - }, - "display_order": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "thumbnail": { - "type": "string" - }, - "title": { + "session_id": { "type": "string" } } @@ -5009,6 +7925,62 @@ const docTemplate = `{ "Error", "Success" ] + }, + "vimeo.OEmbedResponse": { + "type": "object", + "properties": { + "author_name": { + "type": "string" + }, + "author_url": { + "type": "string" + }, + "description": { + "type": "string" + }, + "duration": { + "type": "integer" + }, + "height": { + "type": "integer" + }, + "html": { + "type": "string" + }, + "is_plus": { + "type": "string" + }, + "provider_name": { + "type": "string" + }, + "provider_url": { + "type": "string" + }, + "thumbnail_height": { + "type": "integer" + }, + "thumbnail_url": { + "type": "string" + }, + "thumbnail_width": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "version": { + "type": "string" + }, + "video_id": { + "type": "integer" + }, + "width": { + "type": "integer" + } + } } }, "securityDefinitions": { diff --git a/docs/swagger.json b/docs/swagger.json index 2bc0563..a06f838 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -223,7 +223,7 @@ }, "/api/v1/assessment/questions": { "get": { - "description": "Returns all active assessment questions with their options or answers", + "description": "Returns all active assessment questions from the initial assessment set", "produces": [ "application/json" ], @@ -250,7 +250,7 @@ } }, "post": { - "description": "Creates a new assessment question with options or short answer depending on question type", + "description": "Creates a new assessment question using the unified questions system", "consumes": [ "application/json" ], @@ -268,7 +268,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/domain.CreateAssessmentQuestionInput" + "$ref": "#/definitions/handlers.createQuestionReq" } } ], @@ -813,16 +813,16 @@ } } }, - "/api/v1/course-management/courses/{courseId}/programs": { + "/api/v1/course-management/courses/{courseId}/sub-courses": { "get": { - "description": "Returns all programs under a specific course with total count", + "description": "Returns all sub-courses under a specific course", "produces": [ "application/json" ], "tags": [ - "programs" + "sub-courses" ], - "summary": "Get programs by course", + "summary": "Get sub-courses by course", "parameters": [ { "type": "integer", @@ -854,16 +854,16 @@ } } }, - "/api/v1/course-management/courses/{courseId}/programs/list": { + "/api/v1/course-management/courses/{courseId}/sub-courses/list": { "get": { - "description": "Returns a simple list of programs under a specific course", + "description": "Returns a list of active sub-courses under a specific course", "produces": [ "application/json" ], "tags": [ - "programs" + "sub-courses" ], - "summary": "List programs by course", + "summary": "List active sub-courses by course", "parameters": [ { "type": "integer", @@ -1034,7 +1034,7 @@ }, "/api/v1/course-management/learning-tree": { "get": { - "description": "Returns the complete learning tree structure with courses, programs, levels, and modules", + "description": "Returns the complete learning tree structure with courses and sub-courses", "produces": [ "application/json" ], @@ -1058,9 +1058,9 @@ } } }, - "/api/v1/course-management/levels": { + "/api/v1/course-management/sub-courses": { "post": { - "description": "Creates a new level under a specific program", + "description": "Creates a new sub-course under a specific course", "consumes": [ "application/json" ], @@ -1068,17 +1068,17 @@ "application/json" ], "tags": [ - "levels" + "sub-courses" ], - "summary": "Create a new level", + "summary": "Create a new sub-course", "parameters": [ { - "description": "Create level payload", + "description": "Create sub-course payload", "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.createLevelReq" + "$ref": "#/definitions/handlers.createSubCourseReq" } } ], @@ -1104,242 +1104,16 @@ } } }, - "/api/v1/course-management/levels/{id}": { - "put": { - "description": "Updates a level's title, description, index, and/or active status", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "levels" - ], - "summary": "Update level", - "parameters": [ - { - "type": "integer", - "description": "Level ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Update level payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.updateLevelReq" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/levels/{levelId}": { - "delete": { - "description": "Deletes a level by its ID", - "produces": [ - "application/json" - ], - "tags": [ - "levels" - ], - "summary": "Delete level", - "parameters": [ - { - "type": "integer", - "description": "Level ID", - "name": "levelId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/levels/{levelId}/increment-module": { - "put": { - "description": "Increments the module count for a specific level", - "produces": [ - "application/json" - ], - "tags": [ - "levels" - ], - "summary": "Increment level module count", - "parameters": [ - { - "type": "integer", - "description": "Level ID", - "name": "levelId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/levels/{levelId}/increment-practice": { - "put": { - "description": "Increments the practice count for a specific level", - "produces": [ - "application/json" - ], - "tags": [ - "levels" - ], - "summary": "Increment level practice count", - "parameters": [ - { - "type": "integer", - "description": "Level ID", - "name": "levelId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/levels/{levelId}/increment-video": { - "put": { - "description": "Increments the video count for a specific level", - "produces": [ - "application/json" - ], - "tags": [ - "levels" - ], - "summary": "Increment level video count", - "parameters": [ - { - "type": "integer", - "description": "Level ID", - "name": "levelId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/levels/{levelId}/modules": { + "/api/v1/course-management/sub-courses/active": { "get": { - "description": "Returns a paginated list of modules under a specific level", + "description": "Returns a list of all active sub-courses", "produces": [ "application/json" ], "tags": [ - "modules" - ], - "summary": "Get modules by level", - "parameters": [ - { - "type": "integer", - "description": "Level ID", - "name": "levelId", - "in": "path", - "required": true - } + "sub-courses" ], + "summary": "List all active sub-courses", "responses": { "200": { "description": "OK", @@ -1347,12 +1121,6 @@ "$ref": "#/definitions/domain.Response" } }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, "500": { "description": "Internal Server Error", "schema": { @@ -1362,498 +1130,20 @@ } } }, - "/api/v1/course-management/modules": { - "post": { - "description": "Creates a new module under a specific level", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "modules" - ], - "summary": "Create a new module", - "parameters": [ - { - "description": "Create module payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.createModuleReq" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/modules/{id}": { - "put": { - "description": "Updates a module's title, content, display order, and/or active status", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "modules" - ], - "summary": "Update module", - "parameters": [ - { - "type": "integer", - "description": "Module ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Update module payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.updateModuleReq" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - }, - "delete": { - "description": "Deletes a module by its ID", - "produces": [ - "application/json" - ], - "tags": [ - "modules" - ], - "summary": "Delete module", - "parameters": [ - { - "type": "integer", - "description": "Module ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/modules/{moduleId}/videos/published": { + "/api/v1/course-management/sub-courses/{id}": { "get": { - "description": "Returns all published videos under a specific module", + "description": "Returns a single sub-course by its ID", "produces": [ "application/json" ], "tags": [ - "module-videos" + "sub-courses" ], - "summary": "Get published videos by module", + "summary": "Get sub-course by ID", "parameters": [ { "type": "integer", - "description": "Module ID", - "name": "moduleId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/owners/{ownerType}/{ownerId}/practices": { - "get": { - "description": "Returns all practices for a specific owner type and ID", - "produces": [ - "application/json" - ], - "tags": [ - "practices" - ], - "summary": "Get practices by owner", - "parameters": [ - { - "type": "string", - "description": "Owner Type", - "name": "ownerType", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "Owner ID", - "name": "ownerId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/practices": { - "post": { - "description": "Creates a new practice for a specific owner (module or level)", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "practices" - ], - "summary": "Create a new practice", - "parameters": [ - { - "description": "Create practice payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.createPracticeReq" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/practices/{id}": { - "put": { - "description": "Updates a practice's fields", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "practices" - ], - "summary": "Update practice", - "parameters": [ - { - "type": "integer", - "description": "Practice ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Update practice payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.updatePracticeReq" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - }, - "delete": { - "description": "Deletes a practice by its ID", - "produces": [ - "application/json" - ], - "tags": [ - "practices" - ], - "summary": "Delete practice", - "parameters": [ - { - "type": "integer", - "description": "Practice ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/practices/{practiceId}/questions": { - "get": { - "description": "Returns all questions under a specific practice", - "produces": [ - "application/json" - ], - "tags": [ - "practice-questions" - ], - "summary": "Get questions by practice", - "parameters": [ - { - "type": "integer", - "description": "Practice ID", - "name": "practiceId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/programs": { - "post": { - "description": "Creates a new program under a specific course", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "programs" - ], - "summary": "Create a new program", - "parameters": [ - { - "description": "Create program payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.createProgramReq" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/programs/active": { - "get": { - "description": "Returns all active programs across all courses", - "produces": [ - "application/json" - ], - "tags": [ - "programs" - ], - "summary": "List active programs", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/programs/{id}": { - "get": { - "description": "Returns a single program by its ID", - "produces": [ - "application/json" - ], - "tags": [ - "programs" - ], - "summary": "Get program by ID", - "parameters": [ - { - "type": "integer", - "description": "Program ID", + "description": "Sub-course ID", "name": "id", "in": "path", "required": true @@ -1887,18 +1177,18 @@ } }, "delete": { - "description": "Deletes a program by its ID", + "description": "Deletes a sub-course by its ID", "produces": [ "application/json" ], "tags": [ - "programs" + "sub-courses" ], - "summary": "Delete program", + "summary": "Delete sub-course", "parameters": [ { "type": "integer", - "description": "Program ID", + "description": "Sub-course ID", "name": "id", "in": "path", "required": true @@ -1926,7 +1216,7 @@ } }, "patch": { - "description": "Updates selected fields of a program", + "description": "Updates a sub-course's fields", "consumes": [ "application/json" ], @@ -1934,24 +1224,24 @@ "application/json" ], "tags": [ - "programs" + "sub-courses" ], - "summary": "Update program partially", + "summary": "Update sub-course", "parameters": [ { "type": "integer", - "description": "Program ID", + "description": "Sub-course ID", "name": "id", "in": "path", "required": true }, { - "description": "Update program payload", + "description": "Update sub-course payload", "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.updateProgramPartialReq" + "$ref": "#/definitions/handlers.updateSubCourseReq" } } ], @@ -1977,20 +1267,20 @@ } } }, - "/api/v1/course-management/programs/{id}/deactivate": { + "/api/v1/course-management/sub-courses/{id}/deactivate": { "put": { - "description": "Deactivates a program by setting is_active to false", + "description": "Deactivates a sub-course by its ID", "produces": [ "application/json" ], "tags": [ - "programs" + "sub-courses" ], - "summary": "Deactivate program", + "summary": "Deactivate sub-course", "parameters": [ { "type": "integer", - "description": "Program ID", + "description": "Sub-course ID", "name": "id", "in": "path", "required": true @@ -2018,74 +1308,21 @@ } } }, - "/api/v1/course-management/programs/{id}/full": { - "put": { - "description": "Updates all fields of a program", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "programs" - ], - "summary": "Update program fully", - "parameters": [ - { - "type": "integer", - "description": "Program ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Update program payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.updateProgramFullReq" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/programs/{programId}/levels": { + "/api/v1/course-management/sub-courses/{subCourseId}/videos": { "get": { - "description": "Returns all levels under a specific program", + "description": "Returns all videos under a specific sub-course", "produces": [ "application/json" ], "tags": [ - "levels" + "sub-course-videos" ], - "summary": "Get levels by program", + "summary": "Get videos by sub-course", "parameters": [ { "type": "integer", - "description": "Program ID", - "name": "programId", + "description": "Sub-course ID", + "name": "subCourseId", "in": "path", "required": true } @@ -2112,118 +1349,21 @@ } } }, - "/api/v1/course-management/questions": { - "post": { - "description": "Creates a new question under a specific practice", - "consumes": [ - "application/json" - ], + "/api/v1/course-management/sub-courses/{subCourseId}/videos/published": { + "get": { + "description": "Returns all published videos under a specific sub-course", "produces": [ "application/json" ], "tags": [ - "practice-questions" + "sub-course-videos" ], - "summary": "Create a new practice question", - "parameters": [ - { - "description": "Create question payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.createPracticeQuestionReq" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/questions/{id}": { - "put": { - "description": "Updates a practice question's fields", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "practice-questions" - ], - "summary": "Update practice question", + "summary": "Get published videos by sub-course", "parameters": [ { "type": "integer", - "description": "Question ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Update question payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.updatePracticeQuestionReq" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - }, - "delete": { - "description": "Deletes a practice question by its ID", - "produces": [ - "application/json" - ], - "tags": [ - "practice-questions" - ], - "summary": "Delete practice question", - "parameters": [ - { - "type": "integer", - "description": "Question ID", - "name": "id", + "description": "Sub-course ID", + "name": "subCourseId", "in": "path", "required": true } @@ -2252,7 +1392,7 @@ }, "/api/v1/course-management/videos": { "post": { - "description": "Creates a new video under a specific module", + "description": "Creates a new video under a specific sub-course", "consumes": [ "application/json" ], @@ -2260,9 +1400,9 @@ "application/json" ], "tags": [ - "module-videos" + "sub-course-videos" ], - "summary": "Create a new module video", + "summary": "Create a new sub-course video", "parameters": [ { "description": "Create video payload", @@ -2270,7 +1410,99 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.createModuleVideoReq" + "$ref": "#/definitions/handlers.createSubCourseVideoReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/course-management/videos/vimeo": { + "post": { + "description": "Creates a video by uploading to Vimeo from a source URL", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "sub-course-videos" + ], + "summary": "Create a new sub-course video with Vimeo upload", + "parameters": [ + { + "description": "Create Vimeo video payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createVimeoVideoReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/course-management/videos/vimeo/import": { + "post": { + "description": "Creates a video record from an existing Vimeo video ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "sub-course-videos" + ], + "summary": "Create a sub-course video from existing Vimeo video", + "parameters": [ + { + "description": "Create from Vimeo ID payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createVideoFromVimeoIDReq" } } ], @@ -2297,8 +1529,47 @@ } }, "/api/v1/course-management/videos/{id}": { + "get": { + "description": "Returns a single video by its ID", + "produces": [ + "application/json" + ], + "tags": [ + "sub-course-videos" + ], + "summary": "Get sub-course video by ID", + "parameters": [ + { + "type": "integer", + "description": "Video ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, "put": { - "description": "Updates a module video's fields", + "description": "Updates a video's fields", "consumes": [ "application/json" ], @@ -2306,9 +1577,9 @@ "application/json" ], "tags": [ - "module-videos" + "sub-course-videos" ], - "summary": "Update module video", + "summary": "Update sub-course video", "parameters": [ { "type": "integer", @@ -2323,7 +1594,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.updateModuleVideoReq" + "$ref": "#/definitions/handlers.updateSubCourseVideoReq" } } ], @@ -2349,14 +1620,14 @@ } }, "delete": { - "description": "Deletes a module video by its ID", + "description": "Archives a video by its ID (soft delete)", "produces": [ "application/json" ], "tags": [ - "module-videos" + "sub-course-videos" ], - "summary": "Delete module video", + "summary": "Delete sub-course video", "parameters": [ { "type": "integer", @@ -2388,21 +1659,21 @@ } } }, - "/api/v1/course-management/videos/{videoId}/publish": { + "/api/v1/course-management/videos/{id}/publish": { "put": { - "description": "Publishes a module video by setting publish date", + "description": "Publishes a video by its ID", "produces": [ "application/json" ], "tags": [ - "module-videos" + "sub-course-videos" ], - "summary": "Publish module video", + "summary": "Publish sub-course video", "parameters": [ { "type": "integer", "description": "Video ID", - "name": "videoId", + "name": "id", "in": "path", "required": true } @@ -2489,6 +1760,1345 @@ } } }, + "/api/v1/notifications/test-push": { + "post": { + "description": "Sends a test push notification to all registered devices of the current user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notifications" + ], + "summary": "Send test push notification", + "parameters": [ + { + "description": "Test notification content", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "title": { + "type": "string" + } + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/payments": { + "get": { + "description": "Returns the authenticated user's payment history", + "produces": [ + "application/json" + ], + "tags": [ + "payments" + ], + "summary": "Get payment history", + "parameters": [ + { + "type": "integer", + "default": 20, + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, + "/api/v1/payments/direct": { + "post": { + "description": "Creates a payment session and initiates direct payment (OTP-based)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "payments" + ], + "summary": "Initiate direct payment", + "parameters": [ + { + "description": "Direct payment request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.initiateDirectPaymentReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/payments/direct/methods": { + "get": { + "description": "Returns list of payment methods that support direct payment (OTP-based)", + "produces": [ + "application/json" + ], + "tags": [ + "payments" + ], + "summary": "Get direct payment methods", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, + "/api/v1/payments/direct/verify-otp": { + "post": { + "description": "Verifies the OTP sent for direct payment methods (Amole, HelloCash, etc.)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "payments" + ], + "summary": "Verify OTP for direct payment", + "parameters": [ + { + "description": "OTP verification request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.verifyOTPReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/payments/methods": { + "get": { + "description": "Returns list of supported ArifPay payment methods", + "produces": [ + "application/json" + ], + "tags": [ + "payments" + ], + "summary": "Get available payment methods", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, + "/api/v1/payments/subscribe": { + "post": { + "description": "Creates a payment session for a subscription plan", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "payments" + ], + "summary": "Initiate subscription payment", + "parameters": [ + { + "description": "Payment request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.initiatePaymentReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/payments/verify/{session_id}": { + "get": { + "description": "Checks the payment status with the payment provider", + "produces": [ + "application/json" + ], + "tags": [ + "payments" + ], + "summary": "Verify payment status", + "parameters": [ + { + "type": "string", + "description": "Session ID", + "name": "session_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/payments/webhook": { + "post": { + "description": "Processes payment notifications from ArifPay", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "payments" + ], + "summary": "Handle ArifPay webhook", + "parameters": [ + { + "description": "Webhook payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.WebhookRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/payments/{id}": { + "get": { + "description": "Returns details of a specific payment", + "produces": [ + "application/json" + ], + "tags": [ + "payments" + ], + "summary": "Get payment details", + "parameters": [ + { + "type": "integer", + "description": "Payment ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/payments/{id}/cancel": { + "post": { + "description": "Cancels a payment that is still pending", + "produces": [ + "application/json" + ], + "tags": [ + "payments" + ], + "summary": "Cancel a pending payment", + "parameters": [ + { + "type": "integer", + "description": "Payment ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/question-sets": { + "get": { + "description": "Returns a paginated list of question sets filtered by type", + "produces": [ + "application/json" + ], + "tags": [ + "question-sets" + ], + "summary": "Get question sets by type", + "parameters": [ + { + "type": "string", + "description": "Set type (PRACTICE, INITIAL_ASSESSMENT, QUIZ, EXAM, SURVEY)", + "name": "set_type", + "in": "query", + "required": true + }, + { + "type": "integer", + "default": 10, + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "post": { + "description": "Creates a new question set (practice, assessment, quiz, exam, or survey)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "question-sets" + ], + "summary": "Create a new question set", + "parameters": [ + { + "description": "Create question set payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createQuestionSetReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/question-sets/by-owner": { + "get": { + "description": "Returns question sets for a specific owner (e.g., sub-course)", + "produces": [ + "application/json" + ], + "tags": [ + "question-sets" + ], + "summary": "Get question sets by owner", + "parameters": [ + { + "type": "string", + "description": "Owner type (SUB_COURSE, COURSE, CATEGORY, STANDALONE)", + "name": "owner_type", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "Owner ID", + "name": "owner_id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/question-sets/{id}": { + "get": { + "description": "Returns a question set with question count", + "produces": [ + "application/json" + ], + "tags": [ + "question-sets" + ], + "summary": "Get question set by ID", + "parameters": [ + { + "type": "integer", + "description": "Question Set ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "put": { + "description": "Updates a question set's properties", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "question-sets" + ], + "summary": "Update a question set", + "parameters": [ + { + "type": "integer", + "description": "Question Set ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Update question set payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateQuestionSetReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Archives a question set (soft delete)", + "produces": [ + "application/json" + ], + "tags": [ + "question-sets" + ], + "summary": "Delete a question set", + "parameters": [ + { + "type": "integer", + "description": "Question Set ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/question-sets/{setId}/personas": { + "get": { + "description": "Returns all users assigned as personas to a question set (practice)", + "produces": [ + "application/json" + ], + "tags": [ + "question-sets" + ], + "summary": "Get user personas for a question set", + "parameters": [ + { + "type": "integer", + "description": "Question Set ID", + "name": "setId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "post": { + "description": "Links a user as a persona to a question set (practice)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "question-sets" + ], + "summary": "Add a user as persona to a question set", + "parameters": [ + { + "type": "integer", + "description": "Question Set ID", + "name": "setId", + "in": "path", + "required": true + }, + { + "description": "Add user persona payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.addUserPersonaReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/question-sets/{setId}/personas/{userId}": { + "delete": { + "description": "Unlinks a user as persona from a question set (practice)", + "produces": [ + "application/json" + ], + "tags": [ + "question-sets" + ], + "summary": "Remove a user persona from a question set", + "parameters": [ + { + "type": "integer", + "description": "Question Set ID", + "name": "setId", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "User ID", + "name": "userId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/question-sets/{setId}/questions": { + "get": { + "description": "Returns all questions in a question set with details", + "produces": [ + "application/json" + ], + "tags": [ + "question-set-items" + ], + "summary": "Get questions in set", + "parameters": [ + { + "type": "integer", + "description": "Question Set ID", + "name": "setId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "post": { + "description": "Links a question to a question set", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "question-set-items" + ], + "summary": "Add question to set", + "parameters": [ + { + "type": "integer", + "description": "Question Set ID", + "name": "setId", + "in": "path", + "required": true + }, + { + "description": "Add question to set payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.addQuestionToSetReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/question-sets/{setId}/questions/{questionId}": { + "delete": { + "description": "Unlinks a question from a question set", + "produces": [ + "application/json" + ], + "tags": [ + "question-set-items" + ], + "summary": "Remove question from set", + "parameters": [ + { + "type": "integer", + "description": "Question Set ID", + "name": "setId", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Question ID", + "name": "questionId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/question-sets/{setId}/questions/{questionId}/order": { + "put": { + "description": "Updates the display order of a question in a set", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "question-set-items" + ], + "summary": "Update question order in set", + "parameters": [ + { + "type": "integer", + "description": "Question Set ID", + "name": "setId", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Question ID", + "name": "questionId", + "in": "path", + "required": true + }, + { + "description": "Update order payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateQuestionOrderReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/questions": { + "get": { + "description": "Returns a paginated list of questions with optional filters", + "produces": [ + "application/json" + ], + "tags": [ + "questions" + ], + "summary": "List questions", + "parameters": [ + { + "type": "string", + "description": "Question type filter (MCQ, TRUE_FALSE, SHORT_ANSWER)", + "name": "question_type", + "in": "query" + }, + { + "type": "string", + "description": "Difficulty level filter (EASY, MEDIUM, HARD)", + "name": "difficulty", + "in": "query" + }, + { + "type": "string", + "description": "Status filter (DRAFT, PUBLISHED, INACTIVE)", + "name": "status", + "in": "query" + }, + { + "type": "integer", + "default": 10, + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "post": { + "description": "Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "questions" + ], + "summary": "Create a new question", + "parameters": [ + { + "description": "Create question payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createQuestionReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/questions/search": { + "get": { + "description": "Search questions by text", + "produces": [ + "application/json" + ], + "tags": [ + "questions" + ], + "summary": "Search questions", + "parameters": [ + { + "type": "string", + "description": "Search query", + "name": "q", + "in": "query", + "required": true + }, + { + "type": "integer", + "default": 10, + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/questions/{id}": { + "get": { + "description": "Returns a question with its options/short answers", + "produces": [ + "application/json" + ], + "tags": [ + "questions" + ], + "summary": "Get question by ID", + "parameters": [ + { + "type": "integer", + "description": "Question ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "put": { + "description": "Updates a question and optionally replaces its options/short answers", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "questions" + ], + "summary": "Update a question", + "parameters": [ + { + "type": "integer", + "description": "Question ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Update question payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateQuestionReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Archives a question (soft delete)", + "produces": [ + "application/json" + ], + "tags": [ + "questions" + ], + "summary": "Delete a question", + "parameters": [ + { + "type": "integer", + "description": "Question ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/sendSMS": { "post": { "description": "Sends an SMS message to a single phone number using AfroMessage", @@ -2535,6 +3145,466 @@ } } }, + "/api/v1/subscription-plans": { + "get": { + "description": "Returns all subscription plans", + "produces": [ + "application/json" + ], + "tags": [ + "subscriptions" + ], + "summary": "List subscription plans", + "parameters": [ + { + "type": "boolean", + "description": "Return only active plans", + "name": "active_only", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "post": { + "description": "Creates a new subscription plan (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "subscriptions" + ], + "summary": "Create a subscription plan", + "parameters": [ + { + "description": "Create plan payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createPlanReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/subscription-plans/{id}": { + "get": { + "description": "Returns a single subscription plan by ID", + "produces": [ + "application/json" + ], + "tags": [ + "subscriptions" + ], + "summary": "Get a subscription plan", + "parameters": [ + { + "type": "integer", + "description": "Plan ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "put": { + "description": "Updates a subscription plan (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "subscriptions" + ], + "summary": "Update a subscription plan", + "parameters": [ + { + "type": "integer", + "description": "Plan ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Update plan payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updatePlanReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Deletes a subscription plan (admin only)", + "produces": [ + "application/json" + ], + "tags": [ + "subscriptions" + ], + "summary": "Delete a subscription plan", + "parameters": [ + { + "type": "integer", + "description": "Plan ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/subscriptions": { + "post": { + "description": "Creates a new subscription for the authenticated user. For regular users, use /payments/subscribe instead.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "subscriptions" + ], + "summary": "Subscribe to a plan (Admin only - bypasses payment)", + "deprecated": true, + "parameters": [ + { + "description": "Subscribe payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.subscribeReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/subscriptions/checkout": { + "post": { + "description": "Initiates payment for a subscription plan. Returns payment URL for checkout.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "subscriptions" + ], + "summary": "Subscribe to a plan with payment", + "parameters": [ + { + "description": "Subscribe with payment payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.subscribeWithPaymentReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "409": { + "description": "User already has active subscription", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/subscriptions/history": { + "get": { + "description": "Returns the authenticated user's subscription history", + "produces": [ + "application/json" + ], + "tags": [ + "subscriptions" + ], + "summary": "Get subscription history", + "parameters": [ + { + "type": "integer", + "default": 20, + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, + "/api/v1/subscriptions/me": { + "get": { + "description": "Returns the authenticated user's active subscription", + "produces": [ + "application/json" + ], + "tags": [ + "subscriptions" + ], + "summary": "Get current subscription", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/subscriptions/status": { + "get": { + "description": "Returns whether the authenticated user has an active subscription", + "produces": [ + "application/json" + ], + "tags": [ + "subscriptions" + ], + "summary": "Check subscription status", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, + "/api/v1/subscriptions/{id}/auto-renew": { + "put": { + "description": "Enables or disables auto-renewal for a subscription", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "subscriptions" + ], + "summary": "Set auto-renew", + "parameters": [ + { + "type": "integer", + "description": "Subscription ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Auto-renew payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.autoRenewReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/subscriptions/{id}/cancel": { + "post": { + "description": "Cancels the user's subscription", + "produces": [ + "application/json" + ], + "tags": [ + "subscriptions" + ], + "summary": "Cancel subscription", + "parameters": [ + { + "type": "integer", + "description": "Subscription ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/super-login": { "post": { "description": "Login super-admin", @@ -2687,6 +3757,725 @@ } } }, + "/api/v1/team/login": { + "post": { + "description": "Authenticate a team member (internal staff) with email and password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "Login team member", + "parameters": [ + { + "description": "Team member login credentials", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.TeamMemberLoginReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/handlers.teamMemberLoginRes" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/team/me": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Get the authenticated team member's own profile", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "Get my team profile", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/domain.TeamMemberResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/team/members": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Get a paginated list of team members with optional filtering", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "List all team members", + "parameters": [ + { + "type": "string", + "description": "Filter by team role (super_admin, admin, content_manager, support_agent, instructor, finance, hr, analyst)", + "name": "team_role", + "in": "query" + }, + { + "type": "string", + "description": "Filter by department", + "name": "department", + "in": "query" + }, + { + "type": "string", + "description": "Filter by status (active, inactive, suspended, terminated)", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Search by name, email, or phone number", + "name": "search", + "in": "query" + }, + { + "type": "integer", + "description": "Page number (default: 1)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page (default: 10, max: 100)", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.TeamMemberResponse" + } + }, + "metadata": { + "$ref": "#/definitions/domain.Pagination" + } + } + } + ] + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Create a new internal team member (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "Create a new team member", + "parameters": [ + { + "description": "Team member creation payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.CreateTeamMemberReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/domain.TeamMemberResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/team/members/{id}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Retrieve a team member's details by their ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "Get team member by ID", + "parameters": [ + { + "type": "integer", + "description": "Team Member ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/domain.TeamMemberResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Update an existing team member's details (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "Update team member", + "parameters": [ + { + "type": "integer", + "description": "Team Member ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Team member update payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.UpdateTeamMemberReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Delete a team member (super admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "Delete team member", + "parameters": [ + { + "type": "integer", + "description": "Team Member ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/team/members/{id}/change-password": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Change a team member's password (requires current password)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "Change team member password", + "parameters": [ + { + "type": "integer", + "description": "Team Member ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Password change payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.changePasswordReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/team/members/{id}/status": { + "patch": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Update a team member's status (active, inactive, suspended, terminated)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "Update team member status", + "parameters": [ + { + "type": "integer", + "description": "Team Member ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Status update payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.UpdateTeamMemberStatusReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/team/stats": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Get statistics about team members by status", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "Get team member statistics", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/domain.TeamMemberStats" + } + } + } + ] + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/tenant": { "get": { "description": "Check if phone number or email exist", @@ -3178,7 +4967,7 @@ }, "/api/v1/user/{user_id}/is-profile-completed": { "get": { - "description": "Returns whether the specified user's profile is completed", + "description": "Returns the profile completion status and percentage for the specified user", "consumes": [ "application/json" ], @@ -3299,6 +5088,366 @@ } } }, + "/api/v1/vimeo/oembed": { + "get": { + "description": "Fetches oEmbed metadata for a Vimeo video URL", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Vimeo" + ], + "summary": "Get oEmbed data for a Vimeo URL", + "parameters": [ + { + "type": "string", + "description": "Vimeo video URL", + "name": "url", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "Desired width", + "name": "width", + "in": "query" + }, + { + "type": "integer", + "description": "Desired height", + "name": "height", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/vimeo.OEmbedResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/vimeo/uploads/pull": { + "post": { + "description": "Initiates a pull upload where Vimeo fetches the video from a URL", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Vimeo" + ], + "summary": "Create a pull upload to Vimeo", + "parameters": [ + { + "description": "Pull Upload Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.CreatePullUploadRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handlers.VimeoUploadResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/vimeo/uploads/tus": { + "post": { + "description": "Initiates a TUS resumable upload and returns the upload link", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Vimeo" + ], + "summary": "Create a TUS resumable upload to Vimeo", + "parameters": [ + { + "description": "TUS Upload Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.CreateTusUploadRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handlers.VimeoUploadResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/vimeo/videos/{video_id}": { + "get": { + "description": "Retrieves video details from Vimeo by video ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Vimeo" + ], + "summary": "Get video information from Vimeo", + "parameters": [ + { + "type": "string", + "description": "Vimeo Video ID", + "name": "video_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.VimeoVideoResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Deletes a video from the Vimeo account", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Vimeo" + ], + "summary": "Delete a video from Vimeo", + "parameters": [ + { + "type": "string", + "description": "Vimeo Video ID", + "name": "video_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/vimeo/videos/{video_id}/embed": { + "get": { + "description": "Generates an embeddable player iframe for the video", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Vimeo" + ], + "summary": "Get embed code for a Vimeo video", + "parameters": [ + { + "type": "string", + "description": "Vimeo Video ID", + "name": "video_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "default": 640, + "description": "Player width", + "name": "width", + "in": "query" + }, + { + "type": "integer", + "default": 360, + "description": "Player height", + "name": "height", + "in": "query" + }, + { + "type": "boolean", + "description": "Autoplay video", + "name": "autoplay", + "in": "query" + }, + { + "type": "boolean", + "description": "Loop video", + "name": "loop", + "in": "query" + }, + { + "type": "boolean", + "description": "Mute video", + "name": "muted", + "in": "query" + }, + { + "type": "boolean", + "description": "Background mode (no controls)", + "name": "background", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.VimeoEmbedResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/vimeo/videos/{video_id}/status": { + "get": { + "description": "Returns the current transcoding status of a video", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Vimeo" + ], + "summary": "Get transcode status of a Vimeo video", + "parameters": [ + { + "type": "string", + "description": "Vimeo Video ID", + "name": "video_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/{tenant_slug}/admin-login": { "post": { "description": "Login user", @@ -3841,87 +5990,82 @@ "Age55Plus" ] }, - "domain.AssessmentQuestion": { + "domain.CreateTeamMemberReq": { "type": "object", + "required": [ + "email", + "first_name", + "last_name", + "password", + "team_role" + ], "properties": { - "created_at": { + "bio": { "type": "string" }, - "description": { + "department": { "type": "string" }, - "difficulty_level": { + "email": { "type": "string" }, - "id": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "points": { - "type": "integer" - }, - "question_type": { + "emergency_contact": { "type": "string" }, - "title": { + "employment_type": { "type": "string" }, - "updated_at": { - "type": "string" - } - } - }, - "domain.CreateAssessmentQuestionInput": { - "type": "object", - "properties": { - "correctAnswer": { - "description": "Short Answer only", + "first_name": { "type": "string" }, - "description": { + "hire_date": { + "description": "YYYY-MM-DD", "type": "string" }, - "difficultyLevel": { + "job_title": { "type": "string" }, - "isActive": { - "type": "boolean" + "last_name": { + "type": "string" }, - "options": { - "description": "Multiple Choice only", + "password": { + "type": "string", + "minLength": 8 + }, + "permissions": { "type": "array", "items": { - "$ref": "#/definitions/domain.CreateQuestionOptionInput" + "type": "string" } }, - "points": { - "type": "integer", - "format": "int32" + "phone_number": { + "type": "string" }, - "questionType": { - "$ref": "#/definitions/domain.QuestionType" + "profile_picture_url": { + "type": "string" }, - "title": { + "team_role": { + "type": "string" + }, + "work_phone": { "type": "string" } } }, - "domain.CreateQuestionOptionInput": { - "type": "object", - "properties": { - "isCorrect": { - "type": "boolean" - }, - "order": { - "type": "integer", - "format": "int32" - }, - "text": { - "type": "string" - } - } + "domain.EmploymentType": { + "type": "string", + "enum": [ + "full_time", + "part_time", + "contract", + "intern" + ], + "x-enum-varnames": [ + "EmploymentTypeFullTime", + "EmploymentTypePartTime", + "EmploymentTypeContract", + "EmploymentTypeIntern" + ] }, "domain.ErrorResponse": { "type": "object", @@ -4029,38 +6173,103 @@ "domain.QuestionOption": { "type": "object", "properties": { - "option_text": { + "createdAt": { "type": "string" }, - "question_id": { - "type": "integer" + "id": { + "type": "integer", + "format": "int64" + }, + "isCorrect": { + "type": "boolean" + }, + "optionOrder": { + "type": "integer", + "format": "int32" + }, + "optionText": { + "type": "string" + }, + "questionID": { + "type": "integer", + "format": "int64" } } }, - "domain.QuestionType": { - "type": "string", - "enum": [ - "MULTIPLE_CHOICE", - "TRUE_FALSE", - "SHORT_ANSWER" - ], - "x-enum-varnames": [ - "MultipleChoice", - "TrueFalse", - "ShortAnswer" - ] + "domain.QuestionShortAnswer": { + "type": "object", + "properties": { + "acceptableAnswer": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "matchType": { + "type": "string" + }, + "questionID": { + "type": "integer", + "format": "int64" + } + } }, "domain.QuestionWithDetails": { "type": "object", "properties": { + "createdAt": { + "type": "string" + }, + "difficultyLevel": { + "type": "string" + }, + "explanation": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int64" + }, "options": { "type": "array", "items": { "$ref": "#/definitions/domain.QuestionOption" } }, - "question": { - "$ref": "#/definitions/domain.AssessmentQuestion" + "points": { + "type": "integer", + "format": "int32" + }, + "questionText": { + "type": "string" + }, + "questionType": { + "type": "string" + }, + "sampleAnswerVoicePrompt": { + "type": "string" + }, + "shortAnswers": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.QuestionShortAnswer" + } + }, + "status": { + "type": "string" + }, + "tips": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "voicePrompt": { + "type": "string" } } }, @@ -4128,6 +6337,144 @@ "RoleSupport" ] }, + "domain.TeamMemberLoginReq": { + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "domain.TeamMemberResponse": { + "type": "object", + "properties": { + "bio": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "department": { + "type": "string" + }, + "email": { + "type": "string" + }, + "email_verified": { + "type": "boolean" + }, + "employment_type": { + "$ref": "#/definitions/domain.EmploymentType" + }, + "first_name": { + "type": "string" + }, + "hire_date": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "job_title": { + "type": "string" + }, + "last_login": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "permissions": { + "type": "array", + "items": { + "type": "string" + } + }, + "phone_number": { + "type": "string" + }, + "profile_picture_url": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/domain.TeamMemberStatus" + }, + "team_role": { + "$ref": "#/definitions/domain.TeamRole" + }, + "updated_at": { + "type": "string" + }, + "work_phone": { + "type": "string" + } + } + }, + "domain.TeamMemberStats": { + "type": "object", + "properties": { + "active_count": { + "type": "integer" + }, + "inactive_count": { + "type": "integer" + }, + "suspended_count": { + "type": "integer" + }, + "terminated_count": { + "type": "integer" + }, + "total_count": { + "type": "integer" + } + } + }, + "domain.TeamMemberStatus": { + "type": "string", + "enum": [ + "active", + "inactive", + "suspended", + "terminated" + ], + "x-enum-varnames": [ + "TeamMemberStatusActive", + "TeamMemberStatusInactive", + "TeamMemberStatusSuspended", + "TeamMemberStatusTerminated" + ] + }, + "domain.TeamRole": { + "type": "string", + "enum": [ + "super_admin", + "admin", + "content_manager", + "support_agent", + "instructor", + "finance", + "hr", + "analyst" + ], + "x-enum-varnames": [ + "TeamRoleSuperAdmin", + "TeamRoleAdmin", + "TeamRoleContentManager", + "TeamRoleSupportAgent", + "TeamRoleInstructor", + "TeamRoleFinance", + "TeamRoleHR", + "TeamRoleAnalyst" + ] + }, "domain.UpdateKnowledgeLevelReq": { "type": "object", "properties": { @@ -4140,6 +6487,64 @@ } } }, + "domain.UpdateTeamMemberReq": { + "type": "object", + "properties": { + "bio": { + "type": "string" + }, + "department": { + "type": "string" + }, + "emergency_contact": { + "type": "string" + }, + "employment_type": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "hire_date": { + "type": "string" + }, + "job_title": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "permissions": { + "type": "array", + "items": { + "type": "string" + } + }, + "phone_number": { + "type": "string" + }, + "profile_picture_url": { + "type": "string" + }, + "team_role": { + "type": "string" + }, + "work_phone": { + "type": "string" + } + } + }, + "domain.UpdateTeamMemberStatusReq": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string" + } + } + }, "domain.UpdateUserReq": { "type": "object", "properties": { @@ -4192,9 +6597,6 @@ "preferred_language": { "type": "string" }, - "profile_completed": { - "type": "boolean" - }, "profile_picture_url": { "type": "string" }, @@ -4278,6 +6680,9 @@ "profile_completed": { "type": "boolean" }, + "profile_completion_percentage": { + "type": "integer" + }, "profile_picture_url": { "type": "string" }, @@ -4327,6 +6732,46 @@ } } }, + "domain.WebhookRequest": { + "type": "object", + "properties": { + "nonce": { + "type": "string" + }, + "notificationUrl": { + "type": "string" + }, + "paymentMethod": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "totalAmount": { + "type": "integer" + }, + "transaction": { + "type": "object", + "properties": { + "transactionId": { + "type": "string" + }, + "transactionStatus": { + "type": "string" + } + } + }, + "transactionStatus": { + "type": "string" + }, + "uuid": { + "type": "string" + } + } + }, "handlers.AdminProfileRes": { "type": "object", "properties": { @@ -4464,6 +6909,46 @@ } } }, + "handlers.CreatePullUploadRequest": { + "type": "object", + "required": [ + "file_size", + "name", + "source_url" + ], + "properties": { + "description": { + "type": "string" + }, + "file_size": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "source_url": { + "type": "string" + } + } + }, + "handlers.CreateTusUploadRequest": { + "type": "object", + "required": [ + "file_size", + "name" + ], + "properties": { + "description": { + "type": "string" + }, + "file_size": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + }, "handlers.LoginAdminRes": { "type": "object", "properties": { @@ -4535,6 +7020,138 @@ } } }, + "handlers.VimeoEmbedResponse": { + "type": "object", + "properties": { + "embed_html": { + "type": "string" + }, + "embed_url": { + "type": "string" + }, + "video_id": { + "type": "string" + } + } + }, + "handlers.VimeoUploadResponse": { + "type": "object", + "properties": { + "link": { + "type": "string" + }, + "status": { + "type": "string" + }, + "upload_link": { + "type": "string" + }, + "uri": { + "type": "string" + }, + "vimeo_id": { + "type": "string" + } + } + }, + "handlers.VimeoVideoResponse": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "duration": { + "type": "integer" + }, + "embed_html": { + "type": "string" + }, + "embed_url": { + "type": "string" + }, + "height": { + "type": "integer" + }, + "link": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "thumbnail_url": { + "type": "string" + }, + "transcode_status": { + "type": "string" + }, + "uri": { + "type": "string" + }, + "vimeo_id": { + "type": "string" + }, + "width": { + "type": "integer" + } + } + }, + "handlers.addQuestionToSetReq": { + "type": "object", + "required": [ + "question_id" + ], + "properties": { + "display_order": { + "type": "integer" + }, + "question_id": { + "type": "integer" + } + } + }, + "handlers.addUserPersonaReq": { + "type": "object", + "required": [ + "user_id" + ], + "properties": { + "display_order": { + "type": "integer" + }, + "user_id": { + "type": "integer" + } + } + }, + "handlers.autoRenewReq": { + "type": "object", + "properties": { + "auto_renew": { + "type": "boolean" + } + } + }, + "handlers.changePasswordReq": { + "type": "object", + "required": [ + "current_password", + "new_password" + ], + "properties": { + "current_password": { + "type": "string", + "example": "oldpassword123" + }, + "new_password": { + "type": "string", + "minLength": 8, + "example": "newpassword123" + } + } + }, "handlers.createCourseCategoryReq": { "type": "object", "required": [ @@ -4559,29 +7176,153 @@ "description": { "type": "string" }, + "thumbnail": { + "type": "string" + }, "title": { "type": "string" } } }, - "handlers.createLevelReq": { + "handlers.createPlanReq": { "type": "object", "required": [ - "level_index", - "program_id", - "title" + "currency", + "duration_unit", + "duration_value", + "name", + "price" ], "properties": { + "currency": { + "type": "string" + }, "description": { "type": "string" }, + "duration_unit": { + "type": "string", + "enum": [ + "DAY", + "WEEK", + "MONTH", + "YEAR" + ] + }, + "duration_value": { + "type": "integer", + "minimum": 1 + }, "is_active": { "type": "boolean" }, - "level_index": { + "name": { + "type": "string" + }, + "price": { + "type": "number", + "minimum": 0 + } + } + }, + "handlers.createQuestionReq": { + "type": "object", + "required": [ + "question_text", + "question_type" + ], + "properties": { + "difficulty_level": { + "type": "string" + }, + "explanation": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.optionInput" + } + }, + "points": { "type": "integer" }, - "program_id": { + "question_text": { + "type": "string" + }, + "question_type": { + "type": "string", + "enum": [ + "MCQ", + "TRUE_FALSE", + "SHORT_ANSWER" + ] + }, + "sample_answer_voice_prompt": { + "type": "string" + }, + "short_answers": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.shortAnswerInput" + } + }, + "status": { + "type": "string" + }, + "tips": { + "type": "string" + }, + "voice_prompt": { + "type": "string" + } + } + }, + "handlers.createQuestionSetReq": { + "type": "object", + "required": [ + "set_type", + "title" + ], + "properties": { + "banner_image": { + "type": "string" + }, + "description": { + "type": "string" + }, + "owner_id": { + "type": "integer" + }, + "owner_type": { + "type": "string" + }, + "passing_score": { + "type": "integer" + }, + "persona": { + "type": "string" + }, + "set_type": { + "type": "string", + "enum": [ + "PRACTICE", + "INITIAL_ASSESSMENT", + "QUIZ", + "EXAM", + "SURVEY" + ] + }, + "shuffle_questions": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "sub_course_video_id": { + "type": "integer" + }, + "time_limit_minutes": { "type": "integer" }, "title": { @@ -4589,32 +7330,40 @@ } } }, - "handlers.createModuleReq": { + "handlers.createSubCourseReq": { "type": "object", "required": [ - "level_id", + "course_id", + "level", "title" ], "properties": { - "content": { + "course_id": { + "type": "integer" + }, + "description": { "type": "string" }, "display_order": { "type": "integer" }, - "level_id": { - "type": "integer" + "level": { + "description": "BEGINNER, INTERMEDIATE, ADVANCED", + "type": "string" + }, + "thumbnail": { + "type": "string" }, "title": { "type": "string" } } }, - "handlers.createModuleVideoReq": { + "handlers.createSubCourseVideoReq": { "type": "object", "required": [ "duration", - "module_id", + "sub_course_id", "title", "video_url" ], @@ -4622,18 +7371,25 @@ "description": { "type": "string" }, + "display_order": { + "type": "integer" + }, "duration": { "type": "integer" }, "instructor_id": { "type": "string" }, - "module_id": { - "type": "integer" - }, "resolution": { "type": "string" }, + "status": { + "description": "DRAFT, PUBLISHED, INACTIVE, ARCHIVED", + "type": "string" + }, + "sub_course_id": { + "type": "integer" + }, "thumbnail": { "type": "string" }, @@ -4648,89 +7404,117 @@ } } }, - "handlers.createPracticeQuestionReq": { + "handlers.createVideoFromVimeoIDReq": { "type": "object", "required": [ - "practice_id", - "q_type", - "question" + "sub_course_id", + "title", + "vimeo_video_id" ], "properties": { - "practice_id": { - "type": "integer" - }, - "q_type": { - "type": "string" - }, - "question": { - "type": "string" - }, - "question_voice_prompt": { - "type": "string" - }, - "sample_answer": { - "type": "string" - }, - "sample_answer_voice_prompt": { - "type": "string" - }, - "tips": { - "type": "string" - } - } - }, - "handlers.createPracticeReq": { - "type": "object", - "required": [ - "owner_id", - "owner_type", - "title" - ], - "properties": { - "banner_image": { - "type": "string" - }, - "description": { - "type": "string" - }, - "is_active": { - "type": "boolean" - }, - "owner_id": { - "type": "integer" - }, - "owner_type": { - "type": "string" - }, - "persona": { - "type": "string" - }, - "title": { - "type": "string" - } - } - }, - "handlers.createProgramReq": { - "type": "object", - "required": [ - "course_id", - "title" - ], - "properties": { - "course_id": { - "type": "integer" - }, "description": { "type": "string" }, "display_order": { "type": "integer" }, + "instructor_id": { + "type": "string" + }, + "sub_course_id": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "vimeo_video_id": { + "type": "string" + } + } + }, + "handlers.createVimeoVideoReq": { + "type": "object", + "required": [ + "file_size", + "source_url", + "sub_course_id", + "title" + ], + "properties": { + "description": { + "type": "string" + }, + "display_order": { + "type": "integer" + }, + "duration": { + "type": "integer" + }, + "file_size": { + "type": "integer" + }, + "instructor_id": { + "type": "string" + }, + "resolution": { + "type": "string" + }, + "source_url": { + "type": "string" + }, + "sub_course_id": { + "type": "integer" + }, "thumbnail": { "type": "string" }, "title": { "type": "string" + }, + "visibility": { + "type": "string" + } + } + }, + "handlers.initiateDirectPaymentReq": { + "type": "object", + "required": [ + "email", + "payment_method", + "phone", + "plan_id" + ], + "properties": { + "email": { + "type": "string" + }, + "payment_method": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "plan_id": { + "type": "integer" + } + } + }, + "handlers.initiatePaymentReq": { + "type": "object", + "required": [ + "email", + "phone", + "plan_id" + ], + "properties": { + "email": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "plan_id": { + "type": "integer" } } }, @@ -4757,12 +7541,33 @@ "refresh_token" ], "properties": { + "device_token": { + "type": "string", + "example": "\u003cfcm-device-token\u003e" + }, "refresh_token": { "type": "string", "example": "\u003crefresh-token\u003e" } } }, + "handlers.optionInput": { + "type": "object", + "required": [ + "option_text" + ], + "properties": { + "is_correct": { + "type": "boolean" + }, + "option_order": { + "type": "integer" + }, + "option_text": { + "type": "string" + } + } + }, "handlers.refreshToken": { "type": "object", "required": [ @@ -4780,6 +7585,77 @@ } } }, + "handlers.shortAnswerInput": { + "type": "object", + "required": [ + "acceptable_answer" + ], + "properties": { + "acceptable_answer": { + "type": "string" + }, + "match_type": { + "type": "string" + } + } + }, + "handlers.subscribeReq": { + "type": "object", + "required": [ + "plan_id" + ], + "properties": { + "payment_method": { + "type": "string" + }, + "payment_reference": { + "type": "string" + }, + "plan_id": { + "type": "integer" + } + } + }, + "handlers.subscribeWithPaymentReq": { + "type": "object", + "required": [ + "email", + "phone", + "plan_id" + ], + "properties": { + "email": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "plan_id": { + "type": "integer" + } + } + }, + "handlers.teamMemberLoginRes": { + "type": "object", + "properties": { + "access_token": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + }, + "member_id": { + "type": "integer", + "example": 1 + }, + "refresh_token": { + "type": "string", + "example": "" + }, + "team_role": { + "type": "string", + "example": "admin" + } + } + }, "handlers.updateAdminReq": { "type": "object", "properties": { @@ -4817,21 +7693,120 @@ "is_active": { "type": "boolean" }, + "thumbnail": { + "type": "string" + }, "title": { "type": "string" } } }, - "handlers.updateLevelReq": { + "handlers.updatePlanReq": { "type": "object", "properties": { + "currency": { + "type": "string" + }, "description": { "type": "string" }, + "duration_unit": { + "type": "string" + }, + "duration_value": { + "type": "integer" + }, "is_active": { "type": "boolean" }, - "level_index": { + "name": { + "type": "string" + }, + "price": { + "type": "number" + } + } + }, + "handlers.updateQuestionOrderReq": { + "type": "object", + "required": [ + "display_order" + ], + "properties": { + "display_order": { + "type": "integer" + } + } + }, + "handlers.updateQuestionReq": { + "type": "object", + "properties": { + "difficulty_level": { + "type": "string" + }, + "explanation": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.optionInput" + } + }, + "points": { + "type": "integer" + }, + "question_text": { + "type": "string" + }, + "question_type": { + "type": "string" + }, + "sample_answer_voice_prompt": { + "type": "string" + }, + "short_answers": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.shortAnswerInput" + } + }, + "status": { + "type": "string" + }, + "tips": { + "type": "string" + }, + "voice_prompt": { + "type": "string" + } + } + }, + "handlers.updateQuestionSetReq": { + "type": "object", + "properties": { + "banner_image": { + "type": "string" + }, + "description": { + "type": "string" + }, + "passing_score": { + "type": "integer" + }, + "persona": { + "type": "string" + }, + "shuffle_questions": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "sub_course_video_id": { + "type": "integer" + }, + "time_limit_minutes": { "type": "integer" }, "title": { @@ -4839,10 +7814,10 @@ } } }, - "handlers.updateModuleReq": { + "handlers.updateSubCourseReq": { "type": "object", "properties": { - "content": { + "description": { "type": "string" }, "display_order": { @@ -4851,26 +7826,36 @@ "is_active": { "type": "boolean" }, + "level": { + "type": "string" + }, + "thumbnail": { + "type": "string" + }, "title": { "type": "string" } } }, - "handlers.updateModuleVideoReq": { + "handlers.updateSubCourseVideoReq": { "type": "object", "properties": { "description": { "type": "string" }, + "display_order": { + "type": "integer" + }, "duration": { "type": "integer" }, - "is_active": { - "type": "boolean" - }, "resolution": { "type": "string" }, + "status": { + "description": "DRAFT, PUBLISHED, INACTIVE, ARCHIVED", + "type": "string" + }, "thumbnail": { "type": "string" }, @@ -4885,86 +7870,17 @@ } } }, - "handlers.updatePracticeQuestionReq": { - "type": "object", - "properties": { - "q_type": { - "type": "string" - }, - "question": { - "type": "string" - }, - "sample_answer": { - "type": "string" - }, - "tips": { - "type": "string" - } - } - }, - "handlers.updatePracticeReq": { - "type": "object", - "properties": { - "banner_image": { - "type": "string" - }, - "description": { - "type": "string" - }, - "is_active": { - "type": "boolean" - }, - "persona": { - "type": "string" - }, - "title": { - "type": "string" - } - } - }, - "handlers.updateProgramFullReq": { + "handlers.verifyOTPReq": { "type": "object", "required": [ - "course_id", - "title" + "otp", + "session_id" ], "properties": { - "course_id": { - "type": "integer" - }, - "description": { + "otp": { "type": "string" }, - "display_order": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "thumbnail": { - "type": "string" - }, - "title": { - "type": "string" - } - } - }, - "handlers.updateProgramPartialReq": { - "type": "object", - "properties": { - "description": { - "type": "string" - }, - "display_order": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "thumbnail": { - "type": "string" - }, - "title": { + "session_id": { "type": "string" } } @@ -5001,6 +7917,62 @@ "Error", "Success" ] + }, + "vimeo.OEmbedResponse": { + "type": "object", + "properties": { + "author_name": { + "type": "string" + }, + "author_url": { + "type": "string" + }, + "description": { + "type": "string" + }, + "duration": { + "type": "integer" + }, + "height": { + "type": "integer" + }, + "html": { + "type": "string" + }, + "is_plus": { + "type": "string" + }, + "provider_name": { + "type": "string" + }, + "provider_url": { + "type": "string" + }, + "thumbnail_height": { + "type": "integer" + }, + "thumbnail_url": { + "type": "string" + }, + "thumbnail_width": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "version": { + "type": "string" + }, + "video_id": { + "type": "integer" + }, + "width": { + "type": "integer" + } + } } }, "securityDefinitions": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 63597aa..9891633 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -17,61 +17,61 @@ definitions: - Age35To44 - Age45To54 - Age55Plus - domain.AssessmentQuestion: + domain.CreateTeamMemberReq: properties: - created_at: + bio: type: string - description: + department: type: string - difficulty_level: + email: type: string - id: - type: integer - is_active: - type: boolean - points: - type: integer - question_type: + emergency_contact: type: string - title: + employment_type: type: string - updated_at: + first_name: type: string - type: object - domain.CreateAssessmentQuestionInput: - properties: - correctAnswer: - description: Short Answer only + hire_date: + description: YYYY-MM-DD type: string - description: + job_title: type: string - difficultyLevel: + last_name: type: string - isActive: - type: boolean - options: - description: Multiple Choice only + password: + minLength: 8 + type: string + permissions: items: - $ref: '#/definitions/domain.CreateQuestionOptionInput' + type: string type: array - points: - format: int32 - type: integer - questionType: - $ref: '#/definitions/domain.QuestionType' - title: + phone_number: type: string - type: object - domain.CreateQuestionOptionInput: - properties: - isCorrect: - type: boolean - order: - format: int32 - type: integer - text: + profile_picture_url: type: string + team_role: + type: string + work_phone: + type: string + required: + - email + - first_name + - last_name + - password + - team_role type: object + domain.EmploymentType: + enum: + - full_time + - part_time + - contract + - intern + type: string + x-enum-varnames: + - EmploymentTypeFullTime + - EmploymentTypePartTime + - EmploymentTypeContract + - EmploymentTypeIntern domain.ErrorResponse: properties: error: @@ -142,29 +142,73 @@ definitions: type: object domain.QuestionOption: properties: - option_text: + createdAt: type: string - question_id: + id: + format: int64 + type: integer + isCorrect: + type: boolean + optionOrder: + format: int32 + type: integer + optionText: + type: string + questionID: + format: int64 + type: integer + type: object + domain.QuestionShortAnswer: + properties: + acceptableAnswer: + type: string + createdAt: + type: string + id: + format: int64 + type: integer + matchType: + type: string + questionID: + format: int64 type: integer type: object - domain.QuestionType: - enum: - - MULTIPLE_CHOICE - - TRUE_FALSE - - SHORT_ANSWER - type: string - x-enum-varnames: - - MultipleChoice - - TrueFalse - - ShortAnswer domain.QuestionWithDetails: properties: + createdAt: + type: string + difficultyLevel: + type: string + explanation: + type: string + id: + format: int64 + type: integer options: items: $ref: '#/definitions/domain.QuestionOption' type: array - question: - $ref: '#/definitions/domain.AssessmentQuestion' + points: + format: int32 + type: integer + questionText: + type: string + questionType: + type: string + sampleAnswerVoicePrompt: + type: string + shortAnswers: + items: + $ref: '#/definitions/domain.QuestionShortAnswer' + type: array + status: + type: string + tips: + type: string + updatedAt: + type: string + voicePrompt: + type: string type: object domain.RegisterUserReq: properties: @@ -211,6 +255,104 @@ definitions: - RoleStudent - RoleInstructor - RoleSupport + domain.TeamMemberLoginReq: + properties: + email: + type: string + password: + type: string + required: + - email + - password + type: object + domain.TeamMemberResponse: + properties: + bio: + type: string + created_at: + type: string + department: + type: string + email: + type: string + email_verified: + type: boolean + employment_type: + $ref: '#/definitions/domain.EmploymentType' + first_name: + type: string + hire_date: + type: string + id: + type: integer + job_title: + type: string + last_login: + type: string + last_name: + type: string + permissions: + items: + type: string + type: array + phone_number: + type: string + profile_picture_url: + type: string + status: + $ref: '#/definitions/domain.TeamMemberStatus' + team_role: + $ref: '#/definitions/domain.TeamRole' + updated_at: + type: string + work_phone: + type: string + type: object + domain.TeamMemberStats: + properties: + active_count: + type: integer + inactive_count: + type: integer + suspended_count: + type: integer + terminated_count: + type: integer + total_count: + type: integer + type: object + domain.TeamMemberStatus: + enum: + - active + - inactive + - suspended + - terminated + type: string + x-enum-varnames: + - TeamMemberStatusActive + - TeamMemberStatusInactive + - TeamMemberStatusSuspended + - TeamMemberStatusTerminated + domain.TeamRole: + enum: + - super_admin + - admin + - content_manager + - support_agent + - instructor + - finance + - hr + - analyst + type: string + x-enum-varnames: + - TeamRoleSuperAdmin + - TeamRoleAdmin + - TeamRoleContentManager + - TeamRoleSupportAgent + - TeamRoleInstructor + - TeamRoleFinance + - TeamRoleHR + - TeamRoleAnalyst domain.UpdateKnowledgeLevelReq: properties: knowledge_level: @@ -219,6 +361,44 @@ definitions: user_id: type: integer type: object + domain.UpdateTeamMemberReq: + properties: + bio: + type: string + department: + type: string + emergency_contact: + type: string + employment_type: + type: string + first_name: + type: string + hire_date: + type: string + job_title: + type: string + last_name: + type: string + permissions: + items: + type: string + type: array + phone_number: + type: string + profile_picture_url: + type: string + team_role: + type: string + work_phone: + type: string + type: object + domain.UpdateTeamMemberStatusReq: + properties: + status: + type: string + required: + - status + type: object domain.UpdateUserReq: properties: age_group: @@ -254,8 +434,6 @@ definitions: type: string preferred_language: type: string - profile_completed: - type: boolean profile_picture_url: type: string region: @@ -312,6 +490,8 @@ definitions: type: string profile_completed: type: boolean + profile_completion_percentage: + type: integer profile_picture_url: type: string region: @@ -346,6 +526,32 @@ definitions: required: - otp type: object + domain.WebhookRequest: + properties: + nonce: + type: string + notificationUrl: + type: string + paymentMethod: + type: string + phone: + type: string + sessionId: + type: string + totalAmount: + type: integer + transaction: + properties: + transactionId: + type: string + transactionStatus: + type: string + type: object + transactionStatus: + type: string + uuid: + type: string + type: object handlers.AdminProfileRes: properties: created_at: @@ -438,6 +644,33 @@ definitions: example: "1234567890" type: string type: object + handlers.CreatePullUploadRequest: + properties: + description: + type: string + file_size: + type: integer + name: + type: string + source_url: + type: string + required: + - file_size + - name + - source_url + type: object + handlers.CreateTusUploadRequest: + properties: + description: + type: string + file_size: + type: integer + name: + type: string + required: + - file_size + - name + type: object handlers.LoginAdminRes: properties: access_token: @@ -486,6 +719,93 @@ definitions: - message - recipient type: object + handlers.VimeoEmbedResponse: + properties: + embed_html: + type: string + embed_url: + type: string + video_id: + type: string + type: object + handlers.VimeoUploadResponse: + properties: + link: + type: string + status: + type: string + upload_link: + type: string + uri: + type: string + vimeo_id: + type: string + type: object + handlers.VimeoVideoResponse: + properties: + description: + type: string + duration: + type: integer + embed_html: + type: string + embed_url: + type: string + height: + type: integer + link: + type: string + name: + type: string + status: + type: string + thumbnail_url: + type: string + transcode_status: + type: string + uri: + type: string + vimeo_id: + type: string + width: + type: integer + type: object + handlers.addQuestionToSetReq: + properties: + display_order: + type: integer + question_id: + type: integer + required: + - question_id + type: object + handlers.addUserPersonaReq: + properties: + display_order: + type: integer + user_id: + type: integer + required: + - user_id + type: object + handlers.autoRenewReq: + properties: + auto_renew: + type: boolean + type: object + handlers.changePasswordReq: + properties: + current_password: + example: oldpassword123 + type: string + new_password: + example: newpassword123 + minLength: 8 + type: string + required: + - current_password + - new_password + type: object handlers.createCourseCategoryReq: properties: name: @@ -499,55 +819,153 @@ definitions: type: integer description: type: string + thumbnail: + type: string title: type: string required: - category_id - title type: object - handlers.createLevelReq: + handlers.createPlanReq: properties: + currency: + type: string description: type: string + duration_unit: + enum: + - DAY + - WEEK + - MONTH + - YEAR + type: string + duration_value: + minimum: 1 + type: integer is_active: type: boolean - level_index: + name: + type: string + price: + minimum: 0 + type: number + required: + - currency + - duration_unit + - duration_value + - name + - price + type: object + handlers.createQuestionReq: + properties: + difficulty_level: + type: string + explanation: + type: string + options: + items: + $ref: '#/definitions/handlers.optionInput' + type: array + points: type: integer - program_id: + question_text: + type: string + question_type: + enum: + - MCQ + - TRUE_FALSE + - SHORT_ANSWER + type: string + sample_answer_voice_prompt: + type: string + short_answers: + items: + $ref: '#/definitions/handlers.shortAnswerInput' + type: array + status: + type: string + tips: + type: string + voice_prompt: + type: string + required: + - question_text + - question_type + type: object + handlers.createQuestionSetReq: + properties: + banner_image: + type: string + description: + type: string + owner_id: + type: integer + owner_type: + type: string + passing_score: + type: integer + persona: + type: string + set_type: + enum: + - PRACTICE + - INITIAL_ASSESSMENT + - QUIZ + - EXAM + - SURVEY + type: string + shuffle_questions: + type: boolean + status: + type: string + sub_course_video_id: + type: integer + time_limit_minutes: type: integer title: type: string required: - - level_index - - program_id + - set_type - title type: object - handlers.createModuleReq: + handlers.createSubCourseReq: properties: - content: + course_id: + type: integer + description: type: string display_order: type: integer - level_id: - type: integer + level: + description: BEGINNER, INTERMEDIATE, ADVANCED + type: string + thumbnail: + type: string title: type: string required: - - level_id + - course_id + - level - title type: object - handlers.createModuleVideoReq: + handlers.createSubCourseVideoReq: properties: description: type: string + display_order: + type: integer duration: type: integer instructor_id: type: string - module_id: - type: integer resolution: type: string + status: + description: DRAFT, PUBLISHED, INACTIVE, ARCHIVED + type: string + sub_course_id: + type: integer thumbnail: type: string title: @@ -558,68 +976,88 @@ definitions: type: string required: - duration - - module_id + - sub_course_id - title - video_url type: object - handlers.createPracticeQuestionReq: + handlers.createVideoFromVimeoIDReq: properties: - practice_id: - type: integer - q_type: - type: string - question: - type: string - question_voice_prompt: - type: string - sample_answer: - type: string - sample_answer_voice_prompt: - type: string - tips: - type: string - required: - - practice_id - - q_type - - question - type: object - handlers.createPracticeReq: - properties: - banner_image: - type: string - description: - type: string - is_active: - type: boolean - owner_id: - type: integer - owner_type: - type: string - persona: - type: string - title: - type: string - required: - - owner_id - - owner_type - - title - type: object - handlers.createProgramReq: - properties: - course_id: - type: integer description: type: string display_order: type: integer + instructor_id: + type: string + sub_course_id: + type: integer + title: + type: string + vimeo_video_id: + type: string + required: + - sub_course_id + - title + - vimeo_video_id + type: object + handlers.createVimeoVideoReq: + properties: + description: + type: string + display_order: + type: integer + duration: + type: integer + file_size: + type: integer + instructor_id: + type: string + resolution: + type: string + source_url: + type: string + sub_course_id: + type: integer thumbnail: type: string title: type: string + visibility: + type: string required: - - course_id + - file_size + - source_url + - sub_course_id - title type: object + handlers.initiateDirectPaymentReq: + properties: + email: + type: string + payment_method: + type: string + phone: + type: string + plan_id: + type: integer + required: + - email + - payment_method + - phone + - plan_id + type: object + handlers.initiatePaymentReq: + properties: + email: + type: string + phone: + type: string + plan_id: + type: integer + required: + - email + - phone + - plan_id + type: object handlers.loginUserRes: properties: access_token: @@ -633,12 +1071,26 @@ definitions: type: object handlers.logoutReq: properties: + device_token: + example: + type: string refresh_token: example: type: string required: - refresh_token type: object + handlers.optionInput: + properties: + is_correct: + type: boolean + option_order: + type: integer + option_text: + type: string + required: + - option_text + type: object handlers.refreshToken: properties: access_token: @@ -651,6 +1103,54 @@ definitions: - access_token - refresh_token type: object + handlers.shortAnswerInput: + properties: + acceptable_answer: + type: string + match_type: + type: string + required: + - acceptable_answer + type: object + handlers.subscribeReq: + properties: + payment_method: + type: string + payment_reference: + type: string + plan_id: + type: integer + required: + - plan_id + type: object + handlers.subscribeWithPaymentReq: + properties: + email: + type: string + phone: + type: string + plan_id: + type: integer + required: + - email + - phone + - plan_id + type: object + handlers.teamMemberLoginRes: + properties: + access_token: + example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... + type: string + member_id: + example: 1 + type: integer + refresh_token: + example: "" + type: string + team_role: + example: admin + type: string + type: object handlers.updateAdminReq: properties: first_name: @@ -676,41 +1176,113 @@ definitions: type: string is_active: type: boolean + thumbnail: + type: string title: type: string type: object - handlers.updateLevelReq: + handlers.updatePlanReq: properties: + currency: + type: string description: type: string + duration_unit: + type: string + duration_value: + type: integer is_active: type: boolean - level_index: + name: + type: string + price: + type: number + type: object + handlers.updateQuestionOrderReq: + properties: + display_order: + type: integer + required: + - display_order + type: object + handlers.updateQuestionReq: + properties: + difficulty_level: + type: string + explanation: + type: string + options: + items: + $ref: '#/definitions/handlers.optionInput' + type: array + points: + type: integer + question_text: + type: string + question_type: + type: string + sample_answer_voice_prompt: + type: string + short_answers: + items: + $ref: '#/definitions/handlers.shortAnswerInput' + type: array + status: + type: string + tips: + type: string + voice_prompt: + type: string + type: object + handlers.updateQuestionSetReq: + properties: + banner_image: + type: string + description: + type: string + passing_score: + type: integer + persona: + type: string + shuffle_questions: + type: boolean + status: + type: string + sub_course_video_id: + type: integer + time_limit_minutes: type: integer title: type: string type: object - handlers.updateModuleReq: + handlers.updateSubCourseReq: properties: - content: + description: type: string display_order: type: integer is_active: type: boolean + level: + type: string + thumbnail: + type: string title: type: string type: object - handlers.updateModuleVideoReq: + handlers.updateSubCourseVideoReq: properties: description: type: string + display_order: + type: integer duration: type: integer - is_active: - type: boolean resolution: type: string + status: + description: DRAFT, PUBLISHED, INACTIVE, ARCHIVED + type: string thumbnail: type: string title: @@ -720,60 +1292,15 @@ definitions: visibility: type: string type: object - handlers.updatePracticeQuestionReq: + handlers.verifyOTPReq: properties: - q_type: + otp: type: string - question: - type: string - sample_answer: - type: string - tips: - type: string - type: object - handlers.updatePracticeReq: - properties: - banner_image: - type: string - description: - type: string - is_active: - type: boolean - persona: - type: string - title: - type: string - type: object - handlers.updateProgramFullReq: - properties: - course_id: - type: integer - description: - type: string - display_order: - type: integer - is_active: - type: boolean - thumbnail: - type: string - title: + session_id: type: string required: - - course_id - - title - type: object - handlers.updateProgramPartialReq: - properties: - description: - type: string - display_order: - type: integer - is_active: - type: boolean - thumbnail: - type: string - title: - type: string + - otp + - session_id type: object response.APIResponse: properties: @@ -798,6 +1325,43 @@ definitions: x-enum-varnames: - Error - Success + vimeo.OEmbedResponse: + properties: + author_name: + type: string + author_url: + type: string + description: + type: string + duration: + type: integer + height: + type: integer + html: + type: string + is_plus: + type: string + provider_name: + type: string + provider_url: + type: string + thumbnail_height: + type: integer + thumbnail_url: + type: string + thumbnail_width: + type: integer + title: + type: string + type: + type: string + version: + type: string + video_id: + type: integer + width: + type: integer + type: object info: contact: email: support@swagger.io @@ -1286,7 +1850,8 @@ paths: - admin /api/v1/assessment/questions: get: - description: Returns all active assessment questions with their options or answers + description: Returns all active assessment questions from the initial assessment + set produces: - application/json responses: @@ -1306,15 +1871,14 @@ paths: post: consumes: - application/json - description: Creates a new assessment question with options or short answer - depending on question type + description: Creates a new assessment question using the unified questions system parameters: - description: Create question payload in: body name: body required: true schema: - $ref: '#/definitions/domain.CreateAssessmentQuestionInput' + $ref: '#/definitions/handlers.createQuestionReq' produces: - application/json responses: @@ -1676,9 +2240,9 @@ paths: summary: Create a new course tags: - courses - /api/v1/course-management/courses/{courseId}/programs: + /api/v1/course-management/courses/{courseId}/sub-courses: get: - description: Returns all programs under a specific course with total count + description: Returns all sub-courses under a specific course parameters: - description: Course ID in: path @@ -1700,12 +2264,12 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.ErrorResponse' - summary: Get programs by course + summary: Get sub-courses by course tags: - - programs - /api/v1/course-management/courses/{courseId}/programs/list: + - sub-courses + /api/v1/course-management/courses/{courseId}/sub-courses/list: get: - description: Returns a simple list of programs under a specific course + description: Returns a list of active sub-courses under a specific course parameters: - description: Course ID in: path @@ -1727,9 +2291,9 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.ErrorResponse' - summary: List programs by course + summary: List active sub-courses by course tags: - - programs + - sub-courses /api/v1/course-management/courses/{id}: delete: description: Deletes a course by its ID @@ -1823,8 +2387,7 @@ paths: - courses /api/v1/course-management/learning-tree: get: - description: Returns the complete learning tree structure with courses, programs, - levels, and modules + description: Returns the complete learning tree structure with courses and sub-courses produces: - application/json responses: @@ -1839,18 +2402,18 @@ paths: summary: Get full learning tree tags: - learning-tree - /api/v1/course-management/levels: + /api/v1/course-management/sub-courses: post: consumes: - application/json - description: Creates a new level under a specific program + description: Creates a new sub-course under a specific course parameters: - - description: Create level payload + - description: Create sub-course payload in: body name: body required: true schema: - $ref: '#/definitions/handlers.createLevelReq' + $ref: '#/definitions/handlers.createSubCourseReq' produces: - application/json responses: @@ -1866,214 +2429,14 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.ErrorResponse' - summary: Create a new level + summary: Create a new sub-course tags: - - levels - /api/v1/course-management/levels/{id}: - put: - consumes: - - application/json - description: Updates a level's title, description, index, and/or active status - parameters: - - description: Level ID - in: path - name: id - required: true - type: integer - - description: Update level payload - in: body - name: body - required: true - schema: - $ref: '#/definitions/handlers.updateLevelReq' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Update level - tags: - - levels - /api/v1/course-management/levels/{levelId}: + - sub-courses + /api/v1/course-management/sub-courses/{id}: delete: - description: Deletes a level by its ID + description: Deletes a sub-course by its ID parameters: - - description: Level ID - in: path - name: levelId - required: true - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Delete level - tags: - - levels - /api/v1/course-management/levels/{levelId}/increment-module: - put: - description: Increments the module count for a specific level - parameters: - - description: Level ID - in: path - name: levelId - required: true - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Increment level module count - tags: - - levels - /api/v1/course-management/levels/{levelId}/increment-practice: - put: - description: Increments the practice count for a specific level - parameters: - - description: Level ID - in: path - name: levelId - required: true - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Increment level practice count - tags: - - levels - /api/v1/course-management/levels/{levelId}/increment-video: - put: - description: Increments the video count for a specific level - parameters: - - description: Level ID - in: path - name: levelId - required: true - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Increment level video count - tags: - - levels - /api/v1/course-management/levels/{levelId}/modules: - get: - description: Returns a paginated list of modules under a specific level - parameters: - - description: Level ID - in: path - name: levelId - required: true - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Get modules by level - tags: - - modules - /api/v1/course-management/modules: - post: - consumes: - - application/json - description: Creates a new module under a specific level - parameters: - - description: Create module payload - in: body - name: body - required: true - schema: - $ref: '#/definitions/handlers.createModuleReq' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Create a new module - tags: - - modules - /api/v1/course-management/modules/{id}: - delete: - description: Deletes a module by its ID - parameters: - - description: Module ID + - description: Sub-course ID in: path name: id required: true @@ -2093,282 +2456,13 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.ErrorResponse' - summary: Delete module + summary: Delete sub-course tags: - - modules - put: - consumes: - - application/json - description: Updates a module's title, content, display order, and/or active - status - parameters: - - description: Module ID - in: path - name: id - required: true - type: integer - - description: Update module payload - in: body - name: body - required: true - schema: - $ref: '#/definitions/handlers.updateModuleReq' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Update module - tags: - - modules - /api/v1/course-management/modules/{moduleId}/videos/published: + - sub-courses get: - description: Returns all published videos under a specific module + description: Returns a single sub-course by its ID parameters: - - description: Module ID - in: path - name: moduleId - required: true - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Get published videos by module - tags: - - module-videos - /api/v1/course-management/owners/{ownerType}/{ownerId}/practices: - get: - description: Returns all practices for a specific owner type and ID - parameters: - - description: Owner Type - in: path - name: ownerType - required: true - type: string - - description: Owner ID - in: path - name: ownerId - required: true - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Get practices by owner - tags: - - practices - /api/v1/course-management/practices: - post: - consumes: - - application/json - description: Creates a new practice for a specific owner (module or level) - parameters: - - description: Create practice payload - in: body - name: body - required: true - schema: - $ref: '#/definitions/handlers.createPracticeReq' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Create a new practice - tags: - - practices - /api/v1/course-management/practices/{id}: - delete: - description: Deletes a practice by its ID - parameters: - - description: Practice ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Delete practice - tags: - - practices - put: - consumes: - - application/json - description: Updates a practice's fields - parameters: - - description: Practice ID - in: path - name: id - required: true - type: integer - - description: Update practice payload - in: body - name: body - required: true - schema: - $ref: '#/definitions/handlers.updatePracticeReq' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Update practice - tags: - - practices - /api/v1/course-management/practices/{practiceId}/questions: - get: - description: Returns all questions under a specific practice - parameters: - - description: Practice ID - in: path - name: practiceId - required: true - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Get questions by practice - tags: - - practice-questions - /api/v1/course-management/programs: - post: - consumes: - - application/json - description: Creates a new program under a specific course - parameters: - - description: Create program payload - in: body - name: body - required: true - schema: - $ref: '#/definitions/handlers.createProgramReq' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Create a new program - tags: - - programs - /api/v1/course-management/programs/{id}: - delete: - description: Deletes a program by its ID - parameters: - - description: Program ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Delete program - tags: - - programs - get: - description: Returns a single program by its ID - parameters: - - description: Program ID + - description: Sub-course ID in: path name: id required: true @@ -2392,25 +2486,25 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.ErrorResponse' - summary: Get program by ID + summary: Get sub-course by ID tags: - - programs + - sub-courses patch: consumes: - application/json - description: Updates selected fields of a program + description: Updates a sub-course's fields parameters: - - description: Program ID + - description: Sub-course ID in: path name: id required: true type: integer - - description: Update program payload + - description: Update sub-course payload in: body name: body required: true schema: - $ref: '#/definitions/handlers.updateProgramPartialReq' + $ref: '#/definitions/handlers.updateSubCourseReq' produces: - application/json responses: @@ -2426,14 +2520,14 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.ErrorResponse' - summary: Update program partially + summary: Update sub-course tags: - - programs - /api/v1/course-management/programs/{id}/deactivate: + - sub-courses + /api/v1/course-management/sub-courses/{id}/deactivate: put: - description: Deactivates a program by setting is_active to false + description: Deactivates a sub-course by its ID parameters: - - description: Program ID + - description: Sub-course ID in: path name: id required: true @@ -2453,51 +2547,16 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.ErrorResponse' - summary: Deactivate program + summary: Deactivate sub-course tags: - - programs - /api/v1/course-management/programs/{id}/full: - put: - consumes: - - application/json - description: Updates all fields of a program - parameters: - - description: Program ID - in: path - name: id - required: true - type: integer - - description: Update program payload - in: body - name: body - required: true - schema: - $ref: '#/definitions/handlers.updateProgramFullReq' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Update program fully - tags: - - programs - /api/v1/course-management/programs/{programId}/levels: + - sub-courses + /api/v1/course-management/sub-courses/{subCourseId}/videos: get: - description: Returns all levels under a specific program + description: Returns all videos under a specific sub-course parameters: - - description: Program ID + - description: Sub-course ID in: path - name: programId + name: subCourseId required: true type: integer produces: @@ -2515,63 +2574,16 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.ErrorResponse' - summary: Get levels by program + summary: Get videos by sub-course tags: - - levels - /api/v1/course-management/programs/active: + - sub-course-videos + /api/v1/course-management/sub-courses/{subCourseId}/videos/published: get: - description: Returns all active programs across all courses - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: List active programs - tags: - - programs - /api/v1/course-management/questions: - post: - consumes: - - application/json - description: Creates a new question under a specific practice + description: Returns all published videos under a specific sub-course parameters: - - description: Create question payload - in: body - name: body - required: true - schema: - $ref: '#/definitions/handlers.createPracticeQuestionReq' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Create a new practice question - tags: - - practice-questions - /api/v1/course-management/questions/{id}: - delete: - description: Deletes a practice question by its ID - parameters: - - description: Question ID + - description: Sub-course ID in: path - name: id + name: subCourseId required: true type: integer produces: @@ -2589,25 +2601,12 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.ErrorResponse' - summary: Delete practice question + summary: Get published videos by sub-course tags: - - practice-questions - put: - consumes: - - application/json - description: Updates a practice question's fields - parameters: - - description: Question ID - in: path - name: id - required: true - type: integer - - description: Update question payload - in: body - name: body - required: true - schema: - $ref: '#/definitions/handlers.updatePracticeQuestionReq' + - sub-course-videos + /api/v1/course-management/sub-courses/active: + get: + description: Returns a list of all active sub-courses produces: - application/json responses: @@ -2615,29 +2614,25 @@ paths: description: OK schema: $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' "500": description: Internal Server Error schema: $ref: '#/definitions/domain.ErrorResponse' - summary: Update practice question + summary: List all active sub-courses tags: - - practice-questions + - sub-courses /api/v1/course-management/videos: post: consumes: - application/json - description: Creates a new video under a specific module + description: Creates a new video under a specific sub-course parameters: - description: Create video payload in: body name: body required: true schema: - $ref: '#/definitions/handlers.createModuleVideoReq' + $ref: '#/definitions/handlers.createSubCourseVideoReq' produces: - application/json responses: @@ -2653,12 +2648,12 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.ErrorResponse' - summary: Create a new module video + summary: Create a new sub-course video tags: - - module-videos + - sub-course-videos /api/v1/course-management/videos/{id}: delete: - description: Deletes a module video by its ID + description: Archives a video by its ID (soft delete) parameters: - description: Video ID in: path @@ -2680,13 +2675,39 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.ErrorResponse' - summary: Delete module video + summary: Delete sub-course video tags: - - module-videos + - sub-course-videos + get: + description: Returns a single video by its ID + parameters: + - description: Video ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get sub-course video by ID + tags: + - sub-course-videos put: consumes: - application/json - description: Updates a module video's fields + description: Updates a video's fields parameters: - description: Video ID in: path @@ -2698,7 +2719,7 @@ paths: name: body required: true schema: - $ref: '#/definitions/handlers.updateModuleVideoReq' + $ref: '#/definitions/handlers.updateSubCourseVideoReq' produces: - application/json responses: @@ -2714,16 +2735,16 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.ErrorResponse' - summary: Update module video + summary: Update sub-course video tags: - - module-videos - /api/v1/course-management/videos/{videoId}/publish: + - sub-course-videos + /api/v1/course-management/videos/{id}/publish: put: - description: Publishes a module video by setting publish date + description: Publishes a video by its ID parameters: - description: Video ID in: path - name: videoId + name: id required: true type: integer produces: @@ -2741,9 +2762,69 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.ErrorResponse' - summary: Publish module video + summary: Publish sub-course video tags: - - module-videos + - sub-course-videos + /api/v1/course-management/videos/vimeo: + post: + consumes: + - application/json + description: Creates a video by uploading to Vimeo from a source URL + parameters: + - description: Create Vimeo video payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.createVimeoVideoReq' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Create a new sub-course video with Vimeo upload + tags: + - sub-course-videos + /api/v1/course-management/videos/vimeo/import: + post: + consumes: + - application/json + description: Creates a video record from an existing Vimeo video ID + parameters: + - description: Create from Vimeo ID payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.createVideoFromVimeoIDReq' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Create a sub-course video from existing Vimeo video + tags: + - sub-course-videos /api/v1/logs: get: description: Fetches application logs from MongoDB with pagination, level filtering, @@ -2786,6 +2867,896 @@ paths: summary: Retrieve application logs with filtering and pagination tags: - Logs + /api/v1/notifications/test-push: + post: + consumes: + - application/json + description: Sends a test push notification to all registered devices of the + current user + parameters: + - description: Test notification content + in: body + name: body + required: true + schema: + properties: + message: + type: string + title: + type: string + type: object + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Send test push notification + tags: + - notifications + /api/v1/payments: + get: + description: Returns the authenticated user's payment history + parameters: + - default: 20 + description: Limit + in: query + name: limit + type: integer + - default: 0 + description: Offset + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + summary: Get payment history + tags: + - payments + /api/v1/payments/{id}: + get: + description: Returns details of a specific payment + parameters: + - description: Payment ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get payment details + tags: + - payments + /api/v1/payments/{id}/cancel: + post: + description: Cancels a payment that is still pending + parameters: + - description: Payment ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Cancel a pending payment + tags: + - payments + /api/v1/payments/direct: + post: + consumes: + - application/json + description: Creates a payment session and initiates direct payment (OTP-based) + parameters: + - description: Direct payment request + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.initiateDirectPaymentReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Initiate direct payment + tags: + - payments + /api/v1/payments/direct/methods: + get: + description: Returns list of payment methods that support direct payment (OTP-based) + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + summary: Get direct payment methods + tags: + - payments + /api/v1/payments/direct/verify-otp: + post: + consumes: + - application/json + description: Verifies the OTP sent for direct payment methods (Amole, HelloCash, + etc.) + parameters: + - description: OTP verification request + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.verifyOTPReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Verify OTP for direct payment + tags: + - payments + /api/v1/payments/methods: + get: + description: Returns list of supported ArifPay payment methods + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + summary: Get available payment methods + tags: + - payments + /api/v1/payments/subscribe: + post: + consumes: + - application/json + description: Creates a payment session for a subscription plan + parameters: + - description: Payment request + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.initiatePaymentReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Initiate subscription payment + tags: + - payments + /api/v1/payments/verify/{session_id}: + get: + description: Checks the payment status with the payment provider + parameters: + - description: Session ID + in: path + name: session_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Verify payment status + tags: + - payments + /api/v1/payments/webhook: + post: + consumes: + - application/json + description: Processes payment notifications from ArifPay + parameters: + - description: Webhook payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/domain.WebhookRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Handle ArifPay webhook + tags: + - payments + /api/v1/question-sets: + get: + description: Returns a paginated list of question sets filtered by type + parameters: + - description: Set type (PRACTICE, INITIAL_ASSESSMENT, QUIZ, EXAM, SURVEY) + in: query + name: set_type + required: true + type: string + - default: 10 + description: Limit + in: query + name: limit + type: integer + - default: 0 + description: Offset + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get question sets by type + tags: + - question-sets + post: + consumes: + - application/json + description: Creates a new question set (practice, assessment, quiz, exam, or + survey) + parameters: + - description: Create question set payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.createQuestionSetReq' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Create a new question set + tags: + - question-sets + /api/v1/question-sets/{id}: + delete: + description: Archives a question set (soft delete) + parameters: + - description: Question Set ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Delete a question set + tags: + - question-sets + get: + description: Returns a question set with question count + parameters: + - description: Question Set ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get question set by ID + tags: + - question-sets + put: + consumes: + - application/json + description: Updates a question set's properties + parameters: + - description: Question Set ID + in: path + name: id + required: true + type: integer + - description: Update question set payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.updateQuestionSetReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Update a question set + tags: + - question-sets + /api/v1/question-sets/{setId}/personas: + get: + description: Returns all users assigned as personas to a question set (practice) + parameters: + - description: Question Set ID + in: path + name: setId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get user personas for a question set + tags: + - question-sets + post: + consumes: + - application/json + description: Links a user as a persona to a question set (practice) + parameters: + - description: Question Set ID + in: path + name: setId + required: true + type: integer + - description: Add user persona payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.addUserPersonaReq' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Add a user as persona to a question set + tags: + - question-sets + /api/v1/question-sets/{setId}/personas/{userId}: + delete: + description: Unlinks a user as persona from a question set (practice) + parameters: + - description: Question Set ID + in: path + name: setId + required: true + type: integer + - description: User ID + in: path + name: userId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Remove a user persona from a question set + tags: + - question-sets + /api/v1/question-sets/{setId}/questions: + get: + description: Returns all questions in a question set with details + parameters: + - description: Question Set ID + in: path + name: setId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get questions in set + tags: + - question-set-items + post: + consumes: + - application/json + description: Links a question to a question set + parameters: + - description: Question Set ID + in: path + name: setId + required: true + type: integer + - description: Add question to set payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.addQuestionToSetReq' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Add question to set + tags: + - question-set-items + /api/v1/question-sets/{setId}/questions/{questionId}: + delete: + description: Unlinks a question from a question set + parameters: + - description: Question Set ID + in: path + name: setId + required: true + type: integer + - description: Question ID + in: path + name: questionId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Remove question from set + tags: + - question-set-items + /api/v1/question-sets/{setId}/questions/{questionId}/order: + put: + consumes: + - application/json + description: Updates the display order of a question in a set + parameters: + - description: Question Set ID + in: path + name: setId + required: true + type: integer + - description: Question ID + in: path + name: questionId + required: true + type: integer + - description: Update order payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.updateQuestionOrderReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Update question order in set + tags: + - question-set-items + /api/v1/question-sets/by-owner: + get: + description: Returns question sets for a specific owner (e.g., sub-course) + parameters: + - description: Owner type (SUB_COURSE, COURSE, CATEGORY, STANDALONE) + in: query + name: owner_type + required: true + type: string + - description: Owner ID + in: query + name: owner_id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get question sets by owner + tags: + - question-sets + /api/v1/questions: + get: + description: Returns a paginated list of questions with optional filters + parameters: + - description: Question type filter (MCQ, TRUE_FALSE, SHORT_ANSWER) + in: query + name: question_type + type: string + - description: Difficulty level filter (EASY, MEDIUM, HARD) + in: query + name: difficulty + type: string + - description: Status filter (DRAFT, PUBLISHED, INACTIVE) + in: query + name: status + type: string + - default: 10 + description: Limit + in: query + name: limit + type: integer + - default: 0 + description: Offset + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: List questions + tags: + - questions + post: + consumes: + - application/json + description: Creates a new question with options (for MCQ/TRUE_FALSE) or short + answers (for SHORT_ANSWER) + parameters: + - description: Create question payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.createQuestionReq' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Create a new question + tags: + - questions + /api/v1/questions/{id}: + delete: + description: Archives a question (soft delete) + parameters: + - description: Question ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Delete a question + tags: + - questions + get: + description: Returns a question with its options/short answers + parameters: + - description: Question ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get question by ID + tags: + - questions + put: + consumes: + - application/json + description: Updates a question and optionally replaces its options/short answers + parameters: + - description: Question ID + in: path + name: id + required: true + type: integer + - description: Update question payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.updateQuestionReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Update a question + tags: + - questions + /api/v1/questions/search: + get: + description: Search questions by text + parameters: + - description: Search query + in: query + name: q + required: true + type: string + - default: 10 + description: Limit + in: query + name: limit + type: integer + - default: 0 + description: Offset + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Search questions + tags: + - questions /api/v1/sendSMS: post: consumes: @@ -2816,6 +3787,311 @@ paths: summary: Send single SMS via AfroMessage tags: - user + /api/v1/subscription-plans: + get: + description: Returns all subscription plans + parameters: + - description: Return only active plans + in: query + name: active_only + type: boolean + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: List subscription plans + tags: + - subscriptions + post: + consumes: + - application/json + description: Creates a new subscription plan (admin only) + parameters: + - description: Create plan payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.createPlanReq' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Create a subscription plan + tags: + - subscriptions + /api/v1/subscription-plans/{id}: + delete: + description: Deletes a subscription plan (admin only) + parameters: + - description: Plan ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Delete a subscription plan + tags: + - subscriptions + get: + description: Returns a single subscription plan by ID + parameters: + - description: Plan ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get a subscription plan + tags: + - subscriptions + put: + consumes: + - application/json + description: Updates a subscription plan (admin only) + parameters: + - description: Plan ID + in: path + name: id + required: true + type: integer + - description: Update plan payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.updatePlanReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Update a subscription plan + tags: + - subscriptions + /api/v1/subscriptions: + post: + consumes: + - application/json + deprecated: true + description: Creates a new subscription for the authenticated user. For regular + users, use /payments/subscribe instead. + parameters: + - description: Subscribe payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.subscribeReq' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Subscribe to a plan (Admin only - bypasses payment) + tags: + - subscriptions + /api/v1/subscriptions/{id}/auto-renew: + put: + consumes: + - application/json + description: Enables or disables auto-renewal for a subscription + parameters: + - description: Subscription ID + in: path + name: id + required: true + type: integer + - description: Auto-renew payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.autoRenewReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Set auto-renew + tags: + - subscriptions + /api/v1/subscriptions/{id}/cancel: + post: + description: Cancels the user's subscription + parameters: + - description: Subscription ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Cancel subscription + tags: + - subscriptions + /api/v1/subscriptions/checkout: + post: + consumes: + - application/json + description: Initiates payment for a subscription plan. Returns payment URL + for checkout. + parameters: + - description: Subscribe with payment payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.subscribeWithPaymentReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "409": + description: User already has active subscription + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Subscribe to a plan with payment + tags: + - subscriptions + /api/v1/subscriptions/history: + get: + description: Returns the authenticated user's subscription history + parameters: + - default: 20 + description: Limit + in: query + name: limit + type: integer + - default: 0 + description: Offset + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + summary: Get subscription history + tags: + - subscriptions + /api/v1/subscriptions/me: + get: + description: Returns the authenticated user's active subscription + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get current subscription + tags: + - subscriptions + /api/v1/subscriptions/status: + get: + description: Returns whether the authenticated user has an active subscription + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + summary: Check subscription status + tags: + - subscriptions /api/v1/super-login: post: consumes: @@ -2916,6 +4192,453 @@ paths: summary: Update Admin tags: - admin + /api/v1/team/login: + post: + consumes: + - application/json + description: Authenticate a team member (internal staff) with email and password + parameters: + - description: Team member login credentials + in: body + name: body + required: true + schema: + $ref: '#/definitions/domain.TeamMemberLoginReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/domain.Response' + - properties: + data: + $ref: '#/definitions/handlers.teamMemberLoginRes' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Login team member + tags: + - team + /api/v1/team/me: + get: + consumes: + - application/json + description: Get the authenticated team member's own profile + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/domain.Response' + - properties: + data: + $ref: '#/definitions/domain.TeamMemberResponse' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + security: + - Bearer: [] + summary: Get my team profile + tags: + - team + /api/v1/team/members: + get: + consumes: + - application/json + description: Get a paginated list of team members with optional filtering + parameters: + - description: Filter by team role (super_admin, admin, content_manager, support_agent, + instructor, finance, hr, analyst) + in: query + name: team_role + type: string + - description: Filter by department + in: query + name: department + type: string + - description: Filter by status (active, inactive, suspended, terminated) + in: query + name: status + type: string + - description: Search by name, email, or phone number + in: query + name: search + type: string + - description: 'Page number (default: 1)' + in: query + name: page + type: integer + - description: 'Items per page (default: 10, max: 100)' + in: query + name: page_size + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/domain.Response' + - properties: + data: + items: + $ref: '#/definitions/domain.TeamMemberResponse' + type: array + metadata: + $ref: '#/definitions/domain.Pagination' + type: object + "401": + description: Unauthorized + schema: + $ref: '#/definitions/domain.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + security: + - Bearer: [] + summary: List all team members + tags: + - team + post: + consumes: + - application/json + description: Create a new internal team member (admin only) + parameters: + - description: Team member creation payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/domain.CreateTeamMemberReq' + produces: + - application/json + responses: + "201": + description: Created + schema: + allOf: + - $ref: '#/definitions/domain.Response' + - properties: + data: + $ref: '#/definitions/domain.TeamMemberResponse' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/domain.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + security: + - Bearer: [] + summary: Create a new team member + tags: + - team + /api/v1/team/members/{id}: + delete: + consumes: + - application/json + description: Delete a team member (super admin only) + parameters: + - description: Team Member ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/domain.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + security: + - Bearer: [] + summary: Delete team member + tags: + - team + get: + consumes: + - application/json + description: Retrieve a team member's details by their ID + parameters: + - description: Team Member ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/domain.Response' + - properties: + data: + $ref: '#/definitions/domain.TeamMemberResponse' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + security: + - Bearer: [] + summary: Get team member by ID + tags: + - team + put: + consumes: + - application/json + description: Update an existing team member's details (admin only) + parameters: + - description: Team Member ID + in: path + name: id + required: true + type: integer + - description: Team member update payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/domain.UpdateTeamMemberReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/domain.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + security: + - Bearer: [] + summary: Update team member + tags: + - team + /api/v1/team/members/{id}/change-password: + post: + consumes: + - application/json + description: Change a team member's password (requires current password) + parameters: + - description: Team Member ID + in: path + name: id + required: true + type: integer + - description: Password change payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.changePasswordReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + security: + - Bearer: [] + summary: Change team member password + tags: + - team + /api/v1/team/members/{id}/status: + patch: + consumes: + - application/json + description: Update a team member's status (active, inactive, suspended, terminated) + parameters: + - description: Team Member ID + in: path + name: id + required: true + type: integer + - description: Status update payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/domain.UpdateTeamMemberStatusReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/domain.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + security: + - Bearer: [] + summary: Update team member status + tags: + - team + /api/v1/team/stats: + get: + consumes: + - application/json + description: Get statistics about team members by status + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/domain.Response' + - properties: + data: + $ref: '#/definitions/domain.TeamMemberStats' + type: object + "401": + description: Unauthorized + schema: + $ref: '#/definitions/domain.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + security: + - Bearer: [] + summary: Get team member statistics + tags: + - team /api/v1/tenant: get: consumes: @@ -3058,7 +4781,8 @@ paths: get: consumes: - application/json - description: Returns whether the specified user's profile is completed + description: Returns the profile completion status and percentage for the specified + user parameters: - description: User ID in: path @@ -3317,6 +5041,244 @@ paths: summary: Get all users tags: - user + /api/v1/vimeo/oembed: + get: + consumes: + - application/json + description: Fetches oEmbed metadata for a Vimeo video URL + parameters: + - description: Vimeo video URL + in: query + name: url + required: true + type: string + - description: Desired width + in: query + name: width + type: integer + - description: Desired height + in: query + name: height + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/vimeo.OEmbedResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get oEmbed data for a Vimeo URL + tags: + - Vimeo + /api/v1/vimeo/uploads/pull: + post: + consumes: + - application/json + description: Initiates a pull upload where Vimeo fetches the video from a URL + parameters: + - description: Pull Upload Request + in: body + name: request + required: true + schema: + $ref: '#/definitions/handlers.CreatePullUploadRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/handlers.VimeoUploadResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Create a pull upload to Vimeo + tags: + - Vimeo + /api/v1/vimeo/uploads/tus: + post: + consumes: + - application/json + description: Initiates a TUS resumable upload and returns the upload link + parameters: + - description: TUS Upload Request + in: body + name: request + required: true + schema: + $ref: '#/definitions/handlers.CreateTusUploadRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/handlers.VimeoUploadResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Create a TUS resumable upload to Vimeo + tags: + - Vimeo + /api/v1/vimeo/videos/{video_id}: + delete: + consumes: + - application/json + description: Deletes a video from the Vimeo account + parameters: + - description: Vimeo Video ID + in: path + name: video_id + required: true + type: string + produces: + - application/json + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Delete a video from Vimeo + tags: + - Vimeo + get: + consumes: + - application/json + description: Retrieves video details from Vimeo by video ID + parameters: + - description: Vimeo Video ID + in: path + name: video_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.VimeoVideoResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get video information from Vimeo + tags: + - Vimeo + /api/v1/vimeo/videos/{video_id}/embed: + get: + consumes: + - application/json + description: Generates an embeddable player iframe for the video + parameters: + - description: Vimeo Video ID + in: path + name: video_id + required: true + type: string + - default: 640 + description: Player width + in: query + name: width + type: integer + - default: 360 + description: Player height + in: query + name: height + type: integer + - description: Autoplay video + in: query + name: autoplay + type: boolean + - description: Loop video + in: query + name: loop + type: boolean + - description: Mute video + in: query + name: muted + type: boolean + - description: Background mode (no controls) + in: query + name: background + type: boolean + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.VimeoEmbedResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get embed code for a Vimeo video + tags: + - Vimeo + /api/v1/vimeo/videos/{video_id}/status: + get: + consumes: + - application/json + description: Returns the current transcoding status of a video + parameters: + - description: Vimeo Video ID + in: path + name: video_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get transcode status of a Vimeo video + tags: + - Vimeo securityDefinitions: Bearer: in: header diff --git a/gen/db/copyfrom.go b/gen/db/copyfrom.go new file mode 100644 index 0000000..72b3a50 --- /dev/null +++ b/gen/db/copyfrom.go @@ -0,0 +1,45 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: copyfrom.go + +package dbgen + +import ( + "context" +) + +// iteratorForBulkCreateQuestionOptions implements pgx.CopyFromSource. +type iteratorForBulkCreateQuestionOptions struct { + rows []BulkCreateQuestionOptionsParams + skippedFirstNextCall bool +} + +func (r *iteratorForBulkCreateQuestionOptions) Next() bool { + if len(r.rows) == 0 { + return false + } + if !r.skippedFirstNextCall { + r.skippedFirstNextCall = true + return true + } + r.rows = r.rows[1:] + return len(r.rows) > 0 +} + +func (r iteratorForBulkCreateQuestionOptions) Values() ([]interface{}, error) { + return []interface{}{ + r.rows[0].QuestionID, + r.rows[0].OptionText, + r.rows[0].OptionOrder, + r.rows[0].IsCorrect, + }, nil +} + +func (r iteratorForBulkCreateQuestionOptions) Err() error { + return nil +} + +func (q *Queries) BulkCreateQuestionOptions(ctx context.Context, arg []BulkCreateQuestionOptionsParams) (int64, error) { + return q.db.CopyFrom(ctx, []string{"question_options"}, []string{"question_id", "option_text", "option_order", "is_correct"}, &iteratorForBulkCreateQuestionOptions{rows: arg}) +} diff --git a/gen/db/courses.sql.go b/gen/db/courses.sql.go index 95b9e8d..030db60 100644 --- a/gen/db/courses.sql.go +++ b/gen/db/courses.sql.go @@ -16,17 +16,19 @@ INSERT INTO courses ( category_id, title, description, + thumbnail, is_active ) -VALUES ($1, $2, $3, COALESCE($4, true)) -RETURNING id, category_id, title, description, is_active +VALUES ($1, $2, $3, $4, COALESCE($5, true)) +RETURNING id, category_id, title, description, is_active, thumbnail ` type CreateCourseParams struct { CategoryID int64 `json:"category_id"` Title string `json:"title"` Description pgtype.Text `json:"description"` - Column4 interface{} `json:"column_4"` + Thumbnail pgtype.Text `json:"thumbnail"` + Column5 interface{} `json:"column_5"` } func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Course, error) { @@ -34,7 +36,8 @@ func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Cou arg.CategoryID, arg.Title, arg.Description, - arg.Column4, + arg.Thumbnail, + arg.Column5, ) var i Course err := row.Scan( @@ -43,6 +46,7 @@ func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Cou &i.Title, &i.Description, &i.IsActive, + &i.Thumbnail, ) return i, err } @@ -58,7 +62,7 @@ func (q *Queries) DeleteCourse(ctx context.Context, id int64) error { } const GetCourseByID = `-- name: GetCourseByID :one -SELECT id, category_id, title, description, is_active +SELECT id, category_id, title, description, is_active, thumbnail FROM courses WHERE id = $1 ` @@ -72,6 +76,7 @@ func (q *Queries) GetCourseByID(ctx context.Context, id int64) (Course, error) { &i.Title, &i.Description, &i.IsActive, + &i.Thumbnail, ) return i, err } @@ -83,6 +88,7 @@ SELECT category_id, title, description, + thumbnail, is_active FROM courses WHERE category_id = $1 @@ -103,6 +109,7 @@ type GetCoursesByCategoryRow struct { CategoryID int64 `json:"category_id"` Title string `json:"title"` Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` IsActive bool `json:"is_active"` } @@ -121,6 +128,7 @@ func (q *Queries) GetCoursesByCategory(ctx context.Context, arg GetCoursesByCate &i.CategoryID, &i.Title, &i.Description, + &i.Thumbnail, &i.IsActive, ); err != nil { return nil, err @@ -138,13 +146,15 @@ UPDATE courses SET title = COALESCE($1, title), description = COALESCE($2, description), - is_active = COALESCE($3, is_active) -WHERE id = $4 + thumbnail = COALESCE($3, thumbnail), + is_active = COALESCE($4, is_active) +WHERE id = $5 ` type UpdateCourseParams struct { Title string `json:"title"` Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` IsActive bool `json:"is_active"` ID int64 `json:"id"` } @@ -153,6 +163,7 @@ func (q *Queries) UpdateCourse(ctx context.Context, arg UpdateCourseParams) erro _, err := q.db.Exec(ctx, UpdateCourse, arg.Title, arg.Description, + arg.Thumbnail, arg.IsActive, arg.ID, ) diff --git a/gen/db/db.go b/gen/db/db.go index 67cd40f..8134784 100644 --- a/gen/db/db.go +++ b/gen/db/db.go @@ -15,6 +15,7 @@ type DBTX interface { Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) Query(context.Context, string, ...interface{}) (pgx.Rows, error) QueryRow(context.Context, string, ...interface{}) pgx.Row + CopyFrom(ctx context.Context, tableName pgx.Identifier, columnNames []string, rowSrc pgx.CopyFromSource) (int64, error) } func New(db DBTX) *Queries { diff --git a/gen/db/device.sql.go b/gen/db/device.sql.go index b75d8ff..fc3a291 100644 --- a/gen/db/device.sql.go +++ b/gen/db/device.sql.go @@ -59,6 +59,22 @@ func (q *Queries) DeactivateDevice(ctx context.Context, id int64) error { return err } +const DeactivateDeviceByToken = `-- name: DeactivateDeviceByToken :exec +UPDATE devices +SET is_active = false +WHERE user_id = $1 AND device_token = $2 +` + +type DeactivateDeviceByTokenParams struct { + UserID int64 `json:"user_id"` + DeviceToken string `json:"device_token"` +} + +func (q *Queries) DeactivateDeviceByToken(ctx context.Context, arg DeactivateDeviceByTokenParams) error { + _, err := q.db.Exec(ctx, DeactivateDeviceByToken, arg.UserID, arg.DeviceToken) + return err +} + const DeactivateUserDevices = `-- name: DeactivateUserDevices :exec UPDATE devices SET is_active = false diff --git a/gen/db/initial_assessment.sql.go b/gen/db/initial_assessment.sql.go deleted file mode 100644 index 2f52cc1..0000000 --- a/gen/db/initial_assessment.sql.go +++ /dev/null @@ -1,756 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: initial_assessment.sql - -package dbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const AddAttemptQuestion = `-- name: AddAttemptQuestion :exec -INSERT INTO assessment_attempt_questions ( - attempt_id, - question_id, - question_type, - points -) -VALUES ( - $1, -- attempt_id - $2, -- question_id - $3, -- question_type - $4 -- points -) -` - -type AddAttemptQuestionParams struct { - AttemptID int64 `json:"attempt_id"` - QuestionID int64 `json:"question_id"` - QuestionType string `json:"question_type"` - Points int32 `json:"points"` -} - -func (q *Queries) AddAttemptQuestion(ctx context.Context, arg AddAttemptQuestionParams) error { - _, err := q.db.Exec(ctx, AddAttemptQuestion, - arg.AttemptID, - arg.QuestionID, - arg.QuestionType, - arg.Points, - ) - return err -} - -const CreateAssessmentAttempt = `-- name: CreateAssessmentAttempt :one - -INSERT INTO assessment_attempts ( - user_id, - total_questions, - total_points, - status -) -VALUES ( - $1, -- user_id - $2, -- total_questions - $3, -- total_points - 'IN_PROGRESS' -) -RETURNING id, user_id, total_questions, total_points, score, percentage, status, started_at, submitted_at, evaluated_at, created_at, updated_at -` - -type CreateAssessmentAttemptParams struct { - UserID int64 `json:"user_id"` - TotalQuestions int32 `json:"total_questions"` - TotalPoints int32 `json:"total_points"` -} - -// ------------------------------------------------------------------------------------ -func (q *Queries) CreateAssessmentAttempt(ctx context.Context, arg CreateAssessmentAttemptParams) (AssessmentAttempt, error) { - row := q.db.QueryRow(ctx, CreateAssessmentAttempt, arg.UserID, arg.TotalQuestions, arg.TotalPoints) - var i AssessmentAttempt - err := row.Scan( - &i.ID, - &i.UserID, - &i.TotalQuestions, - &i.TotalPoints, - &i.Score, - &i.Percentage, - &i.Status, - &i.StartedAt, - &i.SubmittedAt, - &i.EvaluatedAt, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} - -const CreateAssessmentQuestion = `-- name: CreateAssessmentQuestion :one -INSERT INTO assessment_questions ( - title, - description, - question_type, - difficulty_level, - points, - is_active -) -VALUES ( - $1, -- title - $2, -- description - $3, -- question_type - $4, -- difficulty_level - $5, -- points - $6 -- is_active -) -RETURNING id, title, description, question_type, difficulty_level, points, is_active, created_at, updated_at -` - -type CreateAssessmentQuestionParams struct { - Title string `json:"title"` - Description pgtype.Text `json:"description"` - QuestionType string `json:"question_type"` - DifficultyLevel pgtype.Text `json:"difficulty_level"` - Points int32 `json:"points"` - IsActive bool `json:"is_active"` -} - -func (q *Queries) CreateAssessmentQuestion(ctx context.Context, arg CreateAssessmentQuestionParams) (AssessmentQuestion, error) { - row := q.db.QueryRow(ctx, CreateAssessmentQuestion, - arg.Title, - arg.Description, - arg.QuestionType, - arg.DifficultyLevel, - arg.Points, - arg.IsActive, - ) - var i AssessmentQuestion - err := row.Scan( - &i.ID, - &i.Title, - &i.Description, - &i.QuestionType, - &i.DifficultyLevel, - &i.Points, - &i.IsActive, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} - -const CreateQuestionOption = `-- name: CreateQuestionOption :one -INSERT INTO assessment_question_options ( - question_id, - option_text, - option_order, - is_correct -) -VALUES ( - $1, -- question_id - $2, -- option_text - $3, -- option_order - $4 -- is_correct -) -RETURNING id, question_id, option_text, option_order, is_correct, created_at -` - -type CreateQuestionOptionParams struct { - QuestionID int64 `json:"question_id"` - OptionText string `json:"option_text"` - OptionOrder int32 `json:"option_order"` - IsCorrect bool `json:"is_correct"` -} - -func (q *Queries) CreateQuestionOption(ctx context.Context, arg CreateQuestionOptionParams) (AssessmentQuestionOption, error) { - row := q.db.QueryRow(ctx, CreateQuestionOption, - arg.QuestionID, - arg.OptionText, - arg.OptionOrder, - arg.IsCorrect, - ) - var i AssessmentQuestionOption - err := row.Scan( - &i.ID, - &i.QuestionID, - &i.OptionText, - &i.OptionOrder, - &i.IsCorrect, - &i.CreatedAt, - ) - return i, err -} - -const CreateShortAnswer = `-- name: CreateShortAnswer :one -INSERT INTO assessment_short_answers ( - question_id, - correct_answer -) -VALUES ( - $1, -- question_id - $2 -- correct_answer -) -RETURNING id, question_id, correct_answer, created_at -` - -type CreateShortAnswerParams struct { - QuestionID int64 `json:"question_id"` - CorrectAnswer string `json:"correct_answer"` -} - -func (q *Queries) CreateShortAnswer(ctx context.Context, arg CreateShortAnswerParams) (AssessmentShortAnswer, error) { - row := q.db.QueryRow(ctx, CreateShortAnswer, arg.QuestionID, arg.CorrectAnswer) - var i AssessmentShortAnswer - err := row.Scan( - &i.ID, - &i.QuestionID, - &i.CorrectAnswer, - &i.CreatedAt, - ) - return i, err -} - -const DeleteAssessmentQuestion = `-- name: DeleteAssessmentQuestion :exec -DELETE FROM assessment_questions -WHERE id = $1 -` - -func (q *Queries) DeleteAssessmentQuestion(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, DeleteAssessmentQuestion, id) - return err -} - -const DeleteQuestionOptionsByQuestionID = `-- name: DeleteQuestionOptionsByQuestionID :exec -DELETE FROM assessment_question_options -WHERE question_id = $1 -` - -func (q *Queries) DeleteQuestionOptionsByQuestionID(ctx context.Context, questionID int64) error { - _, err := q.db.Exec(ctx, DeleteQuestionOptionsByQuestionID, questionID) - return err -} - -const EvaluateMCQAnswer = `-- name: EvaluateMCQAnswer :exec -UPDATE assessment_attempt_answers a -SET - is_correct = o.is_correct, - awarded_points = CASE WHEN o.is_correct THEN q.points ELSE 0 END -FROM assessment_question_options o -JOIN assessment_questions q ON q.id = a.question_id -WHERE a.selected_option_id = o.id - AND a.attempt_id = $1 -` - -func (q *Queries) EvaluateMCQAnswer(ctx context.Context, attemptID int64) error { - _, err := q.db.Exec(ctx, EvaluateMCQAnswer, attemptID) - return err -} - -const EvaluateShortAnswer = `-- name: EvaluateShortAnswer :exec -UPDATE assessment_attempt_answers a -SET - is_correct = EXISTS ( - SELECT 1 - FROM assessment_short_answers s - WHERE s.question_id = a.question_id - AND LOWER(TRIM(s.correct_answer)) = LOWER(TRIM(a.submitted_text)) - ), - awarded_points = CASE - WHEN EXISTS ( - SELECT 1 - FROM assessment_short_answers s - WHERE s.question_id = a.question_id - AND LOWER(TRIM(s.correct_answer)) = LOWER(TRIM(a.submitted_text)) - ) - THEN q.points - ELSE 0 - END -FROM assessment_questions q -WHERE a.question_id = q.id - AND a.attempt_id = $1 -` - -func (q *Queries) EvaluateShortAnswer(ctx context.Context, attemptID int64) error { - _, err := q.db.Exec(ctx, EvaluateShortAnswer, attemptID) - return err -} - -const FinalizeAssessmentAttempt = `-- name: FinalizeAssessmentAttempt :exec -UPDATE assessment_attempts -SET - score = sub.total_score, - percentage = ROUND((sub.total_score::NUMERIC / total_points) * 100, 2), - status = 'EVALUATED', - evaluated_at = CURRENT_TIMESTAMP, - updated_at = CURRENT_TIMESTAMP -FROM ( - SELECT attempt_id, SUM(awarded_points) AS total_score - FROM assessment_attempt_answers - WHERE attempt_id = $1 - GROUP BY attempt_id -) sub -WHERE assessment_attempts.id = sub.attempt_id -` - -func (q *Queries) FinalizeAssessmentAttempt(ctx context.Context, attemptID int64) error { - _, err := q.db.Exec(ctx, FinalizeAssessmentAttempt, attemptID) - return err -} - -const GetActiveAssessmentQuestions = `-- name: GetActiveAssessmentQuestions :many -SELECT id, title, description, question_type, difficulty_level, points, is_active, created_at, updated_at -FROM assessment_questions -WHERE is_active = true -ORDER BY created_at DESC -` - -func (q *Queries) GetActiveAssessmentQuestions(ctx context.Context) ([]AssessmentQuestion, error) { - rows, err := q.db.Query(ctx, GetActiveAssessmentQuestions) - if err != nil { - return nil, err - } - defer rows.Close() - var items []AssessmentQuestion - for rows.Next() { - var i AssessmentQuestion - if err := rows.Scan( - &i.ID, - &i.Title, - &i.Description, - &i.QuestionType, - &i.DifficultyLevel, - &i.Points, - &i.IsActive, - &i.CreatedAt, - &i.UpdatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const GetAssessmentAttemptByID = `-- name: GetAssessmentAttemptByID :one -SELECT id, user_id, total_questions, total_points, score, percentage, status, started_at, submitted_at, evaluated_at, created_at, updated_at -FROM assessment_attempts -WHERE id = $1 -` - -func (q *Queries) GetAssessmentAttemptByID(ctx context.Context, id int64) (AssessmentAttempt, error) { - row := q.db.QueryRow(ctx, GetAssessmentAttemptByID, id) - var i AssessmentAttempt - err := row.Scan( - &i.ID, - &i.UserID, - &i.TotalQuestions, - &i.TotalPoints, - &i.Score, - &i.Percentage, - &i.Status, - &i.StartedAt, - &i.SubmittedAt, - &i.EvaluatedAt, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} - -const GetAssessmentQuestionByID = `-- name: GetAssessmentQuestionByID :one -SELECT id, title, description, question_type, difficulty_level, points, is_active, created_at, updated_at -FROM assessment_questions -WHERE id = $1 -` - -func (q *Queries) GetAssessmentQuestionByID(ctx context.Context, id int64) (AssessmentQuestion, error) { - row := q.db.QueryRow(ctx, GetAssessmentQuestionByID, id) - var i AssessmentQuestion - err := row.Scan( - &i.ID, - &i.Title, - &i.Description, - &i.QuestionType, - &i.DifficultyLevel, - &i.Points, - &i.IsActive, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} - -const GetAssessmentQuestionsPaginated = `-- name: GetAssessmentQuestionsPaginated :many -SELECT - COUNT(*) OVER () AS total_count, - id, - title, - description, - question_type, - difficulty_level, - points, - is_active, - created_at, - updated_at -FROM assessment_questions -WHERE ($1 IS NULL OR question_type = $1) - AND ($2 IS NULL OR difficulty_level = $2) - AND ($3 IS NULL OR is_active = $3) -LIMIT $4 -OFFSET $5 -` - -type GetAssessmentQuestionsPaginatedParams struct { - Column1 interface{} `json:"column_1"` - Column2 interface{} `json:"column_2"` - Column3 interface{} `json:"column_3"` - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` -} - -type GetAssessmentQuestionsPaginatedRow struct { - TotalCount int64 `json:"total_count"` - ID int64 `json:"id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - QuestionType string `json:"question_type"` - DifficultyLevel pgtype.Text `json:"difficulty_level"` - Points int32 `json:"points"` - IsActive bool `json:"is_active"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` -} - -func (q *Queries) GetAssessmentQuestionsPaginated(ctx context.Context, arg GetAssessmentQuestionsPaginatedParams) ([]GetAssessmentQuestionsPaginatedRow, error) { - rows, err := q.db.Query(ctx, GetAssessmentQuestionsPaginated, - arg.Column1, - arg.Column2, - arg.Column3, - arg.Limit, - arg.Offset, - ) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetAssessmentQuestionsPaginatedRow - for rows.Next() { - var i GetAssessmentQuestionsPaginatedRow - if err := rows.Scan( - &i.TotalCount, - &i.ID, - &i.Title, - &i.Description, - &i.QuestionType, - &i.DifficultyLevel, - &i.Points, - &i.IsActive, - &i.CreatedAt, - &i.UpdatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const GetAttemptAnswers = `-- name: GetAttemptAnswers :many -SELECT id, attempt_id, question_id, selected_option_id, submitted_text, is_correct, awarded_points, created_at -FROM assessment_attempt_answers -WHERE attempt_id = $1 -` - -func (q *Queries) GetAttemptAnswers(ctx context.Context, attemptID int64) ([]AssessmentAttemptAnswer, error) { - rows, err := q.db.Query(ctx, GetAttemptAnswers, attemptID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []AssessmentAttemptAnswer - for rows.Next() { - var i AssessmentAttemptAnswer - if err := rows.Scan( - &i.ID, - &i.AttemptID, - &i.QuestionID, - &i.SelectedOptionID, - &i.SubmittedText, - &i.IsCorrect, - &i.AwardedPoints, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const GetAttemptQuestions = `-- name: GetAttemptQuestions :many -SELECT - aq.question_id, - aq.question_type, - aq.points, - q.title, - q.description -FROM assessment_attempt_questions aq -JOIN assessment_questions q ON q.id = aq.question_id -WHERE aq.attempt_id = $1 -` - -type GetAttemptQuestionsRow struct { - QuestionID int64 `json:"question_id"` - QuestionType string `json:"question_type"` - Points int32 `json:"points"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` -} - -func (q *Queries) GetAttemptQuestions(ctx context.Context, attemptID int64) ([]GetAttemptQuestionsRow, error) { - rows, err := q.db.Query(ctx, GetAttemptQuestions, attemptID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetAttemptQuestionsRow - for rows.Next() { - var i GetAttemptQuestionsRow - if err := rows.Scan( - &i.QuestionID, - &i.QuestionType, - &i.Points, - &i.Title, - &i.Description, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const GetQuestionOptions = `-- name: GetQuestionOptions :many -SELECT id, question_id, option_text, option_order, is_correct, created_at -FROM assessment_question_options -WHERE question_id = $1 -ORDER BY option_order -` - -func (q *Queries) GetQuestionOptions(ctx context.Context, questionID int64) ([]AssessmentQuestionOption, error) { - rows, err := q.db.Query(ctx, GetQuestionOptions, questionID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []AssessmentQuestionOption - for rows.Next() { - var i AssessmentQuestionOption - if err := rows.Scan( - &i.ID, - &i.QuestionID, - &i.OptionText, - &i.OptionOrder, - &i.IsCorrect, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const GetShortAnswersByQuestionID = `-- name: GetShortAnswersByQuestionID :many -SELECT id, question_id, correct_answer, created_at -FROM assessment_short_answers -WHERE question_id = $1 -` - -func (q *Queries) GetShortAnswersByQuestionID(ctx context.Context, questionID int64) ([]AssessmentShortAnswer, error) { - rows, err := q.db.Query(ctx, GetShortAnswersByQuestionID, questionID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []AssessmentShortAnswer - for rows.Next() { - var i AssessmentShortAnswer - if err := rows.Scan( - &i.ID, - &i.QuestionID, - &i.CorrectAnswer, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const GetUserAssessmentAttempts = `-- name: GetUserAssessmentAttempts :many -SELECT - id, - user_id, - total_questions, - total_points, - score, - percentage, - status, - started_at, - submitted_at, - evaluated_at -FROM assessment_attempts -WHERE user_id = $1 -ORDER BY started_at DESC -` - -type GetUserAssessmentAttemptsRow struct { - ID int64 `json:"id"` - UserID int64 `json:"user_id"` - TotalQuestions int32 `json:"total_questions"` - TotalPoints int32 `json:"total_points"` - Score pgtype.Int4 `json:"score"` - Percentage pgtype.Numeric `json:"percentage"` - Status string `json:"status"` - StartedAt pgtype.Timestamptz `json:"started_at"` - SubmittedAt pgtype.Timestamptz `json:"submitted_at"` - EvaluatedAt pgtype.Timestamptz `json:"evaluated_at"` -} - -func (q *Queries) GetUserAssessmentAttempts(ctx context.Context, userID int64) ([]GetUserAssessmentAttemptsRow, error) { - rows, err := q.db.Query(ctx, GetUserAssessmentAttempts, userID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetUserAssessmentAttemptsRow - for rows.Next() { - var i GetUserAssessmentAttemptsRow - if err := rows.Scan( - &i.ID, - &i.UserID, - &i.TotalQuestions, - &i.TotalPoints, - &i.Score, - &i.Percentage, - &i.Status, - &i.StartedAt, - &i.SubmittedAt, - &i.EvaluatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const SubmitAssessmentAttempt = `-- name: SubmitAssessmentAttempt :exec -UPDATE assessment_attempts -SET - status = 'SUBMITTED', - submitted_at = CURRENT_TIMESTAMP, - updated_at = CURRENT_TIMESTAMP -WHERE id = $1 -` - -func (q *Queries) SubmitAssessmentAttempt(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, SubmitAssessmentAttempt, id) - return err -} - -const UpdateAssessmentQuestion = `-- name: UpdateAssessmentQuestion :exec -UPDATE assessment_questions -SET - title = COALESCE($1, title), - description = COALESCE($2, description), - question_type = COALESCE($3, question_type), - difficulty_level = COALESCE($4, difficulty_level), - points = COALESCE($5, points), - is_active = COALESCE($6, is_active), - updated_at = CURRENT_TIMESTAMP -WHERE id = $7 -` - -type UpdateAssessmentQuestionParams struct { - Title string `json:"title"` - Description pgtype.Text `json:"description"` - QuestionType string `json:"question_type"` - DifficultyLevel pgtype.Text `json:"difficulty_level"` - Points int32 `json:"points"` - IsActive bool `json:"is_active"` - ID int64 `json:"id"` -} - -func (q *Queries) UpdateAssessmentQuestion(ctx context.Context, arg UpdateAssessmentQuestionParams) error { - _, err := q.db.Exec(ctx, UpdateAssessmentQuestion, - arg.Title, - arg.Description, - arg.QuestionType, - arg.DifficultyLevel, - arg.Points, - arg.IsActive, - arg.ID, - ) - return err -} - -const UpsertAttemptAnswer = `-- name: UpsertAttemptAnswer :exec -INSERT INTO assessment_attempt_answers ( - attempt_id, - question_id, - selected_option_id, - submitted_text -) -VALUES ( - $1, -- attempt_id - $2, -- question_id - $3, -- selected_option_id - $4 -- submitted_text -) -ON CONFLICT (attempt_id, question_id) -DO UPDATE SET - selected_option_id = EXCLUDED.selected_option_id, - submitted_text = EXCLUDED.submitted_text -` - -type UpsertAttemptAnswerParams struct { - AttemptID int64 `json:"attempt_id"` - QuestionID int64 `json:"question_id"` - SelectedOptionID pgtype.Int8 `json:"selected_option_id"` - SubmittedText pgtype.Text `json:"submitted_text"` -} - -func (q *Queries) UpsertAttemptAnswer(ctx context.Context, arg UpsertAttemptAnswerParams) error { - _, err := q.db.Exec(ctx, UpsertAttemptAnswer, - arg.AttemptID, - arg.QuestionID, - arg.SelectedOptionID, - arg.SubmittedText, - ) - return err -} diff --git a/gen/db/learning_tree.sql.go b/gen/db/learning_tree.sql.go index 18887ba..41af19e 100644 --- a/gen/db/learning_tree.sql.go +++ b/gen/db/learning_tree.sql.go @@ -15,29 +15,21 @@ const GetFullLearningTree = `-- name: GetFullLearningTree :many SELECT c.id AS course_id, c.title AS course_title, - p.id AS program_id, - p.title AS program_title, - l.id AS level_id, - l.title AS level_title, - m.id AS module_id, - m.title AS module_title + sc.id AS sub_course_id, + sc.title AS sub_course_title, + sc.level AS sub_course_level FROM courses c -JOIN programs p ON p.course_id = c.id -JOIN levels l ON l.program_id = p.id -LEFT JOIN modules m ON m.level_id = l.id +LEFT JOIN sub_courses sc ON sc.course_id = c.id AND sc.is_active = true WHERE c.is_active = true -ORDER BY p.display_order, l.level_index, m.display_order +ORDER BY c.id, sc.display_order, sc.id ` type GetFullLearningTreeRow struct { - CourseID int64 `json:"course_id"` - CourseTitle string `json:"course_title"` - ProgramID int64 `json:"program_id"` - ProgramTitle string `json:"program_title"` - LevelID int64 `json:"level_id"` - LevelTitle string `json:"level_title"` - ModuleID pgtype.Int8 `json:"module_id"` - ModuleTitle pgtype.Text `json:"module_title"` + CourseID int64 `json:"course_id"` + CourseTitle string `json:"course_title"` + SubCourseID pgtype.Int8 `json:"sub_course_id"` + SubCourseTitle pgtype.Text `json:"sub_course_title"` + SubCourseLevel pgtype.Text `json:"sub_course_level"` } func (q *Queries) GetFullLearningTree(ctx context.Context) ([]GetFullLearningTreeRow, error) { @@ -52,12 +44,9 @@ func (q *Queries) GetFullLearningTree(ctx context.Context) ([]GetFullLearningTre if err := rows.Scan( &i.CourseID, &i.CourseTitle, - &i.ProgramID, - &i.ProgramTitle, - &i.LevelID, - &i.LevelTitle, - &i.ModuleID, - &i.ModuleTitle, + &i.SubCourseID, + &i.SubCourseTitle, + &i.SubCourseLevel, ); err != nil { return nil, err } diff --git a/gen/db/level_modules.sql.go b/gen/db/level_modules.sql.go deleted file mode 100644 index 8651df6..0000000 --- a/gen/db/level_modules.sql.go +++ /dev/null @@ -1,143 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: level_modules.sql - -package dbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const CreateModule = `-- name: CreateModule :one -INSERT INTO modules ( - level_id, - title, - content, - display_order, - is_active -) -VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, true)) -RETURNING id, level_id, title, content, display_order, is_active -` - -type CreateModuleParams struct { - LevelID int64 `json:"level_id"` - Title string `json:"title"` - Content pgtype.Text `json:"content"` - Column4 interface{} `json:"column_4"` - Column5 interface{} `json:"column_5"` -} - -func (q *Queries) CreateModule(ctx context.Context, arg CreateModuleParams) (Module, error) { - row := q.db.QueryRow(ctx, CreateModule, - arg.LevelID, - arg.Title, - arg.Content, - arg.Column4, - arg.Column5, - ) - var i Module - err := row.Scan( - &i.ID, - &i.LevelID, - &i.Title, - &i.Content, - &i.DisplayOrder, - &i.IsActive, - ) - return i, err -} - -const DeleteModule = `-- name: DeleteModule :exec -DELETE FROM modules -WHERE id = $1 -` - -func (q *Queries) DeleteModule(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, DeleteModule, id) - return err -} - -const GetModulesByLevel = `-- name: GetModulesByLevel :many -SELECT - COUNT(*) OVER () AS total_count, - id, - level_id, - title, - content, - display_order, - is_active -FROM modules -WHERE level_id = $1 -ORDER BY display_order ASC -` - -type GetModulesByLevelRow struct { - TotalCount int64 `json:"total_count"` - ID int64 `json:"id"` - LevelID int64 `json:"level_id"` - Title string `json:"title"` - Content pgtype.Text `json:"content"` - DisplayOrder int32 `json:"display_order"` - IsActive bool `json:"is_active"` -} - -func (q *Queries) GetModulesByLevel(ctx context.Context, levelID int64) ([]GetModulesByLevelRow, error) { - rows, err := q.db.Query(ctx, GetModulesByLevel, levelID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetModulesByLevelRow - for rows.Next() { - var i GetModulesByLevelRow - if err := rows.Scan( - &i.TotalCount, - &i.ID, - &i.LevelID, - &i.Title, - &i.Content, - &i.DisplayOrder, - &i.IsActive, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const UpdateModule = `-- name: UpdateModule :exec -UPDATE modules -SET - title = COALESCE($1, title), - content = COALESCE($2, content), - display_order = COALESCE($3, display_order), - is_active = COALESCE($4, is_active) -WHERE id = $5 -` - -type UpdateModuleParams struct { - Title string `json:"title"` - Content pgtype.Text `json:"content"` - DisplayOrder int32 `json:"display_order"` - IsActive bool `json:"is_active"` - ID int64 `json:"id"` -} - -func (q *Queries) UpdateModule(ctx context.Context, arg UpdateModuleParams) error { - _, err := q.db.Exec(ctx, UpdateModule, - arg.Title, - arg.Content, - arg.DisplayOrder, - arg.IsActive, - arg.ID, - ) - return err -} diff --git a/gen/db/models.go b/gen/db/models.go index 4a966c6..a442246 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -8,75 +8,13 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -type AssessmentAttempt struct { - ID int64 `json:"id"` - UserID int64 `json:"user_id"` - TotalQuestions int32 `json:"total_questions"` - TotalPoints int32 `json:"total_points"` - Score pgtype.Int4 `json:"score"` - Percentage pgtype.Numeric `json:"percentage"` - Status string `json:"status"` - StartedAt pgtype.Timestamptz `json:"started_at"` - SubmittedAt pgtype.Timestamptz `json:"submitted_at"` - EvaluatedAt pgtype.Timestamptz `json:"evaluated_at"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` -} - -type AssessmentAttemptAnswer struct { - ID int64 `json:"id"` - AttemptID int64 `json:"attempt_id"` - QuestionID int64 `json:"question_id"` - SelectedOptionID pgtype.Int8 `json:"selected_option_id"` - SubmittedText pgtype.Text `json:"submitted_text"` - IsCorrect pgtype.Bool `json:"is_correct"` - AwardedPoints int32 `json:"awarded_points"` - CreatedAt pgtype.Timestamptz `json:"created_at"` -} - -type AssessmentAttemptQuestion struct { - ID int64 `json:"id"` - AttemptID int64 `json:"attempt_id"` - QuestionID int64 `json:"question_id"` - QuestionType string `json:"question_type"` - Points int32 `json:"points"` - CreatedAt pgtype.Timestamptz `json:"created_at"` -} - -type AssessmentQuestion struct { - ID int64 `json:"id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - QuestionType string `json:"question_type"` - DifficultyLevel pgtype.Text `json:"difficulty_level"` - Points int32 `json:"points"` - IsActive bool `json:"is_active"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` -} - -type AssessmentQuestionOption struct { - ID int64 `json:"id"` - QuestionID int64 `json:"question_id"` - OptionText string `json:"option_text"` - OptionOrder int32 `json:"option_order"` - IsCorrect bool `json:"is_correct"` - CreatedAt pgtype.Timestamptz `json:"created_at"` -} - -type AssessmentShortAnswer struct { - ID int64 `json:"id"` - QuestionID int64 `json:"question_id"` - CorrectAnswer string `json:"correct_answer"` - CreatedAt pgtype.Timestamptz `json:"created_at"` -} - type Course struct { ID int64 `json:"id"` CategoryID int64 `json:"category_id"` Title string `json:"title"` Description pgtype.Text `json:"description"` IsActive bool `json:"is_active"` + Thumbnail pgtype.Text `json:"thumbnail"` } type CourseCategory struct { @@ -103,41 +41,14 @@ type GlobalSetting struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } -type Level struct { - ID int64 `json:"id"` - ProgramID int64 `json:"program_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - LevelIndex int32 `json:"level_index"` - NumberOfModules int32 `json:"number_of_modules"` - NumberOfPractices int32 `json:"number_of_practices"` - NumberOfVideos int32 `json:"number_of_videos"` - IsActive bool `json:"is_active"` +type LevelToSubCourse struct { + LevelID int64 `json:"level_id"` + SubCourseID int64 `json:"sub_course_id"` } -type Module struct { - ID int64 `json:"id"` - LevelID int64 `json:"level_id"` - Title string `json:"title"` - Content pgtype.Text `json:"content"` - DisplayOrder int32 `json:"display_order"` - IsActive bool `json:"is_active"` -} - -type ModuleVideo struct { - ID int64 `json:"id"` - ModuleID int64 `json:"module_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - VideoUrl string `json:"video_url"` - Duration int32 `json:"duration"` - Resolution pgtype.Text `json:"resolution"` - IsPublished bool `json:"is_published"` - PublishDate pgtype.Timestamptz `json:"publish_date"` - Visibility pgtype.Text `json:"visibility"` - InstructorID pgtype.Text `json:"instructor_id"` - Thumbnail pgtype.Text `json:"thumbnail"` - IsActive bool `json:"is_active"` +type ModuleToSubCourse struct { + ModuleID int64 `json:"module_id"` + SubCourseID int64 `json:"sub_course_id"` } type Notification struct { @@ -167,36 +78,89 @@ type Otp struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } -type Practice struct { - ID int64 `json:"id"` - OwnerType string `json:"owner_type"` - OwnerID int64 `json:"owner_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - BannerImage pgtype.Text `json:"banner_image"` - Persona pgtype.Text `json:"persona"` - IsActive bool `json:"is_active"` +type Payment struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + PlanID pgtype.Int8 `json:"plan_id"` + SubscriptionID pgtype.Int8 `json:"subscription_id"` + SessionID pgtype.Text `json:"session_id"` + TransactionID pgtype.Text `json:"transaction_id"` + Nonce string `json:"nonce"` + Amount pgtype.Numeric `json:"amount"` + Currency string `json:"currency"` + PaymentMethod pgtype.Text `json:"payment_method"` + Status string `json:"status"` + PaymentUrl pgtype.Text `json:"payment_url"` + PaidAt pgtype.Timestamptz `json:"paid_at"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` } -type PracticeQuestion struct { - ID int64 `json:"id"` - PracticeID int64 `json:"practice_id"` - Question string `json:"question"` - QuestionVoicePrompt pgtype.Text `json:"question_voice_prompt"` - SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"` - SampleAnswer pgtype.Text `json:"sample_answer"` - Tips pgtype.Text `json:"tips"` - Type string `json:"type"` +type Question struct { + ID int64 `json:"id"` + QuestionText string `json:"question_text"` + QuestionType string `json:"question_type"` + DifficultyLevel pgtype.Text `json:"difficulty_level"` + Points int32 `json:"points"` + Explanation pgtype.Text `json:"explanation"` + Tips pgtype.Text `json:"tips"` + VoicePrompt pgtype.Text `json:"voice_prompt"` + SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` } -type Program struct { - ID int64 `json:"id"` - CourseID int64 `json:"course_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - DisplayOrder int32 `json:"display_order"` - IsActive bool `json:"is_active"` +type QuestionOption struct { + ID int64 `json:"id"` + QuestionID int64 `json:"question_id"` + OptionText string `json:"option_text"` + OptionOrder int32 `json:"option_order"` + IsCorrect bool `json:"is_correct"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type QuestionSet struct { + ID int64 `json:"id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + SetType string `json:"set_type"` + OwnerType pgtype.Text `json:"owner_type"` + OwnerID pgtype.Int8 `json:"owner_id"` + BannerImage pgtype.Text `json:"banner_image"` + Persona pgtype.Text `json:"persona"` + TimeLimitMinutes pgtype.Int4 `json:"time_limit_minutes"` + PassingScore pgtype.Int4 `json:"passing_score"` + ShuffleQuestions bool `json:"shuffle_questions"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"` +} + +type QuestionSetItem struct { + ID int64 `json:"id"` + SetID int64 `json:"set_id"` + QuestionID int64 `json:"question_id"` + DisplayOrder int32 `json:"display_order"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type QuestionSetPersona struct { + ID int64 `json:"id"` + QuestionSetID int64 `json:"question_set_id"` + UserID int64 `json:"user_id"` + DisplayOrder pgtype.Int4 `json:"display_order"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type QuestionShortAnswer struct { + ID int64 `json:"id"` + QuestionID int64 `json:"question_id"` + AcceptableAnswer string `json:"acceptable_answer"` + MatchType string `json:"match_type"` + CreatedAt pgtype.Timestamptz `json:"created_at"` } type RefreshToken struct { @@ -221,37 +185,130 @@ type ReportedIssue struct { UpdatedAt pgtype.Timestamp `json:"updated_at"` } -type User struct { - ID int64 `json:"id"` - FirstName pgtype.Text `json:"first_name"` - LastName pgtype.Text `json:"last_name"` - Gender pgtype.Text `json:"gender"` - BirthDay pgtype.Date `json:"birth_day"` - Email pgtype.Text `json:"email"` - PhoneNumber pgtype.Text `json:"phone_number"` - Role string `json:"role"` - Password []byte `json:"password"` - EducationLevel pgtype.Text `json:"education_level"` - Country pgtype.Text `json:"country"` - Region pgtype.Text `json:"region"` - KnowledgeLevel pgtype.Text `json:"knowledge_level"` - NickName pgtype.Text `json:"nick_name"` - Occupation pgtype.Text `json:"occupation"` - LearningGoal pgtype.Text `json:"learning_goal"` - LanguageGoal pgtype.Text `json:"language_goal"` - LanguageChallange pgtype.Text `json:"language_challange"` - FavouriteTopic pgtype.Text `json:"favourite_topic"` - InitialAssessmentCompleted bool `json:"initial_assessment_completed"` - EmailVerified bool `json:"email_verified"` - PhoneVerified bool `json:"phone_verified"` - Status string `json:"status"` - LastLogin pgtype.Timestamptz `json:"last_login"` - ProfileCompleted pgtype.Bool `json:"profile_completed"` - ProfilePictureUrl pgtype.Text `json:"profile_picture_url"` - PreferredLanguage pgtype.Text `json:"preferred_language"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - AgeGroup pgtype.Text `json:"age_group"` - GoogleID pgtype.Text `json:"google_id"` - GoogleEmailVerified pgtype.Bool `json:"google_email_verified"` +type SubCourse struct { + ID int64 `json:"id"` + CourseID int64 `json:"course_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` + DisplayOrder int32 `json:"display_order"` + Level string `json:"level"` + IsActive bool `json:"is_active"` +} + +type SubCourseVideo struct { + ID int64 `json:"id"` + SubCourseID int64 `json:"sub_course_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + VideoUrl string `json:"video_url"` + Duration int32 `json:"duration"` + Resolution pgtype.Text `json:"resolution"` + IsPublished bool `json:"is_published"` + PublishDate pgtype.Timestamptz `json:"publish_date"` + Visibility pgtype.Text `json:"visibility"` + InstructorID pgtype.Text `json:"instructor_id"` + Thumbnail pgtype.Text `json:"thumbnail"` + DisplayOrder int32 `json:"display_order"` + Status string `json:"status"` + // Vimeo video ID for videos hosted on Vimeo + VimeoID pgtype.Text `json:"vimeo_id"` + // Vimeo player embed URL + VimeoEmbedUrl pgtype.Text `json:"vimeo_embed_url"` + // Vimeo iframe embed HTML code + VimeoPlayerHtml pgtype.Text `json:"vimeo_player_html"` + // Vimeo video status: pending, uploading, transcoding, available, error + VimeoStatus pgtype.Text `json:"vimeo_status"` + // Video hosting provider: DIRECT or VIMEO + VideoHostProvider pgtype.Text `json:"video_host_provider"` +} + +type SubscriptionPlan struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description pgtype.Text `json:"description"` + DurationValue int32 `json:"duration_value"` + DurationUnit string `json:"duration_unit"` + Price pgtype.Numeric `json:"price"` + Currency string `json:"currency"` + IsActive bool `json:"is_active"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type TeamMember struct { + ID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + PhoneNumber pgtype.Text `json:"phone_number"` + Password []byte `json:"password"` + TeamRole string `json:"team_role"` + Department pgtype.Text `json:"department"` + JobTitle pgtype.Text `json:"job_title"` + EmploymentType pgtype.Text `json:"employment_type"` + HireDate pgtype.Date `json:"hire_date"` + ProfilePictureUrl pgtype.Text `json:"profile_picture_url"` + Bio pgtype.Text `json:"bio"` + WorkPhone pgtype.Text `json:"work_phone"` + EmergencyContact pgtype.Text `json:"emergency_contact"` + Status string `json:"status"` + EmailVerified bool `json:"email_verified"` + Permissions []byte `json:"permissions"` + LastLogin pgtype.Timestamptz `json:"last_login"` + CreatedBy pgtype.Int8 `json:"created_by"` + UpdatedBy pgtype.Int8 `json:"updated_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type User struct { + ID int64 `json:"id"` + FirstName pgtype.Text `json:"first_name"` + LastName pgtype.Text `json:"last_name"` + Gender pgtype.Text `json:"gender"` + BirthDay pgtype.Date `json:"birth_day"` + Email pgtype.Text `json:"email"` + PhoneNumber pgtype.Text `json:"phone_number"` + Role string `json:"role"` + Password []byte `json:"password"` + EducationLevel pgtype.Text `json:"education_level"` + Country pgtype.Text `json:"country"` + Region pgtype.Text `json:"region"` + KnowledgeLevel pgtype.Text `json:"knowledge_level"` + NickName pgtype.Text `json:"nick_name"` + Occupation pgtype.Text `json:"occupation"` + LearningGoal pgtype.Text `json:"learning_goal"` + LanguageGoal pgtype.Text `json:"language_goal"` + LanguageChallange pgtype.Text `json:"language_challange"` + FavouriteTopic pgtype.Text `json:"favourite_topic"` + InitialAssessmentCompleted bool `json:"initial_assessment_completed"` + EmailVerified bool `json:"email_verified"` + PhoneVerified bool `json:"phone_verified"` + Status string `json:"status"` + LastLogin pgtype.Timestamptz `json:"last_login"` + ProfileCompleted pgtype.Bool `json:"profile_completed"` + ProfilePictureUrl pgtype.Text `json:"profile_picture_url"` + PreferredLanguage pgtype.Text `json:"preferred_language"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + AgeGroup pgtype.Text `json:"age_group"` + GoogleID pgtype.Text `json:"google_id"` + GoogleEmailVerified pgtype.Bool `json:"google_email_verified"` + ProfileCompletionPercentage int16 `json:"profile_completion_percentage"` +} + +type UserSubscription struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + PlanID int64 `json:"plan_id"` + StartsAt pgtype.Timestamptz `json:"starts_at"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + Status string `json:"status"` + PaymentReference pgtype.Text `json:"payment_reference"` + PaymentMethod pgtype.Text `json:"payment_method"` + AutoRenew bool `json:"auto_renew"` + CancelledAt pgtype.Timestamptz `json:"cancelled_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` } diff --git a/gen/db/module_videos.sql.go b/gen/db/module_videos.sql.go deleted file mode 100644 index a27d7e3..0000000 --- a/gen/db/module_videos.sql.go +++ /dev/null @@ -1,185 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: module_videos.sql - -package dbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const CreateModuleVideo = `-- name: CreateModuleVideo :one -INSERT INTO module_videos ( - module_id, - title, - description, - video_url, - duration, - resolution, - instructor_id, - thumbnail, - visibility, - is_active -) -VALUES ( - $1, $2, $3, $4, $5, $6, - $7, $8, $9, - COALESCE($10, true) -) -RETURNING id, module_id, title, description, video_url, duration, resolution, is_published, publish_date, visibility, instructor_id, thumbnail, is_active -` - -type CreateModuleVideoParams struct { - ModuleID int64 `json:"module_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - VideoUrl string `json:"video_url"` - Duration int32 `json:"duration"` - Resolution pgtype.Text `json:"resolution"` - InstructorID pgtype.Text `json:"instructor_id"` - Thumbnail pgtype.Text `json:"thumbnail"` - Visibility pgtype.Text `json:"visibility"` - Column10 interface{} `json:"column_10"` -} - -func (q *Queries) CreateModuleVideo(ctx context.Context, arg CreateModuleVideoParams) (ModuleVideo, error) { - row := q.db.QueryRow(ctx, CreateModuleVideo, - arg.ModuleID, - arg.Title, - arg.Description, - arg.VideoUrl, - arg.Duration, - arg.Resolution, - arg.InstructorID, - arg.Thumbnail, - arg.Visibility, - arg.Column10, - ) - var i ModuleVideo - err := row.Scan( - &i.ID, - &i.ModuleID, - &i.Title, - &i.Description, - &i.VideoUrl, - &i.Duration, - &i.Resolution, - &i.IsPublished, - &i.PublishDate, - &i.Visibility, - &i.InstructorID, - &i.Thumbnail, - &i.IsActive, - ) - return i, err -} - -const DeleteModuleVideo = `-- name: DeleteModuleVideo :exec -DELETE FROM module_videos -WHERE id = $1 -` - -func (q *Queries) DeleteModuleVideo(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, DeleteModuleVideo, id) - return err -} - -const GetPublishedVideosByModule = `-- name: GetPublishedVideosByModule :many -SELECT id, module_id, title, description, video_url, duration, resolution, is_published, publish_date, visibility, instructor_id, thumbnail, is_active -FROM module_videos -WHERE module_id = $1 - AND is_published = true - AND is_active = true -ORDER BY publish_date ASC -` - -func (q *Queries) GetPublishedVideosByModule(ctx context.Context, moduleID int64) ([]ModuleVideo, error) { - rows, err := q.db.Query(ctx, GetPublishedVideosByModule, moduleID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []ModuleVideo - for rows.Next() { - var i ModuleVideo - if err := rows.Scan( - &i.ID, - &i.ModuleID, - &i.Title, - &i.Description, - &i.VideoUrl, - &i.Duration, - &i.Resolution, - &i.IsPublished, - &i.PublishDate, - &i.Visibility, - &i.InstructorID, - &i.Thumbnail, - &i.IsActive, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const PublishModuleVideo = `-- name: PublishModuleVideo :exec -UPDATE module_videos -SET - is_published = true, - publish_date = CURRENT_TIMESTAMP -WHERE id = $1 -` - -func (q *Queries) PublishModuleVideo(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, PublishModuleVideo, id) - return err -} - -const UpdateModuleVideo = `-- name: UpdateModuleVideo :exec -UPDATE module_videos -SET - title = COALESCE($1, title), - description = COALESCE($2, description), - video_url = COALESCE($3, video_url), - duration = COALESCE($4, duration), - resolution = COALESCE($5, resolution), - visibility = COALESCE($6, visibility), - thumbnail = COALESCE($7, thumbnail), - is_active = COALESCE($8, is_active) -WHERE id = $9 -` - -type UpdateModuleVideoParams struct { - Title string `json:"title"` - Description pgtype.Text `json:"description"` - VideoUrl string `json:"video_url"` - Duration int32 `json:"duration"` - Resolution pgtype.Text `json:"resolution"` - Visibility pgtype.Text `json:"visibility"` - Thumbnail pgtype.Text `json:"thumbnail"` - IsActive bool `json:"is_active"` - ID int64 `json:"id"` -} - -func (q *Queries) UpdateModuleVideo(ctx context.Context, arg UpdateModuleVideoParams) error { - _, err := q.db.Exec(ctx, UpdateModuleVideo, - arg.Title, - arg.Description, - arg.VideoUrl, - arg.Duration, - arg.Resolution, - arg.Visibility, - arg.Thumbnail, - arg.IsActive, - arg.ID, - ) - return err -} diff --git a/gen/db/payments.sql.go b/gen/db/payments.sql.go new file mode 100644 index 0000000..40a78d5 --- /dev/null +++ b/gen/db/payments.sql.go @@ -0,0 +1,486 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: payments.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const CountUserPayments = `-- name: CountUserPayments :one +SELECT COUNT(*) FROM payments WHERE user_id = $1 +` + +func (q *Queries) CountUserPayments(ctx context.Context, userID int64) (int64, error) { + row := q.db.QueryRow(ctx, CountUserPayments, userID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const CreatePayment = `-- name: CreatePayment :one + +INSERT INTO payments ( + user_id, plan_id, subscription_id, session_id, transaction_id, nonce, + amount, currency, payment_method, status, payment_url, expires_at +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, 'PENDING'), $11, $12) +RETURNING id, user_id, plan_id, subscription_id, session_id, transaction_id, nonce, amount, currency, payment_method, status, payment_url, paid_at, expires_at, created_at, updated_at +` + +type CreatePaymentParams struct { + UserID int64 `json:"user_id"` + PlanID pgtype.Int8 `json:"plan_id"` + SubscriptionID pgtype.Int8 `json:"subscription_id"` + SessionID pgtype.Text `json:"session_id"` + TransactionID pgtype.Text `json:"transaction_id"` + Nonce string `json:"nonce"` + Amount pgtype.Numeric `json:"amount"` + Currency string `json:"currency"` + PaymentMethod pgtype.Text `json:"payment_method"` + Column10 interface{} `json:"column_10"` + PaymentUrl pgtype.Text `json:"payment_url"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` +} + +// ===================== +// Payments +// ===================== +func (q *Queries) CreatePayment(ctx context.Context, arg CreatePaymentParams) (Payment, error) { + row := q.db.QueryRow(ctx, CreatePayment, + arg.UserID, + arg.PlanID, + arg.SubscriptionID, + arg.SessionID, + arg.TransactionID, + arg.Nonce, + arg.Amount, + arg.Currency, + arg.PaymentMethod, + arg.Column10, + arg.PaymentUrl, + arg.ExpiresAt, + ) + var i Payment + err := row.Scan( + &i.ID, + &i.UserID, + &i.PlanID, + &i.SubscriptionID, + &i.SessionID, + &i.TransactionID, + &i.Nonce, + &i.Amount, + &i.Currency, + &i.PaymentMethod, + &i.Status, + &i.PaymentUrl, + &i.PaidAt, + &i.ExpiresAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const ExpirePayment = `-- name: ExpirePayment :exec +UPDATE payments +SET + status = 'EXPIRED', + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 +` + +func (q *Queries) ExpirePayment(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, ExpirePayment, id) + return err +} + +const GetExpiredPendingPayments = `-- name: GetExpiredPendingPayments :many +SELECT id, user_id, plan_id, subscription_id, session_id, transaction_id, nonce, amount, currency, payment_method, status, payment_url, paid_at, expires_at, created_at, updated_at FROM payments +WHERE status = 'PENDING' + AND expires_at IS NOT NULL + AND expires_at <= CURRENT_TIMESTAMP +` + +func (q *Queries) GetExpiredPendingPayments(ctx context.Context) ([]Payment, error) { + rows, err := q.db.Query(ctx, GetExpiredPendingPayments) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Payment + for rows.Next() { + var i Payment + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.PlanID, + &i.SubscriptionID, + &i.SessionID, + &i.TransactionID, + &i.Nonce, + &i.Amount, + &i.Currency, + &i.PaymentMethod, + &i.Status, + &i.PaymentUrl, + &i.PaidAt, + &i.ExpiresAt, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetPaymentByID = `-- name: GetPaymentByID :one +SELECT id, user_id, plan_id, subscription_id, session_id, transaction_id, nonce, amount, currency, payment_method, status, payment_url, paid_at, expires_at, created_at, updated_at FROM payments WHERE id = $1 +` + +func (q *Queries) GetPaymentByID(ctx context.Context, id int64) (Payment, error) { + row := q.db.QueryRow(ctx, GetPaymentByID, id) + var i Payment + err := row.Scan( + &i.ID, + &i.UserID, + &i.PlanID, + &i.SubscriptionID, + &i.SessionID, + &i.TransactionID, + &i.Nonce, + &i.Amount, + &i.Currency, + &i.PaymentMethod, + &i.Status, + &i.PaymentUrl, + &i.PaidAt, + &i.ExpiresAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const GetPaymentByNonce = `-- name: GetPaymentByNonce :one +SELECT id, user_id, plan_id, subscription_id, session_id, transaction_id, nonce, amount, currency, payment_method, status, payment_url, paid_at, expires_at, created_at, updated_at FROM payments WHERE nonce = $1 +` + +func (q *Queries) GetPaymentByNonce(ctx context.Context, nonce string) (Payment, error) { + row := q.db.QueryRow(ctx, GetPaymentByNonce, nonce) + var i Payment + err := row.Scan( + &i.ID, + &i.UserID, + &i.PlanID, + &i.SubscriptionID, + &i.SessionID, + &i.TransactionID, + &i.Nonce, + &i.Amount, + &i.Currency, + &i.PaymentMethod, + &i.Status, + &i.PaymentUrl, + &i.PaidAt, + &i.ExpiresAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const GetPaymentBySessionID = `-- name: GetPaymentBySessionID :one +SELECT id, user_id, plan_id, subscription_id, session_id, transaction_id, nonce, amount, currency, payment_method, status, payment_url, paid_at, expires_at, created_at, updated_at FROM payments WHERE session_id = $1 +` + +func (q *Queries) GetPaymentBySessionID(ctx context.Context, sessionID pgtype.Text) (Payment, error) { + row := q.db.QueryRow(ctx, GetPaymentBySessionID, sessionID) + var i Payment + err := row.Scan( + &i.ID, + &i.UserID, + &i.PlanID, + &i.SubscriptionID, + &i.SessionID, + &i.TransactionID, + &i.Nonce, + &i.Amount, + &i.Currency, + &i.PaymentMethod, + &i.Status, + &i.PaymentUrl, + &i.PaidAt, + &i.ExpiresAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const GetPaymentByTransactionID = `-- name: GetPaymentByTransactionID :one +SELECT id, user_id, plan_id, subscription_id, session_id, transaction_id, nonce, amount, currency, payment_method, status, payment_url, paid_at, expires_at, created_at, updated_at FROM payments WHERE transaction_id = $1 +` + +func (q *Queries) GetPaymentByTransactionID(ctx context.Context, transactionID pgtype.Text) (Payment, error) { + row := q.db.QueryRow(ctx, GetPaymentByTransactionID, transactionID) + var i Payment + err := row.Scan( + &i.ID, + &i.UserID, + &i.PlanID, + &i.SubscriptionID, + &i.SessionID, + &i.TransactionID, + &i.Nonce, + &i.Amount, + &i.Currency, + &i.PaymentMethod, + &i.Status, + &i.PaymentUrl, + &i.PaidAt, + &i.ExpiresAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const GetPaymentsByUserID = `-- name: GetPaymentsByUserID :many +SELECT p.id, p.user_id, p.plan_id, p.subscription_id, p.session_id, p.transaction_id, p.nonce, p.amount, p.currency, p.payment_method, p.status, p.payment_url, p.paid_at, p.expires_at, p.created_at, p.updated_at, sp.name AS plan_name +FROM payments p +LEFT JOIN subscription_plans sp ON sp.id = p.plan_id +WHERE p.user_id = $1 +ORDER BY p.created_at DESC +LIMIT $3::INT +OFFSET $2::INT +` + +type GetPaymentsByUserIDParams struct { + UserID int64 `json:"user_id"` + Offset pgtype.Int4 `json:"offset"` + Limit pgtype.Int4 `json:"limit"` +} + +type GetPaymentsByUserIDRow struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + PlanID pgtype.Int8 `json:"plan_id"` + SubscriptionID pgtype.Int8 `json:"subscription_id"` + SessionID pgtype.Text `json:"session_id"` + TransactionID pgtype.Text `json:"transaction_id"` + Nonce string `json:"nonce"` + Amount pgtype.Numeric `json:"amount"` + Currency string `json:"currency"` + PaymentMethod pgtype.Text `json:"payment_method"` + Status string `json:"status"` + PaymentUrl pgtype.Text `json:"payment_url"` + PaidAt pgtype.Timestamptz `json:"paid_at"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + PlanName pgtype.Text `json:"plan_name"` +} + +func (q *Queries) GetPaymentsByUserID(ctx context.Context, arg GetPaymentsByUserIDParams) ([]GetPaymentsByUserIDRow, error) { + rows, err := q.db.Query(ctx, GetPaymentsByUserID, arg.UserID, arg.Offset, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetPaymentsByUserIDRow + for rows.Next() { + var i GetPaymentsByUserIDRow + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.PlanID, + &i.SubscriptionID, + &i.SessionID, + &i.TransactionID, + &i.Nonce, + &i.Amount, + &i.Currency, + &i.PaymentMethod, + &i.Status, + &i.PaymentUrl, + &i.PaidAt, + &i.ExpiresAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.PlanName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetPendingPaymentsByUserID = `-- name: GetPendingPaymentsByUserID :many +SELECT id, user_id, plan_id, subscription_id, session_id, transaction_id, nonce, amount, currency, payment_method, status, payment_url, paid_at, expires_at, created_at, updated_at FROM payments +WHERE user_id = $1 AND status = 'PENDING' +ORDER BY created_at DESC +` + +func (q *Queries) GetPendingPaymentsByUserID(ctx context.Context, userID int64) ([]Payment, error) { + rows, err := q.db.Query(ctx, GetPendingPaymentsByUserID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Payment + for rows.Next() { + var i Payment + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.PlanID, + &i.SubscriptionID, + &i.SessionID, + &i.TransactionID, + &i.Nonce, + &i.Amount, + &i.Currency, + &i.PaymentMethod, + &i.Status, + &i.PaymentUrl, + &i.PaidAt, + &i.ExpiresAt, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const LinkPaymentToSubscription = `-- name: LinkPaymentToSubscription :exec +UPDATE payments +SET + subscription_id = $1, + updated_at = CURRENT_TIMESTAMP +WHERE id = $2 +` + +type LinkPaymentToSubscriptionParams struct { + SubscriptionID pgtype.Int8 `json:"subscription_id"` + ID int64 `json:"id"` +} + +func (q *Queries) LinkPaymentToSubscription(ctx context.Context, arg LinkPaymentToSubscriptionParams) error { + _, err := q.db.Exec(ctx, LinkPaymentToSubscription, arg.SubscriptionID, arg.ID) + return err +} + +const UpdatePaymentSessionID = `-- name: UpdatePaymentSessionID :exec +UPDATE payments +SET + session_id = $1, + payment_url = $2, + updated_at = CURRENT_TIMESTAMP +WHERE id = $3 +` + +type UpdatePaymentSessionIDParams struct { + SessionID pgtype.Text `json:"session_id"` + PaymentUrl pgtype.Text `json:"payment_url"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdatePaymentSessionID(ctx context.Context, arg UpdatePaymentSessionIDParams) error { + _, err := q.db.Exec(ctx, UpdatePaymentSessionID, arg.SessionID, arg.PaymentUrl, arg.ID) + return err +} + +const UpdatePaymentStatus = `-- name: UpdatePaymentStatus :exec +UPDATE payments +SET + status = $1, + updated_at = CURRENT_TIMESTAMP +WHERE id = $2 +` + +type UpdatePaymentStatusParams struct { + Status string `json:"status"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdatePaymentStatus(ctx context.Context, arg UpdatePaymentStatusParams) error { + _, err := q.db.Exec(ctx, UpdatePaymentStatus, arg.Status, arg.ID) + return err +} + +const UpdatePaymentStatusByNonce = `-- name: UpdatePaymentStatusByNonce :exec +UPDATE payments +SET + status = $1, + transaction_id = COALESCE($2, transaction_id), + payment_method = COALESCE($3, payment_method), + paid_at = CASE WHEN $1 = 'SUCCESS' THEN CURRENT_TIMESTAMP ELSE paid_at END, + updated_at = CURRENT_TIMESTAMP +WHERE nonce = $4 +` + +type UpdatePaymentStatusByNonceParams struct { + Status string `json:"status"` + TransactionID pgtype.Text `json:"transaction_id"` + PaymentMethod pgtype.Text `json:"payment_method"` + Nonce string `json:"nonce"` +} + +func (q *Queries) UpdatePaymentStatusByNonce(ctx context.Context, arg UpdatePaymentStatusByNonceParams) error { + _, err := q.db.Exec(ctx, UpdatePaymentStatusByNonce, + arg.Status, + arg.TransactionID, + arg.PaymentMethod, + arg.Nonce, + ) + return err +} + +const UpdatePaymentStatusBySessionID = `-- name: UpdatePaymentStatusBySessionID :exec +UPDATE payments +SET + status = $1, + transaction_id = COALESCE($2, transaction_id), + payment_method = COALESCE($3, payment_method), + paid_at = CASE WHEN $1 = 'SUCCESS' THEN CURRENT_TIMESTAMP ELSE paid_at END, + updated_at = CURRENT_TIMESTAMP +WHERE session_id = $4 +` + +type UpdatePaymentStatusBySessionIDParams struct { + Status string `json:"status"` + TransactionID pgtype.Text `json:"transaction_id"` + PaymentMethod pgtype.Text `json:"payment_method"` + SessionID pgtype.Text `json:"session_id"` +} + +func (q *Queries) UpdatePaymentStatusBySessionID(ctx context.Context, arg UpdatePaymentStatusBySessionIDParams) error { + _, err := q.db.Exec(ctx, UpdatePaymentStatusBySessionID, + arg.Status, + arg.TransactionID, + arg.PaymentMethod, + arg.SessionID, + ) + return err +} diff --git a/gen/db/practice_questions.sql.go b/gen/db/practice_questions.sql.go deleted file mode 100644 index 1984a82..0000000 --- a/gen/db/practice_questions.sql.go +++ /dev/null @@ -1,135 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: practice_questions.sql - -package dbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const CreatePracticeQuestion = `-- name: CreatePracticeQuestion :one -INSERT INTO practice_questions ( - practice_id, - question, - question_voice_prompt, - sample_answer_voice_prompt, - sample_answer, - tips, - type -) -VALUES ($1, $2, $3, $4, $5, $6, $7) -RETURNING id, practice_id, question, question_voice_prompt, sample_answer_voice_prompt, sample_answer, tips, type -` - -type CreatePracticeQuestionParams struct { - PracticeID int64 `json:"practice_id"` - Question string `json:"question"` - QuestionVoicePrompt pgtype.Text `json:"question_voice_prompt"` - SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"` - SampleAnswer pgtype.Text `json:"sample_answer"` - Tips pgtype.Text `json:"tips"` - Type string `json:"type"` -} - -func (q *Queries) CreatePracticeQuestion(ctx context.Context, arg CreatePracticeQuestionParams) (PracticeQuestion, error) { - row := q.db.QueryRow(ctx, CreatePracticeQuestion, - arg.PracticeID, - arg.Question, - arg.QuestionVoicePrompt, - arg.SampleAnswerVoicePrompt, - arg.SampleAnswer, - arg.Tips, - arg.Type, - ) - var i PracticeQuestion - err := row.Scan( - &i.ID, - &i.PracticeID, - &i.Question, - &i.QuestionVoicePrompt, - &i.SampleAnswerVoicePrompt, - &i.SampleAnswer, - &i.Tips, - &i.Type, - ) - return i, err -} - -const DeletePracticeQuestion = `-- name: DeletePracticeQuestion :exec -DELETE FROM practice_questions -WHERE id = $1 -` - -func (q *Queries) DeletePracticeQuestion(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, DeletePracticeQuestion, id) - return err -} - -const GetQuestionsByPractice = `-- name: GetQuestionsByPractice :many -SELECT id, practice_id, question, question_voice_prompt, sample_answer_voice_prompt, sample_answer, tips, type -FROM practice_questions -WHERE practice_id = $1 -ORDER BY id ASC -` - -func (q *Queries) GetQuestionsByPractice(ctx context.Context, practiceID int64) ([]PracticeQuestion, error) { - rows, err := q.db.Query(ctx, GetQuestionsByPractice, practiceID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []PracticeQuestion - for rows.Next() { - var i PracticeQuestion - if err := rows.Scan( - &i.ID, - &i.PracticeID, - &i.Question, - &i.QuestionVoicePrompt, - &i.SampleAnswerVoicePrompt, - &i.SampleAnswer, - &i.Tips, - &i.Type, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const UpdatePracticeQuestion = `-- name: UpdatePracticeQuestion :exec -UPDATE practice_questions -SET - question = COALESCE($1, question), - sample_answer = COALESCE($2, sample_answer), - tips = COALESCE($3, tips), - type = COALESCE($4, type) -WHERE id = $5 -` - -type UpdatePracticeQuestionParams struct { - Question string `json:"question"` - SampleAnswer pgtype.Text `json:"sample_answer"` - Tips pgtype.Text `json:"tips"` - Type string `json:"type"` - ID int64 `json:"id"` -} - -func (q *Queries) UpdatePracticeQuestion(ctx context.Context, arg UpdatePracticeQuestionParams) error { - _, err := q.db.Exec(ctx, UpdatePracticeQuestion, - arg.Question, - arg.SampleAnswer, - arg.Tips, - arg.Type, - arg.ID, - ) - return err -} diff --git a/gen/db/practices.sql.go b/gen/db/practices.sql.go deleted file mode 100644 index da78787..0000000 --- a/gen/db/practices.sql.go +++ /dev/null @@ -1,144 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: practices.sql - -package dbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const CreatePractice = `-- name: CreatePractice :one -INSERT INTO practices ( - owner_type, - owner_id, - title, - description, - banner_image, - persona, - is_active -) -VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, true)) -RETURNING id, owner_type, owner_id, title, description, banner_image, persona, is_active -` - -type CreatePracticeParams struct { - OwnerType string `json:"owner_type"` - OwnerID int64 `json:"owner_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - BannerImage pgtype.Text `json:"banner_image"` - Persona pgtype.Text `json:"persona"` - Column7 interface{} `json:"column_7"` -} - -func (q *Queries) CreatePractice(ctx context.Context, arg CreatePracticeParams) (Practice, error) { - row := q.db.QueryRow(ctx, CreatePractice, - arg.OwnerType, - arg.OwnerID, - arg.Title, - arg.Description, - arg.BannerImage, - arg.Persona, - arg.Column7, - ) - var i Practice - err := row.Scan( - &i.ID, - &i.OwnerType, - &i.OwnerID, - &i.Title, - &i.Description, - &i.BannerImage, - &i.Persona, - &i.IsActive, - ) - return i, err -} - -const DeletePractice = `-- name: DeletePractice :exec -DELETE FROM practices -WHERE id = $1 -` - -func (q *Queries) DeletePractice(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, DeletePractice, id) - return err -} - -const GetPracticesByOwner = `-- name: GetPracticesByOwner :many -SELECT id, owner_type, owner_id, title, description, banner_image, persona, is_active -FROM practices -WHERE owner_type = $1 - AND owner_id = $2 - AND is_active = true -` - -type GetPracticesByOwnerParams struct { - OwnerType string `json:"owner_type"` - OwnerID int64 `json:"owner_id"` -} - -func (q *Queries) GetPracticesByOwner(ctx context.Context, arg GetPracticesByOwnerParams) ([]Practice, error) { - rows, err := q.db.Query(ctx, GetPracticesByOwner, arg.OwnerType, arg.OwnerID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Practice - for rows.Next() { - var i Practice - if err := rows.Scan( - &i.ID, - &i.OwnerType, - &i.OwnerID, - &i.Title, - &i.Description, - &i.BannerImage, - &i.Persona, - &i.IsActive, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const UpdatePractice = `-- name: UpdatePractice :exec -UPDATE practices -SET - title = COALESCE($1, title), - description = COALESCE($2, description), - banner_image = COALESCE($3, banner_image), - persona = COALESCE($4, persona), - is_active = COALESCE($5, is_active) -WHERE id = $6 -` - -type UpdatePracticeParams struct { - Title string `json:"title"` - Description pgtype.Text `json:"description"` - BannerImage pgtype.Text `json:"banner_image"` - Persona pgtype.Text `json:"persona"` - IsActive bool `json:"is_active"` - ID int64 `json:"id"` -} - -func (q *Queries) UpdatePractice(ctx context.Context, arg UpdatePracticeParams) error { - _, err := q.db.Exec(ctx, UpdatePractice, - arg.Title, - arg.Description, - arg.BannerImage, - arg.Persona, - arg.IsActive, - arg.ID, - ) - return err -} diff --git a/gen/db/program_levels.sql.go b/gen/db/program_levels.sql.go deleted file mode 100644 index 3414032..0000000 --- a/gen/db/program_levels.sql.go +++ /dev/null @@ -1,188 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: program_levels.sql - -package dbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const CreateLevel = `-- name: CreateLevel :one -INSERT INTO levels ( - program_id, - title, - description, - level_index, - is_active -) -VALUES ($1, $2, $3, $4, COALESCE($5, true)) -RETURNING id, program_id, title, description, level_index, number_of_modules, number_of_practices, number_of_videos, is_active -` - -type CreateLevelParams struct { - ProgramID int64 `json:"program_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - LevelIndex int32 `json:"level_index"` - Column5 interface{} `json:"column_5"` -} - -func (q *Queries) CreateLevel(ctx context.Context, arg CreateLevelParams) (Level, error) { - row := q.db.QueryRow(ctx, CreateLevel, - arg.ProgramID, - arg.Title, - arg.Description, - arg.LevelIndex, - arg.Column5, - ) - var i Level - err := row.Scan( - &i.ID, - &i.ProgramID, - &i.Title, - &i.Description, - &i.LevelIndex, - &i.NumberOfModules, - &i.NumberOfPractices, - &i.NumberOfVideos, - &i.IsActive, - ) - return i, err -} - -const DeleteLevel = `-- name: DeleteLevel :exec -DELETE FROM levels -WHERE id = $1 -` - -func (q *Queries) DeleteLevel(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, DeleteLevel, id) - return err -} - -const GetLevelsByProgram = `-- name: GetLevelsByProgram :many -SELECT - COUNT(*) OVER () AS total_count, - id, - program_id, - title, - description, - level_index, - number_of_modules, - number_of_practices, - number_of_videos, - is_active -FROM levels -WHERE program_id = $1 -ORDER BY level_index ASC -` - -type GetLevelsByProgramRow struct { - TotalCount int64 `json:"total_count"` - ID int64 `json:"id"` - ProgramID int64 `json:"program_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - LevelIndex int32 `json:"level_index"` - NumberOfModules int32 `json:"number_of_modules"` - NumberOfPractices int32 `json:"number_of_practices"` - NumberOfVideos int32 `json:"number_of_videos"` - IsActive bool `json:"is_active"` -} - -func (q *Queries) GetLevelsByProgram(ctx context.Context, programID int64) ([]GetLevelsByProgramRow, error) { - rows, err := q.db.Query(ctx, GetLevelsByProgram, programID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetLevelsByProgramRow - for rows.Next() { - var i GetLevelsByProgramRow - if err := rows.Scan( - &i.TotalCount, - &i.ID, - &i.ProgramID, - &i.Title, - &i.Description, - &i.LevelIndex, - &i.NumberOfModules, - &i.NumberOfPractices, - &i.NumberOfVideos, - &i.IsActive, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const IncrementLevelModuleCount = `-- name: IncrementLevelModuleCount :exec -UPDATE levels -SET number_of_modules = number_of_modules + 1 -WHERE id = $1 -` - -func (q *Queries) IncrementLevelModuleCount(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, IncrementLevelModuleCount, id) - return err -} - -const IncrementLevelPracticeCount = `-- name: IncrementLevelPracticeCount :exec -UPDATE levels -SET number_of_practices = number_of_practices + 1 -WHERE id = $1 -` - -func (q *Queries) IncrementLevelPracticeCount(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, IncrementLevelPracticeCount, id) - return err -} - -const IncrementLevelVideoCount = `-- name: IncrementLevelVideoCount :exec -UPDATE levels -SET number_of_videos = number_of_videos + 1 -WHERE id = $1 -` - -func (q *Queries) IncrementLevelVideoCount(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, IncrementLevelVideoCount, id) - return err -} - -const UpdateLevel = `-- name: UpdateLevel :exec -UPDATE levels -SET - title = COALESCE($1, title), - description = COALESCE($2, description), - level_index = COALESCE($3, level_index), - is_active = COALESCE($4, is_active) -WHERE id = $5 -` - -type UpdateLevelParams struct { - Title string `json:"title"` - Description pgtype.Text `json:"description"` - LevelIndex int32 `json:"level_index"` - IsActive bool `json:"is_active"` - ID int64 `json:"id"` -} - -func (q *Queries) UpdateLevel(ctx context.Context, arg UpdateLevelParams) error { - _, err := q.db.Exec(ctx, UpdateLevel, - arg.Title, - arg.Description, - arg.LevelIndex, - arg.IsActive, - arg.ID, - ) - return err -} diff --git a/gen/db/question_options.sql.go b/gen/db/question_options.sql.go new file mode 100644 index 0000000..c82c256 --- /dev/null +++ b/gen/db/question_options.sql.go @@ -0,0 +1,134 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: question_options.sql + +package dbgen + +import ( + "context" +) + +type BulkCreateQuestionOptionsParams struct { + QuestionID int64 `json:"question_id"` + OptionText string `json:"option_text"` + OptionOrder int32 `json:"option_order"` + IsCorrect bool `json:"is_correct"` +} + +const CreateQuestionOption = `-- name: CreateQuestionOption :one +INSERT INTO question_options ( + question_id, + option_text, + option_order, + is_correct +) +VALUES ($1, $2, COALESCE($3, 0), COALESCE($4, false)) +RETURNING id, question_id, option_text, option_order, is_correct, created_at +` + +type CreateQuestionOptionParams struct { + QuestionID int64 `json:"question_id"` + OptionText string `json:"option_text"` + Column3 interface{} `json:"column_3"` + Column4 interface{} `json:"column_4"` +} + +func (q *Queries) CreateQuestionOption(ctx context.Context, arg CreateQuestionOptionParams) (QuestionOption, error) { + row := q.db.QueryRow(ctx, CreateQuestionOption, + arg.QuestionID, + arg.OptionText, + arg.Column3, + arg.Column4, + ) + var i QuestionOption + err := row.Scan( + &i.ID, + &i.QuestionID, + &i.OptionText, + &i.OptionOrder, + &i.IsCorrect, + &i.CreatedAt, + ) + return i, err +} + +const DeleteOptionsByQuestionID = `-- name: DeleteOptionsByQuestionID :exec +DELETE FROM question_options +WHERE question_id = $1 +` + +func (q *Queries) DeleteOptionsByQuestionID(ctx context.Context, questionID int64) error { + _, err := q.db.Exec(ctx, DeleteOptionsByQuestionID, questionID) + return err +} + +const DeleteQuestionOption = `-- name: DeleteQuestionOption :exec +DELETE FROM question_options +WHERE id = $1 +` + +func (q *Queries) DeleteQuestionOption(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, DeleteQuestionOption, id) + return err +} + +const GetOptionsByQuestionID = `-- name: GetOptionsByQuestionID :many +SELECT id, question_id, option_text, option_order, is_correct, created_at +FROM question_options +WHERE question_id = $1 +ORDER BY option_order +` + +func (q *Queries) GetOptionsByQuestionID(ctx context.Context, questionID int64) ([]QuestionOption, error) { + rows, err := q.db.Query(ctx, GetOptionsByQuestionID, questionID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []QuestionOption + for rows.Next() { + var i QuestionOption + if err := rows.Scan( + &i.ID, + &i.QuestionID, + &i.OptionText, + &i.OptionOrder, + &i.IsCorrect, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const UpdateQuestionOption = `-- name: UpdateQuestionOption :exec +UPDATE question_options +SET + option_text = COALESCE($1, option_text), + option_order = COALESCE($2, option_order), + is_correct = COALESCE($3, is_correct) +WHERE id = $4 +` + +type UpdateQuestionOptionParams struct { + OptionText string `json:"option_text"` + OptionOrder int32 `json:"option_order"` + IsCorrect bool `json:"is_correct"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateQuestionOption(ctx context.Context, arg UpdateQuestionOptionParams) error { + _, err := q.db.Exec(ctx, UpdateQuestionOption, + arg.OptionText, + arg.OptionOrder, + arg.IsCorrect, + arg.ID, + ) + return err +} diff --git a/gen/db/question_set_items.sql.go b/gen/db/question_set_items.sql.go new file mode 100644 index 0000000..67c5960 --- /dev/null +++ b/gen/db/question_set_items.sql.go @@ -0,0 +1,268 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: question_set_items.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const AddQuestionToSet = `-- name: AddQuestionToSet :one +INSERT INTO question_set_items ( + set_id, + question_id, + display_order +) +VALUES ($1, $2, COALESCE($3, 0)) +ON CONFLICT (set_id, question_id) DO UPDATE SET display_order = EXCLUDED.display_order +RETURNING id, set_id, question_id, display_order, created_at +` + +type AddQuestionToSetParams struct { + SetID int64 `json:"set_id"` + QuestionID int64 `json:"question_id"` + Column3 interface{} `json:"column_3"` +} + +func (q *Queries) AddQuestionToSet(ctx context.Context, arg AddQuestionToSetParams) (QuestionSetItem, error) { + row := q.db.QueryRow(ctx, AddQuestionToSet, arg.SetID, arg.QuestionID, arg.Column3) + var i QuestionSetItem + err := row.Scan( + &i.ID, + &i.SetID, + &i.QuestionID, + &i.DisplayOrder, + &i.CreatedAt, + ) + return i, err +} + +const CountQuestionsInSet = `-- name: CountQuestionsInSet :one +SELECT COUNT(*) as count +FROM question_set_items qsi +JOIN questions q ON q.id = qsi.question_id +WHERE qsi.set_id = $1 + AND q.status != 'ARCHIVED' +` + +func (q *Queries) CountQuestionsInSet(ctx context.Context, setID int64) (int64, error) { + row := q.db.QueryRow(ctx, CountQuestionsInSet, setID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const GetPublishedQuestionsInSet = `-- name: GetPublishedQuestionsInSet :many +SELECT + qsi.id, + qsi.set_id, + qsi.question_id, + qsi.display_order, + q.question_text, + q.question_type, + q.difficulty_level, + q.points, + q.explanation, + q.tips, + q.voice_prompt +FROM question_set_items qsi +JOIN questions q ON q.id = qsi.question_id +WHERE qsi.set_id = $1 + AND q.status = 'PUBLISHED' +ORDER BY qsi.display_order +` + +type GetPublishedQuestionsInSetRow struct { + ID int64 `json:"id"` + SetID int64 `json:"set_id"` + QuestionID int64 `json:"question_id"` + DisplayOrder int32 `json:"display_order"` + QuestionText string `json:"question_text"` + QuestionType string `json:"question_type"` + DifficultyLevel pgtype.Text `json:"difficulty_level"` + Points int32 `json:"points"` + Explanation pgtype.Text `json:"explanation"` + Tips pgtype.Text `json:"tips"` + VoicePrompt pgtype.Text `json:"voice_prompt"` +} + +func (q *Queries) GetPublishedQuestionsInSet(ctx context.Context, setID int64) ([]GetPublishedQuestionsInSetRow, error) { + rows, err := q.db.Query(ctx, GetPublishedQuestionsInSet, setID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetPublishedQuestionsInSetRow + for rows.Next() { + var i GetPublishedQuestionsInSetRow + if err := rows.Scan( + &i.ID, + &i.SetID, + &i.QuestionID, + &i.DisplayOrder, + &i.QuestionText, + &i.QuestionType, + &i.DifficultyLevel, + &i.Points, + &i.Explanation, + &i.Tips, + &i.VoicePrompt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetQuestionSetItems = `-- name: GetQuestionSetItems :many +SELECT + qsi.id, + qsi.set_id, + qsi.question_id, + qsi.display_order, + q.question_text, + q.question_type, + q.difficulty_level, + q.points, + q.explanation, + q.tips, + q.voice_prompt, + q.status as question_status +FROM question_set_items qsi +JOIN questions q ON q.id = qsi.question_id +WHERE qsi.set_id = $1 + AND q.status != 'ARCHIVED' +ORDER BY qsi.display_order +` + +type GetQuestionSetItemsRow struct { + ID int64 `json:"id"` + SetID int64 `json:"set_id"` + QuestionID int64 `json:"question_id"` + DisplayOrder int32 `json:"display_order"` + QuestionText string `json:"question_text"` + QuestionType string `json:"question_type"` + DifficultyLevel pgtype.Text `json:"difficulty_level"` + Points int32 `json:"points"` + Explanation pgtype.Text `json:"explanation"` + Tips pgtype.Text `json:"tips"` + VoicePrompt pgtype.Text `json:"voice_prompt"` + QuestionStatus string `json:"question_status"` +} + +func (q *Queries) GetQuestionSetItems(ctx context.Context, setID int64) ([]GetQuestionSetItemsRow, error) { + rows, err := q.db.Query(ctx, GetQuestionSetItems, setID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetQuestionSetItemsRow + for rows.Next() { + var i GetQuestionSetItemsRow + if err := rows.Scan( + &i.ID, + &i.SetID, + &i.QuestionID, + &i.DisplayOrder, + &i.QuestionText, + &i.QuestionType, + &i.DifficultyLevel, + &i.Points, + &i.Explanation, + &i.Tips, + &i.VoicePrompt, + &i.QuestionStatus, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetQuestionSetsContainingQuestion = `-- name: GetQuestionSetsContainingQuestion :many +SELECT qs.id, qs.title, qs.description, qs.set_type, qs.owner_type, qs.owner_id, qs.banner_image, qs.persona, qs.time_limit_minutes, qs.passing_score, qs.shuffle_questions, qs.status, qs.created_at, qs.updated_at, qs.sub_course_video_id +FROM question_sets qs +JOIN question_set_items qsi ON qsi.set_id = qs.id +WHERE qsi.question_id = $1 + AND qs.status != 'ARCHIVED' +` + +func (q *Queries) GetQuestionSetsContainingQuestion(ctx context.Context, questionID int64) ([]QuestionSet, error) { + rows, err := q.db.Query(ctx, GetQuestionSetsContainingQuestion, questionID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []QuestionSet + for rows.Next() { + var i QuestionSet + if err := rows.Scan( + &i.ID, + &i.Title, + &i.Description, + &i.SetType, + &i.OwnerType, + &i.OwnerID, + &i.BannerImage, + &i.Persona, + &i.TimeLimitMinutes, + &i.PassingScore, + &i.ShuffleQuestions, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + &i.SubCourseVideoID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const RemoveQuestionFromSet = `-- name: RemoveQuestionFromSet :exec +DELETE FROM question_set_items +WHERE set_id = $1 AND question_id = $2 +` + +type RemoveQuestionFromSetParams struct { + SetID int64 `json:"set_id"` + QuestionID int64 `json:"question_id"` +} + +func (q *Queries) RemoveQuestionFromSet(ctx context.Context, arg RemoveQuestionFromSetParams) error { + _, err := q.db.Exec(ctx, RemoveQuestionFromSet, arg.SetID, arg.QuestionID) + return err +} + +const UpdateQuestionOrder = `-- name: UpdateQuestionOrder :exec +UPDATE question_set_items +SET display_order = $1 +WHERE set_id = $2 AND question_id = $3 +` + +type UpdateQuestionOrderParams struct { + DisplayOrder int32 `json:"display_order"` + SetID int64 `json:"set_id"` + QuestionID int64 `json:"question_id"` +} + +func (q *Queries) UpdateQuestionOrder(ctx context.Context, arg UpdateQuestionOrderParams) error { + _, err := q.db.Exec(ctx, UpdateQuestionOrder, arg.DisplayOrder, arg.SetID, arg.QuestionID) + return err +} diff --git a/gen/db/question_sets.sql.go b/gen/db/question_sets.sql.go new file mode 100644 index 0000000..fe8ae87 --- /dev/null +++ b/gen/db/question_sets.sql.go @@ -0,0 +1,499 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: question_sets.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const AddUserPersonaToQuestionSet = `-- name: AddUserPersonaToQuestionSet :one +INSERT INTO question_set_personas ( + question_set_id, + user_id, + display_order +) +VALUES ($1, $2, COALESCE($3, 0)) +RETURNING id, question_set_id, user_id, display_order, created_at +` + +type AddUserPersonaToQuestionSetParams struct { + QuestionSetID int64 `json:"question_set_id"` + UserID int64 `json:"user_id"` + Column3 interface{} `json:"column_3"` +} + +func (q *Queries) AddUserPersonaToQuestionSet(ctx context.Context, arg AddUserPersonaToQuestionSetParams) (QuestionSetPersona, error) { + row := q.db.QueryRow(ctx, AddUserPersonaToQuestionSet, arg.QuestionSetID, arg.UserID, arg.Column3) + var i QuestionSetPersona + err := row.Scan( + &i.ID, + &i.QuestionSetID, + &i.UserID, + &i.DisplayOrder, + &i.CreatedAt, + ) + return i, err +} + +const ArchiveQuestionSet = `-- name: ArchiveQuestionSet :exec +UPDATE question_sets +SET status = 'ARCHIVED', updated_at = CURRENT_TIMESTAMP +WHERE id = $1 +` + +func (q *Queries) ArchiveQuestionSet(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, ArchiveQuestionSet, id) + return err +} + +const CreateQuestionSet = `-- name: CreateQuestionSet :one +INSERT INTO question_sets ( + title, + description, + set_type, + owner_type, + owner_id, + banner_image, + persona, + time_limit_minutes, + passing_score, + shuffle_questions, + status, + sub_course_video_id +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, false), COALESCE($11, 'DRAFT'), $12) +RETURNING id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id +` + +type CreateQuestionSetParams struct { + Title string `json:"title"` + Description pgtype.Text `json:"description"` + SetType string `json:"set_type"` + OwnerType pgtype.Text `json:"owner_type"` + OwnerID pgtype.Int8 `json:"owner_id"` + BannerImage pgtype.Text `json:"banner_image"` + Persona pgtype.Text `json:"persona"` + TimeLimitMinutes pgtype.Int4 `json:"time_limit_minutes"` + PassingScore pgtype.Int4 `json:"passing_score"` + Column10 interface{} `json:"column_10"` + Column11 interface{} `json:"column_11"` + SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"` +} + +func (q *Queries) CreateQuestionSet(ctx context.Context, arg CreateQuestionSetParams) (QuestionSet, error) { + row := q.db.QueryRow(ctx, CreateQuestionSet, + arg.Title, + arg.Description, + arg.SetType, + arg.OwnerType, + arg.OwnerID, + arg.BannerImage, + arg.Persona, + arg.TimeLimitMinutes, + arg.PassingScore, + arg.Column10, + arg.Column11, + arg.SubCourseVideoID, + ) + var i QuestionSet + err := row.Scan( + &i.ID, + &i.Title, + &i.Description, + &i.SetType, + &i.OwnerType, + &i.OwnerID, + &i.BannerImage, + &i.Persona, + &i.TimeLimitMinutes, + &i.PassingScore, + &i.ShuffleQuestions, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + &i.SubCourseVideoID, + ) + return i, err +} + +const DeleteQuestionSet = `-- name: DeleteQuestionSet :exec +DELETE FROM question_sets +WHERE id = $1 +` + +func (q *Queries) DeleteQuestionSet(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, DeleteQuestionSet, id) + return err +} + +const GetInitialAssessmentSet = `-- name: GetInitialAssessmentSet :one +SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id +FROM question_sets +WHERE set_type = 'INITIAL_ASSESSMENT' + AND status = 'PUBLISHED' +ORDER BY created_at DESC +LIMIT 1 +` + +func (q *Queries) GetInitialAssessmentSet(ctx context.Context) (QuestionSet, error) { + row := q.db.QueryRow(ctx, GetInitialAssessmentSet) + var i QuestionSet + err := row.Scan( + &i.ID, + &i.Title, + &i.Description, + &i.SetType, + &i.OwnerType, + &i.OwnerID, + &i.BannerImage, + &i.Persona, + &i.TimeLimitMinutes, + &i.PassingScore, + &i.ShuffleQuestions, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + &i.SubCourseVideoID, + ) + return i, err +} + +const GetPublishedQuestionSetsByOwner = `-- name: GetPublishedQuestionSetsByOwner :many +SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id +FROM question_sets +WHERE owner_type = $1 + AND owner_id = $2 + AND status = 'PUBLISHED' +ORDER BY created_at DESC +` + +type GetPublishedQuestionSetsByOwnerParams struct { + OwnerType pgtype.Text `json:"owner_type"` + OwnerID pgtype.Int8 `json:"owner_id"` +} + +func (q *Queries) GetPublishedQuestionSetsByOwner(ctx context.Context, arg GetPublishedQuestionSetsByOwnerParams) ([]QuestionSet, error) { + rows, err := q.db.Query(ctx, GetPublishedQuestionSetsByOwner, arg.OwnerType, arg.OwnerID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []QuestionSet + for rows.Next() { + var i QuestionSet + if err := rows.Scan( + &i.ID, + &i.Title, + &i.Description, + &i.SetType, + &i.OwnerType, + &i.OwnerID, + &i.BannerImage, + &i.Persona, + &i.TimeLimitMinutes, + &i.PassingScore, + &i.ShuffleQuestions, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + &i.SubCourseVideoID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetQuestionSetByID = `-- name: GetQuestionSetByID :one +SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id +FROM question_sets +WHERE id = $1 +` + +func (q *Queries) GetQuestionSetByID(ctx context.Context, id int64) (QuestionSet, error) { + row := q.db.QueryRow(ctx, GetQuestionSetByID, id) + var i QuestionSet + err := row.Scan( + &i.ID, + &i.Title, + &i.Description, + &i.SetType, + &i.OwnerType, + &i.OwnerID, + &i.BannerImage, + &i.Persona, + &i.TimeLimitMinutes, + &i.PassingScore, + &i.ShuffleQuestions, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + &i.SubCourseVideoID, + ) + return i, err +} + +const GetQuestionSetsByOwner = `-- name: GetQuestionSetsByOwner :many +SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id +FROM question_sets +WHERE owner_type = $1 + AND owner_id = $2 + AND status != 'ARCHIVED' +ORDER BY created_at DESC +` + +type GetQuestionSetsByOwnerParams struct { + OwnerType pgtype.Text `json:"owner_type"` + OwnerID pgtype.Int8 `json:"owner_id"` +} + +func (q *Queries) GetQuestionSetsByOwner(ctx context.Context, arg GetQuestionSetsByOwnerParams) ([]QuestionSet, error) { + rows, err := q.db.Query(ctx, GetQuestionSetsByOwner, arg.OwnerType, arg.OwnerID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []QuestionSet + for rows.Next() { + var i QuestionSet + if err := rows.Scan( + &i.ID, + &i.Title, + &i.Description, + &i.SetType, + &i.OwnerType, + &i.OwnerID, + &i.BannerImage, + &i.Persona, + &i.TimeLimitMinutes, + &i.PassingScore, + &i.ShuffleQuestions, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + &i.SubCourseVideoID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetQuestionSetsByType = `-- name: GetQuestionSetsByType :many +SELECT + COUNT(*) OVER () AS total_count, + qs.id, qs.title, qs.description, qs.set_type, qs.owner_type, qs.owner_id, qs.banner_image, qs.persona, qs.time_limit_minutes, qs.passing_score, qs.shuffle_questions, qs.status, qs.created_at, qs.updated_at, qs.sub_course_video_id +FROM question_sets qs +WHERE set_type = $1 + AND status != 'ARCHIVED' +ORDER BY created_at DESC +LIMIT $3::INT +OFFSET $2::INT +` + +type GetQuestionSetsByTypeParams struct { + SetType string `json:"set_type"` + Offset pgtype.Int4 `json:"offset"` + Limit pgtype.Int4 `json:"limit"` +} + +type GetQuestionSetsByTypeRow struct { + TotalCount int64 `json:"total_count"` + ID int64 `json:"id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + SetType string `json:"set_type"` + OwnerType pgtype.Text `json:"owner_type"` + OwnerID pgtype.Int8 `json:"owner_id"` + BannerImage pgtype.Text `json:"banner_image"` + Persona pgtype.Text `json:"persona"` + TimeLimitMinutes pgtype.Int4 `json:"time_limit_minutes"` + PassingScore pgtype.Int4 `json:"passing_score"` + ShuffleQuestions bool `json:"shuffle_questions"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"` +} + +func (q *Queries) GetQuestionSetsByType(ctx context.Context, arg GetQuestionSetsByTypeParams) ([]GetQuestionSetsByTypeRow, error) { + rows, err := q.db.Query(ctx, GetQuestionSetsByType, arg.SetType, arg.Offset, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetQuestionSetsByTypeRow + for rows.Next() { + var i GetQuestionSetsByTypeRow + if err := rows.Scan( + &i.TotalCount, + &i.ID, + &i.Title, + &i.Description, + &i.SetType, + &i.OwnerType, + &i.OwnerID, + &i.BannerImage, + &i.Persona, + &i.TimeLimitMinutes, + &i.PassingScore, + &i.ShuffleQuestions, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + &i.SubCourseVideoID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetUserPersonasByQuestionSetID = `-- name: GetUserPersonasByQuestionSetID :many +SELECT + u.id, + u.first_name, + u.last_name, + u.nick_name, + u.profile_picture_url, + u.role, + qsp.display_order +FROM users u +INNER JOIN question_set_personas qsp ON qsp.user_id = u.id +WHERE qsp.question_set_id = $1 +ORDER BY qsp.display_order ASC +` + +type GetUserPersonasByQuestionSetIDRow struct { + ID int64 `json:"id"` + FirstName pgtype.Text `json:"first_name"` + LastName pgtype.Text `json:"last_name"` + NickName pgtype.Text `json:"nick_name"` + ProfilePictureUrl pgtype.Text `json:"profile_picture_url"` + Role string `json:"role"` + DisplayOrder pgtype.Int4 `json:"display_order"` +} + +func (q *Queries) GetUserPersonasByQuestionSetID(ctx context.Context, questionSetID int64) ([]GetUserPersonasByQuestionSetIDRow, error) { + rows, err := q.db.Query(ctx, GetUserPersonasByQuestionSetID, questionSetID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetUserPersonasByQuestionSetIDRow + for rows.Next() { + var i GetUserPersonasByQuestionSetIDRow + if err := rows.Scan( + &i.ID, + &i.FirstName, + &i.LastName, + &i.NickName, + &i.ProfilePictureUrl, + &i.Role, + &i.DisplayOrder, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const RemoveUserPersonaFromQuestionSet = `-- name: RemoveUserPersonaFromQuestionSet :exec +DELETE FROM question_set_personas +WHERE question_set_id = $1 + AND user_id = $2 +` + +type RemoveUserPersonaFromQuestionSetParams struct { + QuestionSetID int64 `json:"question_set_id"` + UserID int64 `json:"user_id"` +} + +func (q *Queries) RemoveUserPersonaFromQuestionSet(ctx context.Context, arg RemoveUserPersonaFromQuestionSetParams) error { + _, err := q.db.Exec(ctx, RemoveUserPersonaFromQuestionSet, arg.QuestionSetID, arg.UserID) + return err +} + +const UpdateQuestionSet = `-- name: UpdateQuestionSet :exec +UPDATE question_sets +SET + title = COALESCE($1, title), + description = COALESCE($2, description), + banner_image = COALESCE($3, banner_image), + persona = COALESCE($4, persona), + time_limit_minutes = COALESCE($5, time_limit_minutes), + passing_score = COALESCE($6, passing_score), + shuffle_questions = COALESCE($7, shuffle_questions), + status = COALESCE($8, status), + sub_course_video_id = COALESCE($9, sub_course_video_id), + updated_at = CURRENT_TIMESTAMP +WHERE id = $10 +` + +type UpdateQuestionSetParams struct { + Title string `json:"title"` + Description pgtype.Text `json:"description"` + BannerImage pgtype.Text `json:"banner_image"` + Persona pgtype.Text `json:"persona"` + TimeLimitMinutes pgtype.Int4 `json:"time_limit_minutes"` + PassingScore pgtype.Int4 `json:"passing_score"` + ShuffleQuestions bool `json:"shuffle_questions"` + Status string `json:"status"` + SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateQuestionSet(ctx context.Context, arg UpdateQuestionSetParams) error { + _, err := q.db.Exec(ctx, UpdateQuestionSet, + arg.Title, + arg.Description, + arg.BannerImage, + arg.Persona, + arg.TimeLimitMinutes, + arg.PassingScore, + arg.ShuffleQuestions, + arg.Status, + arg.SubCourseVideoID, + arg.ID, + ) + return err +} + +const UpdateQuestionSetVideoLink = `-- name: UpdateQuestionSetVideoLink :exec +UPDATE question_sets +SET + sub_course_video_id = $1, + updated_at = CURRENT_TIMESTAMP +WHERE id = $2 +` + +type UpdateQuestionSetVideoLinkParams struct { + SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateQuestionSetVideoLink(ctx context.Context, arg UpdateQuestionSetVideoLinkParams) error { + _, err := q.db.Exec(ctx, UpdateQuestionSetVideoLink, arg.SubCourseVideoID, arg.ID) + return err +} diff --git a/gen/db/question_short_answers.sql.go b/gen/db/question_short_answers.sql.go new file mode 100644 index 0000000..ce2df7b --- /dev/null +++ b/gen/db/question_short_answers.sql.go @@ -0,0 +1,110 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: question_short_answers.sql + +package dbgen + +import ( + "context" +) + +const CreateQuestionShortAnswer = `-- name: CreateQuestionShortAnswer :one +INSERT INTO question_short_answers ( + question_id, + acceptable_answer, + match_type +) +VALUES ($1, $2, COALESCE($3, 'EXACT')) +RETURNING id, question_id, acceptable_answer, match_type, created_at +` + +type CreateQuestionShortAnswerParams struct { + QuestionID int64 `json:"question_id"` + AcceptableAnswer string `json:"acceptable_answer"` + Column3 interface{} `json:"column_3"` +} + +func (q *Queries) CreateQuestionShortAnswer(ctx context.Context, arg CreateQuestionShortAnswerParams) (QuestionShortAnswer, error) { + row := q.db.QueryRow(ctx, CreateQuestionShortAnswer, arg.QuestionID, arg.AcceptableAnswer, arg.Column3) + var i QuestionShortAnswer + err := row.Scan( + &i.ID, + &i.QuestionID, + &i.AcceptableAnswer, + &i.MatchType, + &i.CreatedAt, + ) + return i, err +} + +const DeleteQuestionShortAnswer = `-- name: DeleteQuestionShortAnswer :exec +DELETE FROM question_short_answers +WHERE id = $1 +` + +func (q *Queries) DeleteQuestionShortAnswer(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, DeleteQuestionShortAnswer, id) + return err +} + +const DeleteShortAnswersByQuestionID = `-- name: DeleteShortAnswersByQuestionID :exec +DELETE FROM question_short_answers +WHERE question_id = $1 +` + +func (q *Queries) DeleteShortAnswersByQuestionID(ctx context.Context, questionID int64) error { + _, err := q.db.Exec(ctx, DeleteShortAnswersByQuestionID, questionID) + return err +} + +const GetShortAnswersByQuestionID = `-- name: GetShortAnswersByQuestionID :many +SELECT id, question_id, acceptable_answer, match_type, created_at +FROM question_short_answers +WHERE question_id = $1 +` + +func (q *Queries) GetShortAnswersByQuestionID(ctx context.Context, questionID int64) ([]QuestionShortAnswer, error) { + rows, err := q.db.Query(ctx, GetShortAnswersByQuestionID, questionID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []QuestionShortAnswer + for rows.Next() { + var i QuestionShortAnswer + if err := rows.Scan( + &i.ID, + &i.QuestionID, + &i.AcceptableAnswer, + &i.MatchType, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const UpdateQuestionShortAnswer = `-- name: UpdateQuestionShortAnswer :exec +UPDATE question_short_answers +SET + acceptable_answer = COALESCE($1, acceptable_answer), + match_type = COALESCE($2, match_type) +WHERE id = $3 +` + +type UpdateQuestionShortAnswerParams struct { + AcceptableAnswer string `json:"acceptable_answer"` + MatchType string `json:"match_type"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateQuestionShortAnswer(ctx context.Context, arg UpdateQuestionShortAnswerParams) error { + _, err := q.db.Exec(ctx, UpdateQuestionShortAnswer, arg.AcceptableAnswer, arg.MatchType, arg.ID) + return err +} diff --git a/gen/db/questions.sql.go b/gen/db/questions.sql.go new file mode 100644 index 0000000..f4959f0 --- /dev/null +++ b/gen/db/questions.sql.go @@ -0,0 +1,419 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: questions.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const ArchiveQuestion = `-- name: ArchiveQuestion :exec +UPDATE questions +SET status = 'ARCHIVED', updated_at = CURRENT_TIMESTAMP +WHERE id = $1 +` + +func (q *Queries) ArchiveQuestion(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, ArchiveQuestion, id) + return err +} + +const CreateQuestion = `-- name: CreateQuestion :one +INSERT INTO questions ( + question_text, + question_type, + difficulty_level, + points, + explanation, + tips, + voice_prompt, + sample_answer_voice_prompt, + status +) +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 { + QuestionText string `json:"question_text"` + QuestionType string `json:"question_type"` + DifficultyLevel pgtype.Text `json:"difficulty_level"` + Column4 interface{} `json:"column_4"` + Explanation pgtype.Text `json:"explanation"` + Tips pgtype.Text `json:"tips"` + VoicePrompt pgtype.Text `json:"voice_prompt"` + SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"` + Column9 interface{} `json:"column_9"` +} + +func (q *Queries) CreateQuestion(ctx context.Context, arg CreateQuestionParams) (Question, error) { + row := q.db.QueryRow(ctx, CreateQuestion, + arg.QuestionText, + arg.QuestionType, + arg.DifficultyLevel, + arg.Column4, + arg.Explanation, + arg.Tips, + arg.VoicePrompt, + arg.SampleAnswerVoicePrompt, + arg.Column9, + ) + var i Question + err := row.Scan( + &i.ID, + &i.QuestionText, + &i.QuestionType, + &i.DifficultyLevel, + &i.Points, + &i.Explanation, + &i.Tips, + &i.VoicePrompt, + &i.SampleAnswerVoicePrompt, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const DeleteQuestion = `-- name: DeleteQuestion :exec +DELETE FROM questions +WHERE id = $1 +` + +func (q *Queries) DeleteQuestion(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, DeleteQuestion, id) + return err +} + +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 +FROM questions +WHERE id = $1 +` + +func (q *Queries) GetQuestionByID(ctx context.Context, id int64) (Question, error) { + row := q.db.QueryRow(ctx, GetQuestionByID, id) + var i Question + err := row.Scan( + &i.ID, + &i.QuestionText, + &i.QuestionType, + &i.DifficultyLevel, + &i.Points, + &i.Explanation, + &i.Tips, + &i.VoicePrompt, + &i.SampleAnswerVoicePrompt, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const GetQuestionWithOptions = `-- name: GetQuestionWithOptions :many +SELECT + q.id as question_id, + q.question_text, + q.question_type, + q.difficulty_level, + q.points, + q.explanation, + q.tips, + q.voice_prompt, + q.status, + qo.id as option_id, + qo.option_text, + qo.option_order, + qo.is_correct +FROM questions q +LEFT JOIN question_options qo ON qo.question_id = q.id +WHERE q.id = $1 +ORDER BY qo.option_order +` + +type GetQuestionWithOptionsRow struct { + QuestionID int64 `json:"question_id"` + QuestionText string `json:"question_text"` + QuestionType string `json:"question_type"` + DifficultyLevel pgtype.Text `json:"difficulty_level"` + Points int32 `json:"points"` + Explanation pgtype.Text `json:"explanation"` + Tips pgtype.Text `json:"tips"` + VoicePrompt pgtype.Text `json:"voice_prompt"` + Status string `json:"status"` + OptionID pgtype.Int8 `json:"option_id"` + OptionText pgtype.Text `json:"option_text"` + OptionOrder pgtype.Int4 `json:"option_order"` + IsCorrect pgtype.Bool `json:"is_correct"` +} + +func (q *Queries) GetQuestionWithOptions(ctx context.Context, id int64) ([]GetQuestionWithOptionsRow, error) { + rows, err := q.db.Query(ctx, GetQuestionWithOptions, id) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetQuestionWithOptionsRow + for rows.Next() { + var i GetQuestionWithOptionsRow + if err := rows.Scan( + &i.QuestionID, + &i.QuestionText, + &i.QuestionType, + &i.DifficultyLevel, + &i.Points, + &i.Explanation, + &i.Tips, + &i.VoicePrompt, + &i.Status, + &i.OptionID, + &i.OptionText, + &i.OptionOrder, + &i.IsCorrect, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +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 +FROM questions +WHERE id = ANY($1::BIGINT[]) +ORDER BY id +` + +func (q *Queries) GetQuestionsByIDs(ctx context.Context, dollar_1 []int64) ([]Question, error) { + rows, err := q.db.Query(ctx, GetQuestionsByIDs, dollar_1) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Question + for rows.Next() { + var i Question + if err := rows.Scan( + &i.ID, + &i.QuestionText, + &i.QuestionType, + &i.DifficultyLevel, + &i.Points, + &i.Explanation, + &i.Tips, + &i.VoicePrompt, + &i.SampleAnswerVoicePrompt, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +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 +FROM questions q +WHERE status != 'ARCHIVED' + AND ($1::VARCHAR IS NULL OR $1 = '' OR question_type = $1) + AND ($2::VARCHAR IS NULL OR $2 = '' OR difficulty_level = $2) + AND ($3::VARCHAR IS NULL OR $3 = '' OR status = $3) +ORDER BY created_at DESC +LIMIT $5::INT +OFFSET $4::INT +` + +type ListQuestionsParams struct { + Column1 string `json:"column_1"` + Column2 string `json:"column_2"` + Column3 string `json:"column_3"` + Offset pgtype.Int4 `json:"offset"` + Limit pgtype.Int4 `json:"limit"` +} + +type ListQuestionsRow struct { + TotalCount int64 `json:"total_count"` + ID int64 `json:"id"` + QuestionText string `json:"question_text"` + QuestionType string `json:"question_type"` + DifficultyLevel pgtype.Text `json:"difficulty_level"` + Points int32 `json:"points"` + Explanation pgtype.Text `json:"explanation"` + Tips pgtype.Text `json:"tips"` + VoicePrompt pgtype.Text `json:"voice_prompt"` + SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) ListQuestions(ctx context.Context, arg ListQuestionsParams) ([]ListQuestionsRow, error) { + rows, err := q.db.Query(ctx, ListQuestions, + arg.Column1, + arg.Column2, + arg.Column3, + arg.Offset, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListQuestionsRow + for rows.Next() { + var i ListQuestionsRow + if err := rows.Scan( + &i.TotalCount, + &i.ID, + &i.QuestionText, + &i.QuestionType, + &i.DifficultyLevel, + &i.Points, + &i.Explanation, + &i.Tips, + &i.VoicePrompt, + &i.SampleAnswerVoicePrompt, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +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 +FROM questions q +WHERE status != 'ARCHIVED' + AND question_text ILIKE '%' || $1 || '%' +ORDER BY created_at DESC +LIMIT $3::INT +OFFSET $2::INT +` + +type SearchQuestionsParams struct { + Column1 pgtype.Text `json:"column_1"` + Offset pgtype.Int4 `json:"offset"` + Limit pgtype.Int4 `json:"limit"` +} + +type SearchQuestionsRow struct { + TotalCount int64 `json:"total_count"` + ID int64 `json:"id"` + QuestionText string `json:"question_text"` + QuestionType string `json:"question_type"` + DifficultyLevel pgtype.Text `json:"difficulty_level"` + Points int32 `json:"points"` + Explanation pgtype.Text `json:"explanation"` + Tips pgtype.Text `json:"tips"` + VoicePrompt pgtype.Text `json:"voice_prompt"` + SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) SearchQuestions(ctx context.Context, arg SearchQuestionsParams) ([]SearchQuestionsRow, error) { + rows, err := q.db.Query(ctx, SearchQuestions, arg.Column1, arg.Offset, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SearchQuestionsRow + for rows.Next() { + var i SearchQuestionsRow + if err := rows.Scan( + &i.TotalCount, + &i.ID, + &i.QuestionText, + &i.QuestionType, + &i.DifficultyLevel, + &i.Points, + &i.Explanation, + &i.Tips, + &i.VoicePrompt, + &i.SampleAnswerVoicePrompt, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const UpdateQuestion = `-- name: UpdateQuestion :exec +UPDATE questions +SET + question_text = COALESCE($1, question_text), + question_type = COALESCE($2, question_type), + difficulty_level = COALESCE($3, difficulty_level), + points = COALESCE($4, points), + explanation = COALESCE($5, explanation), + tips = COALESCE($6, tips), + voice_prompt = COALESCE($7, voice_prompt), + sample_answer_voice_prompt = COALESCE($8, sample_answer_voice_prompt), + status = COALESCE($9, status), + updated_at = CURRENT_TIMESTAMP +WHERE id = $10 +` + +type UpdateQuestionParams struct { + QuestionText string `json:"question_text"` + QuestionType string `json:"question_type"` + DifficultyLevel pgtype.Text `json:"difficulty_level"` + Points int32 `json:"points"` + Explanation pgtype.Text `json:"explanation"` + Tips pgtype.Text `json:"tips"` + VoicePrompt pgtype.Text `json:"voice_prompt"` + SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"` + Status string `json:"status"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateQuestion(ctx context.Context, arg UpdateQuestionParams) error { + _, err := q.db.Exec(ctx, UpdateQuestion, + arg.QuestionText, + arg.QuestionType, + arg.DifficultyLevel, + arg.Points, + arg.Explanation, + arg.Tips, + arg.VoicePrompt, + arg.SampleAnswerVoicePrompt, + arg.Status, + arg.ID, + ) + return err +} diff --git a/gen/db/sub_course_videos.sql.go b/gen/db/sub_course_videos.sql.go new file mode 100644 index 0000000..e968ea7 --- /dev/null +++ b/gen/db/sub_course_videos.sql.go @@ -0,0 +1,422 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: sub_course_videos.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const ArchiveSubCourseVideo = `-- name: ArchiveSubCourseVideo :exec +UPDATE sub_course_videos +SET status = 'ARCHIVED' +WHERE id = $1 +` + +func (q *Queries) ArchiveSubCourseVideo(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, ArchiveSubCourseVideo, id) + return err +} + +const CreateSubCourseVideo = `-- name: CreateSubCourseVideo :one +INSERT INTO sub_course_videos ( + sub_course_id, + title, + description, + video_url, + duration, + resolution, + instructor_id, + thumbnail, + visibility, + display_order, + status, + vimeo_id, + vimeo_embed_url, + vimeo_player_html, + vimeo_status, + video_host_provider +) +VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, + COALESCE($10, 0), + COALESCE($11, 'DRAFT'), + $12, $13, $14, + COALESCE($15, 'pending'), + COALESCE($16, 'DIRECT') +) +RETURNING id, sub_course_id, title, description, video_url, duration, resolution, is_published, publish_date, visibility, instructor_id, thumbnail, display_order, status, vimeo_id, vimeo_embed_url, vimeo_player_html, vimeo_status, video_host_provider +` + +type CreateSubCourseVideoParams struct { + SubCourseID int64 `json:"sub_course_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + VideoUrl string `json:"video_url"` + Duration int32 `json:"duration"` + Resolution pgtype.Text `json:"resolution"` + InstructorID pgtype.Text `json:"instructor_id"` + Thumbnail pgtype.Text `json:"thumbnail"` + Visibility pgtype.Text `json:"visibility"` + Column10 interface{} `json:"column_10"` + Column11 interface{} `json:"column_11"` + VimeoID pgtype.Text `json:"vimeo_id"` + VimeoEmbedUrl pgtype.Text `json:"vimeo_embed_url"` + VimeoPlayerHtml pgtype.Text `json:"vimeo_player_html"` + Column15 interface{} `json:"column_15"` + Column16 interface{} `json:"column_16"` +} + +func (q *Queries) CreateSubCourseVideo(ctx context.Context, arg CreateSubCourseVideoParams) (SubCourseVideo, error) { + row := q.db.QueryRow(ctx, CreateSubCourseVideo, + arg.SubCourseID, + arg.Title, + arg.Description, + arg.VideoUrl, + arg.Duration, + arg.Resolution, + arg.InstructorID, + arg.Thumbnail, + arg.Visibility, + arg.Column10, + arg.Column11, + arg.VimeoID, + arg.VimeoEmbedUrl, + arg.VimeoPlayerHtml, + arg.Column15, + arg.Column16, + ) + var i SubCourseVideo + err := row.Scan( + &i.ID, + &i.SubCourseID, + &i.Title, + &i.Description, + &i.VideoUrl, + &i.Duration, + &i.Resolution, + &i.IsPublished, + &i.PublishDate, + &i.Visibility, + &i.InstructorID, + &i.Thumbnail, + &i.DisplayOrder, + &i.Status, + &i.VimeoID, + &i.VimeoEmbedUrl, + &i.VimeoPlayerHtml, + &i.VimeoStatus, + &i.VideoHostProvider, + ) + return i, err +} + +const DeleteSubCourseVideo = `-- name: DeleteSubCourseVideo :exec +DELETE FROM sub_course_videos +WHERE id = $1 +` + +func (q *Queries) DeleteSubCourseVideo(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, DeleteSubCourseVideo, id) + return err +} + +const GetPublishedVideosBySubCourse = `-- name: GetPublishedVideosBySubCourse :many +SELECT id, sub_course_id, title, description, video_url, duration, resolution, is_published, publish_date, visibility, instructor_id, thumbnail, display_order, status, vimeo_id, vimeo_embed_url, vimeo_player_html, vimeo_status, video_host_provider +FROM sub_course_videos +WHERE sub_course_id = $1 + AND status = 'PUBLISHED' +ORDER BY display_order ASC, publish_date ASC +` + +func (q *Queries) GetPublishedVideosBySubCourse(ctx context.Context, subCourseID int64) ([]SubCourseVideo, error) { + rows, err := q.db.Query(ctx, GetPublishedVideosBySubCourse, subCourseID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SubCourseVideo + for rows.Next() { + var i SubCourseVideo + if err := rows.Scan( + &i.ID, + &i.SubCourseID, + &i.Title, + &i.Description, + &i.VideoUrl, + &i.Duration, + &i.Resolution, + &i.IsPublished, + &i.PublishDate, + &i.Visibility, + &i.InstructorID, + &i.Thumbnail, + &i.DisplayOrder, + &i.Status, + &i.VimeoID, + &i.VimeoEmbedUrl, + &i.VimeoPlayerHtml, + &i.VimeoStatus, + &i.VideoHostProvider, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetSubCourseVideoByID = `-- name: GetSubCourseVideoByID :one +SELECT id, sub_course_id, title, description, video_url, duration, resolution, is_published, publish_date, visibility, instructor_id, thumbnail, display_order, status, vimeo_id, vimeo_embed_url, vimeo_player_html, vimeo_status, video_host_provider +FROM sub_course_videos +WHERE id = $1 +` + +func (q *Queries) GetSubCourseVideoByID(ctx context.Context, id int64) (SubCourseVideo, error) { + row := q.db.QueryRow(ctx, GetSubCourseVideoByID, id) + var i SubCourseVideo + err := row.Scan( + &i.ID, + &i.SubCourseID, + &i.Title, + &i.Description, + &i.VideoUrl, + &i.Duration, + &i.Resolution, + &i.IsPublished, + &i.PublishDate, + &i.Visibility, + &i.InstructorID, + &i.Thumbnail, + &i.DisplayOrder, + &i.Status, + &i.VimeoID, + &i.VimeoEmbedUrl, + &i.VimeoPlayerHtml, + &i.VimeoStatus, + &i.VideoHostProvider, + ) + return i, err +} + +const GetVideosBySubCourse = `-- name: GetVideosBySubCourse :many +SELECT + COUNT(*) OVER () AS total_count, + id, + sub_course_id, + title, + description, + video_url, + duration, + resolution, + is_published, + publish_date, + visibility, + instructor_id, + thumbnail, + display_order, + status, + vimeo_id, + vimeo_embed_url, + vimeo_player_html, + vimeo_status, + video_host_provider +FROM sub_course_videos +WHERE sub_course_id = $1 + AND status != 'ARCHIVED' +ORDER BY display_order ASC, id ASC +` + +type GetVideosBySubCourseRow struct { + TotalCount int64 `json:"total_count"` + ID int64 `json:"id"` + SubCourseID int64 `json:"sub_course_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + VideoUrl string `json:"video_url"` + Duration int32 `json:"duration"` + Resolution pgtype.Text `json:"resolution"` + IsPublished bool `json:"is_published"` + PublishDate pgtype.Timestamptz `json:"publish_date"` + Visibility pgtype.Text `json:"visibility"` + InstructorID pgtype.Text `json:"instructor_id"` + Thumbnail pgtype.Text `json:"thumbnail"` + DisplayOrder int32 `json:"display_order"` + Status string `json:"status"` + VimeoID pgtype.Text `json:"vimeo_id"` + VimeoEmbedUrl pgtype.Text `json:"vimeo_embed_url"` + VimeoPlayerHtml pgtype.Text `json:"vimeo_player_html"` + VimeoStatus pgtype.Text `json:"vimeo_status"` + VideoHostProvider pgtype.Text `json:"video_host_provider"` +} + +func (q *Queries) GetVideosBySubCourse(ctx context.Context, subCourseID int64) ([]GetVideosBySubCourseRow, error) { + rows, err := q.db.Query(ctx, GetVideosBySubCourse, subCourseID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetVideosBySubCourseRow + for rows.Next() { + var i GetVideosBySubCourseRow + if err := rows.Scan( + &i.TotalCount, + &i.ID, + &i.SubCourseID, + &i.Title, + &i.Description, + &i.VideoUrl, + &i.Duration, + &i.Resolution, + &i.IsPublished, + &i.PublishDate, + &i.Visibility, + &i.InstructorID, + &i.Thumbnail, + &i.DisplayOrder, + &i.Status, + &i.VimeoID, + &i.VimeoEmbedUrl, + &i.VimeoPlayerHtml, + &i.VimeoStatus, + &i.VideoHostProvider, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetVideosByVimeoID = `-- name: GetVideosByVimeoID :one +SELECT id, sub_course_id, title, description, video_url, duration, resolution, is_published, publish_date, visibility, instructor_id, thumbnail, display_order, status, vimeo_id, vimeo_embed_url, vimeo_player_html, vimeo_status, video_host_provider FROM sub_course_videos +WHERE vimeo_id = $1 +` + +func (q *Queries) GetVideosByVimeoID(ctx context.Context, vimeoID pgtype.Text) (SubCourseVideo, error) { + row := q.db.QueryRow(ctx, GetVideosByVimeoID, vimeoID) + var i SubCourseVideo + err := row.Scan( + &i.ID, + &i.SubCourseID, + &i.Title, + &i.Description, + &i.VideoUrl, + &i.Duration, + &i.Resolution, + &i.IsPublished, + &i.PublishDate, + &i.Visibility, + &i.InstructorID, + &i.Thumbnail, + &i.DisplayOrder, + &i.Status, + &i.VimeoID, + &i.VimeoEmbedUrl, + &i.VimeoPlayerHtml, + &i.VimeoStatus, + &i.VideoHostProvider, + ) + return i, err +} + +const PublishSubCourseVideo = `-- name: PublishSubCourseVideo :exec +UPDATE sub_course_videos +SET + is_published = true, + publish_date = CURRENT_TIMESTAMP, + status = 'PUBLISHED' +WHERE id = $1 +` + +func (q *Queries) PublishSubCourseVideo(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, PublishSubCourseVideo, id) + return err +} + +const UpdateSubCourseVideo = `-- name: UpdateSubCourseVideo :exec +UPDATE sub_course_videos +SET + title = COALESCE($1, title), + description = COALESCE($2, description), + video_url = COALESCE($3, video_url), + duration = COALESCE($4, duration), + resolution = COALESCE($5, resolution), + visibility = COALESCE($6, visibility), + thumbnail = COALESCE($7, thumbnail), + display_order = COALESCE($8, display_order), + status = COALESCE($9, status), + vimeo_id = COALESCE($10, vimeo_id), + vimeo_embed_url = COALESCE($11, vimeo_embed_url), + vimeo_player_html = COALESCE($12, vimeo_player_html), + vimeo_status = COALESCE($13, vimeo_status), + video_host_provider = COALESCE($14, video_host_provider) +WHERE id = $15 +` + +type UpdateSubCourseVideoParams struct { + Title string `json:"title"` + Description pgtype.Text `json:"description"` + VideoUrl string `json:"video_url"` + Duration int32 `json:"duration"` + Resolution pgtype.Text `json:"resolution"` + Visibility pgtype.Text `json:"visibility"` + Thumbnail pgtype.Text `json:"thumbnail"` + DisplayOrder int32 `json:"display_order"` + Status string `json:"status"` + VimeoID pgtype.Text `json:"vimeo_id"` + VimeoEmbedUrl pgtype.Text `json:"vimeo_embed_url"` + VimeoPlayerHtml pgtype.Text `json:"vimeo_player_html"` + VimeoStatus pgtype.Text `json:"vimeo_status"` + VideoHostProvider pgtype.Text `json:"video_host_provider"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateSubCourseVideo(ctx context.Context, arg UpdateSubCourseVideoParams) error { + _, err := q.db.Exec(ctx, UpdateSubCourseVideo, + arg.Title, + arg.Description, + arg.VideoUrl, + arg.Duration, + arg.Resolution, + arg.Visibility, + arg.Thumbnail, + arg.DisplayOrder, + arg.Status, + arg.VimeoID, + arg.VimeoEmbedUrl, + arg.VimeoPlayerHtml, + arg.VimeoStatus, + arg.VideoHostProvider, + arg.ID, + ) + return err +} + +const UpdateVimeoStatus = `-- name: UpdateVimeoStatus :exec +UPDATE sub_course_videos +SET + vimeo_status = $1 +WHERE id = $2 +` + +type UpdateVimeoStatusParams struct { + VimeoStatus pgtype.Text `json:"vimeo_status"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateVimeoStatus(ctx context.Context, arg UpdateVimeoStatusParams) error { + _, err := q.db.Exec(ctx, UpdateVimeoStatus, arg.VimeoStatus, arg.ID) + return err +} diff --git a/gen/db/course_programs.sql.go b/gen/db/sub_courses.sql.go similarity index 50% rename from gen/db/course_programs.sql.go rename to gen/db/sub_courses.sql.go index f73f3ed..1dc7ce0 100644 --- a/gen/db/course_programs.sql.go +++ b/gen/db/sub_courses.sql.go @@ -1,7 +1,7 @@ // Code generated by sqlc. DO NOT EDIT. // versions: // sqlc v1.30.0 -// source: course_programs.sql +// source: sub_courses.sql package dbgen @@ -11,38 +11,41 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -const CreateProgram = `-- name: CreateProgram :one -INSERT INTO programs ( +const CreateSubCourse = `-- name: CreateSubCourse :one +INSERT INTO sub_courses ( course_id, title, description, thumbnail, display_order, + level, is_active ) -VALUES ($1, $2, $3, $4, COALESCE($5, 0), COALESCE($6, true)) -RETURNING id, course_id, title, description, thumbnail, display_order, is_active +VALUES ($1, $2, $3, $4, COALESCE($5, 0), $6, COALESCE($7, true)) +RETURNING id, course_id, title, description, thumbnail, display_order, level, is_active ` -type CreateProgramParams struct { +type CreateSubCourseParams struct { CourseID int64 `json:"course_id"` Title string `json:"title"` Description pgtype.Text `json:"description"` Thumbnail pgtype.Text `json:"thumbnail"` Column5 interface{} `json:"column_5"` - Column6 interface{} `json:"column_6"` + Level string `json:"level"` + Column7 interface{} `json:"column_7"` } -func (q *Queries) CreateProgram(ctx context.Context, arg CreateProgramParams) (Program, error) { - row := q.db.QueryRow(ctx, CreateProgram, +func (q *Queries) CreateSubCourse(ctx context.Context, arg CreateSubCourseParams) (SubCourse, error) { + row := q.db.QueryRow(ctx, CreateSubCourse, arg.CourseID, arg.Title, arg.Description, arg.Thumbnail, arg.Column5, - arg.Column6, + arg.Level, + arg.Column7, ) - var i Program + var i SubCourse err := row.Scan( &i.ID, &i.CourseID, @@ -50,38 +53,32 @@ func (q *Queries) CreateProgram(ctx context.Context, arg CreateProgramParams) (P &i.Description, &i.Thumbnail, &i.DisplayOrder, + &i.Level, &i.IsActive, ) return i, err } -const DeactivateProgram = `-- name: DeactivateProgram :exec -UPDATE programs +const DeactivateSubCourse = `-- name: DeactivateSubCourse :exec +UPDATE sub_courses SET is_active = FALSE WHERE id = $1 ` -func (q *Queries) DeactivateProgram(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, DeactivateProgram, id) +func (q *Queries) DeactivateSubCourse(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, DeactivateSubCourse, id) return err } -const DeleteProgram = `-- name: DeleteProgram :one -DELETE FROM programs +const DeleteSubCourse = `-- name: DeleteSubCourse :one +DELETE FROM sub_courses WHERE id = $1 -RETURNING - id, - course_id, - title, - description, - thumbnail, - display_order, - is_active +RETURNING id, course_id, title, description, thumbnail, display_order, level, is_active ` -func (q *Queries) DeleteProgram(ctx context.Context, id int64) (Program, error) { - row := q.db.QueryRow(ctx, DeleteProgram, id) - var i Program +func (q *Queries) DeleteSubCourse(ctx context.Context, id int64) (SubCourse, error) { + row := q.db.QueryRow(ctx, DeleteSubCourse, id) + var i SubCourse err := row.Scan( &i.ID, &i.CourseID, @@ -89,27 +86,21 @@ func (q *Queries) DeleteProgram(ctx context.Context, id int64) (Program, error) &i.Description, &i.Thumbnail, &i.DisplayOrder, + &i.Level, &i.IsActive, ) return i, err } -const GetProgramByID = `-- name: GetProgramByID :one -SELECT - id, - course_id, - title, - description, - thumbnail, - display_order, - is_active -FROM programs +const GetSubCourseByID = `-- name: GetSubCourseByID :one +SELECT id, course_id, title, description, thumbnail, display_order, level, is_active +FROM sub_courses WHERE id = $1 ` -func (q *Queries) GetProgramByID(ctx context.Context, id int64) (Program, error) { - row := q.db.QueryRow(ctx, GetProgramByID, id) - var i Program +func (q *Queries) GetSubCourseByID(ctx context.Context, id int64) (SubCourse, error) { + row := q.db.QueryRow(ctx, GetSubCourseByID, id) + var i SubCourse err := row.Scan( &i.ID, &i.CourseID, @@ -117,12 +108,13 @@ func (q *Queries) GetProgramByID(ctx context.Context, id int64) (Program, error) &i.Description, &i.Thumbnail, &i.DisplayOrder, + &i.Level, &i.IsActive, ) return i, err } -const GetProgramsByCourse = `-- name: GetProgramsByCourse :many +const GetSubCoursesByCourse = `-- name: GetSubCoursesByCourse :many SELECT COUNT(*) OVER () AS total_count, id, @@ -131,13 +123,14 @@ SELECT description, thumbnail, display_order, + level, is_active -FROM programs +FROM sub_courses WHERE course_id = $1 -ORDER BY display_order ASC +ORDER BY display_order ASC, id ASC ` -type GetProgramsByCourseRow struct { +type GetSubCoursesByCourseRow struct { TotalCount int64 `json:"total_count"` ID int64 `json:"id"` CourseID int64 `json:"course_id"` @@ -145,18 +138,19 @@ type GetProgramsByCourseRow struct { Description pgtype.Text `json:"description"` Thumbnail pgtype.Text `json:"thumbnail"` DisplayOrder int32 `json:"display_order"` + Level string `json:"level"` IsActive bool `json:"is_active"` } -func (q *Queries) GetProgramsByCourse(ctx context.Context, courseID int64) ([]GetProgramsByCourseRow, error) { - rows, err := q.db.Query(ctx, GetProgramsByCourse, courseID) +func (q *Queries) GetSubCoursesByCourse(ctx context.Context, courseID int64) ([]GetSubCoursesByCourseRow, error) { + rows, err := q.db.Query(ctx, GetSubCoursesByCourse, courseID) if err != nil { return nil, err } defer rows.Close() - var items []GetProgramsByCourseRow + var items []GetSubCoursesByCourseRow for rows.Next() { - var i GetProgramsByCourseRow + var i GetSubCoursesByCourseRow if err := rows.Scan( &i.TotalCount, &i.ID, @@ -165,6 +159,7 @@ func (q *Queries) GetProgramsByCourse(ctx context.Context, courseID int64) ([]Ge &i.Description, &i.Thumbnail, &i.DisplayOrder, + &i.Level, &i.IsActive, ); err != nil { return nil, err @@ -177,7 +172,7 @@ func (q *Queries) GetProgramsByCourse(ctx context.Context, courseID int64) ([]Ge return items, nil } -const ListActivePrograms = `-- name: ListActivePrograms :many +const ListActiveSubCourses = `-- name: ListActiveSubCourses :many SELECT id, course_id, @@ -185,21 +180,22 @@ SELECT description, thumbnail, display_order, + level, is_active -FROM programs +FROM sub_courses WHERE is_active = TRUE ORDER BY display_order ASC ` -func (q *Queries) ListActivePrograms(ctx context.Context) ([]Program, error) { - rows, err := q.db.Query(ctx, ListActivePrograms) +func (q *Queries) ListActiveSubCourses(ctx context.Context) ([]SubCourse, error) { + rows, err := q.db.Query(ctx, ListActiveSubCourses) if err != nil { return nil, err } defer rows.Close() - var items []Program + var items []SubCourse for rows.Next() { - var i Program + var i SubCourse if err := rows.Scan( &i.ID, &i.CourseID, @@ -207,6 +203,7 @@ func (q *Queries) ListActivePrograms(ctx context.Context) ([]Program, error) { &i.Description, &i.Thumbnail, &i.DisplayOrder, + &i.Level, &i.IsActive, ); err != nil { return nil, err @@ -219,7 +216,7 @@ func (q *Queries) ListActivePrograms(ctx context.Context) ([]Program, error) { return items, nil } -const ListProgramsByCourse = `-- name: ListProgramsByCourse :many +const ListSubCoursesByCourse = `-- name: ListSubCoursesByCourse :many SELECT id, course_id, @@ -227,22 +224,23 @@ SELECT description, thumbnail, display_order, + level, is_active -FROM programs +FROM sub_courses WHERE course_id = $1 AND is_active = TRUE ORDER BY display_order ASC, id ASC ` -func (q *Queries) ListProgramsByCourse(ctx context.Context, courseID int64) ([]Program, error) { - rows, err := q.db.Query(ctx, ListProgramsByCourse, courseID) +func (q *Queries) ListSubCoursesByCourse(ctx context.Context, courseID int64) ([]SubCourse, error) { + rows, err := q.db.Query(ctx, ListSubCoursesByCourse, courseID) if err != nil { return nil, err } defer rows.Close() - var items []Program + var items []SubCourse for rows.Next() { - var i Program + var i SubCourse if err := rows.Scan( &i.ID, &i.CourseID, @@ -250,6 +248,7 @@ func (q *Queries) ListProgramsByCourse(ctx context.Context, courseID int64) ([]P &i.Description, &i.Thumbnail, &i.DisplayOrder, + &i.Level, &i.IsActive, ); err != nil { return nil, err @@ -262,85 +261,35 @@ func (q *Queries) ListProgramsByCourse(ctx context.Context, courseID int64) ([]P return items, nil } -const UpdateProgramFull = `-- name: UpdateProgramFull :one -UPDATE programs -SET - course_id = $2, - title = $3, - description = $4, - thumbnail = $5, - display_order = $6, - is_active = $7 -WHERE id = $1 -RETURNING - id, - course_id, - title, - description, - thumbnail, - display_order, - is_active -` - -type UpdateProgramFullParams struct { - ID int64 `json:"id"` - CourseID int64 `json:"course_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - DisplayOrder int32 `json:"display_order"` - IsActive bool `json:"is_active"` -} - -func (q *Queries) UpdateProgramFull(ctx context.Context, arg UpdateProgramFullParams) (Program, error) { - row := q.db.QueryRow(ctx, UpdateProgramFull, - arg.ID, - arg.CourseID, - arg.Title, - arg.Description, - arg.Thumbnail, - arg.DisplayOrder, - arg.IsActive, - ) - var i Program - err := row.Scan( - &i.ID, - &i.CourseID, - &i.Title, - &i.Description, - &i.Thumbnail, - &i.DisplayOrder, - &i.IsActive, - ) - return i, err -} - -const UpdateProgramPartial = `-- name: UpdateProgramPartial :exec -UPDATE programs +const UpdateSubCourse = `-- name: UpdateSubCourse :exec +UPDATE sub_courses SET title = COALESCE($1, title), description = COALESCE($2, description), thumbnail = COALESCE($3, thumbnail), display_order = COALESCE($4, display_order), - is_active = COALESCE($5, is_active) -WHERE id = $6 + level = COALESCE($5, level), + is_active = COALESCE($6, is_active) +WHERE id = $7 ` -type UpdateProgramPartialParams struct { +type UpdateSubCourseParams struct { Title string `json:"title"` Description pgtype.Text `json:"description"` Thumbnail pgtype.Text `json:"thumbnail"` DisplayOrder int32 `json:"display_order"` + Level string `json:"level"` IsActive bool `json:"is_active"` ID int64 `json:"id"` } -func (q *Queries) UpdateProgramPartial(ctx context.Context, arg UpdateProgramPartialParams) error { - _, err := q.db.Exec(ctx, UpdateProgramPartial, +func (q *Queries) UpdateSubCourse(ctx context.Context, arg UpdateSubCourseParams) error { + _, err := q.db.Exec(ctx, UpdateSubCourse, arg.Title, arg.Description, arg.Thumbnail, arg.DisplayOrder, + arg.Level, arg.IsActive, arg.ID, ) diff --git a/gen/db/subscriptions.sql.go b/gen/db/subscriptions.sql.go new file mode 100644 index 0000000..c0aadd4 --- /dev/null +++ b/gen/db/subscriptions.sql.go @@ -0,0 +1,691 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: subscriptions.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const CancelUserSubscription = `-- name: CancelUserSubscription :exec +UPDATE user_subscriptions +SET + status = 'CANCELLED', + cancelled_at = CURRENT_TIMESTAMP, + auto_renew = false, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 +` + +func (q *Queries) CancelUserSubscription(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, CancelUserSubscription, id) + return err +} + +const CountUserSubscriptions = `-- name: CountUserSubscriptions :one +SELECT COUNT(*) FROM user_subscriptions WHERE user_id = $1 +` + +func (q *Queries) CountUserSubscriptions(ctx context.Context, userID int64) (int64, error) { + row := q.db.QueryRow(ctx, CountUserSubscriptions, userID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const CreateSubscriptionPlan = `-- name: CreateSubscriptionPlan :one + +INSERT INTO subscription_plans ( + name, description, duration_value, duration_unit, price, currency, is_active +) +VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, true)) +RETURNING id, name, description, duration_value, duration_unit, price, currency, is_active, created_at, updated_at +` + +type CreateSubscriptionPlanParams struct { + Name string `json:"name"` + Description pgtype.Text `json:"description"` + DurationValue int32 `json:"duration_value"` + DurationUnit string `json:"duration_unit"` + Price pgtype.Numeric `json:"price"` + Currency string `json:"currency"` + Column7 interface{} `json:"column_7"` +} + +// ===================== +// Subscription Plans +// ===================== +func (q *Queries) CreateSubscriptionPlan(ctx context.Context, arg CreateSubscriptionPlanParams) (SubscriptionPlan, error) { + row := q.db.QueryRow(ctx, CreateSubscriptionPlan, + arg.Name, + arg.Description, + arg.DurationValue, + arg.DurationUnit, + arg.Price, + arg.Currency, + arg.Column7, + ) + var i SubscriptionPlan + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.DurationValue, + &i.DurationUnit, + &i.Price, + &i.Currency, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const CreateUserSubscription = `-- name: CreateUserSubscription :one + +INSERT INTO user_subscriptions ( + user_id, plan_id, starts_at, expires_at, status, payment_reference, payment_method, auto_renew +) +VALUES ($1, $2, COALESCE($3, CURRENT_TIMESTAMP), $4, COALESCE($5, 'ACTIVE'), $6, $7, COALESCE($8, false)) +RETURNING id, user_id, plan_id, starts_at, expires_at, status, payment_reference, payment_method, auto_renew, cancelled_at, created_at, updated_at +` + +type CreateUserSubscriptionParams struct { + UserID int64 `json:"user_id"` + PlanID int64 `json:"plan_id"` + Column3 interface{} `json:"column_3"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + Column5 interface{} `json:"column_5"` + PaymentReference pgtype.Text `json:"payment_reference"` + PaymentMethod pgtype.Text `json:"payment_method"` + Column8 interface{} `json:"column_8"` +} + +// ===================== +// User Subscriptions +// ===================== +func (q *Queries) CreateUserSubscription(ctx context.Context, arg CreateUserSubscriptionParams) (UserSubscription, error) { + row := q.db.QueryRow(ctx, CreateUserSubscription, + arg.UserID, + arg.PlanID, + arg.Column3, + arg.ExpiresAt, + arg.Column5, + arg.PaymentReference, + arg.PaymentMethod, + arg.Column8, + ) + var i UserSubscription + err := row.Scan( + &i.ID, + &i.UserID, + &i.PlanID, + &i.StartsAt, + &i.ExpiresAt, + &i.Status, + &i.PaymentReference, + &i.PaymentMethod, + &i.AutoRenew, + &i.CancelledAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const DeleteSubscriptionPlan = `-- name: DeleteSubscriptionPlan :exec +DELETE FROM subscription_plans WHERE id = $1 +` + +func (q *Queries) DeleteSubscriptionPlan(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, DeleteSubscriptionPlan, id) + return err +} + +const ExpireUserSubscription = `-- name: ExpireUserSubscription :exec +UPDATE user_subscriptions +SET + status = 'EXPIRED', + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 +` + +func (q *Queries) ExpireUserSubscription(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, ExpireUserSubscription, id) + return err +} + +const ExtendSubscription = `-- name: ExtendSubscription :exec +UPDATE user_subscriptions +SET + expires_at = $1, + updated_at = CURRENT_TIMESTAMP +WHERE id = $2 +` + +type ExtendSubscriptionParams struct { + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + ID int64 `json:"id"` +} + +func (q *Queries) ExtendSubscription(ctx context.Context, arg ExtendSubscriptionParams) error { + _, err := q.db.Exec(ctx, ExtendSubscription, arg.ExpiresAt, arg.ID) + return err +} + +const GetActiveSubscriptionByUserID = `-- name: GetActiveSubscriptionByUserID :one +SELECT + us.id, us.user_id, us.plan_id, us.starts_at, us.expires_at, us.status, us.payment_reference, us.payment_method, us.auto_renew, us.cancelled_at, us.created_at, us.updated_at, + sp.name AS plan_name, + sp.duration_value, + sp.duration_unit, + sp.price, + sp.currency +FROM user_subscriptions us +JOIN subscription_plans sp ON sp.id = us.plan_id +WHERE us.user_id = $1 + AND us.status = 'ACTIVE' + AND us.expires_at > CURRENT_TIMESTAMP +ORDER BY us.expires_at DESC +LIMIT 1 +` + +type GetActiveSubscriptionByUserIDRow struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + PlanID int64 `json:"plan_id"` + StartsAt pgtype.Timestamptz `json:"starts_at"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + Status string `json:"status"` + PaymentReference pgtype.Text `json:"payment_reference"` + PaymentMethod pgtype.Text `json:"payment_method"` + AutoRenew bool `json:"auto_renew"` + CancelledAt pgtype.Timestamptz `json:"cancelled_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + PlanName string `json:"plan_name"` + DurationValue int32 `json:"duration_value"` + DurationUnit string `json:"duration_unit"` + Price pgtype.Numeric `json:"price"` + Currency string `json:"currency"` +} + +func (q *Queries) GetActiveSubscriptionByUserID(ctx context.Context, userID int64) (GetActiveSubscriptionByUserIDRow, error) { + row := q.db.QueryRow(ctx, GetActiveSubscriptionByUserID, userID) + var i GetActiveSubscriptionByUserIDRow + err := row.Scan( + &i.ID, + &i.UserID, + &i.PlanID, + &i.StartsAt, + &i.ExpiresAt, + &i.Status, + &i.PaymentReference, + &i.PaymentMethod, + &i.AutoRenew, + &i.CancelledAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.PlanName, + &i.DurationValue, + &i.DurationUnit, + &i.Price, + &i.Currency, + ) + return i, err +} + +const GetExpiredSubscriptions = `-- name: GetExpiredSubscriptions :many +SELECT us.id, us.user_id, us.plan_id, us.starts_at, us.expires_at, us.status, us.payment_reference, us.payment_method, us.auto_renew, us.cancelled_at, us.created_at, us.updated_at, sp.name AS plan_name +FROM user_subscriptions us +JOIN subscription_plans sp ON sp.id = us.plan_id +WHERE us.status = 'ACTIVE' + AND us.expires_at <= CURRENT_TIMESTAMP +` + +type GetExpiredSubscriptionsRow struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + PlanID int64 `json:"plan_id"` + StartsAt pgtype.Timestamptz `json:"starts_at"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + Status string `json:"status"` + PaymentReference pgtype.Text `json:"payment_reference"` + PaymentMethod pgtype.Text `json:"payment_method"` + AutoRenew bool `json:"auto_renew"` + CancelledAt pgtype.Timestamptz `json:"cancelled_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + PlanName string `json:"plan_name"` +} + +func (q *Queries) GetExpiredSubscriptions(ctx context.Context) ([]GetExpiredSubscriptionsRow, error) { + rows, err := q.db.Query(ctx, GetExpiredSubscriptions) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetExpiredSubscriptionsRow + for rows.Next() { + var i GetExpiredSubscriptionsRow + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.PlanID, + &i.StartsAt, + &i.ExpiresAt, + &i.Status, + &i.PaymentReference, + &i.PaymentMethod, + &i.AutoRenew, + &i.CancelledAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.PlanName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetExpiringSubscriptions = `-- name: GetExpiringSubscriptions :many +SELECT + us.id, us.user_id, us.plan_id, us.starts_at, us.expires_at, us.status, us.payment_reference, us.payment_method, us.auto_renew, us.cancelled_at, us.created_at, us.updated_at, + sp.name AS plan_name, + u.email, + u.first_name +FROM user_subscriptions us +JOIN subscription_plans sp ON sp.id = us.plan_id +JOIN users u ON u.id = us.user_id +WHERE us.status = 'ACTIVE' + AND us.expires_at > CURRENT_TIMESTAMP + AND us.expires_at <= CURRENT_TIMESTAMP + INTERVAL '7 days' +` + +type GetExpiringSubscriptionsRow struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + PlanID int64 `json:"plan_id"` + StartsAt pgtype.Timestamptz `json:"starts_at"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + Status string `json:"status"` + PaymentReference pgtype.Text `json:"payment_reference"` + PaymentMethod pgtype.Text `json:"payment_method"` + AutoRenew bool `json:"auto_renew"` + CancelledAt pgtype.Timestamptz `json:"cancelled_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + PlanName string `json:"plan_name"` + Email pgtype.Text `json:"email"` + FirstName pgtype.Text `json:"first_name"` +} + +func (q *Queries) GetExpiringSubscriptions(ctx context.Context) ([]GetExpiringSubscriptionsRow, error) { + rows, err := q.db.Query(ctx, GetExpiringSubscriptions) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetExpiringSubscriptionsRow + for rows.Next() { + var i GetExpiringSubscriptionsRow + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.PlanID, + &i.StartsAt, + &i.ExpiresAt, + &i.Status, + &i.PaymentReference, + &i.PaymentMethod, + &i.AutoRenew, + &i.CancelledAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.PlanName, + &i.Email, + &i.FirstName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetSubscriptionPlanByID = `-- name: GetSubscriptionPlanByID :one +SELECT id, name, description, duration_value, duration_unit, price, currency, is_active, created_at, updated_at FROM subscription_plans WHERE id = $1 +` + +func (q *Queries) GetSubscriptionPlanByID(ctx context.Context, id int64) (SubscriptionPlan, error) { + row := q.db.QueryRow(ctx, GetSubscriptionPlanByID, id) + var i SubscriptionPlan + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.DurationValue, + &i.DurationUnit, + &i.Price, + &i.Currency, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const GetUserSubscriptionByID = `-- name: GetUserSubscriptionByID :one +SELECT + us.id, us.user_id, us.plan_id, us.starts_at, us.expires_at, us.status, us.payment_reference, us.payment_method, us.auto_renew, us.cancelled_at, us.created_at, us.updated_at, + sp.name AS plan_name, + sp.duration_value, + sp.duration_unit, + sp.price, + sp.currency +FROM user_subscriptions us +JOIN subscription_plans sp ON sp.id = us.plan_id +WHERE us.id = $1 +` + +type GetUserSubscriptionByIDRow struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + PlanID int64 `json:"plan_id"` + StartsAt pgtype.Timestamptz `json:"starts_at"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + Status string `json:"status"` + PaymentReference pgtype.Text `json:"payment_reference"` + PaymentMethod pgtype.Text `json:"payment_method"` + AutoRenew bool `json:"auto_renew"` + CancelledAt pgtype.Timestamptz `json:"cancelled_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + PlanName string `json:"plan_name"` + DurationValue int32 `json:"duration_value"` + DurationUnit string `json:"duration_unit"` + Price pgtype.Numeric `json:"price"` + Currency string `json:"currency"` +} + +func (q *Queries) GetUserSubscriptionByID(ctx context.Context, id int64) (GetUserSubscriptionByIDRow, error) { + row := q.db.QueryRow(ctx, GetUserSubscriptionByID, id) + var i GetUserSubscriptionByIDRow + err := row.Scan( + &i.ID, + &i.UserID, + &i.PlanID, + &i.StartsAt, + &i.ExpiresAt, + &i.Status, + &i.PaymentReference, + &i.PaymentMethod, + &i.AutoRenew, + &i.CancelledAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.PlanName, + &i.DurationValue, + &i.DurationUnit, + &i.Price, + &i.Currency, + ) + return i, err +} + +const GetUserSubscriptionHistory = `-- name: GetUserSubscriptionHistory :many +SELECT + us.id, us.user_id, us.plan_id, us.starts_at, us.expires_at, us.status, us.payment_reference, us.payment_method, us.auto_renew, us.cancelled_at, us.created_at, us.updated_at, + sp.name AS plan_name, + sp.duration_value, + sp.duration_unit, + sp.price, + sp.currency +FROM user_subscriptions us +JOIN subscription_plans sp ON sp.id = us.plan_id +WHERE us.user_id = $1 +ORDER BY us.created_at DESC +LIMIT $3::INT +OFFSET $2::INT +` + +type GetUserSubscriptionHistoryParams struct { + UserID int64 `json:"user_id"` + Offset pgtype.Int4 `json:"offset"` + Limit pgtype.Int4 `json:"limit"` +} + +type GetUserSubscriptionHistoryRow struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + PlanID int64 `json:"plan_id"` + StartsAt pgtype.Timestamptz `json:"starts_at"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + Status string `json:"status"` + PaymentReference pgtype.Text `json:"payment_reference"` + PaymentMethod pgtype.Text `json:"payment_method"` + AutoRenew bool `json:"auto_renew"` + CancelledAt pgtype.Timestamptz `json:"cancelled_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + PlanName string `json:"plan_name"` + DurationValue int32 `json:"duration_value"` + DurationUnit string `json:"duration_unit"` + Price pgtype.Numeric `json:"price"` + Currency string `json:"currency"` +} + +func (q *Queries) GetUserSubscriptionHistory(ctx context.Context, arg GetUserSubscriptionHistoryParams) ([]GetUserSubscriptionHistoryRow, error) { + rows, err := q.db.Query(ctx, GetUserSubscriptionHistory, arg.UserID, arg.Offset, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetUserSubscriptionHistoryRow + for rows.Next() { + var i GetUserSubscriptionHistoryRow + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.PlanID, + &i.StartsAt, + &i.ExpiresAt, + &i.Status, + &i.PaymentReference, + &i.PaymentMethod, + &i.AutoRenew, + &i.CancelledAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.PlanName, + &i.DurationValue, + &i.DurationUnit, + &i.Price, + &i.Currency, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const HasActiveSubscription = `-- name: HasActiveSubscription :one +SELECT EXISTS( + SELECT 1 FROM user_subscriptions + WHERE user_id = $1 + AND status = 'ACTIVE' + AND expires_at > CURRENT_TIMESTAMP +) AS has_subscription +` + +func (q *Queries) HasActiveSubscription(ctx context.Context, userID int64) (bool, error) { + row := q.db.QueryRow(ctx, HasActiveSubscription, userID) + var has_subscription bool + err := row.Scan(&has_subscription) + return has_subscription, err +} + +const ListActiveSubscriptionPlans = `-- name: ListActiveSubscriptionPlans :many +SELECT id, name, description, duration_value, duration_unit, price, currency, is_active, created_at, updated_at FROM subscription_plans +WHERE is_active = true +ORDER BY price ASC +` + +func (q *Queries) ListActiveSubscriptionPlans(ctx context.Context) ([]SubscriptionPlan, error) { + rows, err := q.db.Query(ctx, ListActiveSubscriptionPlans) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SubscriptionPlan + for rows.Next() { + var i SubscriptionPlan + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.DurationValue, + &i.DurationUnit, + &i.Price, + &i.Currency, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListSubscriptionPlans = `-- name: ListSubscriptionPlans :many +SELECT id, name, description, duration_value, duration_unit, price, currency, is_active, created_at, updated_at FROM subscription_plans +WHERE ($1::BOOLEAN IS NULL OR $1 = true AND is_active = true OR $1 = false) +ORDER BY price ASC +` + +func (q *Queries) ListSubscriptionPlans(ctx context.Context, dollar_1 bool) ([]SubscriptionPlan, error) { + rows, err := q.db.Query(ctx, ListSubscriptionPlans, dollar_1) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SubscriptionPlan + for rows.Next() { + var i SubscriptionPlan + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.DurationValue, + &i.DurationUnit, + &i.Price, + &i.Currency, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const UpdateAutoRenew = `-- name: UpdateAutoRenew :exec +UPDATE user_subscriptions +SET + auto_renew = $1, + updated_at = CURRENT_TIMESTAMP +WHERE id = $2 +` + +type UpdateAutoRenewParams struct { + AutoRenew bool `json:"auto_renew"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateAutoRenew(ctx context.Context, arg UpdateAutoRenewParams) error { + _, err := q.db.Exec(ctx, UpdateAutoRenew, arg.AutoRenew, arg.ID) + return err +} + +const UpdateSubscriptionPlan = `-- name: UpdateSubscriptionPlan :exec +UPDATE subscription_plans +SET + name = COALESCE($1, name), + description = COALESCE($2, description), + duration_value = COALESCE($3, duration_value), + duration_unit = COALESCE($4, duration_unit), + price = COALESCE($5, price), + currency = COALESCE($6, currency), + is_active = COALESCE($7, is_active), + updated_at = CURRENT_TIMESTAMP +WHERE id = $8 +` + +type UpdateSubscriptionPlanParams struct { + Name string `json:"name"` + Description pgtype.Text `json:"description"` + DurationValue int32 `json:"duration_value"` + DurationUnit string `json:"duration_unit"` + Price pgtype.Numeric `json:"price"` + Currency string `json:"currency"` + IsActive bool `json:"is_active"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateSubscriptionPlan(ctx context.Context, arg UpdateSubscriptionPlanParams) error { + _, err := q.db.Exec(ctx, UpdateSubscriptionPlan, + arg.Name, + arg.Description, + arg.DurationValue, + arg.DurationUnit, + arg.Price, + arg.Currency, + arg.IsActive, + arg.ID, + ) + return err +} + +const UpdateUserSubscriptionStatus = `-- name: UpdateUserSubscriptionStatus :exec +UPDATE user_subscriptions +SET + status = $1, + updated_at = CURRENT_TIMESTAMP +WHERE id = $2 +` + +type UpdateUserSubscriptionStatusParams struct { + Status string `json:"status"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateUserSubscriptionStatus(ctx context.Context, arg UpdateUserSubscriptionStatusParams) error { + _, err := q.db.Exec(ctx, UpdateUserSubscriptionStatus, arg.Status, arg.ID) + return err +} diff --git a/gen/db/team.sql.go b/gen/db/team.sql.go new file mode 100644 index 0000000..2eec032 --- /dev/null +++ b/gen/db/team.sql.go @@ -0,0 +1,709 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: team.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const CheckTeamMemberEmailExists = `-- name: CheckTeamMemberEmailExists :one +SELECT EXISTS ( + SELECT 1 FROM team_members WHERE email = $1 +) AS email_exists +` + +func (q *Queries) CheckTeamMemberEmailExists(ctx context.Context, email string) (bool, error) { + row := q.db.QueryRow(ctx, CheckTeamMemberEmailExists, email) + var email_exists bool + err := row.Scan(&email_exists) + return email_exists, err +} + +const CountTeamMembersByStatus = `-- name: CountTeamMembersByStatus :one +SELECT + COUNT(*) FILTER (WHERE status = 'active') AS active_count, + COUNT(*) FILTER (WHERE status = 'inactive') AS inactive_count, + COUNT(*) FILTER (WHERE status = 'suspended') AS suspended_count, + COUNT(*) FILTER (WHERE status = 'terminated') AS terminated_count, + COUNT(*) AS total_count +FROM team_members +` + +type CountTeamMembersByStatusRow struct { + ActiveCount int64 `json:"active_count"` + InactiveCount int64 `json:"inactive_count"` + SuspendedCount int64 `json:"suspended_count"` + TerminatedCount int64 `json:"terminated_count"` + TotalCount int64 `json:"total_count"` +} + +func (q *Queries) CountTeamMembersByStatus(ctx context.Context) (CountTeamMembersByStatusRow, error) { + row := q.db.QueryRow(ctx, CountTeamMembersByStatus) + var i CountTeamMembersByStatusRow + err := row.Scan( + &i.ActiveCount, + &i.InactiveCount, + &i.SuspendedCount, + &i.TerminatedCount, + &i.TotalCount, + ) + return i, err +} + +const CreateTeamMember = `-- name: CreateTeamMember :one +INSERT INTO team_members ( + first_name, + last_name, + email, + phone_number, + password, + team_role, + department, + job_title, + employment_type, + hire_date, + profile_picture_url, + bio, + work_phone, + emergency_contact, + status, + email_verified, + permissions, + created_by, + updated_at +) +VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11, $12, $13, $14, $15, $16, $17, $18, CURRENT_TIMESTAMP +) +RETURNING id, first_name, last_name, email, phone_number, password, team_role, department, job_title, employment_type, hire_date, profile_picture_url, bio, work_phone, emergency_contact, status, email_verified, permissions, last_login, created_by, updated_by, created_at, updated_at +` + +type CreateTeamMemberParams struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + PhoneNumber pgtype.Text `json:"phone_number"` + Password []byte `json:"password"` + TeamRole string `json:"team_role"` + Department pgtype.Text `json:"department"` + JobTitle pgtype.Text `json:"job_title"` + EmploymentType pgtype.Text `json:"employment_type"` + HireDate pgtype.Date `json:"hire_date"` + ProfilePictureUrl pgtype.Text `json:"profile_picture_url"` + Bio pgtype.Text `json:"bio"` + WorkPhone pgtype.Text `json:"work_phone"` + EmergencyContact pgtype.Text `json:"emergency_contact"` + Status string `json:"status"` + EmailVerified bool `json:"email_verified"` + Permissions []byte `json:"permissions"` + CreatedBy pgtype.Int8 `json:"created_by"` +} + +func (q *Queries) CreateTeamMember(ctx context.Context, arg CreateTeamMemberParams) (TeamMember, error) { + row := q.db.QueryRow(ctx, CreateTeamMember, + arg.FirstName, + arg.LastName, + arg.Email, + arg.PhoneNumber, + arg.Password, + arg.TeamRole, + arg.Department, + arg.JobTitle, + arg.EmploymentType, + arg.HireDate, + arg.ProfilePictureUrl, + arg.Bio, + arg.WorkPhone, + arg.EmergencyContact, + arg.Status, + arg.EmailVerified, + arg.Permissions, + arg.CreatedBy, + ) + var i TeamMember + err := row.Scan( + &i.ID, + &i.FirstName, + &i.LastName, + &i.Email, + &i.PhoneNumber, + &i.Password, + &i.TeamRole, + &i.Department, + &i.JobTitle, + &i.EmploymentType, + &i.HireDate, + &i.ProfilePictureUrl, + &i.Bio, + &i.WorkPhone, + &i.EmergencyContact, + &i.Status, + &i.EmailVerified, + &i.Permissions, + &i.LastLogin, + &i.CreatedBy, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const DeleteTeamMember = `-- name: DeleteTeamMember :exec +DELETE FROM team_members +WHERE id = $1 +` + +func (q *Queries) DeleteTeamMember(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, DeleteTeamMember, id) + return err +} + +const GetAllTeamMembers = `-- name: GetAllTeamMembers :many +SELECT + COUNT(*) OVER () AS total_count, + id, + first_name, + last_name, + email, + phone_number, + team_role, + department, + job_title, + employment_type, + hire_date, + profile_picture_url, + bio, + work_phone, + status, + email_verified, + permissions, + last_login, + created_at, + updated_at +FROM team_members +WHERE (team_role = $1 OR $1 IS NULL) + AND (department = $2 OR $2 IS NULL) + AND (status = $3 OR $3 IS NULL) +ORDER BY created_at DESC +LIMIT $5::INT +OFFSET $4::INT +` + +type GetAllTeamMembersParams struct { + TeamRole pgtype.Text `json:"team_role"` + Department pgtype.Text `json:"department"` + Status pgtype.Text `json:"status"` + Offset pgtype.Int4 `json:"offset"` + Limit pgtype.Int4 `json:"limit"` +} + +type GetAllTeamMembersRow struct { + TotalCount int64 `json:"total_count"` + ID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + PhoneNumber pgtype.Text `json:"phone_number"` + TeamRole string `json:"team_role"` + Department pgtype.Text `json:"department"` + JobTitle pgtype.Text `json:"job_title"` + EmploymentType pgtype.Text `json:"employment_type"` + HireDate pgtype.Date `json:"hire_date"` + ProfilePictureUrl pgtype.Text `json:"profile_picture_url"` + Bio pgtype.Text `json:"bio"` + WorkPhone pgtype.Text `json:"work_phone"` + Status string `json:"status"` + EmailVerified bool `json:"email_verified"` + Permissions []byte `json:"permissions"` + LastLogin pgtype.Timestamptz `json:"last_login"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) GetAllTeamMembers(ctx context.Context, arg GetAllTeamMembersParams) ([]GetAllTeamMembersRow, error) { + rows, err := q.db.Query(ctx, GetAllTeamMembers, + arg.TeamRole, + arg.Department, + arg.Status, + arg.Offset, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAllTeamMembersRow + for rows.Next() { + var i GetAllTeamMembersRow + if err := rows.Scan( + &i.TotalCount, + &i.ID, + &i.FirstName, + &i.LastName, + &i.Email, + &i.PhoneNumber, + &i.TeamRole, + &i.Department, + &i.JobTitle, + &i.EmploymentType, + &i.HireDate, + &i.ProfilePictureUrl, + &i.Bio, + &i.WorkPhone, + &i.Status, + &i.EmailVerified, + &i.Permissions, + &i.LastLogin, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetTeamMemberByEmail = `-- name: GetTeamMemberByEmail :one +SELECT id, first_name, last_name, email, phone_number, password, team_role, department, job_title, employment_type, hire_date, profile_picture_url, bio, work_phone, emergency_contact, status, email_verified, permissions, last_login, created_by, updated_by, created_at, updated_at FROM team_members +WHERE email = $1 +LIMIT 1 +` + +func (q *Queries) GetTeamMemberByEmail(ctx context.Context, email string) (TeamMember, error) { + row := q.db.QueryRow(ctx, GetTeamMemberByEmail, email) + var i TeamMember + err := row.Scan( + &i.ID, + &i.FirstName, + &i.LastName, + &i.Email, + &i.PhoneNumber, + &i.Password, + &i.TeamRole, + &i.Department, + &i.JobTitle, + &i.EmploymentType, + &i.HireDate, + &i.ProfilePictureUrl, + &i.Bio, + &i.WorkPhone, + &i.EmergencyContact, + &i.Status, + &i.EmailVerified, + &i.Permissions, + &i.LastLogin, + &i.CreatedBy, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const GetTeamMemberByID = `-- name: GetTeamMemberByID :one +SELECT id, first_name, last_name, email, phone_number, password, team_role, department, job_title, employment_type, hire_date, profile_picture_url, bio, work_phone, emergency_contact, status, email_verified, permissions, last_login, created_by, updated_by, created_at, updated_at FROM team_members +WHERE id = $1 +` + +func (q *Queries) GetTeamMemberByID(ctx context.Context, id int64) (TeamMember, error) { + row := q.db.QueryRow(ctx, GetTeamMemberByID, id) + var i TeamMember + err := row.Scan( + &i.ID, + &i.FirstName, + &i.LastName, + &i.Email, + &i.PhoneNumber, + &i.Password, + &i.TeamRole, + &i.Department, + &i.JobTitle, + &i.EmploymentType, + &i.HireDate, + &i.ProfilePictureUrl, + &i.Bio, + &i.WorkPhone, + &i.EmergencyContact, + &i.Status, + &i.EmailVerified, + &i.Permissions, + &i.LastLogin, + &i.CreatedBy, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const GetTeamMembersByDepartment = `-- name: GetTeamMembersByDepartment :many +SELECT + id, + first_name, + last_name, + email, + phone_number, + team_role, + department, + job_title, + employment_type, + profile_picture_url, + status, + created_at +FROM team_members +WHERE department = $1 + AND status = 'active' +ORDER BY first_name, last_name +` + +type GetTeamMembersByDepartmentRow struct { + ID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + PhoneNumber pgtype.Text `json:"phone_number"` + TeamRole string `json:"team_role"` + Department pgtype.Text `json:"department"` + JobTitle pgtype.Text `json:"job_title"` + EmploymentType pgtype.Text `json:"employment_type"` + ProfilePictureUrl pgtype.Text `json:"profile_picture_url"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +func (q *Queries) GetTeamMembersByDepartment(ctx context.Context, department pgtype.Text) ([]GetTeamMembersByDepartmentRow, error) { + rows, err := q.db.Query(ctx, GetTeamMembersByDepartment, department) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetTeamMembersByDepartmentRow + for rows.Next() { + var i GetTeamMembersByDepartmentRow + if err := rows.Scan( + &i.ID, + &i.FirstName, + &i.LastName, + &i.Email, + &i.PhoneNumber, + &i.TeamRole, + &i.Department, + &i.JobTitle, + &i.EmploymentType, + &i.ProfilePictureUrl, + &i.Status, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetTeamMembersByRole = `-- name: GetTeamMembersByRole :many +SELECT + id, + first_name, + last_name, + email, + phone_number, + team_role, + department, + job_title, + employment_type, + profile_picture_url, + status, + created_at +FROM team_members +WHERE team_role = $1 + AND status = 'active' +ORDER BY first_name, last_name +` + +type GetTeamMembersByRoleRow struct { + ID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + PhoneNumber pgtype.Text `json:"phone_number"` + TeamRole string `json:"team_role"` + Department pgtype.Text `json:"department"` + JobTitle pgtype.Text `json:"job_title"` + EmploymentType pgtype.Text `json:"employment_type"` + ProfilePictureUrl pgtype.Text `json:"profile_picture_url"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +func (q *Queries) GetTeamMembersByRole(ctx context.Context, teamRole string) ([]GetTeamMembersByRoleRow, error) { + rows, err := q.db.Query(ctx, GetTeamMembersByRole, teamRole) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetTeamMembersByRoleRow + for rows.Next() { + var i GetTeamMembersByRoleRow + if err := rows.Scan( + &i.ID, + &i.FirstName, + &i.LastName, + &i.Email, + &i.PhoneNumber, + &i.TeamRole, + &i.Department, + &i.JobTitle, + &i.EmploymentType, + &i.ProfilePictureUrl, + &i.Status, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const SearchTeamMembers = `-- name: SearchTeamMembers :many +SELECT + id, + first_name, + last_name, + email, + phone_number, + team_role, + department, + job_title, + employment_type, + hire_date, + profile_picture_url, + bio, + status, + email_verified, + permissions, + last_login, + created_at, + updated_at +FROM team_members +WHERE ( + first_name ILIKE '%' || $1 || '%' + OR last_name ILIKE '%' || $1 || '%' + OR email ILIKE '%' || $1 || '%' + OR phone_number ILIKE '%' || $1 || '%' + ) + AND (team_role = $2 OR $2 IS NULL) + AND (status = $3 OR $3 IS NULL) +` + +type SearchTeamMembersParams struct { + Column1 pgtype.Text `json:"column_1"` + TeamRole pgtype.Text `json:"team_role"` + Status pgtype.Text `json:"status"` +} + +type SearchTeamMembersRow struct { + ID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + PhoneNumber pgtype.Text `json:"phone_number"` + TeamRole string `json:"team_role"` + Department pgtype.Text `json:"department"` + JobTitle pgtype.Text `json:"job_title"` + EmploymentType pgtype.Text `json:"employment_type"` + HireDate pgtype.Date `json:"hire_date"` + ProfilePictureUrl pgtype.Text `json:"profile_picture_url"` + Bio pgtype.Text `json:"bio"` + Status string `json:"status"` + EmailVerified bool `json:"email_verified"` + Permissions []byte `json:"permissions"` + LastLogin pgtype.Timestamptz `json:"last_login"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) SearchTeamMembers(ctx context.Context, arg SearchTeamMembersParams) ([]SearchTeamMembersRow, error) { + rows, err := q.db.Query(ctx, SearchTeamMembers, arg.Column1, arg.TeamRole, arg.Status) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SearchTeamMembersRow + for rows.Next() { + var i SearchTeamMembersRow + if err := rows.Scan( + &i.ID, + &i.FirstName, + &i.LastName, + &i.Email, + &i.PhoneNumber, + &i.TeamRole, + &i.Department, + &i.JobTitle, + &i.EmploymentType, + &i.HireDate, + &i.ProfilePictureUrl, + &i.Bio, + &i.Status, + &i.EmailVerified, + &i.Permissions, + &i.LastLogin, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const UpdateTeamMember = `-- name: UpdateTeamMember :exec +UPDATE team_members +SET + first_name = COALESCE($1, first_name), + last_name = COALESCE($2, last_name), + phone_number = COALESCE($3, phone_number), + team_role = COALESCE($4, team_role), + department = COALESCE($5, department), + job_title = COALESCE($6, job_title), + employment_type = COALESCE($7, employment_type), + hire_date = COALESCE($8, hire_date), + profile_picture_url = COALESCE($9, profile_picture_url), + bio = COALESCE($10, bio), + work_phone = COALESCE($11, work_phone), + emergency_contact = COALESCE($12, emergency_contact), + permissions = COALESCE($13, permissions), + updated_by = $14, + updated_at = CURRENT_TIMESTAMP +WHERE id = $15 +` + +type UpdateTeamMemberParams struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + PhoneNumber pgtype.Text `json:"phone_number"` + TeamRole string `json:"team_role"` + Department pgtype.Text `json:"department"` + JobTitle pgtype.Text `json:"job_title"` + EmploymentType pgtype.Text `json:"employment_type"` + HireDate pgtype.Date `json:"hire_date"` + ProfilePictureUrl pgtype.Text `json:"profile_picture_url"` + Bio pgtype.Text `json:"bio"` + WorkPhone pgtype.Text `json:"work_phone"` + EmergencyContact pgtype.Text `json:"emergency_contact"` + Permissions []byte `json:"permissions"` + UpdatedBy pgtype.Int8 `json:"updated_by"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateTeamMember(ctx context.Context, arg UpdateTeamMemberParams) error { + _, err := q.db.Exec(ctx, UpdateTeamMember, + arg.FirstName, + arg.LastName, + arg.PhoneNumber, + arg.TeamRole, + arg.Department, + arg.JobTitle, + arg.EmploymentType, + arg.HireDate, + arg.ProfilePictureUrl, + arg.Bio, + arg.WorkPhone, + arg.EmergencyContact, + arg.Permissions, + arg.UpdatedBy, + arg.ID, + ) + return err +} + +const UpdateTeamMemberEmailVerified = `-- name: UpdateTeamMemberEmailVerified :exec +UPDATE team_members +SET + email_verified = $1, + updated_at = CURRENT_TIMESTAMP +WHERE id = $2 +` + +type UpdateTeamMemberEmailVerifiedParams struct { + EmailVerified bool `json:"email_verified"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateTeamMemberEmailVerified(ctx context.Context, arg UpdateTeamMemberEmailVerifiedParams) error { + _, err := q.db.Exec(ctx, UpdateTeamMemberEmailVerified, arg.EmailVerified, arg.ID) + return err +} + +const UpdateTeamMemberLastLogin = `-- name: UpdateTeamMemberLastLogin :exec +UPDATE team_members +SET + last_login = CURRENT_TIMESTAMP +WHERE id = $1 +` + +func (q *Queries) UpdateTeamMemberLastLogin(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, UpdateTeamMemberLastLogin, id) + return err +} + +const UpdateTeamMemberPassword = `-- name: UpdateTeamMemberPassword :exec +UPDATE team_members +SET + password = $1, + updated_at = CURRENT_TIMESTAMP +WHERE id = $2 +` + +type UpdateTeamMemberPasswordParams struct { + Password []byte `json:"password"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateTeamMemberPassword(ctx context.Context, arg UpdateTeamMemberPasswordParams) error { + _, err := q.db.Exec(ctx, UpdateTeamMemberPassword, arg.Password, arg.ID) + return err +} + +const UpdateTeamMemberStatus = `-- name: UpdateTeamMemberStatus :exec +UPDATE team_members +SET + status = $1, + updated_by = $2, + updated_at = CURRENT_TIMESTAMP +WHERE id = $3 +` + +type UpdateTeamMemberStatusParams struct { + Status string `json:"status"` + UpdatedBy pgtype.Int8 `json:"updated_by"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateTeamMemberStatus(ctx context.Context, arg UpdateTeamMemberStatusParams) error { + _, err := q.db.Exec(ctx, UpdateTeamMemberStatus, arg.Status, arg.UpdatedBy, arg.ID) + return err +} diff --git a/gen/db/user.sql.go b/gen/db/user.sql.go index 83e1a69..69d7398 100644 --- a/gen/db/user.sql.go +++ b/gen/db/user.sql.go @@ -48,13 +48,12 @@ INSERT INTO users ( role, status, email_verified, - profile_picture_url, - profile_completed + profile_picture_url ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, true, $8, false + $1, $2, $3, $4, $5, $6, $7, true, $8 ) -RETURNING id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group, google_id, google_email_verified +RETURNING id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group, google_id, google_email_verified, profile_completion_percentage ` type CreateGoogleUserParams struct { @@ -113,6 +112,7 @@ func (q *Queries) CreateGoogleUser(ctx context.Context, arg CreateGoogleUserPara &i.AgeGroup, &i.GoogleID, &i.GoogleEmailVerified, + &i.ProfileCompletionPercentage, ) return i, err } @@ -465,6 +465,27 @@ func (q *Queries) GetAllUsers(ctx context.Context, arg GetAllUsersParams) ([]Get return items, nil } +const GetProfileCompletionStatus = `-- name: GetProfileCompletionStatus :one +SELECT + profile_completed, + profile_completion_percentage +FROM users +WHERE id = $1 +LIMIT 1 +` + +type GetProfileCompletionStatusRow struct { + ProfileCompleted pgtype.Bool `json:"profile_completed"` + ProfileCompletionPercentage int16 `json:"profile_completion_percentage"` +} + +func (q *Queries) GetProfileCompletionStatus(ctx context.Context, id int64) (GetProfileCompletionStatusRow, error) { + row := q.db.QueryRow(ctx, GetProfileCompletionStatus, id) + var i GetProfileCompletionStatusRow + err := row.Scan(&i.ProfileCompleted, &i.ProfileCompletionPercentage) + return i, err +} + const GetTotalUsers = `-- name: GetTotalUsers :one SELECT COUNT(*) FROM users @@ -625,7 +646,7 @@ func (q *Queries) GetUserByEmailPhone(ctx context.Context, arg GetUserByEmailPho } const GetUserByGoogleID = `-- name: GetUserByGoogleID :one -SELECT id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group, google_id, google_email_verified +SELECT id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group, google_id, google_email_verified, profile_completion_percentage FROM users WHERE google_id = $1 ` @@ -666,12 +687,13 @@ func (q *Queries) GetUserByGoogleID(ctx context.Context, googleID pgtype.Text) ( &i.AgeGroup, &i.GoogleID, &i.GoogleEmailVerified, + &i.ProfileCompletionPercentage, ) return i, err } const GetUserByID = `-- name: GetUserByID :one -SELECT id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group, google_id, google_email_verified +SELECT id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group, google_id, google_email_verified, profile_completion_percentage FROM users WHERE id = $1 ` @@ -712,25 +734,11 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { &i.AgeGroup, &i.GoogleID, &i.GoogleEmailVerified, + &i.ProfileCompletionPercentage, ) return i, err } -const IsProfileCompleted = `-- name: IsProfileCompleted :one -SELECT - CASE WHEN profile_completed = true THEN true ELSE false END AS is_pending -FROM users -WHERE id = $1 -LIMIT 1 -` - -func (q *Queries) IsProfileCompleted(ctx context.Context, id int64) (bool, error) { - row := q.db.QueryRow(ctx, IsProfileCompleted, id) - var is_pending bool - err := row.Scan(&is_pending) - return is_pending, err -} - const IsUserNameUnique = `-- name: IsUserNameUnique :one SELECT CASE WHEN COUNT(*) = 0 THEN true ELSE false END AS is_unique @@ -941,13 +949,12 @@ SET language_challange = COALESCE($12, language_challange), favourite_topic = COALESCE($13, favourite_topic), initial_assessment_completed = COALESCE($14, initial_assessment_completed), - profile_completed = COALESCE($15, profile_completed), - profile_picture_url = COALESCE($16, profile_picture_url), - preferred_language = COALESCE($17, preferred_language), - gender = COALESCE($18, gender), - birth_day = COALESCE($19, birth_day), + profile_picture_url = COALESCE($15, profile_picture_url), + preferred_language = COALESCE($16, preferred_language), + gender = COALESCE($17, gender), + birth_day = COALESCE($18, birth_day), updated_at = CURRENT_TIMESTAMP -WHERE id = $20 +WHERE id = $19 ` type UpdateUserParams struct { @@ -965,7 +972,6 @@ type UpdateUserParams struct { LanguageChallange pgtype.Text `json:"language_challange"` FavouriteTopic pgtype.Text `json:"favourite_topic"` InitialAssessmentCompleted bool `json:"initial_assessment_completed"` - ProfileCompleted pgtype.Bool `json:"profile_completed"` ProfilePictureUrl pgtype.Text `json:"profile_picture_url"` PreferredLanguage pgtype.Text `json:"preferred_language"` Gender pgtype.Text `json:"gender"` @@ -973,6 +979,7 @@ type UpdateUserParams struct { ID int64 `json:"id"` } +// Note: profile_completed and profile_completion_percentage are computed by database trigger func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) error { _, err := q.db.Exec(ctx, UpdateUser, arg.FirstName, @@ -989,7 +996,6 @@ func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) error { arg.LanguageChallange, arg.FavouriteTopic, arg.InitialAssessmentCompleted, - arg.ProfileCompleted, arg.ProfilePictureUrl, arg.PreferredLanguage, arg.Gender, diff --git a/go.mod b/go.mod index 06c0da9..1a5513d 100644 --- a/go.mod +++ b/go.mod @@ -5,15 +5,18 @@ go 1.24.0 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.28.8 + 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 ) require ( @@ -26,7 +29,6 @@ require ( cloud.google.com/go/longrunning v0.6.7 // indirect cloud.google.com/go/monitoring v1.24.2 // indirect cloud.google.com/go/storage v1.53.0 // indirect - firebase.google.com/go/v4 v4.19.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect @@ -60,7 +62,6 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect golang.org/x/time v0.14.0 // indirect - google.golang.org/api v0.239.0 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b // indirect diff --git a/go.sum b/go.sum index 4482a46..b8b9d30 100644 --- a/go.sum +++ b/go.sum @@ -187,6 +187,8 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= @@ -214,6 +216,8 @@ 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.28.8 h1:wbFz7Wt4S5mCEaes6FcM/ddcJGIhdjwp/9CHb9e+4fk= github.com/twilio/twilio-go v1.28.8/go.mod h1:FpgNWMoD8CFnmukpKq9RNpUSGXC0BwnbeKZj2YHlIkw= +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= diff --git a/internal/config/config.go b/internal/config/config.go index 4d84067..5015beb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,19 +24,6 @@ var ( ErrInvalidEnv = errors.New("env not set or invalid") ErrInvalidReportExportPath = errors.New("report export path is invalid") ErrInvalidSMSAPIKey = errors.New("SMS API key is invalid") - // ErrMissingBetToken = errors.New("missing BET365_TOKEN in .env") - // ErrInvalidPopOKClientID = errors.New("PopOK client ID is invalid") - // ErrInvalidPopOKSecretKey = errors.New("PopOK secret key is invalid") - // ErrInvalidPopOKBaseURL = errors.New("PopOK base URL is invalid") - // ErrInvalidPopOKCallbackURL = errors.New("PopOK callback URL is invalid") - // ErrInvalidVeliAPIURL = errors.New("Veli API URL is invalid") - // ErrInvalidVeliOperatorKey = errors.New("Veli operator key is invalid") - // ErrInvalidVeliSecretKey = errors.New("Veli secret key is invalid") - // ErrInvalidAtlasBaseUrl = errors.New("Atlas Base URL is invalid") - // ErrInvalidAtlasOperatorID = errors.New("Atlas operator ID is invalid") - // ErrInvalidAtlasSecretKey = errors.New("Atlas secret key is invalid") - // ErrInvalidAtlasBrandID = errors.New("Atlas brand ID is invalid") - // ErrInvalidAtlasPartnerID = errors.New("Atlas Partner ID is invalid") ErrMissingResendApiKey = errors.New("missing Resend Api key") ErrMissingResendSenderEmail = errors.New("missing Resend sender name") @@ -45,33 +32,6 @@ var ( ErrMissingTwilioSenderPhoneNumber = errors.New("missing twilio sender phone number") ) -// type EnetPulseConfig struct { -// UserName string `mapstructure:"username"` -// Token string `mapstructure:"token"` -// ProviderID string `mapstructure:"provider_id"` -// } - -// type AleaPlayConfig struct { -// Enabled bool `mapstructure:"enabled"` -// BaseURL string `mapstructure:"base_url"` // "https://api.aleaplay.com" -// OperatorID string `mapstructure:"operator_id"` // Your operator ID with Alea -// SecretKey string `mapstructure:"secret_key"` // API secret for signatures -// GameListURL string `mapstructure:"game_list_url"` // Endpoint to fetch available games -// DefaultCurrency string `mapstructure:"default_currency"` // "USD", "EUR", etc. -// SessionTimeout int `mapstructure:"session_timeout"` // In hours -// } - -// type VeliConfig struct { -// APIKey string `mapstructure:"VELI_API_KEY"` -// BaseURL string `mapstructure:"VELI_BASE_URL"` -// SecretKey string `mapstructure:"VELI_SECRET_KEY"` -// OperatorID string `mapstructure:"VELI_OPERATOR_ID"` -// BrandID string `mapstructure:"VELI_BRAND_ID"` -// Currency string `mapstructure:"VELI_DEFAULT_CURRENCY"` -// WebhookURL string `mapstructure:"VELI_WEBHOOK_URL"` -// Enabled bool `mapstructure:"Enabled"` -// } - type AFROSMSConfig struct { AfroSMSSenderName string `mapstructure:"afrom_sms_sender_name"` AfroSMSIdentifierID string `mapstructure:"afro_sms_identifier_id"` @@ -79,14 +39,6 @@ type AFROSMSConfig struct { AfroSMSBaseURL string `mapstructure:"afro_sms_base_url"` } -// type AtlasConfig struct { -// BaseURL string `mapstructure:"ATLAS_BASE_URL"` -// SecretKey string `mapstructure:"ATLAS_SECRET_KEY"` -// OperatorID string `mapstructure:"ATLAS_OPERATOR_ID"` -// CasinoID string `mapstructure:"ATLAS_BRAND_ID"` -// PartnerID string `mapstructure:"ATLAS_PARTNER_ID"` -// } - type ARIFPAYConfig struct { APIKey string `mapstructure:"ARIFPAY_API_KEY"` BaseURL string `mapstructure:"ARIFPAY_BASE_URL"` @@ -126,9 +78,17 @@ type TELEBIRRConfig struct { TelebirrCallbackURL string `mapstructure:"callback_url"` } +type VimeoConfig struct { + AccessToken string `mapstructure:"vimeo_access_token"` + Enabled bool `mapstructure:"vimeo_enabled"` +} + type Config struct { GoogleOAuthClientID string + GoogleOAuthClientSecret string + GoogleOAuthRedirectURL string AFROSMSConfig AFROSMSConfig `mapstructure:"afro_sms_config"` + Vimeo VimeoConfig `mapstructure:"vimeo_config"` APP_VERSION string FIXER_API_KEY string FIXER_BASE_URL string @@ -190,6 +150,8 @@ func (c *Config) loadEnv() error { c.Env = env c.GoogleOAuthClientID = os.Getenv("GOOGLE_OAUTH_CLIENT_ID") + c.GoogleOAuthClientSecret = os.Getenv("GOOGLE_OAUTH_CLIENT_SECRET") + c.GoogleOAuthRedirectURL = os.Getenv("GOOGLE_OAUTH_REDIRECT_URL") c.APP_VERSION = os.Getenv("APP_VERSION") @@ -514,6 +476,13 @@ func (c *Config) loadEnv() error { c.FCMServiceAccountKey = os.Getenv("FCM_SERVICE_ACCOUNT_KEY") + // Vimeo configuration + vimeoEnabled := os.Getenv("VIMEO_ENABLED") + if vimeoEnabled == "true" || vimeoEnabled == "1" { + c.Vimeo.Enabled = true + } + c.Vimeo.AccessToken = os.Getenv("VIMEO_ACCESS_TOKEN") + return nil } diff --git a/internal/domain/arifpay.go b/internal/domain/arifpay.go index 2be1616..eba277e 100644 --- a/internal/domain/arifpay.go +++ b/internal/domain/arifpay.go @@ -66,12 +66,53 @@ type WebhookRequest struct { // // CustomerPhone string `json:"customerPhone" binding:"required"` // } -type ArifpayVerifyByTransactionIDRequest struct{ +type ArifpayVerifyByTransactionIDRequest struct { TransactionId string `json:"transactionId"` - PaymentType int `json:"paymentType"` + PaymentType int `json:"paymentType"` } type ARIFPAYPaymentMethod struct { ID int Name string } + +// Direct Payment Types +type DirectPaymentMethod string + +const ( + DirectPaymentTelebirr DirectPaymentMethod = "TELEBIRR" + DirectPaymentTelebirrUSSD DirectPaymentMethod = "TELEBIRR_USSD" + DirectPaymentCBE DirectPaymentMethod = "CBE" + DirectPaymentAmole DirectPaymentMethod = "AMOLE" + DirectPaymentHelloCash DirectPaymentMethod = "HELLOCASH" + DirectPaymentAwash DirectPaymentMethod = "AWASH" + DirectPaymentMPesa DirectPaymentMethod = "MPESA" +) + +type InitiateDirectPaymentRequest struct { + PlanID int64 `json:"plan_id" validate:"required"` + Phone string `json:"phone" validate:"required"` + Email string `json:"email" validate:"required,email"` + PaymentMethod DirectPaymentMethod `json:"payment_method" validate:"required"` +} + +type InitiateDirectPaymentResponse struct { + PaymentID int64 `json:"payment_id"` + SessionID string `json:"session_id"` + RequiresOTP bool `json:"requires_otp"` + Message string `json:"message"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` +} + +type VerifyOTPRequest struct { + SessionID string `json:"session_id" validate:"required"` + OTP string `json:"otp" validate:"required"` +} + +type VerifyOTPResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + TransactionID string `json:"transaction_id,omitempty"` + PaymentID int64 `json:"payment_id,omitempty"` +} diff --git a/internal/domain/course_management.go b/internal/domain/course_management.go index 90f493e..b80aae1 100644 --- a/internal/domain/course_management.go +++ b/internal/domain/course_management.go @@ -2,27 +2,33 @@ package domain import "time" -type TreeModule struct { +type SubCourseLevel string + +const ( + SubCourseLevelBeginner SubCourseLevel = "BEGINNER" + SubCourseLevelIntermediate SubCourseLevel = "INTERMEDIATE" + SubCourseLevelAdvanced SubCourseLevel = "ADVANCED" +) + +type ContentStatus string + +const ( + ContentStatusDraft ContentStatus = "DRAFT" + ContentStatusPublished ContentStatus = "PUBLISHED" + ContentStatusInactive ContentStatus = "INACTIVE" + ContentStatusArchived ContentStatus = "ARCHIVED" +) + +type TreeSubCourse struct { ID int64 Title string -} - -type TreeLevel struct { - ID int64 - Title string - Modules []TreeModule -} - -type TreeProgram struct { - ID int64 - Title string - Levels []TreeLevel + Level string } type TreeCourse struct { - ID int64 - Title string - Programs []TreeProgram + ID int64 + Title string + SubCourses []TreeSubCourse } type CourseCategory struct { @@ -32,36 +38,29 @@ type CourseCategory struct { CreatedAt time.Time } -type Program struct { +type Course struct { + ID int64 + CategoryID int64 + Title string + Description *string + Thumbnail *string + IsActive bool +} + +type SubCourse struct { ID int64 CourseID int64 Title string Description *string Thumbnail *string DisplayOrder int32 + Level string IsActive bool } -type Course struct { - ID int64 - CategoryID int64 - Title string - Description *string - IsActive bool -} - -type Module struct { +type SubCourseVideo struct { ID int64 - LevelID int64 - Title string - Content *string - DisplayOrder int32 - IsActive bool -} - -type ModuleVideo struct { - ID int64 - ModuleID int64 + SubCourseID int64 Title string Description *string VideoURL string @@ -70,41 +69,20 @@ type ModuleVideo struct { InstructorID *string Thumbnail *string Visibility *string + DisplayOrder int32 IsPublished bool PublishDate *time.Time - IsActive bool + Status string + // Vimeo-specific fields + VimeoID *string + VimeoEmbedURL *string + VimeoPlayerHTML *string + VimeoStatus *string } -type PracticeQuestion struct { - ID int64 - PracticeID int64 - Question string - QuestionVoicePrompt *string - SampleAnswerVoicePrompt *string - SampleAnswer *string - Tips *string - Type string -} +type VideoHostProvider string -type Practice struct { - ID int64 - OwnerType string - OwnerID int64 - Title string - Description *string - BannerImage *string - Persona *string - IsActive bool -} - -type Level struct { - ID int64 - ProgramID int64 - Title string - Description *string - LevelIndex int - NumberOfModules int - NumberOfPractices int - NumberOfVideos int - IsActive bool -} +const ( + VideoHostProviderDirect VideoHostProvider = "DIRECT" + VideoHostProviderVimeo VideoHostProvider = "VIMEO" +) diff --git a/internal/domain/currency.go b/internal/domain/currency.go index a15ba52..c939e3d 100644 --- a/internal/domain/currency.go +++ b/internal/domain/currency.go @@ -25,7 +25,6 @@ func (m Currency) String() string { return fmt.Sprintf("$%.2f", m.Float32()) } - // TODO: Change the currency to this format when implementing multi-currency // type Currency struct { // Value int64 diff --git a/internal/domain/initial_assessment.go b/internal/domain/initial_assessment.go deleted file mode 100644 index 417852b..0000000 --- a/internal/domain/initial_assessment.go +++ /dev/null @@ -1,84 +0,0 @@ -package domain - -import ( - "time" -) - -type QuestionType string - -const ( - MultipleChoice QuestionType = "MULTIPLE_CHOICE" - TrueFalse QuestionType = "TRUE_FALSE" - ShortAnswer QuestionType = "SHORT_ANSWER" -) - -type AssessmentQuestion struct { - ID int64 `json:"id"` - Title string `json:"title"` - Description string `json:"description"` - QuestionType string `json:"question_type"` - DifficultyLevel string `json:"difficulty_level"` - Points int32 `json:"points"` - IsActive bool `json:"is_active"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type QuestionWithDetails struct { - Question AssessmentQuestion - Options []QuestionOption -} - -type QuestionOption struct { - QuestionID int64 `json:"question_id"` - OptionText string `json:"option_text"` -} - -type CreateAssessmentQuestionInput struct { - Title string - Description *string - QuestionType QuestionType - DifficultyLevel string - Points int32 - IsActive bool - - // Multiple Choice only - Options []CreateQuestionOptionInput - - // Short Answer only - CorrectAnswer *string -} - -type CreateQuestionOptionInput struct { - Text string - Order int32 - IsCorrect bool -} - -// type AssessmentQuestion struct { -// ID int64 -// QuestionText string -// Type QuestionType -// Options []string -// CorrectAnswer string -// } - -// type AssessmentOption struct { -// ID int64 -// OptionText string -// IsCorrect bool -// } - -// type AttemptAnswer struct { -// QuestionID int64 -// Answer string -// IsCorrect *bool -// } - -// type AssessmentAttempt struct { -// ID int64 -// UserID int64 -// Answers []AttemptAnswer -// Score int -// Completed bool -// } diff --git a/internal/domain/interval.go b/internal/domain/interval.go index b1f911f..dc8c617 100644 --- a/internal/domain/interval.go +++ b/internal/domain/interval.go @@ -8,7 +8,6 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) - var ( ErrInvalidInterval = errors.New("invalid interval provided") ) diff --git a/internal/domain/mongoLogs.go b/internal/domain/mongoLogs.go index 4007023..f98e877 100644 --- a/internal/domain/mongoLogs.go +++ b/internal/domain/mongoLogs.go @@ -16,5 +16,3 @@ type LogResponse struct { Data []LogEntry `json:"data"` Pagination Pagination `json:"pagination"` } - - diff --git a/internal/domain/payment.go b/internal/domain/payment.go new file mode 100644 index 0000000..aaef1e8 --- /dev/null +++ b/internal/domain/payment.go @@ -0,0 +1,72 @@ +package domain + +import "time" + +type PaymentStatus string + +const ( + PaymentStatusPending PaymentStatus = "PENDING" + PaymentStatusProcessing PaymentStatus = "PROCESSING" + PaymentStatusSuccess PaymentStatus = "SUCCESS" + PaymentStatusFailed PaymentStatus = "FAILED" + PaymentStatusCancelled PaymentStatus = "CANCELLED" + PaymentStatusExpired PaymentStatus = "EXPIRED" +) + +type Payment struct { + ID int64 + UserID int64 + PlanID *int64 + SubscriptionID *int64 + SessionID *string + TransactionID *string + Nonce string + Amount float64 + Currency string + PaymentMethod *string + Status string + PaymentURL *string + PaidAt *time.Time + ExpiresAt *time.Time + CreatedAt time.Time + UpdatedAt *time.Time + PlanName *string +} + +type CreatePaymentInput struct { + UserID int64 + PlanID *int64 + Amount float64 + Currency string + PaymentMethod *string + Nonce string + ExpiresAt *time.Time +} + +type InitiateSubscriptionPaymentRequest struct { + PlanID int64 `json:"plan_id" validate:"required"` + Phone string `json:"phone" validate:"required"` + Email string `json:"email" validate:"required,email"` +} + +type InitiateSubscriptionPaymentResponse struct { + PaymentID int64 `json:"payment_id"` + SessionID string `json:"session_id"` + PaymentURL string `json:"payment_url"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` + ExpiresAt string `json:"expires_at"` +} + +type VerifyPaymentRequest struct { + SessionID string `json:"session_id"` +} + +type PaymentWebhookData struct { + SessionID string + TransactionID string + Nonce string + TransactionStatus string + PaymentMethod string + TotalAmount float64 +} diff --git a/internal/domain/questions.go b/internal/domain/questions.go new file mode 100644 index 0000000..d2534e3 --- /dev/null +++ b/internal/domain/questions.go @@ -0,0 +1,165 @@ +package domain + +import "time" + +type QuestionType string + +const ( + QuestionTypeMCQ QuestionType = "MCQ" + QuestionTypeTrueFalse QuestionType = "TRUE_FALSE" + QuestionTypeShortAnswer QuestionType = "SHORT_ANSWER" +) + +type DifficultyLevel string + +const ( + DifficultyEasy DifficultyLevel = "EASY" + DifficultyMedium DifficultyLevel = "MEDIUM" + DifficultyHard DifficultyLevel = "HARD" +) + +type QuestionSetType string + +const ( + QuestionSetTypePractice QuestionSetType = "PRACTICE" + QuestionSetTypeInitialAssessment QuestionSetType = "INITIAL_ASSESSMENT" + QuestionSetTypeQuiz QuestionSetType = "QUIZ" + QuestionSetTypeExam QuestionSetType = "EXAM" + QuestionSetTypeSurvey QuestionSetType = "SURVEY" +) + +type MatchType string + +const ( + MatchTypeExact MatchType = "EXACT" + MatchTypeContains MatchType = "CONTAINS" + MatchTypeCaseInsensitive MatchType = "CASE_INSENSITIVE" +) + +type Question struct { + ID int64 + QuestionText string + QuestionType string + DifficultyLevel *string + Points int32 + Explanation *string + Tips *string + VoicePrompt *string + SampleAnswerVoicePrompt *string + Status string + CreatedAt time.Time + UpdatedAt *time.Time +} + +type QuestionWithDetails struct { + Question + Options []QuestionOption + ShortAnswers []QuestionShortAnswer +} + +type QuestionOption struct { + ID int64 + QuestionID int64 + OptionText string + OptionOrder int32 + IsCorrect bool + CreatedAt time.Time +} + +type QuestionShortAnswer struct { + ID int64 + QuestionID int64 + AcceptableAnswer string + MatchType string + CreatedAt time.Time +} + +type QuestionSet struct { + ID int64 + Title string + Description *string + SetType string + OwnerType *string + OwnerID *int64 + BannerImage *string + Persona *string + TimeLimitMinutes *int32 + PassingScore *int32 + ShuffleQuestions bool + Status string + SubCourseVideoID *int64 + UserPersonas []UserPersona + CreatedAt time.Time + UpdatedAt *time.Time +} + +type QuestionSetItem struct { + ID int64 + SetID int64 + QuestionID int64 + DisplayOrder int32 + CreatedAt time.Time +} + +type QuestionSetItemWithQuestion struct { + QuestionSetItem + QuestionText string + QuestionType string + DifficultyLevel *string + Points int32 + Explanation *string + Tips *string + VoicePrompt *string + QuestionStatus string +} + +type CreateQuestionInput struct { + QuestionText string + QuestionType string + DifficultyLevel *string + Points *int32 + Explanation *string + Tips *string + VoicePrompt *string + SampleAnswerVoicePrompt *string + Status *string + Options []CreateQuestionOptionInput + ShortAnswers []CreateShortAnswerInput +} + +type CreateQuestionOptionInput struct { + OptionText string + OptionOrder *int32 + IsCorrect bool +} + +type CreateShortAnswerInput struct { + AcceptableAnswer string + MatchType *string +} + +type CreateQuestionSetInput struct { + Title string + Description *string + SetType string + OwnerType *string + OwnerID *int64 + BannerImage *string + Persona *string + TimeLimitMinutes *int32 + PassingScore *int32 + ShuffleQuestions *bool + Status *string + SubCourseVideoID *int64 +} + +// UserPersona represents a user acting as a persona in a practice session +type UserPersona struct { + ID int64 + FirstName *string + LastName *string + NickName *string + ProfilePictureURL *string + Role string + DisplayOrder int32 +} diff --git a/internal/domain/recommendation.go b/internal/domain/recommendation.go index f1bce66..d671bab 100644 --- a/internal/domain/recommendation.go +++ b/internal/domain/recommendation.go @@ -1,11 +1,10 @@ package domain type RecommendationSuccessfulResponse struct { - Message string `json:"message"` + Message string `json:"message"` // RecommendedGames []VirtualGame `json:"recommended_games"` } type RecommendationErrorResponse struct { Message string `json:"message"` } - diff --git a/internal/domain/report_request_status.go b/internal/domain/report_request_status.go index 81b5fe4..070184a 100644 --- a/internal/domain/report_request_status.go +++ b/internal/domain/report_request_status.go @@ -6,7 +6,6 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) - type ReportRequestStatus string var ( @@ -40,6 +39,6 @@ type ValidReportRequestStatus struct { func (v ValidReportRequestStatus) ToPG() pgtype.Text { return pgtype.Text{ String: string(v.Value), - Valid: v.Valid, + Valid: v.Valid, } -} \ No newline at end of file +} diff --git a/internal/domain/santimpay.go b/internal/domain/santimpay.go index 89c2c51..f0f62e2 100644 --- a/internal/domain/santimpay.go +++ b/internal/domain/santimpay.go @@ -77,5 +77,5 @@ type SantimpayB2CWithdrawalRequest struct { type TransactionStatusRequest struct { TransactionID string `json:"id"` - FullParams *bool `json:"fullParams,omitempty"` + FullParams *bool `json:"fullParams,omitempty"` } diff --git a/internal/domain/subscriptions.go b/internal/domain/subscriptions.go new file mode 100644 index 0000000..d5e7228 --- /dev/null +++ b/internal/domain/subscriptions.go @@ -0,0 +1,104 @@ +package domain + +import ( + "time" +) + +type DurationUnit string + +const ( + DurationUnitDay DurationUnit = "DAY" + DurationUnitWeek DurationUnit = "WEEK" + DurationUnitMonth DurationUnit = "MONTH" + DurationUnitYear DurationUnit = "YEAR" +) + +type SubscriptionStatus string + +const ( + SubscriptionStatusPending SubscriptionStatus = "PENDING" + SubscriptionStatusActive SubscriptionStatus = "ACTIVE" + SubscriptionStatusExpired SubscriptionStatus = "EXPIRED" + SubscriptionStatusCancelled SubscriptionStatus = "CANCELLED" +) + +type SubscriptionPlan struct { + ID int64 + Name string + Description *string + DurationValue int32 + DurationUnit string + Price float64 + Currency string + IsActive bool + CreatedAt time.Time + UpdatedAt *time.Time +} + +type UserSubscription struct { + ID int64 + UserID int64 + PlanID int64 + StartsAt time.Time + ExpiresAt time.Time + Status string + PaymentReference *string + PaymentMethod *string + AutoRenew bool + CancelledAt *time.Time + CreatedAt time.Time + UpdatedAt *time.Time + // Joined fields from plan + PlanName *string + DurationValue *int32 + DurationUnit *string + Price *float64 + Currency *string +} + +type CreateSubscriptionPlanInput struct { + Name string + Description *string + DurationValue int32 + DurationUnit string + Price float64 + Currency string + IsActive *bool +} + +type UpdateSubscriptionPlanInput struct { + Name *string + Description *string + DurationValue *int32 + DurationUnit *string + Price *float64 + Currency *string + IsActive *bool +} + +type CreateUserSubscriptionInput struct { + UserID int64 + PlanID int64 + StartsAt *time.Time + ExpiresAt time.Time + Status *string + PaymentReference *string + PaymentMethod *string + AutoRenew *bool +} + +// CalculateExpiryDate calculates the expiry date based on plan duration +func CalculateExpiryDate(startTime time.Time, durationValue int32, durationUnit string) time.Time { + switch durationUnit { + case string(DurationUnitDay): + return startTime.AddDate(0, 0, int(durationValue)) + case string(DurationUnitWeek): + return startTime.AddDate(0, 0, int(durationValue)*7) + case string(DurationUnitMonth): + return startTime.AddDate(0, int(durationValue), 0) + case string(DurationUnitYear): + return startTime.AddDate(int(durationValue), 0, 0) + default: + return startTime.AddDate(0, int(durationValue), 0) // Default to months + } +} diff --git a/internal/domain/team.go b/internal/domain/team.go new file mode 100644 index 0000000..efc10c2 --- /dev/null +++ b/internal/domain/team.go @@ -0,0 +1,210 @@ +package domain + +import ( + "errors" + "time" +) + +var ( + ErrTeamMemberNotFound = errors.New("team member not found") + ErrTeamMemberEmailExists = errors.New("team member email already exists") + ErrInvalidTeamRole = errors.New("invalid team role") + ErrInvalidTeamMemberStatus = errors.New("invalid team member status") + ErrInvalidEmploymentType = errors.New("invalid employment type") + ErrTeamMemberEmailNotVerified = errors.New("team member email not verified") +) + +type TeamRole string + +const ( + TeamRoleSuperAdmin TeamRole = "super_admin" + TeamRoleAdmin TeamRole = "admin" + TeamRoleContentManager TeamRole = "content_manager" + TeamRoleSupportAgent TeamRole = "support_agent" + TeamRoleInstructor TeamRole = "instructor" + TeamRoleFinance TeamRole = "finance" + TeamRoleHR TeamRole = "hr" + TeamRoleAnalyst TeamRole = "analyst" +) + +func (r TeamRole) IsValid() bool { + switch r { + case TeamRoleSuperAdmin, TeamRoleAdmin, TeamRoleContentManager, + TeamRoleSupportAgent, TeamRoleInstructor, TeamRoleFinance, + TeamRoleHR, TeamRoleAnalyst: + return true + default: + return false + } +} + +func (r TeamRole) String() string { + return string(r) +} + +type TeamMemberStatus string + +const ( + TeamMemberStatusActive TeamMemberStatus = "active" + TeamMemberStatusInactive TeamMemberStatus = "inactive" + TeamMemberStatusSuspended TeamMemberStatus = "suspended" + TeamMemberStatusTerminated TeamMemberStatus = "terminated" +) + +func (s TeamMemberStatus) IsValid() bool { + switch s { + case TeamMemberStatusActive, TeamMemberStatusInactive, + TeamMemberStatusSuspended, TeamMemberStatusTerminated: + return true + default: + return false + } +} + +type EmploymentType string + +const ( + EmploymentTypeFullTime EmploymentType = "full_time" + EmploymentTypePartTime EmploymentType = "part_time" + EmploymentTypeContract EmploymentType = "contract" + EmploymentTypeIntern EmploymentType = "intern" +) + +func (e EmploymentType) IsValid() bool { + switch e { + case EmploymentTypeFullTime, EmploymentTypePartTime, + EmploymentTypeContract, EmploymentTypeIntern: + return true + default: + return false + } +} + +type TeamMember struct { + ID int64 + FirstName string + LastName string + Email string + PhoneNumber string + Password []byte + + TeamRole TeamRole + Department string + JobTitle string + EmploymentType EmploymentType + HireDate *time.Time + + ProfilePictureURL string + Bio string + WorkPhone string + EmergencyContact string + + Status TeamMemberStatus + EmailVerified bool + Permissions []string + + LastLogin *time.Time + CreatedBy *int64 + UpdatedBy *int64 + + CreatedAt time.Time + UpdatedAt *time.Time +} + +type TeamMemberResponse struct { + ID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + PhoneNumber string `json:"phone_number,omitempty"` + + TeamRole TeamRole `json:"team_role"` + Department string `json:"department,omitempty"` + JobTitle string `json:"job_title,omitempty"` + EmploymentType EmploymentType `json:"employment_type,omitempty"` + HireDate string `json:"hire_date,omitempty"` + + ProfilePictureURL string `json:"profile_picture_url,omitempty"` + Bio string `json:"bio,omitempty"` + WorkPhone string `json:"work_phone,omitempty"` + + Status TeamMemberStatus `json:"status"` + EmailVerified bool `json:"email_verified"` + Permissions []string `json:"permissions,omitempty"` + + LastLogin *time.Time `json:"last_login,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +type CreateTeamMemberReq struct { + FirstName string `json:"first_name" validate:"required"` + LastName string `json:"last_name" validate:"required"` + Email string `json:"email" validate:"required,email"` + PhoneNumber string `json:"phone_number"` + Password string `json:"password" validate:"required,min=8"` + + TeamRole string `json:"team_role" validate:"required"` + Department string `json:"department"` + JobTitle string `json:"job_title"` + EmploymentType string `json:"employment_type"` + HireDate string `json:"hire_date"` // YYYY-MM-DD + + ProfilePictureURL string `json:"profile_picture_url"` + Bio string `json:"bio"` + WorkPhone string `json:"work_phone"` + EmergencyContact string `json:"emergency_contact"` + + Permissions []string `json:"permissions"` +} + +type UpdateTeamMemberReq struct { + TeamMemberID int64 `json:"-"` + UpdatedBy int64 `json:"-"` + + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + PhoneNumber string `json:"phone_number"` + + TeamRole string `json:"team_role"` + Department string `json:"department"` + JobTitle string `json:"job_title"` + EmploymentType string `json:"employment_type"` + HireDate string `json:"hire_date"` + + ProfilePictureURL string `json:"profile_picture_url"` + Bio string `json:"bio"` + WorkPhone string `json:"work_phone"` + EmergencyContact string `json:"emergency_contact"` + + Permissions []string `json:"permissions"` +} + +type UpdateTeamMemberStatusReq struct { + TeamMemberID int64 `json:"-"` + Status string `json:"status" validate:"required"` + UpdatedBy int64 `json:"-"` +} + +type TeamMemberFilter struct { + TeamRole *string + Department *string + Status *string + Search string + + Page int64 + PageSize int64 +} + +type TeamMemberStats struct { + ActiveCount int64 `json:"active_count"` + InactiveCount int64 `json:"inactive_count"` + SuspendedCount int64 `json:"suspended_count"` + TerminatedCount int64 `json:"terminated_count"` + TotalCount int64 `json:"total_count"` +} + +type TeamMemberLoginReq struct { + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required"` +} diff --git a/internal/domain/user.go b/internal/domain/user.go index ae99e4a..73c930f 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -73,10 +73,11 @@ type User struct { PhoneVerified bool Status UserStatus - LastLogin *time.Time - ProfileCompleted bool - ProfilePictureURL string - PreferredLanguage string + LastLogin *time.Time + ProfileCompleted bool + ProfileCompletionPercentage int + ProfilePictureURL string + PreferredLanguage string CreatedAt time.Time UpdatedAt *time.Time @@ -111,10 +112,11 @@ type UserProfileResponse struct { PhoneVerified bool `json:"phone_verified"` Status UserStatus `json:"status"` - LastLogin *time.Time `json:"last_login,omitempty"` - ProfileCompleted bool `json:"profile_completed"` - ProfilePictureURL string `json:"profile_picture_url"` - PreferredLanguage string `json:"preferred_language,omitempty"` + LastLogin *time.Time `json:"last_login,omitempty"` + ProfileCompleted bool `json:"profile_completed"` + ProfileCompletionPercentage int `json:"profile_completion_percentage"` + ProfilePictureURL string `json:"profile_picture_url"` + PreferredLanguage string `json:"preferred_language,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt *time.Time `json:"updated_at,omitempty"` @@ -201,7 +203,6 @@ type UpdateUserReq struct { FavouriteTopic string `json:"favourite_topic"` InitialAssessmentCompleted bool `json:"initial_assessment_completed"` - ProfileCompleted bool `json:"profile_completed"` ProfilePictureURL string `json:"profile_picture_url"` PreferredLanguage string `json:"preferred_language"` diff --git a/internal/domain/validtypes.go b/internal/domain/validtypes.go index e54bbe3..4734949 100644 --- a/internal/domain/validtypes.go +++ b/internal/domain/validtypes.go @@ -249,7 +249,7 @@ func ConvertStringPtrToTime(value *string) (ValidTime, error) { if value == nil { return ValidTime{}, nil } - + parsedIntervalStart, err := time.Parse(time.RFC3339, *value) if err != nil { return ValidTime{}, nil diff --git a/internal/pkgs/vimeo/client.go b/internal/pkgs/vimeo/client.go new file mode 100644 index 0000000..3c08f20 --- /dev/null +++ b/internal/pkgs/vimeo/client.go @@ -0,0 +1,419 @@ +package vimeo + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +const ( + BaseURL = "https://api.vimeo.com" + APIVersion = "application/vnd.vimeo.*+json;version=3.4" +) + +type Client struct { + httpClient *http.Client + accessToken string +} + +func NewClient(accessToken string) *Client { + return &Client{ + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + accessToken: accessToken, + } +} + +type Video struct { + URI string `json:"uri"` + Name string `json:"name"` + Description string `json:"description"` + Duration int `json:"duration"` + Width int `json:"width"` + Height int `json:"height"` + Link string `json:"link"` + PlayerEmbedURL string `json:"player_embed_url"` + Pictures *Pictures `json:"pictures"` + Status string `json:"status"` + Transcode *Transcode `json:"transcode"` + Privacy *Privacy `json:"privacy"` + Embed *Embed `json:"embed"` + CreatedTime time.Time `json:"created_time"` + ModifiedTime time.Time `json:"modified_time"` +} + +type Pictures struct { + URI string `json:"uri"` + Active bool `json:"active"` + Sizes []Size `json:"sizes"` + BaseURL string `json:"base_link"` +} + +type Size struct { + Width int `json:"width"` + Height int `json:"height"` + Link string `json:"link"` +} + +type Transcode struct { + Status string `json:"status"` +} + +type Privacy struct { + View string `json:"view"` + Embed string `json:"embed"` + Download bool `json:"download"` +} + +type Embed struct { + HTML string `json:"html"` + Badges struct { + HDR bool `json:"hdr"` + Live struct{ Streaming bool } `json:"live"` + StaffPick struct{ Normal bool } `json:"staff_pick"` + VOD bool `json:"vod"` + WeekendChallenge bool `json:"weekend_challenge"` + } `json:"badges"` +} + +type UploadRequest struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Upload UploadParams `json:"upload"` + Privacy *PrivacyParams `json:"privacy,omitempty"` +} + +type UploadParams struct { + Approach string `json:"approach"` + Size int64 `json:"size,omitempty"` + Link string `json:"link,omitempty"` + RedirectURL string `json:"redirect_url,omitempty"` +} + +type PrivacyParams struct { + View string `json:"view,omitempty"` + Embed string `json:"embed,omitempty"` + Download bool `json:"download,omitempty"` +} + +type UploadResponse struct { + URI string `json:"uri"` + Name string `json:"name"` + Link string `json:"link"` + Upload struct { + Status string `json:"status"` + UploadLink string `json:"upload_link"` + Approach string `json:"approach"` + Size int64 `json:"size"` + } `json:"upload"` + Transcode *Transcode `json:"transcode"` +} + +type OEmbedResponse struct { + Type string `json:"type"` + Version string `json:"version"` + ProviderName string `json:"provider_name"` + ProviderURL string `json:"provider_url"` + Title string `json:"title"` + AuthorName string `json:"author_name"` + AuthorURL string `json:"author_url"` + IsPlus string `json:"is_plus"` + HTML string `json:"html"` + Width int `json:"width"` + Height int `json:"height"` + Duration int `json:"duration"` + Description string `json:"description"` + ThumbnailURL string `json:"thumbnail_url"` + ThumbnailWidth int `json:"thumbnail_width"` + ThumbnailHeight int `json:"thumbnail_height"` + VideoID int64 `json:"video_id"` +} + +type UpdateVideoRequest struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Privacy *PrivacyParams `json:"privacy,omitempty"` +} + +func (c *Client) doRequest(ctx context.Context, method, path string, body interface{}) (*http.Response, error) { + var reqBody io.Reader + if body != nil { + jsonBytes, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + reqBody = bytes.NewReader(jsonBytes) + } + + req, err := http.NewRequestWithContext(ctx, method, BaseURL+path, reqBody) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.accessToken) + req.Header.Set("Accept", APIVersion) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + return c.httpClient.Do(req) +} + +func (c *Client) GetVideo(ctx context.Context, videoID string) (*Video, error) { + resp, err := c.doRequest(ctx, http.MethodGet, "/videos/"+videoID, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("failed to get video: status %d, body: %s", resp.StatusCode, string(bodyBytes)) + } + + var video Video + if err := json.NewDecoder(resp.Body).Decode(&video); err != nil { + return nil, fmt.Errorf("failed to decode video response: %w", err) + } + + return &video, nil +} + +func (c *Client) CreateUpload(ctx context.Context, req *UploadRequest) (*UploadResponse, error) { + resp, err := c.doRequest(ctx, http.MethodPost, "/me/videos", req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("failed to create upload: status %d, body: %s", resp.StatusCode, string(bodyBytes)) + } + + var uploadResp UploadResponse + if err := json.NewDecoder(resp.Body).Decode(&uploadResp); err != nil { + return nil, fmt.Errorf("failed to decode upload response: %w", err) + } + + return &uploadResp, nil +} + +func (c *Client) CreatePullUpload(ctx context.Context, name, description, sourceURL string, fileSize int64) (*UploadResponse, error) { + req := &UploadRequest{ + Name: name, + Description: description, + Upload: UploadParams{ + Approach: "pull", + Size: fileSize, + Link: sourceURL, + }, + Privacy: &PrivacyParams{ + View: "unlisted", + Embed: "public", + }, + } + return c.CreateUpload(ctx, req) +} + +func (c *Client) CreateTusUpload(ctx context.Context, name, description string, fileSize int64) (*UploadResponse, error) { + req := &UploadRequest{ + Name: name, + Description: description, + Upload: UploadParams{ + Approach: "tus", + Size: fileSize, + }, + Privacy: &PrivacyParams{ + View: "unlisted", + Embed: "public", + }, + } + return c.CreateUpload(ctx, req) +} + +func (c *Client) UpdateVideo(ctx context.Context, videoID string, req *UpdateVideoRequest) (*Video, error) { + resp, err := c.doRequest(ctx, http.MethodPatch, "/videos/"+videoID, req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("failed to update video: status %d, body: %s", resp.StatusCode, string(bodyBytes)) + } + + var video Video + if err := json.NewDecoder(resp.Body).Decode(&video); err != nil { + return nil, fmt.Errorf("failed to decode video response: %w", err) + } + + return &video, nil +} + +func (c *Client) DeleteVideo(ctx context.Context, videoID string) error { + resp, err := c.doRequest(ctx, http.MethodDelete, "/videos/"+videoID, nil) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to delete video: status %d, body: %s", resp.StatusCode, string(bodyBytes)) + } + + return nil +} + +func (c *Client) GetTranscodeStatus(ctx context.Context, videoID string) (string, error) { + video, err := c.GetVideo(ctx, videoID) + if err != nil { + return "", err + } + + if video.Transcode != nil { + return video.Transcode.Status, nil + } + return "unknown", nil +} + +func GetOEmbed(ctx context.Context, vimeoURL string, width, height int) (*OEmbedResponse, error) { + client := &http.Client{Timeout: 10 * time.Second} + + oembedURL := fmt.Sprintf("https://vimeo.com/api/oembed.json?url=%s", vimeoURL) + if width > 0 { + oembedURL += "&width=" + strconv.Itoa(width) + } + if height > 0 { + oembedURL += "&height=" + strconv.Itoa(height) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, oembedURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create oembed request: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch oembed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("oembed failed: status %d, body: %s", resp.StatusCode, string(bodyBytes)) + } + + var oembed OEmbedResponse + if err := json.NewDecoder(resp.Body).Decode(&oembed); err != nil { + return nil, fmt.Errorf("failed to decode oembed response: %w", err) + } + + return &oembed, nil +} + +func GenerateEmbedURL(videoID string, options *EmbedOptions) string { + url := fmt.Sprintf("https://player.vimeo.com/video/%s", videoID) + + if options == nil { + return url + } + + params := "" + if options.Autoplay { + params += "&autoplay=1" + } + if options.Loop { + params += "&loop=1" + } + if options.Muted { + params += "&muted=1" + } + if !options.Title { + params += "&title=0" + } + if !options.Byline { + params += "&byline=0" + } + if !options.Portrait { + params += "&portrait=0" + } + if options.Color != "" { + params += "&color=" + options.Color + } + if options.Background { + params += "&background=1" + } + if options.Responsive { + params += "&responsive=1" + } + + if params != "" { + url += "?" + params[1:] + } + + return url +} + +type EmbedOptions struct { + Autoplay bool + Loop bool + Muted bool + Title bool + Byline bool + Portrait bool + Color string + Background bool + Responsive bool +} + +func GenerateIframeEmbed(videoID string, width, height int, options *EmbedOptions) string { + embedURL := GenerateEmbedURL(videoID, options) + return fmt.Sprintf( + ``, + embedURL, width, height, + ) +} + +func ExtractVideoID(vimeoURL string) string { + // Handle URLs like: + // - https://vimeo.com/123456789 + // - https://player.vimeo.com/video/123456789 + // - /videos/123456789 + + patterns := []string{ + "vimeo.com/", + "player.vimeo.com/video/", + "/videos/", + } + + for _, pattern := range patterns { + if idx := len(pattern); len(vimeoURL) > idx { + for i := 0; i < len(vimeoURL)-len(pattern)+1; i++ { + if vimeoURL[i:i+len(pattern)] == pattern { + videoID := "" + for j := i + len(pattern); j < len(vimeoURL); j++ { + if vimeoURL[j] >= '0' && vimeoURL[j] <= '9' { + videoID += string(vimeoURL[j]) + } else { + break + } + } + if videoID != "" { + return videoID + } + } + } + } + } + + return "" +} diff --git a/internal/ports/course_management.go b/internal/ports/course_management.go index e246514..a3ec53b 100644 --- a/internal/ports/course_management.go +++ b/internal/ports/course_management.go @@ -6,6 +6,7 @@ import ( ) type CourseStore interface { + // Course Categories CreateCourseCategory( ctx context.Context, name string, @@ -29,55 +30,14 @@ type CourseStore interface { ctx context.Context, id int64, ) error - CreateProgram( - ctx context.Context, - courseID int64, - title string, - description *string, - thumbnail *string, - displayOrder *int32, - ) (domain.Program, error) - GetProgramByID( - ctx context.Context, - id int64, - ) (domain.Program, error) - GetProgramsByCourse( - ctx context.Context, - courseID int64, - ) ([]domain.Program, int64, error) - ListProgramsByCourse( - ctx context.Context, - courseID int64, - ) ([]domain.Program, error) - ListActivePrograms( - ctx context.Context, - ) ([]domain.Program, error) - UpdateProgramPartial( - ctx context.Context, - id int64, - title *string, - description *string, - thumbnail *string, - displayOrder *int32, - isActive *bool, - ) error - UpdateProgramFull( - ctx context.Context, - program domain.Program, - ) (domain.Program, error) - DeactivateProgram( - ctx context.Context, - id int64, - ) error - DeleteProgram( - ctx context.Context, - id int64, - ) (domain.Program, error) + + // Courses CreateCourse( ctx context.Context, categoryID int64, title string, description *string, + thumbnail *string, ) (domain.Course, error) GetCourseByID( ctx context.Context, @@ -94,38 +54,62 @@ type CourseStore interface { id int64, title *string, description *string, + thumbnail *string, isActive *bool, ) error DeleteCourse( ctx context.Context, id int64, ) error - CreateModule( + + // Sub-courses + CreateSubCourse( ctx context.Context, - levelID int64, + courseID int64, title string, - content *string, + description *string, + thumbnail *string, displayOrder *int32, - ) (domain.Module, error) - GetModulesByLevel( + level string, + ) (domain.SubCourse, error) + GetSubCourseByID( ctx context.Context, - levelID int64, - ) ([]domain.Module, int64, error) - UpdateModule( + id int64, + ) (domain.SubCourse, error) + GetSubCoursesByCourse( + ctx context.Context, + courseID int64, + ) ([]domain.SubCourse, int64, error) + ListSubCoursesByCourse( + ctx context.Context, + courseID int64, + ) ([]domain.SubCourse, error) + ListActiveSubCourses( + ctx context.Context, + ) ([]domain.SubCourse, error) + UpdateSubCourse( ctx context.Context, id int64, title *string, - content *string, + description *string, + thumbnail *string, displayOrder *int32, + level *string, isActive *bool, ) error - DeleteModule( + DeactivateSubCourse( ctx context.Context, id int64, ) error - CreateModuleVideo( + DeleteSubCourse( ctx context.Context, - moduleID int64, + id int64, + ) (domain.SubCourse, error) + + // Sub-course Videos + CreateSubCourseVideo( + ctx context.Context, + subCourseID int64, title string, description *string, videoURL string, @@ -134,16 +118,31 @@ type CourseStore interface { instructorID *string, thumbnail *string, visibility *string, - ) (domain.ModuleVideo, error) - PublishModuleVideo( + displayOrder *int32, + status *string, + vimeoID *string, + vimeoEmbedURL *string, + vimeoPlayerHTML *string, + vimeoStatus *string, + videoHostProvider *string, + ) (domain.SubCourseVideo, error) + GetSubCourseVideoByID( + ctx context.Context, + id int64, + ) (domain.SubCourseVideo, error) + GetVideosBySubCourse( + ctx context.Context, + subCourseID int64, + ) ([]domain.SubCourseVideo, int64, error) + GetPublishedVideosBySubCourse( + ctx context.Context, + subCourseID int64, + ) ([]domain.SubCourseVideo, error) + PublishSubCourseVideo( ctx context.Context, videoID int64, ) error - GetPublishedVideosByModule( - ctx context.Context, - moduleID int64, - ) ([]domain.ModuleVideo, error) - UpdateModuleVideo( + UpdateSubCourseVideo( ctx context.Context, id int64, title *string, @@ -153,101 +152,22 @@ type CourseStore interface { resolution *string, visibility *string, thumbnail *string, - isActive *bool, + displayOrder *int32, + status *string, ) error - DeleteModuleVideo( + ArchiveSubCourseVideo( ctx context.Context, id int64, ) error - CreatePracticeQuestion( - ctx context.Context, - practiceID int64, - question string, - questionVoicePrompt *string, - sampleAnswerVoicePrompt *string, - sampleAnswer *string, - tips *string, - qType string, - ) (domain.PracticeQuestion, error) - GetQuestionsByPractice( - ctx context.Context, - practiceID int64, - ) ([]domain.PracticeQuestion, error) - UpdatePracticeQuestion( - ctx context.Context, - id int64, - question *string, - sampleAnswer *string, - tips *string, - qType *string, - ) error - DeletePracticeQuestion( + DeleteSubCourseVideo( ctx context.Context, id int64, ) error - CreatePractice( - ctx context.Context, - ownerType string, - ownerID int64, - title string, - description *string, - bannerImage *string, - persona *string, - isActive *bool, - ) (domain.Practice, error) - GetPracticesByOwner( - ctx context.Context, - ownerType string, - ownerID int64, - ) ([]domain.Practice, error) - UpdatePractice( - ctx context.Context, - id int64, - title *string, - description *string, - bannerImage *string, - persona *string, - isActive *bool, - ) error - DeletePractice( - ctx context.Context, - id int64, - ) error - CreateLevel( - ctx context.Context, - programID int64, - title string, - description *string, - levelIndex int, - isActive *bool, - ) (domain.Level, error) - GetLevelsByProgram( - ctx context.Context, - programID int64, - ) ([]domain.Level, error) - UpdateLevel( - ctx context.Context, - id int64, - title *string, - description *string, - levelIndex *int, - isActive *bool, - ) error - IncrementLevelModuleCount( - ctx context.Context, - levelID int64, - ) error - IncrementLevelPracticeCount( - ctx context.Context, - levelID int64, - ) error - IncrementLevelVideoCount( - ctx context.Context, - levelID int64, - ) error - DeleteLevel( - ctx context.Context, - levelID int64, - ) error + + // Vimeo integration + UpdateVimeoStatus(ctx context.Context, videoID int64, status string) error + GetVideoByVimeoID(ctx context.Context, vimeoID string) (domain.SubCourseVideo, error) + + // Learning Tree GetFullLearningTree(ctx context.Context) ([]domain.TreeCourse, error) } diff --git a/internal/ports/initial_assessment.go b/internal/ports/initial_assessment.go index f68848e..60050a4 100644 --- a/internal/ports/initial_assessment.go +++ b/internal/ports/initial_assessment.go @@ -1,23 +1,6 @@ package ports -import ( - "context" - - dbgen "Yimaru-Backend/gen/db" -) - -type InitialAssessmentStore interface { - CreateAssessmentQuestion(ctx context.Context, arg dbgen.CreateAssessmentQuestionParams) (dbgen.AssessmentQuestion, error) - GetAssessmentQuestionByID(ctx context.Context, id int64) (dbgen.AssessmentQuestion, error) - GetActiveAssessmentQuestions(ctx context.Context) ([]dbgen.AssessmentQuestion, error) - GetAssessmentQuestionsPaginated(ctx context.Context, arg dbgen.GetAssessmentQuestionsPaginatedParams) ([]dbgen.GetAssessmentQuestionsPaginatedRow, error) - UpdateAssessmentQuestion(ctx context.Context, arg dbgen.UpdateAssessmentQuestionParams) error - DeleteAssessmentQuestion(ctx context.Context, id int64) error - - CreateQuestionOption(ctx context.Context, arg dbgen.CreateQuestionOptionParams) (dbgen.AssessmentQuestionOption, error) - GetQuestionOptions(ctx context.Context, questionID int64) ([]dbgen.AssessmentQuestionOption, error) - DeleteQuestionOptionsByQuestionID(ctx context.Context, questionID int64) error - - CreateShortAnswer(ctx context.Context, arg dbgen.CreateShortAnswerParams) (dbgen.AssessmentShortAnswer, error) - GetShortAnswersByQuestionID(ctx context.Context, questionID int64) ([]dbgen.AssessmentShortAnswer, error) -} +// InitialAssessmentStore is now a marker interface. +// The initial assessment functionality uses the unified questions system. +// Use QuestionStore.GetInitialAssessmentSet() to get the initial assessment question set. +type InitialAssessmentStore interface{} diff --git a/internal/ports/payment.go b/internal/ports/payment.go new file mode 100644 index 0000000..2570d5a --- /dev/null +++ b/internal/ports/payment.go @@ -0,0 +1,23 @@ +package ports + +import ( + "Yimaru-Backend/internal/domain" + "context" +) + +type PaymentStore interface { + CreatePayment(ctx context.Context, input domain.CreatePaymentInput) (*domain.Payment, error) + GetPaymentByID(ctx context.Context, id int64) (*domain.Payment, error) + GetPaymentBySessionID(ctx context.Context, sessionID string) (*domain.Payment, error) + GetPaymentByNonce(ctx context.Context, nonce string) (*domain.Payment, error) + GetPaymentByTransactionID(ctx context.Context, transactionID string) (*domain.Payment, error) + GetPaymentsByUserID(ctx context.Context, userID int64, limit, offset int32) ([]domain.Payment, error) + GetPendingPaymentsByUserID(ctx context.Context, userID int64) ([]domain.Payment, error) + UpdatePaymentStatus(ctx context.Context, id int64, status string) error + UpdatePaymentStatusBySessionID(ctx context.Context, sessionID, status, transactionID, paymentMethod string) error + UpdatePaymentStatusByNonce(ctx context.Context, nonce, status, transactionID, paymentMethod string) error + UpdatePaymentSessionID(ctx context.Context, id int64, sessionID, paymentURL string) error + LinkPaymentToSubscription(ctx context.Context, paymentID, subscriptionID int64) error + GetExpiredPendingPayments(ctx context.Context) ([]domain.Payment, error) + ExpirePayment(ctx context.Context, id int64) error +} diff --git a/internal/ports/questions.go b/internal/ports/questions.go new file mode 100644 index 0000000..c1b40c4 --- /dev/null +++ b/internal/ports/questions.go @@ -0,0 +1,57 @@ +package ports + +import ( + "Yimaru-Backend/internal/domain" + "context" +) + +type QuestionStore interface { + // Questions + CreateQuestion(ctx context.Context, input domain.CreateQuestionInput) (domain.Question, error) + GetQuestionByID(ctx context.Context, id int64) (domain.Question, error) + GetQuestionWithDetails(ctx context.Context, id int64) (domain.QuestionWithDetails, error) + ListQuestions(ctx context.Context, questionType, difficulty, status *string, limit, offset int32) ([]domain.Question, int64, error) + SearchQuestions(ctx context.Context, query string, limit, offset int32) ([]domain.Question, int64, error) + UpdateQuestion(ctx context.Context, id int64, input domain.CreateQuestionInput) error + ArchiveQuestion(ctx context.Context, id int64) error + DeleteQuestion(ctx context.Context, id int64) error + + // Question Options + CreateQuestionOption(ctx context.Context, questionID int64, optionText string, optionOrder *int32, isCorrect bool) (domain.QuestionOption, error) + GetOptionsByQuestionID(ctx context.Context, questionID int64) ([]domain.QuestionOption, error) + UpdateQuestionOption(ctx context.Context, id int64, optionText *string, optionOrder *int32, isCorrect *bool) error + DeleteQuestionOption(ctx context.Context, id int64) error + DeleteOptionsByQuestionID(ctx context.Context, questionID int64) error + + // Question Short Answers + CreateQuestionShortAnswer(ctx context.Context, questionID int64, acceptableAnswer string, matchType *string) (domain.QuestionShortAnswer, error) + GetShortAnswersByQuestionID(ctx context.Context, questionID int64) ([]domain.QuestionShortAnswer, error) + UpdateQuestionShortAnswer(ctx context.Context, id int64, acceptableAnswer, matchType *string) error + DeleteQuestionShortAnswer(ctx context.Context, id int64) error + DeleteShortAnswersByQuestionID(ctx context.Context, questionID int64) error + + // Question Sets + CreateQuestionSet(ctx context.Context, input domain.CreateQuestionSetInput) (domain.QuestionSet, error) + GetQuestionSetByID(ctx context.Context, id int64) (domain.QuestionSet, error) + GetQuestionSetsByOwner(ctx context.Context, ownerType string, ownerID int64) ([]domain.QuestionSet, error) + GetQuestionSetsByType(ctx context.Context, setType string, limit, offset int32) ([]domain.QuestionSet, int64, error) + GetPublishedQuestionSetsByOwner(ctx context.Context, ownerType string, ownerID int64) ([]domain.QuestionSet, error) + GetInitialAssessmentSet(ctx context.Context) (domain.QuestionSet, error) + UpdateQuestionSet(ctx context.Context, id int64, input domain.CreateQuestionSetInput) error + ArchiveQuestionSet(ctx context.Context, id int64) error + DeleteQuestionSet(ctx context.Context, id int64) error + + // Question Set Items + AddQuestionToSet(ctx context.Context, setID, questionID int64, displayOrder *int32) (domain.QuestionSetItem, error) + GetQuestionSetItems(ctx context.Context, setID int64) ([]domain.QuestionSetItemWithQuestion, error) + GetPublishedQuestionsInSet(ctx context.Context, setID int64) ([]domain.QuestionSetItemWithQuestion, error) + RemoveQuestionFromSet(ctx context.Context, setID, questionID int64) error + UpdateQuestionOrder(ctx context.Context, setID, questionID int64, displayOrder int32) error + CountQuestionsInSet(ctx context.Context, setID int64) (int64, error) + GetQuestionSetsContainingQuestion(ctx context.Context, questionID int64) ([]domain.QuestionSet, error) + + // User Personas in Question Sets + AddUserPersonaToQuestionSet(ctx context.Context, questionSetID, userID int64, displayOrder int32) error + RemoveUserPersonaFromQuestionSet(ctx context.Context, questionSetID, userID int64) error + GetUserPersonasByQuestionSetID(ctx context.Context, questionSetID int64) ([]domain.UserPersona, error) +} diff --git a/internal/ports/subscriptions.go b/internal/ports/subscriptions.go new file mode 100644 index 0000000..e70c726 --- /dev/null +++ b/internal/ports/subscriptions.go @@ -0,0 +1,27 @@ +package ports + +import ( + "Yimaru-Backend/internal/domain" + "context" + "time" +) + +type SubscriptionStore interface { + // Subscription Plans + CreateSubscriptionPlan(ctx context.Context, input domain.CreateSubscriptionPlanInput) (*domain.SubscriptionPlan, error) + GetSubscriptionPlanByID(ctx context.Context, id int64) (*domain.SubscriptionPlan, error) + ListSubscriptionPlans(ctx context.Context, activeOnly bool) ([]domain.SubscriptionPlan, error) + UpdateSubscriptionPlan(ctx context.Context, id int64, input domain.UpdateSubscriptionPlanInput) error + DeleteSubscriptionPlan(ctx context.Context, id int64) error + + // User Subscriptions + CreateUserSubscription(ctx context.Context, input domain.CreateUserSubscriptionInput) (*domain.UserSubscription, error) + GetUserSubscriptionByID(ctx context.Context, id int64) (*domain.UserSubscription, error) + GetActiveSubscriptionByUserID(ctx context.Context, userID int64) (*domain.UserSubscription, error) + GetUserSubscriptionHistory(ctx context.Context, userID int64, limit, offset int32) ([]domain.UserSubscription, error) + HasActiveSubscription(ctx context.Context, userID int64) (bool, error) + CancelUserSubscription(ctx context.Context, id int64) error + UpdateSubscriptionStatus(ctx context.Context, id int64, status string) error + UpdateAutoRenew(ctx context.Context, id int64, autoRenew bool) error + ExtendSubscription(ctx context.Context, id int64, newExpiresAt time.Time) error +} diff --git a/internal/ports/team.go b/internal/ports/team.go new file mode 100644 index 0000000..bf58732 --- /dev/null +++ b/internal/ports/team.go @@ -0,0 +1,33 @@ +package ports + +import ( + "context" + + "Yimaru-Backend/internal/domain" +) + +type TeamStore interface { + CreateTeamMember(ctx context.Context, member domain.TeamMember) (domain.TeamMember, error) + GetTeamMemberByID(ctx context.Context, id int64) (domain.TeamMember, error) + GetTeamMemberByEmail(ctx context.Context, email string) (domain.TeamMember, error) + GetAllTeamMembers( + ctx context.Context, + teamRole, department, status *string, + limit, offset int32, + ) ([]domain.TeamMember, int64, error) + SearchTeamMembers( + ctx context.Context, + search string, + teamRole, status *string, + ) ([]domain.TeamMember, error) + UpdateTeamMember(ctx context.Context, req domain.UpdateTeamMemberReq) error + UpdateTeamMemberStatus(ctx context.Context, req domain.UpdateTeamMemberStatusReq) error + UpdateTeamMemberPassword(ctx context.Context, memberID int64, password string) error + UpdateTeamMemberLastLogin(ctx context.Context, memberID int64) error + DeleteTeamMember(ctx context.Context, memberID int64) error + CheckTeamMemberEmailExists(ctx context.Context, email string) (bool, error) + GetTeamMembersByDepartment(ctx context.Context, department string) ([]domain.TeamMember, error) + GetTeamMembersByRole(ctx context.Context, role string) ([]domain.TeamMember, error) + CountTeamMembersByStatus(ctx context.Context) (domain.TeamMemberStats, error) + UpdateTeamMemberEmailVerified(ctx context.Context, memberID int64, verified bool) error +} diff --git a/internal/ports/user.go b/internal/ports/user.go index 15da48d..243f924 100644 --- a/internal/ports/user.go +++ b/internal/ports/user.go @@ -7,10 +7,15 @@ import ( "Yimaru-Backend/internal/domain" ) +type ProfileCompletionStatus struct { + IsCompleted bool + Percentage int +} + type UserStore interface { CreateGoogleUser(ctx context.Context, gUser domain.GoogleUser) (domain.User, error) LinkGoogleAccount(ctx context.Context, userID int64, googleID string) error - IsProfileCompleted(ctx context.Context, userId int64) (bool, error) + GetProfileCompletionStatus(ctx context.Context, userId int64) (ProfileCompletionStatus, error) UpdateUserStatus(ctx context.Context, req domain.UpdateUserStatusReq) error // GetCorrectOptionForQuestion( // ctx context.Context, @@ -68,6 +73,8 @@ type UserStore interface { UpdatePassword(ctx context.Context, password string, userID int64) error RegisterDevice(ctx context.Context, userID int64, deviceToken, platform string) error GetUserDeviceTokens(ctx context.Context, userID int64) ([]string, error) + DeactivateDevice(ctx context.Context, userID int64, deviceToken string) error + DeactivateAllUserDevices(ctx context.Context, userID int64) error } type SmsGateway interface { SendSMSOTP(ctx context.Context, phoneNumber, otp string) error diff --git a/internal/repository/common.go b/internal/repository/common.go index 5cf051e..0b8b249 100644 --- a/internal/repository/common.go +++ b/internal/repository/common.go @@ -2,11 +2,20 @@ package repository import ( "errors" + "time" "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" ) func IsUniqueViolation(err error) bool { var pgErr *pgconn.PgError return errors.As(err, &pgErr) && pgErr.Code == "23505" -} \ No newline at end of file +} + +func ptrTimestamptz(t pgtype.Timestamptz) *time.Time { + if !t.Valid { + return nil + } + return &t.Time +} diff --git a/internal/repository/course_programs.go b/internal/repository/course_programs.go deleted file mode 100644 index e9ca29b..0000000 --- a/internal/repository/course_programs.go +++ /dev/null @@ -1,240 +0,0 @@ -package repository - -import ( - dbgen "Yimaru-Backend/gen/db" - "Yimaru-Backend/internal/domain" - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -func (s *Store) CreateProgram( - ctx context.Context, - courseID int64, - title string, - description *string, - thumbnail *string, - displayOrder *int32, -) (domain.Program, error) { - - row, err := s.queries.CreateProgram(ctx, dbgen.CreateProgramParams{ - CourseID: courseID, - Title: title, - Description: pgtype.Text{String: *description}, - Thumbnail: pgtype.Text{String: *thumbnail}, - Column5: displayOrder, - Column6: true, - }) - if err != nil { - return domain.Program{}, err - } - - return domain.Program{ - ID: row.ID, - CourseID: row.CourseID, - Title: row.Title, - Description: &row.Description.String, - Thumbnail: &row.Thumbnail.String, - DisplayOrder: row.DisplayOrder, - IsActive: row.IsActive, - }, nil -} - -func (s *Store) GetProgramByID( - ctx context.Context, - id int64, -) (domain.Program, error) { - - row, err := s.queries.GetProgramByID(ctx, id) - if err != nil { - return domain.Program{}, err - } - - return domain.Program{ - ID: row.ID, - CourseID: row.CourseID, - Title: row.Title, - Description: &row.Description.String, - Thumbnail: &row.Thumbnail.String, - DisplayOrder: row.DisplayOrder, - IsActive: row.IsActive, - }, nil -} - -func (s *Store) GetProgramsByCourse( - ctx context.Context, - courseID int64, -) ([]domain.Program, int64, error) { - - rows, err := s.queries.GetProgramsByCourse(ctx, courseID) - if err != nil { - return nil, 0, err - } - - var ( - programs []domain.Program - totalCount int64 - ) - - for i, row := range rows { - if i == 0 { - totalCount = row.TotalCount - } - - programs = append(programs, domain.Program{ - ID: row.ID, - CourseID: row.CourseID, - Title: row.Title, - Description: &row.Description.String, - Thumbnail: &row.Thumbnail.String, - DisplayOrder: row.DisplayOrder, - IsActive: row.IsActive, - }) - } - - return programs, totalCount, nil -} - -func (s *Store) ListProgramsByCourse( - ctx context.Context, - courseID int64, -) ([]domain.Program, error) { - - rows, err := s.queries.ListProgramsByCourse(ctx, courseID) - if err != nil { - return nil, err - } - - programs := make([]domain.Program, 0, len(rows)) - for _, row := range rows { - programs = append(programs, domain.Program{ - ID: row.ID, - CourseID: row.CourseID, - Title: row.Title, - Description: &row.Description.String, - Thumbnail: &row.Thumbnail.String, - DisplayOrder: row.DisplayOrder, - IsActive: row.IsActive, - }) - } - - return programs, nil -} - -func (s *Store) ListActivePrograms( - ctx context.Context, -) ([]domain.Program, error) { - - rows, err := s.queries.ListActivePrograms(ctx) - if err != nil { - return nil, err - } - - programs := make([]domain.Program, 0, len(rows)) - for _, row := range rows { - programs = append(programs, domain.Program{ - ID: row.ID, - CourseID: row.CourseID, - Title: row.Title, - Description: &row.Description.String, - Thumbnail: &row.Thumbnail.String, - DisplayOrder: row.DisplayOrder, - IsActive: row.IsActive, - }) - } - - return programs, nil -} - -func (s *Store) UpdateProgramPartial( - ctx context.Context, - id int64, - title *string, - description *string, - thumbnail *string, - displayOrder *int32, - isActive *bool, -) error { - - return s.queries.UpdateProgramPartial(ctx, dbgen.UpdateProgramPartialParams{ - Title: func() string { - if title != nil { - return *title - } - return "" - }(), - Description: pgtype.Text{String: *description}, - Thumbnail: pgtype.Text{String: *thumbnail}, - DisplayOrder: func() int32 { - if displayOrder != nil { - return *displayOrder - } - return 0 - }(), - IsActive: func() bool { - if isActive != nil { - return *isActive - } - return false - }(), - ID: id, - }) -} - -func (s *Store) UpdateProgramFull( - ctx context.Context, - program domain.Program, -) (domain.Program, error) { - - row, err := s.queries.UpdateProgramFull(ctx, dbgen.UpdateProgramFullParams{ - ID: program.ID, - CourseID: program.CourseID, - Title: program.Title, - Description: pgtype.Text{String: *program.Description}, - Thumbnail: pgtype.Text{String: *program.Thumbnail}, - DisplayOrder: program.DisplayOrder, - IsActive: program.IsActive, - }) - if err != nil { - return domain.Program{}, err - } - - return domain.Program{ - ID: row.ID, - CourseID: row.CourseID, - Title: row.Title, - Description: &row.Description.String, - Thumbnail: &row.Thumbnail.String, - DisplayOrder: row.DisplayOrder, - IsActive: row.IsActive, - }, nil -} - -func (s *Store) DeactivateProgram( - ctx context.Context, - id int64, -) error { - - return s.queries.DeactivateProgram(ctx, id) -} - -func (s *Store) DeleteProgram( - ctx context.Context, - id int64, -) (domain.Program, error) { - - row, err := s.queries.DeleteProgram(ctx, id) - if err != nil { - return domain.Program{}, err - } - - return domain.Program{ - ID: row.ID, - CourseID: row.CourseID, - Title: row.Title, - Description: &row.Description.String, - Thumbnail: &row.Thumbnail.String, - DisplayOrder: row.DisplayOrder, - IsActive: row.IsActive, - }, nil -} diff --git a/internal/repository/courses.go b/internal/repository/courses.go index 99d13b7..1c63c7b 100644 --- a/internal/repository/courses.go +++ b/internal/repository/courses.go @@ -13,13 +13,22 @@ func (s *Store) CreateCourse( categoryID int64, title string, description *string, + thumbnail *string, ) (domain.Course, error) { + var descVal, thumbVal string + if description != nil { + descVal = *description + } + if thumbnail != nil { + thumbVal = *thumbnail + } row, err := s.queries.CreateCourse(ctx, dbgen.CreateCourseParams{ CategoryID: categoryID, Title: title, - Description: pgtype.Text{String: *description}, - Column4: true, + Description: pgtype.Text{String: descVal, Valid: description != nil}, + Thumbnail: pgtype.Text{String: thumbVal, Valid: thumbnail != nil}, + Column5: true, }) if err != nil { return domain.Course{}, err @@ -30,6 +39,7 @@ func (s *Store) CreateCourse( CategoryID: row.CategoryID, Title: row.Title, Description: &row.Description.String, + Thumbnail: &row.Thumbnail.String, IsActive: row.IsActive, }, nil } @@ -49,6 +59,7 @@ func (s *Store) GetCourseByID( CategoryID: row.CategoryID, Title: row.Title, Description: &row.Description.String, + Thumbnail: &row.Thumbnail.String, IsActive: row.IsActive, }, nil } @@ -84,6 +95,7 @@ func (s *Store) GetCoursesByCategory( CategoryID: row.CategoryID, Title: row.Title, Description: &row.Description.String, + Thumbnail: &row.Thumbnail.String, IsActive: row.IsActive, }) } @@ -96,12 +108,13 @@ func (s *Store) UpdateCourse( id int64, title *string, description *string, + thumbnail *string, isActive *bool, ) error { - var ( titleVal string descriptionVal string + thumbnailVal string isActiveVal bool ) @@ -111,13 +124,17 @@ func (s *Store) UpdateCourse( if description != nil { descriptionVal = *description } + if thumbnail != nil { + thumbnailVal = *thumbnail + } if isActive != nil { isActiveVal = *isActive } return s.queries.UpdateCourse(ctx, dbgen.UpdateCourseParams{ Title: titleVal, - Description: pgtype.Text{String: descriptionVal}, + Description: pgtype.Text{String: descriptionVal, Valid: description != nil}, + Thumbnail: pgtype.Text{String: thumbnailVal, Valid: thumbnail != nil}, IsActive: isActiveVal, ID: id, }) diff --git a/internal/repository/initial_assessment.go b/internal/repository/initial_assessment.go index b87ee35..0ccc63d 100644 --- a/internal/repository/initial_assessment.go +++ b/internal/repository/initial_assessment.go @@ -1,85 +1,12 @@ package repository import ( - dbgen "Yimaru-Backend/gen/db" "Yimaru-Backend/internal/ports" - "context" ) +// NewInitialAssessmentStore returns the Store which now delegates to the unified questions system func NewInitialAssessmentStore(s *Store) ports.InitialAssessmentStore { return s } -func (s *Store) CreateAssessmentQuestion( - ctx context.Context, - arg dbgen.CreateAssessmentQuestionParams, -) (dbgen.AssessmentQuestion, error) { - return s.queries.CreateAssessmentQuestion(ctx, arg) -} - -func (s *Store) GetAssessmentQuestionByID( - ctx context.Context, - id int64, -) (dbgen.AssessmentQuestion, error) { - return s.queries.GetAssessmentQuestionByID(ctx, id) -} - -func (s *Store) GetActiveAssessmentQuestions( - ctx context.Context, -) ([]dbgen.AssessmentQuestion, error) { - return s.queries.GetActiveAssessmentQuestions(ctx) -} - -func (s *Store) GetAssessmentQuestionsPaginated( - ctx context.Context, - arg dbgen.GetAssessmentQuestionsPaginatedParams, -) ([]dbgen.GetAssessmentQuestionsPaginatedRow, error) { - return s.queries.GetAssessmentQuestionsPaginated(ctx, arg) -} - -func (s *Store) UpdateAssessmentQuestion( - ctx context.Context, - arg dbgen.UpdateAssessmentQuestionParams, -) error { - return s.queries.UpdateAssessmentQuestion(ctx, arg) -} - -func (s *Store) DeleteAssessmentQuestion( - ctx context.Context, - id int64, -) error { - return s.queries.DeleteAssessmentQuestion(ctx, id) -} - -func (s *Store) CreateQuestionOption( - ctx context.Context, - arg dbgen.CreateQuestionOptionParams, -) (dbgen.AssessmentQuestionOption, error) { - return s.queries.CreateQuestionOption(ctx, arg) -} - -func (s *Store) GetQuestionOptions( - ctx context.Context, - questionID int64, -) ([]dbgen.AssessmentQuestionOption, error) { - return s.queries.GetQuestionOptions(ctx, questionID) -} - -func (s *Store) DeleteQuestionOptionsByQuestionID( - ctx context.Context, - questionID int64, -) error { - return s.queries.DeleteQuestionOptionsByQuestionID(ctx, questionID) -} - -func (s *Store) CreateShortAnswer( - ctx context.Context, - arg dbgen.CreateShortAnswerParams, -) (dbgen.AssessmentShortAnswer, error) { - return s.queries.CreateShortAnswer(ctx, arg) -} - -func (s *Store) GetShortAnswersByQuestionID( - ctx context.Context, - questionID int64, -) ([]dbgen.AssessmentShortAnswer, error) { - return s.queries.GetShortAnswersByQuestionID(ctx, questionID) -} +// The initial assessment functionality now uses the unified questions system. +// Use GetInitialAssessmentSet to get the initial assessment question set, +// then use the question set items to get the questions. diff --git a/internal/repository/learning_tree.go b/internal/repository/learning_tree.go index aa4dd8e..536bc1d 100644 --- a/internal/repository/learning_tree.go +++ b/internal/repository/learning_tree.go @@ -12,75 +12,28 @@ func (s *Store) GetFullLearningTree(ctx context.Context) ([]domain.TreeCourse, e } coursesMap := make(map[int64]*domain.TreeCourse) - programsMap := make(map[int64]*domain.TreeProgram) - levelsMap := make(map[int64]*domain.TreeLevel) for _, row := range rows { - // COURSE course, ok := coursesMap[row.CourseID] if !ok { course = &domain.TreeCourse{ - ID: row.CourseID, - Title: row.CourseTitle, - Programs: []domain.TreeProgram{}, + ID: row.CourseID, + Title: row.CourseTitle, + SubCourses: []domain.TreeSubCourse{}, } coursesMap[row.CourseID] = course } - // PROGRAM - program, ok := programsMap[row.ProgramID] - if !ok { - program = &domain.TreeProgram{ - ID: row.ProgramID, - Title: row.ProgramTitle, - Levels: []domain.TreeLevel{}, - } - programsMap[row.ProgramID] = program - course.Programs = append(course.Programs, *program) - } - - // LEVEL - level, ok := levelsMap[row.LevelID] - if !ok { - level = &domain.TreeLevel{ - ID: row.LevelID, - Title: row.LevelTitle, - Modules: []domain.TreeModule{}, - } - levelsMap[row.LevelID] = level - - // Append level to its program - for i := range course.Programs { - if course.Programs[i].ID == row.ProgramID { - course.Programs[i].Levels = append(course.Programs[i].Levels, *level) - break - } - } - } - - // MODULE (may be nil) - if row.ModuleID.Valid { - module := domain.TreeModule{ - ID: row.ModuleID.Int64, - Title: row.ModuleTitle.String, - } - - // Append module to its level - for i := range course.Programs { - if course.Programs[i].ID == row.ProgramID { - for j := range course.Programs[i].Levels { - if course.Programs[i].Levels[j].ID == row.LevelID { - course.Programs[i].Levels[j].Modules = append(course.Programs[i].Levels[j].Modules, module) - break - } - } - break - } + if row.SubCourseID.Valid { + subCourse := domain.TreeSubCourse{ + ID: row.SubCourseID.Int64, + Title: row.SubCourseTitle.String, + Level: row.SubCourseLevel.String, } + course.SubCourses = append(course.SubCourses, subCourse) } } - // Flatten map to slice courses := make([]domain.TreeCourse, 0, len(coursesMap)) for _, course := range coursesMap { courses = append(courses, *course) diff --git a/internal/repository/level_modules.go b/internal/repository/level_modules.go deleted file mode 100644 index bc5db68..0000000 --- a/internal/repository/level_modules.go +++ /dev/null @@ -1,112 +0,0 @@ -package repository - -import ( - dbgen "Yimaru-Backend/gen/db" - "Yimaru-Backend/internal/domain" - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -func (s *Store) CreateModule( - ctx context.Context, - levelID int64, - title string, - content *string, - displayOrder *int32, -) (domain.Module, error) { - - row, err := s.queries.CreateModule(ctx, dbgen.CreateModuleParams{ - LevelID: levelID, - Title: title, - Content: pgtype.Text{String: *content}, - Column4: displayOrder, - Column5: true, - }) - if err != nil { - return domain.Module{}, err - } - - return domain.Module{ - ID: row.ID, - LevelID: row.LevelID, - Title: row.Title, - Content: &row.Content.String, - DisplayOrder: row.DisplayOrder, - IsActive: row.IsActive, - }, nil -} - -func (s *Store) GetModulesByLevel( - ctx context.Context, - levelID int64, -) ([]domain.Module, int64, error) { - - rows, err := s.queries.GetModulesByLevel(ctx, levelID) - if err != nil { - return nil, 0, err - } - - var ( - modules []domain.Module - totalCount int64 - ) - - for i, row := range rows { - if i == 0 { - totalCount = row.TotalCount - } - - modules = append(modules, domain.Module{ - ID: row.ID, - LevelID: row.LevelID, - Title: row.Title, - Content: &row.Content.String, - DisplayOrder: row.DisplayOrder, - IsActive: row.IsActive, - }) - } - - return modules, totalCount, nil -} - -func (s *Store) UpdateModule( - ctx context.Context, - id int64, - title *string, - content *string, - displayOrder *int32, - isActive *bool, -) error { - - titleVal := "" - if title != nil { - titleVal = *title - } - - var displayOrderVal int32 - if displayOrder != nil { - displayOrderVal = *displayOrder - } - - var isActiveVal bool - if isActive != nil { - isActiveVal = *isActive - } - - return s.queries.UpdateModule(ctx, dbgen.UpdateModuleParams{ - Title: titleVal, - Content: pgtype.Text{String: *content}, - DisplayOrder: displayOrderVal, - IsActive: isActiveVal, - ID: id, - }) -} - -func (s *Store) DeleteModule( - ctx context.Context, - id int64, -) error { - - return s.queries.DeleteModule(ctx, id) -} diff --git a/internal/repository/module_videos.go b/internal/repository/module_videos.go deleted file mode 100644 index 7403184..0000000 --- a/internal/repository/module_videos.go +++ /dev/null @@ -1,161 +0,0 @@ -package repository - -import ( - dbgen "Yimaru-Backend/gen/db" - "Yimaru-Backend/internal/domain" - "context" - "time" - - "github.com/jackc/pgx/v5/pgtype" -) - -func (s *Store) CreateModuleVideo( - ctx context.Context, - moduleID int64, - title string, - description *string, - videoURL string, - duration int32, - resolution *string, - instructorID *string, - thumbnail *string, - visibility *string, -) (domain.ModuleVideo, error) { - - row, err := s.queries.CreateModuleVideo(ctx, dbgen.CreateModuleVideoParams{ - ModuleID: moduleID, - Title: title, - Description: pgtype.Text{String: *description}, - VideoUrl: videoURL, - Duration: duration, - Resolution: pgtype.Text{String: *resolution}, - InstructorID: pgtype.Text{String: *instructorID}, - Thumbnail: pgtype.Text{String: *thumbnail}, - Visibility: pgtype.Text{String: *visibility}, - Column10: true, - }) - if err != nil { - return domain.ModuleVideo{}, err - } - - var publishDate *time.Time - if row.PublishDate.Valid { - publishDate = &row.PublishDate.Time - } - - return domain.ModuleVideo{ - ID: row.ID, - ModuleID: row.ModuleID, - Title: row.Title, - Description: &row.Description.String, - VideoURL: row.VideoUrl, - Duration: row.Duration, - Resolution: &row.Resolution.String, - InstructorID: &row.InstructorID.String, - Thumbnail: &row.Thumbnail.String, - Visibility: &row.Visibility.String, - IsPublished: row.IsPublished, - PublishDate: publishDate, - IsActive: row.IsActive, - }, nil -} - -func (s *Store) PublishModuleVideo( - ctx context.Context, - videoID int64, -) error { - - return s.queries.PublishModuleVideo(ctx, videoID) -} - -func (s *Store) GetPublishedVideosByModule( - ctx context.Context, - moduleID int64, -) ([]domain.ModuleVideo, error) { - - rows, err := s.queries.GetPublishedVideosByModule(ctx, moduleID) - if err != nil { - return nil, err - } - - videos := make([]domain.ModuleVideo, 0, len(rows)) - for _, row := range rows { - - var publishDate *time.Time - if row.PublishDate.Valid { - publishDate = &row.PublishDate.Time - } - - videos = append(videos, domain.ModuleVideo{ - ID: row.ID, - ModuleID: row.ModuleID, - Title: row.Title, - Description: &row.Description.String, - VideoURL: row.VideoUrl, - Duration: row.Duration, - Resolution: &row.Resolution.String, - InstructorID: &row.InstructorID.String, - Thumbnail: &row.Thumbnail.String, - Visibility: &row.Visibility.String, - IsPublished: row.IsPublished, - PublishDate: publishDate, - IsActive: row.IsActive, - }) - } - - return videos, nil -} - -func (s *Store) UpdateModuleVideo( - ctx context.Context, - id int64, - title *string, - description *string, - videoURL *string, - duration *int32, - resolution *string, - visibility *string, - thumbnail *string, - isActive *bool, -) error { - - return s.queries.UpdateModuleVideo(ctx, dbgen.UpdateModuleVideoParams{ - Title: func() string { - if title != nil { - return *title - } - return "" - }(), - Description: pgtype.Text{String: *description}, - VideoUrl: func() string { - if videoURL != nil { - return *videoURL - } - return "" - }(), - Duration: func() int32 { - if duration != nil { - return *duration - } - return 0 - }(), - Resolution: pgtype.Text{String: *resolution}, - Visibility: pgtype.Text{String: *visibility}, - Thumbnail: pgtype.Text{String: *thumbnail}, - IsActive: func() bool { - if isActive != nil { - return *isActive - } - return false - }(), - ID: id, - }) -} - -func (s *Store) DeleteModuleVideo( - ctx context.Context, - id int64, -) error { - - return s.queries.DeleteModuleVideo(ctx, id) -} diff --git a/internal/repository/payments.go b/internal/repository/payments.go new file mode 100644 index 0000000..b08197b --- /dev/null +++ b/internal/repository/payments.go @@ -0,0 +1,215 @@ +package repository + +import ( + dbgen "Yimaru-Backend/gen/db" + "Yimaru-Backend/internal/domain" + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +func (s *Store) CreatePayment(ctx context.Context, input domain.CreatePaymentInput) (*domain.Payment, error) { + payment, err := s.queries.CreatePayment(ctx, dbgen.CreatePaymentParams{ + UserID: input.UserID, + PlanID: int64PtrToPgInt8(input.PlanID), + SubscriptionID: pgtype.Int8{Valid: false}, + SessionID: pgtype.Text{Valid: false}, + TransactionID: pgtype.Text{Valid: false}, + Nonce: input.Nonce, + Amount: toPgNumeric(input.Amount), + Currency: input.Currency, + PaymentMethod: toPgText(input.PaymentMethod), + Column10: string(domain.PaymentStatusPending), // status with COALESCE + PaymentUrl: pgtype.Text{Valid: false}, + ExpiresAt: toPgTimestamptzPtr(input.ExpiresAt), + }) + if err != nil { + return nil, err + } + return paymentToDomain(payment), nil +} + +func (s *Store) GetPaymentByID(ctx context.Context, id int64) (*domain.Payment, error) { + payment, err := s.queries.GetPaymentByID(ctx, id) + if err != nil { + return nil, err + } + return paymentToDomain(payment), nil +} + +func (s *Store) GetPaymentBySessionID(ctx context.Context, sessionID string) (*domain.Payment, error) { + payment, err := s.queries.GetPaymentBySessionID(ctx, toPgText(&sessionID)) + if err != nil { + return nil, err + } + return paymentToDomain(payment), nil +} + +func (s *Store) GetPaymentByNonce(ctx context.Context, nonce string) (*domain.Payment, error) { + payment, err := s.queries.GetPaymentByNonce(ctx, nonce) + if err != nil { + return nil, err + } + return paymentToDomain(payment), nil +} + +func (s *Store) GetPaymentByTransactionID(ctx context.Context, transactionID string) (*domain.Payment, error) { + payment, err := s.queries.GetPaymentByTransactionID(ctx, toPgText(&transactionID)) + if err != nil { + return nil, err + } + return paymentToDomain(payment), nil +} + +func (s *Store) GetPaymentsByUserID(ctx context.Context, userID int64, limit, offset int32) ([]domain.Payment, error) { + payments, err := s.queries.GetPaymentsByUserID(ctx, dbgen.GetPaymentsByUserIDParams{ + UserID: userID, + Limit: pgtype.Int4{Int32: limit, Valid: true}, + Offset: pgtype.Int4{Int32: offset, Valid: true}, + }) + if err != nil { + return nil, err + } + + result := make([]domain.Payment, len(payments)) + for i, p := range payments { + result[i] = domain.Payment{ + ID: p.ID, + UserID: p.UserID, + PlanID: int8PtrToInt64Ptr(p.PlanID), + SubscriptionID: int8PtrToInt64Ptr(p.SubscriptionID), + SessionID: fromPgTextPtr(p.SessionID), + TransactionID: fromPgTextPtr(p.TransactionID), + Nonce: p.Nonce, + Amount: fromPgNumeric(p.Amount), + Currency: p.Currency, + PaymentMethod: fromPgTextPtr(p.PaymentMethod), + Status: p.Status, + PaymentURL: fromPgTextPtr(p.PaymentUrl), + PaidAt: timePtr(p.PaidAt), + ExpiresAt: timePtr(p.ExpiresAt), + CreatedAt: p.CreatedAt.Time, + UpdatedAt: timePtr(p.UpdatedAt), + PlanName: fromPgTextPtr(p.PlanName), + } + } + return result, nil +} + +func (s *Store) GetPendingPaymentsByUserID(ctx context.Context, userID int64) ([]domain.Payment, error) { + payments, err := s.queries.GetPendingPaymentsByUserID(ctx, userID) + if err != nil { + return nil, err + } + + result := make([]domain.Payment, len(payments)) + for i, p := range payments { + result[i] = *paymentToDomain(p) + } + return result, nil +} + +func (s *Store) UpdatePaymentStatus(ctx context.Context, id int64, status string) error { + return s.queries.UpdatePaymentStatus(ctx, dbgen.UpdatePaymentStatusParams{ + Status: status, + ID: id, + }) +} + +func (s *Store) UpdatePaymentStatusBySessionID(ctx context.Context, sessionID, status, transactionID, paymentMethod string) error { + return s.queries.UpdatePaymentStatusBySessionID(ctx, dbgen.UpdatePaymentStatusBySessionIDParams{ + Status: status, + TransactionID: toPgText(&transactionID), + PaymentMethod: toPgText(&paymentMethod), + SessionID: toPgText(&sessionID), + }) +} + +func (s *Store) UpdatePaymentStatusByNonce(ctx context.Context, nonce, status, transactionID, paymentMethod string) error { + return s.queries.UpdatePaymentStatusByNonce(ctx, dbgen.UpdatePaymentStatusByNonceParams{ + Status: status, + TransactionID: toPgText(&transactionID), + PaymentMethod: toPgText(&paymentMethod), + Nonce: nonce, + }) +} + +func (s *Store) UpdatePaymentSessionID(ctx context.Context, id int64, sessionID, paymentURL string) error { + return s.queries.UpdatePaymentSessionID(ctx, dbgen.UpdatePaymentSessionIDParams{ + SessionID: toPgText(&sessionID), + PaymentUrl: toPgText(&paymentURL), + ID: id, + }) +} + +func (s *Store) LinkPaymentToSubscription(ctx context.Context, paymentID, subscriptionID int64) error { + return s.queries.LinkPaymentToSubscription(ctx, dbgen.LinkPaymentToSubscriptionParams{ + SubscriptionID: pgtype.Int8{Int64: subscriptionID, Valid: true}, + ID: paymentID, + }) +} + +func (s *Store) GetExpiredPendingPayments(ctx context.Context) ([]domain.Payment, error) { + payments, err := s.queries.GetExpiredPendingPayments(ctx) + if err != nil { + return nil, err + } + + result := make([]domain.Payment, len(payments)) + for i, p := range payments { + result[i] = *paymentToDomain(p) + } + return result, nil +} + +func (s *Store) ExpirePayment(ctx context.Context, id int64) error { + return s.queries.ExpirePayment(ctx, id) +} + +// Helper functions + +func paymentToDomain(p dbgen.Payment) *domain.Payment { + return &domain.Payment{ + ID: p.ID, + UserID: p.UserID, + PlanID: int8PtrToInt64Ptr(p.PlanID), + SubscriptionID: int8PtrToInt64Ptr(p.SubscriptionID), + SessionID: fromPgTextPtr(p.SessionID), + TransactionID: fromPgTextPtr(p.TransactionID), + Nonce: p.Nonce, + Amount: fromPgNumeric(p.Amount), + Currency: p.Currency, + PaymentMethod: fromPgTextPtr(p.PaymentMethod), + Status: p.Status, + PaymentURL: fromPgTextPtr(p.PaymentUrl), + PaidAt: timePtr(p.PaidAt), + ExpiresAt: timePtr(p.ExpiresAt), + CreatedAt: p.CreatedAt.Time, + UpdatedAt: timePtr(p.UpdatedAt), + } +} + +func int64PtrToPgInt8(val *int64) pgtype.Int8 { + if val == nil { + return pgtype.Int8{Valid: false} + } + return pgtype.Int8{Int64: *val, Valid: true} +} + +func int8PtrToInt64Ptr(val pgtype.Int8) *int64 { + if !val.Valid { + return nil + } + return &val.Int64 +} + +func fromPgTextPtr(t pgtype.Text) *string { + if !t.Valid { + return nil + } + return &t.String +} + +func strPtr(s string) *string { + return &s +} diff --git a/internal/repository/practice_questions.go b/internal/repository/practice_questions.go deleted file mode 100644 index 5b3b3e5..0000000 --- a/internal/repository/practice_questions.go +++ /dev/null @@ -1,108 +0,0 @@ -package repository - -import ( - dbgen "Yimaru-Backend/gen/db" - "Yimaru-Backend/internal/domain" - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -func (s *Store) CreatePracticeQuestion( - ctx context.Context, - practiceID int64, - question string, - questionVoicePrompt *string, - sampleAnswerVoicePrompt *string, - sampleAnswer *string, - tips *string, - qType string, -) (domain.PracticeQuestion, error) { - - row, err := s.queries.CreatePracticeQuestion(ctx, dbgen.CreatePracticeQuestionParams{ - PracticeID: practiceID, - Question: question, - QuestionVoicePrompt: pgtype.Text{String: *questionVoicePrompt}, - SampleAnswerVoicePrompt: pgtype.Text{String: *sampleAnswerVoicePrompt}, - SampleAnswer: pgtype.Text{String: *sampleAnswer}, - Tips: pgtype.Text{String: *tips}, - Type: qType, - }) - if err != nil { - return domain.PracticeQuestion{}, err - } - - return domain.PracticeQuestion{ - ID: row.ID, - PracticeID: row.PracticeID, - Question: row.Question, - QuestionVoicePrompt: &row.QuestionVoicePrompt.String, - SampleAnswerVoicePrompt: &row.SampleAnswerVoicePrompt.String, - SampleAnswer: &row.SampleAnswer.String, - Tips: &row.Tips.String, - Type: row.Type, - }, nil -} - -func (s *Store) GetQuestionsByPractice( - ctx context.Context, - practiceID int64, -) ([]domain.PracticeQuestion, error) { - - rows, err := s.queries.GetQuestionsByPractice(ctx, practiceID) - if err != nil { - return nil, err - } - - questions := make([]domain.PracticeQuestion, 0, len(rows)) - for _, row := range rows { - questions = append(questions, domain.PracticeQuestion{ - ID: row.ID, - PracticeID: row.PracticeID, - Question: row.Question, - QuestionVoicePrompt: &row.QuestionVoicePrompt.String, - SampleAnswerVoicePrompt: &row.SampleAnswerVoicePrompt.String, - SampleAnswer: &row.SampleAnswer.String, - Tips: &row.Tips.String, - Type: row.Type, - }) - } - - return questions, nil -} - -func (s *Store) UpdatePracticeQuestion( - ctx context.Context, - id int64, - question *string, - sampleAnswer *string, - tips *string, - qType *string, -) error { - - return s.queries.UpdatePracticeQuestion(ctx, dbgen.UpdatePracticeQuestionParams{ - Question: func() string { - if question != nil { - return *question - } - return "" - }(), - SampleAnswer: pgtype.Text{String: *sampleAnswer}, - Tips: pgtype.Text{String: *tips}, - Type: func() string { - if qType != nil { - return *qType - } - return "" - }(), - ID: id, - }) -} - -func (s *Store) DeletePracticeQuestion( - ctx context.Context, - id int64, -) error { - - return s.queries.DeletePracticeQuestion(ctx, id) -} diff --git a/internal/repository/practices.go b/internal/repository/practices.go deleted file mode 100644 index 0ed78e1..0000000 --- a/internal/repository/practices.go +++ /dev/null @@ -1,114 +0,0 @@ -package repository - -import ( - dbgen "Yimaru-Backend/gen/db" - "Yimaru-Backend/internal/domain" - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -func (s *Store) CreatePractice( - ctx context.Context, - ownerType string, - ownerID int64, - title string, - description *string, - bannerImage *string, - persona *string, - isActive *bool, -) (domain.Practice, error) { - - row, err := s.queries.CreatePractice(ctx, dbgen.CreatePracticeParams{ - OwnerType: ownerType, - OwnerID: ownerID, - Title: title, - Description: pgtype.Text{String: *description}, - BannerImage: pgtype.Text{String: *bannerImage}, - Persona: pgtype.Text{String: *persona}, - Column7: isActive, - }) - if err != nil { - return domain.Practice{}, err - } - - return domain.Practice{ - ID: row.ID, - OwnerType: row.OwnerType, - OwnerID: row.OwnerID, - Title: row.Title, - Description: &row.Description.String, - BannerImage: &row.BannerImage.String, - Persona: &row.Persona.String, - IsActive: row.IsActive, - }, nil -} - -func (s *Store) GetPracticesByOwner( - ctx context.Context, - ownerType string, - ownerID int64, -) ([]domain.Practice, error) { - - rows, err := s.queries.GetPracticesByOwner(ctx, dbgen.GetPracticesByOwnerParams{ - OwnerType: ownerType, - OwnerID: ownerID, - }) - if err != nil { - return nil, err - } - - practices := make([]domain.Practice, 0, len(rows)) - for _, row := range rows { - practices = append(practices, domain.Practice{ - ID: row.ID, - OwnerType: row.OwnerType, - OwnerID: row.OwnerID, - Title: row.Title, - Description: &row.Description.String, - BannerImage: &row.BannerImage.String, - Persona: &row.Persona.String, - IsActive: row.IsActive, - }) - } - - return practices, nil -} - -func (s *Store) UpdatePractice( - ctx context.Context, - id int64, - title *string, - description *string, - bannerImage *string, - persona *string, - isActive *bool, -) error { - - return s.queries.UpdatePractice(ctx, dbgen.UpdatePracticeParams{ - Title: func() string { - if title != nil { - return *title - } - return "" - }(), - Description: pgtype.Text{String: *description}, - BannerImage: pgtype.Text{String: *bannerImage}, - Persona: pgtype.Text{String: *persona}, - IsActive: func() bool { - if isActive != nil { - return *isActive - } - return false - }(), - ID: id, - }) -} - -func (s *Store) DeletePractice( - ctx context.Context, - id int64, -) error { - - return s.queries.DeletePractice(ctx, id) -} diff --git a/internal/repository/program_levels.go b/internal/repository/program_levels.go deleted file mode 100644 index 617f992..0000000 --- a/internal/repository/program_levels.go +++ /dev/null @@ -1,125 +0,0 @@ -package repository - -import ( - dbgen "Yimaru-Backend/gen/db" - "Yimaru-Backend/internal/domain" - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -func (s *Store) CreateLevel( - ctx context.Context, - programID int64, - title string, - description *string, - levelIndex int, - isActive *bool, -) (domain.Level, error) { - - row, err := s.queries.CreateLevel(ctx, dbgen.CreateLevelParams{ - ProgramID: programID, - Title: title, - Description: pgtype.Text{String: *description}, - LevelIndex: int32(levelIndex), - Column5: isActive, - }) - if err != nil { - return domain.Level{}, err - } - - return domain.Level{ - ID: row.ID, - ProgramID: row.ProgramID, - Title: row.Title, - Description: &row.Description.String, - LevelIndex: int(row.LevelIndex), - NumberOfModules: int(row.NumberOfModules), - NumberOfPractices: int(row.NumberOfPractices), - NumberOfVideos: int(row.NumberOfVideos), - IsActive: row.IsActive, - }, nil -} - -func (s *Store) GetLevelsByProgram( - ctx context.Context, - programID int64, -) ([]domain.Level, error) { - - rows, err := s.queries.GetLevelsByProgram(ctx, programID) - if err != nil { - return nil, err - } - - levels := make([]domain.Level, 0, len(rows)) - for _, row := range rows { - levels = append(levels, domain.Level{ - ID: row.ID, - ProgramID: row.ProgramID, - Title: row.Title, - Description: &row.Description.String, - LevelIndex: int(row.LevelIndex), - NumberOfModules: int(row.NumberOfModules), - NumberOfPractices: int(row.NumberOfPractices), - NumberOfVideos: int(row.NumberOfVideos), - IsActive: row.IsActive, - }) - } - - return levels, nil -} - -func (s *Store) UpdateLevel( - ctx context.Context, - id int64, - title *string, - description *string, - levelIndex *int, - isActive *bool, -) error { - - return s.queries.UpdateLevel(ctx, dbgen.UpdateLevelParams{ - Title: func() string { - if title != nil { - return *title - } - return "" - }(), - Description: pgtype.Text{String: *description}, - LevelIndex: int32(*levelIndex), - IsActive: *isActive, - ID: id, - }) -} - -func (s *Store) IncrementLevelModuleCount( - ctx context.Context, - levelID int64, -) error { - - return s.queries.IncrementLevelModuleCount(ctx, levelID) -} - -func (s *Store) IncrementLevelPracticeCount( - ctx context.Context, - levelID int64, -) error { - - return s.queries.IncrementLevelPracticeCount(ctx, levelID) -} - -func (s *Store) IncrementLevelVideoCount( - ctx context.Context, - levelID int64, -) error { - - return s.queries.IncrementLevelVideoCount(ctx, levelID) -} - -func (s *Store) DeleteLevel( - ctx context.Context, - levelID int64, -) error { - - return s.queries.DeleteLevel(ctx, levelID) -} diff --git a/internal/repository/questions.go b/internal/repository/questions.go new file mode 100644 index 0000000..dd90786 --- /dev/null +++ b/internal/repository/questions.go @@ -0,0 +1,759 @@ +package repository + +import ( + "context" + "time" + + dbgen "Yimaru-Backend/gen/db" + "Yimaru-Backend/internal/domain" + + "github.com/jackc/pgx/v5/pgtype" +) + +func toPgText(s *string) pgtype.Text { + if s == nil { + return pgtype.Text{Valid: false} + } + return pgtype.Text{String: *s, Valid: true} +} + +func fromPgText(t pgtype.Text) *string { + if !t.Valid { + return nil + } + return &t.String +} + +func fromPgInt4(i pgtype.Int4) *int32 { + if !i.Valid { + return nil + } + return &i.Int32 +} + +func fromPgInt8(i pgtype.Int8) *int64 { + if !i.Valid { + return nil + } + return &i.Int64 +} + +func toPgInt4(i *int32) pgtype.Int4 { + if i == nil { + return pgtype.Int4{Valid: false} + } + return pgtype.Int4{Int32: *i, Valid: true} +} + +func toPgInt8(i *int64) pgtype.Int8 { + if i == nil { + return pgtype.Int8{Valid: false} + } + return pgtype.Int8{Int64: *i, Valid: true} +} + +func timePtr(t pgtype.Timestamptz) *time.Time { + if !t.Valid { + return nil + } + return &t.Time +} + +func questionToDomain(q dbgen.Question) domain.Question { + return domain.Question{ + ID: q.ID, + QuestionText: q.QuestionText, + QuestionType: q.QuestionType, + DifficultyLevel: fromPgText(q.DifficultyLevel), + Points: q.Points, + Explanation: fromPgText(q.Explanation), + Tips: fromPgText(q.Tips), + VoicePrompt: fromPgText(q.VoicePrompt), + SampleAnswerVoicePrompt: fromPgText(q.SampleAnswerVoicePrompt), + Status: q.Status, + CreatedAt: q.CreatedAt.Time, + UpdatedAt: timePtr(q.UpdatedAt), + } +} + +func questionOptionToDomain(o dbgen.QuestionOption) domain.QuestionOption { + return domain.QuestionOption{ + ID: o.ID, + QuestionID: o.QuestionID, + OptionText: o.OptionText, + OptionOrder: o.OptionOrder, + IsCorrect: o.IsCorrect, + CreatedAt: o.CreatedAt.Time, + } +} + +func questionShortAnswerToDomain(a dbgen.QuestionShortAnswer) domain.QuestionShortAnswer { + return domain.QuestionShortAnswer{ + ID: a.ID, + QuestionID: a.QuestionID, + AcceptableAnswer: a.AcceptableAnswer, + MatchType: a.MatchType, + CreatedAt: a.CreatedAt.Time, + } +} + +func questionSetToDomain(qs dbgen.QuestionSet) domain.QuestionSet { + return domain.QuestionSet{ + ID: qs.ID, + Title: qs.Title, + Description: fromPgText(qs.Description), + SetType: qs.SetType, + OwnerType: fromPgText(qs.OwnerType), + OwnerID: fromPgInt8(qs.OwnerID), + BannerImage: fromPgText(qs.BannerImage), + Persona: fromPgText(qs.Persona), + TimeLimitMinutes: fromPgInt4(qs.TimeLimitMinutes), + PassingScore: fromPgInt4(qs.PassingScore), + ShuffleQuestions: qs.ShuffleQuestions, + Status: qs.Status, + SubCourseVideoID: fromPgInt8(qs.SubCourseVideoID), + CreatedAt: qs.CreatedAt.Time, + UpdatedAt: timePtr(qs.UpdatedAt), + } +} + +func questionSetItemToDomain(i dbgen.QuestionSetItem) domain.QuestionSetItem { + return domain.QuestionSetItem{ + ID: i.ID, + SetID: i.SetID, + QuestionID: i.QuestionID, + DisplayOrder: i.DisplayOrder, + CreatedAt: i.CreatedAt.Time, + } +} + +func (s *Store) CreateQuestion(ctx context.Context, input domain.CreateQuestionInput) (domain.Question, error) { + q, tx, err := s.BeginTx(ctx) + if err != nil { + return domain.Question{}, err + } + defer tx.Rollback(ctx) + + var points interface{} + if input.Points != nil { + points = *input.Points + } + var status interface{} + if input.Status != nil { + status = *input.Status + } + + question, err := q.CreateQuestion(ctx, dbgen.CreateQuestionParams{ + QuestionText: input.QuestionText, + QuestionType: input.QuestionType, + DifficultyLevel: toPgText(input.DifficultyLevel), + Column4: points, + Explanation: toPgText(input.Explanation), + Tips: toPgText(input.Tips), + VoicePrompt: toPgText(input.VoicePrompt), + SampleAnswerVoicePrompt: toPgText(input.SampleAnswerVoicePrompt), + Column9: status, + }) + if err != nil { + return domain.Question{}, err + } + + for _, opt := range input.Options { + var order interface{} + if opt.OptionOrder != nil { + order = *opt.OptionOrder + } + _, err = q.CreateQuestionOption(ctx, dbgen.CreateQuestionOptionParams{ + QuestionID: question.ID, + OptionText: opt.OptionText, + Column3: order, + Column4: opt.IsCorrect, + }) + if err != nil { + return domain.Question{}, err + } + } + + for _, sa := range input.ShortAnswers { + var matchType interface{} + if sa.MatchType != nil { + matchType = *sa.MatchType + } + _, err = q.CreateQuestionShortAnswer(ctx, dbgen.CreateQuestionShortAnswerParams{ + QuestionID: question.ID, + AcceptableAnswer: sa.AcceptableAnswer, + Column3: matchType, + }) + if err != nil { + return domain.Question{}, err + } + } + + if err = tx.Commit(ctx); err != nil { + return domain.Question{}, err + } + + return questionToDomain(question), nil +} + +func (s *Store) GetQuestionByID(ctx context.Context, id int64) (domain.Question, error) { + q, err := s.queries.GetQuestionByID(ctx, id) + if err != nil { + return domain.Question{}, err + } + return questionToDomain(q), nil +} + +func (s *Store) GetQuestionWithDetails(ctx context.Context, id int64) (domain.QuestionWithDetails, error) { + q, err := s.queries.GetQuestionByID(ctx, id) + if err != nil { + return domain.QuestionWithDetails{}, err + } + + opts, err := s.queries.GetOptionsByQuestionID(ctx, id) + if err != nil { + return domain.QuestionWithDetails{}, err + } + + shortAnswers, err := s.queries.GetShortAnswersByQuestionID(ctx, id) + if err != nil { + return domain.QuestionWithDetails{}, err + } + + options := make([]domain.QuestionOption, len(opts)) + for i, o := range opts { + options[i] = questionOptionToDomain(o) + } + + answers := make([]domain.QuestionShortAnswer, len(shortAnswers)) + for i, a := range shortAnswers { + answers[i] = questionShortAnswerToDomain(a) + } + + return domain.QuestionWithDetails{ + Question: questionToDomain(q), + Options: options, + ShortAnswers: answers, + }, nil +} + +func (s *Store) ListQuestions(ctx context.Context, questionType, difficulty, status *string, limit, offset int32) ([]domain.Question, int64, error) { + var qType, diff, stat string + if questionType != nil { + qType = *questionType + } + if difficulty != nil { + diff = *difficulty + } + if status != nil { + stat = *status + } + + rows, err := s.queries.ListQuestions(ctx, dbgen.ListQuestionsParams{ + Column1: qType, + Column2: diff, + Column3: stat, + Limit: pgtype.Int4{Int32: limit, Valid: true}, + Offset: pgtype.Int4{Int32: offset, Valid: true}, + }) + if err != nil { + return nil, 0, err + } + + var totalCount int64 + questions := make([]domain.Question, len(rows)) + for i, r := range rows { + if i == 0 { + totalCount = r.TotalCount + } + questions[i] = domain.Question{ + ID: r.ID, + QuestionText: r.QuestionText, + QuestionType: r.QuestionType, + DifficultyLevel: fromPgText(r.DifficultyLevel), + Points: r.Points, + Explanation: fromPgText(r.Explanation), + Tips: fromPgText(r.Tips), + VoicePrompt: fromPgText(r.VoicePrompt), + SampleAnswerVoicePrompt: fromPgText(r.SampleAnswerVoicePrompt), + Status: r.Status, + CreatedAt: r.CreatedAt.Time, + UpdatedAt: timePtr(r.UpdatedAt), + } + } + + return questions, totalCount, nil +} + +func (s *Store) SearchQuestions(ctx context.Context, query string, limit, offset int32) ([]domain.Question, int64, error) { + rows, err := s.queries.SearchQuestions(ctx, dbgen.SearchQuestionsParams{ + Column1: pgtype.Text{String: query, Valid: true}, + Limit: pgtype.Int4{Int32: limit, Valid: true}, + Offset: pgtype.Int4{Int32: offset, Valid: true}, + }) + if err != nil { + return nil, 0, err + } + + var totalCount int64 + questions := make([]domain.Question, len(rows)) + for i, r := range rows { + if i == 0 { + totalCount = r.TotalCount + } + questions[i] = domain.Question{ + ID: r.ID, + QuestionText: r.QuestionText, + QuestionType: r.QuestionType, + DifficultyLevel: fromPgText(r.DifficultyLevel), + Points: r.Points, + Explanation: fromPgText(r.Explanation), + Tips: fromPgText(r.Tips), + VoicePrompt: fromPgText(r.VoicePrompt), + SampleAnswerVoicePrompt: fromPgText(r.SampleAnswerVoicePrompt), + Status: r.Status, + CreatedAt: r.CreatedAt.Time, + UpdatedAt: timePtr(r.UpdatedAt), + } + } + + return questions, totalCount, nil +} + +func (s *Store) UpdateQuestion(ctx context.Context, id int64, input domain.CreateQuestionInput) error { + var points int32 + if input.Points != nil { + points = *input.Points + } + var status string + if input.Status != nil { + status = *input.Status + } + + return s.queries.UpdateQuestion(ctx, dbgen.UpdateQuestionParams{ + ID: id, + QuestionText: input.QuestionText, + QuestionType: input.QuestionType, + DifficultyLevel: toPgText(input.DifficultyLevel), + Points: points, + Explanation: toPgText(input.Explanation), + Tips: toPgText(input.Tips), + VoicePrompt: toPgText(input.VoicePrompt), + SampleAnswerVoicePrompt: toPgText(input.SampleAnswerVoicePrompt), + Status: status, + }) +} + +func (s *Store) ArchiveQuestion(ctx context.Context, id int64) error { + return s.queries.ArchiveQuestion(ctx, id) +} + +func (s *Store) DeleteQuestion(ctx context.Context, id int64) error { + return s.queries.DeleteQuestion(ctx, id) +} + +func (s *Store) CreateQuestionOption(ctx context.Context, questionID int64, optionText string, optionOrder *int32, isCorrect bool) (domain.QuestionOption, error) { + var order interface{} + if optionOrder != nil { + order = *optionOrder + } + + opt, err := s.queries.CreateQuestionOption(ctx, dbgen.CreateQuestionOptionParams{ + QuestionID: questionID, + OptionText: optionText, + Column3: order, + Column4: isCorrect, + }) + if err != nil { + return domain.QuestionOption{}, err + } + return questionOptionToDomain(opt), nil +} + +func (s *Store) GetOptionsByQuestionID(ctx context.Context, questionID int64) ([]domain.QuestionOption, error) { + opts, err := s.queries.GetOptionsByQuestionID(ctx, questionID) + if err != nil { + return nil, err + } + + result := make([]domain.QuestionOption, len(opts)) + for i, o := range opts { + result[i] = questionOptionToDomain(o) + } + return result, nil +} + +func (s *Store) UpdateQuestionOption(ctx context.Context, id int64, optionText *string, optionOrder *int32, isCorrect *bool) error { + var text string + if optionText != nil { + text = *optionText + } + var order int32 + if optionOrder != nil { + order = *optionOrder + } + var correct bool + if isCorrect != nil { + correct = *isCorrect + } + + return s.queries.UpdateQuestionOption(ctx, dbgen.UpdateQuestionOptionParams{ + ID: id, + OptionText: text, + OptionOrder: order, + IsCorrect: correct, + }) +} + +func (s *Store) DeleteQuestionOption(ctx context.Context, id int64) error { + return s.queries.DeleteQuestionOption(ctx, id) +} + +func (s *Store) DeleteOptionsByQuestionID(ctx context.Context, questionID int64) error { + return s.queries.DeleteOptionsByQuestionID(ctx, questionID) +} + +func (s *Store) CreateQuestionShortAnswer(ctx context.Context, questionID int64, acceptableAnswer string, matchType *string) (domain.QuestionShortAnswer, error) { + var mt interface{} + if matchType != nil { + mt = *matchType + } + + sa, err := s.queries.CreateQuestionShortAnswer(ctx, dbgen.CreateQuestionShortAnswerParams{ + QuestionID: questionID, + AcceptableAnswer: acceptableAnswer, + Column3: mt, + }) + if err != nil { + return domain.QuestionShortAnswer{}, err + } + return questionShortAnswerToDomain(sa), nil +} + +func (s *Store) GetShortAnswersByQuestionID(ctx context.Context, questionID int64) ([]domain.QuestionShortAnswer, error) { + answers, err := s.queries.GetShortAnswersByQuestionID(ctx, questionID) + if err != nil { + return nil, err + } + + result := make([]domain.QuestionShortAnswer, len(answers)) + for i, a := range answers { + result[i] = questionShortAnswerToDomain(a) + } + return result, nil +} + +func (s *Store) UpdateQuestionShortAnswer(ctx context.Context, id int64, acceptableAnswer, matchType *string) error { + var answer, mt string + if acceptableAnswer != nil { + answer = *acceptableAnswer + } + if matchType != nil { + mt = *matchType + } + + return s.queries.UpdateQuestionShortAnswer(ctx, dbgen.UpdateQuestionShortAnswerParams{ + ID: id, + AcceptableAnswer: answer, + MatchType: mt, + }) +} + +func (s *Store) DeleteQuestionShortAnswer(ctx context.Context, id int64) error { + return s.queries.DeleteQuestionShortAnswer(ctx, id) +} + +func (s *Store) DeleteShortAnswersByQuestionID(ctx context.Context, questionID int64) error { + return s.queries.DeleteShortAnswersByQuestionID(ctx, questionID) +} + +func (s *Store) CreateQuestionSet(ctx context.Context, input domain.CreateQuestionSetInput) (domain.QuestionSet, error) { + var shuffleQuestions interface{} + if input.ShuffleQuestions != nil { + shuffleQuestions = *input.ShuffleQuestions + } + var status interface{} + if input.Status != nil { + status = *input.Status + } + + qs, err := s.queries.CreateQuestionSet(ctx, dbgen.CreateQuestionSetParams{ + Title: input.Title, + Description: toPgText(input.Description), + SetType: input.SetType, + OwnerType: toPgText(input.OwnerType), + OwnerID: toPgInt8(input.OwnerID), + BannerImage: toPgText(input.BannerImage), + Persona: toPgText(input.Persona), + TimeLimitMinutes: toPgInt4(input.TimeLimitMinutes), + PassingScore: toPgInt4(input.PassingScore), + Column10: shuffleQuestions, + Column11: status, + SubCourseVideoID: toPgInt8(input.SubCourseVideoID), + }) + if err != nil { + return domain.QuestionSet{}, err + } + return questionSetToDomain(qs), nil +} + +func (s *Store) GetQuestionSetByID(ctx context.Context, id int64) (domain.QuestionSet, error) { + qs, err := s.queries.GetQuestionSetByID(ctx, id) + if err != nil { + return domain.QuestionSet{}, err + } + return questionSetToDomain(qs), nil +} + +func (s *Store) GetQuestionSetsByOwner(ctx context.Context, ownerType string, ownerID int64) ([]domain.QuestionSet, error) { + sets, err := s.queries.GetQuestionSetsByOwner(ctx, dbgen.GetQuestionSetsByOwnerParams{ + OwnerType: pgtype.Text{String: ownerType, Valid: true}, + OwnerID: pgtype.Int8{Int64: ownerID, Valid: true}, + }) + if err != nil { + return nil, err + } + + result := make([]domain.QuestionSet, len(sets)) + for i, qs := range sets { + result[i] = questionSetToDomain(qs) + } + return result, nil +} + +func (s *Store) GetQuestionSetsByType(ctx context.Context, setType string, limit, offset int32) ([]domain.QuestionSet, int64, error) { + rows, err := s.queries.GetQuestionSetsByType(ctx, dbgen.GetQuestionSetsByTypeParams{ + SetType: setType, + Limit: pgtype.Int4{Int32: limit, Valid: true}, + Offset: pgtype.Int4{Int32: offset, Valid: true}, + }) + if err != nil { + return nil, 0, err + } + + var totalCount int64 + result := make([]domain.QuestionSet, len(rows)) + for i, r := range rows { + if i == 0 { + totalCount = r.TotalCount + } + result[i] = domain.QuestionSet{ + ID: r.ID, + Title: r.Title, + Description: fromPgText(r.Description), + SetType: r.SetType, + OwnerType: fromPgText(r.OwnerType), + OwnerID: fromPgInt8(r.OwnerID), + BannerImage: fromPgText(r.BannerImage), + Persona: fromPgText(r.Persona), + TimeLimitMinutes: fromPgInt4(r.TimeLimitMinutes), + PassingScore: fromPgInt4(r.PassingScore), + ShuffleQuestions: r.ShuffleQuestions, + Status: r.Status, + SubCourseVideoID: fromPgInt8(r.SubCourseVideoID), + CreatedAt: r.CreatedAt.Time, + UpdatedAt: timePtr(r.UpdatedAt), + } + } + return result, totalCount, nil +} + +func (s *Store) GetPublishedQuestionSetsByOwner(ctx context.Context, ownerType string, ownerID int64) ([]domain.QuestionSet, error) { + sets, err := s.queries.GetPublishedQuestionSetsByOwner(ctx, dbgen.GetPublishedQuestionSetsByOwnerParams{ + OwnerType: pgtype.Text{String: ownerType, Valid: true}, + OwnerID: pgtype.Int8{Int64: ownerID, Valid: true}, + }) + if err != nil { + return nil, err + } + + result := make([]domain.QuestionSet, len(sets)) + for i, qs := range sets { + result[i] = questionSetToDomain(qs) + } + return result, nil +} + +func (s *Store) GetInitialAssessmentSet(ctx context.Context) (domain.QuestionSet, error) { + qs, err := s.queries.GetInitialAssessmentSet(ctx) + if err != nil { + return domain.QuestionSet{}, err + } + return questionSetToDomain(qs), nil +} + +func (s *Store) UpdateQuestionSet(ctx context.Context, id int64, input domain.CreateQuestionSetInput) error { + var shuffleQuestions bool + if input.ShuffleQuestions != nil { + shuffleQuestions = *input.ShuffleQuestions + } + var status string + if input.Status != nil { + status = *input.Status + } + + return s.queries.UpdateQuestionSet(ctx, dbgen.UpdateQuestionSetParams{ + ID: id, + Title: input.Title, + Description: toPgText(input.Description), + BannerImage: toPgText(input.BannerImage), + Persona: toPgText(input.Persona), + TimeLimitMinutes: toPgInt4(input.TimeLimitMinutes), + PassingScore: toPgInt4(input.PassingScore), + ShuffleQuestions: shuffleQuestions, + Status: status, + SubCourseVideoID: toPgInt8(input.SubCourseVideoID), + }) +} + +func (s *Store) ArchiveQuestionSet(ctx context.Context, id int64) error { + return s.queries.ArchiveQuestionSet(ctx, id) +} + +func (s *Store) DeleteQuestionSet(ctx context.Context, id int64) error { + return s.queries.DeleteQuestionSet(ctx, id) +} + +func (s *Store) AddQuestionToSet(ctx context.Context, setID, questionID int64, displayOrder *int32) (domain.QuestionSetItem, error) { + var order interface{} + if displayOrder != nil { + order = *displayOrder + } + + item, err := s.queries.AddQuestionToSet(ctx, dbgen.AddQuestionToSetParams{ + SetID: setID, + QuestionID: questionID, + Column3: order, + }) + if err != nil { + return domain.QuestionSetItem{}, err + } + return questionSetItemToDomain(item), nil +} + +func (s *Store) GetQuestionSetItems(ctx context.Context, setID int64) ([]domain.QuestionSetItemWithQuestion, error) { + rows, err := s.queries.GetQuestionSetItems(ctx, setID) + if err != nil { + return nil, err + } + + result := make([]domain.QuestionSetItemWithQuestion, len(rows)) + for i, r := range rows { + result[i] = domain.QuestionSetItemWithQuestion{ + QuestionSetItem: domain.QuestionSetItem{ + ID: r.ID, + SetID: r.SetID, + QuestionID: r.QuestionID, + DisplayOrder: r.DisplayOrder, + }, + QuestionText: r.QuestionText, + QuestionType: r.QuestionType, + DifficultyLevel: fromPgText(r.DifficultyLevel), + Points: r.Points, + Explanation: fromPgText(r.Explanation), + Tips: fromPgText(r.Tips), + VoicePrompt: fromPgText(r.VoicePrompt), + QuestionStatus: r.QuestionStatus, + } + } + return result, nil +} + +func (s *Store) GetPublishedQuestionsInSet(ctx context.Context, setID int64) ([]domain.QuestionSetItemWithQuestion, error) { + rows, err := s.queries.GetPublishedQuestionsInSet(ctx, setID) + if err != nil { + return nil, err + } + + result := make([]domain.QuestionSetItemWithQuestion, len(rows)) + for i, r := range rows { + result[i] = domain.QuestionSetItemWithQuestion{ + QuestionSetItem: domain.QuestionSetItem{ + ID: r.ID, + SetID: r.SetID, + QuestionID: r.QuestionID, + DisplayOrder: r.DisplayOrder, + }, + QuestionText: r.QuestionText, + QuestionType: r.QuestionType, + DifficultyLevel: fromPgText(r.DifficultyLevel), + Points: r.Points, + Explanation: fromPgText(r.Explanation), + Tips: fromPgText(r.Tips), + VoicePrompt: fromPgText(r.VoicePrompt), + QuestionStatus: "PUBLISHED", + } + } + return result, nil +} + +func (s *Store) RemoveQuestionFromSet(ctx context.Context, setID, questionID int64) error { + return s.queries.RemoveQuestionFromSet(ctx, dbgen.RemoveQuestionFromSetParams{ + SetID: setID, + QuestionID: questionID, + }) +} + +func (s *Store) UpdateQuestionOrder(ctx context.Context, setID, questionID int64, displayOrder int32) error { + return s.queries.UpdateQuestionOrder(ctx, dbgen.UpdateQuestionOrderParams{ + SetID: setID, + QuestionID: questionID, + DisplayOrder: displayOrder, + }) +} + +func (s *Store) CountQuestionsInSet(ctx context.Context, setID int64) (int64, error) { + return s.queries.CountQuestionsInSet(ctx, setID) +} + +func (s *Store) GetQuestionSetsContainingQuestion(ctx context.Context, questionID int64) ([]domain.QuestionSet, error) { + sets, err := s.queries.GetQuestionSetsContainingQuestion(ctx, questionID) + if err != nil { + return nil, err + } + + result := make([]domain.QuestionSet, len(sets)) + for i, qs := range sets { + result[i] = questionSetToDomain(qs) + } + return result, nil +} + +// User Persona methods for question sets + +func (s *Store) AddUserPersonaToQuestionSet(ctx context.Context, questionSetID, userID int64, displayOrder int32) error { + _, err := s.queries.AddUserPersonaToQuestionSet(ctx, dbgen.AddUserPersonaToQuestionSetParams{ + QuestionSetID: questionSetID, + UserID: userID, + Column3: displayOrder, + }) + return err +} + +func (s *Store) RemoveUserPersonaFromQuestionSet(ctx context.Context, questionSetID, userID int64) error { + return s.queries.RemoveUserPersonaFromQuestionSet(ctx, dbgen.RemoveUserPersonaFromQuestionSetParams{ + QuestionSetID: questionSetID, + UserID: userID, + }) +} + +func (s *Store) GetUserPersonasByQuestionSetID(ctx context.Context, questionSetID int64) ([]domain.UserPersona, error) { + rows, err := s.queries.GetUserPersonasByQuestionSetID(ctx, questionSetID) + if err != nil { + return nil, err + } + + result := make([]domain.UserPersona, len(rows)) + for i, r := range rows { + result[i] = domain.UserPersona{ + ID: r.ID, + FirstName: fromPgText(r.FirstName), + LastName: fromPgText(r.LastName), + NickName: fromPgText(r.NickName), + ProfilePictureURL: fromPgText(r.ProfilePictureUrl), + Role: r.Role, + DisplayOrder: r.DisplayOrder.Int32, + } + } + return result, nil +} diff --git a/internal/repository/settings.go b/internal/repository/settings.go index ec72c80..ba6615d 100644 --- a/internal/repository/settings.go +++ b/internal/repository/settings.go @@ -101,6 +101,7 @@ func (s *Store) UpdateGlobalSettingList(ctx context.Context, settingList domain. } return nil } + // func (s *Store) GetOverrideSettings(ctx context.Context, companyID int64) ([]domain.Setting, error) { // settings, err := s.queries.GetOverrideSettings(ctx, companyID) diff --git a/internal/repository/sub_course_videos.go b/internal/repository/sub_course_videos.go new file mode 100644 index 0000000..3a60f30 --- /dev/null +++ b/internal/repository/sub_course_videos.go @@ -0,0 +1,285 @@ +package repository + +import ( + dbgen "Yimaru-Backend/gen/db" + "Yimaru-Backend/internal/domain" + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +func (s *Store) CreateSubCourseVideo( + ctx context.Context, + subCourseID int64, + title string, + description *string, + videoURL string, + duration int32, + resolution *string, + instructorID *string, + thumbnail *string, + visibility *string, + displayOrder *int32, + status *string, + vimeoID *string, + vimeoEmbedURL *string, + vimeoPlayerHTML *string, + vimeoStatus *string, + videoHostProvider *string, +) (domain.SubCourseVideo, error) { + var descText, resText, instrText, thumbText, visText, statusText pgtype.Text + var vimeoIDText, vimeoEmbedText, vimeoHTMLText, vimeoStatusText, hostProviderText pgtype.Text + + if description != nil { + descText = pgtype.Text{String: *description, Valid: true} + } + if resolution != nil { + resText = pgtype.Text{String: *resolution, Valid: true} + } + if instructorID != nil { + instrText = pgtype.Text{String: *instructorID, Valid: true} + } + if thumbnail != nil { + thumbText = pgtype.Text{String: *thumbnail, Valid: true} + } + if visibility != nil { + visText = pgtype.Text{String: *visibility, Valid: true} + } + if status != nil { + statusText = pgtype.Text{String: *status, Valid: true} + } + if vimeoID != nil { + vimeoIDText = pgtype.Text{String: *vimeoID, Valid: true} + } + if vimeoEmbedURL != nil { + vimeoEmbedText = pgtype.Text{String: *vimeoEmbedURL, Valid: true} + } + if vimeoPlayerHTML != nil { + vimeoHTMLText = pgtype.Text{String: *vimeoPlayerHTML, Valid: true} + } + if vimeoStatus != nil { + vimeoStatusText = pgtype.Text{String: *vimeoStatus, Valid: true} + } + if videoHostProvider != nil { + hostProviderText = pgtype.Text{String: *videoHostProvider, Valid: true} + } + + var dispOrder pgtype.Int4 + if displayOrder != nil { + dispOrder = pgtype.Int4{Int32: *displayOrder, Valid: true} + } + + row, err := s.queries.CreateSubCourseVideo(ctx, dbgen.CreateSubCourseVideoParams{ + SubCourseID: subCourseID, + Title: title, + Description: descText, + VideoUrl: videoURL, + Duration: duration, + Resolution: resText, + InstructorID: instrText, + Thumbnail: thumbText, + Visibility: visText, + Column10: dispOrder, + Column11: statusText, + VimeoID: vimeoIDText, + VimeoEmbedUrl: vimeoEmbedText, + VimeoPlayerHtml: vimeoHTMLText, + Column15: vimeoStatusText, + Column16: hostProviderText, + }) + if err != nil { + return domain.SubCourseVideo{}, err + } + + return mapSubCourseVideoRow(row), nil +} + +func (s *Store) GetSubCourseVideoByID( + ctx context.Context, + id int64, +) (domain.SubCourseVideo, error) { + row, err := s.queries.GetSubCourseVideoByID(ctx, id) + if err != nil { + return domain.SubCourseVideo{}, err + } + + return mapSubCourseVideoRow(row), nil +} + +func (s *Store) GetVideosBySubCourse( + ctx context.Context, + subCourseID int64, +) ([]domain.SubCourseVideo, int64, error) { + rows, err := s.queries.GetVideosBySubCourse(ctx, subCourseID) + if err != nil { + return nil, 0, err + } + + var ( + videos []domain.SubCourseVideo + totalCount int64 + ) + + for i, row := range rows { + if i == 0 { + totalCount = row.TotalCount + } + + videos = append(videos, domain.SubCourseVideo{ + ID: row.ID, + SubCourseID: row.SubCourseID, + Title: row.Title, + Description: ptrString(row.Description), + VideoURL: row.VideoUrl, + Duration: row.Duration, + Resolution: ptrString(row.Resolution), + InstructorID: ptrString(row.InstructorID), + Thumbnail: ptrString(row.Thumbnail), + Visibility: ptrString(row.Visibility), + DisplayOrder: row.DisplayOrder, + IsPublished: row.IsPublished, + PublishDate: ptrTimestamptz(row.PublishDate), + Status: row.Status, + VimeoID: ptrString(row.VimeoID), + VimeoEmbedURL: ptrString(row.VimeoEmbedUrl), + VimeoPlayerHTML: ptrString(row.VimeoPlayerHtml), + VimeoStatus: ptrString(row.VimeoStatus), + }) + } + + return videos, totalCount, nil +} + +func (s *Store) GetPublishedVideosBySubCourse( + ctx context.Context, + subCourseID int64, +) ([]domain.SubCourseVideo, error) { + rows, err := s.queries.GetPublishedVideosBySubCourse(ctx, subCourseID) + if err != nil { + return nil, err + } + + videos := make([]domain.SubCourseVideo, 0, len(rows)) + for _, row := range rows { + videos = append(videos, mapSubCourseVideoRow(row)) + } + + return videos, nil +} + +func (s *Store) PublishSubCourseVideo( + ctx context.Context, + videoID int64, +) error { + return s.queries.PublishSubCourseVideo(ctx, videoID) +} + +func (s *Store) UpdateSubCourseVideo( + ctx context.Context, + id int64, + title *string, + description *string, + videoURL *string, + duration *int32, + resolution *string, + visibility *string, + thumbnail *string, + displayOrder *int32, + status *string, +) error { + var titleVal, descVal, urlVal, resVal, visVal, thumbVal, statusVal string + var durationVal, dispOrderVal int32 + + if title != nil { + titleVal = *title + } + if description != nil { + descVal = *description + } + if videoURL != nil { + urlVal = *videoURL + } + if duration != nil { + durationVal = *duration + } + if resolution != nil { + resVal = *resolution + } + if visibility != nil { + visVal = *visibility + } + if thumbnail != nil { + thumbVal = *thumbnail + } + if displayOrder != nil { + dispOrderVal = *displayOrder + } + if status != nil { + statusVal = *status + } + + return s.queries.UpdateSubCourseVideo(ctx, dbgen.UpdateSubCourseVideoParams{ + Title: titleVal, + Description: pgtype.Text{String: descVal, Valid: description != nil}, + VideoUrl: urlVal, + Duration: durationVal, + Resolution: pgtype.Text{String: resVal, Valid: resolution != nil}, + Visibility: pgtype.Text{String: visVal, Valid: visibility != nil}, + Thumbnail: pgtype.Text{String: thumbVal, Valid: thumbnail != nil}, + DisplayOrder: dispOrderVal, + Status: statusVal, + ID: id, + }) +} + +func (s *Store) ArchiveSubCourseVideo( + ctx context.Context, + id int64, +) error { + return s.queries.ArchiveSubCourseVideo(ctx, id) +} + +func (s *Store) DeleteSubCourseVideo( + ctx context.Context, + id int64, +) error { + return s.queries.DeleteSubCourseVideo(ctx, id) +} + +func mapSubCourseVideoRow(row dbgen.SubCourseVideo) domain.SubCourseVideo { + return domain.SubCourseVideo{ + ID: row.ID, + SubCourseID: row.SubCourseID, + Title: row.Title, + Description: ptrString(row.Description), + VideoURL: row.VideoUrl, + Duration: row.Duration, + Resolution: ptrString(row.Resolution), + InstructorID: ptrString(row.InstructorID), + Thumbnail: ptrString(row.Thumbnail), + Visibility: ptrString(row.Visibility), + DisplayOrder: row.DisplayOrder, + IsPublished: row.IsPublished, + PublishDate: ptrTimestamptz(row.PublishDate), + Status: row.Status, + VimeoID: ptrString(row.VimeoID), + VimeoEmbedURL: ptrString(row.VimeoEmbedUrl), + VimeoPlayerHTML: ptrString(row.VimeoPlayerHtml), + VimeoStatus: ptrString(row.VimeoStatus), + } +} + +func (s *Store) UpdateVimeoStatus(ctx context.Context, videoID int64, status string) error { + return s.queries.UpdateVimeoStatus(ctx, dbgen.UpdateVimeoStatusParams{ + VimeoStatus: pgtype.Text{String: status, Valid: true}, + ID: videoID, + }) +} + +func (s *Store) GetVideoByVimeoID(ctx context.Context, vimeoID string) (domain.SubCourseVideo, error) { + row, err := s.queries.GetVideosByVimeoID(ctx, pgtype.Text{String: vimeoID, Valid: true}) + if err != nil { + return domain.SubCourseVideo{}, err + } + return mapSubCourseVideoRow(row), nil +} diff --git a/internal/repository/sub_courses.go b/internal/repository/sub_courses.go new file mode 100644 index 0000000..038b444 --- /dev/null +++ b/internal/repository/sub_courses.go @@ -0,0 +1,241 @@ +package repository + +import ( + dbgen "Yimaru-Backend/gen/db" + "Yimaru-Backend/internal/domain" + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +func (s *Store) CreateSubCourse( + ctx context.Context, + courseID int64, + title string, + description *string, + thumbnail *string, + displayOrder *int32, + level string, +) (domain.SubCourse, error) { + var descText, thumbText pgtype.Text + if description != nil { + descText = pgtype.Text{String: *description, Valid: true} + } + if thumbnail != nil { + thumbText = pgtype.Text{String: *thumbnail, Valid: true} + } + + var dispOrder pgtype.Int4 + if displayOrder != nil { + dispOrder = pgtype.Int4{Int32: *displayOrder, Valid: true} + } + + row, err := s.queries.CreateSubCourse(ctx, dbgen.CreateSubCourseParams{ + CourseID: courseID, + Title: title, + Description: descText, + Thumbnail: thumbText, + Column5: dispOrder, + Level: level, + Column7: pgtype.Bool{Bool: true, Valid: true}, + }) + if err != nil { + return domain.SubCourse{}, err + } + + return domain.SubCourse{ + ID: row.ID, + CourseID: row.CourseID, + Title: row.Title, + Description: ptrString(row.Description), + Thumbnail: ptrString(row.Thumbnail), + DisplayOrder: row.DisplayOrder, + Level: row.Level, + IsActive: row.IsActive, + }, nil +} + +func (s *Store) GetSubCourseByID( + ctx context.Context, + id int64, +) (domain.SubCourse, error) { + row, err := s.queries.GetSubCourseByID(ctx, id) + if err != nil { + return domain.SubCourse{}, err + } + + return domain.SubCourse{ + ID: row.ID, + CourseID: row.CourseID, + Title: row.Title, + Description: ptrString(row.Description), + Thumbnail: ptrString(row.Thumbnail), + DisplayOrder: row.DisplayOrder, + Level: row.Level, + IsActive: row.IsActive, + }, nil +} + +func (s *Store) GetSubCoursesByCourse( + ctx context.Context, + courseID int64, +) ([]domain.SubCourse, int64, error) { + rows, err := s.queries.GetSubCoursesByCourse(ctx, courseID) + if err != nil { + return nil, 0, err + } + + var ( + subCourses []domain.SubCourse + totalCount int64 + ) + + for i, row := range rows { + if i == 0 { + totalCount = row.TotalCount + } + + subCourses = append(subCourses, domain.SubCourse{ + ID: row.ID, + CourseID: row.CourseID, + Title: row.Title, + Description: ptrString(row.Description), + Thumbnail: ptrString(row.Thumbnail), + DisplayOrder: row.DisplayOrder, + Level: row.Level, + IsActive: row.IsActive, + }) + } + + return subCourses, totalCount, nil +} + +func (s *Store) ListSubCoursesByCourse( + ctx context.Context, + courseID int64, +) ([]domain.SubCourse, error) { + rows, err := s.queries.ListSubCoursesByCourse(ctx, courseID) + if err != nil { + return nil, err + } + + subCourses := make([]domain.SubCourse, 0, len(rows)) + for _, row := range rows { + subCourses = append(subCourses, domain.SubCourse{ + ID: row.ID, + CourseID: row.CourseID, + Title: row.Title, + Description: ptrString(row.Description), + Thumbnail: ptrString(row.Thumbnail), + DisplayOrder: row.DisplayOrder, + Level: row.Level, + IsActive: row.IsActive, + }) + } + + return subCourses, nil +} + +func (s *Store) ListActiveSubCourses( + ctx context.Context, +) ([]domain.SubCourse, error) { + rows, err := s.queries.ListActiveSubCourses(ctx) + if err != nil { + return nil, err + } + + subCourses := make([]domain.SubCourse, 0, len(rows)) + for _, row := range rows { + subCourses = append(subCourses, domain.SubCourse{ + ID: row.ID, + CourseID: row.CourseID, + Title: row.Title, + Description: ptrString(row.Description), + Thumbnail: ptrString(row.Thumbnail), + DisplayOrder: row.DisplayOrder, + Level: row.Level, + IsActive: row.IsActive, + }) + } + + return subCourses, nil +} + +func (s *Store) UpdateSubCourse( + ctx context.Context, + id int64, + title *string, + description *string, + thumbnail *string, + displayOrder *int32, + level *string, + isActive *bool, +) error { + var titleVal, descVal, thumbVal, levelVal string + var dispOrderVal int32 + var isActiveVal bool + + if title != nil { + titleVal = *title + } + if description != nil { + descVal = *description + } + if thumbnail != nil { + thumbVal = *thumbnail + } + if displayOrder != nil { + dispOrderVal = *displayOrder + } + if level != nil { + levelVal = *level + } + if isActive != nil { + isActiveVal = *isActive + } + + return s.queries.UpdateSubCourse(ctx, dbgen.UpdateSubCourseParams{ + Title: titleVal, + Description: pgtype.Text{String: descVal, Valid: description != nil}, + Thumbnail: pgtype.Text{String: thumbVal, Valid: thumbnail != nil}, + DisplayOrder: dispOrderVal, + Level: levelVal, + IsActive: isActiveVal, + ID: id, + }) +} + +func (s *Store) DeactivateSubCourse( + ctx context.Context, + id int64, +) error { + return s.queries.DeactivateSubCourse(ctx, id) +} + +func (s *Store) DeleteSubCourse( + ctx context.Context, + id int64, +) (domain.SubCourse, error) { + row, err := s.queries.DeleteSubCourse(ctx, id) + if err != nil { + return domain.SubCourse{}, err + } + + return domain.SubCourse{ + ID: row.ID, + CourseID: row.CourseID, + Title: row.Title, + Description: ptrString(row.Description), + Thumbnail: ptrString(row.Thumbnail), + DisplayOrder: row.DisplayOrder, + Level: row.Level, + IsActive: row.IsActive, + }, nil +} + +func ptrString(t pgtype.Text) *string { + if !t.Valid { + return nil + } + return &t.String +} diff --git a/internal/repository/subscriptions.go b/internal/repository/subscriptions.go new file mode 100644 index 0000000..149e11f --- /dev/null +++ b/internal/repository/subscriptions.go @@ -0,0 +1,310 @@ +package repository + +import ( + dbgen "Yimaru-Backend/gen/db" + "Yimaru-Backend/internal/domain" + "context" + "time" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/shopspring/decimal" +) + +// Helper functions for numeric conversions +func toPgNumeric(val float64) pgtype.Numeric { + d := decimal.NewFromFloat(val) + var num pgtype.Numeric + _ = num.Scan(d.String()) + return num +} + +func fromPgNumeric(num pgtype.Numeric) float64 { + if !num.Valid { + return 0 + } + f, _ := num.Float64Value() + return f.Float64 +} + +func toPgTimestamptz(t time.Time) pgtype.Timestamptz { + return pgtype.Timestamptz{Time: t, Valid: true} +} + +func toPgTimestamptzPtr(t *time.Time) pgtype.Timestamptz { + if t == nil { + return pgtype.Timestamptz{Valid: false} + } + return pgtype.Timestamptz{Time: *t, Valid: true} +} + +// ===================== +// Subscription Plans +// ===================== + +func (s *Store) CreateSubscriptionPlan(ctx context.Context, input domain.CreateSubscriptionPlanInput) (*domain.SubscriptionPlan, error) { + plan, err := s.queries.CreateSubscriptionPlan(ctx, dbgen.CreateSubscriptionPlanParams{ + Name: input.Name, + Description: toPgText(input.Description), + DurationValue: input.DurationValue, + DurationUnit: input.DurationUnit, + Price: toPgNumeric(input.Price), + Currency: input.Currency, + Column7: input.IsActive, + }) + if err != nil { + return nil, err + } + return subscriptionPlanToDomain(plan), nil +} + +func (s *Store) GetSubscriptionPlanByID(ctx context.Context, id int64) (*domain.SubscriptionPlan, error) { + plan, err := s.queries.GetSubscriptionPlanByID(ctx, id) + if err != nil { + return nil, err + } + return subscriptionPlanToDomain(plan), nil +} + +func (s *Store) ListSubscriptionPlans(ctx context.Context, activeOnly bool) ([]domain.SubscriptionPlan, error) { + var plans []dbgen.SubscriptionPlan + var err error + + if activeOnly { + plans, err = s.queries.ListActiveSubscriptionPlans(ctx) + } else { + plans, err = s.queries.ListSubscriptionPlans(ctx, false) + } + if err != nil { + return nil, err + } + + result := make([]domain.SubscriptionPlan, len(plans)) + for i, p := range plans { + result[i] = *subscriptionPlanToDomain(p) + } + return result, nil +} + +func (s *Store) UpdateSubscriptionPlan(ctx context.Context, id int64, input domain.UpdateSubscriptionPlanInput) error { + return s.queries.UpdateSubscriptionPlan(ctx, dbgen.UpdateSubscriptionPlanParams{ + Name: stringVal(input.Name), + Description: toPgText(input.Description), + DurationValue: int32Val(input.DurationValue), + DurationUnit: stringVal(input.DurationUnit), + Price: numericPtrToNumeric(input.Price), + Currency: stringVal(input.Currency), + IsActive: boolPtrToBool(input.IsActive), + ID: id, + }) +} + +func (s *Store) DeleteSubscriptionPlan(ctx context.Context, id int64) error { + return s.queries.DeleteSubscriptionPlan(ctx, id) +} + +// ===================== +// User Subscriptions +// ===================== + +func (s *Store) CreateUserSubscription(ctx context.Context, input domain.CreateUserSubscriptionInput) (*domain.UserSubscription, error) { + sub, err := s.queries.CreateUserSubscription(ctx, dbgen.CreateUserSubscriptionParams{ + UserID: input.UserID, + PlanID: input.PlanID, + Column3: input.StartsAt, + ExpiresAt: toPgTimestamptz(input.ExpiresAt), + Column5: input.Status, + PaymentReference: toPgText(input.PaymentReference), + PaymentMethod: toPgText(input.PaymentMethod), + Column8: input.AutoRenew, + }) + if err != nil { + return nil, err + } + return userSubscriptionToDomain(sub), nil +} + +func (s *Store) GetUserSubscriptionByID(ctx context.Context, id int64) (*domain.UserSubscription, error) { + sub, err := s.queries.GetUserSubscriptionByID(ctx, id) + if err != nil { + return nil, err + } + return userSubscriptionWithPlanToDomain(sub), nil +} + +func (s *Store) GetActiveSubscriptionByUserID(ctx context.Context, userID int64) (*domain.UserSubscription, error) { + sub, err := s.queries.GetActiveSubscriptionByUserID(ctx, userID) + if err != nil { + return nil, err + } + return &domain.UserSubscription{ + ID: sub.ID, + UserID: sub.UserID, + PlanID: sub.PlanID, + StartsAt: sub.StartsAt.Time, + ExpiresAt: sub.ExpiresAt.Time, + Status: sub.Status, + PaymentReference: fromPgText(sub.PaymentReference), + PaymentMethod: fromPgText(sub.PaymentMethod), + AutoRenew: sub.AutoRenew, + CancelledAt: timePtr(sub.CancelledAt), + CreatedAt: sub.CreatedAt.Time, + UpdatedAt: timePtr(sub.UpdatedAt), + PlanName: &sub.PlanName, + DurationValue: &sub.DurationValue, + DurationUnit: &sub.DurationUnit, + Price: float64Ptr(fromPgNumeric(sub.Price)), + Currency: &sub.Currency, + }, nil +} + +func (s *Store) GetUserSubscriptionHistory(ctx context.Context, userID int64, limit, offset int32) ([]domain.UserSubscription, error) { + subs, err := s.queries.GetUserSubscriptionHistory(ctx, dbgen.GetUserSubscriptionHistoryParams{ + UserID: userID, + Limit: pgtype.Int4{Int32: limit, Valid: true}, + Offset: pgtype.Int4{Int32: offset, Valid: true}, + }) + if err != nil { + return nil, err + } + + result := make([]domain.UserSubscription, len(subs)) + for i, sub := range subs { + result[i] = domain.UserSubscription{ + ID: sub.ID, + UserID: sub.UserID, + PlanID: sub.PlanID, + StartsAt: sub.StartsAt.Time, + ExpiresAt: sub.ExpiresAt.Time, + Status: sub.Status, + PaymentReference: fromPgText(sub.PaymentReference), + PaymentMethod: fromPgText(sub.PaymentMethod), + AutoRenew: sub.AutoRenew, + CancelledAt: timePtr(sub.CancelledAt), + CreatedAt: sub.CreatedAt.Time, + UpdatedAt: timePtr(sub.UpdatedAt), + PlanName: &sub.PlanName, + DurationValue: &sub.DurationValue, + DurationUnit: &sub.DurationUnit, + Price: float64Ptr(fromPgNumeric(sub.Price)), + Currency: &sub.Currency, + } + } + return result, nil +} + +func (s *Store) HasActiveSubscription(ctx context.Context, userID int64) (bool, error) { + return s.queries.HasActiveSubscription(ctx, userID) +} + +func (s *Store) CancelUserSubscription(ctx context.Context, id int64) error { + return s.queries.CancelUserSubscription(ctx, id) +} + +func (s *Store) UpdateSubscriptionStatus(ctx context.Context, id int64, status string) error { + return s.queries.UpdateUserSubscriptionStatus(ctx, dbgen.UpdateUserSubscriptionStatusParams{ + Status: status, + ID: id, + }) +} + +func (s *Store) UpdateAutoRenew(ctx context.Context, id int64, autoRenew bool) error { + return s.queries.UpdateAutoRenew(ctx, dbgen.UpdateAutoRenewParams{ + AutoRenew: autoRenew, + ID: id, + }) +} + +func (s *Store) ExtendSubscription(ctx context.Context, id int64, newExpiresAt time.Time) error { + return s.queries.ExtendSubscription(ctx, dbgen.ExtendSubscriptionParams{ + ExpiresAt: toPgTimestamptz(newExpiresAt), + ID: id, + }) +} + +// Helper conversion functions + +func subscriptionPlanToDomain(p dbgen.SubscriptionPlan) *domain.SubscriptionPlan { + return &domain.SubscriptionPlan{ + ID: p.ID, + Name: p.Name, + Description: fromPgText(p.Description), + DurationValue: p.DurationValue, + DurationUnit: p.DurationUnit, + Price: fromPgNumeric(p.Price), + Currency: p.Currency, + IsActive: p.IsActive, + CreatedAt: p.CreatedAt.Time, + UpdatedAt: timePtr(p.UpdatedAt), + } +} + +func userSubscriptionToDomain(s dbgen.UserSubscription) *domain.UserSubscription { + return &domain.UserSubscription{ + ID: s.ID, + UserID: s.UserID, + PlanID: s.PlanID, + StartsAt: s.StartsAt.Time, + ExpiresAt: s.ExpiresAt.Time, + Status: s.Status, + PaymentReference: fromPgText(s.PaymentReference), + PaymentMethod: fromPgText(s.PaymentMethod), + AutoRenew: s.AutoRenew, + CancelledAt: timePtr(s.CancelledAt), + CreatedAt: s.CreatedAt.Time, + UpdatedAt: timePtr(s.UpdatedAt), + } +} + +func userSubscriptionWithPlanToDomain(s dbgen.GetUserSubscriptionByIDRow) *domain.UserSubscription { + return &domain.UserSubscription{ + ID: s.ID, + UserID: s.UserID, + PlanID: s.PlanID, + StartsAt: s.StartsAt.Time, + ExpiresAt: s.ExpiresAt.Time, + Status: s.Status, + PaymentReference: fromPgText(s.PaymentReference), + PaymentMethod: fromPgText(s.PaymentMethod), + AutoRenew: s.AutoRenew, + CancelledAt: timePtr(s.CancelledAt), + CreatedAt: s.CreatedAt.Time, + UpdatedAt: timePtr(s.UpdatedAt), + PlanName: &s.PlanName, + DurationValue: &s.DurationValue, + DurationUnit: &s.DurationUnit, + Price: float64Ptr(fromPgNumeric(s.Price)), + Currency: &s.Currency, + } +} + +func stringVal(s *string) string { + if s == nil { + return "" + } + return *s +} + +func int32Val(i *int32) int32 { + if i == nil { + return 0 + } + return *i +} + +func numericPtrToNumeric(val *float64) pgtype.Numeric { + if val == nil { + return pgtype.Numeric{Valid: false} + } + return toPgNumeric(*val) +} + +func boolPtrToBool(b *bool) bool { + if b == nil { + return false + } + return *b +} + +func float64Ptr(f float64) *float64 { + return &f +} diff --git a/internal/repository/team.go b/internal/repository/team.go new file mode 100644 index 0000000..4a71c78 --- /dev/null +++ b/internal/repository/team.go @@ -0,0 +1,457 @@ +package repository + +import ( + "context" + "encoding/json" + "errors" + "time" + + dbgen "Yimaru-Backend/gen/db" + "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/ports" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +func NewTeamStore(s *Store) ports.TeamStore { return s } + +func (s *Store) CreateTeamMember(ctx context.Context, member domain.TeamMember) (domain.TeamMember, error) { + var permissionsJSON []byte + if len(member.Permissions) > 0 { + var err error + permissionsJSON, err = json.Marshal(member.Permissions) + if err != nil { + return domain.TeamMember{}, err + } + } + + var hireDate pgtype.Date + if member.HireDate != nil { + hireDate = pgtype.Date{Time: *member.HireDate, Valid: true} + } + + var createdBy pgtype.Int8 + if member.CreatedBy != nil { + createdBy = pgtype.Int8{Int64: *member.CreatedBy, Valid: true} + } + + res, err := s.queries.CreateTeamMember(ctx, dbgen.CreateTeamMemberParams{ + FirstName: member.FirstName, + LastName: member.LastName, + Email: member.Email, + PhoneNumber: pgtype.Text{String: member.PhoneNumber, Valid: member.PhoneNumber != ""}, + Password: member.Password, + TeamRole: string(member.TeamRole), + Department: pgtype.Text{String: member.Department, Valid: member.Department != ""}, + JobTitle: pgtype.Text{String: member.JobTitle, Valid: member.JobTitle != ""}, + EmploymentType: pgtype.Text{String: string(member.EmploymentType), Valid: member.EmploymentType != ""}, + HireDate: hireDate, + ProfilePictureUrl: pgtype.Text{String: member.ProfilePictureURL, Valid: member.ProfilePictureURL != ""}, + Bio: pgtype.Text{String: member.Bio, Valid: member.Bio != ""}, + WorkPhone: pgtype.Text{String: member.WorkPhone, Valid: member.WorkPhone != ""}, + EmergencyContact: pgtype.Text{String: member.EmergencyContact, Valid: member.EmergencyContact != ""}, + Status: string(member.Status), + EmailVerified: member.EmailVerified, + Permissions: permissionsJSON, + CreatedBy: createdBy, + }) + if err != nil { + return domain.TeamMember{}, err + } + + return mapDBTeamMember(res), nil +} + +func (s *Store) GetTeamMemberByID(ctx context.Context, id int64) (domain.TeamMember, error) { + res, err := s.queries.GetTeamMemberByID(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.TeamMember{}, domain.ErrTeamMemberNotFound + } + return domain.TeamMember{}, err + } + return mapDBTeamMember(res), nil +} + +func (s *Store) GetTeamMemberByEmail(ctx context.Context, email string) (domain.TeamMember, error) { + res, err := s.queries.GetTeamMemberByEmail(ctx, email) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.TeamMember{}, domain.ErrTeamMemberNotFound + } + return domain.TeamMember{}, err + } + return mapDBTeamMember(res), nil +} + +func (s *Store) GetAllTeamMembers( + ctx context.Context, + teamRole, department, status *string, + limit, offset int32, +) ([]domain.TeamMember, int64, error) { + var teamRoleParam, departmentParam, statusParam pgtype.Text + if teamRole != nil { + teamRoleParam = pgtype.Text{String: *teamRole, Valid: true} + } + if department != nil { + departmentParam = pgtype.Text{String: *department, Valid: true} + } + if status != nil { + statusParam = pgtype.Text{String: *status, Valid: true} + } + + rows, err := s.queries.GetAllTeamMembers(ctx, dbgen.GetAllTeamMembersParams{ + TeamRole: teamRoleParam, + Department: departmentParam, + Status: statusParam, + Limit: pgtype.Int4{Int32: limit, Valid: true}, + Offset: pgtype.Int4{Int32: offset, Valid: true}, + }) + if err != nil { + return nil, 0, err + } + + if len(rows) == 0 { + return []domain.TeamMember{}, 0, nil + } + + var totalCount int64 + members := make([]domain.TeamMember, len(rows)) + for i, row := range rows { + if i == 0 { + totalCount = row.TotalCount + } + members[i] = mapGetAllTeamMembersRow(row) + } + + return members, totalCount, nil +} + +func (s *Store) SearchTeamMembers( + ctx context.Context, + search string, + teamRole, status *string, +) ([]domain.TeamMember, error) { + var teamRoleParam, statusParam pgtype.Text + if teamRole != nil { + teamRoleParam = pgtype.Text{String: *teamRole, Valid: true} + } + if status != nil { + statusParam = pgtype.Text{String: *status, Valid: true} + } + + rows, err := s.queries.SearchTeamMembers(ctx, dbgen.SearchTeamMembersParams{ + Column1: pgtype.Text{String: search, Valid: true}, + TeamRole: teamRoleParam, + Status: statusParam, + }) + if err != nil { + return nil, err + } + + members := make([]domain.TeamMember, len(rows)) + for i, row := range rows { + members[i] = mapSearchTeamMembersRow(row) + } + + return members, nil +} + +func (s *Store) UpdateTeamMember(ctx context.Context, req domain.UpdateTeamMemberReq) error { + var permissionsJSON []byte + if len(req.Permissions) > 0 { + var err error + permissionsJSON, err = json.Marshal(req.Permissions) + if err != nil { + return err + } + } + + var hireDate pgtype.Date + if req.HireDate != "" { + t, err := parseDate(req.HireDate) + if err != nil { + return err + } + hireDate = pgtype.Date{Time: t, Valid: true} + } + + return s.queries.UpdateTeamMember(ctx, dbgen.UpdateTeamMemberParams{ + FirstName: req.FirstName, + LastName: req.LastName, + PhoneNumber: pgtype.Text{String: req.PhoneNumber, Valid: req.PhoneNumber != ""}, + TeamRole: req.TeamRole, + Department: pgtype.Text{String: req.Department, Valid: req.Department != ""}, + JobTitle: pgtype.Text{String: req.JobTitle, Valid: req.JobTitle != ""}, + EmploymentType: pgtype.Text{String: req.EmploymentType, Valid: req.EmploymentType != ""}, + HireDate: hireDate, + ProfilePictureUrl: pgtype.Text{String: req.ProfilePictureURL, Valid: req.ProfilePictureURL != ""}, + Bio: pgtype.Text{String: req.Bio, Valid: req.Bio != ""}, + WorkPhone: pgtype.Text{String: req.WorkPhone, Valid: req.WorkPhone != ""}, + EmergencyContact: pgtype.Text{String: req.EmergencyContact, Valid: req.EmergencyContact != ""}, + Permissions: permissionsJSON, + UpdatedBy: pgtype.Int8{Int64: req.UpdatedBy, Valid: req.UpdatedBy > 0}, + ID: req.TeamMemberID, + }) +} + +func (s *Store) UpdateTeamMemberStatus(ctx context.Context, req domain.UpdateTeamMemberStatusReq) error { + return s.queries.UpdateTeamMemberStatus(ctx, dbgen.UpdateTeamMemberStatusParams{ + Status: req.Status, + UpdatedBy: pgtype.Int8{Int64: req.UpdatedBy, Valid: req.UpdatedBy > 0}, + ID: req.TeamMemberID, + }) +} + +func (s *Store) UpdateTeamMemberPassword(ctx context.Context, memberID int64, password string) error { + return s.queries.UpdateTeamMemberPassword(ctx, dbgen.UpdateTeamMemberPasswordParams{ + Password: []byte(password), + ID: memberID, + }) +} + +func (s *Store) UpdateTeamMemberLastLogin(ctx context.Context, memberID int64) error { + return s.queries.UpdateTeamMemberLastLogin(ctx, memberID) +} + +func (s *Store) DeleteTeamMember(ctx context.Context, memberID int64) error { + return s.queries.DeleteTeamMember(ctx, memberID) +} + +func (s *Store) CheckTeamMemberEmailExists(ctx context.Context, email string) (bool, error) { + return s.queries.CheckTeamMemberEmailExists(ctx, email) +} + +func (s *Store) GetTeamMembersByDepartment(ctx context.Context, department string) ([]domain.TeamMember, error) { + rows, err := s.queries.GetTeamMembersByDepartment(ctx, pgtype.Text{String: department, Valid: true}) + if err != nil { + return nil, err + } + + members := make([]domain.TeamMember, len(rows)) + for i, row := range rows { + members[i] = mapGetTeamMembersByDepartmentRow(row) + } + + return members, nil +} + +func (s *Store) GetTeamMembersByRole(ctx context.Context, role string) ([]domain.TeamMember, error) { + rows, err := s.queries.GetTeamMembersByRole(ctx, role) + if err != nil { + return nil, err + } + + members := make([]domain.TeamMember, len(rows)) + for i, row := range rows { + members[i] = mapGetTeamMembersByRoleRow(row) + } + + return members, nil +} + +func (s *Store) CountTeamMembersByStatus(ctx context.Context) (domain.TeamMemberStats, error) { + res, err := s.queries.CountTeamMembersByStatus(ctx) + if err != nil { + return domain.TeamMemberStats{}, err + } + + return domain.TeamMemberStats{ + ActiveCount: res.ActiveCount, + InactiveCount: res.InactiveCount, + SuspendedCount: res.SuspendedCount, + TerminatedCount: res.TerminatedCount, + TotalCount: res.TotalCount, + }, nil +} + +func (s *Store) UpdateTeamMemberEmailVerified(ctx context.Context, memberID int64, verified bool) error { + return s.queries.UpdateTeamMemberEmailVerified(ctx, dbgen.UpdateTeamMemberEmailVerifiedParams{ + EmailVerified: verified, + ID: memberID, + }) +} + +func mapDBTeamMember(m dbgen.TeamMember) domain.TeamMember { + var permissions []string + if len(m.Permissions) > 0 { + _ = json.Unmarshal(m.Permissions, &permissions) + } + + var hireDate *time.Time + if m.HireDate.Valid { + hireDate = &m.HireDate.Time + } + + var lastLogin *time.Time + if m.LastLogin.Valid { + lastLogin = &m.LastLogin.Time + } + + var createdBy *int64 + if m.CreatedBy.Valid { + createdBy = &m.CreatedBy.Int64 + } + + var updatedBy *int64 + if m.UpdatedBy.Valid { + updatedBy = &m.UpdatedBy.Int64 + } + + var updatedAt *time.Time + if m.UpdatedAt.Valid { + updatedAt = &m.UpdatedAt.Time + } + + return domain.TeamMember{ + ID: m.ID, + FirstName: m.FirstName, + LastName: m.LastName, + Email: m.Email, + PhoneNumber: m.PhoneNumber.String, + Password: m.Password, + TeamRole: domain.TeamRole(m.TeamRole), + Department: m.Department.String, + JobTitle: m.JobTitle.String, + EmploymentType: domain.EmploymentType(m.EmploymentType.String), + HireDate: hireDate, + ProfilePictureURL: m.ProfilePictureUrl.String, + Bio: m.Bio.String, + WorkPhone: m.WorkPhone.String, + EmergencyContact: m.EmergencyContact.String, + Status: domain.TeamMemberStatus(m.Status), + EmailVerified: m.EmailVerified, + Permissions: permissions, + LastLogin: lastLogin, + CreatedBy: createdBy, + UpdatedBy: updatedBy, + CreatedAt: m.CreatedAt.Time, + UpdatedAt: updatedAt, + } +} + +func mapGetAllTeamMembersRow(row dbgen.GetAllTeamMembersRow) domain.TeamMember { + var permissions []string + if len(row.Permissions) > 0 { + _ = json.Unmarshal(row.Permissions, &permissions) + } + + var hireDate *time.Time + if row.HireDate.Valid { + hireDate = &row.HireDate.Time + } + + var lastLogin *time.Time + if row.LastLogin.Valid { + lastLogin = &row.LastLogin.Time + } + + var updatedAt *time.Time + if row.UpdatedAt.Valid { + updatedAt = &row.UpdatedAt.Time + } + + return domain.TeamMember{ + ID: row.ID, + FirstName: row.FirstName, + LastName: row.LastName, + Email: row.Email, + PhoneNumber: row.PhoneNumber.String, + TeamRole: domain.TeamRole(row.TeamRole), + Department: row.Department.String, + JobTitle: row.JobTitle.String, + EmploymentType: domain.EmploymentType(row.EmploymentType.String), + HireDate: hireDate, + ProfilePictureURL: row.ProfilePictureUrl.String, + Bio: row.Bio.String, + WorkPhone: row.WorkPhone.String, + Status: domain.TeamMemberStatus(row.Status), + EmailVerified: row.EmailVerified, + Permissions: permissions, + LastLogin: lastLogin, + CreatedAt: row.CreatedAt.Time, + UpdatedAt: updatedAt, + } +} + +func mapSearchTeamMembersRow(row dbgen.SearchTeamMembersRow) domain.TeamMember { + var permissions []string + if len(row.Permissions) > 0 { + _ = json.Unmarshal(row.Permissions, &permissions) + } + + var hireDate *time.Time + if row.HireDate.Valid { + hireDate = &row.HireDate.Time + } + + var lastLogin *time.Time + if row.LastLogin.Valid { + lastLogin = &row.LastLogin.Time + } + + var updatedAt *time.Time + if row.UpdatedAt.Valid { + updatedAt = &row.UpdatedAt.Time + } + + return domain.TeamMember{ + ID: row.ID, + FirstName: row.FirstName, + LastName: row.LastName, + Email: row.Email, + PhoneNumber: row.PhoneNumber.String, + TeamRole: domain.TeamRole(row.TeamRole), + Department: row.Department.String, + JobTitle: row.JobTitle.String, + EmploymentType: domain.EmploymentType(row.EmploymentType.String), + HireDate: hireDate, + ProfilePictureURL: row.ProfilePictureUrl.String, + Bio: row.Bio.String, + Status: domain.TeamMemberStatus(row.Status), + EmailVerified: row.EmailVerified, + Permissions: permissions, + LastLogin: lastLogin, + CreatedAt: row.CreatedAt.Time, + UpdatedAt: updatedAt, + } +} + +func mapGetTeamMembersByDepartmentRow(row dbgen.GetTeamMembersByDepartmentRow) domain.TeamMember { + return domain.TeamMember{ + ID: row.ID, + FirstName: row.FirstName, + LastName: row.LastName, + Email: row.Email, + PhoneNumber: row.PhoneNumber.String, + TeamRole: domain.TeamRole(row.TeamRole), + Department: row.Department.String, + JobTitle: row.JobTitle.String, + EmploymentType: domain.EmploymentType(row.EmploymentType.String), + ProfilePictureURL: row.ProfilePictureUrl.String, + Status: domain.TeamMemberStatus(row.Status), + CreatedAt: row.CreatedAt.Time, + } +} + +func mapGetTeamMembersByRoleRow(row dbgen.GetTeamMembersByRoleRow) domain.TeamMember { + return domain.TeamMember{ + ID: row.ID, + FirstName: row.FirstName, + LastName: row.LastName, + Email: row.Email, + PhoneNumber: row.PhoneNumber.String, + TeamRole: domain.TeamRole(row.TeamRole), + Department: row.Department.String, + JobTitle: row.JobTitle.String, + EmploymentType: domain.EmploymentType(row.EmploymentType.String), + ProfilePictureURL: row.ProfilePictureUrl.String, + Status: domain.TeamMemberStatus(row.Status), + CreatedAt: row.CreatedAt.Time, + } +} + +func parseDate(dateStr string) (time.Time, error) { + return time.Parse("2006-01-02", dateStr) +} diff --git a/internal/repository/user.go b/internal/repository/user.go index a4f179c..acb4779 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -24,11 +24,22 @@ func (s *Store) RegisterDevice(ctx context.Context, userID int64, deviceToken, p _, err := s.queries.CreateDevice(ctx, dbgen.CreateDeviceParams{ UserID: userID, DeviceToken: deviceToken, - Platform: pgtype.Text{String: platform}, + Platform: pgtype.Text{String: platform, Valid: platform != ""}, }) return err } +func (s *Store) DeactivateDevice(ctx context.Context, userID int64, deviceToken string) error { + return s.queries.DeactivateDeviceByToken(ctx, dbgen.DeactivateDeviceByTokenParams{ + UserID: userID, + DeviceToken: deviceToken, + }) +} + +func (s *Store) DeactivateAllUserDevices(ctx context.Context, userID int64) error { + return s.queries.DeactivateUserDevices(ctx, userID) +} + func (s *Store) LinkGoogleAccount( ctx context.Context, userID int64, @@ -66,15 +77,18 @@ func (s *Store) CreateGoogleUser( return mapDBUser(res, nil, nil), nil } -func (s *Store) IsProfileCompleted(ctx context.Context, userId int64) (bool, error) { - IsProfileCompleted, err := s.queries.IsProfileCompleted(ctx, userId) +func (s *Store) GetProfileCompletionStatus(ctx context.Context, userId int64) (ports.ProfileCompletionStatus, error) { + result, err := s.queries.GetProfileCompletionStatus(ctx, userId) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return false, authentication.ErrUserNotFound + return ports.ProfileCompletionStatus{}, authentication.ErrUserNotFound } - return false, err + return ports.ProfileCompletionStatus{}, err } - return IsProfileCompleted, nil + return ports.ProfileCompletionStatus{ + IsCompleted: result.ProfileCompleted.Bool, + Percentage: int(result.ProfileCompletionPercentage), + }, nil } func (s *Store) UpdateUserKnowledgeLevel(ctx context.Context, userID int64, knowledgeLevel string) error { @@ -320,10 +334,11 @@ func (s *Store) GetUserByID( PhoneVerified: u.PhoneVerified, Status: domain.UserStatus(u.Status), - LastLogin: lastLogin, - ProfileCompleted: u.ProfileCompleted.Bool, - ProfilePictureURL: u.ProfilePictureUrl.String, - PreferredLanguage: u.PreferredLanguage.String, + LastLogin: lastLogin, + ProfileCompleted: u.ProfileCompleted.Bool, + ProfileCompletionPercentage: int(u.ProfileCompletionPercentage), + ProfilePictureURL: u.ProfilePictureUrl.String, + PreferredLanguage: u.PreferredLanguage.String, CreatedAt: u.CreatedAt.Time, UpdatedAt: updatedAt, @@ -635,7 +650,6 @@ func (s *Store) UpdateUser( FavouriteTopic: pgtype.Text{String: req.FavouriteTopic, Valid: req.FavouriteTopic != ""}, InitialAssessmentCompleted: req.InitialAssessmentCompleted, - ProfileCompleted: pgtype.Bool{Bool: req.ProfileCompleted, Valid: true}, ProfilePictureUrl: pgtype.Text{String: req.ProfilePictureURL, Valid: req.ProfilePictureURL != ""}, PreferredLanguage: pgtype.Text{String: req.PreferredLanguage, Valid: req.PreferredLanguage != ""}, @@ -683,8 +697,8 @@ func (s *Store) GetUserByEmailPhone( }, }) if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return domain.User{}, authentication.ErrUserNotFound + if errors.Is(err, sql.ErrNoRows) || errors.Is(err, pgx.ErrNoRows) { + return domain.User{}, domain.ErrUserNotFound } return domain.User{}, err } diff --git a/internal/services/arifpay/service.go b/internal/services/arifpay/service.go index fe6c545..3b75f7d 100644 --- a/internal/services/arifpay/service.go +++ b/internal/services/arifpay/service.go @@ -4,50 +4,354 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" "strings" + "time" "Yimaru-Backend/internal/config" "Yimaru-Backend/internal/domain" - "Yimaru-Backend/internal/services/transaction" + "Yimaru-Backend/internal/ports" "github.com/google/uuid" ) +var ( + ErrPaymentNotFound = errors.New("payment not found") + ErrPaymentAlreadyPaid = errors.New("payment already processed") + ErrPaymentExpired = errors.New("payment has expired") + ErrInvalidPaymentState = errors.New("invalid payment state") +) + type ArifpayService struct { - cfg *config.Config - transactionSvc transaction.Service - httpClient *http.Client + cfg *config.Config + httpClient *http.Client + paymentStore ports.PaymentStore + subscriptionStore ports.SubscriptionStore } -func NewArifpayService(cfg *config.Config, transactionSvc transaction.Service, httpClient *http.Client) *ArifpayService { +func NewArifpayService( + cfg *config.Config, + httpClient *http.Client, + paymentStore ports.PaymentStore, + subscriptionStore ports.SubscriptionStore, +) *ArifpayService { return &ArifpayService{ - cfg: cfg, - transactionSvc: transactionSvc, - httpClient: httpClient, + cfg: cfg, + httpClient: httpClient, + paymentStore: paymentStore, + subscriptionStore: subscriptionStore, } } +// InitiateSubscriptionPayment creates a payment session for a subscription plan +func (s *ArifpayService) InitiateSubscriptionPayment(ctx context.Context, userID int64, req domain.InitiateSubscriptionPaymentRequest) (*domain.InitiateSubscriptionPaymentResponse, error) { + // Get the subscription plan + plan, err := s.subscriptionStore.GetSubscriptionPlanByID(ctx, req.PlanID) + if err != nil { + return nil, fmt.Errorf("failed to get subscription plan: %w", err) + } + + if !plan.IsActive { + return nil, errors.New("subscription plan is not active") + } + + // Check if user already has an active subscription + hasActive, err := s.subscriptionStore.HasActiveSubscription(ctx, userID) + if err != nil { + return nil, fmt.Errorf("failed to check active subscription: %w", err) + } + if hasActive { + return nil, errors.New("user already has an active subscription") + } -func (s *ArifpayService) CreateCheckoutSession(req domain.CheckoutSessionClientRequest, isDeposit bool, userId int64) (map[string]any, error) { // Generate unique nonce nonce := uuid.NewString() + expiresAt := time.Now().Add(3 * time.Hour) + + // Create payment record + payment, err := s.paymentStore.CreatePayment(ctx, domain.CreatePaymentInput{ + UserID: userID, + PlanID: &req.PlanID, + Amount: plan.Price, + Currency: plan.Currency, + Nonce: nonce, + ExpiresAt: &expiresAt, + }) + if err != nil { + return nil, fmt.Errorf("failed to create payment record: %w", err) + } + + // Create ArifPay checkout session + checkoutReq := domain.CheckoutSessionRequest{ + CancelURL: s.cfg.ARIFPAY.CancelUrl, + Phone: formatPhone(req.Phone), + Email: req.Email, + Nonce: nonce, + SuccessURL: s.cfg.ARIFPAY.SuccessUrl, + ErrorURL: s.cfg.ARIFPAY.ErrorUrl, + NotifyURL: s.cfg.ARIFPAY.C2BNotifyUrl, + PaymentMethods: s.cfg.ARIFPAY.PaymentMethods, + ExpireDate: expiresAt.Format("2006-01-02T15:04:05"), + Items: []struct { + Name string `json:"name"` + Quantity int `json:"quantity"` + Price float64 `json:"price"` + Description string `json:"description"` + }{ + { + Name: plan.Name, + Quantity: 1, + Price: plan.Price, + Description: fmt.Sprintf("Subscription: %s", plan.Name), + }, + }, + Beneficiaries: []struct { + AccountNumber string `json:"accountNumber"` + Bank string `json:"bank"` + Amount float64 `json:"amount"` + }{ + { + AccountNumber: s.cfg.ARIFPAY.BeneficiaryAccountNumber, + Bank: s.cfg.ARIFPAY.Bank, + Amount: plan.Price, + }, + }, + Lang: s.cfg.ARIFPAY.Lang, + } + + // Marshal to JSON + payload, err := json.Marshal(checkoutReq) + if err != nil { + return nil, fmt.Errorf("failed to marshal checkout request: %w", err) + } + + // Send request to Arifpay API + url := fmt.Sprintf("%s/api/checkout/session", s.cfg.ARIFPAY.BaseURL) + httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(payload)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("x-arifpay-key", s.cfg.ARIFPAY.APIKey) + + resp, err := s.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to call ArifPay API: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ArifPay API error: %s", string(body)) + } + + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("invalid response from ArifPay: %w", err) + } + + data, ok := result["data"].(map[string]interface{}) + if !ok { + return nil, errors.New("invalid response structure from ArifPay") + } + + sessionID := fmt.Sprintf("%v", data["sessionId"]) + paymentURL := fmt.Sprintf("%v", data["paymentUrl"]) + + // Update payment with session info + if err := s.paymentStore.UpdatePaymentSessionID(ctx, payment.ID, sessionID, paymentURL); err != nil { + return nil, fmt.Errorf("failed to update payment session: %w", err) + } + + return &domain.InitiateSubscriptionPaymentResponse{ + PaymentID: payment.ID, + SessionID: sessionID, + PaymentURL: paymentURL, + Amount: plan.Price, + Currency: plan.Currency, + ExpiresAt: expiresAt.Format(time.RFC3339), + }, nil +} + +// ProcessPaymentWebhook handles the webhook callback from ArifPay +func (s *ArifpayService) ProcessPaymentWebhook(ctx context.Context, req domain.WebhookRequest) error { + // Get payment by nonce + payment, err := s.paymentStore.GetPaymentByNonce(ctx, req.Nonce) + if err != nil { + return fmt.Errorf("payment not found for nonce %s: %w", req.Nonce, err) + } + + if payment.Status == string(domain.PaymentStatusSuccess) { + return ErrPaymentAlreadyPaid + } + + transactionStatus := strings.ToUpper(req.Transaction.TransactionStatus) + if transactionStatus == "" { + transactionStatus = strings.ToUpper(req.TransactionStatus) + } + + var newStatus string + switch transactionStatus { + case "SUCCESS", "COMPLETED": + newStatus = string(domain.PaymentStatusSuccess) + case "FAILED", "REJECTED": + newStatus = string(domain.PaymentStatusFailed) + case "CANCELLED": + newStatus = string(domain.PaymentStatusCancelled) + case "PENDING", "PROCESSING": + newStatus = string(domain.PaymentStatusProcessing) + default: + newStatus = string(domain.PaymentStatusPending) + } + + // Update payment status + if err := s.paymentStore.UpdatePaymentStatusByNonce( + ctx, + req.Nonce, + newStatus, + req.Transaction.TransactionID, + req.PaymentMethod, + ); err != nil { + return fmt.Errorf("failed to update payment status: %w", err) + } + + // If payment succeeded, create the subscription + if newStatus == string(domain.PaymentStatusSuccess) && payment.PlanID != nil { + plan, err := s.subscriptionStore.GetSubscriptionPlanByID(ctx, *payment.PlanID) + if err != nil { + return fmt.Errorf("failed to get subscription plan: %w", err) + } + + startsAt := time.Now() + expiresAt := domain.CalculateExpiryDate(startsAt, plan.DurationValue, plan.DurationUnit) + activeStatus := string(domain.SubscriptionStatusActive) + autoRenew := false + paymentRef := payment.Nonce + paymentMethod := req.PaymentMethod + + subscription, err := s.subscriptionStore.CreateUserSubscription(ctx, domain.CreateUserSubscriptionInput{ + UserID: payment.UserID, + PlanID: *payment.PlanID, + StartsAt: &startsAt, + ExpiresAt: expiresAt, + Status: &activeStatus, + PaymentReference: &paymentRef, + PaymentMethod: &paymentMethod, + AutoRenew: &autoRenew, + }) + if err != nil { + return fmt.Errorf("failed to create subscription: %w", err) + } + + // Link payment to subscription + if err := s.paymentStore.LinkPaymentToSubscription(ctx, payment.ID, subscription.ID); err != nil { + return fmt.Errorf("failed to link payment to subscription: %w", err) + } + } + + return nil +} + +// VerifyPayment checks the status of a payment with ArifPay +func (s *ArifpayService) VerifyPayment(ctx context.Context, sessionID string) (*domain.Payment, error) { + // Get local payment record + payment, err := s.paymentStore.GetPaymentBySessionID(ctx, sessionID) + if err != nil { + return nil, ErrPaymentNotFound + } + + // If already success or failed, return cached result + if payment.Status == string(domain.PaymentStatusSuccess) || + payment.Status == string(domain.PaymentStatusFailed) { + return payment, nil + } + + // Call ArifPay to verify + endpoint := fmt.Sprintf("%s/api/ms/transaction/status/%s", s.cfg.ARIFPAY.BaseURL, sessionID) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + httpReq.Header.Set("x-arifpay-key", s.cfg.ARIFPAY.APIKey) + + resp, err := s.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to verify with ArifPay: %w", err) + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ArifPay verify API error: %s", string(respBytes)) + } + + var result domain.WebhookRequest + if err := json.Unmarshal(respBytes, &result); err != nil { + return nil, fmt.Errorf("failed to parse ArifPay response: %w", err) + } + + // Process the verification result same as webhook + if err := s.ProcessPaymentWebhook(ctx, result); err != nil && err != ErrPaymentAlreadyPaid { + return nil, err + } + + // Return updated payment + return s.paymentStore.GetPaymentBySessionID(ctx, sessionID) +} + +// GetPaymentsByUser returns payment history for a user +func (s *ArifpayService) GetPaymentsByUser(ctx context.Context, userID int64, limit, offset int32) ([]domain.Payment, error) { + return s.paymentStore.GetPaymentsByUserID(ctx, userID, limit, offset) +} + +// GetPaymentByID returns a specific payment +func (s *ArifpayService) GetPaymentByID(ctx context.Context, id int64) (*domain.Payment, error) { + return s.paymentStore.GetPaymentByID(ctx, id) +} + +// CancelPayment cancels a pending payment +func (s *ArifpayService) CancelPayment(ctx context.Context, paymentID int64, userID int64) error { + payment, err := s.paymentStore.GetPaymentByID(ctx, paymentID) + if err != nil { + return ErrPaymentNotFound + } + + if payment.UserID != userID { + return errors.New("unauthorized") + } + + if payment.Status != string(domain.PaymentStatusPending) { + return ErrInvalidPaymentState + } + + return s.paymentStore.UpdatePaymentStatus(ctx, paymentID, string(domain.PaymentStatusCancelled)) +} + +// CreateCheckoutSession creates a generic checkout session (for non-subscription payments) +func (s *ArifpayService) CreateCheckoutSession(req domain.CheckoutSessionClientRequest, isDeposit bool, userId int64) (map[string]any, error) { + nonce := uuid.NewString() var NotifyURL string - if isDeposit { NotifyURL = s.cfg.ARIFPAY.C2BNotifyUrl } else { NotifyURL = s.cfg.ARIFPAY.B2CNotifyUrl } - - // Construct full checkout request checkoutReq := domain.CheckoutSessionRequest{ CancelURL: s.cfg.ARIFPAY.CancelUrl, - Phone: req.CustomerPhone, // must be in format 2519... + Phone: formatPhone(req.CustomerPhone), Email: req.CustomerEmail, Nonce: nonce, SuccessURL: s.cfg.ARIFPAY.SuccessUrl, @@ -82,13 +386,11 @@ func (s *ArifpayService) CreateCheckoutSession(req domain.CheckoutSessionClientR Lang: s.cfg.ARIFPAY.Lang, } - // Marshal to JSON payload, err := json.Marshal(checkoutReq) if err != nil { return nil, fmt.Errorf("failed to marshal checkout request: %w", err) } - // Send request to Arifpay API url := fmt.Sprintf("%s/api/checkout/session", s.cfg.ARIFPAY.BaseURL) httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(payload)) if err != nil { @@ -103,7 +405,6 @@ func (s *ArifpayService) CreateCheckoutSession(req domain.CheckoutSessionClientR } defer resp.Body.Close() - // Read response body, err := io.ReadAll(resp.Body) if err != nil { return nil, err @@ -113,69 +414,42 @@ func (s *ArifpayService) CreateCheckoutSession(req domain.CheckoutSessionClientR return nil, fmt.Errorf("failed to create checkout session: %s", string(body)) } - // Optionally unmarshal response to struct var result map[string]interface{} if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("invalid response from Arifpay: %w", err) } data := result["data"].(map[string]interface{}) - // paymentURL := data["paymentUrl"].(string) - - // Store transfer in DB - // transfer := domain.CreateTransfer{ - // Amount: domain.Currency(req.Amount), - // Verified: false, - // Type: domain.DEPOSIT, - // ReferenceNumber: nonce, - // SessionID: fmt.Sprintf("%v", data["sessionId"]), - // Status: string(domain.PaymentStatusPending), - // CashierID: domain.ValidInt64{ - // Value: userId, - // Valid: true, - // }, - // } - - // if _, err := s.transactionSvc(context.Background(), transfer); err != nil { - // return nil, err - // } - return data, nil } +// CancelCheckoutSession cancels an existing checkout session func (s *ArifpayService) CancelCheckoutSession(ctx context.Context, sessionID string) (any, error) { - // Build the cancel URL url := fmt.Sprintf("%s/api/sandbox/checkout/session/%s", s.cfg.ARIFPAY.BaseURL, sessionID) - // Create the request req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } - // Add headers req.Header.Set("Content-Type", "application/json") req.Header.Set("x-arifpay-key", s.cfg.ARIFPAY.APIKey) - // Execute request resp, err := s.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to execute cancel request: %w", err) } defer resp.Body.Close() - // Read response body body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read cancel response: %w", err) } - // Handle non-200 status codes if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("cancel request failed: status=%d, body=%s", resp.StatusCode, string(body)) } - // Decode into response struct var cancelResp domain.CancelCheckoutSessionResponse if err := json.Unmarshal(body, &cancelResp); err != nil { return nil, fmt.Errorf("failed to unmarshal cancel response: %w", err) @@ -184,426 +458,49 @@ func (s *ArifpayService) CancelCheckoutSession(ctx context.Context, sessionID st return cancelResp.Data, nil } -func (s *ArifpayService) ProcessWebhook(ctx context.Context, req domain.WebhookRequest, isDeposit bool) error { - // 1. Get transfer by SessionID - // transfer, err := s.transactionSvc.GetTransferByReference(ctx, req.Nonce) - // if err != nil { - // return err - // } - - // userId := transfer.DepositorID.Value - - // wallet, err := s.walletSvc.GetCustomerWallet(ctx, userId) - // if err != nil { - // return err - // } - - // if transfer.Verified { - // return errors.New("transfer already verified") - // } - - // 2. Update transfer status - newStatus := strings.ToLower(req.Transaction.TransactionStatus) - // if req.Transaction.TransactionStatus != "" { - // newStatus = req.Transaction.TransactionStatus - // } - - // err = s.transferStore.UpdateTransferStatus(ctx, transfer.ID, newStatus) - // if err != nil { - // return err - // } - - // err = s.transferStore.UpdateTransferVerification(ctx, transfer.ID, true) - // if err != nil { - // return err - // } - - // 3. If SUCCESS -> update customer wallet balance - if (newStatus == "success" && isDeposit) || (newStatus == "failed" && !isDeposit) { - // _, err = s.walletSvc.AddToWallet(ctx, wallet.RegularID, domain.Currency(req.TotalAmount), domain.ValidInt64{}, transfer.PaymentMethod, domain.PaymentDetails{ - // ReferenceNumber: domain.ValidString{ - // Value: req.Nonce, - // Valid: true, - // }, - // BankNumber: domain.ValidString{ - // Value: "", - // Valid: false, - // }, - // }, "") - // if err != nil { - // return err - // } - // } +// VerifyTransactionBySessionID verifies a transaction by session ID +func (s *ArifpayService) VerifyTransactionBySessionID(ctx context.Context, sessionID string) (*domain.WebhookRequest, error) { + endpoint := fmt.Sprintf("%s/api/ms/transaction/status/%s", s.cfg.ARIFPAY.BaseURL, sessionID) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) } - return nil -} - -func (s *ArifpayService) ExecuteTelebirrB2CTransfer(ctx context.Context, req domain.CheckoutSessionClientRequest, userId int64) error { - // Step 1: Create Session - - // userWallet, err := s.walletSvc.GetCustomerWallet(ctx, userId) - // if err != nil { - // return fmt.Errorf("failed to get user wallets: %w", err) - // } - // if len(userWallets) == 0 { - // return fmt.Errorf("no wallet found for user %d", userId) - // } - - // _, err = s.walletSvc.DeductFromWallet( - // ctx, - // userWallet.RegularID, - // domain.Currency(req.Amount), - // domain.ValidInt64{}, - // domain.TRANSFER_ARIFPAY, - // "", - // ) - // if err != nil { - // return fmt.Errorf("failed to deduct from wallet: %w", err) - // } - - // referenceNum := uuid.NewString() - - sessionReq := domain.CheckoutSessionClientRequest{ - Amount: req.Amount, - CustomerEmail: req.CustomerEmail, - CustomerPhone: "251" + req.CustomerPhone[:9], - } - - sessionResp, err := s.CreateCheckoutSession(sessionReq, false, userId) - if err != nil { - // _, err = s.walletSvc.AddToWallet( - // ctx, - // userWallet.RegularID, - // domain.Currency(req.Amount), - // domain.ValidInt64{}, - // domain.TRANSFER_ARIFPAY, - // domain.PaymentDetails{}, - // "", - // ) - // if err != nil { - // return fmt.Errorf("failed to deduct from wallet: %w", err) - // } - return fmt.Errorf("failed to create session: %w", err) - } - - sessionRespData := sessionResp["data"].(map[string]any) - - // Step 2: Execute Transfer - transferURL := fmt.Sprintf("%s/api/Telebirr/b2c/transfer", s.cfg.ARIFPAY.BaseURL) - reqBody := map[string]any{ - "Sessionid": sessionRespData["sessionId"], - "Phonenumber": "251" + req.CustomerPhone[:9], - } - - payload, err := json.Marshal(reqBody) - if err != nil { - // _, err = s.walletSvc.AddToWallet( - // ctx, - // userWallet.RegularID, - // domain.Currency(req.Amount), - // domain.ValidInt64{}, - // domain.TRANSFER_ARIFPAY, - // domain.PaymentDetails{}, - // "", - // ) - // if err != nil { - // return fmt.Errorf("failed to deduct from wallet: %w", err) - // } - return fmt.Errorf("failed to marshal transfer request: %w", err) - } - - transferReq, err := http.NewRequestWithContext(ctx, http.MethodPost, transferURL, bytes.NewBuffer(payload)) - if err != nil { - // _, err = s.walletSvc.AddToWallet( - // ctx, - // userWallet.RegularID, - // domain.Currency(req.Amount), - // domain.ValidInt64{}, - // domain.TRANSFER_ARIFPAY, - // domain.PaymentDetails{}, - // "", - // ) - // if err != nil { - // return fmt.Errorf("failed to deduct from wallet: %w", err) - // } - return fmt.Errorf("failed to build transfer request: %w", err) - } - transferReq.Header.Set("Content-Type", "application/json") - transferReq.Header.Set("x-arifpay-key", s.cfg.ARIFPAY.APIKey) - - transferResp, err := s.httpClient.Do(transferReq) - if err != nil { - // _, err = s.walletSvc.AddToWallet( - // ctx, - // userWallet.RegularID, - // domain.Currency(req.Amount), - // domain.ValidInt64{}, - // domain.TRANSFER_ARIFPAY, - // domain.PaymentDetails{}, - // "", - // ) - // if err != nil { - // return fmt.Errorf("failed to deduct from wallet: %w", err) - // } - return fmt.Errorf("failed to execute transfer request: %w", err) - } - defer transferResp.Body.Close() - - if transferResp.StatusCode != http.StatusOK { - // _, err = s.walletSvc.AddToWallet( - // ctx, - // userWallet.RegularID, - // domain.Currency(req.Amount), - // domain.ValidInt64{}, - // domain.TRANSFER_ARIFPAY, - // domain.PaymentDetails{}, - // "", - // ) - // if err != nil { - // return fmt.Errorf("failed to deduct from wallet: %w", err) - // } - body, _ := io.ReadAll(transferResp.Body) - return fmt.Errorf("transfer failed with status %d: %s", transferResp.StatusCode, string(body)) - } - - // Step 3: Store transfer in DB - // transfer := domain.CreateTransfer{ - // Amount: domain.Currency(req.Amount), - // Verified: false, - // Type: domain.WITHDRAW, // B2C = payout - // ReferenceNumber: referenceNum, - // SessionID: fmt.Sprintf("%v", sessionRespData["sessionId"]), - // Status: string(domain.PaymentStatusPending), - // PaymentMethod: domain.TRANSFER_ARIFPAY, - // CashierID: domain.ValidInt64{ - // Value: userId, - // Valid: true, - // }, - // } - // if _, err := s.transferStore.CreateTransfer(ctx, transfer); err != nil { - // return fmt.Errorf("failed to store transfer: %w", err) - // } - - // Step 4: Deduct from wallet - - return nil -} - -func (s *ArifpayService) ExecuteCBEB2CTransfer(ctx context.Context, req domain.CheckoutSessionClientRequest, userId int64) error { - // Step 1: Deduct from user wallet first - // userWallet, err := s.walletSvc.GetCustomerWallet(ctx, userId) - // if err != nil { - // return fmt.Errorf("cbebirr: failed to get user wallet: %w", err) - // } - - // _, err = s.walletSvc.DeductFromWallet( - // ctx, - // userWallet.RegularID, - // domain.Currency(req.Amount), - // domain.ValidInt64{}, - // domain.TRANSFER_ARIFPAY, - // "", - // ) - // if err != nil { - // return fmt.Errorf("cbebirr: failed to deduct from wallet: %w", err) - // } - - // referenceNum := uuid.NewString() - - // Step 2: Create Session - sessionReq := domain.CheckoutSessionClientRequest{ - Amount: req.Amount, - CustomerEmail: req.CustomerEmail, - CustomerPhone: "251" + req.CustomerPhone[:9], - } - - sessionResp, err := s.CreateCheckoutSession(sessionReq, false, userId) - if err != nil { - // refund wallet if session creation fails - // _, refundErr := s.walletSvc.AddToWallet( - // ctx, - // userWallet.RegularID, - // domain.Currency(req.Amount), - // domain.ValidInt64{}, - // domain.TRANSFER_ARIFPAY, - // domain.PaymentDetails{}, - // "", - // ) - // if refundErr != nil { - // return fmt.Errorf("cbebirr: refund failed after session creation error: %v", refundErr) - // } - return fmt.Errorf("cbebirr: failed to create session: %w", err) - } - - // Step 3: Execute Transfer - transferURL := fmt.Sprintf("%s/api/Cbebirr/b2c/transfer", s.cfg.ARIFPAY.BaseURL) - reqBody := map[string]any{ - "Sessionid": sessionResp["sessionId"], - "Phonenumber": "251" + req.CustomerPhone[:9], - } - - payload, err := json.Marshal(reqBody) - if err != nil { - // s.walletSvc.AddToWallet(ctx, userWallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{}, domain.TRANSFER_ARIFPAY, domain.PaymentDetails{}, "") - return fmt.Errorf("cbebirr: failed to marshal transfer request: %w", err) - } - - transferReq, err := http.NewRequestWithContext(ctx, http.MethodPost, transferURL, bytes.NewBuffer(payload)) - if err != nil { - // s.walletSvc.AddToWallet(ctx, userWallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{}, domain.TRANSFER_ARIFPAY, domain.PaymentDetails{}, "") - return fmt.Errorf("cbebirr: failed to build transfer request: %w", err) - } - transferReq.Header.Set("Content-Type", "application/json") - transferReq.Header.Set("x-arifpay-key", s.cfg.ARIFPAY.APIKey) - - transferResp, err := s.httpClient.Do(transferReq) - if err != nil { - // s.walletSvc.AddToWallet(ctx, userWallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{}, domain.TRANSFER_ARIFPAY, domain.PaymentDetails{}, "") - return fmt.Errorf("cbebirr: failed to execute transfer request: %w", err) - } - defer transferResp.Body.Close() - - if transferResp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(transferResp.Body) - // s.walletSvc.AddToWallet(ctx, userWallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{}, domain.TRANSFER_ARIFPAY, domain.PaymentDetails{}, "") - return fmt.Errorf("cbebirr: transfer failed with status %d: %s", transferResp.StatusCode, string(body)) - } - - // Step 4: Store transfer in DB - // transfer := domain.CreateTransfer{ - // Amount: domain.Currency(req.Amount), - // Verified: false, - // Type: domain.WITHDRAW, // B2C = payout - // ReferenceNumber: referenceNum, - // SessionID: fmt.Sprintf("%v", sessionResp["sessionId"]), - // Status: string(domain.PaymentStatusPending), - // PaymentMethod: domain.TRANSFER_ARIFPAY, - // CashierID: domain.ValidInt64{ - // Value: userId, - // Valid: true, - // }, - // } - - // if _, err := s.transferStore.CreateTransfer(ctx, transfer); err != nil { - // return fmt.Errorf("cbebirr: failed to store transfer: %w", err) - // } - - return nil -} - -func (s *ArifpayService) ExecuteMPesaB2CTransfer(ctx context.Context, req domain.CheckoutSessionClientRequest, userId int64) error { - // Step 1: Deduct from user wallet first - // userWallet, err := s.walletSvc.GetCustomerWallet(ctx, userId) - // if err != nil { - // return fmt.Errorf("mpesa: failed to get user wallet: %w", err) - // } - - // _, err = s.walletSvc.DeductFromWallet( - // ctx, - // userWallet.RegularID, - // domain.Currency(req.Amount), - // domain.ValidInt64{}, - // domain.TRANSFER_ARIFPAY, - // "", - // ) - // if err != nil { - // return fmt.Errorf("mpesa: failed to deduct from wallet: %w", err) - // } - - // referenceNum := uuid.NewString() - - // Step 2: Create Session - sessionReq := domain.CheckoutSessionClientRequest{ - Amount: req.Amount, - CustomerEmail: req.CustomerEmail, - CustomerPhone: "251" + req.CustomerPhone[:9], - } - - sessionResp, err := s.CreateCheckoutSession(sessionReq, false, userId) - if err != nil { - // Refund wallet if session creation fails - // _, refundErr := s.walletSvc.AddToWallet( - // ctx, - // userWallet.RegularID, - // domain.Currency(req.Amount), - // domain.ValidInt64{}, - // domain.TRANSFER_ARIFPAY, - // domain.PaymentDetails{}, - // "", - // ) - // if refundErr != nil { - // return fmt.Errorf("mpesa: refund failed after session creation error: %v", refundErr) - // } - return fmt.Errorf("mpesa: failed to create session: %w", err) - } - - // Step 3: Execute Transfer - transferURL := fmt.Sprintf("%s/api/Mpesa/b2c/transfer", s.cfg.ARIFPAY.BaseURL) - reqBody := map[string]any{ - "Sessionid": sessionResp["sessionId"], - "Phonenumber": "251" + req.CustomerPhone[:9], - } - - payload, err := json.Marshal(reqBody) - if err != nil { - // s.walletSvc.AddToWallet(ctx, userWallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{}, domain.TRANSFER_ARIFPAY, domain.PaymentDetails{}, "") - return fmt.Errorf("mpesa: failed to marshal transfer request: %w", err) - } - - transferReq, err := http.NewRequestWithContext(ctx, http.MethodPost, transferURL, bytes.NewBuffer(payload)) - if err != nil { - // s.walletSvc.AddToWallet(ctx, userWallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{}, domain.TRANSFER_ARIFPAY, domain.PaymentDetails{}, "") - return fmt.Errorf("mpesa: failed to build transfer request: %w", err) - } - transferReq.Header.Set("Content-Type", "application/json") - transferReq.Header.Set("x-arifpay-key", s.cfg.ARIFPAY.APIKey) - - transferResp, err := s.httpClient.Do(transferReq) - if err != nil { - // s.walletSvc.AddToWallet(ctx, userWallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{}, domain.TRANSFER_ARIFPAY, domain.PaymentDetails{}, "") - return fmt.Errorf("mpesa: failed to execute transfer request: %w", err) - } - defer transferResp.Body.Close() - - if transferResp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(transferResp.Body) - // s.walletSvc.AddToWallet(ctx, userWallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{}, domain.TRANSFER_ARIFPAY, domain.PaymentDetails{}, "") - return fmt.Errorf("mpesa: transfer failed with status %d: %s", transferResp.StatusCode, string(body)) - } - - // Step 4: Store transfer in DB - // transfer := domain.CreateTransfer{ - // Amount: domain.Currency(req.Amount), - // Verified: false, - // Type: domain.WITHDRAW, // B2C = payout - // ReferenceNumber: referenceNum, - // SessionID: fmt.Sprintf("%v", sessionResp["sessionId"]), - // Status: string(domain.PaymentStatusPending), - // PaymentMethod: domain.TRANSFER_ARIFPAY, - // CashierID: domain.ValidInt64{ - // Value: userId, - // Valid: true, - // }, - // } - - // if _, err := s.transferStore.CreateTransfer(ctx, transfer); err != nil { - // return fmt.Errorf("mpesa: failed to store transfer: %w", err) - // } - - return nil + + httpReq.Header.Set("x-arifpay-key", s.cfg.ARIFPAY.APIKey) + + resp, err := s.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to call verify transaction API: %w", err) + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(respBytes)) + } + + var result domain.WebhookRequest + if err := json.Unmarshal(respBytes, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &result, nil } +// VerifyTransactionByTransactionID verifies a transaction by transaction ID func (s *ArifpayService) VerifyTransactionByTransactionID(ctx context.Context, req domain.ArifpayVerifyByTransactionIDRequest) (*domain.WebhookRequest, error) { endpoint := fmt.Sprintf("%s/api/checkout/getSessionByTransactionId", s.cfg.ARIFPAY.BaseURL) - // Marshal request payload bodyBytes, err := json.Marshal(req) if err != nil { return nil, fmt.Errorf("failed to marshal request: %w", err) } - // Build HTTP request httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(bodyBytes)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) @@ -611,64 +508,21 @@ func (s *ArifpayService) VerifyTransactionByTransactionID(ctx context.Context, r httpReq.Header.Set("x-arifpay-key", s.cfg.ARIFPAY.APIKey) - // Execute request resp, err := s.httpClient.Do(httpReq) if err != nil { return nil, fmt.Errorf("failed to call verify transaction API: %w", err) } defer resp.Body.Close() - // Read response body respBytes, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } - // Handle non-200 responses if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(respBytes)) } - // Decode into domain response - var result domain.WebhookRequest - if err := json.Unmarshal(respBytes, &result); err != nil { - return nil, fmt.Errorf("failed to unmarshal response: %w", err) - } - - return &result, nil -} - -func (s *ArifpayService) VerifyTransactionBySessionID(ctx context.Context, sessionID string) (*domain.WebhookRequest, error) { - endpoint := fmt.Sprintf("%s/api/ms/transaction/status/%s", s.cfg.ARIFPAY.BaseURL, sessionID) - - // Create HTTP GET request - httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - // Set required headers - httpReq.Header.Set("x-arifpay-key", s.cfg.ARIFPAY.APIKey) - - // Execute request - resp, err := s.httpClient.Do(httpReq) - if err != nil { - return nil, fmt.Errorf("failed to call verify transaction API: %w", err) - } - defer resp.Body.Close() - - // Read response body - respBytes, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - // Handle non-200 responses - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(respBytes)) - } - - // Decode into domain response var result domain.WebhookRequest if err := json.Unmarshal(respBytes, &result); err != nil { return nil, fmt.Errorf("failed to unmarshal response: %w", err) @@ -677,6 +531,7 @@ func (s *ArifpayService) VerifyTransactionBySessionID(ctx context.Context, sessi return &result, nil } +// GetPaymentMethodsMapping returns the list of payment methods func (s *ArifpayService) GetPaymentMethodsMapping() []domain.ARIFPAYPaymentMethod { return []domain.ARIFPAYPaymentMethod{ {ID: 1, Name: "ACCOUNT"}, @@ -696,3 +551,419 @@ func (s *ArifpayService) GetPaymentMethodsMapping() []domain.ARIFPAYPaymentMetho {ID: 15, Name: "MPESSA"}, } } + +// ===================== +// Direct Payment Methods (OTP-based) +// ===================== + +// InitiateDirectPayment creates a session and initiates direct payment (triggers OTP) +func (s *ArifpayService) InitiateDirectPayment(ctx context.Context, userID int64, req domain.InitiateDirectPaymentRequest) (*domain.InitiateDirectPaymentResponse, error) { + // Get the subscription plan + plan, err := s.subscriptionStore.GetSubscriptionPlanByID(ctx, req.PlanID) + if err != nil { + return nil, fmt.Errorf("failed to get subscription plan: %w", err) + } + + if !plan.IsActive { + return nil, errors.New("subscription plan is not active") + } + + // Check if user already has an active subscription + hasActive, err := s.subscriptionStore.HasActiveSubscription(ctx, userID) + if err != nil { + return nil, fmt.Errorf("failed to check active subscription: %w", err) + } + if hasActive { + return nil, errors.New("user already has an active subscription") + } + + // Generate unique nonce + nonce := uuid.NewString() + expiresAt := time.Now().Add(15 * time.Minute) // Shorter expiry for direct payments + + // Create payment record + paymentMethod := string(req.PaymentMethod) + payment, err := s.paymentStore.CreatePayment(ctx, domain.CreatePaymentInput{ + UserID: userID, + PlanID: &req.PlanID, + Amount: plan.Price, + Currency: plan.Currency, + Nonce: nonce, + PaymentMethod: &paymentMethod, + ExpiresAt: &expiresAt, + }) + if err != nil { + return nil, fmt.Errorf("failed to create payment record: %w", err) + } + + // Create ArifPay checkout session with specific payment method + checkoutReq := domain.CheckoutSessionRequest{ + CancelURL: s.cfg.ARIFPAY.CancelUrl, + Phone: formatPhone(req.Phone), + Email: req.Email, + Nonce: nonce, + SuccessURL: s.cfg.ARIFPAY.SuccessUrl, + ErrorURL: s.cfg.ARIFPAY.ErrorUrl, + NotifyURL: s.cfg.ARIFPAY.C2BNotifyUrl, + PaymentMethods: []string{string(req.PaymentMethod)}, + ExpireDate: expiresAt.Format("2006-01-02T15:04:05"), + Items: []struct { + Name string `json:"name"` + Quantity int `json:"quantity"` + Price float64 `json:"price"` + Description string `json:"description"` + }{ + { + Name: plan.Name, + Quantity: 1, + Price: plan.Price, + Description: fmt.Sprintf("Subscription: %s", plan.Name), + }, + }, + Beneficiaries: []struct { + AccountNumber string `json:"accountNumber"` + Bank string `json:"bank"` + Amount float64 `json:"amount"` + }{ + { + AccountNumber: s.cfg.ARIFPAY.BeneficiaryAccountNumber, + Bank: s.cfg.ARIFPAY.Bank, + Amount: plan.Price, + }, + }, + Lang: s.cfg.ARIFPAY.Lang, + } + + // Create session + payload, err := json.Marshal(checkoutReq) + if err != nil { + return nil, fmt.Errorf("failed to marshal checkout request: %w", err) + } + + url := fmt.Sprintf("%s/api/checkout/session", s.cfg.ARIFPAY.BaseURL) + httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(payload)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("x-arifpay-key", s.cfg.ARIFPAY.APIKey) + + resp, err := s.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to call ArifPay API: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ArifPay API error: %s", string(body)) + } + + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("invalid response from ArifPay: %w", err) + } + + data, ok := result["data"].(map[string]interface{}) + if !ok { + return nil, errors.New("invalid response structure from ArifPay") + } + + sessionID := fmt.Sprintf("%v", data["sessionId"]) + + // Update payment with session info + if err := s.paymentStore.UpdatePaymentSessionID(ctx, payment.ID, sessionID, ""); err != nil { + return nil, fmt.Errorf("failed to update payment session: %w", err) + } + + // Now initiate direct transfer based on payment method + directResp, err := s.initiateDirectTransfer(ctx, sessionID, formatPhone(req.Phone), req.PaymentMethod) + if err != nil { + // Update payment status to failed + s.paymentStore.UpdatePaymentStatus(ctx, payment.ID, string(domain.PaymentStatusFailed)) + return nil, fmt.Errorf("failed to initiate direct transfer: %w", err) + } + + requiresOTP := s.paymentMethodRequiresOTP(req.PaymentMethod) + message := "Payment initiated" + if requiresOTP { + message = "OTP sent to your phone. Please verify to complete payment." + } else { + message = directResp + } + + return &domain.InitiateDirectPaymentResponse{ + PaymentID: payment.ID, + SessionID: sessionID, + RequiresOTP: requiresOTP, + Message: message, + Amount: plan.Price, + Currency: plan.Currency, + }, nil +} + +// initiateDirectTransfer calls the appropriate direct transfer endpoint +func (s *ArifpayService) initiateDirectTransfer(ctx context.Context, sessionID, phone string, method domain.DirectPaymentMethod) (string, error) { + var endpoint string + + switch method { + case domain.DirectPaymentTelebirr, domain.DirectPaymentTelebirrUSSD: + endpoint = fmt.Sprintf("%s/api/checkout/telebirr/direct/transfer", s.cfg.ARIFPAY.BaseURL) + case domain.DirectPaymentCBE: + endpoint = fmt.Sprintf("%s/api/checkout/cbe/direct/transfer", s.cfg.ARIFPAY.BaseURL) + case domain.DirectPaymentAmole: + endpoint = fmt.Sprintf("%s/api/checkout/amole/direct/transfer", s.cfg.ARIFPAY.BaseURL) + case domain.DirectPaymentHelloCash: + endpoint = fmt.Sprintf("%s/api/checkout/hellocash/direct/transfer", s.cfg.ARIFPAY.BaseURL) + case domain.DirectPaymentAwash: + endpoint = fmt.Sprintf("%s/api/checkout/awash/direct/transfer", s.cfg.ARIFPAY.BaseURL) + case domain.DirectPaymentMPesa: + endpoint = fmt.Sprintf("%s/api/Mpesa/c2b/transfer", s.cfg.ARIFPAY.BaseURL) + default: + return "", fmt.Errorf("unsupported direct payment method: %s", method) + } + + reqBody := map[string]string{ + "sessionId": sessionID, + "phoneNumber": phone, + } + + payload, err := json.Marshal(reqBody) + if err != nil { + return "", err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(payload)) + if err != nil { + return "", err + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("x-arifpay-key", s.cfg.ARIFPAY.APIKey) + + resp, err := s.httpClient.Do(httpReq) + if err != nil { + return "", fmt.Errorf("failed to initiate direct transfer: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("direct transfer failed: %s", string(body)) + } + + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return string(body), nil + } + + if msg, ok := result["msg"].(string); ok { + return msg, nil + } + if msg, ok := result["message"].(string); ok { + return msg, nil + } + + return "Transfer initiated successfully", nil +} + +// VerifyDirectPaymentOTP verifies the OTP for direct payment methods +func (s *ArifpayService) VerifyDirectPaymentOTP(ctx context.Context, userID int64, req domain.VerifyOTPRequest) (*domain.VerifyOTPResponse, error) { + // Get payment by session ID + payment, err := s.paymentStore.GetPaymentBySessionID(ctx, req.SessionID) + if err != nil { + return nil, ErrPaymentNotFound + } + + // Verify ownership + if payment.UserID != userID { + return nil, errors.New("unauthorized") + } + + // Check payment status + if payment.Status == string(domain.PaymentStatusSuccess) { + return &domain.VerifyOTPResponse{ + Success: true, + Message: "Payment already completed", + PaymentID: payment.ID, + }, nil + } + + if payment.Status != string(domain.PaymentStatusPending) && payment.Status != string(domain.PaymentStatusProcessing) { + return nil, ErrInvalidPaymentState + } + + // Determine OTP verification endpoint based on payment method + paymentMethod := "" + if payment.PaymentMethod != nil { + paymentMethod = *payment.PaymentMethod + } + + endpoint, err := s.getOTPVerifyEndpoint(domain.DirectPaymentMethod(paymentMethod)) + if err != nil { + return nil, err + } + + // Call OTP verification + reqBody := map[string]string{ + "sessionId": req.SessionID, + "otp": req.OTP, + } + // Some endpoints use different field names + if paymentMethod == string(domain.DirectPaymentAmole) { + reqBody["SessionId"] = req.SessionID + } + + payload, err := json.Marshal(reqBody) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(payload)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("x-arifpay-key", s.cfg.ARIFPAY.APIKey) + + resp, err := s.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to verify OTP: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var result map[string]interface{} + json.Unmarshal(body, &result) + + if resp.StatusCode != http.StatusOK { + errMsg := "OTP verification failed" + if msg, ok := result["msg"].(string); ok { + errMsg = msg + } else if msg, ok := result["message"].(string); ok { + errMsg = msg + } + return &domain.VerifyOTPResponse{ + Success: false, + Message: errMsg, + }, nil + } + + // OTP verified successfully - update payment status + transactionID := "" + if tid, ok := result["transactionId"].(string); ok { + transactionID = tid + } + + // Update payment to success + if err := s.paymentStore.UpdatePaymentStatusBySessionID( + ctx, + req.SessionID, + string(domain.PaymentStatusSuccess), + transactionID, + paymentMethod, + ); err != nil { + return nil, fmt.Errorf("failed to update payment status: %w", err) + } + + // Create subscription + if payment.PlanID != nil { + plan, err := s.subscriptionStore.GetSubscriptionPlanByID(ctx, *payment.PlanID) + if err == nil { + startsAt := time.Now() + expiresAt := domain.CalculateExpiryDate(startsAt, plan.DurationValue, plan.DurationUnit) + activeStatus := string(domain.SubscriptionStatusActive) + autoRenew := false + paymentRef := payment.Nonce + + subscription, err := s.subscriptionStore.CreateUserSubscription(ctx, domain.CreateUserSubscriptionInput{ + UserID: payment.UserID, + PlanID: *payment.PlanID, + StartsAt: &startsAt, + ExpiresAt: expiresAt, + Status: &activeStatus, + PaymentReference: &paymentRef, + PaymentMethod: &paymentMethod, + AutoRenew: &autoRenew, + }) + if err == nil { + s.paymentStore.LinkPaymentToSubscription(ctx, payment.ID, subscription.ID) + } + } + } + + return &domain.VerifyOTPResponse{ + Success: true, + Message: "Payment completed successfully", + TransactionID: transactionID, + PaymentID: payment.ID, + }, nil +} + +// getOTPVerifyEndpoint returns the OTP verification endpoint for the payment method +func (s *ArifpayService) getOTPVerifyEndpoint(method domain.DirectPaymentMethod) (string, error) { + switch method { + case domain.DirectPaymentAmole: + return fmt.Sprintf("%s/api/checkout/amole/direct/verifyOTP", s.cfg.ARIFPAY.BaseURL), nil + case domain.DirectPaymentHelloCash: + return fmt.Sprintf("%s/api/checkout/hellocash/direct/verify", s.cfg.ARIFPAY.BaseURL), nil + case domain.DirectPaymentCBE: + return fmt.Sprintf("%s/api/checkout/cbe/direct/verify", s.cfg.ARIFPAY.BaseURL), nil + case domain.DirectPaymentAwash: + return fmt.Sprintf("%s/api/checkout/awash/direct/verify", s.cfg.ARIFPAY.BaseURL), nil + default: + return "", fmt.Errorf("payment method %s does not require OTP verification", method) + } +} + +// paymentMethodRequiresOTP checks if the payment method requires OTP verification +func (s *ArifpayService) paymentMethodRequiresOTP(method domain.DirectPaymentMethod) bool { + switch method { + case domain.DirectPaymentAmole, domain.DirectPaymentHelloCash, domain.DirectPaymentAwash: + return true + case domain.DirectPaymentTelebirr, domain.DirectPaymentTelebirrUSSD, domain.DirectPaymentCBE, domain.DirectPaymentMPesa: + // These use push notification or USSD, no OTP needed in API + return false + default: + return false + } +} + +// GetDirectPaymentMethods returns payment methods that support direct payment +func (s *ArifpayService) GetDirectPaymentMethods() []domain.ARIFPAYPaymentMethod { + return []domain.ARIFPAYPaymentMethod{ + {ID: 1, Name: string(domain.DirectPaymentTelebirr)}, + {ID: 2, Name: string(domain.DirectPaymentTelebirrUSSD)}, + {ID: 3, Name: string(domain.DirectPaymentCBE)}, + {ID: 4, Name: string(domain.DirectPaymentAmole)}, + {ID: 5, Name: string(domain.DirectPaymentHelloCash)}, + {ID: 6, Name: string(domain.DirectPaymentAwash)}, + {ID: 7, Name: string(domain.DirectPaymentMPesa)}, + } +} + +// Helper to format phone number +func formatPhone(phone string) string { + phone = strings.TrimSpace(phone) + if strings.HasPrefix(phone, "+251") { + return strings.TrimPrefix(phone, "+") + } + if strings.HasPrefix(phone, "0") { + return "251" + phone[1:] + } + if strings.HasPrefix(phone, "251") { + return phone + } + return "251" + phone +} diff --git a/internal/services/assessment/initial_assessment.go b/internal/services/assessment/initial_assessment.go index 0da7a54..fd62e70 100644 --- a/internal/services/assessment/initial_assessment.go +++ b/internal/services/assessment/initial_assessment.go @@ -1,320 +1,48 @@ package assessment import ( - dbgen "Yimaru-Backend/gen/db" "Yimaru-Backend/internal/domain" "context" - "errors" - "encoding/json" - - "github.com/jackc/pgx/v5/pgtype" ) +// CreateQuestion creates a question in the unified questions system. +// Initial assessment questions should be created through the unified questions API +// and then linked to the initial assessment question set. func (s *Service) CreateQuestion( ctx context.Context, - input domain.CreateAssessmentQuestionInput, -) error { - repo := s.initialAssessmentStore - - // 1. Create Question - question, err := repo.CreateAssessmentQuestion( - ctx, - dbgen.CreateAssessmentQuestionParams{ - Title: input.Title, - Description: func() pgtype.Text { - ns := toNullString(input.Description) - return pgtype.Text{String: ns.String, Valid: ns.Valid} - }(), - QuestionType: string(input.QuestionType), - DifficultyLevel: pgtype.Text{String: input.DifficultyLevel}, - Points: input.Points, - IsActive: input.IsActive, - }, - ) - if err != nil { - return err - } - - // 2. Branch by Question Type - switch input.QuestionType { - - case domain.MultipleChoice: - if len(input.Options) == 0 { - return errors.New("multiple choice question requires options") - } - - for _, opt := range input.Options { - _, err := repo.CreateQuestionOption( - ctx, - dbgen.CreateQuestionOptionParams{ - QuestionID: question.ID, - OptionText: opt.Text, - OptionOrder: opt.Order, - IsCorrect: opt.IsCorrect, - }, - ) - if err != nil { - return err - } - } - - case domain.TrueFalse: - // TRUE - if _, err := repo.CreateQuestionOption( - ctx, - dbgen.CreateQuestionOptionParams{ - QuestionID: question.ID, - OptionText: "True", - OptionOrder: 1, - IsCorrect: true, - }, - ); err != nil { - return err - } - - // FALSE - if _, err := repo.CreateQuestionOption( - ctx, - dbgen.CreateQuestionOptionParams{ - QuestionID: question.ID, - OptionText: "False", - OptionOrder: 2, - IsCorrect: false, - }, - ); err != nil { - return err - } - - case domain.ShortAnswer: - if input.CorrectAnswer == nil || *input.CorrectAnswer == "" { - return errors.New("short answer question requires correct_answer") - } - - _, err := repo.CreateShortAnswer( - ctx, - dbgen.CreateShortAnswerParams{ - QuestionID: question.ID, - CorrectAnswer: *input.CorrectAnswer, - }, - ) - if err != nil { - return err - } - - default: - return errors.New("unsupported question type") - } - - return nil + input domain.CreateQuestionInput, +) (domain.Question, error) { + return s.questionStore.CreateQuestion(ctx, input) } +// ListQuestions returns questions from the initial assessment set. func (s *Service) ListQuestions(ctx context.Context) ([]domain.QuestionWithDetails, error) { - // repo := s.initialAssessmentStore - - questions, err := s.initialAssessmentStore.GetActiveAssessmentQuestions(ctx) + // Get the initial assessment set + set, err := s.questionStore.GetInitialAssessmentSet(ctx) if err != nil { return nil, err } - out := make([]domain.QuestionWithDetails, 0, len(questions)) - for _, q := range questions { - var dq domain.AssessmentQuestion - b, err := json.Marshal(q) - if err != nil { - return nil, err - } - if err := json.Unmarshal(b, &dq); err != nil { - return nil, err - } - item := domain.QuestionWithDetails{Question: dq} - - switch domain.QuestionType(q.QuestionType) { - case domain.MultipleChoice, domain.TrueFalse: - opts, err := s.initialAssessmentStore.GetQuestionOptions(ctx, q.ID) - if err != nil { - return nil, err - } - for _, opt := range opts { - tempOpt := domain.QuestionOption{ - QuestionID: opt.ID, - OptionText: opt.OptionText, - } - item.Options = append(item.Options, tempOpt) - } - - // case domain.ShortAnswer: - // sa, err := s.initialAssessmentStore.GetShortAnswerByQuestionID(ctx, q.ID) - // if err != nil { - // return nil, err - // } - // item.ShortAnswer = &sa - // } - - out = append(out, item) - } + // Get all questions in the set + items, err := s.questionStore.GetPublishedQuestionsInSet(ctx, set.ID) + if err != nil { + return nil, err } - return out, nil + // Build the full question details + result := make([]domain.QuestionWithDetails, 0, len(items)) + for _, item := range items { + qwd, err := s.questionStore.GetQuestionWithDetails(ctx, item.QuestionID) + if err != nil { + continue // Skip if we can't get details + } + result = append(result, qwd) + } + + return result, nil } +// GetQuestionByID returns a question with full details. func (s *Service) GetQuestionByID(ctx context.Context, id int64) (domain.QuestionWithDetails, error) { - repo := s.initialAssessmentStore - - q, err := repo.GetAssessmentQuestionByID(ctx, id) - if err != nil { - return domain.QuestionWithDetails{}, err - } - - var dq domain.AssessmentQuestion - b, err := json.Marshal(q) - if err != nil { - return domain.QuestionWithDetails{}, err - } - if err := json.Unmarshal(b, &dq); err != nil { - return domain.QuestionWithDetails{}, err - } - item := domain.QuestionWithDetails{Question: dq} - switch domain.QuestionType(q.QuestionType) { - case domain.MultipleChoice, domain.TrueFalse: - opts, err := repo.GetQuestionOptions(ctx, q.ID) - if err != nil { - return domain.QuestionWithDetails{}, err - } - for _, opt := range opts { - tempOpt := domain.QuestionOption{ - QuestionID: opt.ID, - OptionText: opt.OptionText, - } - item.Options = append(item.Options, tempOpt) - } - // case domain.ShortAnswer: - // sa, err := repo.GetShortAnswerByQuestionID(ctx, q.ID) - // if err != nil { - // return QuestionWithDetails{}, err - // } - // item.ShortAnswer = &sa - } - - return item, nil + return s.questionStore.GetQuestionWithDetails(ctx, id) } - -// func (s *Service) UpdateQuestion(ctx context.Context, id int64, input domain.UpdateAssessmentQuestionInput) error { -// repo := s.initialAssessmentStore - -// // fetch existing -// existing, err := repo.GetAssessmentQuestionByID(ctx, id) -// if err != nil { -// return err -// } - -// // update base question -// _, err = repo.UpdateAssessmentQuestion( -// ctx, -// dbgen.UpdateAssessmentQuestionParams{ -// ID: id, -// Title: input.Title, -// Description: func() pgtype.Text { -// ns := toNullString(input.Description) -// return pgtype.Text{String: ns.String, Valid: ns.Valid} -// }(), -// QuestionType: string(input.QuestionType), -// DifficultyLevel: pgtype.Text{String: input.DifficultyLevel}, -// Points: input.Points, -// IsActive: input.IsActive, -// }, -// ) -// if err != nil { -// return err -// } - -// // remove previous dependents (safe to remove regardless of new type) -// // try delete options and short answer; ignore not-found errors if repo returns them -// if err := repo.DeleteQuestionOptionsByQuestionID(ctx, id); err != nil { -// return err -// } -// if err := repo.DeleteShortAnswerByQuestionID(ctx, id); err != nil { -// return err -// } - -// // create dependents for new type -// switch input.QuestionType { -// case domain.MultipleChoice: -// if len(input.Options) == 0 { -// return errors.New("multiple choice question requires options") -// } -// for _, opt := range input.Options { -// if _, err := repo.CreateQuestionOption( -// ctx, -// dbgen.CreateQuestionOptionParams{ -// QuestionID: id, -// OptionText: opt.Text, -// OptionOrder: opt.Order, -// IsCorrect: opt.IsCorrect, -// }, -// ); err != nil { -// return err -// } -// } -// case domain.TrueFalse: -// if _, err := repo.CreateQuestionOption(ctx, dbgen.CreateQuestionOptionParams{ -// QuestionID: id, -// OptionText: "True", -// OptionOrder: 1, -// IsCorrect: true, -// }); err != nil { -// return err -// } -// if _, err := repo.CreateQuestionOption(ctx, dbgen.CreateQuestionOptionParams{ -// QuestionID: id, -// OptionText: "False", -// OptionOrder: 2, -// IsCorrect: false, -// }); err != nil { -// return err -// } -// case domain.ShortAnswer: -// if input.CorrectAnswer == nil || *input.CorrectAnswer == "" { -// return errors.New("short answer question requires correct_answer") -// } -// if _, err := repo.CreateShortAnswer(ctx, dbgen.CreateShortAnswerParams{ -// QuestionID: id, -// CorrectAnswer: *input.CorrectAnswer, -// }); err != nil { -// return err -// } -// default: -// return errors.New("unsupported question type") -// } - -// _ = existing -// return nil -// } - -// func (s *Service) DeleteQuestion(ctx context.Context, id int64) error { -// repo := s.initialAssessmentStore - -// q, err := repo.GetAssessmentQuestionByID(ctx, id) -// if err != nil { -// return err -// } - -// // delete dependents by existing type -// switch domain.QuestionType(q.QuestionType) { -// case domain.MultipleChoice, domain.TrueFalse: -// if err := repo.DeleteQuestionOptionsByQuestionID(ctx, id); err != nil { -// return err -// } -// case domain.ShortAnswer: -// if err := repo.DeleteShortAnswerByQuestionID(ctx, id); err != nil { -// return err -// } -// } - -// if err := repo.DeleteAssessmentQuestion(ctx, id); err != nil { -// return err -// } - -// return nil -// } - -// ...existing code... diff --git a/internal/services/assessment/service.go b/internal/services/assessment/service.go index 9c3ebdd..ca955e9 100644 --- a/internal/services/assessment/service.go +++ b/internal/services/assessment/service.go @@ -7,25 +7,22 @@ import ( ) type Service struct { - userStore ports.UserStore - initialAssessmentStore ports.InitialAssessmentStore - notificationSvc *notificationservice.Service - // messengerSvc *messenger.Service - config *config.Config + userStore ports.UserStore + questionStore ports.QuestionStore + notificationSvc *notificationservice.Service + config *config.Config } func NewService( userStore ports.UserStore, - initialAssessmentStore ports.InitialAssessmentStore, + questionStore ports.QuestionStore, notificationSvc *notificationservice.Service, - // messengerSvc *messenger.Service, cfg *config.Config, ) *Service { return &Service{ - userStore: userStore, - initialAssessmentStore: initialAssessmentStore, - notificationSvc: notificationSvc, - // messengerSvc: messengerSvc, - config: cfg, + userStore: userStore, + questionStore: questionStore, + notificationSvc: notificationSvc, + config: cfg, } } diff --git a/internal/services/authentication/google.go b/internal/services/authentication/google.go index 4339403..ccaf50b 100644 --- a/internal/services/authentication/google.go +++ b/internal/services/authentication/google.go @@ -6,6 +6,8 @@ import ( "encoding/json" "errors" "fmt" + "io" + "net/http" "time" "cloud.google.com/go/auth/credentials/idtoken" @@ -15,7 +17,12 @@ import ( var googleOAuthConfig *oauth2.Config +var ErrGoogleOAuthNotInitialized = errors.New("google oauth not initialized") + func (s *Service) InitGoogleOAuth(clientID, clientSecret, redirectURL string) { + if clientID == "" || clientSecret == "" || redirectURL == "" { + return + } googleOAuthConfig = &oauth2.Config{ ClientID: clientID, ClientSecret: clientSecret, @@ -29,6 +36,9 @@ func (s *Service) InitGoogleOAuth(clientID, clientSecret, redirectURL string) { } func (s *Service) GenerateGoogleLoginURL(state string) string { + if googleOAuthConfig == nil { + return "" + } return googleOAuthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline) } @@ -36,6 +46,9 @@ func (s *Service) ExchangeGoogleCode( ctx context.Context, code string, ) (*oauth2.Token, error) { + if googleOAuthConfig == nil { + return nil, ErrGoogleOAuthNotInitialized + } if code == "" { return nil, errors.New("missing google auth code") } @@ -46,6 +59,12 @@ func (s *Service) FetchGoogleUser( ctx context.Context, token *oauth2.Token, ) (*domain.GoogleUser, error) { + if googleOAuthConfig == nil { + return nil, ErrGoogleOAuthNotInitialized + } + if token == nil { + return nil, errors.New("missing google oauth token") + } client := googleOAuthConfig.Client(ctx, token) resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo") @@ -54,6 +73,11 @@ func (s *Service) FetchGoogleUser( } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("google userinfo failed: status=%d body=%s", resp.StatusCode, string(body)) + } + var user domain.GoogleUser if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { return nil, fmt.Errorf("failed to decode google user: %w", err) @@ -76,23 +100,41 @@ func (s *Service) LoginWithGoogleAndroid( return domain.LoginSuccess{}, errors.New("missing id token") } - // 1. Verify ID token with Google + if clientID == "" { + return domain.LoginSuccess{}, errors.New("missing google client id") + } + payload, err := idtoken.Validate(ctx, idToken, clientID) if err != nil { return domain.LoginSuccess{}, errors.New("invalid google id token") } - // 2. Extract Google user info - gUser := domain.GoogleUser{ - ID: payload.Subject, - Email: payload.Claims["email"].(string), - VerifiedEmail: payload.Claims["email_verified"].(bool), - GivenName: payload.Claims["given_name"].(string), - FamilyName: payload.Claims["family_name"].(string), - Picture: payload.Claims["picture"].(string), + email, _ := payload.Claims["email"].(string) + if email == "" { + return domain.LoginSuccess{}, errors.New("google id token missing email") + } + + verified := false + switch v := payload.Claims["email_verified"].(type) { + case bool: + verified = v + case string: + verified = (v == "true") + } + + givenName, _ := payload.Claims["given_name"].(string) + familyName, _ := payload.Claims["family_name"].(string) + picture, _ := payload.Claims["picture"].(string) + + gUser := domain.GoogleUser{ + ID: payload.Subject, + Email: email, + VerifiedEmail: verified, + GivenName: givenName, + FamilyName: familyName, + Picture: picture, } - // 3. Delegate to existing login logic return s.LoginWithGoogle(ctx, gUser) } @@ -104,29 +146,29 @@ func (s *Service) LoginWithGoogle( var user domain.User var err error - // 1. Try login via Google ID user, err = s.userStore.GetUserByGoogleID(ctx, gUser.ID) if err != nil { + if !errors.Is(err, domain.ErrUserNotFound) { + return domain.LoginSuccess{}, err + } - // 2. Try account linking by email user, err = s.userStore.GetUserByEmailPhone(ctx, gUser.Email, "") if err != nil { + if !errors.Is(err, domain.ErrUserNotFound) { + return domain.LoginSuccess{}, err + } - // 3. First-time Google user → create user, err = s.userStore.CreateGoogleUser(ctx, gUser) if err != nil { return domain.LoginSuccess{}, err } - } else { - // 4. Link Google account if err := s.userStore.LinkGoogleAccount(ctx, user.ID, gUser.ID); err != nil { return domain.LoginSuccess{}, err } } } - // 5. Enforce account status if user.Status == domain.UserStatusPending { return domain.LoginSuccess{}, domain.ErrUserNotVerified } @@ -135,15 +177,17 @@ func (s *Service) LoginWithGoogle( return domain.LoginSuccess{}, ErrUserSuspended } - // 6. Revoke existing refresh token (single active session) oldToken, err := s.tokenStore.GetRefreshTokenByUserID(ctx, user.ID) - if err == nil && !oldToken.Revoked { + if err != nil { + if !errors.Is(err, ErrRefreshTokenNotFound) { + return domain.LoginSuccess{}, err + } + } else if !oldToken.Revoked { if err := s.tokenStore.RevokeRefreshToken(ctx, oldToken.Token); err != nil { return domain.LoginSuccess{}, err } } - // 7. Issue new refresh token (initial issuance, not rotation) refreshToken, err := generateRefreshToken() if err != nil { return domain.LoginSuccess{}, err @@ -158,7 +202,6 @@ func (s *Service) LoginWithGoogle( return domain.LoginSuccess{}, err } - // 8. Return standard login response return domain.LoginSuccess{ UserId: user.ID, Role: user.Role, diff --git a/internal/services/course_management/course_programs.go b/internal/services/course_management/course_programs.go deleted file mode 100644 index 0dc1f15..0000000 --- a/internal/services/course_management/course_programs.go +++ /dev/null @@ -1,77 +0,0 @@ -package course_management - -import ( - "Yimaru-Backend/internal/domain" - "context" -) - -func (s *Service) CreateProgram( - ctx context.Context, - courseID int64, - title string, - description *string, - thumbnail *string, - displayOrder *int32, -) (domain.Program, error) { - return s.courseStore.CreateProgram(ctx, courseID, title, description, thumbnail, displayOrder) -} - -func (s *Service) GetProgramByID( - ctx context.Context, - id int64, -) (domain.Program, error) { - return s.courseStore.GetProgramByID(ctx, id) -} - -func (s *Service) GetProgramsByCourse( - ctx context.Context, - courseID int64, -) ([]domain.Program, int64, error) { - return s.courseStore.GetProgramsByCourse(ctx, courseID) -} - -func (s *Service) ListProgramsByCourse( - ctx context.Context, - courseID int64, -) ([]domain.Program, error) { - return s.courseStore.ListProgramsByCourse(ctx, courseID) -} - -func (s *Service) ListActivePrograms( - ctx context.Context, -) ([]domain.Program, error) { - return s.courseStore.ListActivePrograms(ctx) -} - -func (s *Service) UpdateProgramPartial( - ctx context.Context, - id int64, - title *string, - description *string, - thumbnail *string, - displayOrder *int32, - isActive *bool, -) error { - return s.courseStore.UpdateProgramPartial(ctx, id, title, description, thumbnail, displayOrder, isActive) -} - -func (s *Service) UpdateProgramFull( - ctx context.Context, - program domain.Program, -) (domain.Program, error) { - return s.courseStore.UpdateProgramFull(ctx, program) -} - -func (s *Service) DeactivateProgram( - ctx context.Context, - id int64, -) error { - return s.courseStore.DeactivateProgram(ctx, id) -} - -func (s *Service) DeleteProgram( - ctx context.Context, - id int64, -) (domain.Program, error) { - return s.courseStore.DeleteProgram(ctx, id) -} diff --git a/internal/services/course_management/courses.go b/internal/services/course_management/courses.go index 31cda97..2dda984 100644 --- a/internal/services/course_management/courses.go +++ b/internal/services/course_management/courses.go @@ -10,8 +10,9 @@ func (s *Service) CreateCourse( categoryID int64, title string, description *string, + thumbnail *string, ) (domain.Course, error) { - return s.courseStore.CreateCourse(ctx, categoryID, title, description) + return s.courseStore.CreateCourse(ctx, categoryID, title, description, thumbnail) } func (s *Service) GetCourseByID( @@ -35,9 +36,10 @@ func (s *Service) UpdateCourse( id int64, title *string, description *string, + thumbnail *string, isActive *bool, ) error { - return s.courseStore.UpdateCourse(ctx, id, title, description, isActive) + return s.courseStore.UpdateCourse(ctx, id, title, description, thumbnail, isActive) } func (s *Service) DeleteCourse( diff --git a/internal/services/course_management/level_modules.go b/internal/services/course_management/level_modules.go deleted file mode 100644 index 5d366dd..0000000 --- a/internal/services/course_management/level_modules.go +++ /dev/null @@ -1,41 +0,0 @@ -package course_management - -import ( - "Yimaru-Backend/internal/domain" - "context" -) - -func (s *Service) CreateModule( - ctx context.Context, - levelID int64, - title string, - content *string, - displayOrder *int32, -) (domain.Module, error) { - return s.courseStore.CreateModule(ctx, levelID, title, content, displayOrder) -} - -func (s *Service) GetModulesByLevel( - ctx context.Context, - levelID int64, -) ([]domain.Module, int64, error) { - return s.courseStore.GetModulesByLevel(ctx, levelID) -} - -func (s *Service) UpdateModule( - ctx context.Context, - id int64, - title *string, - content *string, - displayOrder *int32, - isActive *bool, -) error { - return s.courseStore.UpdateModule(ctx, id, title, content, displayOrder, isActive) -} - -func (s *Service) DeleteModule( - ctx context.Context, - id int64, -) error { - return s.courseStore.DeleteModule(ctx, id) -} diff --git a/internal/services/course_management/module_videos.go b/internal/services/course_management/module_videos.go deleted file mode 100644 index 6ea712d..0000000 --- a/internal/services/course_management/module_videos.go +++ /dev/null @@ -1,57 +0,0 @@ -package course_management - -import ( - "Yimaru-Backend/internal/domain" - "context" -) - -func (s *Service) CreateModuleVideo( - ctx context.Context, - moduleID int64, - title string, - description *string, - videoURL string, - duration int32, - resolution *string, - instructorID *string, - thumbnail *string, - visibility *string, -) (domain.ModuleVideo, error) { - return s.courseStore.CreateModuleVideo(ctx, moduleID, title, description, videoURL, duration, resolution, instructorID, thumbnail, visibility) -} - -func (s *Service) PublishModuleVideo( - ctx context.Context, - videoID int64, -) error { - return s.courseStore.PublishModuleVideo(ctx, videoID) -} - -func (s *Service) GetPublishedVideosByModule( - ctx context.Context, - moduleID int64, -) ([]domain.ModuleVideo, error) { - return s.courseStore.GetPublishedVideosByModule(ctx, moduleID) -} - -func (s *Service) UpdateModuleVideo( - ctx context.Context, - id int64, - title *string, - description *string, - videoURL *string, - duration *int32, - resolution *string, - visibility *string, - thumbnail *string, - isActive *bool, -) error { - return s.courseStore.UpdateModuleVideo(ctx, id, title, description, videoURL, duration, resolution, visibility, thumbnail, isActive) -} - -func (s *Service) DeleteModuleVideo( - ctx context.Context, - id int64, -) error { - return s.courseStore.DeleteModuleVideo(ctx, id) -} diff --git a/internal/services/course_management/practice_questions.go b/internal/services/course_management/practice_questions.go deleted file mode 100644 index 2debaa7..0000000 --- a/internal/services/course_management/practice_questions.go +++ /dev/null @@ -1,44 +0,0 @@ -package course_management - -import ( - "Yimaru-Backend/internal/domain" - "context" -) - -func (s *Service) CreatePracticeQuestion( - ctx context.Context, - practiceID int64, - question string, - questionVoicePrompt *string, - sampleAnswerVoicePrompt *string, - sampleAnswer *string, - tips *string, - qType string, -) (domain.PracticeQuestion, error) { - return s.courseStore.CreatePracticeQuestion(ctx, practiceID, question, questionVoicePrompt, sampleAnswerVoicePrompt, sampleAnswer, tips, qType) -} - -func (s *Service) GetQuestionsByPractice( - ctx context.Context, - practiceID int64, -) ([]domain.PracticeQuestion, error) { - return s.courseStore.GetQuestionsByPractice(ctx, practiceID) -} - -func (s *Service) UpdatePracticeQuestion( - ctx context.Context, - id int64, - question *string, - sampleAnswer *string, - tips *string, - qType *string, -) error { - return s.courseStore.UpdatePracticeQuestion(ctx, id, question, sampleAnswer, tips, qType) -} - -func (s *Service) DeletePracticeQuestion( - ctx context.Context, - id int64, -) error { - return s.courseStore.DeletePracticeQuestion(ctx, id) -} diff --git a/internal/services/course_management/practices.go b/internal/services/course_management/practices.go deleted file mode 100644 index f7004c5..0000000 --- a/internal/services/course_management/practices.go +++ /dev/null @@ -1,46 +0,0 @@ -package course_management - -import ( - "Yimaru-Backend/internal/domain" - "context" -) - -func (s *Service) CreatePractice( - ctx context.Context, - ownerType string, - ownerID int64, - title string, - description *string, - bannerImage *string, - persona *string, - isActive *bool, -) (domain.Practice, error) { - return s.courseStore.CreatePractice(ctx, ownerType, ownerID, title, description, bannerImage, persona, isActive) -} - -func (s *Service) GetPracticesByOwner( - ctx context.Context, - ownerType string, - ownerID int64, -) ([]domain.Practice, error) { - return s.courseStore.GetPracticesByOwner(ctx, ownerType, ownerID) -} - -func (s *Service) UpdatePractice( - ctx context.Context, - id int64, - title *string, - description *string, - bannerImage *string, - persona *string, - isActive *bool, -) error { - return s.courseStore.UpdatePractice(ctx, id, title, description, bannerImage, persona, isActive) -} - -func (s *Service) DeletePractice( - ctx context.Context, - id int64, -) error { - return s.courseStore.DeletePractice(ctx, id) -} diff --git a/internal/services/course_management/program_levels.go b/internal/services/course_management/program_levels.go deleted file mode 100644 index 41a882e..0000000 --- a/internal/services/course_management/program_levels.go +++ /dev/null @@ -1,63 +0,0 @@ -package course_management - -import ( - "Yimaru-Backend/internal/domain" - "context" -) - -func (s *Service) CreateLevel( - ctx context.Context, - programID int64, - title string, - description *string, - levelIndex int, - isActive *bool, -) (domain.Level, error) { - return s.courseStore.CreateLevel(ctx, programID, title, description, levelIndex, isActive) -} - -func (s *Service) GetLevelsByProgram( - ctx context.Context, - programID int64, -) ([]domain.Level, error) { - return s.courseStore.GetLevelsByProgram(ctx, programID) -} - -func (s *Service) UpdateLevel( - ctx context.Context, - id int64, - title *string, - description *string, - levelIndex *int, - isActive *bool, -) error { - return s.courseStore.UpdateLevel(ctx, id, title, description, levelIndex, isActive) -} - -func (s *Service) IncrementLevelModuleCount( - ctx context.Context, - levelID int64, -) error { - return s.courseStore.IncrementLevelModuleCount(ctx, levelID) -} - -func (s *Service) IncrementLevelPracticeCount( - ctx context.Context, - levelID int64, -) error { - return s.courseStore.IncrementLevelPracticeCount(ctx, levelID) -} - -func (s *Service) IncrementLevelVideoCount( - ctx context.Context, - levelID int64, -) error { - return s.courseStore.IncrementLevelVideoCount(ctx, levelID) -} - -func (s *Service) DeleteLevel( - ctx context.Context, - levelID int64, -) error { - return s.courseStore.DeleteLevel(ctx, levelID) -} diff --git a/internal/services/course_management/service.go b/internal/services/course_management/service.go index 5f6f064..828582c 100644 --- a/internal/services/course_management/service.go +++ b/internal/services/course_management/service.go @@ -4,28 +4,35 @@ import ( "Yimaru-Backend/internal/config" "Yimaru-Backend/internal/ports" notificationservice "Yimaru-Backend/internal/services/notification" + vimeoservice "Yimaru-Backend/internal/services/vimeo" ) type Service struct { userStore ports.UserStore courseStore ports.CourseStore notificationSvc *notificationservice.Service - // messengerSvc *messenger.Service - config *config.Config + vimeoSvc *vimeoservice.Service + config *config.Config } func NewService( userStore ports.UserStore, courseStore ports.CourseStore, notificationSvc *notificationservice.Service, - // messengerSvc *messenger.Service, cfg *config.Config, ) *Service { return &Service{ userStore: userStore, courseStore: courseStore, notificationSvc: notificationSvc, - // messengerSvc: messengerSvc, - config: cfg, + config: cfg, } } + +func (s *Service) SetVimeoService(vimeoSvc *vimeoservice.Service) { + s.vimeoSvc = vimeoSvc +} + +func (s *Service) HasVimeoService() bool { + return s.vimeoSvc != nil +} diff --git a/internal/services/course_management/sub_course_videos.go b/internal/services/course_management/sub_course_videos.go new file mode 100644 index 0000000..0f257aa --- /dev/null +++ b/internal/services/course_management/sub_course_videos.go @@ -0,0 +1,182 @@ +package course_management + +import ( + "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/pkgs/vimeo" + "context" + "fmt" +) + +func (s *Service) CreateSubCourseVideo( + ctx context.Context, + subCourseID int64, + title string, + description *string, + videoURL string, + duration int32, + resolution *string, + instructorID *string, + thumbnail *string, + visibility *string, + displayOrder *int32, + status *string, +) (domain.SubCourseVideo, error) { + // Default to DIRECT provider when no Vimeo info provided + provider := string(domain.VideoHostProviderDirect) + return s.courseStore.CreateSubCourseVideo( + ctx, subCourseID, title, description, videoURL, duration, + resolution, instructorID, thumbnail, visibility, displayOrder, status, + nil, nil, nil, nil, &provider, + ) +} + +// CreateSubCourseVideoWithVimeo creates a video and uploads it to Vimeo +func (s *Service) CreateSubCourseVideoWithVimeo( + ctx context.Context, + subCourseID int64, + title string, + description *string, + sourceURL string, + fileSize int64, + duration int32, + resolution *string, + instructorID *string, + thumbnail *string, + visibility *string, + displayOrder *int32, +) (domain.SubCourseVideo, error) { + if s.vimeoSvc == nil { + return domain.SubCourseVideo{}, fmt.Errorf("vimeo service is not configured") + } + + // Create pull upload to Vimeo + descStr := "" + if description != nil { + descStr = *description + } + + uploadResult, err := s.vimeoSvc.CreatePullUpload(ctx, title, descStr, sourceURL, fileSize) + if err != nil { + return domain.SubCourseVideo{}, fmt.Errorf("failed to upload to Vimeo: %w", err) + } + + // Generate embed URL + embedURL := vimeo.GenerateEmbedURL(uploadResult.VimeoID, &vimeo.EmbedOptions{ + Title: true, + Byline: true, + Portrait: true, + }) + + // Generate embed HTML + embedHTML := vimeo.GenerateIframeEmbed(uploadResult.VimeoID, 640, 360, nil) + + // Set values for Vimeo fields + provider := string(domain.VideoHostProviderVimeo) + vimeoStatus := "uploading" + status := "DRAFT" + + // Create the video record with Vimeo info + return s.courseStore.CreateSubCourseVideo( + ctx, subCourseID, title, description, + uploadResult.Link, // Use Vimeo link as video URL + duration, resolution, instructorID, thumbnail, visibility, displayOrder, &status, + &uploadResult.VimeoID, &embedURL, &embedHTML, &vimeoStatus, &provider, + ) +} + +// CreateSubCourseVideoFromVimeoID creates a video record from an existing Vimeo video +func (s *Service) CreateSubCourseVideoFromVimeoID( + ctx context.Context, + subCourseID int64, + vimeoVideoID string, + title string, + description *string, + displayOrder *int32, + instructorID *string, +) (domain.SubCourseVideo, error) { + if s.vimeoSvc == nil { + return domain.SubCourseVideo{}, fmt.Errorf("vimeo service is not configured") + } + + // Fetch video info from Vimeo + info, err := s.vimeoSvc.GetVideoInfo(ctx, vimeoVideoID) + if err != nil { + return domain.SubCourseVideo{}, fmt.Errorf("failed to get Vimeo video info: %w", err) + } + + // Use Vimeo data + embedHTML := vimeo.GenerateIframeEmbed(vimeoVideoID, 640, 360, nil) + provider := string(domain.VideoHostProviderVimeo) + vimeoStatus := info.TranscodeStatus + if vimeoStatus == "" { + vimeoStatus = "available" + } + status := "DRAFT" + duration := int32(info.Duration) + resolution := fmt.Sprintf("%dx%d", info.Width, info.Height) + thumbnail := info.ThumbnailURL + + return s.courseStore.CreateSubCourseVideo( + ctx, subCourseID, title, description, + info.Link, duration, &resolution, instructorID, &thumbnail, nil, displayOrder, &status, + &vimeoVideoID, &info.EmbedURL, &embedHTML, &vimeoStatus, &provider, + ) +} + +func (s *Service) GetSubCourseVideoByID( + ctx context.Context, + id int64, +) (domain.SubCourseVideo, error) { + return s.courseStore.GetSubCourseVideoByID(ctx, id) +} + +func (s *Service) GetVideosBySubCourse( + ctx context.Context, + subCourseID int64, +) ([]domain.SubCourseVideo, int64, error) { + return s.courseStore.GetVideosBySubCourse(ctx, subCourseID) +} + +func (s *Service) GetPublishedVideosBySubCourse( + ctx context.Context, + subCourseID int64, +) ([]domain.SubCourseVideo, error) { + return s.courseStore.GetPublishedVideosBySubCourse(ctx, subCourseID) +} + +func (s *Service) PublishSubCourseVideo( + ctx context.Context, + videoID int64, +) error { + return s.courseStore.PublishSubCourseVideo(ctx, videoID) +} + +func (s *Service) UpdateSubCourseVideo( + ctx context.Context, + id int64, + title *string, + description *string, + videoURL *string, + duration *int32, + resolution *string, + visibility *string, + thumbnail *string, + displayOrder *int32, + status *string, +) error { + return s.courseStore.UpdateSubCourseVideo(ctx, id, title, description, videoURL, duration, resolution, visibility, thumbnail, displayOrder, status) +} + +func (s *Service) ArchiveSubCourseVideo( + ctx context.Context, + id int64, +) error { + return s.courseStore.ArchiveSubCourseVideo(ctx, id) +} + +func (s *Service) DeleteSubCourseVideo( + ctx context.Context, + id int64, +) error { + return s.courseStore.DeleteSubCourseVideo(ctx, id) +} diff --git a/internal/services/course_management/sub_courses.go b/internal/services/course_management/sub_courses.go new file mode 100644 index 0000000..106172d --- /dev/null +++ b/internal/services/course_management/sub_courses.go @@ -0,0 +1,72 @@ +package course_management + +import ( + "Yimaru-Backend/internal/domain" + "context" +) + +func (s *Service) CreateSubCourse( + ctx context.Context, + courseID int64, + title string, + description *string, + thumbnail *string, + displayOrder *int32, + level string, +) (domain.SubCourse, error) { + return s.courseStore.CreateSubCourse(ctx, courseID, title, description, thumbnail, displayOrder, level) +} + +func (s *Service) GetSubCourseByID( + ctx context.Context, + id int64, +) (domain.SubCourse, error) { + return s.courseStore.GetSubCourseByID(ctx, id) +} + +func (s *Service) GetSubCoursesByCourse( + ctx context.Context, + courseID int64, +) ([]domain.SubCourse, int64, error) { + return s.courseStore.GetSubCoursesByCourse(ctx, courseID) +} + +func (s *Service) ListSubCoursesByCourse( + ctx context.Context, + courseID int64, +) ([]domain.SubCourse, error) { + return s.courseStore.ListSubCoursesByCourse(ctx, courseID) +} + +func (s *Service) ListActiveSubCourses( + ctx context.Context, +) ([]domain.SubCourse, error) { + return s.courseStore.ListActiveSubCourses(ctx) +} + +func (s *Service) UpdateSubCourse( + ctx context.Context, + id int64, + title *string, + description *string, + thumbnail *string, + displayOrder *int32, + level *string, + isActive *bool, +) error { + return s.courseStore.UpdateSubCourse(ctx, id, title, description, thumbnail, displayOrder, level, isActive) +} + +func (s *Service) DeactivateSubCourse( + ctx context.Context, + id int64, +) error { + return s.courseStore.DeactivateSubCourse(ctx, id) +} + +func (s *Service) DeleteSubCourse( + ctx context.Context, + id int64, +) (domain.SubCourse, error) { + return s.courseStore.DeleteSubCourse(ctx, id) +} diff --git a/internal/services/kafka/producer.go b/internal/services/kafka/producer.go index e2720ee..9baa38e 100644 --- a/internal/services/kafka/producer.go +++ b/internal/services/kafka/producer.go @@ -27,7 +27,7 @@ package kafka // if err != nil { // return err // } - + // return p.writer.WriteMessages(ctx, kafka.Message{ // Key: []byte(key), // Value: msgBytes, diff --git a/internal/services/notification/interface.go b/internal/services/notification/interface.go index 33cadd0..f354d54 100644 --- a/internal/services/notification/interface.go +++ b/internal/services/notification/interface.go @@ -9,7 +9,7 @@ package notificationservice // type NotificationStore interface { // SendNotification(ctx context.Context, notification *domain.Notification) error // MarkAsRead(ctx context.Context, notificationID string, recipientID int64) error -// GetUserNotifications(ctx context.Context, recipientID int64, limit, offset int) ([]domain.Notification, int64, error) +// GetUserNotifications(ctx context.Context, recipientID int64, limit, offset int) ([]domain.Notification, int64, error) // ConnectWebSocket(ctx context.Context, recipientID int64, c *websocket.Conn) error // DisconnectWebSocket(recipientID int64) // SendSMS(ctx context.Context, recipientID int64, message string) error diff --git a/internal/services/notification/service.go b/internal/services/notification/service.go index 0dab1dd..d48bc2a 100644 --- a/internal/services/notification/service.go +++ b/internal/services/notification/service.go @@ -26,9 +26,9 @@ import ( // afro "github.com/amanuelabay/afrosms-go" firebase "firebase.google.com/go/v4" "firebase.google.com/go/v4/messaging" - "google.golang.org/api/option" afro "github.com/amanuelabay/afrosms-go" "github.com/gorilla/websocket" + "google.golang.org/api/option" // "github.com/redis/go-redis/v9" ) @@ -580,6 +580,24 @@ func (s *Service) SendPushNotification(ctx context.Context, notification *domain zap.Error(err), zap.Time("timestamp", time.Now()), ) + + // Check if token is invalid/unregistered and deactivate it + if messaging.IsUnregistered(err) || messaging.IsInvalidArgument(err) { + if deactivateErr := s.userSvc.DeactivateDevice(ctx, notification.RecipientID, token); deactivateErr != nil { + s.mongoLogger.Warn("[NotificationSvc.SendPushNotification] Failed to deactivate invalid token", + zap.String("token", token), + zap.Int64("userID", notification.RecipientID), + zap.Error(deactivateErr), + zap.Time("timestamp", time.Now()), + ) + } else { + s.mongoLogger.Info("[NotificationSvc.SendPushNotification] Deactivated invalid FCM token", + zap.String("token", token), + zap.Int64("userID", notification.RecipientID), + zap.Time("timestamp", time.Now()), + ) + } + } continue // Continue with other tokens } diff --git a/internal/services/questions/service.go b/internal/services/questions/service.go new file mode 100644 index 0000000..eec79de --- /dev/null +++ b/internal/services/questions/service.go @@ -0,0 +1,177 @@ +package questions + +import ( + "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/ports" + "context" +) + +type Service struct { + questionStore ports.QuestionStore +} + +func NewService(questionStore ports.QuestionStore) *Service { + return &Service{ + questionStore: questionStore, + } +} + +// Questions + +func (s *Service) CreateQuestion(ctx context.Context, input domain.CreateQuestionInput) (domain.Question, error) { + return s.questionStore.CreateQuestion(ctx, input) +} + +func (s *Service) GetQuestionByID(ctx context.Context, id int64) (domain.Question, error) { + return s.questionStore.GetQuestionByID(ctx, id) +} + +func (s *Service) GetQuestionWithDetails(ctx context.Context, id int64) (domain.QuestionWithDetails, error) { + return s.questionStore.GetQuestionWithDetails(ctx, id) +} + +func (s *Service) ListQuestions(ctx context.Context, questionType, difficulty, status *string, limit, offset int32) ([]domain.Question, int64, error) { + return s.questionStore.ListQuestions(ctx, questionType, difficulty, status, limit, offset) +} + +func (s *Service) SearchQuestions(ctx context.Context, query string, limit, offset int32) ([]domain.Question, int64, error) { + return s.questionStore.SearchQuestions(ctx, query, limit, offset) +} + +func (s *Service) UpdateQuestion(ctx context.Context, id int64, input domain.CreateQuestionInput) error { + return s.questionStore.UpdateQuestion(ctx, id, input) +} + +func (s *Service) ArchiveQuestion(ctx context.Context, id int64) error { + return s.questionStore.ArchiveQuestion(ctx, id) +} + +func (s *Service) DeleteQuestion(ctx context.Context, id int64) error { + return s.questionStore.DeleteQuestion(ctx, id) +} + +// Question Options + +func (s *Service) CreateQuestionOption(ctx context.Context, questionID int64, optionText string, optionOrder *int32, isCorrect bool) (domain.QuestionOption, error) { + return s.questionStore.CreateQuestionOption(ctx, questionID, optionText, optionOrder, isCorrect) +} + +func (s *Service) GetOptionsByQuestionID(ctx context.Context, questionID int64) ([]domain.QuestionOption, error) { + return s.questionStore.GetOptionsByQuestionID(ctx, questionID) +} + +func (s *Service) UpdateQuestionOption(ctx context.Context, id int64, optionText *string, optionOrder *int32, isCorrect *bool) error { + return s.questionStore.UpdateQuestionOption(ctx, id, optionText, optionOrder, isCorrect) +} + +func (s *Service) DeleteQuestionOption(ctx context.Context, id int64) error { + return s.questionStore.DeleteQuestionOption(ctx, id) +} + +func (s *Service) DeleteOptionsByQuestionID(ctx context.Context, questionID int64) error { + return s.questionStore.DeleteOptionsByQuestionID(ctx, questionID) +} + +// Question Short Answers + +func (s *Service) CreateQuestionShortAnswer(ctx context.Context, questionID int64, acceptableAnswer string, matchType *string) (domain.QuestionShortAnswer, error) { + return s.questionStore.CreateQuestionShortAnswer(ctx, questionID, acceptableAnswer, matchType) +} + +func (s *Service) GetShortAnswersByQuestionID(ctx context.Context, questionID int64) ([]domain.QuestionShortAnswer, error) { + return s.questionStore.GetShortAnswersByQuestionID(ctx, questionID) +} + +func (s *Service) UpdateQuestionShortAnswer(ctx context.Context, id int64, acceptableAnswer, matchType *string) error { + return s.questionStore.UpdateQuestionShortAnswer(ctx, id, acceptableAnswer, matchType) +} + +func (s *Service) DeleteQuestionShortAnswer(ctx context.Context, id int64) error { + return s.questionStore.DeleteQuestionShortAnswer(ctx, id) +} + +func (s *Service) DeleteShortAnswersByQuestionID(ctx context.Context, questionID int64) error { + return s.questionStore.DeleteShortAnswersByQuestionID(ctx, questionID) +} + +// Question Sets + +func (s *Service) CreateQuestionSet(ctx context.Context, input domain.CreateQuestionSetInput) (domain.QuestionSet, error) { + return s.questionStore.CreateQuestionSet(ctx, input) +} + +func (s *Service) GetQuestionSetByID(ctx context.Context, id int64) (domain.QuestionSet, error) { + return s.questionStore.GetQuestionSetByID(ctx, id) +} + +func (s *Service) GetQuestionSetsByOwner(ctx context.Context, ownerType string, ownerID int64) ([]domain.QuestionSet, error) { + return s.questionStore.GetQuestionSetsByOwner(ctx, ownerType, ownerID) +} + +func (s *Service) GetQuestionSetsByType(ctx context.Context, setType string, limit, offset int32) ([]domain.QuestionSet, int64, error) { + return s.questionStore.GetQuestionSetsByType(ctx, setType, limit, offset) +} + +func (s *Service) GetPublishedQuestionSetsByOwner(ctx context.Context, ownerType string, ownerID int64) ([]domain.QuestionSet, error) { + return s.questionStore.GetPublishedQuestionSetsByOwner(ctx, ownerType, ownerID) +} + +func (s *Service) GetInitialAssessmentSet(ctx context.Context) (domain.QuestionSet, error) { + return s.questionStore.GetInitialAssessmentSet(ctx) +} + +func (s *Service) UpdateQuestionSet(ctx context.Context, id int64, input domain.CreateQuestionSetInput) error { + return s.questionStore.UpdateQuestionSet(ctx, id, input) +} + +func (s *Service) ArchiveQuestionSet(ctx context.Context, id int64) error { + return s.questionStore.ArchiveQuestionSet(ctx, id) +} + +func (s *Service) DeleteQuestionSet(ctx context.Context, id int64) error { + return s.questionStore.DeleteQuestionSet(ctx, id) +} + +// Question Set Items + +func (s *Service) AddQuestionToSet(ctx context.Context, setID, questionID int64, displayOrder *int32) (domain.QuestionSetItem, error) { + return s.questionStore.AddQuestionToSet(ctx, setID, questionID, displayOrder) +} + +func (s *Service) GetQuestionSetItems(ctx context.Context, setID int64) ([]domain.QuestionSetItemWithQuestion, error) { + return s.questionStore.GetQuestionSetItems(ctx, setID) +} + +func (s *Service) GetPublishedQuestionsInSet(ctx context.Context, setID int64) ([]domain.QuestionSetItemWithQuestion, error) { + return s.questionStore.GetPublishedQuestionsInSet(ctx, setID) +} + +func (s *Service) RemoveQuestionFromSet(ctx context.Context, setID, questionID int64) error { + return s.questionStore.RemoveQuestionFromSet(ctx, setID, questionID) +} + +func (s *Service) UpdateQuestionOrder(ctx context.Context, setID, questionID int64, displayOrder int32) error { + return s.questionStore.UpdateQuestionOrder(ctx, setID, questionID, displayOrder) +} + +func (s *Service) CountQuestionsInSet(ctx context.Context, setID int64) (int64, error) { + return s.questionStore.CountQuestionsInSet(ctx, setID) +} + +func (s *Service) GetQuestionSetsContainingQuestion(ctx context.Context, questionID int64) ([]domain.QuestionSet, error) { + return s.questionStore.GetQuestionSetsContainingQuestion(ctx, questionID) +} + +// User Personas in Question Sets + +func (s *Service) AddUserPersonaToQuestionSet(ctx context.Context, questionSetID, userID int64, displayOrder int32) error { + return s.questionStore.AddUserPersonaToQuestionSet(ctx, questionSetID, userID, displayOrder) +} + +func (s *Service) RemoveUserPersonaFromQuestionSet(ctx context.Context, questionSetID, userID int64) error { + return s.questionStore.RemoveUserPersonaFromQuestionSet(ctx, questionSetID, userID) +} + +func (s *Service) GetUserPersonasByQuestionSetID(ctx context.Context, questionSetID int64) ([]domain.UserPersona, error) { + return s.questionStore.GetUserPersonasByQuestionSetID(ctx, questionSetID) +} diff --git a/internal/services/subscriptions/service.go b/internal/services/subscriptions/service.go new file mode 100644 index 0000000..c936e57 --- /dev/null +++ b/internal/services/subscriptions/service.go @@ -0,0 +1,158 @@ +package subscriptions + +import ( + "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/ports" + "context" + "errors" + "time" +) + +var ( + ErrPlanNotFound = errors.New("subscription plan not found") + ErrSubscriptionNotFound = errors.New("subscription not found") + ErrAlreadySubscribed = errors.New("user already has an active subscription") + ErrInvalidPlan = errors.New("invalid subscription plan") +) + +type Service struct { + store ports.SubscriptionStore +} + +func NewService(store ports.SubscriptionStore) *Service { + return &Service{store: store} +} + +// ===================== +// Subscription Plans +// ===================== + +func (s *Service) CreatePlan(ctx context.Context, input domain.CreateSubscriptionPlanInput) (*domain.SubscriptionPlan, error) { + return s.store.CreateSubscriptionPlan(ctx, input) +} + +func (s *Service) GetPlanByID(ctx context.Context, id int64) (*domain.SubscriptionPlan, error) { + return s.store.GetSubscriptionPlanByID(ctx, id) +} + +func (s *Service) ListPlans(ctx context.Context, activeOnly bool) ([]domain.SubscriptionPlan, error) { + return s.store.ListSubscriptionPlans(ctx, activeOnly) +} + +func (s *Service) UpdatePlan(ctx context.Context, id int64, input domain.UpdateSubscriptionPlanInput) error { + return s.store.UpdateSubscriptionPlan(ctx, id, input) +} + +func (s *Service) DeletePlan(ctx context.Context, id int64) error { + return s.store.DeleteSubscriptionPlan(ctx, id) +} + +// ===================== +// User Subscriptions +// ===================== + +// Subscribe creates a new subscription for a user +func (s *Service) Subscribe(ctx context.Context, userID, planID int64, paymentRef, paymentMethod *string) (*domain.UserSubscription, error) { + // Check if user already has an active subscription + hasActive, err := s.store.HasActiveSubscription(ctx, userID) + if err != nil { + return nil, err + } + if hasActive { + return nil, ErrAlreadySubscribed + } + + // Get the plan to calculate expiry + plan, err := s.store.GetSubscriptionPlanByID(ctx, planID) + if err != nil { + return nil, ErrPlanNotFound + } + if !plan.IsActive { + return nil, ErrInvalidPlan + } + + // Calculate expiry date + startsAt := time.Now() + expiresAt := domain.CalculateExpiryDate(startsAt, plan.DurationValue, plan.DurationUnit) + + input := domain.CreateUserSubscriptionInput{ + UserID: userID, + PlanID: planID, + StartsAt: &startsAt, + ExpiresAt: expiresAt, + Status: strPtr(string(domain.SubscriptionStatusActive)), + PaymentReference: paymentRef, + PaymentMethod: paymentMethod, + AutoRenew: boolPtr(false), + } + + return s.store.CreateUserSubscription(ctx, input) +} + +func (s *Service) GetSubscriptionByID(ctx context.Context, id int64) (*domain.UserSubscription, error) { + return s.store.GetUserSubscriptionByID(ctx, id) +} + +func (s *Service) GetActiveSubscription(ctx context.Context, userID int64) (*domain.UserSubscription, error) { + return s.store.GetActiveSubscriptionByUserID(ctx, userID) +} + +func (s *Service) GetSubscriptionHistory(ctx context.Context, userID int64, limit, offset int32) ([]domain.UserSubscription, error) { + return s.store.GetUserSubscriptionHistory(ctx, userID, limit, offset) +} + +func (s *Service) HasActiveSubscription(ctx context.Context, userID int64) (bool, error) { + return s.store.HasActiveSubscription(ctx, userID) +} + +func (s *Service) CancelSubscription(ctx context.Context, subscriptionID int64) error { + return s.store.CancelUserSubscription(ctx, subscriptionID) +} + +func (s *Service) SetAutoRenew(ctx context.Context, subscriptionID int64, autoRenew bool) error { + return s.store.UpdateAutoRenew(ctx, subscriptionID, autoRenew) +} + +// RenewSubscription extends an existing subscription +func (s *Service) RenewSubscription(ctx context.Context, subscriptionID int64) (*domain.UserSubscription, error) { + sub, err := s.store.GetUserSubscriptionByID(ctx, subscriptionID) + if err != nil { + return nil, ErrSubscriptionNotFound + } + + plan, err := s.store.GetSubscriptionPlanByID(ctx, sub.PlanID) + if err != nil { + return nil, ErrPlanNotFound + } + + // Calculate new expiry from current expiry (or now if expired) + baseTime := sub.ExpiresAt + if baseTime.Before(time.Now()) { + baseTime = time.Now() + } + newExpiry := domain.CalculateExpiryDate(baseTime, plan.DurationValue, plan.DurationUnit) + + err = s.store.ExtendSubscription(ctx, subscriptionID, newExpiry) + if err != nil { + return nil, err + } + + // Also reactivate if expired + if sub.Status == string(domain.SubscriptionStatusExpired) { + err = s.store.UpdateSubscriptionStatus(ctx, subscriptionID, string(domain.SubscriptionStatusActive)) + if err != nil { + return nil, err + } + } + + return s.store.GetUserSubscriptionByID(ctx, subscriptionID) +} + +// Helper functions +func strPtr(s string) *string { + return &s +} + +func boolPtr(b bool) *bool { + return &b +} diff --git a/internal/services/team/service.go b/internal/services/team/service.go new file mode 100644 index 0000000..29d4706 --- /dev/null +++ b/internal/services/team/service.go @@ -0,0 +1,15 @@ +package team + +import ( + "Yimaru-Backend/internal/ports" +) + +type Service struct { + teamStore ports.TeamStore +} + +func NewService(teamStore ports.TeamStore) *Service { + return &Service{ + teamStore: teamStore, + } +} diff --git a/internal/services/team/team.go b/internal/services/team/team.go new file mode 100644 index 0000000..6b26f28 --- /dev/null +++ b/internal/services/team/team.go @@ -0,0 +1,164 @@ +package team + +import ( + "context" + "time" + + "Yimaru-Backend/internal/domain" + + "golang.org/x/crypto/bcrypt" +) + +const bcryptCost = 10 + +func (s *Service) CreateTeamMember(ctx context.Context, req domain.CreateTeamMemberReq, createdBy *int64) (domain.TeamMember, error) { + if !domain.TeamRole(req.TeamRole).IsValid() { + return domain.TeamMember{}, domain.ErrInvalidTeamRole + } + + if req.EmploymentType != "" && !domain.EmploymentType(req.EmploymentType).IsValid() { + return domain.TeamMember{}, domain.ErrInvalidEmploymentType + } + + exists, err := s.teamStore.CheckTeamMemberEmailExists(ctx, req.Email) + if err != nil { + return domain.TeamMember{}, err + } + if exists { + return domain.TeamMember{}, domain.ErrTeamMemberEmailExists + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost) + if err != nil { + return domain.TeamMember{}, err + } + + var hireDate *time.Time + if req.HireDate != "" { + parsed, err := time.Parse("2006-01-02", req.HireDate) + if err != nil { + return domain.TeamMember{}, err + } + hireDate = &parsed + } + + member := domain.TeamMember{ + FirstName: req.FirstName, + LastName: req.LastName, + Email: req.Email, + PhoneNumber: req.PhoneNumber, + Password: hashedPassword, + TeamRole: domain.TeamRole(req.TeamRole), + Department: req.Department, + JobTitle: req.JobTitle, + EmploymentType: domain.EmploymentType(req.EmploymentType), + HireDate: hireDate, + ProfilePictureURL: req.ProfilePictureURL, + Bio: req.Bio, + WorkPhone: req.WorkPhone, + EmergencyContact: req.EmergencyContact, + Status: domain.TeamMemberStatusActive, + Permissions: req.Permissions, + CreatedBy: createdBy, + } + + return s.teamStore.CreateTeamMember(ctx, member) +} + +func (s *Service) GetTeamMemberByID(ctx context.Context, id int64) (domain.TeamMember, error) { + return s.teamStore.GetTeamMemberByID(ctx, id) +} + +func (s *Service) GetTeamMemberByEmail(ctx context.Context, email string) (domain.TeamMember, error) { + return s.teamStore.GetTeamMemberByEmail(ctx, email) +} + +func (s *Service) GetAllTeamMembers(ctx context.Context, filter domain.TeamMemberFilter) ([]domain.TeamMember, int64, error) { + var limit int32 = 10 + var offset int32 = 0 + + if filter.PageSize > 0 { + limit = int32(filter.PageSize) + } + if filter.Page > 0 && filter.PageSize > 0 { + offset = int32((filter.Page - 1) * filter.PageSize) + } + + return s.teamStore.GetAllTeamMembers(ctx, filter.TeamRole, filter.Department, filter.Status, limit, offset) +} + +func (s *Service) SearchTeamMembers(ctx context.Context, search string, teamRole, status *string) ([]domain.TeamMember, error) { + return s.teamStore.SearchTeamMembers(ctx, search, teamRole, status) +} + +func (s *Service) UpdateTeamMember(ctx context.Context, req domain.UpdateTeamMemberReq) error { + if req.TeamRole != "" && !domain.TeamRole(req.TeamRole).IsValid() { + return domain.ErrInvalidTeamRole + } + + if req.EmploymentType != "" && !domain.EmploymentType(req.EmploymentType).IsValid() { + return domain.ErrInvalidEmploymentType + } + + return s.teamStore.UpdateTeamMember(ctx, req) +} + +func (s *Service) UpdateTeamMemberStatus(ctx context.Context, req domain.UpdateTeamMemberStatusReq) error { + if !domain.TeamMemberStatus(req.Status).IsValid() { + return domain.ErrInvalidTeamMemberStatus + } + + return s.teamStore.UpdateTeamMemberStatus(ctx, req) +} + +func (s *Service) DeleteTeamMember(ctx context.Context, memberID int64) error { + return s.teamStore.DeleteTeamMember(ctx, memberID) +} + +func (s *Service) GetTeamMemberStats(ctx context.Context) (domain.TeamMemberStats, error) { + return s.teamStore.CountTeamMembersByStatus(ctx) +} + +func (s *Service) Login(ctx context.Context, req domain.TeamMemberLoginReq) (domain.TeamMember, error) { + member, err := s.teamStore.GetTeamMemberByEmail(ctx, req.Email) + if err != nil { + return domain.TeamMember{}, err + } + + if err := bcrypt.CompareHashAndPassword(member.Password, []byte(req.Password)); err != nil { + return domain.TeamMember{}, err + } + + if err := s.teamStore.UpdateTeamMemberLastLogin(ctx, member.ID); err != nil { + return domain.TeamMember{}, err + } + + return member, nil +} + +func (s *Service) ChangePassword(ctx context.Context, memberID int64, currentPassword, newPassword string) error { + member, err := s.teamStore.GetTeamMemberByID(ctx, memberID) + if err != nil { + return err + } + + if err := bcrypt.CompareHashAndPassword(member.Password, []byte(currentPassword)); err != nil { + return err + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcryptCost) + if err != nil { + return err + } + + return s.teamStore.UpdateTeamMemberPassword(ctx, memberID, string(hashedPassword)) +} + +func (s *Service) ResetPassword(ctx context.Context, memberID int64, newPassword string) error { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcryptCost) + if err != nil { + return err + } + + return s.teamStore.UpdateTeamMemberPassword(ctx, memberID, string(hashedPassword)) +} diff --git a/internal/services/user/common.go b/internal/services/user/common.go index 1408ab8..2f2946a 100644 --- a/internal/services/user/common.go +++ b/internal/services/user/common.go @@ -100,7 +100,6 @@ func (s *Service) SendOtp(ctx context.Context, userID int64, sentTo string, otpF return s.otpStore.CreateOtp(ctx, otp) } - func hashPassword(plaintextPassword string) ([]byte, error) { hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), 12) if err != nil { diff --git a/internal/services/user/interface.go b/internal/services/user/interface.go index 254e534..22873f2 100644 --- a/internal/services/user/interface.go +++ b/internal/services/user/interface.go @@ -2,11 +2,12 @@ package user import ( "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/ports" "context" ) type UserStore interface { - IsProfileCompleted(ctx context.Context, userId int64) (bool, error) + GetProfileCompletionStatus(ctx context.Context, userId int64) (ports.ProfileCompletionStatus, error) UpdateUserKnowledgeLevel(ctx context.Context, userID int64, knowledgeLevel string) error IsUserPending(ctx context.Context, userName string) (bool, error) GetUserByUserName( diff --git a/internal/services/user/user.go b/internal/services/user/user.go index df253f7..ac46277 100644 --- a/internal/services/user/user.go +++ b/internal/services/user/user.go @@ -2,6 +2,7 @@ package user import ( "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/ports" "context" "strconv" ) @@ -18,8 +19,8 @@ func (s *Service) IsUserPending(ctx context.Context, userID int64) (bool, error) return s.userStore.IsUserPending(ctx, userID) } -func (s *Service) IsProfileCompleted(ctx context.Context, userId int64) (bool, error) { - return s.userStore.IsProfileCompleted(ctx, userId) +func (s *Service) GetProfileCompletionStatus(ctx context.Context, userId int64) (ports.ProfileCompletionStatus, error) { + return s.userStore.GetProfileCompletionStatus(ctx, userId) } func (s *Service) SearchUserByNameOrPhone(ctx context.Context, searchString string, role *int64) ([]domain.User, error) { @@ -86,3 +87,11 @@ func (s *Service) RegisterDevice(ctx context.Context, userID int64, deviceToken, func (s *Service) GetUserDeviceTokens(ctx context.Context, userID int64) ([]string, error) { return s.userStore.GetUserDeviceTokens(ctx, userID) } + +func (s *Service) DeactivateDevice(ctx context.Context, userID int64, deviceToken string) error { + return s.userStore.DeactivateDevice(ctx, userID, deviceToken) +} + +func (s *Service) DeactivateAllUserDevices(ctx context.Context, userID int64) error { + return s.userStore.DeactivateAllUserDevices(ctx, userID) +} diff --git a/internal/services/vimeo/service.go b/internal/services/vimeo/service.go new file mode 100644 index 0000000..78ee649 --- /dev/null +++ b/internal/services/vimeo/service.go @@ -0,0 +1,173 @@ +package vimeo + +import ( + "Yimaru-Backend/internal/pkgs/vimeo" + "context" + "fmt" + + "go.uber.org/zap" +) + +type Service struct { + client *vimeo.Client + logger *zap.Logger +} + +func NewService(accessToken string, logger *zap.Logger) *Service { + return &Service{ + client: vimeo.NewClient(accessToken), + logger: logger, + } +} + +type VideoInfo struct { + VimeoID string + URI string + Name string + Description string + Duration int + Width int + Height int + Link string + EmbedURL string + EmbedHTML string + ThumbnailURL string + Status string + TranscodeStatus string +} + +type UploadResult struct { + VimeoID string + URI string + Link string + UploadLink string + Status string +} + +func (s *Service) GetVideoInfo(ctx context.Context, videoID string) (*VideoInfo, error) { + video, err := s.client.GetVideo(ctx, videoID) + if err != nil { + s.logger.Error("Failed to get video from Vimeo", zap.String("video_id", videoID), zap.Error(err)) + return nil, fmt.Errorf("failed to get video: %w", err) + } + + info := &VideoInfo{ + VimeoID: videoID, + URI: video.URI, + Name: video.Name, + Description: video.Description, + Duration: video.Duration, + Width: video.Width, + Height: video.Height, + Link: video.Link, + Status: video.Status, + } + + if video.PlayerEmbedURL != "" { + info.EmbedURL = video.PlayerEmbedURL + } else { + info.EmbedURL = vimeo.GenerateEmbedURL(videoID, nil) + } + + if video.Embed != nil { + info.EmbedHTML = video.Embed.HTML + } + + if video.Pictures != nil && len(video.Pictures.Sizes) > 0 { + info.ThumbnailURL = video.Pictures.Sizes[len(video.Pictures.Sizes)-1].Link + } + + if video.Transcode != nil { + info.TranscodeStatus = video.Transcode.Status + } + + return info, nil +} + +func (s *Service) CreatePullUpload(ctx context.Context, name, description, sourceURL string, fileSize int64) (*UploadResult, error) { + resp, err := s.client.CreatePullUpload(ctx, name, description, sourceURL, fileSize) + if err != nil { + s.logger.Error("Failed to create pull upload", zap.String("source_url", sourceURL), zap.Error(err)) + return nil, fmt.Errorf("failed to create pull upload: %w", err) + } + + videoID := vimeo.ExtractVideoID(resp.URI) + + return &UploadResult{ + VimeoID: videoID, + URI: resp.URI, + Link: resp.Link, + UploadLink: resp.Upload.UploadLink, + Status: resp.Upload.Status, + }, nil +} + +func (s *Service) CreateTusUpload(ctx context.Context, name, description string, fileSize int64) (*UploadResult, error) { + resp, err := s.client.CreateTusUpload(ctx, name, description, fileSize) + if err != nil { + s.logger.Error("Failed to create TUS upload", zap.Error(err)) + return nil, fmt.Errorf("failed to create TUS upload: %w", err) + } + + videoID := vimeo.ExtractVideoID(resp.URI) + + return &UploadResult{ + VimeoID: videoID, + URI: resp.URI, + Link: resp.Link, + UploadLink: resp.Upload.UploadLink, + Status: resp.Upload.Status, + }, nil +} + +func (s *Service) UpdateVideoMetadata(ctx context.Context, videoID string, name, description *string) (*VideoInfo, error) { + req := &vimeo.UpdateVideoRequest{ + Name: name, + Description: description, + } + + video, err := s.client.UpdateVideo(ctx, videoID, req) + if err != nil { + s.logger.Error("Failed to update video metadata", zap.String("video_id", videoID), zap.Error(err)) + return nil, fmt.Errorf("failed to update video: %w", err) + } + + info := &VideoInfo{ + VimeoID: videoID, + URI: video.URI, + Name: video.Name, + Description: video.Description, + Duration: video.Duration, + Link: video.Link, + } + + return info, nil +} + +func (s *Service) DeleteVideo(ctx context.Context, videoID string) error { + if err := s.client.DeleteVideo(ctx, videoID); err != nil { + s.logger.Error("Failed to delete video from Vimeo", zap.String("video_id", videoID), zap.Error(err)) + return fmt.Errorf("failed to delete video: %w", err) + } + return nil +} + +func (s *Service) GetTranscodeStatus(ctx context.Context, videoID string) (string, error) { + status, err := s.client.GetTranscodeStatus(ctx, videoID) + if err != nil { + return "", fmt.Errorf("failed to get transcode status: %w", err) + } + return status, nil +} + +func (s *Service) GetEmbedCode(ctx context.Context, videoID string, width, height int, opts *vimeo.EmbedOptions) (string, error) { + return vimeo.GenerateIframeEmbed(videoID, width, height, opts), nil +} + +func (s *Service) GetOEmbed(ctx context.Context, vimeoURL string, width, height int) (*vimeo.OEmbedResponse, error) { + return vimeo.GetOEmbed(ctx, vimeoURL, width, height) +} + +func (s *Service) GeneratePlayerURL(videoID string, opts *vimeo.EmbedOptions) string { + return vimeo.GenerateEmbedURL(videoID, opts) +} diff --git a/internal/web_server/app.go b/internal/web_server/app.go index 0987514..cb4a801 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -8,7 +8,11 @@ import ( "Yimaru-Backend/internal/services/course_management" issuereporting "Yimaru-Backend/internal/services/issue_reporting" notificationservice "Yimaru-Backend/internal/services/notification" + "Yimaru-Backend/internal/services/questions" "Yimaru-Backend/internal/services/recommendation" + "Yimaru-Backend/internal/services/subscriptions" + "Yimaru-Backend/internal/services/team" + vimeoservice "Yimaru-Backend/internal/services/vimeo" "Yimaru-Backend/internal/services/settings" "Yimaru-Backend/internal/services/transaction" @@ -28,8 +32,12 @@ import ( type App struct { assessmentSvc *assessment.Service courseSvc *course_management.Service + questionsSvc *questions.Service + subscriptionsSvc *subscriptions.Service arifpaySvc *arifpay.ArifpayService issueReportingSvc *issuereporting.Service + vimeoSvc *vimeoservice.Service + teamSvc *team.Service fiber *fiber.App recommendationSvc recommendation.RecommendationService cfg *config.Config @@ -49,8 +57,12 @@ type App struct { func NewApp( assessmentSvc *assessment.Service, courseSvc *course_management.Service, + questionsSvc *questions.Service, + subscriptionsSvc *subscriptions.Service, arifpaySvc *arifpay.ArifpayService, issueReportingSvc *issuereporting.Service, + vimeoSvc *vimeoservice.Service, + teamSvc *team.Service, port int, validator *customvalidator.CustomValidator, settingSvc *settings.Service, authSvc *authentication.Service, @@ -80,9 +92,13 @@ func NewApp( app.Static("/static", "./static") s := &App{ - assessmentSvc: assessmentSvc, - courseSvc: courseSvc, - arifpaySvc: arifpaySvc, + assessmentSvc: assessmentSvc, + courseSvc: courseSvc, + questionsSvc: questionsSvc, + subscriptionsSvc: subscriptionsSvc, + arifpaySvc: arifpaySvc, + vimeoSvc: vimeoSvc, + teamSvc: teamSvc, // issueReportingSvc: issueReportingSvc, fiber: app, port: port, diff --git a/internal/web_server/handlers/arifpay.go b/internal/web_server/handlers/arifpay.go index 18564d2..6a05c76 100644 --- a/internal/web_server/handlers/arifpay.go +++ b/internal/web_server/handlers/arifpay.go @@ -1,342 +1,452 @@ package handlers -// import ( -// "Yimaru-Backend/internal/domain" +import ( + "Yimaru-Backend/internal/domain" + "strconv" -// "github.com/gofiber/fiber/v2" -// ) + "github.com/gofiber/fiber/v2" +) -// // CreateCheckoutSessionHandler initializes a checkout session with Arifpay. -// // -// // @Summary Create Arifpay Checkout Session -// // @Description Creates a payment session using Arifpay and returns a redirect URL. -// // @Tags Arifpay -// // @Accept json -// // @Produce json -// // @Param request body domain.CheckoutSessionClientRequest true "Checkout session request payload" -// // @Success 200 {object} domain.Response -// // @Failure 400 {object} domain.ErrorResponse -// // @Failure 500 {object} domain.ErrorResponse -// // @Router /api/v1/arifpay/checkout [post] -// func (h *Handler) CreateCheckoutSessionHandler(c *fiber.Ctx) error { +// ===================== +// Payment Types +// ===================== -// userId, ok := c.Locals("user_id").(int64) -// if !ok { -// return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ -// Error: "missing user id", -// Message: "Unauthorized", -// }) -// } +type initiatePaymentReq struct { + PlanID int64 `json:"plan_id" validate:"required"` + Phone string `json:"phone" validate:"required"` + Email string `json:"email" validate:"required,email"` +} -// var req domain.CheckoutSessionClientRequest -// if err := c.BodyParser(&req); err != nil { -// return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ -// Error: err.Error(), -// Message: "Failed to process your request", -// }) -// } +type paymentRes struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + PlanID *int64 `json:"plan_id,omitempty"` + SubscriptionID *int64 `json:"subscription_id,omitempty"` + SessionID *string `json:"session_id,omitempty"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` + PaymentMethod *string `json:"payment_method,omitempty"` + Status string `json:"status"` + PaymentURL *string `json:"payment_url,omitempty"` + PlanName *string `json:"plan_name,omitempty"` + PaidAt *string `json:"paid_at,omitempty"` + ExpiresAt *string `json:"expires_at,omitempty"` + CreatedAt string `json:"created_at"` +} -// data, err := h.arifpaySvc.CreateCheckoutSession(req, true, userId) -// if err != nil { -// return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ -// Error: err.Error(), -// Message: "Failed to process your request", -// }) -// } +// ===================== +// Subscription Payment Handlers +// ===================== -// return c.Status(fiber.StatusOK).JSON(domain.Response{ -// Message: "Checkout session created successfully", -// Data: data, -// Success: true, -// StatusCode: fiber.StatusOK, -// }) -// } +// InitiateSubscriptionPayment godoc +// @Summary Initiate subscription payment +// @Description Creates a payment session for a subscription plan +// @Tags payments +// @Accept json +// @Produce json +// @Param body body initiatePaymentReq true "Payment request" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/payments/subscribe [post] +func (h *Handler) InitiateSubscriptionPayment(c *fiber.Ctx) error { + userID, ok := c.Locals("user_id").(int64) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ + Message: "Unauthorized", + }) + } -// // CancelCheckoutSessionHandler cancels an existing Arifpay checkout session. -// // -// // @Summary Cancel Arifpay Checkout Session -// // @Description Cancels a payment session using Arifpay before completion. -// // @Tags Arifpay -// // @Accept json -// // @Produce json -// // @Param sessionId path string true "Checkout session ID" -// // @Success 200 {object} domain.Response -// // @Failure 400 {object} domain.ErrorResponse -// // @Failure 500 {object} domain.ErrorResponse -// // @Router /api/v1/arifpay/checkout/cancel/{sessionId} [post] -// func (h *Handler) CancelCheckoutSessionHandler(c *fiber.Ctx) error { -// sessionID := c.Params("sessionId") -// if sessionID == "" { -// return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ -// Error: "missing session ID", -// Message: "Session ID is required", -// }) -// } + var req initiatePaymentReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } -// data, err := h.arifpaySvc.CancelCheckoutSession(c.Context(), sessionID) -// if err != nil { -// return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ -// Error: err.Error(), -// Message: "Failed to cancel checkout session", -// }) -// } + result, err := h.arifpaySvc.InitiateSubscriptionPayment(c.Context(), userID, domain.InitiateSubscriptionPaymentRequest{ + PlanID: req.PlanID, + Phone: req.Phone, + Email: req.Email, + }) + if err != nil { + status := fiber.StatusInternalServerError + if err.Error() == "user already has an active subscription" { + status = fiber.StatusConflict + } + return c.Status(status).JSON(domain.ErrorResponse{ + Message: "Failed to initiate payment", + Error: err.Error(), + }) + } -// return c.Status(fiber.StatusOK).JSON(domain.Response{ -// Message: "Checkout session canceled successfully", -// Data: data, -// Success: true, -// StatusCode: fiber.StatusOK, -// }) -// } + return c.JSON(domain.Response{ + Message: "Payment initiated successfully", + Data: result, + }) +} -// // HandleWebhook processes Arifpay webhook notifications. -// // -// // @Summary Handle Arifpay C2B Webhook -// // @Description Handles webhook notifications from Arifpay for C2B transfers and updates transfer + wallet status. -// // @Tags Arifpay -// // @Accept json -// // @Produce json -// // @Param request body domain.WebhookRequest true "Arifpay webhook payload" -// // @Success 200 {object} domain.Response -// // @Failure 400 {object} domain.ErrorResponse -// // @Failure 500 {object} domain.ErrorResponse -// // @Router /api/v1/arifpay/c2b-webhook [post] -// func (h *Handler) HandleArifpayC2BWebhook(c *fiber.Ctx) error { -// var req domain.WebhookRequest -// if err := c.BodyParser(&req); err != nil { -// return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ -// Error: err.Error(), -// Message: "Invalid webhook payload", -// }) -// } +// VerifyPayment godoc +// @Summary Verify payment status +// @Description Checks the payment status with the payment provider +// @Tags payments +// @Produce json +// @Param session_id path string true "Session ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Router /api/v1/payments/verify/{session_id} [get] +func (h *Handler) VerifyPayment(c *fiber.Ctx) error { + sessionID := c.Params("session_id") + if sessionID == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Session ID is required", + }) + } -// // 🚨 Decide how to get userId: -// // If you get it from auth context/middleware, extract it here. -// // For now, let's assume userId comes from your auth claims: -// // userId, ok := c.Locals("user_id").(int64) -// // if !ok { -// // return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ -// // Error: "missing user id", -// // Message: "Unauthorized", -// // }) -// // } + payment, err := h.arifpaySvc.VerifyPayment(c.Context(), sessionID) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Payment not found or verification failed", + Error: err.Error(), + }) + } -// err := h.arifpaySvc.ProcessWebhook(c.Context(), req, true) -// if err != nil { -// return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ -// Error: err.Error(), -// Message: "Failed to process webhook", -// }) -// } + return c.JSON(domain.Response{ + Message: "Payment status retrieved", + Data: paymentToRes(payment), + }) +} -// return c.Status(fiber.StatusOK).JSON(domain.Response{ -// Message: "Webhook processed successfully", -// Success: true, -// StatusCode: fiber.StatusOK, -// }) -// } +// GetMyPayments godoc +// @Summary Get payment history +// @Description Returns the authenticated user's payment history +// @Tags payments +// @Produce json +// @Param limit query int false "Limit" default(20) +// @Param offset query int false "Offset" default(0) +// @Success 200 {object} domain.Response +// @Router /api/v1/payments [get] +func (h *Handler) GetMyPayments(c *fiber.Ctx) error { + userID, ok := c.Locals("user_id").(int64) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ + Message: "Unauthorized", + }) + } -// // HandleWebhook processes Arifpay webhook notifications. -// // -// // @Summary Handle Arifpay B2C Webhook -// // @Description Handles webhook notifications from Arifpay for B2C transfers and updates transfer + wallet status. -// // @Tags Arifpay -// // @Accept json -// // @Produce json -// // @Param request body domain.WebhookRequest true "Arifpay webhook payload" -// // @Success 200 {object} domain.Response -// // @Failure 400 {object} domain.ErrorResponse -// // @Failure 500 {object} domain.ErrorResponse -// // @Router /api/v1/arifpay/b2c-webhook [post] -// func (h *Handler) HandleArifpayB2CWebhook(c *fiber.Ctx) error { -// var req domain.WebhookRequest -// if err := c.BodyParser(&req); err != nil { -// return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ -// Error: err.Error(), -// Message: "Invalid webhook payload", -// }) -// } + limit, _ := strconv.Atoi(c.Query("limit", "20")) + offset, _ := strconv.Atoi(c.Query("offset", "0")) -// // 🚨 Decide how to get userId: -// // If you get it from auth context/middleware, extract it here. -// // For now, let's assume userId comes from your auth claims: -// // userId, ok := c.Locals("user_id").(int64) -// // if !ok { -// // return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ -// // Error: "missing user id", -// // Message: "Unauthorized", -// // }) -// // } + payments, err := h.arifpaySvc.GetPaymentsByUser(c.Context(), userID, int32(limit), int32(offset)) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to get payment history", + Error: err.Error(), + }) + } -// err := h.arifpaySvc.ProcessWebhook(c.Context(), req, false) -// if err != nil { -// return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ -// Error: err.Error(), -// Message: "Failed to process webhook", -// }) -// } + result := make([]paymentRes, len(payments)) + for i, p := range payments { + result[i] = *paymentToRes(&p) + } -// return c.Status(fiber.StatusOK).JSON(domain.Response{ -// Message: "Webhook processed successfully", -// Success: true, -// StatusCode: fiber.StatusOK, -// }) -// } + return c.JSON(domain.Response{ + Message: "Payment history retrieved successfully", + Data: result, + }) +} -// // ArifpayVerifyByTransactionIDHandler godoc -// // @Summary Verify Arifpay Transaction -// // @Description Verifies a transaction using transaction ID and payment type -// // @Tags Arifpay -// // @Accept json -// // @Produce json -// // @Param request body domain.ArifpayVerifyByTransactionIDRequest true "Transaction verification payload" -// // @Success 200 {object} domain.Response -// // @Failure 400 {object} domain.ErrorResponse -// // @Failure 502 {object} domain.ErrorResponse -// // @Router /api/v1/arifpay/transaction-id/verify-transaction [post] -// func (h *Handler) ArifpayVerifyByTransactionIDHandler(c *fiber.Ctx) error { -// var req domain.ArifpayVerifyByTransactionIDRequest -// if err := c.BodyParser(&req); err != nil { -// return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ -// Error: err.Error(), -// Message: "Failed to parse request body", -// }) -// } +// GetPaymentByID godoc +// @Summary Get payment details +// @Description Returns details of a specific payment +// @Tags payments +// @Produce json +// @Param id path int true "Payment ID" +// @Success 200 {object} domain.Response +// @Failure 404 {object} domain.ErrorResponse +// @Router /api/v1/payments/{id} [get] +func (h *Handler) GetPaymentByID(c *fiber.Ctx) error { + userID, ok := c.Locals("user_id").(int64) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ + Message: "Unauthorized", + }) + } -// resp, err := h.arifpaySvc.VerifyTransactionByTransactionID(c.Context(), req) -// if err != nil { -// return c.Status(fiber.StatusBadGateway).JSON(domain.ErrorResponse{ -// Error: err.Error(), -// Message: "Failed to verify transaction", -// }) -// } + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid payment ID", + }) + } -// return c.Status(fiber.StatusOK).JSON(domain.Response{ -// Message: "Transaction verified successfully", -// Data: resp, -// Success: true, -// StatusCode: fiber.StatusOK, -// }) -// } + payment, err := h.arifpaySvc.GetPaymentByID(c.Context(), id) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Payment not found", + }) + } -// // ArifpayVerifyBySessionIDHandler godoc -// // @Summary Verify Arifpay Transaction by Session ID -// // @Description Verifies an Arifpay transaction using a session ID -// // @Tags Arifpay -// // @Accept json -// // @Produce json -// // @Param session_id query string true "Arifpay Session ID" -// // @Success 200 {object} domain.Response -// // @Failure 400 {object} domain.ErrorResponse -// // @Failure 502 {object} domain.ErrorResponse -// // @Router /api/v1/arifpay/session-id/verify-transaction/{session_id} [get] -// func (h *Handler) ArifpayVerifyBySessionIDHandler(c *fiber.Ctx) error { -// sessionID := c.Query("session_id") -// if sessionID == "" { -// return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ -// Error: "missing session_id", -// Message: "session_id query parameter is required", -// }) -// } + if payment.UserID != userID { + return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ + Message: "Access denied", + }) + } -// resp, err := h.arifpaySvc.VerifyTransactionBySessionID(c.Context(), sessionID) -// if err != nil { -// return c.Status(fiber.StatusBadGateway).JSON(domain.ErrorResponse{ -// Error: err.Error(), -// Message: "Failed to verify transaction", -// }) -// } + return c.JSON(domain.Response{ + Message: "Payment retrieved successfully", + Data: paymentToRes(payment), + }) +} -// return c.Status(fiber.StatusOK).JSON(domain.Response{ -// Message: "Transaction verified successfully", -// Data: resp, -// Success: true, -// StatusCode: fiber.StatusOK, -// }) -// } +// CancelPayment godoc +// @Summary Cancel a pending payment +// @Description Cancels a payment that is still pending +// @Tags payments +// @Produce json +// @Param id path int true "Payment ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Router /api/v1/payments/{id}/cancel [post] +func (h *Handler) CancelPayment(c *fiber.Ctx) error { + userID, ok := c.Locals("user_id").(int64) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ + Message: "Unauthorized", + }) + } -// // ExecuteTransfer handles B2C transfers via Telebirr, CBE, or MPESA. -// // -// // @Summary Execute B2C Transfer -// // @Description Initiates a B2C transfer using Telebirr, CBE, or MPESA depending on the "type" query parameter -// // @Tags Arifpay -// // @Accept json -// // @Produce json -// // @Param type query string true "Transfer type (telebirr, cbe, mpesa)" -// // @Param request body domain.CheckoutSessionClientRequest true "Transfer request payload" -// // @Success 200 {object} map[string]string "message: transfer executed successfully" -// // @Failure 400 {object} map[string]string "error: invalid request or unsupported transfer type" -// // @Failure 500 {object} map[string]string "error: internal server error" -// // @Router /api/v1/arifpay/b2c/transfer [post] -// func (h *Handler) ExecuteArifpayB2CTransfer(c *fiber.Ctx) error { -// transferType := c.Query("type") -// if transferType == "" { -// return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ -// Message: "Failed to process your withdrawal request", -// Error: "missing query parameter: type (telebirr, cbe, mpesa)", -// }) -// } + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid payment ID", + }) + } -// userId, ok := c.Locals("user_id").(int64) -// if !ok { -// return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ -// Error: "missing user id", -// Message: "Unauthorized", -// }) -// } + if err := h.arifpaySvc.CancelPayment(c.Context(), id, userID); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Failed to cancel payment", + Error: err.Error(), + }) + } -// var req domain.CheckoutSessionClientRequest -// if err := c.BodyParser(&req); err != nil { -// return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ -// Message: "Failed to process your withdrawal request", -// Error: "invalid request body", -// }) -// } + return c.JSON(domain.Response{ + Message: "Payment cancelled successfully", + }) +} -// var err error -// switch transferType { -// case "telebirr": -// err = h.arifpaySvc.ExecuteTelebirrB2CTransfer(c.Context(), req, userId) -// case "cbe": -// err = h.arifpaySvc.ExecuteCBEB2CTransfer(c.Context(), req, userId) -// case "mpesa": -// err = h.arifpaySvc.ExecuteMPesaB2CTransfer(c.Context(), req, userId) -// default: -// return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ -// Message: "Failed to process your withdrawal request", -// Error: "unsupported transfer type, must be one of: telebirr, cbe, mpesa", -// }) -// } +// HandleArifpayWebhook godoc +// @Summary Handle ArifPay webhook +// @Description Processes payment notifications from ArifPay +// @Tags payments +// @Accept json +// @Produce json +// @Param body body domain.WebhookRequest true "Webhook payload" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Router /api/v1/payments/webhook [post] +func (h *Handler) HandleArifpayWebhook(c *fiber.Ctx) error { + var req domain.WebhookRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid webhook payload", + Error: err.Error(), + }) + } -// if err != nil { -// return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ -// Message: "Failed to process your withdrawal request", -// Error: err.Error(), -// }) -// } + if err := h.arifpaySvc.ProcessPaymentWebhook(c.Context(), req); err != nil { + h.logger.Error("Failed to process webhook", "error", err, "nonce", req.Nonce) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to process webhook", + Error: err.Error(), + }) + } -// return c.Status(fiber.StatusOK).JSON(domain.Response{ -// Message: "Withdrawal process initiated successfully", -// Success: true, -// StatusCode: fiber.StatusOK, -// }) -// } + return c.JSON(domain.Response{ + Message: "Webhook processed successfully", + }) +} -// // GetPaymentMethodsHandler returns the list of all Arifpay payment methods -// // -// // @Summary List Arifpay Payment Methods -// // @Description Returns all payment method IDs and names for Arifpay -// // @Tags Arifpay -// // @Produce json -// // @Success 200 {object} []domain.ARIFPAYPaymentMethod -// // @Router /api/v1/arifpay/payment-methods [get] -// func (h *Handler) GetArifpayPaymentMethodsHandler(c *fiber.Ctx) error { -// methods := h.arifpaySvc.GetPaymentMethodsMapping() +// GetArifpayPaymentMethods godoc +// @Summary Get available payment methods +// @Description Returns list of supported ArifPay payment methods +// @Tags payments +// @Produce json +// @Success 200 {object} domain.Response +// @Router /api/v1/payments/methods [get] +func (h *Handler) GetArifpayPaymentMethods(c *fiber.Ctx) error { + methods := h.arifpaySvc.GetPaymentMethodsMapping() -// return c.Status(fiber.StatusOK).JSON(domain.Response{ -// Success: true, -// Message: "Arifpay payment methods fetched successfully", -// Data: methods, -// StatusCode: fiber.StatusOK, -// }) -// } + return c.JSON(domain.Response{ + Message: "Payment methods retrieved successfully", + Data: methods, + }) +} + +// ===================== +// Direct Payment Handlers +// ===================== + +type initiateDirectPaymentReq struct { + PlanID int64 `json:"plan_id" validate:"required"` + Phone string `json:"phone" validate:"required"` + Email string `json:"email" validate:"required,email"` + PaymentMethod string `json:"payment_method" validate:"required"` +} + +type verifyOTPReq struct { + SessionID string `json:"session_id" validate:"required"` + OTP string `json:"otp" validate:"required"` +} + +// InitiateDirectPayment godoc +// @Summary Initiate direct payment +// @Description Creates a payment session and initiates direct payment (OTP-based) +// @Tags payments +// @Accept json +// @Produce json +// @Param body body initiateDirectPaymentReq true "Direct payment request" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/payments/direct [post] +func (h *Handler) InitiateDirectPayment(c *fiber.Ctx) error { + userID, ok := c.Locals("user_id").(int64) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ + Message: "Unauthorized", + }) + } + + var req initiateDirectPaymentReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + + result, err := h.arifpaySvc.InitiateDirectPayment(c.Context(), userID, domain.InitiateDirectPaymentRequest{ + PlanID: req.PlanID, + Phone: req.Phone, + Email: req.Email, + PaymentMethod: domain.DirectPaymentMethod(req.PaymentMethod), + }) + if err != nil { + status := fiber.StatusInternalServerError + if err.Error() == "user already has an active subscription" { + status = fiber.StatusConflict + } + return c.Status(status).JSON(domain.ErrorResponse{ + Message: "Failed to initiate direct payment", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: result.Message, + Data: result, + }) +} + +// VerifyDirectPaymentOTP godoc +// @Summary Verify OTP for direct payment +// @Description Verifies the OTP sent for direct payment methods (Amole, HelloCash, etc.) +// @Tags payments +// @Accept json +// @Produce json +// @Param body body verifyOTPReq true "OTP verification request" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Router /api/v1/payments/direct/verify-otp [post] +func (h *Handler) VerifyDirectPaymentOTP(c *fiber.Ctx) error { + userID, ok := c.Locals("user_id").(int64) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ + Message: "Unauthorized", + }) + } + + var req verifyOTPReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + + result, err := h.arifpaySvc.VerifyDirectPaymentOTP(c.Context(), userID, domain.VerifyOTPRequest{ + SessionID: req.SessionID, + OTP: req.OTP, + }) + if err != nil { + status := fiber.StatusInternalServerError + if err.Error() == "payment not found" { + status = fiber.StatusNotFound + } + return c.Status(status).JSON(domain.ErrorResponse{ + Message: "Failed to verify OTP", + Error: err.Error(), + }) + } + + if !result.Success { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: result.Message, + }) + } + + return c.JSON(domain.Response{ + Message: result.Message, + Data: result, + }) +} + +// GetDirectPaymentMethods godoc +// @Summary Get direct payment methods +// @Description Returns list of payment methods that support direct payment (OTP-based) +// @Tags payments +// @Produce json +// @Success 200 {object} domain.Response +// @Router /api/v1/payments/direct/methods [get] +func (h *Handler) GetDirectPaymentMethods(c *fiber.Ctx) error { + methods := h.arifpaySvc.GetDirectPaymentMethods() + + return c.JSON(domain.Response{ + Message: "Direct payment methods retrieved successfully", + Data: methods, + }) +} + +// Helper functions + +func paymentToRes(p *domain.Payment) *paymentRes { + res := &paymentRes{ + ID: p.ID, + UserID: p.UserID, + PlanID: p.PlanID, + SubscriptionID: p.SubscriptionID, + SessionID: p.SessionID, + Amount: p.Amount, + Currency: p.Currency, + PaymentMethod: p.PaymentMethod, + Status: p.Status, + PaymentURL: p.PaymentURL, + PlanName: p.PlanName, + CreatedAt: p.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + + if p.PaidAt != nil { + t := p.PaidAt.Format("2006-01-02T15:04:05Z07:00") + res.PaidAt = &t + } + if p.ExpiresAt != nil { + t := p.ExpiresAt.Format("2006-01-02T15:04:05Z07:00") + res.ExpiresAt = &t + } + + return res +} diff --git a/internal/web_server/handlers/auth_handler.go b/internal/web_server/handlers/auth_handler.go index 949035c..0b11c18 100644 --- a/internal/web_server/handlers/auth_handler.go +++ b/internal/web_server/handlers/auth_handler.go @@ -88,7 +88,14 @@ func (h *Handler) GoogleAndroidLogin(c *fiber.Ctx) error { // @Router /api/v1/auth/google/login [get] func (h *Handler) GoogleLogin(c *fiber.Ctx) error { state := uuid.NewString() - return c.Redirect(h.authSvc.GenerateGoogleLoginURL(state)) + url := h.authSvc.GenerateGoogleLoginURL(state) + if url == "" { + return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{ + Message: "Google OAuth is not configured", + Error: "Google OAuth client credentials are not set", + }) + } + return c.Redirect(url) } // GoogleCallback godoc @@ -612,6 +619,7 @@ func (h *Handler) RefreshToken(c *fiber.Ctx) error { type logoutReq struct { RefreshToken string `json:"refresh_token" validate:"required" example:""` + DeviceToken string `json:"device_token" example:""` } // LogOutuser godoc @@ -683,6 +691,20 @@ func (h *Handler) LogOutuser(c *fiber.Ctx) error { } } + // Deactivate device token if provided + if req.DeviceToken != "" { + userID, ok := c.Locals("user_id").(int64) + if ok && userID > 0 { + if err := h.userSvc.DeactivateDevice(c.Context(), userID, req.DeviceToken); err != nil { + h.mongoLoggerSvc.Warn("Failed to deactivate device token during logout", + zap.Int64("userID", userID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + } + } + } + h.mongoLoggerSvc.Info("Logout successful", zap.Int("status_code", fiber.StatusOK), zap.Time("timestamp", time.Now()), diff --git a/internal/web_server/handlers/course_management.go b/internal/web_server/handlers/course_management.go index 72c2e24..e2aa3c8 100644 --- a/internal/web_server/handlers/course_management.go +++ b/internal/web_server/handlers/course_management.go @@ -2,6 +2,7 @@ package handlers import ( "Yimaru-Backend/internal/domain" + "fmt" "strconv" "github.com/gofiber/fiber/v2" @@ -249,6 +250,7 @@ type createCourseReq struct { CategoryID int64 `json:"category_id" validate:"required"` Title string `json:"title" validate:"required"` Description *string `json:"description"` + Thumbnail *string `json:"thumbnail"` } type courseRes struct { @@ -256,6 +258,7 @@ type courseRes struct { CategoryID int64 `json:"category_id"` Title string `json:"title"` Description *string `json:"description"` + Thumbnail *string `json:"thumbnail"` IsActive bool `json:"is_active"` } @@ -279,7 +282,7 @@ func (h *Handler) CreateCourse(c *fiber.Ctx) error { }) } - course, err := h.courseMgmtSvc.CreateCourse(c.Context(), req.CategoryID, req.Title, req.Description) + course, err := h.courseMgmtSvc.CreateCourse(c.Context(), req.CategoryID, req.Title, req.Description, req.Thumbnail) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to create course", @@ -294,6 +297,7 @@ func (h *Handler) CreateCourse(c *fiber.Ctx) error { CategoryID: course.CategoryID, Title: course.Title, Description: course.Description, + Thumbnail: course.Thumbnail, IsActive: course.IsActive, }, }) @@ -335,6 +339,7 @@ func (h *Handler) GetCourseByID(c *fiber.Ctx) error { CategoryID: course.CategoryID, Title: course.Title, Description: course.Description, + Thumbnail: course.Thumbnail, IsActive: course.IsActive, }, }) @@ -401,6 +406,7 @@ func (h *Handler) GetCoursesByCategory(c *fiber.Ctx) error { CategoryID: course.CategoryID, Title: course.Title, Description: course.Description, + Thumbnail: course.Thumbnail, IsActive: course.IsActive, }) } @@ -417,6 +423,7 @@ func (h *Handler) GetCoursesByCategory(c *fiber.Ctx) error { type updateCourseReq struct { Title *string `json:"title"` Description *string `json:"description"` + Thumbnail *string `json:"thumbnail"` IsActive *bool `json:"is_active"` } @@ -450,7 +457,7 @@ func (h *Handler) UpdateCourse(c *fiber.Ctx) error { }) } - err = h.courseMgmtSvc.UpdateCourse(c.Context(), id, req.Title, req.Description, req.IsActive) + err = h.courseMgmtSvc.UpdateCourse(c.Context(), id, req.Title, req.Description, req.Thumbnail, req.IsActive) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to update course", @@ -496,39 +503,41 @@ func (h *Handler) DeleteCourse(c *fiber.Ctx) error { }) } -// Program Handlers +// Sub-course Handlers -type createProgramReq struct { +type createSubCourseReq struct { CourseID int64 `json:"course_id" validate:"required"` Title string `json:"title" validate:"required"` Description *string `json:"description"` Thumbnail *string `json:"thumbnail"` DisplayOrder *int32 `json:"display_order"` + Level string `json:"level" validate:"required"` // BEGINNER, INTERMEDIATE, ADVANCED } -type programRes struct { +type subCourseRes struct { ID int64 `json:"id"` CourseID int64 `json:"course_id"` Title string `json:"title"` Description *string `json:"description"` Thumbnail *string `json:"thumbnail"` DisplayOrder int32 `json:"display_order"` + Level string `json:"level"` IsActive bool `json:"is_active"` } -// CreateProgram godoc -// @Summary Create a new program -// @Description Creates a new program under a specific course -// @Tags programs +// CreateSubCourse godoc +// @Summary Create a new sub-course +// @Description Creates a new sub-course under a specific course +// @Tags sub-courses // @Accept json // @Produce json -// @Param body body createProgramReq true "Create program payload" +// @Param body body createSubCourseReq true "Create sub-course payload" // @Success 201 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/programs [post] -func (h *Handler) CreateProgram(c *fiber.Ctx) error { - var req createProgramReq +// @Router /api/v1/course-management/sub-courses [post] +func (h *Handler) CreateSubCourse(c *fiber.Ctx) error { + var req createSubCourseReq if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid request body", @@ -536,87 +545,89 @@ func (h *Handler) CreateProgram(c *fiber.Ctx) error { }) } - program, err := h.courseMgmtSvc.CreateProgram(c.Context(), req.CourseID, req.Title, req.Description, req.Thumbnail, req.DisplayOrder) + subCourse, err := h.courseMgmtSvc.CreateSubCourse(c.Context(), req.CourseID, req.Title, req.Description, req.Thumbnail, req.DisplayOrder, req.Level) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to create program", + Message: "Failed to create sub-course", Error: err.Error(), }) } return c.Status(fiber.StatusCreated).JSON(domain.Response{ - Message: "Program created successfully", - Data: programRes{ - ID: program.ID, - CourseID: program.CourseID, - Title: program.Title, - Description: program.Description, - Thumbnail: program.Thumbnail, - DisplayOrder: program.DisplayOrder, - IsActive: program.IsActive, + Message: "Sub-course created successfully", + Data: subCourseRes{ + ID: subCourse.ID, + CourseID: subCourse.CourseID, + Title: subCourse.Title, + Description: subCourse.Description, + Thumbnail: subCourse.Thumbnail, + DisplayOrder: subCourse.DisplayOrder, + Level: subCourse.Level, + IsActive: subCourse.IsActive, }, }) } -// GetProgramByID godoc -// @Summary Get program by ID -// @Description Returns a single program by its ID -// @Tags programs +// GetSubCourseByID godoc +// @Summary Get sub-course by ID +// @Description Returns a single sub-course by its ID +// @Tags sub-courses // @Produce json -// @Param id path int true "Program ID" +// @Param id path int true "Sub-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/programs/{id} [get] -func (h *Handler) GetProgramByID(c *fiber.Ctx) error { +// @Router /api/v1/course-management/sub-courses/{id} [get] +func (h *Handler) GetSubCourseByID(c *fiber.Ctx) error { idStr := c.Params("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid program ID", + Message: "Invalid sub-course ID", Error: err.Error(), }) } - program, err := h.courseMgmtSvc.GetProgramByID(c.Context(), id) + subCourse, err := h.courseMgmtSvc.GetSubCourseByID(c.Context(), id) if err != nil { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ - Message: "Program not found", + Message: "Sub-course not found", Error: err.Error(), }) } return c.JSON(domain.Response{ - Message: "Program retrieved successfully", - Data: programRes{ - ID: program.ID, - CourseID: program.CourseID, - Title: program.Title, - Description: program.Description, - Thumbnail: program.Thumbnail, - DisplayOrder: program.DisplayOrder, - IsActive: program.IsActive, + Message: "Sub-course retrieved successfully", + Data: subCourseRes{ + ID: subCourse.ID, + CourseID: subCourse.CourseID, + Title: subCourse.Title, + Description: subCourse.Description, + Thumbnail: subCourse.Thumbnail, + DisplayOrder: subCourse.DisplayOrder, + Level: subCourse.Level, + IsActive: subCourse.IsActive, }, }) } -type getProgramsByCourseRes struct { - Programs []programRes `json:"programs"` - TotalCount int64 `json:"total_count"` +type getSubCoursesByCourseRes struct { + SubCourses []subCourseRes `json:"sub_courses"` + TotalCount int64 `json:"total_count"` } -// GetProgramsByCourse godoc -// @Summary Get programs by course -// @Description Returns all programs under a specific course with total count -// @Tags programs +// GetSubCoursesByCourse godoc +// @Summary Get sub-courses by course +// @Description Returns all sub-courses under a specific course +// @Tags sub-courses // @Produce json // @Param courseId path int true "Course ID" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/courses/{courseId}/programs [get] -func (h *Handler) GetProgramsByCourse(c *fiber.Ctx) error { +// @Router /api/v1/course-management/courses/{courseId}/sub-courses [get] +func (h *Handler) GetSubCoursesByCourse(c *fiber.Ctx) error { courseIDStr := c.Params("courseId") courseID, err := strconv.ParseInt(courseIDStr, 10, 64) if err != nil { @@ -626,51 +637,48 @@ func (h *Handler) GetProgramsByCourse(c *fiber.Ctx) error { }) } - programs, totalCount, err := h.courseMgmtSvc.GetProgramsByCourse(c.Context(), courseID) + subCourses, totalCount, err := h.courseMgmtSvc.GetSubCoursesByCourse(c.Context(), courseID) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to retrieve programs", + Message: "Failed to retrieve sub-courses", Error: err.Error(), }) } - var programResponses []programRes - for _, program := range programs { - programResponses = append(programResponses, programRes{ - ID: program.ID, - CourseID: program.CourseID, - Title: program.Title, - Description: program.Description, - Thumbnail: program.Thumbnail, - DisplayOrder: program.DisplayOrder, - IsActive: program.IsActive, + var subCourseResponses []subCourseRes + for _, sc := range subCourses { + subCourseResponses = append(subCourseResponses, subCourseRes{ + ID: sc.ID, + CourseID: sc.CourseID, + Title: sc.Title, + Description: sc.Description, + Thumbnail: sc.Thumbnail, + DisplayOrder: sc.DisplayOrder, + Level: sc.Level, + IsActive: sc.IsActive, }) } return c.JSON(domain.Response{ - Message: "Programs retrieved successfully", - Data: getProgramsByCourseRes{ - Programs: programResponses, + Message: "Sub-courses retrieved successfully", + Data: getSubCoursesByCourseRes{ + SubCourses: subCourseResponses, TotalCount: totalCount, }, }) } -type listProgramsByCourseRes struct { - Programs []programRes `json:"programs"` -} - -// ListProgramsByCourse godoc -// @Summary List programs by course -// @Description Returns a simple list of programs under a specific course -// @Tags programs +// ListSubCoursesByCourse godoc +// @Summary List active sub-courses by course +// @Description Returns a list of active sub-courses under a specific course +// @Tags sub-courses // @Produce json // @Param courseId path int true "Course ID" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/courses/{courseId}/programs/list [get] -func (h *Handler) ListProgramsByCourse(c *fiber.Ctx) error { +// @Router /api/v1/course-management/courses/{courseId}/sub-courses/list [get] +func (h *Handler) ListSubCoursesByCourse(c *fiber.Ctx) error { courseIDStr := c.Params("courseId") courseID, err := strconv.ParseInt(courseIDStr, 10, 64) if err != nil { @@ -680,108 +688,103 @@ func (h *Handler) ListProgramsByCourse(c *fiber.Ctx) error { }) } - programs, err := h.courseMgmtSvc.ListProgramsByCourse(c.Context(), courseID) + subCourses, err := h.courseMgmtSvc.ListSubCoursesByCourse(c.Context(), courseID) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to retrieve programs", + Message: "Failed to retrieve sub-courses", Error: err.Error(), }) } - var programResponses []programRes - for _, program := range programs { - programResponses = append(programResponses, programRes{ - ID: program.ID, - CourseID: program.CourseID, - Title: program.Title, - Description: program.Description, - Thumbnail: program.Thumbnail, - DisplayOrder: program.DisplayOrder, - IsActive: program.IsActive, + var subCourseResponses []subCourseRes + for _, sc := range subCourses { + subCourseResponses = append(subCourseResponses, subCourseRes{ + ID: sc.ID, + CourseID: sc.CourseID, + Title: sc.Title, + Description: sc.Description, + Thumbnail: sc.Thumbnail, + DisplayOrder: sc.DisplayOrder, + Level: sc.Level, + IsActive: sc.IsActive, }) } return c.JSON(domain.Response{ - Message: "Programs retrieved successfully", - Data: listProgramsByCourseRes{ - Programs: programResponses, - }, + Message: "Sub-courses retrieved successfully", + Data: subCourseResponses, }) } -type listActiveProgramsRes struct { - Programs []programRes `json:"programs"` -} - -// ListActivePrograms godoc -// @Summary List active programs -// @Description Returns all active programs across all courses -// @Tags programs +// ListActiveSubCourses godoc +// @Summary List all active sub-courses +// @Description Returns a list of all active sub-courses +// @Tags sub-courses // @Produce json // @Success 200 {object} domain.Response // @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/programs/active [get] -func (h *Handler) ListActivePrograms(c *fiber.Ctx) error { - programs, err := h.courseMgmtSvc.ListActivePrograms(c.Context()) +// @Router /api/v1/course-management/sub-courses/active [get] +func (h *Handler) ListActiveSubCourses(c *fiber.Ctx) error { + subCourses, err := h.courseMgmtSvc.ListActiveSubCourses(c.Context()) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to retrieve active programs", + Message: "Failed to retrieve active sub-courses", Error: err.Error(), }) } - var programResponses []programRes - for _, program := range programs { - programResponses = append(programResponses, programRes{ - ID: program.ID, - CourseID: program.CourseID, - Title: program.Title, - Description: program.Description, - Thumbnail: program.Thumbnail, - DisplayOrder: program.DisplayOrder, - IsActive: program.IsActive, + var subCourseResponses []subCourseRes + for _, sc := range subCourses { + subCourseResponses = append(subCourseResponses, subCourseRes{ + ID: sc.ID, + CourseID: sc.CourseID, + Title: sc.Title, + Description: sc.Description, + Thumbnail: sc.Thumbnail, + DisplayOrder: sc.DisplayOrder, + Level: sc.Level, + IsActive: sc.IsActive, }) } return c.JSON(domain.Response{ - Message: "Active programs retrieved successfully", - Data: listActiveProgramsRes{ - Programs: programResponses, - }, + Message: "Active sub-courses retrieved successfully", + Data: subCourseResponses, }) } -type updateProgramPartialReq struct { +type updateSubCourseReq struct { Title *string `json:"title"` Description *string `json:"description"` Thumbnail *string `json:"thumbnail"` DisplayOrder *int32 `json:"display_order"` + Level *string `json:"level"` IsActive *bool `json:"is_active"` } -// UpdateProgramPartial godoc -// @Summary Update program partially -// @Description Updates selected fields of a program -// @Tags programs +// UpdateSubCourse godoc +// @Summary Update sub-course +// @Description Updates a sub-course's fields +// @Tags sub-courses // @Accept json // @Produce json -// @Param id path int true "Program ID" -// @Param body body updateProgramPartialReq true "Update program payload" +// @Param id path int true "Sub-course ID" +// @Param body body updateSubCourseReq true "Update sub-course payload" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/programs/{id} [patch] -func (h *Handler) UpdateProgramPartial(c *fiber.Ctx) error { +// @Router /api/v1/course-management/sub-courses/{id} [patch] +func (h *Handler) UpdateSubCourse(c *fiber.Ctx) error { idStr := c.Params("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid program ID", + Message: "Invalid sub-course ID", Error: err.Error(), }) } - var req updateProgramPartialReq + var req updateSubCourseReq if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid request body", @@ -789,668 +792,89 @@ func (h *Handler) UpdateProgramPartial(c *fiber.Ctx) error { }) } - err = h.courseMgmtSvc.UpdateProgramPartial(c.Context(), id, req.Title, req.Description, req.Thumbnail, req.DisplayOrder, req.IsActive) + err = h.courseMgmtSvc.UpdateSubCourse(c.Context(), id, req.Title, req.Description, req.Thumbnail, req.DisplayOrder, req.Level, req.IsActive) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to update program", + Message: "Failed to update sub-course", Error: err.Error(), }) } return c.JSON(domain.Response{ - Message: "Program updated successfully", + Message: "Sub-course updated successfully", }) } -type updateProgramFullReq struct { - CourseID int64 `json:"course_id" validate:"required"` - Title string `json:"title" validate:"required"` - Description *string `json:"description"` - Thumbnail *string `json:"thumbnail"` - DisplayOrder int32 `json:"display_order"` - IsActive bool `json:"is_active"` -} - -// UpdateProgramFull godoc -// @Summary Update program fully -// @Description Updates all fields of a program -// @Tags programs -// @Accept json +// DeactivateSubCourse godoc +// @Summary Deactivate sub-course +// @Description Deactivates a sub-course by its ID +// @Tags sub-courses // @Produce json -// @Param id path int true "Program ID" -// @Param body body updateProgramFullReq true "Update program payload" +// @Param id path int true "Sub-course ID" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/programs/{id}/full [put] -func (h *Handler) UpdateProgramFull(c *fiber.Ctx) error { +// @Router /api/v1/course-management/sub-courses/{id}/deactivate [put] +func (h *Handler) DeactivateSubCourse(c *fiber.Ctx) error { idStr := c.Params("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid program ID", + Message: "Invalid sub-course ID", Error: err.Error(), }) } - var req updateProgramFullReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - } - - program := domain.Program{ - ID: id, - CourseID: req.CourseID, - Title: req.Title, - Description: req.Description, - Thumbnail: req.Thumbnail, - DisplayOrder: req.DisplayOrder, - IsActive: req.IsActive, - } - - updatedProgram, err := h.courseMgmtSvc.UpdateProgramFull(c.Context(), program) + err = h.courseMgmtSvc.DeactivateSubCourse(c.Context(), id) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to update program", + Message: "Failed to deactivate sub-course", Error: err.Error(), }) } return c.JSON(domain.Response{ - Message: "Program updated successfully", - Data: programRes{ - ID: updatedProgram.ID, - CourseID: updatedProgram.CourseID, - Title: updatedProgram.Title, - Description: updatedProgram.Description, - Thumbnail: updatedProgram.Thumbnail, - DisplayOrder: updatedProgram.DisplayOrder, - IsActive: updatedProgram.IsActive, - }, + Message: "Sub-course deactivated successfully", }) } -// DeactivateProgram godoc -// @Summary Deactivate program -// @Description Deactivates a program by setting is_active to false -// @Tags programs +// DeleteSubCourse godoc +// @Summary Delete sub-course +// @Description Deletes a sub-course by its ID +// @Tags sub-courses // @Produce json -// @Param id path int true "Program ID" +// @Param id path int true "Sub-course ID" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/programs/{id}/deactivate [put] -func (h *Handler) DeactivateProgram(c *fiber.Ctx) error { +// @Router /api/v1/course-management/sub-courses/{id} [delete] +func (h *Handler) DeleteSubCourse(c *fiber.Ctx) error { idStr := c.Params("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid program ID", + Message: "Invalid sub-course ID", Error: err.Error(), }) } - err = h.courseMgmtSvc.DeactivateProgram(c.Context(), id) + _, err = h.courseMgmtSvc.DeleteSubCourse(c.Context(), id) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to deactivate program", + Message: "Failed to delete sub-course", Error: err.Error(), }) } return c.JSON(domain.Response{ - Message: "Program deactivated successfully", + Message: "Sub-course deleted successfully", }) } -// DeleteProgram godoc -// @Summary Delete program -// @Description Deletes a program by its ID -// @Tags programs -// @Produce json -// @Param id path int true "Program ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/programs/{id} [delete] -func (h *Handler) DeleteProgram(c *fiber.Ctx) error { - idStr := c.Params("id") - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid program ID", - Error: err.Error(), - }) - } +// Sub-course Video Handlers - deletedProgram, err := h.courseMgmtSvc.DeleteProgram(c.Context(), id) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to delete program", - Error: err.Error(), - }) - } - - return c.JSON(domain.Response{ - Message: "Program deleted successfully", - Data: programRes{ - ID: deletedProgram.ID, - CourseID: deletedProgram.CourseID, - Title: deletedProgram.Title, - Description: deletedProgram.Description, - Thumbnail: deletedProgram.Thumbnail, - DisplayOrder: deletedProgram.DisplayOrder, - IsActive: deletedProgram.IsActive, - }, - }) -} - -// Level Handlers - -type createLevelReq struct { - ProgramID int64 `json:"program_id" validate:"required"` - Title string `json:"title" validate:"required"` - Description *string `json:"description"` - LevelIndex int `json:"level_index" validate:"required"` - IsActive *bool `json:"is_active"` -} - -type levelRes struct { - ID int64 `json:"id"` - ProgramID int64 `json:"program_id"` - Title string `json:"title"` - Description *string `json:"description"` - LevelIndex int `json:"level_index"` - NumberOfModules int `json:"number_of_modules"` - NumberOfPractices int `json:"number_of_practices"` - NumberOfVideos int `json:"number_of_videos"` - IsActive bool `json:"is_active"` -} - -// CreateLevel godoc -// @Summary Create a new level -// @Description Creates a new level under a specific program -// @Tags levels -// @Accept json -// @Produce json -// @Param body body createLevelReq true "Create level payload" -// @Success 201 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/levels [post] -func (h *Handler) CreateLevel(c *fiber.Ctx) error { - var req createLevelReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - } - - level, err := h.courseMgmtSvc.CreateLevel(c.Context(), req.ProgramID, req.Title, req.Description, req.LevelIndex, req.IsActive) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to create level", - Error: err.Error(), - }) - } - - return c.Status(fiber.StatusCreated).JSON(domain.Response{ - Message: "Level created successfully", - Data: levelRes{ - ID: level.ID, - ProgramID: level.ProgramID, - Title: level.Title, - Description: level.Description, - LevelIndex: level.LevelIndex, - NumberOfModules: level.NumberOfModules, - NumberOfPractices: level.NumberOfPractices, - NumberOfVideos: level.NumberOfVideos, - IsActive: level.IsActive, - }, - }) -} - -type getLevelsByProgramRes struct { - Levels []levelRes `json:"levels"` -} - -// GetLevelsByProgram godoc -// @Summary Get levels by program -// @Description Returns all levels under a specific program -// @Tags levels -// @Produce json -// @Param programId path int true "Program ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/programs/{programId}/levels [get] -func (h *Handler) GetLevelsByProgram(c *fiber.Ctx) error { - programIDStr := c.Params("programId") - programID, err := strconv.ParseInt(programIDStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid program ID", - Error: err.Error(), - }) - } - - levels, err := h.courseMgmtSvc.GetLevelsByProgram(c.Context(), programID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to retrieve levels", - Error: err.Error(), - }) - } - - var levelResponses []levelRes - for _, level := range levels { - levelResponses = append(levelResponses, levelRes{ - ID: level.ID, - ProgramID: level.ProgramID, - Title: level.Title, - Description: level.Description, - LevelIndex: level.LevelIndex, - NumberOfModules: level.NumberOfModules, - NumberOfPractices: level.NumberOfPractices, - NumberOfVideos: level.NumberOfVideos, - IsActive: level.IsActive, - }) - } - - return c.JSON(domain.Response{ - Message: "Levels retrieved successfully", - Data: getLevelsByProgramRes{ - Levels: levelResponses, - }, - }) -} - -type updateLevelReq struct { - Title *string `json:"title"` - Description *string `json:"description"` - LevelIndex *int `json:"level_index"` - IsActive *bool `json:"is_active"` -} - -// UpdateLevel godoc -// @Summary Update level -// @Description Updates a level's title, description, index, and/or active status -// @Tags levels -// @Accept json -// @Produce json -// @Param id path int true "Level ID" -// @Param body body updateLevelReq true "Update level payload" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/levels/{id} [put] -func (h *Handler) UpdateLevel(c *fiber.Ctx) error { - idStr := c.Params("id") - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid level ID", - Error: err.Error(), - }) - } - - var req updateLevelReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - } - - err = h.courseMgmtSvc.UpdateLevel(c.Context(), id, req.Title, req.Description, req.LevelIndex, req.IsActive) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to update level", - Error: err.Error(), - }) - } - - return c.JSON(domain.Response{ - Message: "Level updated successfully", - }) -} - -// IncrementLevelModuleCount godoc -// @Summary Increment level module count -// @Description Increments the module count for a specific level -// @Tags levels -// @Produce json -// @Param levelId path int true "Level ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/levels/{levelId}/increment-module [put] -func (h *Handler) IncrementLevelModuleCount(c *fiber.Ctx) error { - levelIDStr := c.Params("levelId") - levelID, err := strconv.ParseInt(levelIDStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid level ID", - Error: err.Error(), - }) - } - - err = h.courseMgmtSvc.IncrementLevelModuleCount(c.Context(), levelID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to increment level module count", - Error: err.Error(), - }) - } - - return c.JSON(domain.Response{ - Message: "Level module count incremented successfully", - }) -} - -// IncrementLevelPracticeCount godoc -// @Summary Increment level practice count -// @Description Increments the practice count for a specific level -// @Tags levels -// @Produce json -// @Param levelId path int true "Level ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/levels/{levelId}/increment-practice [put] -func (h *Handler) IncrementLevelPracticeCount(c *fiber.Ctx) error { - levelIDStr := c.Params("levelId") - levelID, err := strconv.ParseInt(levelIDStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid level ID", - Error: err.Error(), - }) - } - - err = h.courseMgmtSvc.IncrementLevelPracticeCount(c.Context(), levelID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to increment level practice count", - Error: err.Error(), - }) - } - - return c.JSON(domain.Response{ - Message: "Level practice count incremented successfully", - }) -} - -// IncrementLevelVideoCount godoc -// @Summary Increment level video count -// @Description Increments the video count for a specific level -// @Tags levels -// @Produce json -// @Param levelId path int true "Level ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/levels/{levelId}/increment-video [put] -func (h *Handler) IncrementLevelVideoCount(c *fiber.Ctx) error { - levelIDStr := c.Params("levelId") - levelID, err := strconv.ParseInt(levelIDStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid level ID", - Error: err.Error(), - }) - } - - err = h.courseMgmtSvc.IncrementLevelVideoCount(c.Context(), levelID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to increment level video count", - Error: err.Error(), - }) - } - - return c.JSON(domain.Response{ - Message: "Level video count incremented successfully", - }) -} - -// DeleteLevel godoc -// @Summary Delete level -// @Description Deletes a level by its ID -// @Tags levels -// @Produce json -// @Param levelId path int true "Level ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/levels/{levelId} [delete] -func (h *Handler) DeleteLevel(c *fiber.Ctx) error { - levelIDStr := c.Params("levelId") - levelID, err := strconv.ParseInt(levelIDStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid level ID", - Error: err.Error(), - }) - } - - err = h.courseMgmtSvc.DeleteLevel(c.Context(), levelID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to delete level", - Error: err.Error(), - }) - } - - return c.JSON(domain.Response{ - Message: "Level deleted successfully", - }) -} - -// Module Handlers - -type createModuleReq struct { - LevelID int64 `json:"level_id" validate:"required"` - Title string `json:"title" validate:"required"` - Content *string `json:"content"` - DisplayOrder *int32 `json:"display_order"` -} - -type moduleRes struct { - ID int64 `json:"id"` - LevelID int64 `json:"level_id"` - Title string `json:"title"` - Content *string `json:"content"` - DisplayOrder int32 `json:"display_order"` - IsActive bool `json:"is_active"` -} - -// CreateModule godoc -// @Summary Create a new module -// @Description Creates a new module under a specific level -// @Tags modules -// @Accept json -// @Produce json -// @Param body body createModuleReq true "Create module payload" -// @Success 201 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/modules [post] -func (h *Handler) CreateModule(c *fiber.Ctx) error { - var req createModuleReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - } - - module, err := h.courseMgmtSvc.CreateModule(c.Context(), req.LevelID, req.Title, req.Content, req.DisplayOrder) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to create module", - Error: err.Error(), - }) - } - - return c.Status(fiber.StatusCreated).JSON(domain.Response{ - Message: "Module created successfully", - Data: moduleRes{ - ID: module.ID, - LevelID: module.LevelID, - Title: module.Title, - Content: module.Content, - DisplayOrder: module.DisplayOrder, - IsActive: module.IsActive, - }, - }) -} - -type getModulesByLevelRes struct { - Modules []moduleRes `json:"modules"` - TotalCount int64 `json:"total_count"` -} - -// GetModulesByLevel godoc -// @Summary Get modules by level -// @Description Returns a paginated list of modules under a specific level -// @Tags modules -// @Produce json -// @Param levelId path int true "Level ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/levels/{levelId}/modules [get] -func (h *Handler) GetModulesByLevel(c *fiber.Ctx) error { - levelIDStr := c.Params("levelId") - levelID, err := strconv.ParseInt(levelIDStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid level ID", - Error: err.Error(), - }) - } - - modules, totalCount, err := h.courseMgmtSvc.GetModulesByLevel(c.Context(), levelID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to retrieve modules", - Error: err.Error(), - }) - } - - var moduleResponses []moduleRes - for _, module := range modules { - moduleResponses = append(moduleResponses, moduleRes{ - ID: module.ID, - LevelID: module.LevelID, - Title: module.Title, - Content: module.Content, - DisplayOrder: module.DisplayOrder, - IsActive: module.IsActive, - }) - } - - return c.JSON(domain.Response{ - Message: "Modules retrieved successfully", - Data: getModulesByLevelRes{ - Modules: moduleResponses, - TotalCount: totalCount, - }, - }) -} - -type updateModuleReq struct { - Title *string `json:"title"` - Content *string `json:"content"` - DisplayOrder *int32 `json:"display_order"` - IsActive *bool `json:"is_active"` -} - -// UpdateModule godoc -// @Summary Update module -// @Description Updates a module's title, content, display order, and/or active status -// @Tags modules -// @Accept json -// @Produce json -// @Param id path int true "Module ID" -// @Param body body updateModuleReq true "Update module payload" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/modules/{id} [put] -func (h *Handler) UpdateModule(c *fiber.Ctx) error { - idStr := c.Params("id") - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid module ID", - Error: err.Error(), - }) - } - - var req updateModuleReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - } - - err = h.courseMgmtSvc.UpdateModule(c.Context(), id, req.Title, req.Content, req.DisplayOrder, req.IsActive) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to update module", - Error: err.Error(), - }) - } - - return c.JSON(domain.Response{ - Message: "Module updated successfully", - }) -} - -// DeleteModule godoc -// @Summary Delete module -// @Description Deletes a module by its ID -// @Tags modules -// @Produce json -// @Param id path int true "Module ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/modules/{id} [delete] -func (h *Handler) DeleteModule(c *fiber.Ctx) error { - idStr := c.Params("id") - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid module ID", - Error: err.Error(), - }) - } - - err = h.courseMgmtSvc.DeleteModule(c.Context(), id) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to delete module", - Error: err.Error(), - }) - } - - return c.JSON(domain.Response{ - Message: "Module deleted successfully", - }) -} - -// Module Video Handlers - -type createModuleVideoReq struct { - ModuleID int64 `json:"module_id" validate:"required"` +type createSubCourseVideoReq struct { + SubCourseID int64 `json:"sub_course_id" validate:"required"` Title string `json:"title" validate:"required"` Description *string `json:"description"` VideoURL string `json:"video_url" validate:"required"` @@ -1459,37 +883,67 @@ type createModuleVideoReq struct { InstructorID *string `json:"instructor_id"` Thumbnail *string `json:"thumbnail"` Visibility *string `json:"visibility"` + DisplayOrder *int32 `json:"display_order"` + Status *string `json:"status"` // DRAFT, PUBLISHED, INACTIVE, ARCHIVED } -type moduleVideoRes struct { - ID int64 `json:"id"` - ModuleID int64 `json:"module_id"` - Title string `json:"title"` +type subCourseVideoRes struct { + ID int64 `json:"id"` + SubCourseID int64 `json:"sub_course_id"` + Title string `json:"title"` + Description *string `json:"description"` + VideoURL string `json:"video_url"` + Duration int32 `json:"duration"` + Resolution *string `json:"resolution"` + InstructorID *string `json:"instructor_id"` + Thumbnail *string `json:"thumbnail"` + Visibility *string `json:"visibility"` + DisplayOrder int32 `json:"display_order"` + IsPublished bool `json:"is_published"` + PublishDate *string `json:"publish_date"` + Status string `json:"status"` + VimeoID *string `json:"vimeo_id,omitempty"` + VimeoEmbedURL *string `json:"vimeo_embed_url,omitempty"` + VimeoPlayerHTML *string `json:"vimeo_player_html,omitempty"` + VimeoStatus *string `json:"vimeo_status,omitempty"` +} + +type createVimeoVideoReq struct { + SubCourseID int64 `json:"sub_course_id" validate:"required"` + Title string `json:"title" validate:"required"` Description *string `json:"description"` - VideoURL string `json:"video_url"` + SourceURL string `json:"source_url" validate:"required,url"` + FileSize int64 `json:"file_size" validate:"required,gt=0"` Duration int32 `json:"duration"` Resolution *string `json:"resolution"` InstructorID *string `json:"instructor_id"` Thumbnail *string `json:"thumbnail"` Visibility *string `json:"visibility"` - IsPublished bool `json:"is_published"` - PublishDate *string `json:"publish_date,omitempty"` - IsActive bool `json:"is_active"` + DisplayOrder *int32 `json:"display_order"` } -// CreateModuleVideo godoc -// @Summary Create a new module video -// @Description Creates a new video under a specific module -// @Tags module-videos +type createVideoFromVimeoIDReq struct { + SubCourseID int64 `json:"sub_course_id" validate:"required"` + VimeoVideoID string `json:"vimeo_video_id" validate:"required"` + Title string `json:"title" validate:"required"` + Description *string `json:"description"` + DisplayOrder *int32 `json:"display_order"` + InstructorID *string `json:"instructor_id"` +} + +// CreateSubCourseVideo godoc +// @Summary Create a new sub-course video +// @Description Creates a new video under a specific sub-course +// @Tags sub-course-videos // @Accept json // @Produce json -// @Param body body createModuleVideoReq true "Create video payload" +// @Param body body createSubCourseVideoReq true "Create video payload" // @Success 201 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/course-management/videos [post] -func (h *Handler) CreateModuleVideo(c *fiber.Ctx) error { - var req createModuleVideoReq +func (h *Handler) CreateSubCourseVideo(c *fiber.Ctx) error { + var req createSubCourseVideoReq if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid request body", @@ -1497,25 +951,25 @@ func (h *Handler) CreateModuleVideo(c *fiber.Ctx) error { }) } - video, err := h.courseMgmtSvc.CreateModuleVideo(c.Context(), req.ModuleID, req.Title, req.Description, req.VideoURL, req.Duration, req.Resolution, req.InstructorID, req.Thumbnail, req.Visibility) + video, err := h.courseMgmtSvc.CreateSubCourseVideo(c.Context(), req.SubCourseID, req.Title, req.Description, req.VideoURL, req.Duration, req.Resolution, req.InstructorID, req.Thumbnail, req.Visibility, req.DisplayOrder, req.Status) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to create module video", + Message: "Failed to create sub-course video", Error: err.Error(), }) } - var publishDateStr *string + var publishDate *string if video.PublishDate != nil { - publishDateStr = new(string) - *publishDateStr = video.PublishDate.String() + pd := video.PublishDate.String() + publishDate = &pd } return c.Status(fiber.StatusCreated).JSON(domain.Response{ - Message: "Module video created successfully", - Data: moduleVideoRes{ + Message: "Sub-course video created successfully", + Data: subCourseVideoRes{ ID: video.ID, - ModuleID: video.ModuleID, + SubCourseID: video.SubCourseID, Title: video.Title, Description: video.Description, VideoURL: video.VideoURL, @@ -1524,26 +978,27 @@ func (h *Handler) CreateModuleVideo(c *fiber.Ctx) error { InstructorID: video.InstructorID, Thumbnail: video.Thumbnail, Visibility: video.Visibility, + DisplayOrder: video.DisplayOrder, IsPublished: video.IsPublished, - PublishDate: publishDateStr, - IsActive: video.IsActive, + PublishDate: publishDate, + Status: video.Status, }, }) } -// PublishModuleVideo godoc -// @Summary Publish module video -// @Description Publishes a module video by setting publish date -// @Tags module-videos +// GetSubCourseVideoByID godoc +// @Summary Get sub-course video by ID +// @Description Returns a single video by its ID +// @Tags sub-course-videos // @Produce json -// @Param videoId path int true "Video ID" +// @Param id path int true "Video ID" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/videos/{videoId}/publish [put] -func (h *Handler) PublishModuleVideo(c *fiber.Ctx) error { - videoIDStr := c.Params("videoId") - videoID, err := strconv.ParseInt(videoIDStr, 10, 64) +// @Failure 404 {object} domain.ErrorResponse +// @Router /api/v1/course-management/videos/{id} [get] +func (h *Handler) GetSubCourseVideoByID(c *fiber.Ctx) error { + idStr := c.Params("id") + id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid video ID", @@ -1551,44 +1006,130 @@ func (h *Handler) PublishModuleVideo(c *fiber.Ctx) error { }) } - err = h.courseMgmtSvc.PublishModuleVideo(c.Context(), videoID) + video, err := h.courseMgmtSvc.GetSubCourseVideoByID(c.Context(), id) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Video not found", + Error: err.Error(), + }) + } + + var publishDate *string + if video.PublishDate != nil { + pd := video.PublishDate.String() + publishDate = &pd + } + + return c.JSON(domain.Response{ + Message: "Video retrieved successfully", + Data: subCourseVideoRes{ + ID: video.ID, + SubCourseID: video.SubCourseID, + Title: video.Title, + Description: video.Description, + VideoURL: video.VideoURL, + Duration: video.Duration, + Resolution: video.Resolution, + InstructorID: video.InstructorID, + Thumbnail: video.Thumbnail, + Visibility: video.Visibility, + DisplayOrder: video.DisplayOrder, + IsPublished: video.IsPublished, + PublishDate: publishDate, + Status: video.Status, + }, + }) +} + +type getVideosBySubCourseRes struct { + Videos []subCourseVideoRes `json:"videos"` + TotalCount int64 `json:"total_count"` +} + +// GetVideosBySubCourse godoc +// @Summary Get videos by sub-course +// @Description Returns all videos under a specific sub-course +// @Tags sub-course-videos +// @Produce json +// @Param subCourseId path int true "Sub-course ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/sub-courses/{subCourseId}/videos [get] +func (h *Handler) GetVideosBySubCourse(c *fiber.Ctx) error { + subCourseIDStr := c.Params("subCourseId") + subCourseID, err := strconv.ParseInt(subCourseIDStr, 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid sub-course ID", + Error: err.Error(), + }) + } + + videos, totalCount, err := h.courseMgmtSvc.GetVideosBySubCourse(c.Context(), subCourseID) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to publish module video", + Message: "Failed to retrieve videos", Error: err.Error(), }) } + var videoResponses []subCourseVideoRes + for _, v := range videos { + var publishDate *string + if v.PublishDate != nil { + pd := v.PublishDate.String() + publishDate = &pd + } + + videoResponses = append(videoResponses, subCourseVideoRes{ + ID: v.ID, + SubCourseID: v.SubCourseID, + Title: v.Title, + Description: v.Description, + VideoURL: v.VideoURL, + Duration: v.Duration, + Resolution: v.Resolution, + InstructorID: v.InstructorID, + Thumbnail: v.Thumbnail, + Visibility: v.Visibility, + DisplayOrder: v.DisplayOrder, + IsPublished: v.IsPublished, + PublishDate: publishDate, + Status: v.Status, + }) + } + return c.JSON(domain.Response{ - Message: "Module video published successfully", + Message: "Videos retrieved successfully", + Data: getVideosBySubCourseRes{ + Videos: videoResponses, + TotalCount: totalCount, + }, }) } -type getPublishedVideosByModuleRes struct { - Videos []moduleVideoRes `json:"videos"` -} - -// GetPublishedVideosByModule godoc -// @Summary Get published videos by module -// @Description Returns all published videos under a specific module -// @Tags module-videos +// GetPublishedVideosBySubCourse godoc +// @Summary Get published videos by sub-course +// @Description Returns all published videos under a specific sub-course +// @Tags sub-course-videos // @Produce json -// @Param moduleId path int true "Module ID" +// @Param subCourseId path int true "Sub-course ID" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/modules/{moduleId}/videos/published [get] -func (h *Handler) GetPublishedVideosByModule(c *fiber.Ctx) error { - moduleIDStr := c.Params("moduleId") - moduleID, err := strconv.ParseInt(moduleIDStr, 10, 64) +// @Router /api/v1/course-management/sub-courses/{subCourseId}/videos/published [get] +func (h *Handler) GetPublishedVideosBySubCourse(c *fiber.Ctx) error { + subCourseIDStr := c.Params("subCourseId") + subCourseID, err := strconv.ParseInt(subCourseIDStr, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid module ID", + Message: "Invalid sub-course ID", Error: err.Error(), }) } - videos, err := h.courseMgmtSvc.GetPublishedVideosByModule(c.Context(), moduleID) + videos, err := h.courseMgmtSvc.GetPublishedVideosBySubCourse(c.Context(), subCourseID) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to retrieve published videos", @@ -1596,63 +1137,49 @@ func (h *Handler) GetPublishedVideosByModule(c *fiber.Ctx) error { }) } - var videoResponses []moduleVideoRes - for _, video := range videos { - var publishDateStr *string - if video.PublishDate != nil { - publishDateStr = new(string) - *publishDateStr = video.PublishDate.String() + var videoResponses []subCourseVideoRes + for _, v := range videos { + var publishDate *string + if v.PublishDate != nil { + pd := v.PublishDate.String() + publishDate = &pd } - videoResponses = append(videoResponses, moduleVideoRes{ - ID: video.ID, - ModuleID: video.ModuleID, - Title: video.Title, - Description: video.Description, - VideoURL: video.VideoURL, - Duration: video.Duration, - Resolution: video.Resolution, - InstructorID: video.InstructorID, - Thumbnail: video.Thumbnail, - Visibility: video.Visibility, - IsPublished: video.IsPublished, - PublishDate: publishDateStr, - IsActive: video.IsActive, + videoResponses = append(videoResponses, subCourseVideoRes{ + ID: v.ID, + SubCourseID: v.SubCourseID, + Title: v.Title, + Description: v.Description, + VideoURL: v.VideoURL, + Duration: v.Duration, + Resolution: v.Resolution, + InstructorID: v.InstructorID, + Thumbnail: v.Thumbnail, + Visibility: v.Visibility, + DisplayOrder: v.DisplayOrder, + IsPublished: v.IsPublished, + PublishDate: publishDate, + Status: v.Status, }) } return c.JSON(domain.Response{ Message: "Published videos retrieved successfully", - Data: getPublishedVideosByModuleRes{ - Videos: videoResponses, - }, + Data: videoResponses, }) } -type updateModuleVideoReq struct { - Title *string `json:"title"` - Description *string `json:"description"` - VideoURL *string `json:"video_url"` - Duration *int32 `json:"duration"` - Resolution *string `json:"resolution"` - Visibility *string `json:"visibility"` - Thumbnail *string `json:"thumbnail"` - IsActive *bool `json:"is_active"` -} - -// UpdateModuleVideo godoc -// @Summary Update module video -// @Description Updates a module video's fields -// @Tags module-videos -// @Accept json +// PublishSubCourseVideo godoc +// @Summary Publish sub-course video +// @Description Publishes a video by its ID +// @Tags sub-course-videos // @Produce json // @Param id path int true "Video ID" -// @Param body body updateModuleVideoReq true "Update video payload" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/videos/{id} [put] -func (h *Handler) UpdateModuleVideo(c *fiber.Ctx) error { +// @Router /api/v1/course-management/videos/{id}/publish [put] +func (h *Handler) PublishSubCourseVideo(c *fiber.Ctx) error { idStr := c.Params("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { @@ -1662,7 +1189,54 @@ func (h *Handler) UpdateModuleVideo(c *fiber.Ctx) error { }) } - var req updateModuleVideoReq + err = h.courseMgmtSvc.PublishSubCourseVideo(c.Context(), id) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to publish video", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Video published successfully", + }) +} + +type updateSubCourseVideoReq struct { + Title *string `json:"title"` + Description *string `json:"description"` + VideoURL *string `json:"video_url"` + Duration *int32 `json:"duration"` + Resolution *string `json:"resolution"` + Visibility *string `json:"visibility"` + Thumbnail *string `json:"thumbnail"` + DisplayOrder *int32 `json:"display_order"` + Status *string `json:"status"` // DRAFT, PUBLISHED, INACTIVE, ARCHIVED +} + +// UpdateSubCourseVideo godoc +// @Summary Update sub-course video +// @Description Updates a video's fields +// @Tags sub-course-videos +// @Accept json +// @Produce json +// @Param id path int true "Video ID" +// @Param body body updateSubCourseVideoReq true "Update video payload" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/videos/{id} [put] +func (h *Handler) UpdateSubCourseVideo(c *fiber.Ctx) error { + idStr := c.Params("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid video ID", + Error: err.Error(), + }) + } + + var req updateSubCourseVideoReq if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid request body", @@ -1670,30 +1244,30 @@ func (h *Handler) UpdateModuleVideo(c *fiber.Ctx) error { }) } - err = h.courseMgmtSvc.UpdateModuleVideo(c.Context(), id, req.Title, req.Description, req.VideoURL, req.Duration, req.Resolution, req.Visibility, req.Thumbnail, req.IsActive) + err = h.courseMgmtSvc.UpdateSubCourseVideo(c.Context(), id, req.Title, req.Description, req.VideoURL, req.Duration, req.Resolution, req.Visibility, req.Thumbnail, req.DisplayOrder, req.Status) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to update module video", + Message: "Failed to update video", Error: err.Error(), }) } return c.JSON(domain.Response{ - Message: "Module video updated successfully", + Message: "Video updated successfully", }) } -// DeleteModuleVideo godoc -// @Summary Delete module video -// @Description Deletes a module video by its ID -// @Tags module-videos +// DeleteSubCourseVideo godoc +// @Summary Delete sub-course video +// @Description Archives a video by its ID (soft delete) +// @Tags sub-course-videos // @Produce json // @Param id path int true "Video ID" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/course-management/videos/{id} [delete] -func (h *Handler) DeleteModuleVideo(c *fiber.Ctx) error { +func (h *Handler) DeleteSubCourseVideo(c *fiber.Ctx) error { idStr := c.Params("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { @@ -1703,433 +1277,28 @@ func (h *Handler) DeleteModuleVideo(c *fiber.Ctx) error { }) } - err = h.courseMgmtSvc.DeleteModuleVideo(c.Context(), id) + err = h.courseMgmtSvc.ArchiveSubCourseVideo(c.Context(), id) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to delete module video", + Message: "Failed to delete video", Error: err.Error(), }) } return c.JSON(domain.Response{ - Message: "Module video deleted successfully", + Message: "Video deleted successfully", }) } -// Practice Handlers - -type createPracticeReq struct { - OwnerType string `json:"owner_type" validate:"required"` - OwnerID int64 `json:"owner_id" validate:"required"` - Title string `json:"title" validate:"required"` - Description *string `json:"description"` - BannerImage *string `json:"banner_image"` - Persona *string `json:"persona"` - IsActive *bool `json:"is_active"` -} - -type practiceRes struct { - ID int64 `json:"id"` - OwnerType string `json:"owner_type"` - OwnerID int64 `json:"owner_id"` - Title string `json:"title"` - Description *string `json:"description"` - BannerImage *string `json:"banner_image"` - Persona *string `json:"persona"` - IsActive bool `json:"is_active"` -} - -// CreatePractice godoc -// @Summary Create a new practice -// @Description Creates a new practice for a specific owner (module or level) -// @Tags practices -// @Accept json -// @Produce json -// @Param body body createPracticeReq true "Create practice payload" -// @Success 201 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/practices [post] -func (h *Handler) CreatePractice(c *fiber.Ctx) error { - var req createPracticeReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - } - - practice, err := h.courseMgmtSvc.CreatePractice(c.Context(), req.OwnerType, req.OwnerID, req.Title, req.Description, req.BannerImage, req.Persona, req.IsActive) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to create practice", - Error: err.Error(), - }) - } - - return c.Status(fiber.StatusCreated).JSON(domain.Response{ - Message: "Practice created successfully", - Data: practiceRes{ - ID: practice.ID, - OwnerType: practice.OwnerType, - OwnerID: practice.OwnerID, - Title: practice.Title, - Description: practice.Description, - BannerImage: practice.BannerImage, - Persona: practice.Persona, - IsActive: practice.IsActive, - }, - }) -} - -type getPracticesByOwnerRes struct { - Practices []practiceRes `json:"practices"` -} - -// GetPracticesByOwner godoc -// @Summary Get practices by owner -// @Description Returns all practices for a specific owner type and ID -// @Tags practices -// @Produce json -// @Param ownerType path string true "Owner Type" -// @Param ownerId path int true "Owner ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/owners/{ownerType}/{ownerId}/practices [get] -func (h *Handler) GetPracticesByOwner(c *fiber.Ctx) error { - ownerType := c.Params("ownerType") - ownerIDStr := c.Params("ownerId") - ownerID, err := strconv.ParseInt(ownerIDStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid owner ID", - Error: err.Error(), - }) - } - - practices, err := h.courseMgmtSvc.GetPracticesByOwner(c.Context(), ownerType, ownerID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to retrieve practices", - Error: err.Error(), - }) - } - - var practiceResponses []practiceRes - for _, practice := range practices { - practiceResponses = append(practiceResponses, practiceRes{ - ID: practice.ID, - OwnerType: practice.OwnerType, - OwnerID: practice.OwnerID, - Title: practice.Title, - Description: practice.Description, - BannerImage: practice.BannerImage, - Persona: practice.Persona, - IsActive: practice.IsActive, - }) - } - - return c.JSON(domain.Response{ - Message: "Practices retrieved successfully", - Data: getPracticesByOwnerRes{ - Practices: practiceResponses, - }, - }) -} - -type updatePracticeReq struct { - Title *string `json:"title"` - Description *string `json:"description"` - BannerImage *string `json:"banner_image"` - Persona *string `json:"persona"` - IsActive *bool `json:"is_active"` -} - -// UpdatePractice godoc -// @Summary Update practice -// @Description Updates a practice's fields -// @Tags practices -// @Accept json -// @Produce json -// @Param id path int true "Practice ID" -// @Param body body updatePracticeReq true "Update practice payload" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/practices/{id} [put] -func (h *Handler) UpdatePractice(c *fiber.Ctx) error { - idStr := c.Params("id") - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid practice ID", - Error: err.Error(), - }) - } - - var req updatePracticeReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - } - - err = h.courseMgmtSvc.UpdatePractice(c.Context(), id, req.Title, req.Description, req.BannerImage, req.Persona, req.IsActive) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to update practice", - Error: err.Error(), - }) - } - - return c.JSON(domain.Response{ - Message: "Practice updated successfully", - }) -} - -// DeletePractice godoc -// @Summary Delete practice -// @Description Deletes a practice by its ID -// @Tags practices -// @Produce json -// @Param id path int true "Practice ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/practices/{id} [delete] -func (h *Handler) DeletePractice(c *fiber.Ctx) error { - idStr := c.Params("id") - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid practice ID", - Error: err.Error(), - }) - } - - err = h.courseMgmtSvc.DeletePractice(c.Context(), id) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to delete practice", - Error: err.Error(), - }) - } - - return c.JSON(domain.Response{ - Message: "Practice deleted successfully", - }) -} - -// Practice Question Handlers - -type createPracticeQuestionReq struct { - PracticeID int64 `json:"practice_id" validate:"required"` - Question string `json:"question" validate:"required"` - QuestionVoicePrompt *string `json:"question_voice_prompt"` - SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt"` - SampleAnswer *string `json:"sample_answer"` - Tips *string `json:"tips"` - QType string `json:"q_type" validate:"required"` -} - -type practiceQuestionRes struct { - ID int64 `json:"id"` - PracticeID int64 `json:"practice_id"` - Question string `json:"question"` - QuestionVoicePrompt *string `json:"question_voice_prompt"` - SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt"` - SampleAnswer *string `json:"sample_answer"` - Tips *string `json:"tips"` - Type string `json:"type"` -} - -// CreatePracticeQuestion godoc -// @Summary Create a new practice question -// @Description Creates a new question under a specific practice -// @Tags practice-questions -// @Accept json -// @Produce json -// @Param body body createPracticeQuestionReq true "Create question payload" -// @Success 201 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/questions [post] -func (h *Handler) CreatePracticeQuestion(c *fiber.Ctx) error { - var req createPracticeQuestionReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - } - - question, err := h.courseMgmtSvc.CreatePracticeQuestion(c.Context(), req.PracticeID, req.Question, req.QuestionVoicePrompt, req.SampleAnswerVoicePrompt, req.SampleAnswer, req.Tips, req.QType) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to create practice question", - Error: err.Error(), - }) - } - - return c.Status(fiber.StatusCreated).JSON(domain.Response{ - Message: "Practice question created successfully", - Data: practiceQuestionRes{ - ID: question.ID, - PracticeID: question.PracticeID, - Question: question.Question, - QuestionVoicePrompt: question.QuestionVoicePrompt, - SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt, - SampleAnswer: question.SampleAnswer, - Tips: question.Tips, - Type: question.Type, - }, - }) -} - -type getQuestionsByPracticeRes struct { - Questions []practiceQuestionRes `json:"questions"` -} - -// GetQuestionsByPractice godoc -// @Summary Get questions by practice -// @Description Returns all questions under a specific practice -// @Tags practice-questions -// @Produce json -// @Param practiceId path int true "Practice ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/practices/{practiceId}/questions [get] -func (h *Handler) GetQuestionsByPractice(c *fiber.Ctx) error { - practiceIDStr := c.Params("practiceId") - practiceID, err := strconv.ParseInt(practiceIDStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid practice ID", - Error: err.Error(), - }) - } - - questions, err := h.courseMgmtSvc.GetQuestionsByPractice(c.Context(), practiceID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to retrieve questions", - Error: err.Error(), - }) - } - - var questionResponses []practiceQuestionRes - for _, question := range questions { - questionResponses = append(questionResponses, practiceQuestionRes{ - ID: question.ID, - PracticeID: question.PracticeID, - Question: question.Question, - QuestionVoicePrompt: question.QuestionVoicePrompt, - SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt, - SampleAnswer: question.SampleAnswer, - Tips: question.Tips, - Type: question.Type, - }) - } - - return c.JSON(domain.Response{ - Message: "Questions retrieved successfully", - Data: getQuestionsByPracticeRes{ - Questions: questionResponses, - }, - }) -} - -type updatePracticeQuestionReq struct { - Question *string `json:"question"` - SampleAnswer *string `json:"sample_answer"` - Tips *string `json:"tips"` - QType *string `json:"q_type"` -} - -// UpdatePracticeQuestion godoc -// @Summary Update practice question -// @Description Updates a practice question's fields -// @Tags practice-questions -// @Accept json -// @Produce json -// @Param id path int true "Question ID" -// @Param body body updatePracticeQuestionReq true "Update question payload" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/questions/{id} [put] -func (h *Handler) UpdatePracticeQuestion(c *fiber.Ctx) error { - idStr := c.Params("id") - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid question ID", - Error: err.Error(), - }) - } - - var req updatePracticeQuestionReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - } - - err = h.courseMgmtSvc.UpdatePracticeQuestion(c.Context(), id, req.Question, req.SampleAnswer, req.Tips, req.QType) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to update practice question", - Error: err.Error(), - }) - } - - return c.JSON(domain.Response{ - Message: "Practice question updated successfully", - }) -} - -// DeletePracticeQuestion godoc -// @Summary Delete practice question -// @Description Deletes a practice question by its ID -// @Tags practice-questions -// @Produce json -// @Param id path int true "Question ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/questions/{id} [delete] -func (h *Handler) DeletePracticeQuestion(c *fiber.Ctx) error { - idStr := c.Params("id") - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid question ID", - Error: err.Error(), - }) - } - - err = h.courseMgmtSvc.DeletePracticeQuestion(c.Context(), id) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to delete practice question", - Error: err.Error(), - }) - } - - return c.JSON(domain.Response{ - Message: "Practice question deleted successfully", - }) -} +// NOTE: Practice and Practice Question handlers have been removed. +// Use the unified questions system at /api/v1/questions and /api/v1/question-sets instead. +// Create a question set with set_type="PRACTICE" and owner_type="SUB_COURSE" to replace practices. // Learning Tree Handler // GetFullLearningTree godoc // @Summary Get full learning tree -// @Description Returns the complete learning tree structure with courses, programs, levels, and modules +// @Description Returns the complete learning tree structure with courses and sub-courses // @Tags learning-tree // @Produce json // @Success 200 {object} domain.Response @@ -2149,3 +1318,124 @@ func (h *Handler) GetFullLearningTree(c *fiber.Ctx) error { Data: courses, }) } + +// CreateSubCourseVideoWithVimeo godoc +// @Summary Create a new sub-course video with Vimeo upload +// @Description Creates a video by uploading to Vimeo from a source URL +// @Tags sub-course-videos +// @Accept json +// @Produce json +// @Param body body createVimeoVideoReq true "Create Vimeo video payload" +// @Success 201 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/videos/vimeo [post] +func (h *Handler) CreateSubCourseVideoWithVimeo(c *fiber.Ctx) error { + var req createVimeoVideoReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + + if valErrs, ok := h.validator.Validate(c, req); !ok { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Validation failed", + Error: fmt.Sprintf("%v", valErrs), + }) + } + + video, err := h.courseMgmtSvc.CreateSubCourseVideoWithVimeo( + c.Context(), req.SubCourseID, req.Title, req.Description, + req.SourceURL, req.FileSize, req.Duration, req.Resolution, + req.InstructorID, req.Thumbnail, req.Visibility, req.DisplayOrder, + ) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to create video with Vimeo", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusCreated).JSON(domain.Response{ + Message: "Video created and uploaded to Vimeo successfully", + Data: mapVideoToResponse(video), + Success: true, + }) +} + +// CreateSubCourseVideoFromVimeoID godoc +// @Summary Create a sub-course video from existing Vimeo video +// @Description Creates a video record from an existing Vimeo video ID +// @Tags sub-course-videos +// @Accept json +// @Produce json +// @Param body body createVideoFromVimeoIDReq true "Create from Vimeo ID payload" +// @Success 201 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/videos/vimeo/import [post] +func (h *Handler) CreateSubCourseVideoFromVimeoID(c *fiber.Ctx) error { + var req createVideoFromVimeoIDReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + + if valErrs, ok := h.validator.Validate(c, req); !ok { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Validation failed", + Error: fmt.Sprintf("%v", valErrs), + }) + } + + video, err := h.courseMgmtSvc.CreateSubCourseVideoFromVimeoID( + c.Context(), req.SubCourseID, req.VimeoVideoID, req.Title, + req.Description, req.DisplayOrder, req.InstructorID, + ) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to import video from Vimeo", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusCreated).JSON(domain.Response{ + Message: "Video imported from Vimeo successfully", + Data: mapVideoToResponse(video), + Success: true, + }) +} + +// Helper function to map video to response +func mapVideoToResponse(video domain.SubCourseVideo) subCourseVideoRes { + var publishDate *string + if video.PublishDate != nil { + pd := video.PublishDate.Format("2006-01-02T15:04:05Z07:00") + publishDate = &pd + } + + return subCourseVideoRes{ + ID: video.ID, + SubCourseID: video.SubCourseID, + Title: video.Title, + Description: video.Description, + VideoURL: video.VideoURL, + Duration: video.Duration, + Resolution: video.Resolution, + InstructorID: video.InstructorID, + Thumbnail: video.Thumbnail, + Visibility: video.Visibility, + DisplayOrder: video.DisplayOrder, + IsPublished: video.IsPublished, + PublishDate: publishDate, + Status: video.Status, + VimeoID: video.VimeoID, + VimeoEmbedURL: video.VimeoEmbedURL, + VimeoPlayerHTML: video.VimeoPlayerHTML, + VimeoStatus: video.VimeoStatus, + } +} diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index 0b2dc22..8b97d29 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -7,7 +7,11 @@ import ( "Yimaru-Backend/internal/services/authentication" course_management "Yimaru-Backend/internal/services/course_management" notificationservice "Yimaru-Backend/internal/services/notification" + "Yimaru-Backend/internal/services/questions" "Yimaru-Backend/internal/services/recommendation" + "Yimaru-Backend/internal/services/subscriptions" + "Yimaru-Backend/internal/services/team" + vimeoservice "Yimaru-Backend/internal/services/vimeo" // referralservice "Yimaru-Backend/internal/services/referal" @@ -25,6 +29,8 @@ import ( type Handler struct { assessmentSvc *assessment.Service courseMgmtSvc *course_management.Service + questionsSvc *questions.Service + subscriptionsSvc *subscriptions.Service arifpaySvc *arifpay.ArifpayService logger *slog.Logger settingSvc *settings.Service @@ -33,6 +39,8 @@ type Handler struct { transactionSvc *transaction.Service recommendationSvc recommendation.RecommendationService authSvc *authentication.Service + vimeoSvc *vimeoservice.Service + teamSvc *team.Service jwtConfig jwtutil.JwtConfig validator *customvalidator.CustomValidator Cfg *config.Config @@ -42,6 +50,8 @@ type Handler struct { func New( assessmentSvc *assessment.Service, courseMgmtSvc *course_management.Service, + questionsSvc *questions.Service, + subscriptionsSvc *subscriptions.Service, arifpaySvc *arifpay.ArifpayService, logger *slog.Logger, settingSvc *settings.Service, @@ -51,6 +61,8 @@ func New( userSvc *user.Service, transactionSvc *transaction.Service, authSvc *authentication.Service, + vimeoSvc *vimeoservice.Service, + teamSvc *team.Service, jwtConfig jwtutil.JwtConfig, cfg *config.Config, mongoLoggerSvc *zap.Logger, @@ -58,6 +70,8 @@ func New( return &Handler{ assessmentSvc: assessmentSvc, courseMgmtSvc: courseMgmtSvc, + questionsSvc: questionsSvc, + subscriptionsSvc: subscriptionsSvc, arifpaySvc: arifpaySvc, logger: logger, settingSvc: settingSvc, @@ -67,6 +81,8 @@ func New( transactionSvc: transactionSvc, recommendationSvc: recommendationSvc, authSvc: authSvc, + vimeoSvc: vimeoSvc, + teamSvc: teamSvc, jwtConfig: jwtConfig, Cfg: cfg, mongoLoggerSvc: mongoLoggerSvc, diff --git a/internal/web_server/handlers/initial_assessment.go b/internal/web_server/handlers/initial_assessment.go index b10da03..e8e22e5 100644 --- a/internal/web_server/handlers/initial_assessment.go +++ b/internal/web_server/handlers/initial_assessment.go @@ -9,17 +9,17 @@ import ( // CreateAssessmentQuestion godoc // @Summary Create assessment question -// @Description Creates a new assessment question with options or short answer depending on question type +// @Description Creates a new assessment question using the unified questions system // @Tags assessment-question // @Accept json // @Produce json -// @Param body body domain.CreateAssessmentQuestionInput true "Create question payload" +// @Param body body createQuestionReq true "Create question payload" // @Success 201 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/assessment/questions [post] func (h *Handler) CreateAssessmentQuestion(c *fiber.Ctx) error { - var req domain.CreateAssessmentQuestionInput + var req createQuestionReq if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid request body", @@ -27,15 +27,48 @@ func (h *Handler) CreateAssessmentQuestion(c *fiber.Ctx) error { }) } - // Basic validation - if req.Title == "" { + if req.QuestionText == "" { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Validation error", - Error: "title is required", + Error: "question_text is required", }) } - if err := h.assessmentSvc.CreateQuestion(c.Context(), req); err != nil { + // Build options input + var options []domain.CreateQuestionOptionInput + for _, opt := range req.Options { + options = append(options, domain.CreateQuestionOptionInput{ + OptionText: opt.OptionText, + OptionOrder: opt.OptionOrder, + IsCorrect: opt.IsCorrect, + }) + } + + // Build short answers input + var shortAnswers []domain.CreateShortAnswerInput + for _, sa := range req.ShortAnswers { + shortAnswers = append(shortAnswers, domain.CreateShortAnswerInput{ + AcceptableAnswer: sa.AcceptableAnswer, + MatchType: sa.MatchType, + }) + } + + input := domain.CreateQuestionInput{ + QuestionText: req.QuestionText, + QuestionType: req.QuestionType, + DifficultyLevel: req.DifficultyLevel, + Points: req.Points, + Explanation: req.Explanation, + Tips: req.Tips, + VoicePrompt: req.VoicePrompt, + SampleAnswerVoicePrompt: req.SampleAnswerVoicePrompt, + Status: req.Status, + Options: options, + ShortAnswers: shortAnswers, + } + + question, err := h.assessmentSvc.CreateQuestion(c.Context(), input) + if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to create assessment question", Error: err.Error(), @@ -46,12 +79,19 @@ func (h *Handler) CreateAssessmentQuestion(c *fiber.Ctx) error { Message: "Assessment question created successfully", StatusCode: fiber.StatusCreated, Success: true, + Data: questionRes{ + ID: question.ID, + QuestionText: question.QuestionText, + QuestionType: question.QuestionType, + Status: question.Status, + CreatedAt: question.CreatedAt.String(), + }, }) } // ListAssessmentQuestions godoc // @Summary List assessment questions -// @Description Returns all active assessment questions with their options or answers +// @Description Returns all active assessment questions from the initial assessment set // @Tags assessment-question // @Produce json // @Success 200 {array} domain.QuestionWithDetails @@ -66,9 +106,43 @@ func (h *Handler) ListAssessmentQuestions(c *fiber.Ctx) error { }) } + var questionResponses []questionRes + for _, q := range questions { + var options []optionRes + for _, opt := range q.Options { + options = append(options, optionRes{ + ID: opt.ID, + OptionText: opt.OptionText, + OptionOrder: opt.OptionOrder, + IsCorrect: opt.IsCorrect, + }) + } + + var shortAnswers []shortAnswerRes + for _, sa := range q.ShortAnswers { + shortAnswers = append(shortAnswers, shortAnswerRes{ + ID: sa.ID, + AcceptableAnswer: sa.AcceptableAnswer, + MatchType: sa.MatchType, + }) + } + + questionResponses = append(questionResponses, questionRes{ + ID: q.ID, + QuestionText: q.QuestionText, + QuestionType: q.QuestionType, + DifficultyLevel: q.DifficultyLevel, + Points: q.Points, + Status: q.Status, + CreatedAt: q.CreatedAt.String(), + Options: options, + ShortAnswers: shortAnswers, + }) + } + return c.Status(fiber.StatusOK).JSON(domain.Response{ Message: "Questions fetched successfully", - Data: questions, + Data: questionResponses, Success: true, StatusCode: 200, }) @@ -97,16 +171,44 @@ func (h *Handler) GetAssessmentQuestionByID(c *fiber.Ctx) error { question, err := h.assessmentSvc.GetQuestionByID(c.Context(), id) if err != nil { - // Adjust if you introduce a sentinel error (e.g. ErrQuestionNotFound) return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to fetch assessment question", Error: err.Error(), }) } + var options []optionRes + for _, opt := range question.Options { + options = append(options, optionRes{ + ID: opt.ID, + OptionText: opt.OptionText, + OptionOrder: opt.OptionOrder, + IsCorrect: opt.IsCorrect, + }) + } + + var shortAnswers []shortAnswerRes + for _, sa := range question.ShortAnswers { + shortAnswers = append(shortAnswers, shortAnswerRes{ + ID: sa.ID, + AcceptableAnswer: sa.AcceptableAnswer, + MatchType: sa.MatchType, + }) + } + return c.Status(fiber.StatusOK).JSON(domain.Response{ - Message: "Question fetched successfully", - Data: question, + Message: "Question fetched successfully", + Data: questionRes{ + ID: question.ID, + QuestionText: question.QuestionText, + QuestionType: question.QuestionType, + DifficultyLevel: question.DifficultyLevel, + Points: question.Points, + Status: question.Status, + CreatedAt: question.CreatedAt.String(), + Options: options, + ShortAnswers: shortAnswers, + }, Success: true, StatusCode: 200, }) diff --git a/internal/web_server/handlers/notification_handler.go b/internal/web_server/handlers/notification_handler.go index bf4e27f..78c8868 100644 --- a/internal/web_server/handlers/notification_handler.go +++ b/internal/web_server/handlers/notification_handler.go @@ -572,3 +572,144 @@ func (h *Handler) RegisterDeviceToken(c *fiber.Ctx) error { StatusCode: fiber.StatusCreated, }) } + +// SendTestPushNotification sends a test push notification to the authenticated user +// @Summary Send test push notification +// @Description Sends a test push notification to all registered devices of the current user +// @Tags notifications +// @Accept json +// @Produce json +// @Param body body object{title=string,message=string} true "Test notification content" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 401 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/notifications/test-push [post] +func (h *Handler) SendTestPushNotification(c *fiber.Ctx) error { + type Request struct { + Title string `json:"title"` + Message string `json:"message"` + } + + var req Request + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + + if req.Title == "" { + req.Title = "Test Push Notification" + } + if req.Message == "" { + req.Message = "This is a test push notification from Yimaru Backend" + } + + userID, ok := c.Locals("user_id").(int64) + if !ok || userID == 0 { + return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ + Message: "Invalid user identification", + Error: "User ID not found in context", + }) + } + + // Get user's device tokens first to provide feedback + tokens, err := h.userSvc.GetUserDeviceTokens(c.Context(), userID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to get device tokens", + Error: err.Error(), + }) + } + + if len(tokens) == 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "No registered devices found", + Error: "Please register a device token first using POST /devices/register", + }) + } + + // Create test notification + notification := &domain.Notification{ + RecipientID: userID, + Type: "system_alert", + DeliveryChannel: domain.DeliveryChannelPush, + Payload: domain.NotificationPayload{ + Headline: req.Title, + Message: req.Message, + }, + } + + // Send push notification + err = h.notificationSvc.SendPushNotification(c.Context(), notification) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to send push notification", + Error: err.Error(), + }) + } + + h.mongoLoggerSvc.Info("[NotificationHandler.SendTestPushNotification] Test push sent", + zap.Int64("userID", userID), + zap.Int("deviceCount", len(tokens)), + zap.Time("timestamp", time.Now()), + ) + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Test push notification sent successfully", + Success: true, + StatusCode: fiber.StatusOK, + Data: map[string]interface{}{ + "devices_count": len(tokens), + "title": req.Title, + "message": req.Message, + }, + }) +} + +func (h *Handler) UnregisterDeviceToken(c *fiber.Ctx) error { + type Request struct { + DeviceToken string `json:"device_token" validate:"required"` + } + + var req Request + if err := c.BodyParser(&req); err != nil { + h.mongoLoggerSvc.Info("[NotificationHandler.UnregisterDeviceToken] Failed to parse request body", + zap.Int("status_code", fiber.StatusBadRequest), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + userID, ok := c.Locals("user_id").(int64) + if !ok || userID == 0 { + h.mongoLoggerSvc.Error("[NotificationHandler.UnregisterDeviceToken] Invalid user ID in context", + zap.Int("status_code", fiber.StatusUnauthorized), + zap.Time("timestamp", time.Now()), + ) + return fiber.NewError(fiber.StatusUnauthorized, "Invalid user identification") + } + + if err := h.userSvc.DeactivateDevice(c.Context(), userID, req.DeviceToken); err != nil { + h.mongoLoggerSvc.Error("[NotificationHandler.UnregisterDeviceToken] Failed to unregister device token", + zap.Int64("userID", userID), + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to unregister device token: "+err.Error()) + } + + h.mongoLoggerSvc.Info("[NotificationHandler.UnregisterDeviceToken] Device token unregistered successfully", + zap.Int64("userID", userID), + zap.Time("timestamp", time.Now()), + ) + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Device token unregistered successfully", + Success: true, + StatusCode: fiber.StatusOK, + }) +} diff --git a/internal/web_server/handlers/questions.go b/internal/web_server/handlers/questions.go new file mode 100644 index 0000000..446e03f --- /dev/null +++ b/internal/web_server/handlers/questions.go @@ -0,0 +1,1227 @@ +package handlers + +import ( + "Yimaru-Backend/internal/domain" + "strconv" + + "github.com/gofiber/fiber/v2" +) + +// Request/Response types for Questions + +type optionInput struct { + OptionText string `json:"option_text" validate:"required"` + OptionOrder *int32 `json:"option_order"` + IsCorrect bool `json:"is_correct"` +} + +type shortAnswerInput struct { + AcceptableAnswer string `json:"acceptable_answer" validate:"required"` + MatchType *string `json:"match_type"` +} + +type createQuestionReq struct { + QuestionText string `json:"question_text" validate:"required"` + 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"` + Status *string `json:"status"` + Options []optionInput `json:"options"` + ShortAnswers []shortAnswerInput `json:"short_answers"` +} + +type optionRes struct { + ID int64 `json:"id"` + OptionText string `json:"option_text"` + OptionOrder int32 `json:"option_order"` + IsCorrect bool `json:"is_correct"` +} + +type shortAnswerRes struct { + ID int64 `json:"id"` + AcceptableAnswer string `json:"acceptable_answer"` + MatchType string `json:"match_type"` +} + +type questionRes struct { + ID int64 `json:"id"` + QuestionText string `json:"question_text"` + QuestionType string `json:"question_type"` + DifficultyLevel *string `json:"difficulty_level,omitempty"` + Points int32 `json:"points"` + Explanation *string `json:"explanation,omitempty"` + Tips *string `json:"tips,omitempty"` + VoicePrompt *string `json:"voice_prompt,omitempty"` + SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt,omitempty"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + Options []optionRes `json:"options,omitempty"` + ShortAnswers []shortAnswerRes `json:"short_answers,omitempty"` +} + +type listQuestionsRes struct { + Questions []questionRes `json:"questions"` + TotalCount int64 `json:"total_count"` +} + +// CreateQuestion godoc +// @Summary Create a new question +// @Description Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER) +// @Tags questions +// @Accept json +// @Produce json +// @Param body body createQuestionReq true "Create question payload" +// @Success 201 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/questions [post] +func (h *Handler) CreateQuestion(c *fiber.Ctx) error { + var req createQuestionReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + + // Build options input + var options []domain.CreateQuestionOptionInput + for _, opt := range req.Options { + options = append(options, domain.CreateQuestionOptionInput{ + OptionText: opt.OptionText, + OptionOrder: opt.OptionOrder, + IsCorrect: opt.IsCorrect, + }) + } + + // Build short answers input + var shortAnswers []domain.CreateShortAnswerInput + for _, sa := range req.ShortAnswers { + shortAnswers = append(shortAnswers, domain.CreateShortAnswerInput{ + AcceptableAnswer: sa.AcceptableAnswer, + MatchType: sa.MatchType, + }) + } + + input := domain.CreateQuestionInput{ + QuestionText: req.QuestionText, + QuestionType: req.QuestionType, + DifficultyLevel: req.DifficultyLevel, + Points: req.Points, + Explanation: req.Explanation, + Tips: req.Tips, + VoicePrompt: req.VoicePrompt, + SampleAnswerVoicePrompt: req.SampleAnswerVoicePrompt, + Status: req.Status, + Options: options, + ShortAnswers: shortAnswers, + } + + question, err := h.questionsSvc.CreateQuestion(c.Context(), input) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to create question", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusCreated).JSON(domain.Response{ + Message: "Question created successfully", + Data: questionRes{ + ID: question.ID, + QuestionText: question.QuestionText, + QuestionType: question.QuestionType, + DifficultyLevel: question.DifficultyLevel, + Points: question.Points, + Explanation: question.Explanation, + Tips: question.Tips, + VoicePrompt: question.VoicePrompt, + SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt, + Status: question.Status, + CreatedAt: question.CreatedAt.String(), + }, + }) +} + +// GetQuestionByID godoc +// @Summary Get question by ID +// @Description Returns a question with its options/short answers +// @Tags questions +// @Produce json +// @Param id path int true "Question ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Router /api/v1/questions/{id} [get] +func (h *Handler) GetQuestionByID(c *fiber.Ctx) error { + idStr := c.Params("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid question ID", + Error: err.Error(), + }) + } + + question, err := h.questionsSvc.GetQuestionWithDetails(c.Context(), id) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Question not found", + Error: err.Error(), + }) + } + + var options []optionRes + for _, opt := range question.Options { + options = append(options, optionRes{ + ID: opt.ID, + OptionText: opt.OptionText, + OptionOrder: opt.OptionOrder, + IsCorrect: opt.IsCorrect, + }) + } + + var shortAnswers []shortAnswerRes + for _, sa := range question.ShortAnswers { + shortAnswers = append(shortAnswers, shortAnswerRes{ + ID: sa.ID, + AcceptableAnswer: sa.AcceptableAnswer, + MatchType: sa.MatchType, + }) + } + + return c.JSON(domain.Response{ + Message: "Question retrieved successfully", + Data: questionRes{ + ID: question.ID, + QuestionText: question.QuestionText, + QuestionType: question.QuestionType, + DifficultyLevel: question.DifficultyLevel, + Points: question.Points, + Explanation: question.Explanation, + Tips: question.Tips, + VoicePrompt: question.VoicePrompt, + SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt, + Status: question.Status, + CreatedAt: question.CreatedAt.String(), + Options: options, + ShortAnswers: shortAnswers, + }, + }) +} + +// ListQuestions godoc +// @Summary List questions +// @Description Returns a paginated list of questions with optional filters +// @Tags questions +// @Produce json +// @Param question_type query string false "Question type filter (MCQ, TRUE_FALSE, SHORT_ANSWER)" +// @Param difficulty query string false "Difficulty level filter (EASY, MEDIUM, HARD)" +// @Param status query string false "Status filter (DRAFT, PUBLISHED, INACTIVE)" +// @Param limit query int false "Limit" default(10) +// @Param offset query int false "Offset" default(0) +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/questions [get] +func (h *Handler) ListQuestions(c *fiber.Ctx) error { + questionType := c.Query("question_type") + difficulty := c.Query("difficulty") + status := c.Query("status") + limitStr := c.Query("limit", "10") + offsetStr := c.Query("offset", "0") + + limit, _ := strconv.Atoi(limitStr) + offset, _ := strconv.Atoi(offsetStr) + + var qTypePtr, diffPtr, statusPtr *string + if questionType != "" { + qTypePtr = &questionType + } + if difficulty != "" { + diffPtr = &difficulty + } + if status != "" { + statusPtr = &status + } + + questions, totalCount, err := h.questionsSvc.ListQuestions(c.Context(), qTypePtr, diffPtr, statusPtr, int32(limit), int32(offset)) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to list questions", + Error: err.Error(), + }) + } + + var questionResponses []questionRes + for _, q := range questions { + questionResponses = append(questionResponses, questionRes{ + ID: q.ID, + QuestionText: q.QuestionText, + QuestionType: q.QuestionType, + DifficultyLevel: q.DifficultyLevel, + Points: q.Points, + Explanation: q.Explanation, + Tips: q.Tips, + VoicePrompt: q.VoicePrompt, + Status: q.Status, + CreatedAt: q.CreatedAt.String(), + }) + } + + return c.JSON(domain.Response{ + Message: "Questions retrieved successfully", + Data: listQuestionsRes{ + Questions: questionResponses, + TotalCount: totalCount, + }, + }) +} + +// SearchQuestions godoc +// @Summary Search questions +// @Description Search questions by text +// @Tags questions +// @Produce json +// @Param q query string true "Search query" +// @Param limit query int false "Limit" default(10) +// @Param offset query int false "Offset" default(0) +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/questions/search [get] +func (h *Handler) SearchQuestions(c *fiber.Ctx) error { + query := c.Query("q") + if query == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Search query is required", + }) + } + + limitStr := c.Query("limit", "10") + offsetStr := c.Query("offset", "0") + limit, _ := strconv.Atoi(limitStr) + offset, _ := strconv.Atoi(offsetStr) + + questions, totalCount, err := h.questionsSvc.SearchQuestions(c.Context(), query, int32(limit), int32(offset)) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to search questions", + Error: err.Error(), + }) + } + + var questionResponses []questionRes + for _, q := range questions { + questionResponses = append(questionResponses, questionRes{ + ID: q.ID, + QuestionText: q.QuestionText, + QuestionType: q.QuestionType, + DifficultyLevel: q.DifficultyLevel, + Points: q.Points, + Status: q.Status, + CreatedAt: q.CreatedAt.String(), + }) + } + + return c.JSON(domain.Response{ + Message: "Questions retrieved successfully", + Data: listQuestionsRes{ + Questions: questionResponses, + TotalCount: totalCount, + }, + }) +} + +type updateQuestionReq struct { + QuestionText *string `json:"question_text"` + QuestionType *string `json:"question_type"` + 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"` + Status *string `json:"status"` + Options []optionInput `json:"options"` + ShortAnswers []shortAnswerInput `json:"short_answers"` +} + +// UpdateQuestion godoc +// @Summary Update a question +// @Description Updates a question and optionally replaces its options/short answers +// @Tags questions +// @Accept json +// @Produce json +// @Param id path int true "Question ID" +// @Param body body updateQuestionReq true "Update question payload" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/questions/{id} [put] +func (h *Handler) UpdateQuestion(c *fiber.Ctx) error { + idStr := c.Params("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid question ID", + Error: err.Error(), + }) + } + + var req updateQuestionReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + + var options []domain.CreateQuestionOptionInput + for _, opt := range req.Options { + options = append(options, domain.CreateQuestionOptionInput{ + OptionText: opt.OptionText, + OptionOrder: opt.OptionOrder, + IsCorrect: opt.IsCorrect, + }) + } + + var shortAnswers []domain.CreateShortAnswerInput + for _, sa := range req.ShortAnswers { + shortAnswers = append(shortAnswers, domain.CreateShortAnswerInput{ + AcceptableAnswer: sa.AcceptableAnswer, + MatchType: sa.MatchType, + }) + } + + questionText := "" + if req.QuestionText != nil { + questionText = *req.QuestionText + } + questionType := "" + if req.QuestionType != nil { + questionType = *req.QuestionType + } + + input := domain.CreateQuestionInput{ + QuestionText: questionText, + QuestionType: questionType, + DifficultyLevel: req.DifficultyLevel, + Points: req.Points, + Explanation: req.Explanation, + Tips: req.Tips, + VoicePrompt: req.VoicePrompt, + SampleAnswerVoicePrompt: req.SampleAnswerVoicePrompt, + Status: req.Status, + Options: options, + ShortAnswers: shortAnswers, + } + + err = h.questionsSvc.UpdateQuestion(c.Context(), id, input) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to update question", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Question updated successfully", + }) +} + +// DeleteQuestion godoc +// @Summary Delete a question +// @Description Archives a question (soft delete) +// @Tags questions +// @Produce json +// @Param id path int true "Question ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/questions/{id} [delete] +func (h *Handler) DeleteQuestion(c *fiber.Ctx) error { + idStr := c.Params("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid question ID", + Error: err.Error(), + }) + } + + err = h.questionsSvc.ArchiveQuestion(c.Context(), id) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to delete question", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Question deleted successfully", + }) +} + +// Question Set types + +type createQuestionSetReq struct { + Title string `json:"title" validate:"required"` + Description *string `json:"description"` + SetType string `json:"set_type" validate:"required,oneof=PRACTICE INITIAL_ASSESSMENT QUIZ EXAM SURVEY"` + OwnerType *string `json:"owner_type"` + OwnerID *int64 `json:"owner_id"` + BannerImage *string `json:"banner_image"` + Persona *string `json:"persona"` + TimeLimitMinutes *int32 `json:"time_limit_minutes"` + PassingScore *int32 `json:"passing_score"` + ShuffleQuestions *bool `json:"shuffle_questions"` + Status *string `json:"status"` + SubCourseVideoID *int64 `json:"sub_course_video_id"` +} + +type questionSetRes struct { + ID int64 `json:"id"` + Title string `json:"title"` + Description *string `json:"description,omitempty"` + SetType string `json:"set_type"` + OwnerType *string `json:"owner_type,omitempty"` + OwnerID *int64 `json:"owner_id,omitempty"` + BannerImage *string `json:"banner_image,omitempty"` + Persona *string `json:"persona,omitempty"` + TimeLimitMinutes *int32 `json:"time_limit_minutes,omitempty"` + PassingScore *int32 `json:"passing_score,omitempty"` + ShuffleQuestions bool `json:"shuffle_questions"` + Status string `json:"status"` + SubCourseVideoID *int64 `json:"sub_course_video_id,omitempty"` + CreatedAt string `json:"created_at"` + QuestionCount *int64 `json:"question_count,omitempty"` +} + +type listQuestionSetsRes struct { + QuestionSets []questionSetRes `json:"question_sets"` + TotalCount int64 `json:"total_count"` +} + +// CreateQuestionSet godoc +// @Summary Create a new question set +// @Description Creates a new question set (practice, assessment, quiz, exam, or survey) +// @Tags question-sets +// @Accept json +// @Produce json +// @Param body body createQuestionSetReq true "Create question set payload" +// @Success 201 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/question-sets [post] +func (h *Handler) CreateQuestionSet(c *fiber.Ctx) error { + var req createQuestionSetReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + + input := domain.CreateQuestionSetInput{ + Title: req.Title, + Description: req.Description, + SetType: req.SetType, + OwnerType: req.OwnerType, + OwnerID: req.OwnerID, + BannerImage: req.BannerImage, + Persona: req.Persona, + TimeLimitMinutes: req.TimeLimitMinutes, + PassingScore: req.PassingScore, + ShuffleQuestions: req.ShuffleQuestions, + Status: req.Status, + SubCourseVideoID: req.SubCourseVideoID, + } + + set, err := h.questionsSvc.CreateQuestionSet(c.Context(), input) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to create question set", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusCreated).JSON(domain.Response{ + Message: "Question set created successfully", + Data: questionSetRes{ + ID: set.ID, + Title: set.Title, + Description: set.Description, + SetType: set.SetType, + OwnerType: set.OwnerType, + OwnerID: set.OwnerID, + BannerImage: set.BannerImage, + Persona: set.Persona, + TimeLimitMinutes: set.TimeLimitMinutes, + PassingScore: set.PassingScore, + ShuffleQuestions: set.ShuffleQuestions, + Status: set.Status, + SubCourseVideoID: set.SubCourseVideoID, + CreatedAt: set.CreatedAt.String(), + }, + }) +} + +// GetQuestionSetByID godoc +// @Summary Get question set by ID +// @Description Returns a question set with question count +// @Tags question-sets +// @Produce json +// @Param id path int true "Question Set ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Router /api/v1/question-sets/{id} [get] +func (h *Handler) GetQuestionSetByID(c *fiber.Ctx) error { + idStr := c.Params("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid question set ID", + Error: err.Error(), + }) + } + + set, err := h.questionsSvc.GetQuestionSetByID(c.Context(), id) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Question set not found", + Error: err.Error(), + }) + } + + count, _ := h.questionsSvc.CountQuestionsInSet(c.Context(), id) + + return c.JSON(domain.Response{ + Message: "Question set retrieved successfully", + Data: questionSetRes{ + ID: set.ID, + Title: set.Title, + Description: set.Description, + SetType: set.SetType, + OwnerType: set.OwnerType, + OwnerID: set.OwnerID, + BannerImage: set.BannerImage, + Persona: set.Persona, + TimeLimitMinutes: set.TimeLimitMinutes, + PassingScore: set.PassingScore, + ShuffleQuestions: set.ShuffleQuestions, + Status: set.Status, + SubCourseVideoID: set.SubCourseVideoID, + CreatedAt: set.CreatedAt.String(), + QuestionCount: &count, + }, + }) +} + +// GetQuestionSetsByType godoc +// @Summary Get question sets by type +// @Description Returns a paginated list of question sets filtered by type +// @Tags question-sets +// @Produce json +// @Param set_type query string true "Set type (PRACTICE, INITIAL_ASSESSMENT, QUIZ, EXAM, SURVEY)" +// @Param limit query int false "Limit" default(10) +// @Param offset query int false "Offset" default(0) +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/question-sets [get] +func (h *Handler) GetQuestionSetsByType(c *fiber.Ctx) error { + setType := c.Query("set_type") + if setType == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "set_type query parameter is required", + }) + } + + limitStr := c.Query("limit", "10") + offsetStr := c.Query("offset", "0") + limit, _ := strconv.Atoi(limitStr) + offset, _ := strconv.Atoi(offsetStr) + + sets, totalCount, err := h.questionsSvc.GetQuestionSetsByType(c.Context(), setType, int32(limit), int32(offset)) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to get question sets", + Error: err.Error(), + }) + } + + var setResponses []questionSetRes + for _, s := range sets { + setResponses = append(setResponses, questionSetRes{ + ID: s.ID, + Title: s.Title, + Description: s.Description, + SetType: s.SetType, + OwnerType: s.OwnerType, + OwnerID: s.OwnerID, + BannerImage: s.BannerImage, + Persona: s.Persona, + TimeLimitMinutes: s.TimeLimitMinutes, + PassingScore: s.PassingScore, + ShuffleQuestions: s.ShuffleQuestions, + Status: s.Status, + SubCourseVideoID: s.SubCourseVideoID, + CreatedAt: s.CreatedAt.String(), + }) + } + + return c.JSON(domain.Response{ + Message: "Question sets retrieved successfully", + Data: listQuestionSetsRes{ + QuestionSets: setResponses, + TotalCount: totalCount, + }, + }) +} + +// GetQuestionSetsByOwner godoc +// @Summary Get question sets by owner +// @Description Returns question sets for a specific owner (e.g., sub-course) +// @Tags question-sets +// @Produce json +// @Param owner_type query string true "Owner type (SUB_COURSE, COURSE, CATEGORY, STANDALONE)" +// @Param owner_id query int true "Owner ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/question-sets/by-owner [get] +func (h *Handler) GetQuestionSetsByOwner(c *fiber.Ctx) error { + ownerType := c.Query("owner_type") + ownerIDStr := c.Query("owner_id") + + if ownerType == "" || ownerIDStr == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "owner_type and owner_id query parameters are required", + }) + } + + ownerID, err := strconv.ParseInt(ownerIDStr, 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid owner_id", + Error: err.Error(), + }) + } + + sets, err := h.questionsSvc.GetQuestionSetsByOwner(c.Context(), ownerType, ownerID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to get question sets", + Error: err.Error(), + }) + } + + var setResponses []questionSetRes + for _, s := range sets { + setResponses = append(setResponses, questionSetRes{ + ID: s.ID, + Title: s.Title, + Description: s.Description, + SetType: s.SetType, + OwnerType: s.OwnerType, + OwnerID: s.OwnerID, + BannerImage: s.BannerImage, + Persona: s.Persona, + TimeLimitMinutes: s.TimeLimitMinutes, + PassingScore: s.PassingScore, + ShuffleQuestions: s.ShuffleQuestions, + Status: s.Status, + SubCourseVideoID: s.SubCourseVideoID, + CreatedAt: s.CreatedAt.String(), + }) + } + + return c.JSON(domain.Response{ + Message: "Question sets retrieved successfully", + Data: setResponses, + }) +} + +type updateQuestionSetReq struct { + Title *string `json:"title"` + Description *string `json:"description"` + BannerImage *string `json:"banner_image"` + Persona *string `json:"persona"` + TimeLimitMinutes *int32 `json:"time_limit_minutes"` + PassingScore *int32 `json:"passing_score"` + ShuffleQuestions *bool `json:"shuffle_questions"` + Status *string `json:"status"` + SubCourseVideoID *int64 `json:"sub_course_video_id"` +} + +// UpdateQuestionSet godoc +// @Summary Update a question set +// @Description Updates a question set's properties +// @Tags question-sets +// @Accept json +// @Produce json +// @Param id path int true "Question Set ID" +// @Param body body updateQuestionSetReq true "Update question set payload" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/question-sets/{id} [put] +func (h *Handler) UpdateQuestionSet(c *fiber.Ctx) error { + idStr := c.Params("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid question set ID", + Error: err.Error(), + }) + } + + var req updateQuestionSetReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + + title := "" + if req.Title != nil { + title = *req.Title + } + + input := domain.CreateQuestionSetInput{ + Title: title, + Description: req.Description, + BannerImage: req.BannerImage, + Persona: req.Persona, + TimeLimitMinutes: req.TimeLimitMinutes, + PassingScore: req.PassingScore, + ShuffleQuestions: req.ShuffleQuestions, + Status: req.Status, + SubCourseVideoID: req.SubCourseVideoID, + } + + err = h.questionsSvc.UpdateQuestionSet(c.Context(), id, input) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to update question set", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Question set updated successfully", + }) +} + +// DeleteQuestionSet godoc +// @Summary Delete a question set +// @Description Archives a question set (soft delete) +// @Tags question-sets +// @Produce json +// @Param id path int true "Question Set ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/question-sets/{id} [delete] +func (h *Handler) DeleteQuestionSet(c *fiber.Ctx) error { + idStr := c.Params("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid question set ID", + Error: err.Error(), + }) + } + + err = h.questionsSvc.ArchiveQuestionSet(c.Context(), id) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to delete question set", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Question set deleted successfully", + }) +} + +// Question Set Items + +type addQuestionToSetReq struct { + QuestionID int64 `json:"question_id" validate:"required"` + DisplayOrder *int32 `json:"display_order"` +} + +type questionSetItemRes struct { + ID int64 `json:"id"` + SetID int64 `json:"set_id"` + QuestionID int64 `json:"question_id"` + DisplayOrder int32 `json:"display_order"` + QuestionText string `json:"question_text"` + QuestionType string `json:"question_type"` + DifficultyLevel *string `json:"difficulty_level,omitempty"` + Points int32 `json:"points"` + Explanation *string `json:"explanation,omitempty"` + Tips *string `json:"tips,omitempty"` + VoicePrompt *string `json:"voice_prompt,omitempty"` + QuestionStatus string `json:"question_status"` +} + +// AddQuestionToSet godoc +// @Summary Add question to set +// @Description Links a question to a question set +// @Tags question-set-items +// @Accept json +// @Produce json +// @Param setId path int true "Question Set ID" +// @Param body body addQuestionToSetReq true "Add question to set payload" +// @Success 201 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/question-sets/{setId}/questions [post] +func (h *Handler) AddQuestionToSet(c *fiber.Ctx) error { + setIDStr := c.Params("setId") + setID, err := strconv.ParseInt(setIDStr, 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid set ID", + Error: err.Error(), + }) + } + + var req addQuestionToSetReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + + item, err := h.questionsSvc.AddQuestionToSet(c.Context(), setID, req.QuestionID, req.DisplayOrder) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to add question to set", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusCreated).JSON(domain.Response{ + Message: "Question added to set successfully", + Data: map[string]interface{}{ + "id": item.ID, + "set_id": item.SetID, + "question_id": item.QuestionID, + "display_order": item.DisplayOrder, + }, + }) +} + +// GetQuestionSetItems godoc +// @Summary Get questions in set +// @Description Returns all questions in a question set with details +// @Tags question-set-items +// @Produce json +// @Param setId path int true "Question Set ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/question-sets/{setId}/questions [get] +func (h *Handler) GetQuestionsInSet(c *fiber.Ctx) error { + setIDStr := c.Params("setId") + setID, err := strconv.ParseInt(setIDStr, 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid set ID", + Error: err.Error(), + }) + } + + items, err := h.questionsSvc.GetQuestionSetItems(c.Context(), setID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to get questions in set", + Error: err.Error(), + }) + } + + var itemResponses []questionSetItemRes + for _, item := range items { + itemResponses = append(itemResponses, questionSetItemRes{ + ID: item.ID, + SetID: item.SetID, + QuestionID: item.QuestionID, + DisplayOrder: item.DisplayOrder, + QuestionText: item.QuestionText, + QuestionType: item.QuestionType, + DifficultyLevel: item.DifficultyLevel, + Points: item.Points, + Explanation: item.Explanation, + Tips: item.Tips, + VoicePrompt: item.VoicePrompt, + QuestionStatus: item.QuestionStatus, + }) + } + + return c.JSON(domain.Response{ + Message: "Questions retrieved successfully", + Data: itemResponses, + }) +} + +// RemoveQuestionFromSet godoc +// @Summary Remove question from set +// @Description Unlinks a question from a question set +// @Tags question-set-items +// @Produce json +// @Param setId path int true "Question Set ID" +// @Param questionId path int true "Question ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/question-sets/{setId}/questions/{questionId} [delete] +func (h *Handler) RemoveQuestionFromSet(c *fiber.Ctx) error { + setIDStr := c.Params("setId") + setID, err := strconv.ParseInt(setIDStr, 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid set ID", + Error: err.Error(), + }) + } + + questionIDStr := c.Params("questionId") + questionID, err := strconv.ParseInt(questionIDStr, 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid question ID", + Error: err.Error(), + }) + } + + err = h.questionsSvc.RemoveQuestionFromSet(c.Context(), setID, questionID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to remove question from set", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Question removed from set successfully", + }) +} + +type updateQuestionOrderReq struct { + DisplayOrder int32 `json:"display_order" validate:"required"` +} + +// UpdateQuestionOrder godoc +// @Summary Update question order in set +// @Description Updates the display order of a question in a set +// @Tags question-set-items +// @Accept json +// @Produce json +// @Param setId path int true "Question Set ID" +// @Param questionId path int true "Question ID" +// @Param body body updateQuestionOrderReq true "Update order payload" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/question-sets/{setId}/questions/{questionId}/order [put] +func (h *Handler) UpdateQuestionOrderInSet(c *fiber.Ctx) error { + setIDStr := c.Params("setId") + setID, err := strconv.ParseInt(setIDStr, 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid set ID", + Error: err.Error(), + }) + } + + questionIDStr := c.Params("questionId") + questionID, err := strconv.ParseInt(questionIDStr, 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid question ID", + Error: err.Error(), + }) + } + + var req updateQuestionOrderReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + + err = h.questionsSvc.UpdateQuestionOrder(c.Context(), setID, questionID, req.DisplayOrder) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to update question order", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Question order updated successfully", + }) +} + +// User Persona types for question sets + +type userPersonaRes struct { + ID int64 `json:"id"` + FirstName *string `json:"first_name,omitempty"` + LastName *string `json:"last_name,omitempty"` + NickName *string `json:"nick_name,omitempty"` + ProfilePictureURL *string `json:"profile_picture_url,omitempty"` + Role string `json:"role"` + DisplayOrder int32 `json:"display_order"` +} + +type addUserPersonaReq struct { + UserID int64 `json:"user_id" validate:"required"` + DisplayOrder int32 `json:"display_order"` +} + +// GetUserPersonasByQuestionSet godoc +// @Summary Get user personas for a question set +// @Description Returns all users assigned as personas to a question set (practice) +// @Tags question-sets +// @Produce json +// @Param setId path int true "Question Set ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/question-sets/{setId}/personas [get] +func (h *Handler) GetUserPersonasByQuestionSet(c *fiber.Ctx) error { + setIDStr := c.Params("setId") + setID, err := strconv.ParseInt(setIDStr, 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid set ID", + Error: err.Error(), + }) + } + + personas, err := h.questionsSvc.GetUserPersonasByQuestionSetID(c.Context(), setID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to get personas", + Error: err.Error(), + }) + } + + result := make([]userPersonaRes, len(personas)) + for i, p := range personas { + result[i] = userPersonaRes{ + ID: p.ID, + FirstName: p.FirstName, + LastName: p.LastName, + NickName: p.NickName, + ProfilePictureURL: p.ProfilePictureURL, + Role: p.Role, + DisplayOrder: p.DisplayOrder, + } + } + + return c.JSON(domain.Response{ + Message: "Personas retrieved successfully", + Data: result, + }) +} + +// AddUserPersonaToQuestionSet godoc +// @Summary Add a user as persona to a question set +// @Description Links a user as a persona to a question set (practice) +// @Tags question-sets +// @Accept json +// @Produce json +// @Param setId path int true "Question Set ID" +// @Param body body addUserPersonaReq true "Add user persona payload" +// @Success 201 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/question-sets/{setId}/personas [post] +func (h *Handler) AddUserPersonaToQuestionSet(c *fiber.Ctx) error { + setIDStr := c.Params("setId") + setID, err := strconv.ParseInt(setIDStr, 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid set ID", + Error: err.Error(), + }) + } + + var req addUserPersonaReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + + err = h.questionsSvc.AddUserPersonaToQuestionSet(c.Context(), setID, req.UserID, req.DisplayOrder) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to add persona to question set", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusCreated).JSON(domain.Response{ + Message: "Persona added to question set successfully", + }) +} + +// RemoveUserPersonaFromQuestionSet godoc +// @Summary Remove a user persona from a question set +// @Description Unlinks a user as persona from a question set (practice) +// @Tags question-sets +// @Produce json +// @Param setId path int true "Question Set ID" +// @Param userId path int true "User ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/question-sets/{setId}/personas/{userId} [delete] +func (h *Handler) RemoveUserPersonaFromQuestionSet(c *fiber.Ctx) error { + setIDStr := c.Params("setId") + setID, err := strconv.ParseInt(setIDStr, 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid set ID", + Error: err.Error(), + }) + } + + userIDStr := c.Params("userId") + userID, err := strconv.ParseInt(userIDStr, 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid user ID", + Error: err.Error(), + }) + } + + err = h.questionsSvc.RemoveUserPersonaFromQuestionSet(c.Context(), setID, userID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to remove persona from question set", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Persona removed from question set successfully", + }) +} diff --git a/internal/web_server/handlers/subscriptions.go b/internal/web_server/handlers/subscriptions.go new file mode 100644 index 0000000..6d7255b --- /dev/null +++ b/internal/web_server/handlers/subscriptions.go @@ -0,0 +1,585 @@ +package handlers + +import ( + "Yimaru-Backend/internal/domain" + "strconv" + + "github.com/gofiber/fiber/v2" +) + +// ===================== +// Subscription Plan Types +// ===================== + +type createPlanReq struct { + Name string `json:"name" validate:"required"` + Description *string `json:"description"` + DurationValue int32 `json:"duration_value" validate:"required,min=1"` + DurationUnit string `json:"duration_unit" validate:"required,oneof=DAY WEEK MONTH YEAR"` + Price float64 `json:"price" validate:"required,min=0"` + Currency string `json:"currency" validate:"required"` + IsActive *bool `json:"is_active"` +} + +type updatePlanReq struct { + Name *string `json:"name"` + Description *string `json:"description"` + DurationValue *int32 `json:"duration_value"` + DurationUnit *string `json:"duration_unit"` + Price *float64 `json:"price"` + Currency *string `json:"currency"` + IsActive *bool `json:"is_active"` +} + +type planRes struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + DurationValue int32 `json:"duration_value"` + DurationUnit string `json:"duration_unit"` + Price float64 `json:"price"` + Currency string `json:"currency"` + IsActive bool `json:"is_active"` + CreatedAt string `json:"created_at"` +} + +// ===================== +// User Subscription Types +// ===================== + +type subscribeReq struct { + PlanID int64 `json:"plan_id" validate:"required"` + PaymentReference *string `json:"payment_reference"` + PaymentMethod *string `json:"payment_method"` +} + +type subscribeWithPaymentReq struct { + PlanID int64 `json:"plan_id" validate:"required"` + Phone string `json:"phone" validate:"required"` + Email string `json:"email" validate:"required,email"` +} + +type subscriptionRes struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + PlanID int64 `json:"plan_id"` + PlanName *string `json:"plan_name,omitempty"` + StartsAt string `json:"starts_at"` + ExpiresAt string `json:"expires_at"` + Status string `json:"status"` + PaymentReference *string `json:"payment_reference,omitempty"` + PaymentMethod *string `json:"payment_method,omitempty"` + AutoRenew bool `json:"auto_renew"` + DurationValue *int32 `json:"duration_value,omitempty"` + DurationUnit *string `json:"duration_unit,omitempty"` + Price *float64 `json:"price,omitempty"` + Currency *string `json:"currency,omitempty"` + CreatedAt string `json:"created_at"` +} + +type autoRenewReq struct { + AutoRenew bool `json:"auto_renew"` +} + +// ===================== +// Subscription Plan Handlers +// ===================== + +// CreateSubscriptionPlan godoc +// @Summary Create a subscription plan +// @Description Creates a new subscription plan (admin only) +// @Tags subscriptions +// @Accept json +// @Produce json +// @Param body body createPlanReq true "Create plan payload" +// @Success 201 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/subscription-plans [post] +func (h *Handler) CreateSubscriptionPlan(c *fiber.Ctx) error { + var req createPlanReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + + plan, err := h.subscriptionsSvc.CreatePlan(c.Context(), domain.CreateSubscriptionPlanInput{ + Name: req.Name, + Description: req.Description, + DurationValue: req.DurationValue, + DurationUnit: req.DurationUnit, + Price: req.Price, + Currency: req.Currency, + IsActive: req.IsActive, + }) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to create subscription plan", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusCreated).JSON(domain.Response{ + Message: "Subscription plan created successfully", + Data: planToRes(plan), + }) +} + +// ListSubscriptionPlans godoc +// @Summary List subscription plans +// @Description Returns all subscription plans +// @Tags subscriptions +// @Produce json +// @Param active_only query bool false "Return only active plans" +// @Success 200 {object} domain.Response +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/subscription-plans [get] +func (h *Handler) ListSubscriptionPlans(c *fiber.Ctx) error { + activeOnly := c.Query("active_only", "true") == "true" + + plans, err := h.subscriptionsSvc.ListPlans(c.Context(), activeOnly) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to list subscription plans", + Error: err.Error(), + }) + } + + result := make([]planRes, len(plans)) + for i, p := range plans { + result[i] = *planToRes(&p) + } + + return c.JSON(domain.Response{ + Message: "Subscription plans retrieved successfully", + Data: result, + }) +} + +// GetSubscriptionPlan godoc +// @Summary Get a subscription plan +// @Description Returns a single subscription plan by ID +// @Tags subscriptions +// @Produce json +// @Param id path int true "Plan ID" +// @Success 200 {object} domain.Response +// @Failure 404 {object} domain.ErrorResponse +// @Router /api/v1/subscription-plans/{id} [get] +func (h *Handler) GetSubscriptionPlan(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid plan ID", + }) + } + + plan, err := h.subscriptionsSvc.GetPlanByID(c.Context(), id) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Subscription plan not found", + }) + } + + return c.JSON(domain.Response{ + Message: "Subscription plan retrieved successfully", + Data: planToRes(plan), + }) +} + +// UpdateSubscriptionPlan godoc +// @Summary Update a subscription plan +// @Description Updates a subscription plan (admin only) +// @Tags subscriptions +// @Accept json +// @Produce json +// @Param id path int true "Plan ID" +// @Param body body updatePlanReq true "Update plan payload" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/subscription-plans/{id} [put] +func (h *Handler) UpdateSubscriptionPlan(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid plan ID", + }) + } + + var req updatePlanReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + + err = h.subscriptionsSvc.UpdatePlan(c.Context(), id, domain.UpdateSubscriptionPlanInput{ + Name: req.Name, + Description: req.Description, + DurationValue: req.DurationValue, + DurationUnit: req.DurationUnit, + Price: req.Price, + Currency: req.Currency, + IsActive: req.IsActive, + }) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to update subscription plan", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Subscription plan updated successfully", + }) +} + +// DeleteSubscriptionPlan godoc +// @Summary Delete a subscription plan +// @Description Deletes a subscription plan (admin only) +// @Tags subscriptions +// @Produce json +// @Param id path int true "Plan ID" +// @Success 200 {object} domain.Response +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/subscription-plans/{id} [delete] +func (h *Handler) DeleteSubscriptionPlan(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid plan ID", + }) + } + + err = h.subscriptionsSvc.DeletePlan(c.Context(), id) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to delete subscription plan", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Subscription plan deleted successfully", + }) +} + +// ===================== +// User Subscription Handlers +// ===================== + +// Subscribe godoc +// @Summary Subscribe to a plan (Admin only - bypasses payment) +// @Description Creates a new subscription for the authenticated user. For regular users, use /payments/subscribe instead. +// @Tags subscriptions +// @Accept json +// @Produce json +// @Param body body subscribeReq true "Subscribe payload" +// @Success 201 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/subscriptions [post] +// @deprecated Use POST /api/v1/payments/subscribe for user subscriptions with payment +func (h *Handler) Subscribe(c *fiber.Ctx) error { + userID, ok := c.Locals("user_id").(int64) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ + Message: "Unauthorized", + }) + } + + // Check role - only admins can create subscriptions without payment + role, ok := c.Locals("role").(domain.Role) + if !ok || (role != domain.RoleAdmin && role != domain.RoleSuperAdmin) { + return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ + Message: "Use /api/v1/payments/subscribe to subscribe with payment", + Error: "Direct subscription creation requires admin privileges", + }) + } + + var req subscribeReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + + sub, err := h.subscriptionsSvc.Subscribe(c.Context(), userID, req.PlanID, req.PaymentReference, req.PaymentMethod) + if err != nil { + status := fiber.StatusInternalServerError + if err.Error() == "user already has an active subscription" { + status = fiber.StatusConflict + } + return c.Status(status).JSON(domain.ErrorResponse{ + Message: "Failed to create subscription", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusCreated).JSON(domain.Response{ + Message: "Subscription created successfully", + Data: subscriptionToRes(sub), + }) +} + +// SubscribeWithPayment godoc +// @Summary Subscribe to a plan with payment +// @Description Initiates payment for a subscription plan. Returns payment URL for checkout. +// @Tags subscriptions +// @Accept json +// @Produce json +// @Param body body subscribeWithPaymentReq true "Subscribe with payment payload" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 409 {object} domain.ErrorResponse "User already has active subscription" +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/subscriptions/checkout [post] +func (h *Handler) SubscribeWithPayment(c *fiber.Ctx) error { + userID, ok := c.Locals("user_id").(int64) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ + Message: "Unauthorized", + }) + } + + var req subscribeWithPaymentReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + + // Use ArifPay service to initiate payment + result, err := h.arifpaySvc.InitiateSubscriptionPayment(c.Context(), userID, domain.InitiateSubscriptionPaymentRequest{ + PlanID: req.PlanID, + Phone: req.Phone, + Email: req.Email, + }) + if err != nil { + status := fiber.StatusInternalServerError + if err.Error() == "user already has an active subscription" { + status = fiber.StatusConflict + } else if err.Error() == "subscription plan is not active" { + status = fiber.StatusBadRequest + } + return c.Status(status).JSON(domain.ErrorResponse{ + Message: "Failed to initiate subscription payment", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Payment initiated. Complete payment to activate subscription.", + Data: result, + }) +} + +// GetMySubscription godoc +// @Summary Get current subscription +// @Description Returns the authenticated user's active subscription +// @Tags subscriptions +// @Produce json +// @Success 200 {object} domain.Response +// @Failure 404 {object} domain.ErrorResponse +// @Router /api/v1/subscriptions/me [get] +func (h *Handler) GetMySubscription(c *fiber.Ctx) error { + userID, ok := c.Locals("user_id").(int64) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ + Message: "Unauthorized", + }) + } + + sub, err := h.subscriptionsSvc.GetActiveSubscription(c.Context(), userID) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "No active subscription found", + }) + } + + return c.JSON(domain.Response{ + Message: "Subscription retrieved successfully", + Data: subscriptionToRes(sub), + }) +} + +// GetMySubscriptionHistory godoc +// @Summary Get subscription history +// @Description Returns the authenticated user's subscription history +// @Tags subscriptions +// @Produce json +// @Param limit query int false "Limit" default(20) +// @Param offset query int false "Offset" default(0) +// @Success 200 {object} domain.Response +// @Router /api/v1/subscriptions/history [get] +func (h *Handler) GetMySubscriptionHistory(c *fiber.Ctx) error { + userID, ok := c.Locals("user_id").(int64) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ + Message: "Unauthorized", + }) + } + + limit, _ := strconv.Atoi(c.Query("limit", "20")) + offset, _ := strconv.Atoi(c.Query("offset", "0")) + + subs, err := h.subscriptionsSvc.GetSubscriptionHistory(c.Context(), userID, int32(limit), int32(offset)) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to get subscription history", + Error: err.Error(), + }) + } + + result := make([]subscriptionRes, len(subs)) + for i, s := range subs { + result[i] = *subscriptionToRes(&s) + } + + return c.JSON(domain.Response{ + Message: "Subscription history retrieved successfully", + Data: result, + }) +} + +// CheckSubscriptionStatus godoc +// @Summary Check subscription status +// @Description Returns whether the authenticated user has an active subscription +// @Tags subscriptions +// @Produce json +// @Success 200 {object} domain.Response +// @Router /api/v1/subscriptions/status [get] +func (h *Handler) CheckSubscriptionStatus(c *fiber.Ctx) error { + userID, ok := c.Locals("user_id").(int64) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ + Message: "Unauthorized", + }) + } + + hasActive, err := h.subscriptionsSvc.HasActiveSubscription(c.Context(), userID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to check subscription status", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Subscription status retrieved", + Data: fiber.Map{ + "has_active_subscription": hasActive, + }, + }) +} + +// CancelSubscription godoc +// @Summary Cancel subscription +// @Description Cancels the user's subscription +// @Tags subscriptions +// @Produce json +// @Param id path int true "Subscription ID" +// @Success 200 {object} domain.Response +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/subscriptions/{id}/cancel [post] +func (h *Handler) CancelSubscription(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid subscription ID", + }) + } + + err = h.subscriptionsSvc.CancelSubscription(c.Context(), id) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to cancel subscription", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Subscription cancelled successfully", + }) +} + +// SetAutoRenew godoc +// @Summary Set auto-renew +// @Description Enables or disables auto-renewal for a subscription +// @Tags subscriptions +// @Accept json +// @Produce json +// @Param id path int true "Subscription ID" +// @Param body body autoRenewReq true "Auto-renew payload" +// @Success 200 {object} domain.Response +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/subscriptions/{id}/auto-renew [put] +func (h *Handler) SetAutoRenew(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid subscription ID", + }) + } + + var req autoRenewReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + + err = h.subscriptionsSvc.SetAutoRenew(c.Context(), id, req.AutoRenew) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to update auto-renew setting", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Auto-renew setting updated successfully", + }) +} + +// Helper functions + +func planToRes(p *domain.SubscriptionPlan) *planRes { + return &planRes{ + ID: p.ID, + Name: p.Name, + Description: p.Description, + DurationValue: p.DurationValue, + DurationUnit: p.DurationUnit, + Price: p.Price, + Currency: p.Currency, + IsActive: p.IsActive, + CreatedAt: p.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + } +} + +func subscriptionToRes(s *domain.UserSubscription) *subscriptionRes { + return &subscriptionRes{ + ID: s.ID, + UserID: s.UserID, + PlanID: s.PlanID, + PlanName: s.PlanName, + StartsAt: s.StartsAt.Format("2006-01-02T15:04:05Z07:00"), + ExpiresAt: s.ExpiresAt.Format("2006-01-02T15:04:05Z07:00"), + Status: s.Status, + PaymentReference: s.PaymentReference, + PaymentMethod: s.PaymentMethod, + AutoRenew: s.AutoRenew, + DurationValue: s.DurationValue, + DurationUnit: s.DurationUnit, + Price: s.Price, + Currency: s.Currency, + CreatedAt: s.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + } +} diff --git a/internal/web_server/handlers/team_handler.go b/internal/web_server/handlers/team_handler.go new file mode 100644 index 0000000..dbaccbd --- /dev/null +++ b/internal/web_server/handlers/team_handler.go @@ -0,0 +1,814 @@ +package handlers + +import ( + "Yimaru-Backend/internal/domain" + jwtutil "Yimaru-Backend/internal/web_server/jwt" + "errors" + "fmt" + "strconv" + "time" + + "github.com/gofiber/fiber/v2" + "go.uber.org/zap" +) + +// teamMemberLoginRes represents the response body for team member login +type teamMemberLoginRes struct { + AccessToken string `json:"access_token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."` + RefreshToken string `json:"refresh_token" example:""` + MemberID int64 `json:"member_id" example:"1"` + TeamRole string `json:"team_role" example:"admin"` +} + +// changePasswordReq represents the request body for changing password +type changePasswordReq struct { + CurrentPassword string `json:"current_password" validate:"required" example:"oldpassword123"` + NewPassword string `json:"new_password" validate:"required,min=8" example:"newpassword123"` +} + +func mapTeamRoleToRole(teamRole domain.TeamRole) domain.Role { + switch teamRole { + case domain.TeamRoleSuperAdmin: + return domain.RoleSuperAdmin + case domain.TeamRoleAdmin: + return domain.RoleAdmin + case domain.TeamRoleInstructor: + return domain.RoleInstructor + case domain.TeamRoleSupportAgent: + return domain.RoleSupport + default: + return domain.RoleAdmin + } +} + +// TeamMemberLogin godoc +// @Summary Login team member +// @Description Authenticate a team member (internal staff) with email and password +// @Tags team +// @Accept json +// @Produce json +// @Param body body domain.TeamMemberLoginReq true "Team member login credentials" +// @Success 200 {object} domain.Response{data=teamMemberLoginRes} +// @Failure 400 {object} domain.ErrorResponse +// @Failure 401 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/team/login [post] +func (h *Handler) TeamMemberLogin(c *fiber.Ctx) error { + var req domain.TeamMemberLoginReq + if err := c.BodyParser(&req); err != nil { + h.mongoLoggerSvc.Info("Failed to parse TeamMemberLogin request", + zap.Int("status_code", fiber.StatusBadRequest), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Failed to login", + Error: "Invalid request body: " + err.Error(), + }) + } + + if valErrs, ok := h.validator.Validate(c, req); !ok { + var errMsg string + for field, msg := range valErrs { + errMsg += fmt.Sprintf("%s: %s; ", field, msg) + } + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Failed to login", + Error: errMsg, + }) + } + + loginRes, err := h.teamSvc.Login(c.Context(), req) + if err != nil { + switch { + case errors.Is(err, domain.ErrTeamMemberNotFound): + h.mongoLoggerSvc.Info("Team member login failed: member not found", + zap.Int("status_code", fiber.StatusUnauthorized), + zap.String("email", req.Email), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ + Message: "Failed to login", + Error: "Invalid credentials", + }) + case errors.Is(err, domain.ErrInvalidTeamMemberStatus): + h.mongoLoggerSvc.Info("Team member login failed: account suspended or inactive", + zap.Int("status_code", fiber.StatusUnauthorized), + zap.String("email", req.Email), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ + Message: "Failed to login", + Error: "Account is not active", + }) + default: + h.mongoLoggerSvc.Error("Team member login failed", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.String("email", req.Email), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to login", + Error: err.Error(), + }) + } + } + + role := mapTeamRoleToRole(loginRes.TeamRole) + accessToken, err := jwtutil.CreateJwt( + loginRes.ID, + role, + h.jwtConfig.JwtAccessKey, + h.jwtConfig.JwtAccessExpiry, + ) + if err != nil { + h.mongoLoggerSvc.Error("Failed to create access token for team member", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Int64("member_id", loginRes.ID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to login", + Error: "Failed to generate access token", + }) + } + + h.mongoLoggerSvc.Info("Team member login successful", + zap.Int("status_code", fiber.StatusOK), + zap.Int64("member_id", loginRes.ID), + zap.String("team_role", string(loginRes.TeamRole)), + zap.Time("timestamp", time.Now()), + ) + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Login successful", + Data: teamMemberLoginRes{ + AccessToken: accessToken, + RefreshToken: "", + MemberID: loginRes.ID, + TeamRole: string(loginRes.TeamRole), + }, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// CreateTeamMember godoc +// @Summary Create a new team member +// @Description Create a new internal team member (admin only) +// @Tags team +// @Accept json +// @Produce json +// @Security Bearer +// @Param body body domain.CreateTeamMemberReq true "Team member creation payload" +// @Success 201 {object} domain.Response{data=domain.TeamMemberResponse} +// @Failure 400 {object} domain.ErrorResponse +// @Failure 401 {object} domain.ErrorResponse +// @Failure 403 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/team/members [post] +func (h *Handler) CreateTeamMember(c *fiber.Ctx) error { + var req domain.CreateTeamMemberReq + if err := c.BodyParser(&req); err != nil { + h.mongoLoggerSvc.Info("Failed to parse CreateTeamMember request", + zap.Int("status_code", fiber.StatusBadRequest), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Failed to create team member", + Error: "Invalid request body: " + err.Error(), + }) + } + + if valErrs, ok := h.validator.Validate(c, req); !ok { + var errMsg string + for field, msg := range valErrs { + errMsg += fmt.Sprintf("%s: %s; ", field, msg) + } + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Failed to create team member", + Error: errMsg, + }) + } + + creatorID, ok := c.Locals("user_id").(int64) + if !ok { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Failed to create team member", + Error: "User ID not found in request context", + }) + } + + member, err := h.teamSvc.CreateTeamMember(c.Context(), req, &creatorID) + if err != nil { + switch { + case errors.Is(err, domain.ErrTeamMemberEmailExists): + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Failed to create team member", + Error: "Email already exists", + }) + case errors.Is(err, domain.ErrInvalidTeamRole): + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Failed to create team member", + Error: "Invalid team role", + }) + default: + h.mongoLoggerSvc.Error("Failed to create team member", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to create team member", + Error: err.Error(), + }) + } + } + + h.mongoLoggerSvc.Info("Team member created successfully", + zap.Int("status_code", fiber.StatusCreated), + zap.Int64("member_id", member.ID), + zap.Int64("created_by", creatorID), + zap.Time("timestamp", time.Now()), + ) + + return c.Status(fiber.StatusCreated).JSON(domain.Response{ + Message: "Team member created successfully", + Data: toTeamMemberResponse(&member), + Success: true, + StatusCode: fiber.StatusCreated, + }) +} + +// GetTeamMember godoc +// @Summary Get team member by ID +// @Description Retrieve a team member's details by their ID +// @Tags team +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "Team Member ID" +// @Success 200 {object} domain.Response{data=domain.TeamMemberResponse} +// @Failure 400 {object} domain.ErrorResponse +// @Failure 401 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/team/members/{id} [get] +func (h *Handler) GetTeamMember(c *fiber.Ctx) error { + memberIDStr := c.Params("id") + memberID, err := strconv.ParseInt(memberIDStr, 10, 64) + if err != nil || memberID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid member ID", + Error: "Member ID must be a valid positive integer", + }) + } + + member, err := h.teamSvc.GetTeamMemberByID(c.Context(), memberID) + if err != nil { + if errors.Is(err, domain.ErrTeamMemberNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Team member not found", + Error: err.Error(), + }) + } + h.mongoLoggerSvc.Error("Failed to get team member", + zap.Int64("member_id", memberID), + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to get team member", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Team member retrieved successfully", + Data: toTeamMemberResponse(&member), + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// GetAllTeamMembers godoc +// @Summary List all team members +// @Description Get a paginated list of team members with optional filtering +// @Tags team +// @Accept json +// @Produce json +// @Security Bearer +// @Param team_role query string false "Filter by team role (super_admin, admin, content_manager, support_agent, instructor, finance, hr, analyst)" +// @Param department query string false "Filter by department" +// @Param status query string false "Filter by status (active, inactive, suspended, terminated)" +// @Param search query string false "Search by name, email, or phone number" +// @Param page query int false "Page number (default: 1)" +// @Param page_size query int false "Items per page (default: 10, max: 100)" +// @Success 200 {object} domain.Response{data=[]domain.TeamMemberResponse,metadata=domain.Pagination} +// @Failure 401 {object} domain.ErrorResponse +// @Failure 403 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/team/members [get] +func (h *Handler) GetAllTeamMembers(c *fiber.Ctx) error { + filter := domain.TeamMemberFilter{} + + if teamRole := c.Query("team_role"); teamRole != "" { + filter.TeamRole = &teamRole + } + if department := c.Query("department"); department != "" { + filter.Department = &department + } + if status := c.Query("status"); status != "" { + filter.Status = &status + } + filter.Search = c.Query("search") + + page, _ := strconv.ParseInt(c.Query("page", "1"), 10, 64) + if page < 1 { + page = 1 + } + filter.Page = page + + pageSize, _ := strconv.ParseInt(c.Query("page_size", "10"), 10, 64) + if pageSize < 1 || pageSize > 100 { + pageSize = 10 + } + filter.PageSize = pageSize + + var members []domain.TeamMember + var total int64 + var err error + + if filter.Search != "" { + members, err = h.teamSvc.SearchTeamMembers(c.Context(), filter.Search, filter.TeamRole, filter.Status) + if err == nil { + total = int64(len(members)) + } + } else { + members, total, err = h.teamSvc.GetAllTeamMembers(c.Context(), filter) + } + + if err != nil { + h.mongoLoggerSvc.Error("Failed to get team members", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to get team members", + Error: err.Error(), + }) + } + + memberResponses := make([]domain.TeamMemberResponse, 0, len(members)) + for i := range members { + memberResponses = append(memberResponses, toTeamMemberResponse(&members[i])) + } + + totalPages := int((total + filter.PageSize - 1) / filter.PageSize) + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Team members retrieved successfully", + Data: memberResponses, + Success: true, + MetaData: domain.Pagination{ + Total: int(total), + TotalPages: totalPages, + CurrentPage: int(filter.Page), + Limit: int(filter.PageSize), + }, + StatusCode: fiber.StatusOK, + }) +} + +// UpdateTeamMember godoc +// @Summary Update team member +// @Description Update an existing team member's details (admin only) +// @Tags team +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "Team Member ID" +// @Param body body domain.UpdateTeamMemberReq true "Team member update payload" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 401 {object} domain.ErrorResponse +// @Failure 403 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/team/members/{id} [put] +func (h *Handler) UpdateTeamMember(c *fiber.Ctx) error { + memberIDStr := c.Params("id") + memberID, err := strconv.ParseInt(memberIDStr, 10, 64) + if err != nil || memberID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid member ID", + Error: "Member ID must be a valid positive integer", + }) + } + + var req domain.UpdateTeamMemberReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Failed to update team member", + Error: "Invalid request body: " + err.Error(), + }) + } + + updaterID, ok := c.Locals("user_id").(int64) + if !ok { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Failed to update team member", + Error: "User ID not found in request context", + }) + } + + req.TeamMemberID = memberID + req.UpdatedBy = updaterID + + if err := h.teamSvc.UpdateTeamMember(c.Context(), req); err != nil { + switch { + case errors.Is(err, domain.ErrTeamMemberNotFound): + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Team member not found", + Error: err.Error(), + }) + case errors.Is(err, domain.ErrInvalidTeamRole): + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Failed to update team member", + Error: "Invalid team role", + }) + default: + h.mongoLoggerSvc.Error("Failed to update team member", + zap.Int64("member_id", memberID), + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to update team member", + Error: err.Error(), + }) + } + } + + h.mongoLoggerSvc.Info("Team member updated successfully", + zap.Int64("member_id", memberID), + zap.Int64("updated_by", updaterID), + zap.Time("timestamp", time.Now()), + ) + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Team member updated successfully", + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// UpdateTeamMemberStatus godoc +// @Summary Update team member status +// @Description Update a team member's status (active, inactive, suspended, terminated) +// @Tags team +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "Team Member ID" +// @Param body body domain.UpdateTeamMemberStatusReq true "Status update payload" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 401 {object} domain.ErrorResponse +// @Failure 403 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/team/members/{id}/status [patch] +func (h *Handler) UpdateTeamMemberStatus(c *fiber.Ctx) error { + memberIDStr := c.Params("id") + memberID, err := strconv.ParseInt(memberIDStr, 10, 64) + if err != nil || memberID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid member ID", + Error: "Member ID must be a valid positive integer", + }) + } + + var req domain.UpdateTeamMemberStatusReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Failed to update team member status", + Error: "Invalid request body: " + err.Error(), + }) + } + + if valErrs, ok := h.validator.Validate(c, req); !ok { + var errMsg string + for field, msg := range valErrs { + errMsg += fmt.Sprintf("%s: %s; ", field, msg) + } + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Failed to update team member status", + Error: errMsg, + }) + } + + updaterID, ok := c.Locals("user_id").(int64) + if !ok { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Failed to update team member status", + Error: "User ID not found in request context", + }) + } + + req.TeamMemberID = memberID + req.UpdatedBy = updaterID + + if err := h.teamSvc.UpdateTeamMemberStatus(c.Context(), req); err != nil { + switch { + case errors.Is(err, domain.ErrTeamMemberNotFound): + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Team member not found", + Error: err.Error(), + }) + case errors.Is(err, domain.ErrInvalidTeamMemberStatus): + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Failed to update team member status", + Error: "Invalid status value", + }) + default: + h.mongoLoggerSvc.Error("Failed to update team member status", + zap.Int64("member_id", memberID), + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to update team member status", + Error: err.Error(), + }) + } + } + + h.mongoLoggerSvc.Info("Team member status updated successfully", + zap.Int64("member_id", memberID), + zap.String("new_status", req.Status), + zap.Int64("updated_by", updaterID), + zap.Time("timestamp", time.Now()), + ) + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Team member status updated successfully", + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// DeleteTeamMember godoc +// @Summary Delete team member +// @Description Delete a team member (super admin only) +// @Tags team +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "Team Member ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 401 {object} domain.ErrorResponse +// @Failure 403 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/team/members/{id} [delete] +func (h *Handler) DeleteTeamMember(c *fiber.Ctx) error { + memberIDStr := c.Params("id") + memberID, err := strconv.ParseInt(memberIDStr, 10, 64) + if err != nil || memberID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid member ID", + Error: "Member ID must be a valid positive integer", + }) + } + + if err := h.teamSvc.DeleteTeamMember(c.Context(), memberID); err != nil { + if errors.Is(err, domain.ErrTeamMemberNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Team member not found", + Error: err.Error(), + }) + } + h.mongoLoggerSvc.Error("Failed to delete team member", + zap.Int64("member_id", memberID), + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to delete team member", + Error: err.Error(), + }) + } + + h.mongoLoggerSvc.Info("Team member deleted successfully", + zap.Int64("member_id", memberID), + zap.Time("timestamp", time.Now()), + ) + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Team member deleted successfully", + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// GetTeamMemberStats godoc +// @Summary Get team member statistics +// @Description Get statistics about team members by status +// @Tags team +// @Accept json +// @Produce json +// @Security Bearer +// @Success 200 {object} domain.Response{data=domain.TeamMemberStats} +// @Failure 401 {object} domain.ErrorResponse +// @Failure 403 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/team/stats [get] +func (h *Handler) GetTeamMemberStats(c *fiber.Ctx) error { + stats, err := h.teamSvc.GetTeamMemberStats(c.Context()) + if err != nil { + h.mongoLoggerSvc.Error("Failed to get team member stats", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to get team member stats", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Team member stats retrieved successfully", + Data: stats, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// ChangeTeamMemberPassword godoc +// @Summary Change team member password +// @Description Change a team member's password (requires current password) +// @Tags team +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "Team Member ID" +// @Param body body changePasswordReq true "Password change payload" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 401 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/team/members/{id}/change-password [post] +func (h *Handler) ChangeTeamMemberPassword(c *fiber.Ctx) error { + memberIDStr := c.Params("id") + memberID, err := strconv.ParseInt(memberIDStr, 10, 64) + if err != nil || memberID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid member ID", + Error: "Member ID must be a valid positive integer", + }) + } + + var req changePasswordReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Failed to change password", + Error: "Invalid request body: " + err.Error(), + }) + } + + if valErrs, ok := h.validator.Validate(c, req); !ok { + var errMsg string + for field, msg := range valErrs { + errMsg += fmt.Sprintf("%s: %s; ", field, msg) + } + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Failed to change password", + Error: errMsg, + }) + } + + if err := h.teamSvc.ChangePassword(c.Context(), memberID, req.CurrentPassword, req.NewPassword); err != nil { + switch { + case errors.Is(err, domain.ErrTeamMemberNotFound): + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Team member not found", + Error: err.Error(), + }) + default: + h.mongoLoggerSvc.Error("Failed to change team member password", + zap.Int64("member_id", memberID), + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Failed to change password", + Error: err.Error(), + }) + } + } + + h.mongoLoggerSvc.Info("Team member password changed successfully", + zap.Int64("member_id", memberID), + zap.Time("timestamp", time.Now()), + ) + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Password changed successfully", + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// GetMyTeamProfile godoc +// @Summary Get my team profile +// @Description Get the authenticated team member's own profile +// @Tags team +// @Accept json +// @Produce json +// @Security Bearer +// @Success 200 {object} domain.Response{data=domain.TeamMemberResponse} +// @Failure 400 {object} domain.ErrorResponse +// @Failure 401 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/team/me [get] +func (h *Handler) GetMyTeamProfile(c *fiber.Ctx) error { + memberID, ok := c.Locals("user_id").(int64) + if !ok { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid user context", + Error: "User ID not found in request context", + }) + } + + member, err := h.teamSvc.GetTeamMemberByID(c.Context(), memberID) + if err != nil { + if errors.Is(err, domain.ErrTeamMemberNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Team member not found", + Error: err.Error(), + }) + } + h.mongoLoggerSvc.Error("Failed to get team member profile", + zap.Int64("member_id", memberID), + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to get profile", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Profile retrieved successfully", + Data: toTeamMemberResponse(&member), + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +func toTeamMemberResponse(m *domain.TeamMember) domain.TeamMemberResponse { + resp := domain.TeamMemberResponse{ + ID: m.ID, + FirstName: m.FirstName, + LastName: m.LastName, + Email: m.Email, + PhoneNumber: m.PhoneNumber, + TeamRole: m.TeamRole, + Department: m.Department, + JobTitle: m.JobTitle, + EmploymentType: m.EmploymentType, + ProfilePictureURL: m.ProfilePictureURL, + Bio: m.Bio, + WorkPhone: m.WorkPhone, + Status: m.Status, + EmailVerified: m.EmailVerified, + Permissions: m.Permissions, + LastLogin: m.LastLogin, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + } + + if m.HireDate != nil { + resp.HireDate = m.HireDate.Format("2006-01-02") + } + + return resp +} diff --git a/internal/web_server/handlers/user.go b/internal/web_server/handlers/user.go index 56fa31a..51c2be9 100644 --- a/internal/web_server/handlers/user.go +++ b/internal/web_server/handlers/user.go @@ -21,7 +21,7 @@ import ( // CheckProfileCompleted godoc // @Summary Check if user profile is completed -// @Description Returns whether the specified user's profile is completed +// @Description Returns the profile completion status and percentage for the specified user // @Tags user // @Accept json // @Produce json @@ -48,7 +48,7 @@ func (h *Handler) CheckProfileCompleted(c *fiber.Ctx) error { }) } - isCompleted, err := h.userSvc.IsProfileCompleted(c.Context(), userID) + status, err := h.userSvc.GetProfileCompletionStatus(c.Context(), userID) if err != nil { if errors.Is(err, authentication.ErrUserNotFound) { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ @@ -65,8 +65,9 @@ func (h *Handler) CheckProfileCompleted(c *fiber.Ctx) error { return c.Status(fiber.StatusOK).JSON(domain.Response{ Message: "Profile completion status fetched successfully", - Data: map[string]bool{ - "is_profile_completed": isCompleted, + Data: map[string]interface{}{ + "is_profile_completed": status.IsCompleted, + "profile_completion_percentage": status.Percentage, }, }) } diff --git a/internal/web_server/handlers/vimeo.go b/internal/web_server/handlers/vimeo.go new file mode 100644 index 0000000..72b793b --- /dev/null +++ b/internal/web_server/handlers/vimeo.go @@ -0,0 +1,427 @@ +package handlers + +import ( + "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/pkgs/vimeo" + "fmt" + "strconv" + + "github.com/gofiber/fiber/v2" +) + +type CreatePullUploadRequest struct { + Name string `json:"name" validate:"required"` + Description string `json:"description"` + SourceURL string `json:"source_url" validate:"required,url"` + FileSize int64 `json:"file_size" validate:"required,gt=0"` +} + +type CreateTusUploadRequest struct { + Name string `json:"name" validate:"required"` + Description string `json:"description"` + FileSize int64 `json:"file_size" validate:"required,gt=0"` +} + +type GetEmbedCodeRequest struct { + VideoID string `json:"video_id" validate:"required"` + Width int `json:"width"` + Height int `json:"height"` + Autoplay bool `json:"autoplay"` + Loop bool `json:"loop"` + Muted bool `json:"muted"` + Background bool `json:"background"` +} + +type VimeoVideoResponse struct { + VimeoID string `json:"vimeo_id"` + URI string `json:"uri"` + Name string `json:"name"` + Description string `json:"description"` + Duration int `json:"duration"` + Width int `json:"width"` + Height int `json:"height"` + Link string `json:"link"` + EmbedURL string `json:"embed_url"` + EmbedHTML string `json:"embed_html"` + ThumbnailURL string `json:"thumbnail_url"` + Status string `json:"status"` + TranscodeStatus string `json:"transcode_status"` +} + +type VimeoUploadResponse struct { + VimeoID string `json:"vimeo_id"` + URI string `json:"uri"` + Link string `json:"link"` + UploadLink string `json:"upload_link,omitempty"` + Status string `json:"status"` +} + +type VimeoEmbedResponse struct { + VideoID string `json:"video_id"` + EmbedURL string `json:"embed_url"` + EmbedHTML string `json:"embed_html"` +} + +// GetVimeoVideo godoc +// @Summary Get video information from Vimeo +// @Description Retrieves video details from Vimeo by video ID +// @Tags Vimeo +// @Accept json +// @Produce json +// @Param video_id path string true "Vimeo Video ID" +// @Success 200 {object} VimeoVideoResponse +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/vimeo/videos/{video_id} [get] +func (h *Handler) GetVimeoVideo(c *fiber.Ctx) error { + if h.vimeoSvc == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{ + Message: "Vimeo service is not configured", + Error: "Vimeo service is not enabled or missing access token", + }) + } + + videoID := c.Params("video_id") + if videoID == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "video_id is required", + Error: "video_id path parameter is empty", + }) + } + + info, err := h.vimeoSvc.GetVideoInfo(c.Context(), videoID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to get video info", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Video retrieved successfully", + Data: VimeoVideoResponse{ + VimeoID: info.VimeoID, + URI: info.URI, + Name: info.Name, + Description: info.Description, + Duration: info.Duration, + Width: info.Width, + Height: info.Height, + Link: info.Link, + EmbedURL: info.EmbedURL, + EmbedHTML: info.EmbedHTML, + ThumbnailURL: info.ThumbnailURL, + Status: info.Status, + TranscodeStatus: info.TranscodeStatus, + }, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// CreatePullUpload godoc +// @Summary Create a pull upload to Vimeo +// @Description Initiates a pull upload where Vimeo fetches the video from a URL +// @Tags Vimeo +// @Accept json +// @Produce json +// @Param request body CreatePullUploadRequest true "Pull Upload Request" +// @Success 201 {object} VimeoUploadResponse +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/vimeo/uploads/pull [post] +func (h *Handler) CreatePullUpload(c *fiber.Ctx) error { + if h.vimeoSvc == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{ + Message: "Vimeo service is not configured", + Error: "Vimeo service is not configured", + }) + } + + var req CreatePullUploadRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + + if valErrs, ok := h.validator.Validate(c, req); !ok { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Failed to validate request body", + Error: fmt.Sprintf("%v", valErrs), + }) + } + + result, err := h.vimeoSvc.CreatePullUpload(c.Context(), req.Name, req.Description, req.SourceURL, req.FileSize) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to create Vimeo upload", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusCreated).JSON(domain.Response{ + Message: "Vimeo upload created successfully", + Data: result, + Success: true, + StatusCode: fiber.StatusCreated, + }) +} + +// CreateTusUpload godoc +// @Summary Create a TUS resumable upload to Vimeo +// @Description Initiates a TUS resumable upload and returns the upload link +// @Tags Vimeo +// @Accept json +// @Produce json +// @Param request body CreateTusUploadRequest true "TUS Upload Request" +// @Success 201 {object} VimeoUploadResponse +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/vimeo/uploads/tus [post] +func (h *Handler) CreateTusUpload(c *fiber.Ctx) error { + if h.vimeoSvc == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{ + Message: "Vimeo service is not configured", + Error: "Vimeo service is not enabled or missing access token", + }) + } + + var req CreateTusUploadRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + + if valErrs, ok := h.validator.Validate(c, req); !ok { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Failed to validate request body", + Error: fmt.Sprintf("%v", valErrs), + }) + } + + result, err := h.vimeoSvc.CreateTusUpload(c.Context(), req.Name, req.Description, req.FileSize) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to create Vimeo TUS upload", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusCreated).JSON(domain.Response{ + Message: "Vimeo TUS upload created successfully", + Data: result, + Success: true, + StatusCode: fiber.StatusCreated, + }) +} + +// GetEmbedCode godoc +// @Summary Get embed code for a Vimeo video +// @Description Generates an embeddable player iframe for the video +// @Tags Vimeo +// @Accept json +// @Produce json +// @Param video_id path string true "Vimeo Video ID" +// @Param width query int false "Player width" default(640) +// @Param height query int false "Player height" default(360) +// @Param autoplay query bool false "Autoplay video" +// @Param loop query bool false "Loop video" +// @Param muted query bool false "Mute video" +// @Param background query bool false "Background mode (no controls)" +// @Success 200 {object} VimeoEmbedResponse +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/vimeo/videos/{video_id}/embed [get] +func (h *Handler) GetEmbedCode(c *fiber.Ctx) error { + if h.vimeoSvc == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{ + Message: "Vimeo service is not configured", + Error: "Vimeo service is not configured", + }) + } + + videoID := c.Params("video_id") + if videoID == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "video_id is required", + Error: "video_id is empty", + }) + } + + width, _ := strconv.Atoi(c.Query("width", "640")) + height, _ := strconv.Atoi(c.Query("height", "360")) + autoplay := c.Query("autoplay") == "true" + loop := c.Query("loop") == "true" + muted := c.Query("muted") == "true" + background := c.Query("background") == "true" + + opts := &vimeo.EmbedOptions{ + Autoplay: autoplay, + Loop: loop, + Muted: muted, + Background: background, + Title: true, + Byline: true, + Portrait: true, + } + + embedHTML, err := h.vimeoSvc.GetEmbedCode(c.Context(), videoID, width, height, opts) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to get embed code", + Error: err.Error(), + }) + } + + embedURL := h.vimeoSvc.GeneratePlayerURL(videoID, opts) + + return c.JSON(domain.Response{ + Message: "Embed code retrieved successfully", + Data: VimeoEmbedResponse{ + VideoID: videoID, + EmbedURL: embedURL, + EmbedHTML: embedHTML, + }, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// DeleteVimeoVideo godoc +// @Summary Delete a video from Vimeo +// @Description Deletes a video from the Vimeo account +// @Tags Vimeo +// @Accept json +// @Produce json +// @Param video_id path string true "Vimeo Video ID" +// @Success 204 +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/vimeo/videos/{video_id} [delete] +func (h *Handler) DeleteVimeoVideo(c *fiber.Ctx) error { + if h.vimeoSvc == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{ + Message: "Vimeo service is not configured", + Error: "Vimeo service is not enabled or missing access token", + }) + } + + videoID := c.Params("video_id") + if videoID == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "video_id is required", + Error: "video_id path parameter is empty", + }) + } + + if err := h.vimeoSvc.DeleteVideo(c.Context(), videoID); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to delete video", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Video deleted successfully", + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// GetTranscodeStatus godoc +// @Summary Get transcode status of a Vimeo video +// @Description Returns the current transcoding status of a video +// @Tags Vimeo +// @Accept json +// @Produce json +// @Param video_id path string true "Vimeo Video ID" +// @Success 200 {object} map[string]string +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/vimeo/videos/{video_id}/status [get] +func (h *Handler) GetTranscodeStatus(c *fiber.Ctx) error { + if h.vimeoSvc == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{ + Message: "Vimeo service is not configured", + Error: "Vimeo service is not enabled or missing access token", + }) + } + + videoID := c.Params("video_id") + if videoID == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "video_id is required", + Error: "video_id path parameter is empty", + }) + } + + status, err := h.vimeoSvc.GetTranscodeStatus(c.Context(), videoID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to get transcode status", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Transcode status retrieved successfully", + Data: fiber.Map{ + "video_id": videoID, + "status": status, + }, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// GetOEmbed godoc +// @Summary Get oEmbed data for a Vimeo URL +// @Description Fetches oEmbed metadata for a Vimeo video URL +// @Tags Vimeo +// @Accept json +// @Produce json +// @Param url query string true "Vimeo video URL" +// @Param width query int false "Desired width" +// @Param height query int false "Desired height" +// @Success 200 {object} vimeo.OEmbedResponse +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/vimeo/oembed [get] +func (h *Handler) GetOEmbed(c *fiber.Ctx) error { + if h.vimeoSvc == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{ + Message: "Vimeo service is not configured", + Error: "Vimeo service is not enabled or missing access token", + }) + } + + vimeoURL := c.Query("url") + if vimeoURL == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "url query parameter is required", + Error: "url query parameter is empty", + }) + } + + width, _ := strconv.Atoi(c.Query("width", "0")) + height, _ := strconv.Atoi(c.Query("height", "0")) + + oembed, err := h.vimeoSvc.GetOEmbed(c.Context(), vimeoURL, width, height) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to get oEmbed data", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "oEmbed data retrieved successfully", + Data: oembed, + Success: true, + StatusCode: fiber.StatusOK, + }) +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index f9c075d..e751064 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -15,6 +15,8 @@ func (a *App) initAppRoutes() { h := handlers.New( a.assessmentSvc, a.courseSvc, + a.questionsSvc, + a.subscriptionsSvc, a.arifpaySvc, a.logger, a.settingSvc, @@ -24,6 +26,8 @@ func (a *App) initAppRoutes() { a.userSvc, a.transactionSvc, a.authSvc, + a.vimeoSvc, + a.teamSvc, a.JwtConfig, a.cfg, a.mongoLoggerSvc, @@ -143,54 +147,89 @@ func (a *App) initAppRoutes() { groupV1.Put("/course-management/courses/:id", a.authMiddleware, h.UpdateCourse) groupV1.Delete("/course-management/courses/:id", a.authMiddleware, h.DeleteCourse) - // Programs - groupV1.Post("/course-management/programs", a.authMiddleware, h.CreateProgram) - groupV1.Get("/course-management/programs/:id", a.authMiddleware, h.GetProgramByID) - groupV1.Get("/course-management/courses/:courseId/programs", a.authMiddleware, h.GetProgramsByCourse) - groupV1.Get("/course-management/courses/:courseId/programs/list", a.authMiddleware, h.ListProgramsByCourse) - groupV1.Get("/course-management/programs/active", a.authMiddleware, h.ListActivePrograms) - groupV1.Patch("/course-management/programs/:id", a.authMiddleware, h.UpdateProgramPartial) - groupV1.Put("/course-management/programs/:id/full", a.authMiddleware, h.UpdateProgramFull) - groupV1.Put("/course-management/programs/:id/deactivate", a.authMiddleware, h.DeactivateProgram) - groupV1.Delete("/course-management/programs/:id", a.authMiddleware, h.DeleteProgram) + // Sub-courses + groupV1.Post("/course-management/sub-courses", a.authMiddleware, h.CreateSubCourse) + groupV1.Get("/course-management/sub-courses/:id", a.authMiddleware, h.GetSubCourseByID) + groupV1.Get("/course-management/courses/:courseId/sub-courses", a.authMiddleware, h.GetSubCoursesByCourse) + groupV1.Get("/course-management/courses/:courseId/sub-courses/list", a.authMiddleware, h.ListSubCoursesByCourse) + groupV1.Get("/course-management/sub-courses/active", a.authMiddleware, h.ListActiveSubCourses) + groupV1.Patch("/course-management/sub-courses/:id", a.authMiddleware, h.UpdateSubCourse) + groupV1.Put("/course-management/sub-courses/:id/deactivate", a.authMiddleware, h.DeactivateSubCourse) + groupV1.Delete("/course-management/sub-courses/:id", a.authMiddleware, h.DeleteSubCourse) - // Levels - groupV1.Post("/course-management/levels", a.authMiddleware, h.CreateLevel) - groupV1.Get("/course-management/programs/:programId/levels", a.authMiddleware, h.GetLevelsByProgram) - groupV1.Put("/course-management/levels/:id", a.authMiddleware, h.UpdateLevel) - groupV1.Put("/course-management/levels/:levelId/increment-module", a.authMiddleware, h.IncrementLevelModuleCount) - groupV1.Put("/course-management/levels/:levelId/increment-practice", a.authMiddleware, h.IncrementLevelPracticeCount) - groupV1.Put("/course-management/levels/:levelId/increment-video", a.authMiddleware, h.IncrementLevelVideoCount) - groupV1.Delete("/course-management/levels/:levelId", a.authMiddleware, h.DeleteLevel) - - // Modules - groupV1.Post("/course-management/modules", a.authMiddleware, h.CreateModule) - groupV1.Get("/course-management/levels/:levelId/modules", a.authMiddleware, h.GetModulesByLevel) - groupV1.Put("/course-management/modules/:id", a.authMiddleware, h.UpdateModule) - groupV1.Delete("/course-management/modules/:id", a.authMiddleware, h.DeleteModule) - - // Module Videos - groupV1.Post("/course-management/videos", a.authMiddleware, h.CreateModuleVideo) - groupV1.Put("/course-management/videos/:videoId/publish", a.authMiddleware, h.PublishModuleVideo) - groupV1.Get("/course-management/modules/:moduleId/videos/published", a.authMiddleware, h.GetPublishedVideosByModule) - groupV1.Put("/course-management/videos/:id", a.authMiddleware, h.UpdateModuleVideo) - groupV1.Delete("/course-management/videos/:id", a.authMiddleware, h.DeleteModuleVideo) - - // Practices - groupV1.Post("/course-management/practices", a.authMiddleware, h.CreatePractice) - groupV1.Get("/course-management/owners/:ownerType/:ownerId/practices", a.authMiddleware, h.GetPracticesByOwner) - groupV1.Put("/course-management/practices/:id", a.authMiddleware, h.UpdatePractice) - groupV1.Delete("/course-management/practices/:id", a.authMiddleware, h.DeletePractice) - - // Practice Questions - groupV1.Post("/course-management/questions", a.authMiddleware, h.CreatePracticeQuestion) - groupV1.Get("/course-management/practices/:practiceId/questions", a.authMiddleware, h.GetQuestionsByPractice) - groupV1.Put("/course-management/questions/:id", a.authMiddleware, h.UpdatePracticeQuestion) - groupV1.Delete("/course-management/questions/:id", a.authMiddleware, h.DeletePracticeQuestion) + // Sub-course Videos + groupV1.Post("/course-management/videos", a.authMiddleware, h.CreateSubCourseVideo) + groupV1.Post("/course-management/videos/vimeo", a.authMiddleware, h.CreateSubCourseVideoWithVimeo) + groupV1.Post("/course-management/videos/vimeo/import", a.authMiddleware, h.CreateSubCourseVideoFromVimeoID) + groupV1.Get("/course-management/videos/:id", a.authMiddleware, h.GetSubCourseVideoByID) + groupV1.Get("/course-management/sub-courses/:subCourseId/videos", a.authMiddleware, h.GetVideosBySubCourse) + groupV1.Get("/course-management/sub-courses/:subCourseId/videos/published", a.authMiddleware, h.GetPublishedVideosBySubCourse) + groupV1.Put("/course-management/videos/:id/publish", a.authMiddleware, h.PublishSubCourseVideo) + groupV1.Put("/course-management/videos/:id", a.authMiddleware, h.UpdateSubCourseVideo) + groupV1.Delete("/course-management/videos/:id", a.authMiddleware, h.DeleteSubCourseVideo) // Learning Tree groupV1.Get("/course-management/learning-tree", a.authMiddleware, h.GetFullLearningTree) + // Unified Questions System + // Questions + groupV1.Post("/questions", a.authMiddleware, h.CreateQuestion) + groupV1.Get("/questions", a.authMiddleware, h.ListQuestions) + groupV1.Get("/questions/search", a.authMiddleware, h.SearchQuestions) + groupV1.Get("/questions/:id", a.authMiddleware, h.GetQuestionByID) + groupV1.Put("/questions/:id", a.authMiddleware, h.UpdateQuestion) + groupV1.Delete("/questions/:id", a.authMiddleware, h.DeleteQuestion) + + // Question Sets (replaces Practices for question grouping) + groupV1.Post("/question-sets", a.authMiddleware, h.CreateQuestionSet) + groupV1.Get("/question-sets", a.authMiddleware, h.GetQuestionSetsByType) + groupV1.Get("/question-sets/by-owner", a.authMiddleware, h.GetQuestionSetsByOwner) + groupV1.Get("/question-sets/:id", a.authMiddleware, h.GetQuestionSetByID) + groupV1.Put("/question-sets/:id", a.authMiddleware, h.UpdateQuestionSet) + groupV1.Delete("/question-sets/:id", a.authMiddleware, h.DeleteQuestionSet) + + // Question Set Items (questions within sets) + groupV1.Post("/question-sets/:setId/questions", a.authMiddleware, h.AddQuestionToSet) + groupV1.Get("/question-sets/:setId/questions", a.authMiddleware, h.GetQuestionsInSet) + groupV1.Delete("/question-sets/:setId/questions/:questionId", a.authMiddleware, h.RemoveQuestionFromSet) + groupV1.Put("/question-sets/:setId/questions/:questionId/order", a.authMiddleware, h.UpdateQuestionOrderInSet) + + // Question Set User Personas (users as personas within practices) + groupV1.Get("/question-sets/:setId/personas", a.authMiddleware, h.GetUserPersonasByQuestionSet) + groupV1.Post("/question-sets/:setId/personas", a.authMiddleware, h.AddUserPersonaToQuestionSet) + groupV1.Delete("/question-sets/:setId/personas/:userId", a.authMiddleware, h.RemoveUserPersonaFromQuestionSet) + + // Subscription Plans (admin) + groupV1.Post("/subscription-plans", a.authMiddleware, h.CreateSubscriptionPlan) + groupV1.Get("/subscription-plans", h.ListSubscriptionPlans) + groupV1.Get("/subscription-plans/:id", h.GetSubscriptionPlan) + groupV1.Put("/subscription-plans/:id", a.authMiddleware, h.UpdateSubscriptionPlan) + groupV1.Delete("/subscription-plans/:id", a.authMiddleware, h.DeleteSubscriptionPlan) + + // User Subscriptions + groupV1.Post("/subscriptions", a.authMiddleware, h.Subscribe) // Admin only - creates subscription without payment + groupV1.Post("/subscriptions/checkout", a.authMiddleware, h.SubscribeWithPayment) // User - initiates payment for subscription + groupV1.Get("/subscriptions/me", a.authMiddleware, h.GetMySubscription) + groupV1.Get("/subscriptions/history", a.authMiddleware, h.GetMySubscriptionHistory) + groupV1.Get("/subscriptions/status", a.authMiddleware, h.CheckSubscriptionStatus) + groupV1.Post("/subscriptions/:id/cancel", a.authMiddleware, h.CancelSubscription) + groupV1.Put("/subscriptions/:id/auto-renew", a.authMiddleware, h.SetAutoRenew) + + // Payments (ArifPay Integration) + groupV1.Post("/payments/subscribe", a.authMiddleware, h.InitiateSubscriptionPayment) + groupV1.Get("/payments/verify/:session_id", a.authMiddleware, h.VerifyPayment) + groupV1.Get("/payments", a.authMiddleware, h.GetMyPayments) + groupV1.Get("/payments/:id", a.authMiddleware, h.GetPaymentByID) + groupV1.Post("/payments/:id/cancel", a.authMiddleware, h.CancelPayment) + groupV1.Get("/payments/methods", h.GetArifpayPaymentMethods) + // Webhook endpoint (no auth - called by ArifPay) + groupV1.Post("/payments/webhook", h.HandleArifpayWebhook) + + // Direct Payments (OTP-based - Telebirr, CBE, Amole, HelloCash, etc.) + groupV1.Post("/payments/direct", a.authMiddleware, h.InitiateDirectPayment) + groupV1.Post("/payments/direct/verify-otp", a.authMiddleware, h.VerifyDirectPaymentOTP) + groupV1.Get("/payments/direct/methods", h.GetDirectPaymentMethods) + // Auth Routes groupV1.Post("/auth/google/android", h.GoogleAndroidLogin) groupV1.Get("/auth/google/login", h.GoogleLogin) @@ -300,10 +339,37 @@ func (a *App) initAppRoutes() { // Device Token Registration groupV1.Post("/devices/register", a.authMiddleware, h.RegisterDeviceToken) + groupV1.Post("/devices/unregister", a.authMiddleware, h.UnregisterDeviceToken) + + // Test Push Notification (for development/testing) + groupV1.Post("/notifications/test-push", a.authMiddleware, h.SendTestPushNotification) // Settings groupV1.Get("/settings", a.authMiddleware, a.SuperAdminOnly, h.GetGlobalSettingList) groupV1.Get("/settings/:key", a.authMiddleware, a.SuperAdminOnly, h.GetGlobalSettingByKey) groupV1.Put("/settings", a.authMiddleware, a.SuperAdminOnly, h.UpdateGlobalSettingList) + // Vimeo Video Hosting Routes + vimeoGroup := groupV1.Group("/vimeo") + vimeoGroup.Get("/videos/:video_id", a.authMiddleware, h.GetVimeoVideo) + vimeoGroup.Get("/videos/:video_id/embed", a.authMiddleware, h.GetEmbedCode) + vimeoGroup.Get("/videos/:video_id/status", a.authMiddleware, h.GetTranscodeStatus) + vimeoGroup.Delete("/videos/:video_id", a.authMiddleware, h.DeleteVimeoVideo) + vimeoGroup.Post("/uploads/pull", a.authMiddleware, h.CreatePullUpload) + vimeoGroup.Post("/uploads/tus", a.authMiddleware, h.CreateTusUpload) + vimeoGroup.Get("/oembed", h.GetOEmbed) + + // Team Management Routes (Internal HR/Team) + teamGroup := groupV1.Group("/team") + teamGroup.Post("/login", h.TeamMemberLogin) // Team member authentication + teamGroup.Get("/me", a.authMiddleware, h.GetMyTeamProfile) // Get own profile + teamGroup.Get("/stats", a.authMiddleware, a.OnlyAdminAndAbove, h.GetTeamMemberStats) // Team statistics + teamGroup.Get("/members", a.authMiddleware, a.OnlyAdminAndAbove, h.GetAllTeamMembers) // List all team members + teamGroup.Post("/members", a.authMiddleware, a.OnlyAdminAndAbove, h.CreateTeamMember) // Create team member + teamGroup.Get("/members/:id", a.authMiddleware, h.GetTeamMember) // Get team member by ID + teamGroup.Put("/members/:id", a.authMiddleware, a.OnlyAdminAndAbove, h.UpdateTeamMember) // Update team member + teamGroup.Patch("/members/:id/status", a.authMiddleware, a.OnlyAdminAndAbove, h.UpdateTeamMemberStatus) // Update status + teamGroup.Delete("/members/:id", a.authMiddleware, a.SuperAdminOnly, h.DeleteTeamMember) // Delete team member + teamGroup.Post("/members/:id/change-password", a.authMiddleware, h.ChangeTeamMemberPassword) // Change password + }