Compare commits

...

16 Commits

Author SHA1 Message Date
a1c6b3c15a progress precentage fix 2026-05-27 09:18:25 -07:00
d3225ca61a Improve Google Android login error diagnostics.
Preserve the underlying Google token validation failure and log safe request context so ID token issues are easier to debug.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 06:32:15 -07:00
79fb95ce36 Add category-based subscription controls for LMS and exam prep.
Introduce plan and content categories across programs and exam-prep catalog roots, wire category-aware checkout and access checks, and keep learner gating temporarily bypassed until data migration is ready.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 06:20:49 -07:00
7a4253edf4 Add explicit payment provider selection for subscriptions.
Require the client to choose CHAPA or ARIFPAY in the subscription checkout request body and route payment initiation and verification through the matching provider.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 04:18:24 -07:00
82de00b1e7 Add LMS progress summary endpoint.
Expose a single learner endpoint that returns the nested LMS hierarchy with the same access-based progress percentages used across the existing content APIs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 04:07:19 -07:00
56cc009579 progress tracking fix 2026-05-26 03:50:46 -07:00
afdd07d65d Update learner progress to use practice completions only.
Remove lesson completion from learner progress percentages, access completion snapshots, and LMS rollups while keeping generated SQLC and Swagger artifacts in sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 03:27:54 -07:00
a719c0daca Add mobile app version management and refresh profile field seeds.
Introduce admin CRUD and public version check APIs for Play Store/App Store releases with force or optional update policies, and update profile dropdown seed data for countries, regions, and learner profile fields.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 06:52:20 -07:00
3f73afb4bf Add video engagement tracking and analytics metrics.
Record playback heartbeats via POST /api/v1/videos/engagement/heartbeat and expose completion, replay, and drop-off rates on the analytics dashboard.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 02:59:46 -07:00
56089fa8fd Add users by country to analytics dashboard.
Expose by_country breakdown on GET /api/v1/analytics/dashboard from users.country.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 01:47:41 -07:00
e957eacf80 Add profile field breakdowns to analytics dashboard.
Expose user counts by education_level, occupation, learning_goal, and language_challange on GET /api/v1/analytics/dashboard.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 09:54:47 -07:00
f7d4b5c3fb Seed country and ethiopia_regions field options for dropdowns.
Add ISO-style country codes and Ethiopian regional states to initial and follow-up migrations.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 09:45:39 -07:00
a5acd00637 Add admin-managed field options API for configurable dropdowns.
Store options in field_options with public /field-options and admin CRUD; validate learner profile values on update.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 09:21:36 -07:00
176f78515d Fix partial team member updates clearing team_role on invite accept.
Use nullable sqlc.narg fields so empty strings are not written to team_role and other optional columns.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 07:54:44 -07:00
215a4bd1dc Simplify team invite to email and role; collect profile on accept
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 06:49:28 -07:00
0ad7f094cf Include access metadata for OPEN_LEARNER with is_accessible always true
Keeps the same response shape as STUDENT while skipping sequential locks; progress fields are still populated for completion UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 05:45:44 -07:00
100 changed files with 11847 additions and 549 deletions

View File

@ -14,6 +14,7 @@ import (
"Yimaru-Backend/internal/repository" "Yimaru-Backend/internal/repository"
activitylogservice "Yimaru-Backend/internal/services/activity_log" activitylogservice "Yimaru-Backend/internal/services/activity_log"
"Yimaru-Backend/internal/services/arifpay" "Yimaru-Backend/internal/services/arifpay"
"Yimaru-Backend/internal/services/appversions"
"Yimaru-Backend/internal/services/chapa" "Yimaru-Backend/internal/services/chapa"
"Yimaru-Backend/internal/services/assessment" "Yimaru-Backend/internal/services/assessment"
"Yimaru-Backend/internal/services/authentication" "Yimaru-Backend/internal/services/authentication"
@ -31,6 +32,7 @@ import (
notificationservice "Yimaru-Backend/internal/services/notification" notificationservice "Yimaru-Backend/internal/services/notification"
personasservice "Yimaru-Backend/internal/services/personas" personasservice "Yimaru-Backend/internal/services/personas"
practicesservice "Yimaru-Backend/internal/services/practices" practicesservice "Yimaru-Backend/internal/services/practices"
profilefieldoptions "Yimaru-Backend/internal/services/profilefieldoptions"
programsservice "Yimaru-Backend/internal/services/programs" programsservice "Yimaru-Backend/internal/services/programs"
"Yimaru-Backend/internal/services/questions" "Yimaru-Backend/internal/services/questions"
ratingsservice "Yimaru-Backend/internal/services/ratings" ratingsservice "Yimaru-Backend/internal/services/ratings"
@ -45,6 +47,7 @@ import (
// referralservice "Yimaru-Backend/internal/services/referal" // referralservice "Yimaru-Backend/internal/services/referal"
"Yimaru-Backend/internal/services/transaction" "Yimaru-Backend/internal/services/transaction"
"Yimaru-Backend/internal/services/user" "Yimaru-Backend/internal/services/user"
videoengagementservice "Yimaru-Backend/internal/services/videoengagement"
httpserver "Yimaru-Backend/internal/web_server" httpserver "Yimaru-Backend/internal/web_server"
jwtutil "Yimaru-Backend/internal/web_server/jwt" jwtutil "Yimaru-Backend/internal/web_server/jwt"
customvalidator "Yimaru-Backend/internal/web_server/validator" customvalidator "Yimaru-Backend/internal/web_server/validator"
@ -109,6 +112,7 @@ func main() {
messengerSvc := messenger.NewService(settingSvc, cfg) messengerSvc := messenger.NewService(settingSvc, cfg)
emailTemplateSvc := emailtemplates.NewService(repository.NewEmailTemplateStore(store)) emailTemplateSvc := emailtemplates.NewService(repository.NewEmailTemplateStore(store))
profileFieldOptionSvc := profilefieldoptions.NewService(repository.NewProfileFieldOptionStore(store))
userSvc := user.NewService( userSvc := user.NewService(
repository.NewTokenStore(store), repository.NewTokenStore(store),
@ -116,6 +120,7 @@ func main() {
repository.NewOTPStore(store), repository.NewOTPStore(store),
messengerSvc, messengerSvc,
emailTemplateSvc, emailTemplateSvc,
profileFieldOptionSvc,
cfg, cfg,
) )
@ -396,6 +401,7 @@ func main() {
// Questions service (unified questions system) // Questions service (unified questions system)
questionsSvc := questions.NewService(store) questionsSvc := questions.NewService(store)
faqSvc := faqs.NewService(repository.NewFAQStore(store)) faqSvc := faqs.NewService(repository.NewFAQStore(store))
appVersionSvc := appversions.NewService(repository.NewMobileAppVersionStore(store))
personasSvc := personasservice.NewService(store) personasSvc := personasservice.NewService(store)
examPrepSvc := examprep.NewService(store) examPrepSvc := examprep.NewService(store)
@ -413,6 +419,8 @@ func main() {
lmsProgressSvc := lmsprogress.NewService(store) lmsProgressSvc := lmsprogress.NewService(store)
videoEngagementSvc := videoengagementservice.NewService(store)
// LMS practices (under course, module, or lesson) // LMS practices (under course, module, or lesson)
practiceSvc := practicesservice.NewService(store, store, store, store, store, store) practiceSvc := practicesservice.NewService(store, store, store, store, store, store)
@ -474,7 +482,9 @@ func main() {
assessmentSvc, assessmentSvc,
questionsSvc, questionsSvc,
faqSvc, faqSvc,
appVersionSvc,
emailTemplateSvc, emailTemplateSvc,
profileFieldOptionSvc,
personasSvc, personasSvc,
examPrepSvc, examPrepSvc,
programSvc, programSvc,
@ -510,6 +520,7 @@ func main() {
domain.MongoDBLogger, domain.MongoDBLogger,
analyticsDB, analyticsDB,
rbacSvc, rbacSvc,
videoEngagementSvc,
) )
logger.Info("Starting server", "port", cfg.Port) logger.Info("Starting server", "port", cfg.Port)

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS field_options;

View File

@ -0,0 +1,236 @@
CREATE TABLE IF NOT EXISTS field_options (
id BIGSERIAL PRIMARY KEY,
field_key VARCHAR(50) NOT NULL,
code VARCHAR(50) NOT NULL,
label VARCHAR(255) NOT NULL,
display_order INT NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'INACTIVE')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ,
CONSTRAINT field_options_field_key_format CHECK (field_key ~ '^[a-z][a-z0-9_]*$'),
CONSTRAINT field_options_unique_field_code UNIQUE (field_key, code)
);
CREATE INDEX IF NOT EXISTS idx_field_options_field_key ON field_options(field_key);
CREATE INDEX IF NOT EXISTS idx_field_options_status ON field_options(status);
CREATE INDEX IF NOT EXISTS idx_field_options_display_order ON field_options(display_order);
INSERT INTO field_options (field_key, code, label, display_order, status) VALUES
('education_level', 'NO_FORMAL', 'No formal education', 1, 'ACTIVE'),
('education_level', 'PRIMARY', 'Primary school', 2, 'ACTIVE'),
('education_level', 'SECONDARY', 'Secondary school', 3, 'ACTIVE'),
('education_level', 'HIGH_SCHOOL', 'High school', 4, 'ACTIVE'),
('education_level', 'VOCATIONAL', 'Vocational / technical', 5, 'ACTIVE'),
('education_level', 'BACHELOR', 'Bachelor''s degree', 6, 'ACTIVE'),
('education_level', 'MASTER', 'Master''s degree', 7, 'ACTIVE'),
('education_level', 'DOCTORATE', 'Doctorate', 8, 'ACTIVE'),
('education_level', 'OTHER', 'Other', 99, 'ACTIVE'),
('occupation', 'STUDENTS', 'Students (High school & University)', 1, 'ACTIVE'),
('occupation', 'JOB_SEEKERS', 'Job Seekers / Fresh Graduates', 2, 'ACTIVE'),
('occupation', 'WORKING_PROFESSIONALS', 'Working Professionals (Corporate/Office)', 3, 'ACTIVE'),
('occupation', 'GOVERNMENT_NGO', 'Government & NGO Workers', 4, 'ACTIVE'),
('occupation', 'ENTREPRENEURS', 'Entrepreneurs & Small Business Owners', 5, 'ACTIVE'),
('occupation', 'HOSPITALITY_TOURISM', 'Hospitality & Tourism Workers', 6, 'ACTIVE'),
('occupation', 'FREELANCERS_REMOTE', 'Freelancers / Remote Workers (Digital Economy)', 7, 'ACTIVE'),
('age_group', 'UNDER_13', 'Under 13', 1, 'ACTIVE'),
('age_group', '13_17', '1317', 2, 'ACTIVE'),
('age_group', '18_24', '1824', 3, 'ACTIVE'),
('age_group', '25_34', '2534', 4, 'ACTIVE'),
('age_group', '35_44', '3544', 5, 'ACTIVE'),
('age_group', '45_54', '4554', 6, 'ACTIVE'),
('age_group', '55_PLUS', '55+', 7, 'ACTIVE'),
('learning_goal', 'EVERYDAY_CONVERSATION', 'Everyday conversation', 1, 'ACTIVE'),
('learning_goal', 'WORK_CAREER', 'Work and career', 2, 'ACTIVE'),
('learning_goal', 'ACADEMIC_STUDY', 'Academic study', 3, 'ACTIVE'),
('learning_goal', 'TRAVEL', 'Travel', 4, 'ACTIVE'),
('learning_goal', 'EXAM_PREP', 'Exam preparation', 5, 'ACTIVE'),
('learning_goal', 'PERSONAL_GROWTH', 'Personal growth', 6, 'ACTIVE'),
('learning_goal', 'OTHER', 'Other', 99, 'ACTIVE'),
('language_challange', 'PRONUNCIATION', 'Pronunciation', 1, 'ACTIVE'),
('language_challange', 'WORDS_GRAMMAR', 'Finding words or grammar quickly', 2, 'ACTIVE'),
('language_challange', 'CONFIDENCE', 'Feeling nervous or lacking confidence', 3, 'ACTIVE'),
('language_challange', 'ACCENTS_FAST_SPEECH', 'Understanding accents or fast speech', 4, 'ACTIVE'),
('language_challange', 'OTHER', 'Other', 99, 'ACTIVE'),
('language_goal', 'SPEAK_CONFIDENTLY', 'Speak confidently at work or school', 1, 'ACTIVE'),
('language_goal', 'TRAVEL_DAILY', 'Travel or handle daily situations', 2, 'ACTIVE'),
('language_goal', 'FAMILY_FRIENDS', 'Connect with family or friends', 3, 'ACTIVE'),
('language_goal', 'GENERAL_SKILLS', 'General skills expansion', 4, 'ACTIVE'),
('language_goal', 'OTHER', 'Other', 99, 'ACTIVE'),
('favourite_topic', 'FOOD_COOKING', 'Food & Cooking', 1, 'ACTIVE'),
('favourite_topic', 'HOBBIES_SPORTS_MUSIC', 'Hobbies, Sports, Music', 2, 'ACTIVE'),
('favourite_topic', 'TECH_NEWS_BUSINESS', 'Tech, News, Business', 3, 'ACTIVE'),
('favourite_topic', 'TRAVEL_PLACES_CULTURE', 'Travel, Places, Culture', 4, 'ACTIVE'),
('favourite_topic', 'OTHER', 'Other', 99, 'ACTIVE'),
('country', 'AF', 'Afghanistan', 1, 'ACTIVE'),
('country', 'AL', 'Albania', 2, 'ACTIVE'),
('country', 'DZ', 'Algeria', 3, 'ACTIVE'),
('country', 'AD', 'Andorra', 4, 'ACTIVE'),
('country', 'AO', 'Angola', 5, 'ACTIVE'),
('country', 'AR', 'Argentina', 6, 'ACTIVE'),
('country', 'AM', 'Armenia', 7, 'ACTIVE'),
('country', 'AU', 'Australia', 8, 'ACTIVE'),
('country', 'AT', 'Austria', 9, 'ACTIVE'),
('country', 'AZ', 'Azerbaijan', 10, 'ACTIVE'),
('country', 'BH', 'Bahrain', 11, 'ACTIVE'),
('country', 'BD', 'Bangladesh', 12, 'ACTIVE'),
('country', 'BY', 'Belarus', 13, 'ACTIVE'),
('country', 'BE', 'Belgium', 14, 'ACTIVE'),
('country', 'BZ', 'Belize', 15, 'ACTIVE'),
('country', 'BJ', 'Benin', 16, 'ACTIVE'),
('country', 'BT', 'Bhutan', 17, 'ACTIVE'),
('country', 'BO', 'Bolivia', 18, 'ACTIVE'),
('country', 'BA', 'Bosnia and Herzegovina', 19, 'ACTIVE'),
('country', 'BW', 'Botswana', 20, 'ACTIVE'),
('country', 'BR', 'Brazil', 21, 'ACTIVE'),
('country', 'BN', 'Brunei', 22, 'ACTIVE'),
('country', 'BG', 'Bulgaria', 23, 'ACTIVE'),
('country', 'BF', 'Burkina Faso', 24, 'ACTIVE'),
('country', 'BI', 'Burundi', 25, 'ACTIVE'),
('country', 'KH', 'Cambodia', 26, 'ACTIVE'),
('country', 'CM', 'Cameroon', 27, 'ACTIVE'),
('country', 'CA', 'Canada', 28, 'ACTIVE'),
('country', 'TD', 'Chad', 29, 'ACTIVE'),
('country', 'CL', 'Chile', 30, 'ACTIVE'),
('country', 'CN', 'China', 31, 'ACTIVE'),
('country', 'CO', 'Colombia', 32, 'ACTIVE'),
('country', 'KM', 'Comoros', 33, 'ACTIVE'),
('country', 'CG', 'Congo', 34, 'ACTIVE'),
('country', 'CR', 'Costa Rica', 35, 'ACTIVE'),
('country', 'HR', 'Croatia', 36, 'ACTIVE'),
('country', 'CU', 'Cuba', 37, 'ACTIVE'),
('country', 'CY', 'Cyprus', 38, 'ACTIVE'),
('country', 'CZ', 'Czech Republic', 39, 'ACTIVE'),
('country', 'DK', 'Denmark', 40, 'ACTIVE'),
('country', 'DJ', 'Djibouti', 41, 'ACTIVE'),
('country', 'DO', 'Dominican Republic', 42, 'ACTIVE'),
('country', 'EC', 'Ecuador', 43, 'ACTIVE'),
('country', 'EG', 'Egypt', 44, 'ACTIVE'),
('country', 'SV', 'El Salvador', 45, 'ACTIVE'),
('country', 'ER', 'Eritrea', 46, 'ACTIVE'),
('country', 'EE', 'Estonia', 47, 'ACTIVE'),
('country', 'SZ', 'Eswatini', 48, 'ACTIVE'),
('country', 'ET', 'Ethiopia', 49, 'ACTIVE'),
('country', 'FI', 'Finland', 50, 'ACTIVE'),
('country', 'FR', 'France', 51, 'ACTIVE'),
('country', 'GA', 'Gabon', 52, 'ACTIVE'),
('country', 'GM', 'Gambia', 53, 'ACTIVE'),
('country', 'GE', 'Georgia', 54, 'ACTIVE'),
('country', 'DE', 'Germany', 55, 'ACTIVE'),
('country', 'GH', 'Ghana', 56, 'ACTIVE'),
('country', 'GR', 'Greece', 57, 'ACTIVE'),
('country', 'GT', 'Guatemala', 58, 'ACTIVE'),
('country', 'GN', 'Guinea', 59, 'ACTIVE'),
('country', 'HT', 'Haiti', 60, 'ACTIVE'),
('country', 'HN', 'Honduras', 61, 'ACTIVE'),
('country', 'HU', 'Hungary', 62, 'ACTIVE'),
('country', 'IS', 'Iceland', 63, 'ACTIVE'),
('country', 'IN', 'India', 64, 'ACTIVE'),
('country', 'ID', 'Indonesia', 65, 'ACTIVE'),
('country', 'IR', 'Iran', 66, 'ACTIVE'),
('country', 'IQ', 'Iraq', 67, 'ACTIVE'),
('country', 'IE', 'Ireland', 68, 'ACTIVE'),
('country', 'IL', 'Israel', 69, 'ACTIVE'),
('country', 'IT', 'Italy', 70, 'ACTIVE'),
('country', 'JM', 'Jamaica', 71, 'ACTIVE'),
('country', 'JP', 'Japan', 72, 'ACTIVE'),
('country', 'JO', 'Jordan', 73, 'ACTIVE'),
('country', 'KZ', 'Kazakhstan', 74, 'ACTIVE'),
('country', 'KE', 'Kenya', 75, 'ACTIVE'),
('country', 'KW', 'Kuwait', 76, 'ACTIVE'),
('country', 'KG', 'Kyrgyzstan', 77, 'ACTIVE'),
('country', 'LA', 'Laos', 78, 'ACTIVE'),
('country', 'LV', 'Latvia', 79, 'ACTIVE'),
('country', 'LB', 'Lebanon', 80, 'ACTIVE'),
('country', 'LR', 'Liberia', 81, 'ACTIVE'),
('country', 'LY', 'Libya', 82, 'ACTIVE'),
('country', 'LT', 'Lithuania', 83, 'ACTIVE'),
('country', 'LU', 'Luxembourg', 84, 'ACTIVE'),
('country', 'MG', 'Madagascar', 85, 'ACTIVE'),
('country', 'MW', 'Malawi', 86, 'ACTIVE'),
('country', 'MY', 'Malaysia', 87, 'ACTIVE'),
('country', 'MV', 'Maldives', 88, 'ACTIVE'),
('country', 'ML', 'Mali', 89, 'ACTIVE'),
('country', 'MT', 'Malta', 90, 'ACTIVE'),
('country', 'MX', 'Mexico', 91, 'ACTIVE'),
('country', 'MD', 'Moldova', 92, 'ACTIVE'),
('country', 'MC', 'Monaco', 93, 'ACTIVE'),
('country', 'MN', 'Mongolia', 94, 'ACTIVE'),
('country', 'MA', 'Morocco', 95, 'ACTIVE'),
('country', 'MZ', 'Mozambique', 96, 'ACTIVE'),
('country', 'MM', 'Myanmar', 97, 'ACTIVE'),
('country', 'NA', 'Namibia', 98, 'ACTIVE'),
('country', 'NP', 'Nepal', 99, 'ACTIVE'),
('country', 'NL', 'Netherlands', 100, 'ACTIVE'),
('country', 'NZ', 'New Zealand', 101, 'ACTIVE'),
('country', 'NI', 'Nicaragua', 102, 'ACTIVE'),
('country', 'NE', 'Niger', 103, 'ACTIVE'),
('country', 'NG', 'Nigeria', 104, 'ACTIVE'),
('country', 'KP', 'North Korea', 105, 'ACTIVE'),
('country', 'NO', 'Norway', 106, 'ACTIVE'),
('country', 'OM', 'Oman', 107, 'ACTIVE'),
('country', 'PK', 'Pakistan', 108, 'ACTIVE'),
('country', 'PA', 'Panama', 109, 'ACTIVE'),
('country', 'PY', 'Paraguay', 110, 'ACTIVE'),
('country', 'PE', 'Peru', 111, 'ACTIVE'),
('country', 'PH', 'Philippines', 112, 'ACTIVE'),
('country', 'PL', 'Poland', 113, 'ACTIVE'),
('country', 'PT', 'Portugal', 114, 'ACTIVE'),
('country', 'QA', 'Qatar', 115, 'ACTIVE'),
('country', 'RO', 'Romania', 116, 'ACTIVE'),
('country', 'RU', 'Russia', 117, 'ACTIVE'),
('country', 'RW', 'Rwanda', 118, 'ACTIVE'),
('country', 'SA', 'Saudi Arabia', 119, 'ACTIVE'),
('country', 'SN', 'Senegal', 120, 'ACTIVE'),
('country', 'RS', 'Serbia', 121, 'ACTIVE'),
('country', 'SG', 'Singapore', 122, 'ACTIVE'),
('country', 'SK', 'Slovakia', 123, 'ACTIVE'),
('country', 'SI', 'Slovenia', 124, 'ACTIVE'),
('country', 'SO', 'Somalia', 125, 'ACTIVE'),
('country', 'ZA', 'South Africa', 126, 'ACTIVE'),
('country', 'KR', 'South Korea', 127, 'ACTIVE'),
('country', 'ES', 'Spain', 128, 'ACTIVE'),
('country', 'LK', 'Sri Lanka', 129, 'ACTIVE'),
('country', 'SD', 'Sudan', 130, 'ACTIVE'),
('country', 'SE', 'Sweden', 131, 'ACTIVE'),
('country', 'CH', 'Switzerland', 132, 'ACTIVE'),
('country', 'SY', 'Syria', 133, 'ACTIVE'),
('country', 'TW', 'Taiwan', 134, 'ACTIVE'),
('country', 'TJ', 'Tajikistan', 135, 'ACTIVE'),
('country', 'TZ', 'Tanzania', 136, 'ACTIVE'),
('country', 'TH', 'Thailand', 137, 'ACTIVE'),
('country', 'TN', 'Tunisia', 138, 'ACTIVE'),
('country', 'TR', 'Turkey', 139, 'ACTIVE'),
('country', 'UG', 'Uganda', 140, 'ACTIVE'),
('country', 'UA', 'Ukraine', 141, 'ACTIVE'),
('country', 'AE', 'United Arab Emirates', 142, 'ACTIVE'),
('country', 'GB', 'United Kingdom', 143, 'ACTIVE'),
('country', 'US', 'United States', 144, 'ACTIVE'),
('country', 'UY', 'Uruguay', 145, 'ACTIVE'),
('country', 'UZ', 'Uzbekistan', 146, 'ACTIVE'),
('country', 'VE', 'Venezuela', 147, 'ACTIVE'),
('country', 'VN', 'Vietnam', 148, 'ACTIVE'),
('country', 'YE', 'Yemen', 149, 'ACTIVE'),
('country', 'ZM', 'Zambia', 150, 'ACTIVE'),
('country', 'ZW', 'Zimbabwe', 151, 'ACTIVE'),
('ethiopia_regions', 'ADDIS_ABABA', 'Addis Ababa', 1, 'ACTIVE'),
('ethiopia_regions', 'AFAR', 'Afar', 2, 'ACTIVE'),
('ethiopia_regions', 'AMHARA', 'Amhara', 3, 'ACTIVE'),
('ethiopia_regions', 'BENISHANGUL_GUMUZ', 'Benishangul-Gumuz', 4, 'ACTIVE'),
('ethiopia_regions', 'CENTRAL_ETHIOPIA', 'Central Ethiopia', 5, 'ACTIVE'),
('ethiopia_regions', 'DIRE_DAWA', 'Dire Dawa', 6, 'ACTIVE'),
('ethiopia_regions', 'GAMBELA', 'Gambela', 7, 'ACTIVE'),
('ethiopia_regions', 'HARARI', 'Harari', 8, 'ACTIVE'),
('ethiopia_regions', 'OROMIA', 'Oromia', 9, 'ACTIVE'),
('ethiopia_regions', 'SIDAMA', 'Sidama', 10, 'ACTIVE'),
('ethiopia_regions', 'SOMALI', 'Somali', 11, 'ACTIVE'),
('ethiopia_regions', 'SOUTH_ETHIOPIA', 'South Ethiopia', 12, 'ACTIVE'),
('ethiopia_regions', 'SOUTH_WEST_ETHIOPIA_PEOPLES', 'South West Ethiopia Peoples', 13, 'ACTIVE'),
('ethiopia_regions', 'TIGRAY', 'Tigray', 14, 'ACTIVE');

View File

@ -0,0 +1 @@
-- No-op: keep field_options table name on rollback of 070 alone.

View File

@ -0,0 +1,20 @@
-- For databases that already applied 000069 with profile_field_options table name.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'profile_field_options'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'field_options'
) THEN
ALTER TABLE profile_field_options RENAME TO field_options;
ALTER TABLE field_options RENAME CONSTRAINT profile_field_options_field_key_check TO field_options_field_key_check_old;
ALTER TABLE field_options DROP CONSTRAINT IF EXISTS field_options_field_key_check_old;
ALTER TABLE field_options RENAME CONSTRAINT profile_field_options_unique_field_code TO field_options_unique_field_code;
ALTER INDEX IF EXISTS idx_profile_field_options_field_key RENAME TO idx_field_options_field_key;
ALTER INDEX IF EXISTS idx_profile_field_options_status RENAME TO idx_field_options_status;
ALTER INDEX IF EXISTS idx_profile_field_options_display_order RENAME TO idx_field_options_display_order;
ALTER TABLE field_options ADD CONSTRAINT field_options_field_key_format CHECK (field_key ~ '^[a-z][a-z0-9_]*$');
END IF;
END $$;

View File

@ -0,0 +1,16 @@
DROP INDEX IF EXISTS idx_exam_prep_catalog_courses_category;
DROP INDEX IF EXISTS idx_programs_category;
DROP INDEX IF EXISTS idx_subscription_plans_category;
ALTER TABLE exam_prep.catalog_courses
DROP CONSTRAINT IF EXISTS chk_exam_prep_catalog_courses_category,
ALTER COLUMN category DROP DEFAULT,
DROP COLUMN IF EXISTS category;
ALTER TABLE subscription_plans
DROP CONSTRAINT IF EXISTS chk_subscription_plans_category,
DROP COLUMN IF EXISTS category;
ALTER TABLE programs
DROP CONSTRAINT IF EXISTS chk_programs_category,
DROP COLUMN IF EXISTS category;

View File

@ -0,0 +1,30 @@
ALTER TABLE subscription_plans
ADD COLUMN category VARCHAR(32) NOT NULL DEFAULT 'LEARN_ENGLISH',
ADD CONSTRAINT chk_subscription_plans_category
CHECK (category IN ('LEARN_ENGLISH', 'IELTS', 'DUOLINGO'));
ALTER TABLE programs
ADD COLUMN category VARCHAR(32) NOT NULL DEFAULT 'LEARN_ENGLISH',
ADD CONSTRAINT chk_programs_category
CHECK (category IN ('LEARN_ENGLISH', 'IELTS', 'DUOLINGO'));
ALTER TABLE exam_prep.catalog_courses
ADD COLUMN category VARCHAR(32);
UPDATE exam_prep.catalog_courses
SET category = CASE
WHEN upper(name) LIKE '%DUOLINGO%' OR upper(name) LIKE '%DET%' THEN 'DUOLINGO'
WHEN upper(name) LIKE '%IELTS%' THEN 'IELTS'
ELSE 'IELTS'
END
WHERE category IS NULL;
ALTER TABLE exam_prep.catalog_courses
ALTER COLUMN category SET NOT NULL,
ALTER COLUMN category SET DEFAULT 'IELTS',
ADD CONSTRAINT chk_exam_prep_catalog_courses_category
CHECK (category IN ('IELTS', 'DUOLINGO'));
CREATE INDEX idx_subscription_plans_category ON subscription_plans(category);
CREATE INDEX idx_programs_category ON programs(category);
CREATE INDEX idx_exam_prep_catalog_courses_category ON exam_prep.catalog_courses(category);

View File

@ -0,0 +1,7 @@
DELETE FROM field_options
WHERE field_key = 'country'
AND code IN (
'ET', 'ER', 'DJ', 'SO', 'KE', 'SD', 'SS', 'UG', 'RW', 'TZ',
'EG', 'NG', 'ZA', 'US', 'GB', 'CA', 'DE', 'FR', 'IN', 'CN',
'SA', 'AE', 'OTHER'
);

View File

@ -0,0 +1,25 @@
INSERT INTO field_options (field_key, code, label, display_order, status) VALUES
('country', 'ET', 'Ethiopia', 1, 'ACTIVE'),
('country', 'ER', 'Eritrea', 2, 'ACTIVE'),
('country', 'DJ', 'Djibouti', 3, 'ACTIVE'),
('country', 'SO', 'Somalia', 4, 'ACTIVE'),
('country', 'KE', 'Kenya', 5, 'ACTIVE'),
('country', 'SD', 'Sudan', 6, 'ACTIVE'),
('country', 'SS', 'South Sudan', 7, 'ACTIVE'),
('country', 'UG', 'Uganda', 8, 'ACTIVE'),
('country', 'RW', 'Rwanda', 9, 'ACTIVE'),
('country', 'TZ', 'Tanzania', 10, 'ACTIVE'),
('country', 'EG', 'Egypt', 11, 'ACTIVE'),
('country', 'NG', 'Nigeria', 12, 'ACTIVE'),
('country', 'ZA', 'South Africa', 13, 'ACTIVE'),
('country', 'US', 'United States', 20, 'ACTIVE'),
('country', 'GB', 'United Kingdom', 21, 'ACTIVE'),
('country', 'CA', 'Canada', 22, 'ACTIVE'),
('country', 'DE', 'Germany', 23, 'ACTIVE'),
('country', 'FR', 'France', 24, 'ACTIVE'),
('country', 'IN', 'India', 25, 'ACTIVE'),
('country', 'CN', 'China', 26, 'ACTIVE'),
('country', 'SA', 'Saudi Arabia', 27, 'ACTIVE'),
('country', 'AE', 'United Arab Emirates', 28, 'ACTIVE'),
('country', 'OTHER', 'Other', 99, 'ACTIVE')
ON CONFLICT (field_key, code) DO NOTHING;

View File

@ -0,0 +1,7 @@
DELETE FROM field_options
WHERE field_key = 'ethiopia_regions'
AND code IN (
'ADDIS_ABABA', 'DIRE_DAWA', 'TIGRAY', 'AFAR', 'AMHARA', 'OROMIA', 'SOMALI',
'BENISHANGUL_GUMUZ', 'GAMBELA', 'HARARI', 'SIDAMA', 'SOUTH_ETHIOPIA',
'SOUTH_WEST_ETHIOPIA', 'CENTRAL_ETHIOPIA', 'OTHER'
);

View File

@ -0,0 +1,19 @@
INSERT INTO field_options (field_key, code, label, display_order, status) VALUES
('ethiopia_regions', 'ADDIS_ABABA', 'Addis Ababa', 1, 'ACTIVE'),
('ethiopia_regions', 'AFAR', 'Afar', 2, 'ACTIVE'),
('ethiopia_regions', 'AMHARA', 'Amhara', 3, 'ACTIVE'),
('ethiopia_regions', 'BENISHANGUL_GUMUZ', 'Benishangul-Gumuz', 4, 'ACTIVE'),
('ethiopia_regions', 'CENTRAL_ETHIOPIA', 'Central Ethiopia', 5, 'ACTIVE'),
('ethiopia_regions', 'DIRE_DAWA', 'Dire Dawa', 6, 'ACTIVE'),
('ethiopia_regions', 'GAMBELA', 'Gambela', 7, 'ACTIVE'),
('ethiopia_regions', 'HARARI', 'Harari', 8, 'ACTIVE'),
('ethiopia_regions', 'OROMIA', 'Oromia', 9, 'ACTIVE'),
('ethiopia_regions', 'SIDAMA', 'Sidama', 10, 'ACTIVE'),
('ethiopia_regions', 'SOMALI', 'Somali', 11, 'ACTIVE'),
('ethiopia_regions', 'SOUTH_ETHIOPIA', 'South Ethiopia', 12, 'ACTIVE'),
('ethiopia_regions', 'SOUTH_WEST_ETHIOPIA_PEOPLES', 'South West Ethiopia Peoples', 13, 'ACTIVE'),
('ethiopia_regions', 'TIGRAY', 'Tigray', 14, 'ACTIVE')
ON CONFLICT (field_key, code) DO UPDATE SET
label = EXCLUDED.label,
display_order = EXCLUDED.display_order,
status = EXCLUDED.status;

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS user_video_watch_sessions;

View File

@ -0,0 +1,18 @@
CREATE TABLE user_video_watch_sessions (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
content_kind VARCHAR(32) NOT NULL CHECK (content_kind IN ('lms_lesson', 'exam_prep_lesson')),
content_id BIGINT NOT NULL,
session_number INT NOT NULL CHECK (session_number > 0),
video_duration_sec INT,
max_position_sec INT NOT NULL DEFAULT 0 CHECK (max_position_sec >= 0),
started_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_heartbeat_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
ended_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
UNIQUE (user_id, content_kind, content_id, session_number)
);
CREATE INDEX idx_user_video_watch_sessions_user ON user_video_watch_sessions (user_id);
CREATE INDEX idx_user_video_watch_sessions_content ON user_video_watch_sessions (content_kind, content_id);
CREATE INDEX idx_user_video_watch_sessions_started_at ON user_video_watch_sessions (started_at);

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS mobile_app_versions;

View File

@ -0,0 +1,19 @@
CREATE TABLE IF NOT EXISTS mobile_app_versions (
id BIGSERIAL PRIMARY KEY,
platform VARCHAR(20) NOT NULL CHECK (platform IN ('ANDROID', 'IOS')),
version_name VARCHAR(50) NOT NULL,
version_code INT NOT NULL CHECK (version_code > 0),
update_type VARCHAR(20) NOT NULL DEFAULT 'OPTIONAL' CHECK (update_type IN ('FORCE', 'OPTIONAL')),
release_notes TEXT,
store_url TEXT,
min_supported_version_code INT CHECK (min_supported_version_code IS NULL OR min_supported_version_code > 0),
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'INACTIVE')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ,
CONSTRAINT mobile_app_versions_platform_version_code UNIQUE (platform, version_code),
CONSTRAINT mobile_app_versions_platform_version_name UNIQUE (platform, version_name)
);
CREATE INDEX IF NOT EXISTS idx_mobile_app_versions_platform ON mobile_app_versions (platform);
CREATE INDEX IF NOT EXISTS idx_mobile_app_versions_status ON mobile_app_versions (status);
CREATE INDEX IF NOT EXISTS idx_mobile_app_versions_platform_code ON mobile_app_versions (platform, version_code DESC);

View File

@ -68,6 +68,56 @@ WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR u.created_at >= sqlc.nar
GROUP BY u.knowledge_level GROUP BY u.knowledge_level
ORDER BY count DESC; ORDER BY count DESC;
-- name: AnalyticsUsersByEducationLevel :many
SELECT
COALESCE(NULLIF(TRIM(u.education_level), ''), 'unknown')::text AS education_level,
COUNT(*)::bigint AS count
FROM users u
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR u.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR u.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY COALESCE(NULLIF(TRIM(u.education_level), ''), 'unknown')
ORDER BY count DESC;
-- name: AnalyticsUsersByOccupation :many
SELECT
COALESCE(NULLIF(TRIM(u.occupation), ''), 'unknown')::text AS occupation,
COUNT(*)::bigint AS count
FROM users u
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR u.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR u.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY COALESCE(NULLIF(TRIM(u.occupation), ''), 'unknown')
ORDER BY count DESC;
-- name: AnalyticsUsersByLearningGoal :many
SELECT
COALESCE(NULLIF(TRIM(u.learning_goal), ''), 'unknown')::text AS learning_goal,
COUNT(*)::bigint AS count
FROM users u
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR u.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR u.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY COALESCE(NULLIF(TRIM(u.learning_goal), ''), 'unknown')
ORDER BY count DESC;
-- name: AnalyticsUsersByLanguageChallange :many
SELECT
COALESCE(NULLIF(TRIM(u.language_challange), ''), 'unknown')::text AS language_challange,
COUNT(*)::bigint AS count
FROM users u
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR u.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR u.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY COALESCE(NULLIF(TRIM(u.language_challange), ''), 'unknown')
ORDER BY count DESC;
-- name: AnalyticsUsersByCountry :many
SELECT
COALESCE(NULLIF(TRIM(u.country), ''), 'unknown')::text AS country,
COUNT(*)::bigint AS count
FROM users u
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR u.created_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR u.created_at < sqlc.narg('range_end')::timestamptz)
GROUP BY COALESCE(NULLIF(TRIM(u.country), ''), 'unknown')
ORDER BY count DESC;
-- name: AnalyticsUsersByRegion :many -- name: AnalyticsUsersByRegion :many
SELECT SELECT
COALESCE(u.region, 'unknown') AS region, COALESCE(u.region, 'unknown') AS region,
@ -267,6 +317,77 @@ SELECT
(SELECT COUNT(*)::bigint FROM exam_prep.unit_module_lessons WHERE NULLIF(BTRIM(video_url), '') IS NOT NULL) AS exam_prep_lessons_with_video, (SELECT COUNT(*)::bigint FROM exam_prep.unit_module_lessons WHERE NULLIF(BTRIM(video_url), '') IS NOT NULL) AS exam_prep_lessons_with_video,
(SELECT COUNT(*)::bigint FROM exam_prep.lesson_practices) AS exam_prep_lesson_practices; (SELECT COUNT(*)::bigint FROM exam_prep.lesson_practices) AS exam_prep_lesson_practices;
-- =====================
-- Video Engagement Analytics
-- =====================
-- name: AnalyticsVideoEngagementSummary :one
SELECT
COUNT(*)::bigint AS total_sessions,
COUNT(*) FILTER (WHERE s.completed_at IS NOT NULL)::bigint AS completed_sessions,
COUNT(*) FILTER (WHERE s.session_number > 1)::bigint AS replay_sessions,
COUNT(DISTINCT (s.user_id, s.content_kind, s.content_id))::bigint AS unique_video_starts,
(
SELECT COUNT(*)::bigint
FROM (
SELECT
s2.user_id,
s2.content_kind,
s2.content_id
FROM user_video_watch_sessions s2
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR s2.started_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR s2.started_at < sqlc.narg('range_end')::timestamptz)
GROUP BY s2.user_id, s2.content_kind, s2.content_id
HAVING MAX(s2.session_number) > 1
) replayed
) AS users_who_replayed
FROM user_video_watch_sessions s
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR s.started_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR s.started_at < sqlc.narg('range_end')::timestamptz);
-- name: AnalyticsVideoDropOffByCheckpoint :many
WITH filtered AS (
SELECT
s.max_position_sec,
s.video_duration_sec AS duration_sec
FROM user_video_watch_sessions s
WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR s.started_at >= sqlc.narg('range_start')::timestamptz)
AND (sqlc.narg('range_end')::timestamptz IS NULL OR s.started_at < sqlc.narg('range_end')::timestamptz)
AND s.video_duration_sec IS NOT NULL
AND s.video_duration_sec > 0
),
totals AS (
SELECT COUNT(*)::bigint AS total
FROM filtered
),
checkpoints AS (
SELECT unnest(ARRAY[10, 25, 50, 75, 90, 100])::int AS checkpoint_percent
)
SELECT
c.checkpoint_percent,
t.total AS total_sessions,
(
SELECT COUNT(*)::bigint
FROM filtered f
WHERE (f.max_position_sec * 100 / f.duration_sec) >= c.checkpoint_percent
) AS viewers_reached,
CASE
WHEN t.total = 0 THEN 0::float8
ELSE ROUND(
(
1.0 - (
SELECT COUNT(*)::float8
FROM filtered f
WHERE (f.max_position_sec * 100 / f.duration_sec) >= c.checkpoint_percent
) / t.total::float8
)::numeric,
4
)::float8
END AS drop_off_rate
FROM checkpoints c
CROSS JOIN totals t
ORDER BY c.checkpoint_percent;
-- ===================== -- =====================
-- Content Analytics -- Content Analytics
-- ===================== -- =====================

View File

@ -1,9 +1,10 @@
-- name: ExamPrepCreateCatalogCourse :one -- name: ExamPrepCreateCatalogCourse :one
INSERT INTO exam_prep.catalog_courses (name, description, thumbnail, sort_order) INSERT INTO exam_prep.catalog_courses (name, description, category, thumbnail, sort_order)
SELECT SELECT
$1, $1,
$2, $2,
$3, $3,
$4,
coalesce(( coalesce((
SELECT SELECT
max(c.sort_order) max(c.sort_order)
@ -42,6 +43,7 @@ SELECT
c.id, c.id,
c.name, c.name,
c.description, c.description,
c.category,
c.thumbnail, c.thumbnail,
c.sort_order, c.sort_order,
COALESCE(cc.units_count, 0)::BIGINT AS units_count, COALESCE(cc.units_count, 0)::BIGINT AS units_count,
@ -73,6 +75,7 @@ UPDATE exam_prep.catalog_courses
SET SET
name = coalesce(sqlc.narg('name')::varchar, name), name = coalesce(sqlc.narg('name')::varchar, name),
description = coalesce(sqlc.narg('description')::text, description), description = coalesce(sqlc.narg('description')::text, description),
category = coalesce(sqlc.narg('category')::varchar, category),
thumbnail = coalesce(sqlc.narg('thumbnail')::text, thumbnail), thumbnail = coalesce(sqlc.narg('thumbnail')::text, thumbnail),
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order), sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP

View File

@ -0,0 +1,119 @@
-- name: CountPublishedExamPrepPracticesInLesson :one
SELECT
count(*)::int AS n
FROM
exam_prep.lesson_practices p
INNER JOIN question_sets qs ON qs.id = p.question_set_id
WHERE
p.unit_module_lesson_id = $1
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND p.publish_status = 'PUBLISHED';
-- name: CountUserCompletedPublishedExamPrepPracticesInLesson :one
SELECT
count(*)::int AS n
FROM
exam_prep.lesson_practices p
INNER JOIN question_sets qs ON qs.id = p.question_set_id
INNER JOIN user_practice_progress upp ON upp.question_set_id = p.question_set_id
WHERE
p.unit_module_lesson_id = $1
AND upp.user_id = $2
AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND p.publish_status = 'PUBLISHED';
-- name: CountPublishedExamPrepPracticesInModule :one
SELECT
count(*)::int AS n
FROM
exam_prep.lesson_practices p
INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id
INNER JOIN question_sets qs ON qs.id = p.question_set_id
WHERE
l.unit_module_id = $1
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND p.publish_status = 'PUBLISHED';
-- name: CountUserCompletedPublishedExamPrepPracticesInModule :one
SELECT
count(*)::int AS n
FROM
exam_prep.lesson_practices p
INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id
INNER JOIN question_sets qs ON qs.id = p.question_set_id
INNER JOIN user_practice_progress upp ON upp.question_set_id = p.question_set_id
WHERE
l.unit_module_id = $1
AND upp.user_id = $2
AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND p.publish_status = 'PUBLISHED';
-- name: CountPublishedExamPrepPracticesInUnit :one
SELECT
count(*)::int AS n
FROM
exam_prep.lesson_practices p
INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id
INNER JOIN exam_prep.unit_modules m ON m.id = l.unit_module_id
INNER JOIN question_sets qs ON qs.id = p.question_set_id
WHERE
m.unit_id = $1
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND p.publish_status = 'PUBLISHED';
-- name: CountUserCompletedPublishedExamPrepPracticesInUnit :one
SELECT
count(*)::int AS n
FROM
exam_prep.lesson_practices p
INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id
INNER JOIN exam_prep.unit_modules m ON m.id = l.unit_module_id
INNER JOIN question_sets qs ON qs.id = p.question_set_id
INNER JOIN user_practice_progress upp ON upp.question_set_id = p.question_set_id
WHERE
m.unit_id = $1
AND upp.user_id = $2
AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND p.publish_status = 'PUBLISHED';
-- name: CountPublishedExamPrepPracticesInCatalogCourse :one
SELECT
count(*)::int AS n
FROM
exam_prep.lesson_practices p
INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id
INNER JOIN exam_prep.unit_modules m ON m.id = l.unit_module_id
INNER JOIN exam_prep.units u ON u.id = m.unit_id
INNER JOIN question_sets qs ON qs.id = p.question_set_id
WHERE
u.catalog_course_id = $1
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND p.publish_status = 'PUBLISHED';
-- name: CountUserCompletedPublishedExamPrepPracticesInCatalogCourse :one
SELECT
count(*)::int AS n
FROM
exam_prep.lesson_practices p
INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id
INNER JOIN exam_prep.unit_modules m ON m.id = l.unit_module_id
INNER JOIN exam_prep.units u ON u.id = m.unit_id
INNER JOIN question_sets qs ON qs.id = p.question_set_id
INNER JOIN user_practice_progress upp ON upp.question_set_id = p.question_set_id
WHERE
u.catalog_course_id = $1
AND upp.user_id = $2
AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND p.publish_status = 'PUBLISHED';

View File

@ -117,6 +117,33 @@ INSERT INTO lms_user_program_progress (user_id, program_id)
ON CONFLICT (user_id, program_id) ON CONFLICT (user_id, program_id)
DO NOTHING; DO NOTHING;
-- name: CountPublishedPracticesInLesson :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE
lp.lesson_id = $1
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED';
-- name: CountUserCompletedPublishedPracticesInLesson :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
WHERE
lp.lesson_id = $1
AND upp.user_id = $2
AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED';
-- name: CountLessonsInModule :one -- name: CountLessonsInModule :one
SELECT SELECT
count(*)::int AS n count(*)::int AS n
@ -175,47 +202,147 @@ WHERE
-- name: ListLMSCompletedLessonIDsByUser :many -- name: ListLMSCompletedLessonIDsByUser :many
SELECT SELECT
ulp.lesson_id lp.lesson_id
FROM FROM
lms_user_lesson_progress AS ulp lms_practices AS lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
LEFT JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
AND upp.user_id = $1
AND upp.completed_at IS NOT NULL
WHERE WHERE
ulp.user_id = $1 lp.lesson_id IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED'
GROUP BY
lp.lesson_id
HAVING
count(DISTINCT lp.question_set_id) > 0
AND count(DISTINCT upp.question_set_id) >= count(DISTINCT lp.question_set_id)
ORDER BY ORDER BY
ulp.completed_at ASC, max(upp.completed_at) ASC,
ulp.lesson_id ASC; lp.lesson_id ASC;
-- name: ListLMSCompletedModuleIDsByUser :many -- name: ListLMSCompletedModuleIDsByUser :many
SELECT SELECT
ump.module_id scoped.module_id
FROM FROM (
lms_user_module_progress AS ump SELECT
WHERE m.id AS module_id,
ump.user_id = $1 lp.question_set_id
FROM
modules m
INNER JOIN lms_practices lp ON (
lp.module_id = m.id
OR lp.lesson_id IN (
SELECT
id
FROM
lessons
WHERE
module_id = m.id))
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE
qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED') scoped
LEFT JOIN user_practice_progress upp ON upp.question_set_id = scoped.question_set_id
AND upp.user_id = $1
AND upp.completed_at IS NOT NULL
GROUP BY
scoped.module_id
HAVING
count(DISTINCT scoped.question_set_id) > 0
AND count(DISTINCT upp.question_set_id) >= count(DISTINCT scoped.question_set_id)
ORDER BY ORDER BY
ump.completed_at ASC, max(upp.completed_at) ASC,
ump.module_id ASC; scoped.module_id ASC;
-- name: ListLMSCompletedCourseIDsByUser :many -- name: ListLMSCompletedCourseIDsByUser :many
SELECT SELECT
ucp.course_id scoped.course_id
FROM FROM (
lms_user_course_progress AS ucp SELECT
WHERE c.id AS course_id,
ucp.user_id = $1 lp.question_set_id
FROM
courses c
INNER JOIN lms_practices lp ON (
lp.course_id = c.id
OR lp.module_id IN (
SELECT
id
FROM
modules
WHERE
course_id = c.id)
OR lp.lesson_id IN (
SELECT
l.id
FROM
lessons l
INNER JOIN modules m ON m.id = l.module_id
WHERE
m.course_id = c.id))
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE
qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED') scoped
LEFT JOIN user_practice_progress upp ON upp.question_set_id = scoped.question_set_id
AND upp.user_id = $1
AND upp.completed_at IS NOT NULL
GROUP BY
scoped.course_id
HAVING
count(DISTINCT scoped.question_set_id) > 0
AND count(DISTINCT upp.question_set_id) >= count(DISTINCT scoped.question_set_id)
ORDER BY ORDER BY
ucp.completed_at ASC, max(upp.completed_at) ASC,
ucp.course_id ASC; scoped.course_id ASC;
-- name: ListLMSCompletedProgramIDsByUser :many -- name: ListLMSCompletedProgramIDsByUser :many
SELECT SELECT
upp.program_id scoped.program_id
FROM FROM (
lms_user_program_progress AS upp SELECT
WHERE c.program_id,
upp.user_id = $1 lp.question_set_id
FROM
courses c
INNER JOIN lms_practices lp ON (
lp.course_id = c.id
OR lp.module_id IN (
SELECT
m.id
FROM
modules m
WHERE
m.course_id = c.id)
OR lp.lesson_id IN (
SELECT
l.id
FROM
lessons l
INNER JOIN modules m ON m.id = l.module_id
WHERE
m.course_id = c.id))
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE
qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED') scoped
LEFT JOIN user_practice_progress upp ON upp.question_set_id = scoped.question_set_id
AND upp.user_id = $1
AND upp.completed_at IS NOT NULL
GROUP BY
scoped.program_id
HAVING
count(DISTINCT scoped.question_set_id) > 0
AND count(DISTINCT upp.question_set_id) >= count(DISTINCT scoped.question_set_id)
ORDER BY ORDER BY
upp.completed_at ASC, max(upp.completed_at) ASC,
upp.program_id ASC; scoped.program_id ASC;
-- Lesson-based progress within a course (all modules). -- Lesson-based progress within a course (all modules).
-- name: CountLessonsInCourse :one -- name: CountLessonsInCourse :one
@ -265,7 +392,7 @@ WHERE
AND ulp.user_id = $2 AND ulp.user_id = $2
AND l.publish_status = 'PUBLISHED'; AND l.publish_status = 'PUBLISHED';
-- Published practices in a module (module-level and lesson-level practices should carry module_id). -- Published practices in a module (direct module practices and practices on lessons in the module).
-- name: CountPublishedPracticesInModule :one -- name: CountPublishedPracticesInModule :one
SELECT SELECT
count(*)::int AS n count(*)::int AS n
@ -273,7 +400,15 @@ FROM
lms_practices lp lms_practices lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE WHERE
lp.module_id = $1 (
lp.module_id = $1
OR lp.lesson_id IN (
SELECT
id
FROM
lessons
WHERE
module_id = $1))
AND qs.set_type = 'PRACTICE' AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED' AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED'; AND lp.publish_status = 'PUBLISHED';
@ -286,7 +421,15 @@ FROM
INNER JOIN question_sets qs ON qs.id = lp.question_set_id INNER JOIN question_sets qs ON qs.id = lp.question_set_id
INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
WHERE WHERE
lp.module_id = $1 (
lp.module_id = $1
OR lp.lesson_id IN (
SELECT
id
FROM
lessons
WHERE
module_id = $1))
AND upp.user_id = $2 AND upp.user_id = $2
AND upp.completed_at IS NOT NULL AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE' AND qs.set_type = 'PRACTICE'
@ -300,7 +443,23 @@ FROM
lms_practices lp lms_practices lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE WHERE
lp.course_id = $1 (
lp.course_id = $1
OR lp.module_id IN (
SELECT
id
FROM
modules
WHERE
course_id = $1)
OR lp.lesson_id IN (
SELECT
l.id
FROM
lessons l
INNER JOIN modules m ON m.id = l.module_id
WHERE
m.course_id = $1))
AND qs.set_type = 'PRACTICE' AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED' AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED'; AND lp.publish_status = 'PUBLISHED';
@ -313,21 +472,61 @@ FROM
INNER JOIN question_sets qs ON qs.id = lp.question_set_id INNER JOIN question_sets qs ON qs.id = lp.question_set_id
INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
WHERE WHERE
lp.course_id = $1 (
lp.course_id = $1
OR lp.module_id IN (
SELECT
id
FROM
modules
WHERE
course_id = $1)
OR lp.lesson_id IN (
SELECT
l.id
FROM
lessons l
INNER JOIN modules m ON m.id = l.module_id
WHERE
m.course_id = $1))
AND upp.user_id = $2 AND upp.user_id = $2
AND upp.completed_at IS NOT NULL AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE' AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'; AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED';
-- name: CountPublishedPracticesInProgram :one -- name: CountPublishedPracticesInProgram :one
SELECT SELECT
count(*)::int AS n count(*)::int AS n
FROM FROM
lms_practices lp lms_practices lp
INNER JOIN courses c ON c.id = lp.course_id
INNER JOIN question_sets qs ON qs.id = lp.question_set_id INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE WHERE
c.program_id = $1 (
lp.course_id IN (
SELECT
c.id
FROM
courses c
WHERE
c.program_id = $1)
OR lp.module_id IN (
SELECT
m.id
FROM
modules m
INNER JOIN courses c ON c.id = m.course_id
WHERE
c.program_id = $1)
OR lp.lesson_id IN (
SELECT
l.id
FROM
lessons l
INNER JOIN modules m ON m.id = l.module_id
INNER JOIN courses c ON c.id = m.course_id
WHERE
c.program_id = $1))
AND qs.set_type = 'PRACTICE' AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED' AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED'; AND lp.publish_status = 'PUBLISHED';
@ -337,11 +536,34 @@ SELECT
count(*)::int AS n count(*)::int AS n
FROM FROM
lms_practices lp lms_practices lp
INNER JOIN courses c ON c.id = lp.course_id
INNER JOIN question_sets qs ON qs.id = lp.question_set_id INNER JOIN question_sets qs ON qs.id = lp.question_set_id
INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
WHERE WHERE
c.program_id = $1 (
lp.course_id IN (
SELECT
c.id
FROM
courses c
WHERE
c.program_id = $1)
OR lp.module_id IN (
SELECT
m.id
FROM
modules m
INNER JOIN courses c ON c.id = m.course_id
WHERE
c.program_id = $1)
OR lp.lesson_id IN (
SELECT
l.id
FROM
lessons l
INNER JOIN modules m ON m.id = l.module_id
INNER JOIN courses c ON c.id = m.course_id
WHERE
c.program_id = $1))
AND upp.user_id = $2 AND upp.user_id = $2
AND upp.completed_at IS NOT NULL AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE' AND qs.set_type = 'PRACTICE'

View File

@ -1,8 +1,9 @@
-- name: CreateProgram :one -- name: CreateProgram :one
INSERT INTO programs (name, description, thumbnail, sort_order) INSERT INTO programs (name, description, category, thumbnail, sort_order)
SELECT SELECT
sqlc.arg('name'), sqlc.arg('name'),
sqlc.arg('description'), sqlc.arg('description'),
sqlc.arg('category'),
sqlc.arg('thumbnail'), sqlc.arg('thumbnail'),
COALESCE(sqlc.narg('sort_order')::int, COALESCE(( COALESCE(sqlc.narg('sort_order')::int, COALESCE((
SELECT SELECT
@ -30,6 +31,7 @@ SELECT
p.id, p.id,
p.name, p.name,
p.description, p.description,
p.category,
p.thumbnail, p.thumbnail,
p.sort_order, p.sort_order,
p.created_at, p.created_at,
@ -43,6 +45,7 @@ UPDATE programs
SET SET
name = COALESCE(sqlc.narg('name')::varchar, name), name = COALESCE(sqlc.narg('name')::varchar, name),
description = COALESCE(sqlc.narg('description')::text, description), description = COALESCE(sqlc.narg('description')::text, description),
category = COALESCE(sqlc.narg('category')::varchar, category),
thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail), thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail),
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order), sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP

View File

@ -4,9 +4,9 @@
-- name: CreateSubscriptionPlan :one -- name: CreateSubscriptionPlan :one
INSERT INTO subscription_plans ( INSERT INTO subscription_plans (
name, description, duration_value, duration_unit, price, currency, is_active name, description, category, duration_value, duration_unit, price, currency, is_active
) )
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, true)) VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE($8, true))
RETURNING *; RETURNING *;
-- name: GetSubscriptionPlanByID :one -- name: GetSubscriptionPlanByID :one
@ -25,15 +25,16 @@ ORDER BY price ASC;
-- name: UpdateSubscriptionPlan :exec -- name: UpdateSubscriptionPlan :exec
UPDATE subscription_plans UPDATE subscription_plans
SET SET
name = COALESCE($1, name), name = COALESCE(sqlc.narg('name')::varchar, name),
description = COALESCE($2, description), description = COALESCE(sqlc.narg('description')::text, description),
duration_value = COALESCE($3, duration_value), category = COALESCE(sqlc.narg('category')::varchar, category),
duration_unit = COALESCE($4, duration_unit), duration_value = COALESCE(sqlc.narg('duration_value')::int, duration_value),
price = COALESCE($5, price), duration_unit = COALESCE(sqlc.narg('duration_unit')::varchar, duration_unit),
currency = COALESCE($6, currency), price = COALESCE(sqlc.narg('price')::numeric, price),
is_active = COALESCE($7, is_active), currency = COALESCE(sqlc.narg('currency')::varchar, currency),
is_active = COALESCE(sqlc.narg('is_active')::boolean, is_active),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $8; WHERE id = sqlc.arg('id');
-- name: DeleteSubscriptionPlan :exec -- name: DeleteSubscriptionPlan :exec
DELETE FROM subscription_plans WHERE id = $1; DELETE FROM subscription_plans WHERE id = $1;
@ -186,6 +187,17 @@ SELECT EXISTS(
AND expires_at > CURRENT_TIMESTAMP AND expires_at > CURRENT_TIMESTAMP
) AS has_subscription; ) AS has_subscription;
-- name: HasActiveSubscriptionByCategory :one
SELECT EXISTS(
SELECT 1
FROM user_subscriptions us
JOIN subscription_plans sp ON sp.id = us.plan_id
WHERE us.user_id = $1
AND sp.category = $2
AND us.status = 'ACTIVE'
AND us.expires_at > CURRENT_TIMESTAMP
) AS has_subscription;
-- name: ExtendSubscription :exec -- name: ExtendSubscription :exec
UPDATE user_subscriptions UPDATE user_subscriptions
SET SET

View File

@ -98,22 +98,22 @@ WHERE (
-- name: UpdateTeamMember :exec -- name: UpdateTeamMember :exec
UPDATE team_members UPDATE team_members
SET SET
first_name = COALESCE($1, first_name), first_name = COALESCE(sqlc.narg('first_name')::VARCHAR, first_name),
last_name = COALESCE($2, last_name), last_name = COALESCE(sqlc.narg('last_name')::VARCHAR, last_name),
phone_number = COALESCE($3, phone_number), phone_number = COALESCE(sqlc.narg('phone_number'), phone_number),
team_role = COALESCE($4, team_role), team_role = COALESCE(sqlc.narg('team_role')::VARCHAR, team_role),
department = COALESCE($5, department), department = COALESCE(sqlc.narg('department'), department),
job_title = COALESCE($6, job_title), job_title = COALESCE(sqlc.narg('job_title'), job_title),
employment_type = COALESCE($7, employment_type), employment_type = COALESCE(sqlc.narg('employment_type'), employment_type),
hire_date = COALESCE($8, hire_date), hire_date = COALESCE(sqlc.narg('hire_date'), hire_date),
profile_picture_url = COALESCE($9, profile_picture_url), profile_picture_url = COALESCE(sqlc.narg('profile_picture_url'), profile_picture_url),
bio = COALESCE($10, bio), bio = COALESCE(sqlc.narg('bio'), bio),
work_phone = COALESCE($11, work_phone), work_phone = COALESCE(sqlc.narg('work_phone'), work_phone),
emergency_contact = COALESCE($12, emergency_contact), emergency_contact = COALESCE(sqlc.narg('emergency_contact'), emergency_contact),
permissions = COALESCE($13, permissions), permissions = COALESCE(sqlc.narg('permissions'), permissions),
updated_by = $14, updated_by = @updated_by,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $15; WHERE id = @id;
-- name: UpdateTeamMemberStatus :exec -- name: UpdateTeamMemberStatus :exec
UPDATE team_members UPDATE team_members

View File

@ -0,0 +1,74 @@
-- name: GetActiveVideoWatchSession :one
SELECT
id,
user_id,
content_kind,
content_id,
session_number,
video_duration_sec,
max_position_sec,
started_at,
last_heartbeat_at,
ended_at,
completed_at
FROM user_video_watch_sessions
WHERE user_id = $1
AND content_kind = $2
AND content_id = $3
AND ended_at IS NULL
AND last_heartbeat_at >= $4
ORDER BY session_number DESC
LIMIT 1;
-- name: GetMaxVideoWatchSessionNumber :one
SELECT
coalesce(max(session_number), 0)::int AS max_session_number
FROM user_video_watch_sessions
WHERE user_id = $1
AND content_kind = $2
AND content_id = $3;
-- name: InsertVideoWatchSession :one
INSERT INTO user_video_watch_sessions (
user_id,
content_kind,
content_id,
session_number,
video_duration_sec,
max_position_sec
)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING
id,
user_id,
content_kind,
content_id,
session_number,
video_duration_sec,
max_position_sec,
started_at,
last_heartbeat_at,
ended_at,
completed_at;
-- name: UpdateVideoWatchSession :one
UPDATE user_video_watch_sessions
SET
max_position_sec = $2,
video_duration_sec = $3,
last_heartbeat_at = $4,
completed_at = $5,
ended_at = $6
WHERE id = $1
RETURNING
id,
user_id,
content_kind,
content_id,
session_number,
video_duration_sec,
max_position_sec,
started_at,
last_heartbeat_at,
ended_at,
completed_at;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1080,6 +1080,88 @@ func (q *Queries) AnalyticsUsersByAgeGroup(ctx context.Context, arg AnalyticsUse
return items, nil return items, nil
} }
const AnalyticsUsersByCountry = `-- name: AnalyticsUsersByCountry :many
SELECT
COALESCE(NULLIF(TRIM(u.country), ''), 'unknown')::text AS country,
COUNT(*)::bigint AS count
FROM users u
WHERE ($1::timestamptz IS NULL OR u.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR u.created_at < $2::timestamptz)
GROUP BY COALESCE(NULLIF(TRIM(u.country), ''), 'unknown')
ORDER BY count DESC
`
type AnalyticsUsersByCountryParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsUsersByCountryRow struct {
Country string `json:"country"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsUsersByCountry(ctx context.Context, arg AnalyticsUsersByCountryParams) ([]AnalyticsUsersByCountryRow, error) {
rows, err := q.db.Query(ctx, AnalyticsUsersByCountry, arg.RangeStart, arg.RangeEnd)
if err != nil {
return nil, err
}
defer rows.Close()
var items []AnalyticsUsersByCountryRow
for rows.Next() {
var i AnalyticsUsersByCountryRow
if err := rows.Scan(&i.Country, &i.Count); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const AnalyticsUsersByEducationLevel = `-- name: AnalyticsUsersByEducationLevel :many
SELECT
COALESCE(NULLIF(TRIM(u.education_level), ''), 'unknown')::text AS education_level,
COUNT(*)::bigint AS count
FROM users u
WHERE ($1::timestamptz IS NULL OR u.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR u.created_at < $2::timestamptz)
GROUP BY COALESCE(NULLIF(TRIM(u.education_level), ''), 'unknown')
ORDER BY count DESC
`
type AnalyticsUsersByEducationLevelParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsUsersByEducationLevelRow struct {
EducationLevel string `json:"education_level"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsUsersByEducationLevel(ctx context.Context, arg AnalyticsUsersByEducationLevelParams) ([]AnalyticsUsersByEducationLevelRow, error) {
rows, err := q.db.Query(ctx, AnalyticsUsersByEducationLevel, arg.RangeStart, arg.RangeEnd)
if err != nil {
return nil, err
}
defer rows.Close()
var items []AnalyticsUsersByEducationLevelRow
for rows.Next() {
var i AnalyticsUsersByEducationLevelRow
if err := rows.Scan(&i.EducationLevel, &i.Count); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const AnalyticsUsersByKnowledgeLevel = `-- name: AnalyticsUsersByKnowledgeLevel :many const AnalyticsUsersByKnowledgeLevel = `-- name: AnalyticsUsersByKnowledgeLevel :many
SELECT SELECT
COALESCE(u.knowledge_level, 'unknown') AS knowledge_level, COALESCE(u.knowledge_level, 'unknown') AS knowledge_level,
@ -1121,6 +1203,129 @@ func (q *Queries) AnalyticsUsersByKnowledgeLevel(ctx context.Context, arg Analyt
return items, nil return items, nil
} }
const AnalyticsUsersByLanguageChallange = `-- name: AnalyticsUsersByLanguageChallange :many
SELECT
COALESCE(NULLIF(TRIM(u.language_challange), ''), 'unknown')::text AS language_challange,
COUNT(*)::bigint AS count
FROM users u
WHERE ($1::timestamptz IS NULL OR u.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR u.created_at < $2::timestamptz)
GROUP BY COALESCE(NULLIF(TRIM(u.language_challange), ''), 'unknown')
ORDER BY count DESC
`
type AnalyticsUsersByLanguageChallangeParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsUsersByLanguageChallangeRow struct {
LanguageChallange string `json:"language_challange"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsUsersByLanguageChallange(ctx context.Context, arg AnalyticsUsersByLanguageChallangeParams) ([]AnalyticsUsersByLanguageChallangeRow, error) {
rows, err := q.db.Query(ctx, AnalyticsUsersByLanguageChallange, arg.RangeStart, arg.RangeEnd)
if err != nil {
return nil, err
}
defer rows.Close()
var items []AnalyticsUsersByLanguageChallangeRow
for rows.Next() {
var i AnalyticsUsersByLanguageChallangeRow
if err := rows.Scan(&i.LanguageChallange, &i.Count); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const AnalyticsUsersByLearningGoal = `-- name: AnalyticsUsersByLearningGoal :many
SELECT
COALESCE(NULLIF(TRIM(u.learning_goal), ''), 'unknown')::text AS learning_goal,
COUNT(*)::bigint AS count
FROM users u
WHERE ($1::timestamptz IS NULL OR u.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR u.created_at < $2::timestamptz)
GROUP BY COALESCE(NULLIF(TRIM(u.learning_goal), ''), 'unknown')
ORDER BY count DESC
`
type AnalyticsUsersByLearningGoalParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsUsersByLearningGoalRow struct {
LearningGoal string `json:"learning_goal"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsUsersByLearningGoal(ctx context.Context, arg AnalyticsUsersByLearningGoalParams) ([]AnalyticsUsersByLearningGoalRow, error) {
rows, err := q.db.Query(ctx, AnalyticsUsersByLearningGoal, arg.RangeStart, arg.RangeEnd)
if err != nil {
return nil, err
}
defer rows.Close()
var items []AnalyticsUsersByLearningGoalRow
for rows.Next() {
var i AnalyticsUsersByLearningGoalRow
if err := rows.Scan(&i.LearningGoal, &i.Count); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const AnalyticsUsersByOccupation = `-- name: AnalyticsUsersByOccupation :many
SELECT
COALESCE(NULLIF(TRIM(u.occupation), ''), 'unknown')::text AS occupation,
COUNT(*)::bigint AS count
FROM users u
WHERE ($1::timestamptz IS NULL OR u.created_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR u.created_at < $2::timestamptz)
GROUP BY COALESCE(NULLIF(TRIM(u.occupation), ''), 'unknown')
ORDER BY count DESC
`
type AnalyticsUsersByOccupationParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsUsersByOccupationRow struct {
Occupation string `json:"occupation"`
Count int64 `json:"count"`
}
func (q *Queries) AnalyticsUsersByOccupation(ctx context.Context, arg AnalyticsUsersByOccupationParams) ([]AnalyticsUsersByOccupationRow, error) {
rows, err := q.db.Query(ctx, AnalyticsUsersByOccupation, arg.RangeStart, arg.RangeEnd)
if err != nil {
return nil, err
}
defer rows.Close()
var items []AnalyticsUsersByOccupationRow
for rows.Next() {
var i AnalyticsUsersByOccupationRow
if err := rows.Scan(&i.Occupation, &i.Count); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const AnalyticsUsersByRegion = `-- name: AnalyticsUsersByRegion :many const AnalyticsUsersByRegion = `-- name: AnalyticsUsersByRegion :many
SELECT SELECT
COALESCE(u.region, 'unknown') AS region, COALESCE(u.region, 'unknown') AS region,
@ -1305,3 +1510,139 @@ func (q *Queries) AnalyticsUsersSummary(ctx context.Context, arg AnalyticsUsersS
) )
return i, err return i, err
} }
const AnalyticsVideoDropOffByCheckpoint = `-- name: AnalyticsVideoDropOffByCheckpoint :many
WITH filtered AS (
SELECT
s.max_position_sec,
s.video_duration_sec AS duration_sec
FROM user_video_watch_sessions s
WHERE ($1::timestamptz IS NULL OR s.started_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR s.started_at < $2::timestamptz)
AND s.video_duration_sec IS NOT NULL
AND s.video_duration_sec > 0
),
totals AS (
SELECT COUNT(*)::bigint AS total
FROM filtered
),
checkpoints AS (
SELECT unnest(ARRAY[10, 25, 50, 75, 90, 100])::int AS checkpoint_percent
)
SELECT
c.checkpoint_percent,
t.total AS total_sessions,
(
SELECT COUNT(*)::bigint
FROM filtered f
WHERE (f.max_position_sec * 100 / f.duration_sec) >= c.checkpoint_percent
) AS viewers_reached,
CASE
WHEN t.total = 0 THEN 0::float8
ELSE ROUND(
(
1.0 - (
SELECT COUNT(*)::float8
FROM filtered f
WHERE (f.max_position_sec * 100 / f.duration_sec) >= c.checkpoint_percent
) / t.total::float8
)::numeric,
4
)::float8
END AS drop_off_rate
FROM checkpoints c
CROSS JOIN totals t
ORDER BY c.checkpoint_percent
`
type AnalyticsVideoDropOffByCheckpointParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsVideoDropOffByCheckpointRow struct {
CheckpointPercent int32 `json:"checkpoint_percent"`
TotalSessions int64 `json:"total_sessions"`
ViewersReached int64 `json:"viewers_reached"`
DropOffRate float64 `json:"drop_off_rate"`
}
func (q *Queries) AnalyticsVideoDropOffByCheckpoint(ctx context.Context, arg AnalyticsVideoDropOffByCheckpointParams) ([]AnalyticsVideoDropOffByCheckpointRow, error) {
rows, err := q.db.Query(ctx, AnalyticsVideoDropOffByCheckpoint, arg.RangeStart, arg.RangeEnd)
if err != nil {
return nil, err
}
defer rows.Close()
var items []AnalyticsVideoDropOffByCheckpointRow
for rows.Next() {
var i AnalyticsVideoDropOffByCheckpointRow
if err := rows.Scan(
&i.CheckpointPercent,
&i.TotalSessions,
&i.ViewersReached,
&i.DropOffRate,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const AnalyticsVideoEngagementSummary = `-- name: AnalyticsVideoEngagementSummary :one
SELECT
COUNT(*)::bigint AS total_sessions,
COUNT(*) FILTER (WHERE s.completed_at IS NOT NULL)::bigint AS completed_sessions,
COUNT(*) FILTER (WHERE s.session_number > 1)::bigint AS replay_sessions,
COUNT(DISTINCT (s.user_id, s.content_kind, s.content_id))::bigint AS unique_video_starts,
(
SELECT COUNT(*)::bigint
FROM (
SELECT
s2.user_id,
s2.content_kind,
s2.content_id
FROM user_video_watch_sessions s2
WHERE ($1::timestamptz IS NULL OR s2.started_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR s2.started_at < $2::timestamptz)
GROUP BY s2.user_id, s2.content_kind, s2.content_id
HAVING MAX(s2.session_number) > 1
) replayed
) AS users_who_replayed
FROM user_video_watch_sessions s
WHERE ($1::timestamptz IS NULL OR s.started_at >= $1::timestamptz)
AND ($2::timestamptz IS NULL OR s.started_at < $2::timestamptz)
`
type AnalyticsVideoEngagementSummaryParams struct {
RangeStart pgtype.Timestamptz `json:"range_start"`
RangeEnd pgtype.Timestamptz `json:"range_end"`
}
type AnalyticsVideoEngagementSummaryRow struct {
TotalSessions int64 `json:"total_sessions"`
CompletedSessions int64 `json:"completed_sessions"`
ReplaySessions int64 `json:"replay_sessions"`
UniqueVideoStarts int64 `json:"unique_video_starts"`
UsersWhoReplayed int64 `json:"users_who_replayed"`
}
// =====================
// Video Engagement Analytics
// =====================
func (q *Queries) AnalyticsVideoEngagementSummary(ctx context.Context, arg AnalyticsVideoEngagementSummaryParams) (AnalyticsVideoEngagementSummaryRow, error) {
row := q.db.QueryRow(ctx, AnalyticsVideoEngagementSummary, arg.RangeStart, arg.RangeEnd)
var i AnalyticsVideoEngagementSummaryRow
err := row.Scan(
&i.TotalSessions,
&i.CompletedSessions,
&i.ReplaySessions,
&i.UniqueVideoStarts,
&i.UsersWhoReplayed,
)
return i, err
}

View File

@ -12,27 +12,34 @@ import (
) )
const ExamPrepCreateCatalogCourse = `-- name: ExamPrepCreateCatalogCourse :one const ExamPrepCreateCatalogCourse = `-- name: ExamPrepCreateCatalogCourse :one
INSERT INTO exam_prep.catalog_courses (name, description, thumbnail, sort_order) INSERT INTO exam_prep.catalog_courses (name, description, category, thumbnail, sort_order)
SELECT SELECT
$1, $1,
$2, $2,
$3, $3,
$4,
coalesce(( coalesce((
SELECT SELECT
max(c.sort_order) max(c.sort_order)
FROM exam_prep.catalog_courses AS c), 0) + 1 FROM exam_prep.catalog_courses AS c), 0) + 1
RETURNING RETURNING
id, name, description, thumbnail, sort_order, created_at, updated_at id, name, description, thumbnail, sort_order, created_at, updated_at, category
` `
type ExamPrepCreateCatalogCourseParams struct { type ExamPrepCreateCatalogCourseParams struct {
Name string `json:"name"` Name string `json:"name"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
Category string `json:"category"`
Thumbnail pgtype.Text `json:"thumbnail"` Thumbnail pgtype.Text `json:"thumbnail"`
} }
func (q *Queries) ExamPrepCreateCatalogCourse(ctx context.Context, arg ExamPrepCreateCatalogCourseParams) (ExamPrepCatalogCourse, error) { func (q *Queries) ExamPrepCreateCatalogCourse(ctx context.Context, arg ExamPrepCreateCatalogCourseParams) (ExamPrepCatalogCourse, error) {
row := q.db.QueryRow(ctx, ExamPrepCreateCatalogCourse, arg.Name, arg.Description, arg.Thumbnail) row := q.db.QueryRow(ctx, ExamPrepCreateCatalogCourse,
arg.Name,
arg.Description,
arg.Category,
arg.Thumbnail,
)
var i ExamPrepCatalogCourse var i ExamPrepCatalogCourse
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
@ -42,6 +49,7 @@ func (q *Queries) ExamPrepCreateCatalogCourse(ctx context.Context, arg ExamPrepC
&i.SortOrder, &i.SortOrder,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.Category,
) )
return i, err return i, err
} }
@ -58,7 +66,7 @@ func (q *Queries) ExamPrepDeleteCatalogCourse(ctx context.Context, id int64) err
const ExamPrepGetCatalogCourseByID = `-- name: ExamPrepGetCatalogCourseByID :one const ExamPrepGetCatalogCourseByID = `-- name: ExamPrepGetCatalogCourseByID :one
SELECT SELECT
c.id, c.name, c.description, c.thumbnail, c.sort_order, c.created_at, c.updated_at, c.id, c.name, c.description, c.thumbnail, c.sort_order, c.created_at, c.updated_at, c.category,
EXISTS ( EXISTS (
SELECT 1 SELECT 1
FROM exam_prep.lesson_practices p FROM exam_prep.lesson_practices p
@ -79,6 +87,7 @@ type ExamPrepGetCatalogCourseByIDRow struct {
SortOrder int32 `json:"sort_order"` SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Category string `json:"category"`
HasPractice bool `json:"has_practice"` HasPractice bool `json:"has_practice"`
} }
@ -93,6 +102,7 @@ func (q *Queries) ExamPrepGetCatalogCourseByID(ctx context.Context, id int64) (E
&i.SortOrder, &i.SortOrder,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.Category,
&i.HasPractice, &i.HasPractice,
) )
return i, err return i, err
@ -142,6 +152,7 @@ SELECT
c.id, c.id,
c.name, c.name,
c.description, c.description,
c.category,
c.thumbnail, c.thumbnail,
c.sort_order, c.sort_order,
COALESCE(cc.units_count, 0)::BIGINT AS units_count, COALESCE(cc.units_count, 0)::BIGINT AS units_count,
@ -173,6 +184,7 @@ type ExamPrepListCatalogCoursesRow struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
Category string `json:"category"`
Thumbnail pgtype.Text `json:"thumbnail"` Thumbnail pgtype.Text `json:"thumbnail"`
SortOrder int32 `json:"sort_order"` SortOrder int32 `json:"sort_order"`
UnitsCount int64 `json:"units_count"` UnitsCount int64 `json:"units_count"`
@ -197,6 +209,7 @@ func (q *Queries) ExamPrepListCatalogCourses(ctx context.Context, arg ExamPrepLi
&i.ID, &i.ID,
&i.Name, &i.Name,
&i.Description, &i.Description,
&i.Category,
&i.Thumbnail, &i.Thumbnail,
&i.SortOrder, &i.SortOrder,
&i.UnitsCount, &i.UnitsCount,
@ -221,17 +234,19 @@ UPDATE exam_prep.catalog_courses
SET SET
name = coalesce($1::varchar, name), name = coalesce($1::varchar, name),
description = coalesce($2::text, description), description = coalesce($2::text, description),
thumbnail = coalesce($3::text, thumbnail), category = coalesce($3::varchar, category),
sort_order = coalesce($4::int, sort_order), thumbnail = coalesce($4::text, thumbnail),
sort_order = coalesce($5::int, sort_order),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $5 WHERE id = $6
RETURNING RETURNING
id, name, description, thumbnail, sort_order, created_at, updated_at id, name, description, thumbnail, sort_order, created_at, updated_at, category
` `
type ExamPrepUpdateCatalogCourseParams struct { type ExamPrepUpdateCatalogCourseParams struct {
Name pgtype.Text `json:"name"` Name pgtype.Text `json:"name"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
Category pgtype.Text `json:"category"`
Thumbnail pgtype.Text `json:"thumbnail"` Thumbnail pgtype.Text `json:"thumbnail"`
SortOrder pgtype.Int4 `json:"sort_order"` SortOrder pgtype.Int4 `json:"sort_order"`
ID int64 `json:"id"` ID int64 `json:"id"`
@ -241,6 +256,7 @@ func (q *Queries) ExamPrepUpdateCatalogCourse(ctx context.Context, arg ExamPrepU
row := q.db.QueryRow(ctx, ExamPrepUpdateCatalogCourse, row := q.db.QueryRow(ctx, ExamPrepUpdateCatalogCourse,
arg.Name, arg.Name,
arg.Description, arg.Description,
arg.Category,
arg.Thumbnail, arg.Thumbnail,
arg.SortOrder, arg.SortOrder,
arg.ID, arg.ID,
@ -254,6 +270,7 @@ func (q *Queries) ExamPrepUpdateCatalogCourse(ctx context.Context, arg ExamPrepU
&i.SortOrder, &i.SortOrder,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.Category,
) )
return i, err return i, err
} }

View File

@ -0,0 +1,214 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: exam_prep_progress.sql
package dbgen
import (
"context"
)
const CountPublishedExamPrepPracticesInCatalogCourse = `-- name: CountPublishedExamPrepPracticesInCatalogCourse :one
SELECT
count(*)::int AS n
FROM
exam_prep.lesson_practices p
INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id
INNER JOIN exam_prep.unit_modules m ON m.id = l.unit_module_id
INNER JOIN exam_prep.units u ON u.id = m.unit_id
INNER JOIN question_sets qs ON qs.id = p.question_set_id
WHERE
u.catalog_course_id = $1
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND p.publish_status = 'PUBLISHED'
`
func (q *Queries) CountPublishedExamPrepPracticesInCatalogCourse(ctx context.Context, catalogCourseID int64) (int32, error) {
row := q.db.QueryRow(ctx, CountPublishedExamPrepPracticesInCatalogCourse, catalogCourseID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountPublishedExamPrepPracticesInLesson = `-- name: CountPublishedExamPrepPracticesInLesson :one
SELECT
count(*)::int AS n
FROM
exam_prep.lesson_practices p
INNER JOIN question_sets qs ON qs.id = p.question_set_id
WHERE
p.unit_module_lesson_id = $1
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND p.publish_status = 'PUBLISHED'
`
func (q *Queries) CountPublishedExamPrepPracticesInLesson(ctx context.Context, unitModuleLessonID int64) (int32, error) {
row := q.db.QueryRow(ctx, CountPublishedExamPrepPracticesInLesson, unitModuleLessonID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountPublishedExamPrepPracticesInModule = `-- name: CountPublishedExamPrepPracticesInModule :one
SELECT
count(*)::int AS n
FROM
exam_prep.lesson_practices p
INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id
INNER JOIN question_sets qs ON qs.id = p.question_set_id
WHERE
l.unit_module_id = $1
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND p.publish_status = 'PUBLISHED'
`
func (q *Queries) CountPublishedExamPrepPracticesInModule(ctx context.Context, unitModuleID int64) (int32, error) {
row := q.db.QueryRow(ctx, CountPublishedExamPrepPracticesInModule, unitModuleID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountPublishedExamPrepPracticesInUnit = `-- name: CountPublishedExamPrepPracticesInUnit :one
SELECT
count(*)::int AS n
FROM
exam_prep.lesson_practices p
INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id
INNER JOIN exam_prep.unit_modules m ON m.id = l.unit_module_id
INNER JOIN question_sets qs ON qs.id = p.question_set_id
WHERE
m.unit_id = $1
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND p.publish_status = 'PUBLISHED'
`
func (q *Queries) CountPublishedExamPrepPracticesInUnit(ctx context.Context, unitID int64) (int32, error) {
row := q.db.QueryRow(ctx, CountPublishedExamPrepPracticesInUnit, unitID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountUserCompletedPublishedExamPrepPracticesInCatalogCourse = `-- name: CountUserCompletedPublishedExamPrepPracticesInCatalogCourse :one
SELECT
count(*)::int AS n
FROM
exam_prep.lesson_practices p
INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id
INNER JOIN exam_prep.unit_modules m ON m.id = l.unit_module_id
INNER JOIN exam_prep.units u ON u.id = m.unit_id
INNER JOIN question_sets qs ON qs.id = p.question_set_id
INNER JOIN user_practice_progress upp ON upp.question_set_id = p.question_set_id
WHERE
u.catalog_course_id = $1
AND upp.user_id = $2
AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND p.publish_status = 'PUBLISHED'
`
type CountUserCompletedPublishedExamPrepPracticesInCatalogCourseParams struct {
CatalogCourseID int64 `json:"catalog_course_id"`
UserID int64 `json:"user_id"`
}
func (q *Queries) CountUserCompletedPublishedExamPrepPracticesInCatalogCourse(ctx context.Context, arg CountUserCompletedPublishedExamPrepPracticesInCatalogCourseParams) (int32, error) {
row := q.db.QueryRow(ctx, CountUserCompletedPublishedExamPrepPracticesInCatalogCourse, arg.CatalogCourseID, arg.UserID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountUserCompletedPublishedExamPrepPracticesInLesson = `-- name: CountUserCompletedPublishedExamPrepPracticesInLesson :one
SELECT
count(*)::int AS n
FROM
exam_prep.lesson_practices p
INNER JOIN question_sets qs ON qs.id = p.question_set_id
INNER JOIN user_practice_progress upp ON upp.question_set_id = p.question_set_id
WHERE
p.unit_module_lesson_id = $1
AND upp.user_id = $2
AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND p.publish_status = 'PUBLISHED'
`
type CountUserCompletedPublishedExamPrepPracticesInLessonParams struct {
UnitModuleLessonID int64 `json:"unit_module_lesson_id"`
UserID int64 `json:"user_id"`
}
func (q *Queries) CountUserCompletedPublishedExamPrepPracticesInLesson(ctx context.Context, arg CountUserCompletedPublishedExamPrepPracticesInLessonParams) (int32, error) {
row := q.db.QueryRow(ctx, CountUserCompletedPublishedExamPrepPracticesInLesson, arg.UnitModuleLessonID, arg.UserID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountUserCompletedPublishedExamPrepPracticesInModule = `-- name: CountUserCompletedPublishedExamPrepPracticesInModule :one
SELECT
count(*)::int AS n
FROM
exam_prep.lesson_practices p
INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id
INNER JOIN question_sets qs ON qs.id = p.question_set_id
INNER JOIN user_practice_progress upp ON upp.question_set_id = p.question_set_id
WHERE
l.unit_module_id = $1
AND upp.user_id = $2
AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND p.publish_status = 'PUBLISHED'
`
type CountUserCompletedPublishedExamPrepPracticesInModuleParams struct {
UnitModuleID int64 `json:"unit_module_id"`
UserID int64 `json:"user_id"`
}
func (q *Queries) CountUserCompletedPublishedExamPrepPracticesInModule(ctx context.Context, arg CountUserCompletedPublishedExamPrepPracticesInModuleParams) (int32, error) {
row := q.db.QueryRow(ctx, CountUserCompletedPublishedExamPrepPracticesInModule, arg.UnitModuleID, arg.UserID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountUserCompletedPublishedExamPrepPracticesInUnit = `-- name: CountUserCompletedPublishedExamPrepPracticesInUnit :one
SELECT
count(*)::int AS n
FROM
exam_prep.lesson_practices p
INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id
INNER JOIN exam_prep.unit_modules m ON m.id = l.unit_module_id
INNER JOIN question_sets qs ON qs.id = p.question_set_id
INNER JOIN user_practice_progress upp ON upp.question_set_id = p.question_set_id
WHERE
m.unit_id = $1
AND upp.user_id = $2
AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND p.publish_status = 'PUBLISHED'
`
type CountUserCompletedPublishedExamPrepPracticesInUnitParams struct {
UnitID int64 `json:"unit_id"`
UserID int64 `json:"user_id"`
}
func (q *Queries) CountUserCompletedPublishedExamPrepPracticesInUnit(ctx context.Context, arg CountUserCompletedPublishedExamPrepPracticesInUnitParams) (int32, error) {
row := q.db.QueryRow(ctx, CountUserCompletedPublishedExamPrepPracticesInUnit, arg.UnitID, arg.UserID)
var n int32
err := row.Scan(&n)
return n, err
}

View File

@ -106,7 +106,23 @@ FROM
lms_practices lp lms_practices lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE WHERE
lp.course_id = $1 (
lp.course_id = $1
OR lp.module_id IN (
SELECT
id
FROM
modules
WHERE
course_id = $1)
OR lp.lesson_id IN (
SELECT
l.id
FROM
lessons l
INNER JOIN modules m ON m.id = l.module_id
WHERE
m.course_id = $1))
AND qs.set_type = 'PRACTICE' AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED' AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED' AND lp.publish_status = 'PUBLISHED'
@ -119,6 +135,26 @@ func (q *Queries) CountPublishedPracticesInCourse(ctx context.Context, courseID
return n, err return n, err
} }
const CountPublishedPracticesInLesson = `-- name: CountPublishedPracticesInLesson :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE
lp.lesson_id = $1
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED'
`
func (q *Queries) CountPublishedPracticesInLesson(ctx context.Context, lessonID pgtype.Int8) (int32, error) {
row := q.db.QueryRow(ctx, CountPublishedPracticesInLesson, lessonID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountPublishedPracticesInModule = `-- name: CountPublishedPracticesInModule :one const CountPublishedPracticesInModule = `-- name: CountPublishedPracticesInModule :one
SELECT SELECT
count(*)::int AS n count(*)::int AS n
@ -126,13 +162,21 @@ FROM
lms_practices lp lms_practices lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE WHERE
lp.module_id = $1 (
lp.module_id = $1
OR lp.lesson_id IN (
SELECT
id
FROM
lessons
WHERE
module_id = $1))
AND qs.set_type = 'PRACTICE' AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED' AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED' AND lp.publish_status = 'PUBLISHED'
` `
// Published practices in a module (module-level and lesson-level practices should carry module_id). // Published practices in a module (direct module practices and practices on lessons in the module).
func (q *Queries) CountPublishedPracticesInModule(ctx context.Context, moduleID pgtype.Int8) (int32, error) { func (q *Queries) CountPublishedPracticesInModule(ctx context.Context, moduleID pgtype.Int8) (int32, error) {
row := q.db.QueryRow(ctx, CountPublishedPracticesInModule, moduleID) row := q.db.QueryRow(ctx, CountPublishedPracticesInModule, moduleID)
var n int32 var n int32
@ -145,10 +189,33 @@ SELECT
count(*)::int AS n count(*)::int AS n
FROM FROM
lms_practices lp lms_practices lp
INNER JOIN courses c ON c.id = lp.course_id
INNER JOIN question_sets qs ON qs.id = lp.question_set_id INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE WHERE
c.program_id = $1 (
lp.course_id IN (
SELECT
c.id
FROM
courses c
WHERE
c.program_id = $1)
OR lp.module_id IN (
SELECT
m.id
FROM
modules m
INNER JOIN courses c ON c.id = m.course_id
WHERE
c.program_id = $1)
OR lp.lesson_id IN (
SELECT
l.id
FROM
lessons l
INNER JOIN modules m ON m.id = l.module_id
INNER JOIN courses c ON c.id = m.course_id
WHERE
c.program_id = $1))
AND qs.set_type = 'PRACTICE' AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED' AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED' AND lp.publish_status = 'PUBLISHED'
@ -290,11 +357,28 @@ FROM
INNER JOIN question_sets qs ON qs.id = lp.question_set_id INNER JOIN question_sets qs ON qs.id = lp.question_set_id
INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
WHERE WHERE
lp.course_id = $1 (
lp.course_id = $1
OR lp.module_id IN (
SELECT
id
FROM
modules
WHERE
course_id = $1)
OR lp.lesson_id IN (
SELECT
l.id
FROM
lessons l
INNER JOIN modules m ON m.id = l.module_id
WHERE
m.course_id = $1))
AND upp.user_id = $2 AND upp.user_id = $2
AND upp.completed_at IS NOT NULL AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE' AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED' AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED'
` `
type CountUserCompletedPublishedPracticesInCourseParams struct { type CountUserCompletedPublishedPracticesInCourseParams struct {
@ -309,6 +393,34 @@ func (q *Queries) CountUserCompletedPublishedPracticesInCourse(ctx context.Conte
return n, err return n, err
} }
const CountUserCompletedPublishedPracticesInLesson = `-- name: CountUserCompletedPublishedPracticesInLesson :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
WHERE
lp.lesson_id = $1
AND upp.user_id = $2
AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED'
`
type CountUserCompletedPublishedPracticesInLessonParams struct {
LessonID pgtype.Int8 `json:"lesson_id"`
UserID int64 `json:"user_id"`
}
func (q *Queries) CountUserCompletedPublishedPracticesInLesson(ctx context.Context, arg CountUserCompletedPublishedPracticesInLessonParams) (int32, error) {
row := q.db.QueryRow(ctx, CountUserCompletedPublishedPracticesInLesson, arg.LessonID, arg.UserID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountUserCompletedPublishedPracticesInModule = `-- name: CountUserCompletedPublishedPracticesInModule :one const CountUserCompletedPublishedPracticesInModule = `-- name: CountUserCompletedPublishedPracticesInModule :one
SELECT SELECT
count(*)::int AS n count(*)::int AS n
@ -317,7 +429,15 @@ FROM
INNER JOIN question_sets qs ON qs.id = lp.question_set_id INNER JOIN question_sets qs ON qs.id = lp.question_set_id
INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
WHERE WHERE
lp.module_id = $1 (
lp.module_id = $1
OR lp.lesson_id IN (
SELECT
id
FROM
lessons
WHERE
module_id = $1))
AND upp.user_id = $2 AND upp.user_id = $2
AND upp.completed_at IS NOT NULL AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE' AND qs.set_type = 'PRACTICE'
@ -342,11 +462,34 @@ SELECT
count(*)::int AS n count(*)::int AS n
FROM FROM
lms_practices lp lms_practices lp
INNER JOIN courses c ON c.id = lp.course_id
INNER JOIN question_sets qs ON qs.id = lp.question_set_id INNER JOIN question_sets qs ON qs.id = lp.question_set_id
INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
WHERE WHERE
c.program_id = $1 (
lp.course_id IN (
SELECT
c.id
FROM
courses c
WHERE
c.program_id = $1)
OR lp.module_id IN (
SELECT
m.id
FROM
modules m
INNER JOIN courses c ON c.id = m.course_id
WHERE
c.program_id = $1)
OR lp.lesson_id IN (
SELECT
l.id
FROM
lessons l
INNER JOIN modules m ON m.id = l.module_id
INNER JOIN courses c ON c.id = m.course_id
WHERE
c.program_id = $1))
AND upp.user_id = $2 AND upp.user_id = $2
AND upp.completed_at IS NOT NULL AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE' AND qs.set_type = 'PRACTICE'
@ -498,7 +641,7 @@ func (q *Queries) GetPreviousModuleInCourse(ctx context.Context, id int64) (Modu
const GetPreviousProgram = `-- name: GetPreviousProgram :one const GetPreviousProgram = `-- name: GetPreviousProgram :one
SELECT SELECT
p2.id, p2.name, p2.description, p2.thumbnail, p2.created_at, p2.updated_at, p2.sort_order p2.id, p2.name, p2.description, p2.thumbnail, p2.created_at, p2.updated_at, p2.sort_order, p2.category
FROM FROM
programs AS p1 programs AS p1
INNER JOIN programs AS p2 ON p2.sort_order = p1.sort_order - 1 INNER JOIN programs AS p2 ON p2.sort_order = p1.sort_order - 1
@ -517,6 +660,7 @@ func (q *Queries) GetPreviousProgram(ctx context.Context, id int64) (Program, er
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SortOrder, &i.SortOrder,
&i.Category,
) )
return i, err return i, err
} }
@ -591,14 +735,46 @@ func (q *Queries) InsertUserProgramProgress(ctx context.Context, arg InsertUserP
const ListLMSCompletedCourseIDsByUser = `-- name: ListLMSCompletedCourseIDsByUser :many const ListLMSCompletedCourseIDsByUser = `-- name: ListLMSCompletedCourseIDsByUser :many
SELECT SELECT
ucp.course_id scoped.course_id
FROM FROM (
lms_user_course_progress AS ucp SELECT
WHERE c.id AS course_id,
ucp.user_id = $1 lp.question_set_id
FROM
courses c
INNER JOIN lms_practices lp ON (
lp.course_id = c.id
OR lp.module_id IN (
SELECT
id
FROM
modules
WHERE
course_id = c.id)
OR lp.lesson_id IN (
SELECT
l.id
FROM
lessons l
INNER JOIN modules m ON m.id = l.module_id
WHERE
m.course_id = c.id))
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE
qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED') scoped
LEFT JOIN user_practice_progress upp ON upp.question_set_id = scoped.question_set_id
AND upp.user_id = $1
AND upp.completed_at IS NOT NULL
GROUP BY
scoped.course_id
HAVING
count(DISTINCT scoped.question_set_id) > 0
AND count(DISTINCT upp.question_set_id) >= count(DISTINCT scoped.question_set_id)
ORDER BY ORDER BY
ucp.completed_at ASC, max(upp.completed_at) ASC,
ucp.course_id ASC scoped.course_id ASC
` `
func (q *Queries) ListLMSCompletedCourseIDsByUser(ctx context.Context, userID int64) ([]int64, error) { func (q *Queries) ListLMSCompletedCourseIDsByUser(ctx context.Context, userID int64) ([]int64, error) {
@ -623,25 +799,37 @@ func (q *Queries) ListLMSCompletedCourseIDsByUser(ctx context.Context, userID in
const ListLMSCompletedLessonIDsByUser = `-- name: ListLMSCompletedLessonIDsByUser :many const ListLMSCompletedLessonIDsByUser = `-- name: ListLMSCompletedLessonIDsByUser :many
SELECT SELECT
ulp.lesson_id lp.lesson_id
FROM FROM
lms_user_lesson_progress AS ulp lms_practices AS lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
LEFT JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
AND upp.user_id = $1
AND upp.completed_at IS NOT NULL
WHERE WHERE
ulp.user_id = $1 lp.lesson_id IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED'
GROUP BY
lp.lesson_id
HAVING
count(DISTINCT lp.question_set_id) > 0
AND count(DISTINCT upp.question_set_id) >= count(DISTINCT lp.question_set_id)
ORDER BY ORDER BY
ulp.completed_at ASC, max(upp.completed_at) ASC,
ulp.lesson_id ASC lp.lesson_id ASC
` `
func (q *Queries) ListLMSCompletedLessonIDsByUser(ctx context.Context, userID int64) ([]int64, error) { func (q *Queries) ListLMSCompletedLessonIDsByUser(ctx context.Context, userID int64) ([]pgtype.Int8, error) {
rows, err := q.db.Query(ctx, ListLMSCompletedLessonIDsByUser, userID) rows, err := q.db.Query(ctx, ListLMSCompletedLessonIDsByUser, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []int64 var items []pgtype.Int8
for rows.Next() { for rows.Next() {
var lesson_id int64 var lesson_id pgtype.Int8
if err := rows.Scan(&lesson_id); err != nil { if err := rows.Scan(&lesson_id); err != nil {
return nil, err return nil, err
} }
@ -655,14 +843,38 @@ func (q *Queries) ListLMSCompletedLessonIDsByUser(ctx context.Context, userID in
const ListLMSCompletedModuleIDsByUser = `-- name: ListLMSCompletedModuleIDsByUser :many const ListLMSCompletedModuleIDsByUser = `-- name: ListLMSCompletedModuleIDsByUser :many
SELECT SELECT
ump.module_id scoped.module_id
FROM FROM (
lms_user_module_progress AS ump SELECT
WHERE m.id AS module_id,
ump.user_id = $1 lp.question_set_id
FROM
modules m
INNER JOIN lms_practices lp ON (
lp.module_id = m.id
OR lp.lesson_id IN (
SELECT
id
FROM
lessons
WHERE
module_id = m.id))
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE
qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED') scoped
LEFT JOIN user_practice_progress upp ON upp.question_set_id = scoped.question_set_id
AND upp.user_id = $1
AND upp.completed_at IS NOT NULL
GROUP BY
scoped.module_id
HAVING
count(DISTINCT scoped.question_set_id) > 0
AND count(DISTINCT upp.question_set_id) >= count(DISTINCT scoped.question_set_id)
ORDER BY ORDER BY
ump.completed_at ASC, max(upp.completed_at) ASC,
ump.module_id ASC scoped.module_id ASC
` `
func (q *Queries) ListLMSCompletedModuleIDsByUser(ctx context.Context, userID int64) ([]int64, error) { func (q *Queries) ListLMSCompletedModuleIDsByUser(ctx context.Context, userID int64) ([]int64, error) {
@ -687,14 +899,46 @@ func (q *Queries) ListLMSCompletedModuleIDsByUser(ctx context.Context, userID in
const ListLMSCompletedProgramIDsByUser = `-- name: ListLMSCompletedProgramIDsByUser :many const ListLMSCompletedProgramIDsByUser = `-- name: ListLMSCompletedProgramIDsByUser :many
SELECT SELECT
upp.program_id scoped.program_id
FROM FROM (
lms_user_program_progress AS upp SELECT
WHERE c.program_id,
upp.user_id = $1 lp.question_set_id
FROM
courses c
INNER JOIN lms_practices lp ON (
lp.course_id = c.id
OR lp.module_id IN (
SELECT
m.id
FROM
modules m
WHERE
m.course_id = c.id)
OR lp.lesson_id IN (
SELECT
l.id
FROM
lessons l
INNER JOIN modules m ON m.id = l.module_id
WHERE
m.course_id = c.id))
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE
qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED') scoped
LEFT JOIN user_practice_progress upp ON upp.question_set_id = scoped.question_set_id
AND upp.user_id = $1
AND upp.completed_at IS NOT NULL
GROUP BY
scoped.program_id
HAVING
count(DISTINCT scoped.question_set_id) > 0
AND count(DISTINCT upp.question_set_id) >= count(DISTINCT scoped.question_set_id)
ORDER BY ORDER BY
upp.completed_at ASC, max(upp.completed_at) ASC,
upp.program_id ASC scoped.program_id ASC
` `
func (q *Queries) ListLMSCompletedProgramIDsByUser(ctx context.Context, userID int64) ([]int64, error) { func (q *Queries) ListLMSCompletedProgramIDsByUser(ctx context.Context, userID int64) ([]int64, error) {

View File

@ -65,6 +65,7 @@ type ExamPrepCatalogCourse struct {
SortOrder int32 `json:"sort_order"` SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Category string `json:"category"`
} }
type ExamPrepLessonPractice struct { type ExamPrepLessonPractice struct {
@ -127,6 +128,17 @@ type Faq struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
} }
type FieldOption struct {
ID int64 `json:"id"`
FieldKey string `json:"field_key"`
Code string `json:"code"`
Label string `json:"label"`
DisplayOrder int32 `json:"display_order"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type GlobalSetting struct { type GlobalSetting struct {
Key string `json:"key"` Key string `json:"key"`
Value string `json:"value"` Value string `json:"value"`
@ -203,6 +215,20 @@ type LmsUserProgramProgress struct {
CompletedAt pgtype.Timestamptz `json:"completed_at"` CompletedAt pgtype.Timestamptz `json:"completed_at"`
} }
type MobileAppVersion struct {
ID int64 `json:"id"`
Platform string `json:"platform"`
VersionName string `json:"version_name"`
VersionCode int32 `json:"version_code"`
UpdateType string `json:"update_type"`
ReleaseNotes pgtype.Text `json:"release_notes"`
StoreUrl pgtype.Text `json:"store_url"`
MinSupportedVersionCode pgtype.Int4 `json:"min_supported_version_code"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type Module struct { type Module struct {
ID int64 `json:"id"` ID int64 `json:"id"`
ProgramID int64 `json:"program_id"` ProgramID int64 `json:"program_id"`
@ -284,6 +310,7 @@ type Program struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SortOrder int32 `json:"sort_order"` SortOrder int32 `json:"sort_order"`
Category string `json:"category"`
} }
type Question struct { type Question struct {
@ -457,6 +484,7 @@ type SubscriptionPlan struct {
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Category string `json:"category"`
} }
type TeamInvitation struct { type TeamInvitation struct {
@ -577,3 +605,17 @@ type UserSubscription struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
} }
type UserVideoWatchSession struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
ContentKind string `json:"content_kind"`
ContentID int64 `json:"content_id"`
SessionNumber int32 `json:"session_number"`
VideoDurationSec pgtype.Int4 `json:"video_duration_sec"`
MaxPositionSec int32 `json:"max_position_sec"`
StartedAt pgtype.Timestamptz `json:"started_at"`
LastHeartbeatAt pgtype.Timestamptz `json:"last_heartbeat_at"`
EndedAt pgtype.Timestamptz `json:"ended_at"`
CompletedAt pgtype.Timestamptz `json:"completed_at"`
}

View File

@ -12,22 +12,24 @@ import (
) )
const CreateProgram = `-- name: CreateProgram :one const CreateProgram = `-- name: CreateProgram :one
INSERT INTO programs (name, description, thumbnail, sort_order) INSERT INTO programs (name, description, category, thumbnail, sort_order)
SELECT SELECT
$1, $1,
$2, $2,
$3, $3,
COALESCE($4::int, COALESCE(( $4,
COALESCE($5::int, COALESCE((
SELECT SELECT
max(p.sort_order) max(p.sort_order)
FROM programs AS p), 0) + 1) FROM programs AS p), 0) + 1)
RETURNING RETURNING
id, name, description, thumbnail, created_at, updated_at, sort_order id, name, description, thumbnail, created_at, updated_at, sort_order, category
` `
type CreateProgramParams struct { type CreateProgramParams struct {
Name string `json:"name"` Name string `json:"name"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
Category string `json:"category"`
Thumbnail pgtype.Text `json:"thumbnail"` Thumbnail pgtype.Text `json:"thumbnail"`
SortOrder pgtype.Int4 `json:"sort_order"` SortOrder pgtype.Int4 `json:"sort_order"`
} }
@ -36,6 +38,7 @@ func (q *Queries) CreateProgram(ctx context.Context, arg CreateProgramParams) (P
row := q.db.QueryRow(ctx, CreateProgram, row := q.db.QueryRow(ctx, CreateProgram,
arg.Name, arg.Name,
arg.Description, arg.Description,
arg.Category,
arg.Thumbnail, arg.Thumbnail,
arg.SortOrder, arg.SortOrder,
) )
@ -48,6 +51,7 @@ func (q *Queries) CreateProgram(ctx context.Context, arg CreateProgramParams) (P
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SortOrder, &i.SortOrder,
&i.Category,
) )
return i, err return i, err
} }
@ -63,7 +67,7 @@ func (q *Queries) DeleteProgram(ctx context.Context, id int64) error {
} }
const GetProgramByID = `-- name: GetProgramByID :one const GetProgramByID = `-- name: GetProgramByID :one
SELECT id, name, description, thumbnail, created_at, updated_at, sort_order SELECT id, name, description, thumbnail, created_at, updated_at, sort_order, category
FROM programs FROM programs
WHERE id = $1 WHERE id = $1
` `
@ -79,6 +83,7 @@ func (q *Queries) GetProgramByID(ctx context.Context, id int64) (Program, error)
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SortOrder, &i.SortOrder,
&i.Category,
) )
return i, err return i, err
} }
@ -118,6 +123,7 @@ SELECT
p.id, p.id,
p.name, p.name,
p.description, p.description,
p.category,
p.thumbnail, p.thumbnail,
p.sort_order, p.sort_order,
p.created_at, p.created_at,
@ -137,6 +143,7 @@ type ListProgramsRow struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
Category string `json:"category"`
Thumbnail pgtype.Text `json:"thumbnail"` Thumbnail pgtype.Text `json:"thumbnail"`
SortOrder int32 `json:"sort_order"` SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
@ -157,6 +164,7 @@ func (q *Queries) ListPrograms(ctx context.Context, arg ListProgramsParams) ([]L
&i.ID, &i.ID,
&i.Name, &i.Name,
&i.Description, &i.Description,
&i.Category,
&i.Thumbnail, &i.Thumbnail,
&i.SortOrder, &i.SortOrder,
&i.CreatedAt, &i.CreatedAt,
@ -177,18 +185,20 @@ UPDATE programs
SET SET
name = COALESCE($1::varchar, name), name = COALESCE($1::varchar, name),
description = COALESCE($2::text, description), description = COALESCE($2::text, description),
thumbnail = COALESCE($3::text, thumbnail), category = COALESCE($3::varchar, category),
sort_order = coalesce($4::int, sort_order), thumbnail = COALESCE($4::text, thumbnail),
sort_order = coalesce($5::int, sort_order),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE WHERE
id = $5 id = $6
RETURNING RETURNING
id, name, description, thumbnail, created_at, updated_at, sort_order id, name, description, thumbnail, created_at, updated_at, sort_order, category
` `
type UpdateProgramParams struct { type UpdateProgramParams struct {
Name pgtype.Text `json:"name"` Name pgtype.Text `json:"name"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
Category pgtype.Text `json:"category"`
Thumbnail pgtype.Text `json:"thumbnail"` Thumbnail pgtype.Text `json:"thumbnail"`
SortOrder pgtype.Int4 `json:"sort_order"` SortOrder pgtype.Int4 `json:"sort_order"`
ID int64 `json:"id"` ID int64 `json:"id"`
@ -198,6 +208,7 @@ func (q *Queries) UpdateProgram(ctx context.Context, arg UpdateProgramParams) (P
row := q.db.QueryRow(ctx, UpdateProgram, row := q.db.QueryRow(ctx, UpdateProgram,
arg.Name, arg.Name,
arg.Description, arg.Description,
arg.Category,
arg.Thumbnail, arg.Thumbnail,
arg.SortOrder, arg.SortOrder,
arg.ID, arg.ID,
@ -211,6 +222,7 @@ func (q *Queries) UpdateProgram(ctx context.Context, arg UpdateProgramParams) (P
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SortOrder, &i.SortOrder,
&i.Category,
) )
return i, err return i, err
} }

View File

@ -40,20 +40,21 @@ func (q *Queries) CountUserSubscriptions(ctx context.Context, userID int64) (int
const CreateSubscriptionPlan = `-- name: CreateSubscriptionPlan :one const CreateSubscriptionPlan = `-- name: CreateSubscriptionPlan :one
INSERT INTO subscription_plans ( INSERT INTO subscription_plans (
name, description, duration_value, duration_unit, price, currency, is_active name, description, category, duration_value, duration_unit, price, currency, is_active
) )
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, true)) VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE($8, true))
RETURNING id, name, description, duration_value, duration_unit, price, currency, is_active, created_at, updated_at RETURNING id, name, description, duration_value, duration_unit, price, currency, is_active, created_at, updated_at, category
` `
type CreateSubscriptionPlanParams struct { type CreateSubscriptionPlanParams struct {
Name string `json:"name"` Name string `json:"name"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
Category string `json:"category"`
DurationValue int32 `json:"duration_value"` DurationValue int32 `json:"duration_value"`
DurationUnit string `json:"duration_unit"` DurationUnit string `json:"duration_unit"`
Price pgtype.Numeric `json:"price"` Price pgtype.Numeric `json:"price"`
Currency string `json:"currency"` Currency string `json:"currency"`
Column7 interface{} `json:"column_7"` Column8 interface{} `json:"column_8"`
} }
// ===================== // =====================
@ -63,11 +64,12 @@ func (q *Queries) CreateSubscriptionPlan(ctx context.Context, arg CreateSubscrip
row := q.db.QueryRow(ctx, CreateSubscriptionPlan, row := q.db.QueryRow(ctx, CreateSubscriptionPlan,
arg.Name, arg.Name,
arg.Description, arg.Description,
arg.Category,
arg.DurationValue, arg.DurationValue,
arg.DurationUnit, arg.DurationUnit,
arg.Price, arg.Price,
arg.Currency, arg.Currency,
arg.Column7, arg.Column8,
) )
var i SubscriptionPlan var i SubscriptionPlan
err := row.Scan( err := row.Scan(
@ -81,6 +83,7 @@ func (q *Queries) CreateSubscriptionPlan(ctx context.Context, arg CreateSubscrip
&i.IsActive, &i.IsActive,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.Category,
) )
return i, err return i, err
} }
@ -387,7 +390,7 @@ func (q *Queries) GetSubscriptionDisplayStatusByUserID(ctx context.Context, user
} }
const GetSubscriptionPlanByID = `-- name: GetSubscriptionPlanByID :one 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 SELECT id, name, description, duration_value, duration_unit, price, currency, is_active, created_at, updated_at, category FROM subscription_plans WHERE id = $1
` `
func (q *Queries) GetSubscriptionPlanByID(ctx context.Context, id int64) (SubscriptionPlan, error) { func (q *Queries) GetSubscriptionPlanByID(ctx context.Context, id int64) (SubscriptionPlan, error) {
@ -404,6 +407,7 @@ func (q *Queries) GetSubscriptionPlanByID(ctx context.Context, id int64) (Subscr
&i.IsActive, &i.IsActive,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.Category,
) )
return i, err return i, err
} }
@ -562,8 +566,32 @@ func (q *Queries) HasActiveSubscription(ctx context.Context, userID int64) (bool
return has_subscription, err return has_subscription, err
} }
const HasActiveSubscriptionByCategory = `-- name: HasActiveSubscriptionByCategory :one
SELECT EXISTS(
SELECT 1
FROM user_subscriptions us
JOIN subscription_plans sp ON sp.id = us.plan_id
WHERE us.user_id = $1
AND sp.category = $2
AND us.status = 'ACTIVE'
AND us.expires_at > CURRENT_TIMESTAMP
) AS has_subscription
`
type HasActiveSubscriptionByCategoryParams struct {
UserID int64 `json:"user_id"`
Category string `json:"category"`
}
func (q *Queries) HasActiveSubscriptionByCategory(ctx context.Context, arg HasActiveSubscriptionByCategoryParams) (bool, error) {
row := q.db.QueryRow(ctx, HasActiveSubscriptionByCategory, arg.UserID, arg.Category)
var has_subscription bool
err := row.Scan(&has_subscription)
return has_subscription, err
}
const ListActiveSubscriptionPlans = `-- name: ListActiveSubscriptionPlans :many const ListActiveSubscriptionPlans = `-- name: ListActiveSubscriptionPlans :many
SELECT id, name, description, duration_value, duration_unit, price, currency, is_active, created_at, updated_at FROM subscription_plans SELECT id, name, description, duration_value, duration_unit, price, currency, is_active, created_at, updated_at, category FROM subscription_plans
WHERE is_active = true WHERE is_active = true
ORDER BY price ASC ORDER BY price ASC
` `
@ -588,6 +616,7 @@ func (q *Queries) ListActiveSubscriptionPlans(ctx context.Context) ([]Subscripti
&i.IsActive, &i.IsActive,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.Category,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -646,7 +675,7 @@ func (q *Queries) ListSubscriptionDisplayStatusesByUserIDs(ctx context.Context,
} }
const ListSubscriptionPlans = `-- name: ListSubscriptionPlans :many const ListSubscriptionPlans = `-- name: ListSubscriptionPlans :many
SELECT id, name, description, duration_value, duration_unit, price, currency, is_active, created_at, updated_at FROM subscription_plans SELECT id, name, description, duration_value, duration_unit, price, currency, is_active, created_at, updated_at, category FROM subscription_plans
WHERE ($1::BOOLEAN IS NULL OR $1 = true AND is_active = true OR $1 = false) WHERE ($1::BOOLEAN IS NULL OR $1 = true AND is_active = true OR $1 = false)
ORDER BY price ASC ORDER BY price ASC
` `
@ -671,6 +700,7 @@ func (q *Queries) ListSubscriptionPlans(ctx context.Context, dollar_1 bool) ([]S
&i.IsActive, &i.IsActive,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.Category,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -703,25 +733,27 @@ func (q *Queries) UpdateAutoRenew(ctx context.Context, arg UpdateAutoRenewParams
const UpdateSubscriptionPlan = `-- name: UpdateSubscriptionPlan :exec const UpdateSubscriptionPlan = `-- name: UpdateSubscriptionPlan :exec
UPDATE subscription_plans UPDATE subscription_plans
SET SET
name = COALESCE($1, name), name = COALESCE($1::varchar, name),
description = COALESCE($2, description), description = COALESCE($2::text, description),
duration_value = COALESCE($3, duration_value), category = COALESCE($3::varchar, category),
duration_unit = COALESCE($4, duration_unit), duration_value = COALESCE($4::int, duration_value),
price = COALESCE($5, price), duration_unit = COALESCE($5::varchar, duration_unit),
currency = COALESCE($6, currency), price = COALESCE($6::numeric, price),
is_active = COALESCE($7, is_active), currency = COALESCE($7::varchar, currency),
is_active = COALESCE($8::boolean, is_active),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $8 WHERE id = $9
` `
type UpdateSubscriptionPlanParams struct { type UpdateSubscriptionPlanParams struct {
Name string `json:"name"` Name pgtype.Text `json:"name"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
DurationValue int32 `json:"duration_value"` Category pgtype.Text `json:"category"`
DurationUnit string `json:"duration_unit"` DurationValue pgtype.Int4 `json:"duration_value"`
DurationUnit pgtype.Text `json:"duration_unit"`
Price pgtype.Numeric `json:"price"` Price pgtype.Numeric `json:"price"`
Currency string `json:"currency"` Currency pgtype.Text `json:"currency"`
IsActive bool `json:"is_active"` IsActive pgtype.Bool `json:"is_active"`
ID int64 `json:"id"` ID int64 `json:"id"`
} }
@ -729,6 +761,7 @@ func (q *Queries) UpdateSubscriptionPlan(ctx context.Context, arg UpdateSubscrip
_, err := q.db.Exec(ctx, UpdateSubscriptionPlan, _, err := q.db.Exec(ctx, UpdateSubscriptionPlan,
arg.Name, arg.Name,
arg.Description, arg.Description,
arg.Category,
arg.DurationValue, arg.DurationValue,
arg.DurationUnit, arg.DurationUnit,
arg.Price, arg.Price,

View File

@ -637,10 +637,10 @@ func (q *Queries) SearchTeamMembers(ctx context.Context, arg SearchTeamMembersPa
const UpdateTeamMember = `-- name: UpdateTeamMember :exec const UpdateTeamMember = `-- name: UpdateTeamMember :exec
UPDATE team_members UPDATE team_members
SET SET
first_name = COALESCE($1, first_name), first_name = COALESCE($1::VARCHAR, first_name),
last_name = COALESCE($2, last_name), last_name = COALESCE($2::VARCHAR, last_name),
phone_number = COALESCE($3, phone_number), phone_number = COALESCE($3, phone_number),
team_role = COALESCE($4, team_role), team_role = COALESCE($4::VARCHAR, team_role),
department = COALESCE($5, department), department = COALESCE($5, department),
job_title = COALESCE($6, job_title), job_title = COALESCE($6, job_title),
employment_type = COALESCE($7, employment_type), employment_type = COALESCE($7, employment_type),
@ -656,10 +656,10 @@ WHERE id = $15
` `
type UpdateTeamMemberParams struct { type UpdateTeamMemberParams struct {
FirstName string `json:"first_name"` FirstName pgtype.Text `json:"first_name"`
LastName string `json:"last_name"` LastName pgtype.Text `json:"last_name"`
PhoneNumber pgtype.Text `json:"phone_number"` PhoneNumber pgtype.Text `json:"phone_number"`
TeamRole string `json:"team_role"` TeamRole pgtype.Text `json:"team_role"`
Department pgtype.Text `json:"department"` Department pgtype.Text `json:"department"`
JobTitle pgtype.Text `json:"job_title"` JobTitle pgtype.Text `json:"job_title"`
EmploymentType pgtype.Text `json:"employment_type"` EmploymentType pgtype.Text `json:"employment_type"`

View File

@ -0,0 +1,205 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: video_engagement.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const GetActiveVideoWatchSession = `-- name: GetActiveVideoWatchSession :one
SELECT
id,
user_id,
content_kind,
content_id,
session_number,
video_duration_sec,
max_position_sec,
started_at,
last_heartbeat_at,
ended_at,
completed_at
FROM user_video_watch_sessions
WHERE user_id = $1
AND content_kind = $2
AND content_id = $3
AND ended_at IS NULL
AND last_heartbeat_at >= $4
ORDER BY session_number DESC
LIMIT 1
`
type GetActiveVideoWatchSessionParams struct {
UserID int64 `json:"user_id"`
ContentKind string `json:"content_kind"`
ContentID int64 `json:"content_id"`
LastHeartbeatAt pgtype.Timestamptz `json:"last_heartbeat_at"`
}
func (q *Queries) GetActiveVideoWatchSession(ctx context.Context, arg GetActiveVideoWatchSessionParams) (UserVideoWatchSession, error) {
row := q.db.QueryRow(ctx, GetActiveVideoWatchSession,
arg.UserID,
arg.ContentKind,
arg.ContentID,
arg.LastHeartbeatAt,
)
var i UserVideoWatchSession
err := row.Scan(
&i.ID,
&i.UserID,
&i.ContentKind,
&i.ContentID,
&i.SessionNumber,
&i.VideoDurationSec,
&i.MaxPositionSec,
&i.StartedAt,
&i.LastHeartbeatAt,
&i.EndedAt,
&i.CompletedAt,
)
return i, err
}
const GetMaxVideoWatchSessionNumber = `-- name: GetMaxVideoWatchSessionNumber :one
SELECT
coalesce(max(session_number), 0)::int AS max_session_number
FROM user_video_watch_sessions
WHERE user_id = $1
AND content_kind = $2
AND content_id = $3
`
type GetMaxVideoWatchSessionNumberParams struct {
UserID int64 `json:"user_id"`
ContentKind string `json:"content_kind"`
ContentID int64 `json:"content_id"`
}
func (q *Queries) GetMaxVideoWatchSessionNumber(ctx context.Context, arg GetMaxVideoWatchSessionNumberParams) (int32, error) {
row := q.db.QueryRow(ctx, GetMaxVideoWatchSessionNumber, arg.UserID, arg.ContentKind, arg.ContentID)
var max_session_number int32
err := row.Scan(&max_session_number)
return max_session_number, err
}
const InsertVideoWatchSession = `-- name: InsertVideoWatchSession :one
INSERT INTO user_video_watch_sessions (
user_id,
content_kind,
content_id,
session_number,
video_duration_sec,
max_position_sec
)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING
id,
user_id,
content_kind,
content_id,
session_number,
video_duration_sec,
max_position_sec,
started_at,
last_heartbeat_at,
ended_at,
completed_at
`
type InsertVideoWatchSessionParams struct {
UserID int64 `json:"user_id"`
ContentKind string `json:"content_kind"`
ContentID int64 `json:"content_id"`
SessionNumber int32 `json:"session_number"`
VideoDurationSec pgtype.Int4 `json:"video_duration_sec"`
MaxPositionSec int32 `json:"max_position_sec"`
}
func (q *Queries) InsertVideoWatchSession(ctx context.Context, arg InsertVideoWatchSessionParams) (UserVideoWatchSession, error) {
row := q.db.QueryRow(ctx, InsertVideoWatchSession,
arg.UserID,
arg.ContentKind,
arg.ContentID,
arg.SessionNumber,
arg.VideoDurationSec,
arg.MaxPositionSec,
)
var i UserVideoWatchSession
err := row.Scan(
&i.ID,
&i.UserID,
&i.ContentKind,
&i.ContentID,
&i.SessionNumber,
&i.VideoDurationSec,
&i.MaxPositionSec,
&i.StartedAt,
&i.LastHeartbeatAt,
&i.EndedAt,
&i.CompletedAt,
)
return i, err
}
const UpdateVideoWatchSession = `-- name: UpdateVideoWatchSession :one
UPDATE user_video_watch_sessions
SET
max_position_sec = $2,
video_duration_sec = $3,
last_heartbeat_at = $4,
completed_at = $5,
ended_at = $6
WHERE id = $1
RETURNING
id,
user_id,
content_kind,
content_id,
session_number,
video_duration_sec,
max_position_sec,
started_at,
last_heartbeat_at,
ended_at,
completed_at
`
type UpdateVideoWatchSessionParams struct {
ID int64 `json:"id"`
MaxPositionSec int32 `json:"max_position_sec"`
VideoDurationSec pgtype.Int4 `json:"video_duration_sec"`
LastHeartbeatAt pgtype.Timestamptz `json:"last_heartbeat_at"`
CompletedAt pgtype.Timestamptz `json:"completed_at"`
EndedAt pgtype.Timestamptz `json:"ended_at"`
}
func (q *Queries) UpdateVideoWatchSession(ctx context.Context, arg UpdateVideoWatchSessionParams) (UserVideoWatchSession, error) {
row := q.db.QueryRow(ctx, UpdateVideoWatchSession,
arg.ID,
arg.MaxPositionSec,
arg.VideoDurationSec,
arg.LastHeartbeatAt,
arg.CompletedAt,
arg.EndedAt,
)
var i UserVideoWatchSession
err := row.Scan(
&i.ID,
&i.UserID,
&i.ContentKind,
&i.ContentID,
&i.SessionNumber,
&i.VideoDurationSec,
&i.MaxPositionSec,
&i.StartedAt,
&i.LastHeartbeatAt,
&i.EndedAt,
&i.CompletedAt,
)
return i, err
}

View File

@ -44,11 +44,16 @@ type AnalyticsUsersSection struct {
NewWeek int64 `json:"new_week"` NewWeek int64 `json:"new_week"`
NewMonth int64 `json:"new_month"` NewMonth int64 `json:"new_month"`
ByRole []AnalyticsLabelCount `json:"by_role"` ByRole []AnalyticsLabelCount `json:"by_role"`
ByStatus []AnalyticsLabelCount `json:"by_status"` ByStatus []AnalyticsLabelCount `json:"by_status"`
ByAgeGroup []AnalyticsLabelCount `json:"by_age_group"` ByAgeGroup []AnalyticsLabelCount `json:"by_age_group"`
ByKnowledgeLevel []AnalyticsLabelCount `json:"by_knowledge_level"` ByEducationLevel []AnalyticsLabelCount `json:"by_education_level"`
ByRegion []AnalyticsLabelCount `json:"by_region"` ByOccupation []AnalyticsLabelCount `json:"by_occupation"`
ByLearningGoal []AnalyticsLabelCount `json:"by_learning_goal"`
ByLanguageChallange []AnalyticsLabelCount `json:"by_language_challange"`
ByKnowledgeLevel []AnalyticsLabelCount `json:"by_knowledge_level"`
ByCountry []AnalyticsLabelCount `json:"by_country"`
ByRegion []AnalyticsLabelCount `json:"by_region"`
RegistrationsLast30Days []AnalyticsTimePoint `json:"registrations_last_30_days"` RegistrationsLast30Days []AnalyticsTimePoint `json:"registrations_last_30_days"`
} }
@ -149,6 +154,24 @@ type AnalyticsTeamSection struct {
ByStatus []AnalyticsLabelCount `json:"by_status"` ByStatus []AnalyticsLabelCount `json:"by_status"`
} }
type AnalyticsVideoDropOffPoint struct {
CheckpointPercent int `json:"checkpoint_percent"`
TotalSessions int64 `json:"total_sessions"`
ViewersReached int64 `json:"viewers_reached"`
DropOffRate float64 `json:"drop_off_rate"`
}
type AnalyticsVideosSection struct {
TotalWatchSessions int64 `json:"total_watch_sessions"`
CompletedSessions int64 `json:"completed_sessions"`
ReplaySessions int64 `json:"replay_sessions"`
UniqueVideoStarts int64 `json:"unique_video_starts"`
UsersWhoReplayed int64 `json:"users_who_replayed"`
CompletionRate float64 `json:"completion_rate"`
ReplayRate float64 `json:"replay_rate"`
DropOffByCheckpoint []AnalyticsVideoDropOffPoint `json:"drop_off_by_checkpoint"`
}
type AnalyticsDashboard struct { type AnalyticsDashboard struct {
GeneratedAt time.Time `json:"generated_at"` GeneratedAt time.Time `json:"generated_at"`
DateFilter AnalyticsDateFilter `json:"date_filter"` DateFilter AnalyticsDateFilter `json:"date_filter"`
@ -156,6 +179,7 @@ type AnalyticsDashboard struct {
Subscriptions AnalyticsSubscriptionsSection `json:"subscriptions"` Subscriptions AnalyticsSubscriptionsSection `json:"subscriptions"`
Payments AnalyticsPaymentsSection `json:"payments"` Payments AnalyticsPaymentsSection `json:"payments"`
Courses AnalyticsCoursesSection `json:"courses"` Courses AnalyticsCoursesSection `json:"courses"`
Videos AnalyticsVideosSection `json:"videos"`
Content AnalyticsContentSection `json:"content"` Content AnalyticsContentSection `json:"content"`
Notifications AnalyticsNotificationsSection `json:"notifications"` Notifications AnalyticsNotificationsSection `json:"notifications"`
Issues AnalyticsIssuesSection `json:"issues"` Issues AnalyticsIssuesSection `json:"issues"`

View File

@ -7,25 +7,29 @@ type ExamPrepCatalogCourse struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Category string `json:"category"`
Thumbnail *string `json:"thumbnail,omitempty"` Thumbnail *string `json:"thumbnail,omitempty"`
SortOrder int `json:"sort_order"` SortOrder int `json:"sort_order"`
UnitsCount *int64 `json:"units_count,omitempty"` UnitsCount *int64 `json:"units_count,omitempty"`
ModulesCount *int64 `json:"modules_count,omitempty"` ModulesCount *int64 `json:"modules_count,omitempty"`
LessonsCount *int64 `json:"lessons_count,omitempty"` LessonsCount *int64 `json:"lessons_count,omitempty"`
HasPractice bool `json:"has_practice"` HasPractice bool `json:"has_practice"`
CreatedAt time.Time `json:"created_at"` Access *LMSEntityAccess `json:"access,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"`
} }
type CreateExamPrepCatalogCourseInput struct { type CreateExamPrepCatalogCourseInput struct {
Name string `json:"name" validate:"required"` Name string `json:"name" validate:"required"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Category string `json:"category" validate:"required,oneof=IELTS DUOLINGO"`
Thumbnail *string `json:"thumbnail,omitempty"` Thumbnail *string `json:"thumbnail,omitempty"`
} }
type UpdateExamPrepCatalogCourseInput struct { type UpdateExamPrepCatalogCourseInput struct {
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Category *string `json:"category,omitempty" validate:"omitempty,oneof=IELTS DUOLINGO"`
Thumbnail *string `json:"thumbnail,omitempty"` Thumbnail *string `json:"thumbnail,omitempty"`
SortOrder *int `json:"sort_order,omitempty"` SortOrder *int `json:"sort_order,omitempty"`
} }

View File

@ -11,8 +11,9 @@ type ExamPrepLesson struct {
Thumbnail *string `json:"thumbnail,omitempty"` Thumbnail *string `json:"thumbnail,omitempty"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
SortOrder int `json:"sort_order"` SortOrder int `json:"sort_order"`
HasPractice bool `json:"has_practice"` HasPractice bool `json:"has_practice"`
CreatedAt time.Time `json:"created_at"` Access *LMSEntityAccess `json:"access,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"`
} }

View File

@ -13,8 +13,9 @@ type ExamPrepModule struct {
SortOrder int `json:"sort_order"` SortOrder int `json:"sort_order"`
LessonsCount *int64 `json:"lessons_count,omitempty"` LessonsCount *int64 `json:"lessons_count,omitempty"`
PracticesCount *int64 `json:"practices_count,omitempty"` PracticesCount *int64 `json:"practices_count,omitempty"`
HasPractice bool `json:"has_practice"` HasPractice bool `json:"has_practice"`
CreatedAt time.Time `json:"created_at"` Access *LMSEntityAccess `json:"access,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"`
} }

View File

@ -13,8 +13,9 @@ type ExamPrepUnit struct {
ModulesCount *int64 `json:"modules_count,omitempty"` ModulesCount *int64 `json:"modules_count,omitempty"`
LessonsCount *int64 `json:"lessons_count,omitempty"` LessonsCount *int64 `json:"lessons_count,omitempty"`
PracticesCount *int64 `json:"practices_count,omitempty"` PracticesCount *int64 `json:"practices_count,omitempty"`
HasPractice bool `json:"has_practice"` HasPractice bool `json:"has_practice"`
CreatedAt time.Time `json:"created_at"` Access *LMSEntityAccess `json:"access,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"`
} }

View File

@ -1,19 +1,23 @@
package domain package domain
// LMSEntityAccess describes learner gating for a program, course, module, or lesson. // LMSEntityAccess describes learner gating for a program, course, module, or lesson.
// It is omitted (nil) for non-learner roles in API responses. // Included for STUDENT and OPEN_LEARNER; omitted (nil) for staff roles in API responses.
// Progress fields count completed lessons vs total lessons in that entitys scope (lesson: 0 or 1 of 1). // OPEN_LEARNER always has is_accessible true; STUDENT may be false when prerequisites are unmet.
// Progress fields count completed published practices vs total published practices in the
// entity's scope. progress_percent keeps the legacy whole-number value; use
// progress_percent_precise for decimal precision in learner UIs.
type LMSEntityAccess struct { type LMSEntityAccess struct {
IsAccessible bool `json:"is_accessible"` IsAccessible bool `json:"is_accessible"`
IsCompleted bool `json:"is_completed"` IsCompleted bool `json:"is_completed"`
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
CompletedCount int `json:"completed_count"` CompletedCount int `json:"completed_count"`
TotalCount int `json:"total_count"` TotalCount int `json:"total_count"`
ProgressPercent int `json:"progress_percent"` ProgressPercent int `json:"progress_percent"`
ProgressPercentPrecise float64 `json:"progress_percent_precise"`
} }
// LMSUserProgress lists entity IDs the authenticated user has fully completed // LMSUserProgress lists entity IDs the authenticated user has fully completed based on
// (lessons as marked complete; module/course/program when rollup conditions were met). // published practice completion in each LMS scope.
type LMSUserProgress struct { type LMSUserProgress struct {
LessonIDs []int64 `json:"lesson_ids"` LessonIDs []int64 `json:"lesson_ids"`
ModuleIDs []int64 `json:"module_ids"` ModuleIDs []int64 `json:"module_ids"`

View File

@ -0,0 +1,38 @@
package domain
// LMSProgressSummary returns the learner's progress tree using the same access
// contract exposed by the LMS hierarchy endpoints.
type LMSProgressSummary struct {
Programs []LMSProgressSummaryProgram `json:"programs"`
}
type LMSProgressSummaryProgram struct {
ID int64 `json:"id"`
Name string `json:"name"`
Access *LMSEntityAccess `json:"access,omitempty"`
Courses []LMSProgressSummaryCourse `json:"courses"`
}
type LMSProgressSummaryCourse struct {
ID int64 `json:"id"`
ProgramID int64 `json:"program_id"`
Name string `json:"name"`
Access *LMSEntityAccess `json:"access,omitempty"`
Modules []LMSProgressSummaryModule `json:"modules"`
}
type LMSProgressSummaryModule struct {
ID int64 `json:"id"`
ProgramID int64 `json:"program_id"`
CourseID int64 `json:"course_id"`
Name string `json:"name"`
Access *LMSEntityAccess `json:"access,omitempty"`
Lessons []LMSProgressSummaryLesson `json:"lessons"`
}
type LMSProgressSummaryLesson struct {
ID int64 `json:"id"`
ModuleID int64 `json:"module_id"`
Title string `json:"title"`
Access *LMSEntityAccess `json:"access,omitempty"`
}

View File

@ -0,0 +1,62 @@
package domain
import "time"
const (
MobileAppPlatformAndroid = "ANDROID"
MobileAppPlatformIOS = "IOS"
MobileAppUpdateTypeForce = "FORCE"
MobileAppUpdateTypeOptional = "OPTIONAL"
MobileAppVersionStatusActive = "ACTIVE"
MobileAppVersionStatusInactive = "INACTIVE"
)
type MobileAppVersion struct {
ID int64 `json:"id"`
Platform string `json:"platform"`
VersionName string `json:"version_name"`
VersionCode int32 `json:"version_code"`
UpdateType string `json:"update_type"`
ReleaseNotes *string `json:"release_notes,omitempty"`
StoreURL *string `json:"store_url,omitempty"`
MinSupportedVersionCode *int32 `json:"min_supported_version_code,omitempty"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
type CreateMobileAppVersionInput struct {
Platform string
VersionName string
VersionCode int32
UpdateType *string
ReleaseNotes *string
StoreURL *string
MinSupportedVersionCode *int32
Status *string
}
type UpdateMobileAppVersionInput struct {
VersionName *string
VersionCode *int32
UpdateType *string
ReleaseNotes *string
StoreURL *string
MinSupportedVersionCode *int32
Status *string
}
// MobileAppVersionCheckResult is returned to mobile clients checking for updates.
type MobileAppVersionCheckResult struct {
Platform string `json:"platform"`
ClientVersionCode int32 `json:"client_version_code"`
LatestVersionName string `json:"latest_version_name"`
LatestVersionCode int32 `json:"latest_version_code"`
UpdateAvailable bool `json:"update_available"`
ForceUpdate bool `json:"force_update"`
UpdateType string `json:"update_type,omitempty"`
ReleaseNotes *string `json:"release_notes,omitempty"`
StoreURL *string `json:"store_url,omitempty"`
}

View File

@ -1,6 +1,10 @@
package domain package domain
import "time" import (
"fmt"
"strings"
"time"
)
type PaymentStatus string type PaymentStatus string
@ -43,10 +47,29 @@ type CreatePaymentInput struct {
ExpiresAt *time.Time ExpiresAt *time.Time
} }
type PaymentProvider string
const (
PaymentProviderChapa PaymentProvider = "CHAPA"
PaymentProviderArifPay PaymentProvider = "ARIFPAY"
)
func ParsePaymentProvider(raw string) (PaymentProvider, error) {
switch strings.ToUpper(strings.TrimSpace(raw)) {
case string(PaymentProviderChapa):
return PaymentProviderChapa, nil
case string(PaymentProviderArifPay), "ARIF_PAY":
return PaymentProviderArifPay, nil
default:
return "", fmt.Errorf("unsupported payment provider %q", raw)
}
}
type InitiateSubscriptionPaymentRequest struct { type InitiateSubscriptionPaymentRequest struct {
PlanID int64 `json:"plan_id" validate:"required"` PlanID int64 `json:"plan_id" validate:"required"`
Phone string `json:"phone" validate:"required"` Phone string `json:"phone" validate:"required"`
Email string `json:"email" validate:"required,email"` Email string `json:"email" validate:"required,email"`
Provider PaymentProvider `json:"provider"`
} }
type InitiateSubscriptionPaymentResponse struct { type InitiateSubscriptionPaymentResponse struct {

View File

@ -0,0 +1,36 @@
package domain
import "testing"
func TestParsePaymentProvider(t *testing.T) {
tests := []struct {
name string
input string
want PaymentProvider
wantErr bool
}{
{name: "chapa uppercase", input: "CHAPA", want: PaymentProviderChapa},
{name: "chapa lowercase", input: "chapa", want: PaymentProviderChapa},
{name: "arifpay uppercase", input: "ARIFPAY", want: PaymentProviderArifPay},
{name: "arifpay underscored", input: "arif_pay", want: PaymentProviderArifPay},
{name: "invalid", input: "telebirr", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParsePaymentProvider(tt.input)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Fatalf("provider=%q, want %q", got, tt.want)
}
})
}
}

View File

@ -0,0 +1,77 @@
package domain
import (
"errors"
"time"
)
const (
ProfileFieldOptionStatusActive = "ACTIVE"
ProfileFieldOptionStatusInactive = "INACTIVE"
)
var (
ErrInvalidFieldKey = errors.New("invalid field key")
ErrInvalidOptionCode = errors.New("invalid field option code")
ErrFieldOptionNotFound = errors.New("field option not found")
)
// Deprecated aliases for internal references during transition.
var (
ErrInvalidProfileFieldKey = ErrInvalidFieldKey
ErrInvalidProfileFieldOptionCode = ErrInvalidOptionCode
ErrProfileFieldOptionNotFound = ErrFieldOptionNotFound
)
const (
ProfileFieldEducationLevel = "education_level"
ProfileFieldOccupation = "occupation"
ProfileFieldAgeGroup = "age_group"
ProfileFieldLearningGoal = "learning_goal"
ProfileFieldLanguageChallange = "language_challange"
ProfileFieldLanguageGoal = "language_goal"
ProfileFieldFavouriteTopic = "favourite_topic"
)
// User profile columns validated against field_options on PUT /user (when non-empty).
var UserProfileFieldsWithOptions = []string{
ProfileFieldEducationLevel,
ProfileFieldOccupation,
ProfileFieldAgeGroup,
ProfileFieldLearningGoal,
ProfileFieldLanguageChallange,
ProfileFieldLanguageGoal,
ProfileFieldFavouriteTopic,
}
type ProfileFieldOption struct {
ID int64
FieldKey string
Code string
Label string
DisplayOrder int32
Status string
CreatedAt time.Time
UpdatedAt *time.Time
}
type CreateProfileFieldOptionInput struct {
FieldKey string
Code string
Label string
DisplayOrder *int32
Status *string
}
type UpdateProfileFieldOptionInput struct {
Label *string
DisplayOrder *int32
Status *string
}
type ProfileFieldOptionsGrouped map[string][]ProfileFieldOptionItem
type ProfileFieldOptionItem struct {
Code string `json:"code"`
Label string `json:"label"`
}

View File

@ -7,6 +7,7 @@ type Program struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Category string `json:"category"`
Thumbnail *string `json:"thumbnail,omitempty"` Thumbnail *string `json:"thumbnail,omitempty"`
SortOrder int `json:"sort_order"` SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
@ -17,6 +18,7 @@ type Program struct {
type CreateProgramInput struct { type CreateProgramInput struct {
Name string `json:"name" validate:"required"` Name string `json:"name" validate:"required"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Category string `json:"category" validate:"required,oneof=LEARN_ENGLISH IELTS DUOLINGO"`
Thumbnail *string `json:"thumbnail,omitempty"` Thumbnail *string `json:"thumbnail,omitempty"`
// SortOrder inserts at this global program order when set; omit to append after current max (sort_order uniqueness is enforced). // SortOrder inserts at this global program order when set; omit to append after current max (sort_order uniqueness is enforced).
SortOrder *int `json:"sort_order,omitempty" validate:"omitempty,min=0"` SortOrder *int `json:"sort_order,omitempty" validate:"omitempty,min=0"`
@ -25,6 +27,7 @@ type CreateProgramInput struct {
type UpdateProgramInput struct { type UpdateProgramInput struct {
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Category *string `json:"category,omitempty" validate:"omitempty,oneof=LEARN_ENGLISH IELTS DUOLINGO"`
Thumbnail *string `json:"thumbnail,omitempty"` Thumbnail *string `json:"thumbnail,omitempty"`
SortOrder *int `json:"sort_order,omitempty"` SortOrder *int `json:"sort_order,omitempty"`
} }

View File

@ -4,6 +4,14 @@ import (
"time" "time"
) )
type SubscriptionCategory string
const (
SubscriptionCategoryLearnEnglish SubscriptionCategory = "LEARN_ENGLISH"
SubscriptionCategoryIELTS SubscriptionCategory = "IELTS"
SubscriptionCategoryDuolingo SubscriptionCategory = "DUOLINGO"
)
type DurationUnit string type DurationUnit string
const ( const (
@ -26,6 +34,7 @@ type SubscriptionPlan struct {
ID int64 ID int64
Name string Name string
Description *string Description *string
Category string
DurationValue int32 DurationValue int32
DurationUnit string DurationUnit string
Price float64 Price float64
@ -59,6 +68,7 @@ type UserSubscription struct {
type CreateSubscriptionPlanInput struct { type CreateSubscriptionPlanInput struct {
Name string Name string
Description *string Description *string
Category string
DurationValue int32 DurationValue int32
DurationUnit string DurationUnit string
Price float64 Price float64
@ -69,6 +79,7 @@ type CreateSubscriptionPlanInput struct {
type UpdateSubscriptionPlanInput struct { type UpdateSubscriptionPlanInput struct {
Name *string Name *string
Description *string Description *string
Category *string
DurationValue *int32 DurationValue *int32
DurationUnit *string DurationUnit *string
Price *float64 Price *float64

View File

@ -43,32 +43,47 @@ type TeamInvitationWithMember struct {
TeamRole TeamRole TeamRole TeamRole
} }
const (
TeamInvitePlaceholderFirstName = "Pending"
TeamInvitePlaceholderLastName = "Invite"
)
func IsTeamInvitePlaceholderProfile(firstName, lastName string) bool {
return firstName == TeamInvitePlaceholderFirstName && lastName == TeamInvitePlaceholderLastName
}
// InviteTeamMemberReq only requires email and role; profile fields are collected on accept.
type InviteTeamMemberReq struct { type InviteTeamMemberReq struct {
FirstName string `json:"first_name" validate:"required"` Email string `json:"email" validate:"required,email"`
LastName string `json:"last_name" validate:"required"` TeamRole string `json:"team_role" validate:"required"`
Email string `json:"email" validate:"required,email"`
PhoneNumber string `json:"phone_number"`
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"`
Permissions []string `json:"permissions"`
} }
type AcceptTeamInvitationReq struct { type AcceptTeamInvitationReq struct {
Token string `json:"token" validate:"required"` Token string `json:"token" validate:"required"`
Password string `json:"password" validate:"required,min=8"` Password string `json:"password" validate:"required,min=8"`
FirstName string `json:"first_name" validate:"required"`
LastName string `json:"last_name" validate:"required"`
PhoneNumber string `json:"phone_number"`
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"`
} }
type VerifyTeamInvitationRes struct { type VerifyTeamInvitationRes struct {
Valid bool `json:"valid"` Valid bool `json:"valid"`
Email string `json:"email,omitempty"` Email string `json:"email,omitempty"`
FirstName string `json:"first_name,omitempty"` TeamRole string `json:"team_role,omitempty"`
LastName string `json:"last_name,omitempty"` NeedsProfileSetup bool `json:"needs_profile_setup,omitempty"`
TeamRole string `json:"team_role,omitempty"` ExpiresAt time.Time `json:"expires_at,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"` Status string `json:"status,omitempty"`
Status string `json:"status,omitempty"`
} }
type InviteTeamMemberRes struct { type InviteTeamMemberRes struct {

View File

@ -0,0 +1,23 @@
package domain
const (
VideoContentKindLMSLesson = "lms_lesson"
VideoContentKindExamPrepLesson = "exam_prep_lesson"
)
const VideoCompletionThresholdPercent = 90
type VideoEngagementHeartbeatInput struct {
ContentKind string `json:"content_kind" validate:"required,oneof=lms_lesson exam_prep_lesson"`
ContentID int64 `json:"content_id" validate:"required,gt=0"`
PositionSec int `json:"position_sec" validate:"gte=0"`
DurationSec int `json:"duration_sec" validate:"gte=0"`
Ended bool `json:"ended"`
}
type VideoWatchSessionResponse struct {
SessionID int64 `json:"session_id"`
SessionNumber int `json:"session_number"`
MaxPositionSec int `json:"max_position_sec"`
Completed bool `json:"completed"`
}

View File

@ -0,0 +1,15 @@
package ports
import (
"Yimaru-Backend/internal/domain"
"context"
)
type MobileAppVersionStore interface {
CreateMobileAppVersion(ctx context.Context, input domain.CreateMobileAppVersionInput) (domain.MobileAppVersion, error)
UpdateMobileAppVersion(ctx context.Context, id int64, input domain.UpdateMobileAppVersionInput) (domain.MobileAppVersion, error)
GetMobileAppVersionByID(ctx context.Context, id int64) (domain.MobileAppVersion, error)
ListMobileAppVersions(ctx context.Context, platform *string, status *string, limit int32, offset int32) ([]domain.MobileAppVersion, int64, error)
DeleteMobileAppVersion(ctx context.Context, id int64) error
GetLatestActiveMobileAppVersion(ctx context.Context, platform string) (domain.MobileAppVersion, error)
}

View File

@ -0,0 +1,17 @@
package ports
import (
"Yimaru-Backend/internal/domain"
"context"
)
type ProfileFieldOptionStore interface {
CreateProfileFieldOption(ctx context.Context, input domain.CreateProfileFieldOptionInput) (domain.ProfileFieldOption, error)
UpdateProfileFieldOption(ctx context.Context, id int64, input domain.UpdateProfileFieldOptionInput) (domain.ProfileFieldOption, error)
GetProfileFieldOptionByID(ctx context.Context, id int64, includeInactive bool) (domain.ProfileFieldOption, error)
ListProfileFieldOptions(ctx context.Context, fieldKey *string, status *string, limit, offset int32) ([]domain.ProfileFieldOption, int64, error)
ListActiveProfileFieldOptions(ctx context.Context, fieldKey *string) ([]domain.ProfileFieldOption, error)
IsActiveProfileFieldOption(ctx context.Context, fieldKey, code string) (bool, error)
ListDistinctFieldKeys(ctx context.Context, activeOnly bool) ([]string, error)
DeleteProfileFieldOption(ctx context.Context, id int64) error
}

View File

@ -22,6 +22,7 @@ type SubscriptionStore interface {
GetSubscriptionDisplayStatusByUserID(ctx context.Context, userID int64) (string, error) GetSubscriptionDisplayStatusByUserID(ctx context.Context, userID int64) (string, error)
GetUserSubscriptionHistory(ctx context.Context, userID int64, limit, offset int32) ([]domain.UserSubscription, error) GetUserSubscriptionHistory(ctx context.Context, userID int64, limit, offset int32) ([]domain.UserSubscription, error)
HasActiveSubscription(ctx context.Context, userID int64) (bool, error) HasActiveSubscription(ctx context.Context, userID int64) (bool, error)
HasActiveSubscriptionByCategory(ctx context.Context, userID int64, category string) (bool, error)
CancelUserSubscription(ctx context.Context, id int64) error CancelUserSubscription(ctx context.Context, id int64) error
UpdateSubscriptionStatus(ctx context.Context, id int64, status string) error UpdateSubscriptionStatus(ctx context.Context, id int64, status string) error
UpdateAutoRenew(ctx context.Context, id int64, autoRenew bool) error UpdateAutoRenew(ctx context.Context, id int64, autoRenew bool) error

View File

@ -15,6 +15,7 @@ func examPrepCatalogCourseToDomain(c dbgen.ExamPrepCatalogCourse) domain.ExamPre
out := domain.ExamPrepCatalogCourse{ out := domain.ExamPrepCatalogCourse{
ID: c.ID, ID: c.ID,
Name: c.Name, Name: c.Name,
Category: c.Category,
SortOrder: int(c.SortOrder), SortOrder: int(c.SortOrder),
} }
out.Description = fromPgText(c.Description) out.Description = fromPgText(c.Description)
@ -31,6 +32,7 @@ func (s *Store) CreateExamPrepCatalogCourse(ctx context.Context, input domain.Cr
c, err := s.queries.ExamPrepCreateCatalogCourse(ctx, dbgen.ExamPrepCreateCatalogCourseParams{ c, err := s.queries.ExamPrepCreateCatalogCourse(ctx, dbgen.ExamPrepCreateCatalogCourseParams{
Name: input.Name, Name: input.Name,
Description: toPgText(input.Description), Description: toPgText(input.Description),
Category: input.Category,
Thumbnail: toPgText(input.Thumbnail), Thumbnail: toPgText(input.Thumbnail),
}) })
if err != nil { if err != nil {
@ -51,6 +53,7 @@ func (s *Store) GetExamPrepCatalogCourseByID(ctx context.Context, id int64) (dom
ID: c.ID, ID: c.ID,
Name: c.Name, Name: c.Name,
Description: c.Description, Description: c.Description,
Category: c.Category,
Thumbnail: c.Thumbnail, Thumbnail: c.Thumbnail,
SortOrder: c.SortOrder, SortOrder: c.SortOrder,
CreatedAt: c.CreatedAt, CreatedAt: c.CreatedAt,
@ -81,6 +84,7 @@ func (s *Store) ListExamPrepCatalogCourses(ctx context.Context, limit, offset in
ID: r.ID, ID: r.ID,
Name: r.Name, Name: r.Name,
Description: r.Description, Description: r.Description,
Category: r.Category,
Thumbnail: r.Thumbnail, Thumbnail: r.Thumbnail,
SortOrder: r.SortOrder, SortOrder: r.SortOrder,
CreatedAt: r.CreatedAt, CreatedAt: r.CreatedAt,
@ -110,6 +114,7 @@ func (s *Store) UpdateExamPrepCatalogCourse(ctx context.Context, id int64, input
ID: id, ID: id,
Name: nameText, Name: nameText,
Description: optionalTextUpdate(input.Description), Description: optionalTextUpdate(input.Description),
Category: optionalTextUpdate(input.Category),
Thumbnail: optionalTextUpdate(input.Thumbnail), Thumbnail: optionalTextUpdate(input.Thumbnail),
SortOrder: optionalInt4Update(input.SortOrder), SortOrder: optionalInt4Update(input.SortOrder),
}) })

View File

@ -0,0 +1,71 @@
package repository
import (
"context"
dbgen "Yimaru-Backend/gen/db"
)
// ExamPrepUserPracticeProgressInLesson returns published practice completion counts scoped to an exam-prep lesson.
func (s *Store) ExamPrepUserPracticeProgressInLesson(ctx context.Context, userID, lessonID int64) (completed, total int32, err error) {
total, err = s.queries.CountPublishedExamPrepPracticesInLesson(ctx, lessonID)
if err != nil {
return 0, 0, err
}
completed, err = s.queries.CountUserCompletedPublishedExamPrepPracticesInLesson(ctx, dbgen.CountUserCompletedPublishedExamPrepPracticesInLessonParams{
UnitModuleLessonID: lessonID,
UserID: userID,
})
if err != nil {
return 0, 0, err
}
return completed, total, nil
}
// ExamPrepUserPracticeProgressInModule returns published practice completion counts in an exam-prep module.
func (s *Store) ExamPrepUserPracticeProgressInModule(ctx context.Context, userID, moduleID int64) (completed, total int32, err error) {
total, err = s.queries.CountPublishedExamPrepPracticesInModule(ctx, moduleID)
if err != nil {
return 0, 0, err
}
completed, err = s.queries.CountUserCompletedPublishedExamPrepPracticesInModule(ctx, dbgen.CountUserCompletedPublishedExamPrepPracticesInModuleParams{
UnitModuleID: moduleID,
UserID: userID,
})
if err != nil {
return 0, 0, err
}
return completed, total, nil
}
// ExamPrepUserPracticeProgressInUnit returns published practice completion counts in an exam-prep unit.
func (s *Store) ExamPrepUserPracticeProgressInUnit(ctx context.Context, userID, unitID int64) (completed, total int32, err error) {
total, err = s.queries.CountPublishedExamPrepPracticesInUnit(ctx, unitID)
if err != nil {
return 0, 0, err
}
completed, err = s.queries.CountUserCompletedPublishedExamPrepPracticesInUnit(ctx, dbgen.CountUserCompletedPublishedExamPrepPracticesInUnitParams{
UnitID: unitID,
UserID: userID,
})
if err != nil {
return 0, 0, err
}
return completed, total, nil
}
// ExamPrepUserPracticeProgressInCatalogCourse returns published practice completion counts in a catalog course.
func (s *Store) ExamPrepUserPracticeProgressInCatalogCourse(ctx context.Context, userID, catalogCourseID int64) (completed, total int32, err error) {
total, err = s.queries.CountPublishedExamPrepPracticesInCatalogCourse(ctx, catalogCourseID)
if err != nil {
return 0, 0, err
}
completed, err = s.queries.CountUserCompletedPublishedExamPrepPracticesInCatalogCourse(ctx, dbgen.CountUserCompletedPublishedExamPrepPracticesInCatalogCourseParams{
CatalogCourseID: catalogCourseID,
UserID: userID,
})
if err != nil {
return 0, 0, err
}
return completed, total, nil
}

View File

@ -38,89 +38,67 @@ func (s *Store) LmsUserHasLessonProgress(ctx context.Context, userID, lessonID i
return s.queries.UserHasLessonProgress(ctx, dbgen.UserHasLessonProgressParams{UserID: userID, LessonID: lessonID}) return s.queries.UserHasLessonProgress(ctx, dbgen.UserHasLessonProgressParams{UserID: userID, LessonID: lessonID})
} }
// LmsUserLessonProgressInModule returns combined completed/total counts for lessons + published practices in a module. // LmsUserPracticeProgressInLesson returns published practice completion counts scoped to a lesson.
func (s *Store) LmsUserLessonProgressInModule(ctx context.Context, userID, moduleID int64) (completed, total int32, err error) { func (s *Store) LmsUserPracticeProgressInLesson(ctx context.Context, userID, lessonID int64) (completed, total int32, err error) {
lessonTotal, err := s.queries.CountLessonsInModule(ctx, moduleID) lessonIDPG := toPgInt8(&lessonID)
total, err = s.queries.CountPublishedPracticesInLesson(ctx, lessonIDPG)
if err != nil { if err != nil {
return 0, 0, err return 0, 0, err
} }
lessonCompleted, err := s.queries.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{ completed, err = s.queries.CountUserCompletedPublishedPracticesInLesson(ctx, dbgen.CountUserCompletedPublishedPracticesInLessonParams{
ModuleID: moduleID, LessonID: lessonIDPG,
UserID: userID, UserID: userID,
}) })
if err != nil { if err != nil {
return 0, 0, err return 0, 0, err
} }
practiceTotal, err := s.queries.CountPublishedPracticesInModule(ctx, toPgInt8(&moduleID)) return completed, total, nil
}
// LmsUserLessonProgressInModule returns published practice completion counts in a module.
func (s *Store) LmsUserLessonProgressInModule(ctx context.Context, userID, moduleID int64) (completed, total int32, err error) {
total, err = s.queries.CountPublishedPracticesInModule(ctx, toPgInt8(&moduleID))
if err != nil { if err != nil {
return 0, 0, err return 0, 0, err
} }
practiceCompleted, err := s.queries.CountUserCompletedPublishedPracticesInModule(ctx, dbgen.CountUserCompletedPublishedPracticesInModuleParams{ completed, err = s.queries.CountUserCompletedPublishedPracticesInModule(ctx, dbgen.CountUserCompletedPublishedPracticesInModuleParams{
ModuleID: toPgInt8(&moduleID), ModuleID: toPgInt8(&moduleID),
UserID: userID, UserID: userID,
}) })
if err != nil { if err != nil {
return 0, 0, err return 0, 0, err
} }
total = lessonTotal + practiceTotal
completed = lessonCompleted + practiceCompleted
return completed, total, nil return completed, total, nil
} }
// LmsUserLessonProgressInCourse returns combined completed/total counts for lessons + published practices in a course. // LmsUserLessonProgressInCourse returns published practice completion counts in a course.
func (s *Store) LmsUserLessonProgressInCourse(ctx context.Context, userID, courseID int64) (completed, total int32, err error) { func (s *Store) LmsUserLessonProgressInCourse(ctx context.Context, userID, courseID int64) (completed, total int32, err error) {
lessonTotal, err := s.queries.CountLessonsInCourse(ctx, courseID) total, err = s.queries.CountPublishedPracticesInCourse(ctx, toPgInt8(&courseID))
if err != nil { if err != nil {
return 0, 0, err return 0, 0, err
} }
lessonCompleted, err := s.queries.CountUserCompletedLessonsInCourse(ctx, dbgen.CountUserCompletedLessonsInCourseParams{ completed, err = s.queries.CountUserCompletedPublishedPracticesInCourse(ctx, dbgen.CountUserCompletedPublishedPracticesInCourseParams{
CourseID: courseID,
UserID: userID,
})
if err != nil {
return 0, 0, err
}
practiceTotal, err := s.queries.CountPublishedPracticesInCourse(ctx, toPgInt8(&courseID))
if err != nil {
return 0, 0, err
}
practiceCompleted, err := s.queries.CountUserCompletedPublishedPracticesInCourse(ctx, dbgen.CountUserCompletedPublishedPracticesInCourseParams{
CourseID: toPgInt8(&courseID), CourseID: toPgInt8(&courseID),
UserID: userID, UserID: userID,
}) })
if err != nil { if err != nil {
return 0, 0, err return 0, 0, err
} }
total = lessonTotal + practiceTotal
completed = lessonCompleted + practiceCompleted
return completed, total, nil return completed, total, nil
} }
// LmsUserLessonProgressInProgram returns combined completed/total counts for lessons + published practices in a program. // LmsUserLessonProgressInProgram returns published practice completion counts in a program.
func (s *Store) LmsUserLessonProgressInProgram(ctx context.Context, userID, programID int64) (completed, total int32, err error) { func (s *Store) LmsUserLessonProgressInProgram(ctx context.Context, userID, programID int64) (completed, total int32, err error) {
lessonTotal, err := s.queries.CountLessonsInProgram(ctx, programID) total, err = s.queries.CountPublishedPracticesInProgram(ctx, programID)
if err != nil { if err != nil {
return 0, 0, err return 0, 0, err
} }
lessonCompleted, err := s.queries.CountUserCompletedLessonsInProgram(ctx, dbgen.CountUserCompletedLessonsInProgramParams{ completed, err = s.queries.CountUserCompletedPublishedPracticesInProgram(ctx, dbgen.CountUserCompletedPublishedPracticesInProgramParams{
ProgramID: programID, ProgramID: programID,
UserID: userID, UserID: userID,
}) })
if err != nil { if err != nil {
return 0, 0, err return 0, 0, err
} }
practiceTotal, err := s.queries.CountPublishedPracticesInProgram(ctx, programID)
if err != nil {
return 0, 0, err
}
practiceCompleted, err := s.queries.CountUserCompletedPublishedPracticesInProgram(ctx, dbgen.CountUserCompletedPublishedPracticesInProgramParams{
ProgramID: programID,
UserID: userID,
})
if err != nil {
return 0, 0, err
}
total = lessonTotal + practiceTotal
completed = lessonCompleted + practiceCompleted
return completed, total, nil return completed, total, nil
} }

View File

@ -2,13 +2,15 @@ package repository
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
dbgen "Yimaru-Backend/gen/db" dbgen "Yimaru-Backend/gen/db"
"github.com/jackc/pgx/v5"
) )
// CompleteLessonForUser records lesson completion and cascades completion upward when // CompleteLessonForUser records lesson completion for sequential lesson gating and
// both lesson and related practice requirements are satisfied. // re-evaluates higher-level practice-based rollups.
func (s *Store) CompleteLessonForUser(ctx context.Context, userID, lessonID int64) error { func (s *Store) CompleteLessonForUser(ctx context.Context, userID, lessonID int64) error {
q, tx, err := s.BeginTx(ctx) q, tx, err := s.BeginTx(ctx)
if err != nil { if err != nil {
@ -42,8 +44,8 @@ func (s *Store) CompleteLessonForUser(ctx context.Context, userID, lessonID int6
return nil return nil
} }
// CompletePracticeForUser records practice completion and cascades completion upward when // CompletePracticeForUser records practice completion and cascades practice-based
// both lesson and related practice requirements are satisfied. // completion upward when all published practices in scope are complete.
func (s *Store) CompletePracticeForUser(ctx context.Context, userID, questionSetID int64) error { func (s *Store) CompletePracticeForUser(ctx context.Context, userID, questionSetID int64) error {
q, tx, err := s.BeginTx(ctx) q, tx, err := s.BeginTx(ctx)
if err != nil { if err != nil {
@ -60,6 +62,13 @@ func (s *Store) CompletePracticeForUser(ctx context.Context, userID, questionSet
scope, err := q.GetPracticeScopeByQuestionSetID(ctx, questionSetID) scope, err := q.GetPracticeScopeByQuestionSetID(ctx, questionSetID)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// Exam-prep practices are not in lms_practices; completion is tracked in user_practice_progress only.
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit: %w", err)
}
return nil
}
return err return err
} }
var ( var (
@ -110,17 +119,6 @@ func (s *Store) CompletePracticeForUser(ctx context.Context, userID, questionSet
func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, userID int64, moduleID *int64, courseID, programID int64) error { func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, userID int64, moduleID *int64, courseID, programID int64) error {
if moduleID != nil { if moduleID != nil {
moduleLessonsTotal, err := q.CountLessonsInModule(ctx, *moduleID)
if err != nil {
return err
}
moduleLessonsDone, err := q.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{
ModuleID: *moduleID,
UserID: userID,
})
if err != nil {
return err
}
modulePracticesTotal, err := q.CountPublishedPracticesInModule(ctx, toPgInt8(moduleID)) modulePracticesTotal, err := q.CountPublishedPracticesInModule(ctx, toPgInt8(moduleID))
if err != nil { if err != nil {
return err return err
@ -133,9 +131,8 @@ func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, user
return err return err
} }
moduleLessonsComplete := moduleLessonsTotal > 0 && moduleLessonsDone >= moduleLessonsTotal modulePracticesComplete := modulePracticesTotal > 0 && modulePracticesDone >= modulePracticesTotal
modulePracticesComplete := modulePracticesDone >= modulePracticesTotal if !modulePracticesComplete {
if !moduleLessonsComplete || !modulePracticesComplete {
return nil return nil
} }
@ -169,7 +166,7 @@ func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, user
} }
courseModulesComplete := nMods > 0 && nDoneMods >= nMods courseModulesComplete := nMods > 0 && nDoneMods >= nMods
coursePracticesComplete := coursePracticesDone >= coursePracticesTotal coursePracticesComplete := coursePracticesTotal > 0 && coursePracticesDone >= coursePracticesTotal
if !courseModulesComplete || !coursePracticesComplete { if !courseModulesComplete || !coursePracticesComplete {
return nil return nil
} }
@ -203,7 +200,7 @@ func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, user
} }
programCoursesComplete := nCr > 0 && nCrDone >= nCr programCoursesComplete := nCr > 0 && nCrDone >= nCr
programPracticesComplete := programPracticesDone >= programPracticesTotal programPracticesComplete := programPracticesTotal > 0 && programPracticesDone >= programPracticesTotal
if !programCoursesComplete || !programPracticesComplete { if !programCoursesComplete || !programPracticesComplete {
return nil return nil
} }

View File

@ -5,9 +5,11 @@ import (
dbgen "Yimaru-Backend/gen/db" dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
"github.com/jackc/pgx/v5/pgtype"
) )
// GetLMSUserProgressSnapshot returns all completed lesson, module, course, and program IDs for a user. // GetLMSUserProgressSnapshot returns practice-based completed lesson, module, course,
// and program IDs for a user.
func (s *Store) GetLMSUserProgressSnapshot(ctx context.Context, userID int64) (domain.LMSUserProgress, error) { func (s *Store) GetLMSUserProgressSnapshot(ctx context.Context, userID int64) (domain.LMSUserProgress, error) {
lessons, err := s.queries.ListLMSCompletedLessonIDsByUser(ctx, userID) lessons, err := s.queries.ListLMSCompletedLessonIDsByUser(ctx, userID)
if err != nil { if err != nil {
@ -26,13 +28,31 @@ func (s *Store) GetLMSUserProgressSnapshot(ctx context.Context, userID int64) (d
return domain.LMSUserProgress{}, err return domain.LMSUserProgress{}, err
} }
return domain.LMSUserProgress{ return domain.LMSUserProgress{
LessonIDs: lessons, LessonIDs: pgInt8IDsToInt64(lessons),
ModuleIDs: mods, ModuleIDs: int64IDsOrEmpty(mods),
CourseIDs: courses, CourseIDs: int64IDsOrEmpty(courses),
ProgramIDs: programs, ProgramIDs: int64IDsOrEmpty(programs),
}, nil }, nil
} }
func pgInt8IDsToInt64(items []pgtype.Int8) []int64 {
out := make([]int64, 0, len(items))
for _, item := range items {
if !item.Valid {
continue
}
out = append(out, item.Int64)
}
return out
}
func int64IDsOrEmpty(items []int64) []int64 {
if items == nil {
return []int64{}
}
return items
}
// ListUserLMSFlatLearningActivity returns flattened LMS activity rows for admin reporting (lesson + practice completions). // ListUserLMSFlatLearningActivity returns flattened LMS activity rows for admin reporting (lesson + practice completions).
func (s *Store) ListUserLMSFlatLearningActivity(ctx context.Context, userID int64) ([]dbgen.ListUserLMSFlatLearningActivityByUserRow, error) { func (s *Store) ListUserLMSFlatLearningActivity(ctx context.Context, userID int64) ([]dbgen.ListUserLMSFlatLearningActivityByUserRow, error) {
return s.queries.ListUserLMSFlatLearningActivityByUser(ctx, userID) return s.queries.ListUserLMSFlatLearningActivityByUser(ctx, userID)

View File

@ -0,0 +1,41 @@
package repository
import (
"testing"
"github.com/jackc/pgx/v5/pgtype"
)
func TestPgInt8IDsToInt64ReturnsEmptySlice(t *testing.T) {
got := pgInt8IDsToInt64(nil)
if got == nil {
t.Fatal("expected empty slice, got nil")
}
if len(got) != 0 {
t.Fatalf("expected empty slice, got len=%d", len(got))
}
}
func TestPgInt8IDsToInt64FiltersInvalidIDs(t *testing.T) {
got := pgInt8IDsToInt64([]pgtype.Int8{
{Int64: 10, Valid: true},
{Valid: false},
{Int64: 20, Valid: true},
})
if len(got) != 2 {
t.Fatalf("expected 2 ids, got %d", len(got))
}
if got[0] != 10 || got[1] != 20 {
t.Fatalf("unexpected ids: %#v", got)
}
}
func TestInt64IDsOrEmptyReturnsEmptySlice(t *testing.T) {
got := int64IDsOrEmpty(nil)
if got == nil {
t.Fatal("expected empty slice, got nil")
}
if len(got) != 0 {
t.Fatalf("expected empty slice, got len=%d", len(got))
}
}

View File

@ -0,0 +1,228 @@
package repository
import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/ports"
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
func NewMobileAppVersionStore(s *Store) ports.MobileAppVersionStore { return s }
func mobileAppVersionToDomain(
id int64,
platform string,
versionName string,
versionCode int32,
updateType string,
releaseNotes pgtype.Text,
storeURL pgtype.Text,
minSupported pgtype.Int4,
status string,
createdAt pgtype.Timestamptz,
updatedAt pgtype.Timestamptz,
) domain.MobileAppVersion {
var minSupportedPtr *int32
if minSupported.Valid {
v := minSupported.Int32
minSupportedPtr = &v
}
return domain.MobileAppVersion{
ID: id,
Platform: platform,
VersionName: versionName,
VersionCode: versionCode,
UpdateType: updateType,
ReleaseNotes: fromPgText(releaseNotes),
StoreURL: fromPgText(storeURL),
MinSupportedVersionCode: minSupportedPtr,
Status: status,
CreatedAt: createdAt.Time,
UpdatedAt: timePtr(updatedAt),
}
}
func scanMobileAppVersion(row pgx.Row) (domain.MobileAppVersion, error) {
var (
id int64
platform string
versionName string
versionCode int32
updateType string
releaseNotes pgtype.Text
storeURL pgtype.Text
minSupported pgtype.Int4
status string
createdAt pgtype.Timestamptz
updatedAt pgtype.Timestamptz
)
if err := row.Scan(&id, &platform, &versionName, &versionCode, &updateType, &releaseNotes, &storeURL, &minSupported, &status, &createdAt, &updatedAt); err != nil {
return domain.MobileAppVersion{}, err
}
return mobileAppVersionToDomain(id, platform, versionName, versionCode, updateType, releaseNotes, storeURL, minSupported, status, createdAt, updatedAt), nil
}
const mobileAppVersionSelectCols = `
id, platform, version_name, version_code, update_type, release_notes, store_url,
min_supported_version_code, status, created_at, updated_at
`
func (s *Store) CreateMobileAppVersion(ctx context.Context, input domain.CreateMobileAppVersionInput) (domain.MobileAppVersion, error) {
updateType := domain.MobileAppUpdateTypeOptional
if input.UpdateType != nil {
updateType = *input.UpdateType
}
status := domain.MobileAppVersionStatusActive
if input.Status != nil {
status = *input.Status
}
row := s.conn.QueryRow(ctx, `
INSERT INTO mobile_app_versions (
platform, version_name, version_code, update_type, release_notes, store_url,
min_supported_version_code, status
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING `+mobileAppVersionSelectCols,
input.Platform,
input.VersionName,
input.VersionCode,
updateType,
toPgText(input.ReleaseNotes),
toPgText(input.StoreURL),
toPgInt4(input.MinSupportedVersionCode),
status,
)
return scanMobileAppVersion(row)
}
func (s *Store) UpdateMobileAppVersion(ctx context.Context, id int64, input domain.UpdateMobileAppVersionInput) (domain.MobileAppVersion, error) {
releaseNotesSet := input.ReleaseNotes != nil
var releaseNotesValue pgtype.Text
if releaseNotesSet {
releaseNotesValue = toPgText(input.ReleaseNotes)
}
storeURLSet := input.StoreURL != nil
var storeURLValue pgtype.Text
if storeURLSet {
storeURLValue = toPgText(input.StoreURL)
}
minSupportedSet := input.MinSupportedVersionCode != nil
var minSupportedValue pgtype.Int4
if minSupportedSet {
minSupportedValue = toPgInt4(input.MinSupportedVersionCode)
}
row := s.conn.QueryRow(ctx, `
UPDATE mobile_app_versions
SET version_name = COALESCE($2, version_name),
version_code = COALESCE($3, version_code),
update_type = COALESCE($4, update_type),
release_notes = CASE WHEN $5::boolean THEN $6 ELSE release_notes END,
store_url = CASE WHEN $7::boolean THEN $8 ELSE store_url END,
min_supported_version_code = CASE WHEN $9::boolean THEN $10::int ELSE min_supported_version_code END,
status = COALESCE($11, status),
updated_at = NOW()
WHERE id = $1
RETURNING `+mobileAppVersionSelectCols,
id,
input.VersionName,
input.VersionCode,
input.UpdateType,
releaseNotesSet,
releaseNotesValue,
storeURLSet,
storeURLValue,
minSupportedSet,
minSupportedValue,
input.Status,
)
return scanMobileAppVersion(row)
}
func (s *Store) GetMobileAppVersionByID(ctx context.Context, id int64) (domain.MobileAppVersion, error) {
row := s.conn.QueryRow(ctx, `
SELECT `+mobileAppVersionSelectCols+`
FROM mobile_app_versions
WHERE id = $1
`, id)
return scanMobileAppVersion(row)
}
func (s *Store) ListMobileAppVersions(ctx context.Context, platform *string, status *string, limit int32, offset int32) ([]domain.MobileAppVersion, int64, error) {
rows, err := s.conn.Query(ctx, `
SELECT `+mobileAppVersionSelectCols+`
FROM mobile_app_versions
WHERE ($1::text IS NULL OR platform = $1)
AND ($2::text IS NULL OR status = $2)
ORDER BY platform ASC, version_code DESC, id DESC
LIMIT $3 OFFSET $4
`, toPgText(platform), toPgText(status), limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
versions := make([]domain.MobileAppVersion, 0)
for rows.Next() {
var (
id int64
rowPlatform string
versionName string
versionCode int32
updateType string
releaseNotes pgtype.Text
storeURL pgtype.Text
minSupported pgtype.Int4
rowStatus string
createdAt pgtype.Timestamptz
updatedAt pgtype.Timestamptz
)
if err := rows.Scan(&id, &rowPlatform, &versionName, &versionCode, &updateType, &releaseNotes, &storeURL, &minSupported, &rowStatus, &createdAt, &updatedAt); err != nil {
return nil, 0, err
}
versions = append(versions, mobileAppVersionToDomain(id, rowPlatform, versionName, versionCode, updateType, releaseNotes, storeURL, minSupported, rowStatus, createdAt, updatedAt))
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
var totalCount int64
if err := s.conn.QueryRow(ctx, `
SELECT COUNT(*)
FROM mobile_app_versions
WHERE ($1::text IS NULL OR platform = $1)
AND ($2::text IS NULL OR status = $2)
`, toPgText(platform), toPgText(status)).Scan(&totalCount); err != nil {
return nil, 0, err
}
return versions, totalCount, nil
}
func (s *Store) DeleteMobileAppVersion(ctx context.Context, id int64) error {
cmd, err := s.conn.Exec(ctx, `DELETE FROM mobile_app_versions WHERE id = $1`, id)
if err != nil {
return err
}
if cmd.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
}
func (s *Store) GetLatestActiveMobileAppVersion(ctx context.Context, platform string) (domain.MobileAppVersion, error) {
row := s.conn.QueryRow(ctx, `
SELECT `+mobileAppVersionSelectCols+`
FROM mobile_app_versions
WHERE platform = $1
AND status = 'ACTIVE'
ORDER BY version_code DESC, id DESC
LIMIT 1
`, platform)
return scanMobileAppVersion(row)
}

View File

@ -0,0 +1,214 @@
package repository
import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/ports"
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
func NewProfileFieldOptionStore(s *Store) ports.ProfileFieldOptionStore { return s }
func profileFieldOptionToDomain(
id int64,
fieldKey, code, label string,
displayOrder int32,
status string,
createdAt pgtype.Timestamptz,
updatedAt pgtype.Timestamptz,
) domain.ProfileFieldOption {
return domain.ProfileFieldOption{
ID: id,
FieldKey: fieldKey,
Code: code,
Label: label,
DisplayOrder: displayOrder,
Status: status,
CreatedAt: createdAt.Time,
UpdatedAt: timePtr(updatedAt),
}
}
func (s *Store) CreateProfileFieldOption(ctx context.Context, input domain.CreateProfileFieldOptionInput) (domain.ProfileFieldOption, error) {
displayOrder := int32(0)
if input.DisplayOrder != nil {
displayOrder = *input.DisplayOrder
}
status := domain.ProfileFieldOptionStatusActive
if input.Status != nil {
status = *input.Status
}
row := s.conn.QueryRow(ctx, `
INSERT INTO field_options (field_key, code, label, display_order, status)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, field_key, code, label, display_order, status, created_at, updated_at
`, input.FieldKey, input.Code, input.Label, displayOrder, status)
var (
id int64
fieldKey string
code string
label string
orderVal int32
optionStatus string
createdAt pgtype.Timestamptz
updatedAt pgtype.Timestamptz
)
if err := row.Scan(&id, &fieldKey, &code, &label, &orderVal, &optionStatus, &createdAt, &updatedAt); err != nil {
return domain.ProfileFieldOption{}, err
}
return profileFieldOptionToDomain(id, fieldKey, code, label, orderVal, optionStatus, createdAt, updatedAt), nil
}
func (s *Store) UpdateProfileFieldOption(ctx context.Context, id int64, input domain.UpdateProfileFieldOptionInput) (domain.ProfileFieldOption, error) {
row := s.conn.QueryRow(ctx, `
UPDATE field_options
SET label = COALESCE($2, label),
display_order = COALESCE($3, display_order),
status = COALESCE($4, status),
updated_at = NOW()
WHERE id = $1
RETURNING id, field_key, code, label, display_order, status, created_at, updated_at
`, id, input.Label, input.DisplayOrder, input.Status)
var (
optionID int64
fieldKey string
code string
label string
orderVal int32
optionStatus string
createdAt pgtype.Timestamptz
updatedAt pgtype.Timestamptz
)
if err := row.Scan(&optionID, &fieldKey, &code, &label, &orderVal, &optionStatus, &createdAt, &updatedAt); err != nil {
return domain.ProfileFieldOption{}, err
}
return profileFieldOptionToDomain(optionID, fieldKey, code, label, orderVal, optionStatus, createdAt, updatedAt), nil
}
func (s *Store) GetProfileFieldOptionByID(ctx context.Context, id int64, includeInactive bool) (domain.ProfileFieldOption, error) {
row := s.conn.QueryRow(ctx, `
SELECT id, field_key, code, label, display_order, status, created_at, updated_at
FROM field_options
WHERE id = $1
AND ($2::boolean = TRUE OR status = 'ACTIVE')
`, id, includeInactive)
var (
optionID int64
fieldKey string
code string
label string
orderVal int32
optionStatus string
createdAt pgtype.Timestamptz
updatedAt pgtype.Timestamptz
)
if err := row.Scan(&optionID, &fieldKey, &code, &label, &orderVal, &optionStatus, &createdAt, &updatedAt); err != nil {
return domain.ProfileFieldOption{}, err
}
return profileFieldOptionToDomain(optionID, fieldKey, code, label, orderVal, optionStatus, createdAt, updatedAt), nil
}
func (s *Store) ListProfileFieldOptions(ctx context.Context, fieldKey *string, status *string, limit, offset int32) ([]domain.ProfileFieldOption, int64, error) {
rows, err := s.conn.Query(ctx, `
SELECT id, field_key, code, label, display_order, status, created_at, updated_at
FROM field_options
WHERE ($1::text IS NULL OR field_key = $1)
AND ($2::text IS NULL OR status = $2)
ORDER BY field_key ASC, display_order ASC, id ASC
LIMIT $3 OFFSET $4
`, toPgText(fieldKey), toPgText(status), limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
options := make([]domain.ProfileFieldOption, 0)
for rows.Next() {
var (
optionID int64
fk string
code string
label string
orderVal int32
optionStatus string
createdAt pgtype.Timestamptz
updatedAt pgtype.Timestamptz
)
if err := rows.Scan(&optionID, &fk, &code, &label, &orderVal, &optionStatus, &createdAt, &updatedAt); err != nil {
return nil, 0, err
}
options = append(options, profileFieldOptionToDomain(optionID, fk, code, label, orderVal, optionStatus, createdAt, updatedAt))
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
var totalCount int64
if err := s.conn.QueryRow(ctx, `
SELECT COUNT(*)
FROM field_options
WHERE ($1::text IS NULL OR field_key = $1)
AND ($2::text IS NULL OR status = $2)
`, toPgText(fieldKey), toPgText(status)).Scan(&totalCount); err != nil {
return nil, 0, err
}
return options, totalCount, nil
}
func (s *Store) ListActiveProfileFieldOptions(ctx context.Context, fieldKey *string) ([]domain.ProfileFieldOption, error) {
active := domain.ProfileFieldOptionStatusActive
options, _, err := s.ListProfileFieldOptions(ctx, fieldKey, &active, 500, 0)
return options, err
}
func (s *Store) IsActiveProfileFieldOption(ctx context.Context, fieldKey, code string) (bool, error) {
var exists bool
err := s.conn.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM field_options
WHERE field_key = $1 AND code = $2 AND status = 'ACTIVE'
)
`, fieldKey, code).Scan(&exists)
return exists, err
}
func (s *Store) ListDistinctFieldKeys(ctx context.Context, activeOnly bool) ([]string, error) {
rows, err := s.conn.Query(ctx, `
SELECT DISTINCT field_key
FROM field_options
WHERE ($1::boolean = FALSE OR status = 'ACTIVE')
ORDER BY field_key ASC
`, activeOnly)
if err != nil {
return nil, err
}
defer rows.Close()
keys := make([]string, 0)
for rows.Next() {
var key string
if err := rows.Scan(&key); err != nil {
return nil, err
}
keys = append(keys, key)
}
return keys, rows.Err()
}
func (s *Store) DeleteProfileFieldOption(ctx context.Context, id int64) error {
cmd, err := s.conn.Exec(ctx, `DELETE FROM field_options WHERE id = $1`, id)
if err != nil {
return err
}
if cmd.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
}

View File

@ -14,8 +14,9 @@ import (
func programToDomain(p dbgen.Program) domain.Program { func programToDomain(p dbgen.Program) domain.Program {
out := domain.Program{ out := domain.Program{
ID: p.ID, ID: p.ID,
Name: p.Name, Name: p.Name,
Category: p.Category,
} }
out.Description = fromPgText(p.Description) out.Description = fromPgText(p.Description)
out.Thumbnail = fromPgText(p.Thumbnail) out.Thumbnail = fromPgText(p.Thumbnail)
@ -42,6 +43,7 @@ func (s *Store) CreateProgram(ctx context.Context, input domain.CreateProgramInp
p, err := q.CreateProgram(ctx, dbgen.CreateProgramParams{ p, err := q.CreateProgram(ctx, dbgen.CreateProgramParams{
Name: input.Name, Name: input.Name,
Description: toPgText(input.Description), Description: toPgText(input.Description),
Category: input.Category,
Thumbnail: toPgText(input.Thumbnail), Thumbnail: toPgText(input.Thumbnail),
SortOrder: pgtype.Int4{Int32: target, Valid: true}, SortOrder: pgtype.Int4{Int32: target, Valid: true},
}) })
@ -57,6 +59,7 @@ func (s *Store) CreateProgram(ctx context.Context, input domain.CreateProgramInp
p, err := s.queries.CreateProgram(ctx, dbgen.CreateProgramParams{ p, err := s.queries.CreateProgram(ctx, dbgen.CreateProgramParams{
Name: input.Name, Name: input.Name,
Description: toPgText(input.Description), Description: toPgText(input.Description),
Category: input.Category,
Thumbnail: toPgText(input.Thumbnail), Thumbnail: toPgText(input.Thumbnail),
SortOrder: pgtype.Int4{Valid: false}, SortOrder: pgtype.Int4{Valid: false},
}) })
@ -102,6 +105,7 @@ func (s *Store) ListPrograms(ctx context.Context, limit, offset int32) ([]domain
ID: r.ID, ID: r.ID,
Name: r.Name, Name: r.Name,
Description: r.Description, Description: r.Description,
Category: r.Category,
Thumbnail: r.Thumbnail, Thumbnail: r.Thumbnail,
CreatedAt: r.CreatedAt, CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt, UpdatedAt: r.UpdatedAt,
@ -166,6 +170,7 @@ func (s *Store) UpdateProgram(ctx context.Context, id int64, input domain.Update
ID: id, ID: id,
Name: nameText, Name: nameText,
Description: optionalTextUpdate(input.Description), Description: optionalTextUpdate(input.Description),
Category: optionalTextUpdate(input.Category),
Thumbnail: optionalTextUpdate(input.Thumbnail), Thumbnail: optionalTextUpdate(input.Thumbnail),
SortOrder: pgtype.Int4{Valid: false}, SortOrder: pgtype.Int4{Valid: false},
}) })
@ -190,6 +195,7 @@ func (s *Store) UpdateProgram(ctx context.Context, id int64, input domain.Update
ID: id, ID: id,
Name: nameText, Name: nameText,
Description: optionalTextUpdate(input.Description), Description: optionalTextUpdate(input.Description),
Category: optionalTextUpdate(input.Category),
Thumbnail: optionalTextUpdate(input.Thumbnail), Thumbnail: optionalTextUpdate(input.Thumbnail),
SortOrder: sortParam, SortOrder: sortParam,
}) })

View File

@ -45,11 +45,12 @@ func (s *Store) CreateSubscriptionPlan(ctx context.Context, input domain.CreateS
plan, err := s.queries.CreateSubscriptionPlan(ctx, dbgen.CreateSubscriptionPlanParams{ plan, err := s.queries.CreateSubscriptionPlan(ctx, dbgen.CreateSubscriptionPlanParams{
Name: input.Name, Name: input.Name,
Description: toPgText(input.Description), Description: toPgText(input.Description),
Category: input.Category,
DurationValue: input.DurationValue, DurationValue: input.DurationValue,
DurationUnit: input.DurationUnit, DurationUnit: input.DurationUnit,
Price: toPgNumeric(input.Price), Price: toPgNumeric(input.Price),
Currency: input.Currency, Currency: input.Currency,
Column7: input.IsActive, Column8: input.IsActive,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@ -87,13 +88,14 @@ func (s *Store) ListSubscriptionPlans(ctx context.Context, activeOnly bool) ([]d
func (s *Store) UpdateSubscriptionPlan(ctx context.Context, id int64, input domain.UpdateSubscriptionPlanInput) error { func (s *Store) UpdateSubscriptionPlan(ctx context.Context, id int64, input domain.UpdateSubscriptionPlanInput) error {
return s.queries.UpdateSubscriptionPlan(ctx, dbgen.UpdateSubscriptionPlanParams{ return s.queries.UpdateSubscriptionPlan(ctx, dbgen.UpdateSubscriptionPlanParams{
Name: stringVal(input.Name), Name: optionalTextUpdate(input.Name),
Description: toPgText(input.Description), Description: optionalTextUpdate(input.Description),
DurationValue: int32Val(input.DurationValue), Category: optionalTextUpdate(input.Category),
DurationUnit: stringVal(input.DurationUnit), DurationValue: optionalInt4(input.DurationValue),
DurationUnit: optionalTextUpdate(input.DurationUnit),
Price: numericPtrToNumeric(input.Price), Price: numericPtrToNumeric(input.Price),
Currency: stringVal(input.Currency), Currency: optionalTextUpdate(input.Currency),
IsActive: boolPtrToBool(input.IsActive), IsActive: optionalBool(input.IsActive),
ID: id, ID: id,
}) })
} }
@ -215,6 +217,13 @@ func (s *Store) HasActiveSubscription(ctx context.Context, userID int64) (bool,
return s.queries.HasActiveSubscription(ctx, userID) return s.queries.HasActiveSubscription(ctx, userID)
} }
func (s *Store) HasActiveSubscriptionByCategory(ctx context.Context, userID int64, category string) (bool, error) {
return s.queries.HasActiveSubscriptionByCategory(ctx, dbgen.HasActiveSubscriptionByCategoryParams{
UserID: userID,
Category: category,
})
}
func (s *Store) CancelUserSubscription(ctx context.Context, id int64) error { func (s *Store) CancelUserSubscription(ctx context.Context, id int64) error {
return s.queries.CancelUserSubscription(ctx, id) return s.queries.CancelUserSubscription(ctx, id)
} }
@ -247,6 +256,7 @@ func subscriptionPlanToDomain(p dbgen.SubscriptionPlan) *domain.SubscriptionPlan
ID: p.ID, ID: p.ID,
Name: p.Name, Name: p.Name,
Description: fromPgText(p.Description), Description: fromPgText(p.Description),
Category: p.Category,
DurationValue: p.DurationValue, DurationValue: p.DurationValue,
DurationUnit: p.DurationUnit, DurationUnit: p.DurationUnit,
Price: fromPgNumeric(p.Price), Price: fromPgNumeric(p.Price),
@ -296,18 +306,11 @@ func userSubscriptionWithPlanToDomain(s dbgen.GetUserSubscriptionByIDRow) *domai
} }
} }
func stringVal(s *string) string { func optionalInt4(v *int32) pgtype.Int4 {
if s == nil { if v == nil {
return "" return pgtype.Int4{Valid: false}
} }
return *s return pgtype.Int4{Int32: *v, Valid: true}
}
func int32Val(i *int32) int32 {
if i == nil {
return 0
}
return *i
} }
func numericPtrToNumeric(val *float64) pgtype.Numeric { func numericPtrToNumeric(val *float64) pgtype.Numeric {
@ -317,11 +320,11 @@ func numericPtrToNumeric(val *float64) pgtype.Numeric {
return toPgNumeric(*val) return toPgNumeric(*val)
} }
func boolPtrToBool(b *bool) bool { func optionalBool(b *bool) pgtype.Bool {
if b == nil { if b == nil {
return false return pgtype.Bool{Valid: false}
} }
return *b return pgtype.Bool{Bool: *b, Valid: true}
} }
func float64Ptr(f float64) *float64 { func float64Ptr(f float64) *float64 {

View File

@ -178,18 +178,18 @@ func (s *Store) UpdateTeamMember(ctx context.Context, req domain.UpdateTeamMembe
} }
return s.queries.UpdateTeamMember(ctx, dbgen.UpdateTeamMemberParams{ return s.queries.UpdateTeamMember(ctx, dbgen.UpdateTeamMemberParams{
FirstName: req.FirstName, FirstName: optionalPgText(req.FirstName),
LastName: req.LastName, LastName: optionalPgText(req.LastName),
PhoneNumber: pgtype.Text{String: req.PhoneNumber, Valid: req.PhoneNumber != ""}, PhoneNumber: optionalPgText(req.PhoneNumber),
TeamRole: req.TeamRole, TeamRole: optionalPgText(req.TeamRole),
Department: pgtype.Text{String: req.Department, Valid: req.Department != ""}, Department: optionalPgText(req.Department),
JobTitle: pgtype.Text{String: req.JobTitle, Valid: req.JobTitle != ""}, JobTitle: optionalPgText(req.JobTitle),
EmploymentType: pgtype.Text{String: req.EmploymentType, Valid: req.EmploymentType != ""}, EmploymentType: optionalPgText(req.EmploymentType),
HireDate: hireDate, HireDate: hireDate,
ProfilePictureUrl: pgtype.Text{String: req.ProfilePictureURL, Valid: req.ProfilePictureURL != ""}, ProfilePictureUrl: optionalPgText(req.ProfilePictureURL),
Bio: pgtype.Text{String: req.Bio, Valid: req.Bio != ""}, Bio: optionalPgText(req.Bio),
WorkPhone: pgtype.Text{String: req.WorkPhone, Valid: req.WorkPhone != ""}, WorkPhone: optionalPgText(req.WorkPhone),
EmergencyContact: pgtype.Text{String: req.EmergencyContact, Valid: req.EmergencyContact != ""}, EmergencyContact: optionalPgText(req.EmergencyContact),
Permissions: permissionsJSON, Permissions: permissionsJSON,
UpdatedBy: pgtype.Int8{Int64: req.UpdatedBy, Valid: req.UpdatedBy > 0}, UpdatedBy: pgtype.Int8{Int64: req.UpdatedBy, Valid: req.UpdatedBy > 0},
ID: req.TeamMemberID, ID: req.TeamMemberID,
@ -516,3 +516,10 @@ func (s *Store) GetTeamRefreshTokenByToken(ctx context.Context, token string) (d
func (s *Store) RevokeTeamRefreshTokenByToken(ctx context.Context, token string) error { func (s *Store) RevokeTeamRefreshTokenByToken(ctx context.Context, token string) error {
return s.queries.RevokeTeamRefreshTokenByToken(ctx, token) return s.queries.RevokeTeamRefreshTokenByToken(ctx, token)
} }
func optionalPgText(value string) pgtype.Text {
if value == "" {
return pgtype.Text{}
}
return pgtype.Text{String: value, Valid: true}
}

View File

@ -0,0 +1,165 @@
package repository
import (
"context"
"errors"
"fmt"
"strings"
"time"
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
const videoWatchSessionGap = 30 * time.Minute
var (
ErrVideoContentNotFound = errors.New("video content not found")
ErrVideoContentHasNoURL = errors.New("content has no video")
)
func (s *Store) RecordVideoEngagementHeartbeat(
ctx context.Context,
userID int64,
input domain.VideoEngagementHeartbeatInput,
) (domain.VideoWatchSessionResponse, error) {
if err := s.validateVideoContent(ctx, input.ContentKind, input.ContentID); err != nil {
return domain.VideoWatchSessionResponse{}, err
}
q, tx, err := s.BeginTx(ctx)
if err != nil {
return domain.VideoWatchSessionResponse{}, fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
now := time.Now().UTC()
activeCutoff := now.Add(-videoWatchSessionGap)
position := int32(input.PositionSec)
duration := nullableInt32(input.DurationSec)
session, err := q.GetActiveVideoWatchSession(ctx, dbgen.GetActiveVideoWatchSessionParams{
UserID: userID,
ContentKind: input.ContentKind,
ContentID: input.ContentID,
LastHeartbeatAt: pgtype.Timestamptz{Time: activeCutoff, Valid: true},
})
var sessionID int64
if errors.Is(err, pgx.ErrNoRows) {
maxNum, err := q.GetMaxVideoWatchSessionNumber(ctx, dbgen.GetMaxVideoWatchSessionNumberParams{
UserID: userID,
ContentKind: input.ContentKind,
ContentID: input.ContentID,
})
if err != nil {
return domain.VideoWatchSessionResponse{}, err
}
inserted, err := q.InsertVideoWatchSession(ctx, dbgen.InsertVideoWatchSessionParams{
UserID: userID,
ContentKind: input.ContentKind,
ContentID: input.ContentID,
SessionNumber: maxNum + 1,
VideoDurationSec: duration,
MaxPositionSec: position,
})
if err != nil {
return domain.VideoWatchSessionResponse{}, err
}
session = inserted
sessionID = inserted.ID
} else if err != nil {
return domain.VideoWatchSessionResponse{}, err
} else {
sessionID = session.ID
if position > session.MaxPositionSec {
session.MaxPositionSec = position
}
if duration.Valid && duration.Int32 > 0 {
session.VideoDurationSec = duration
}
}
completedAt := session.CompletedAt
if !completedAt.Valid && session.VideoDurationSec.Valid && session.VideoDurationSec.Int32 > 0 {
threshold := int32(float64(session.VideoDurationSec.Int32) * float64(domain.VideoCompletionThresholdPercent) / 100.0)
if session.MaxPositionSec >= threshold {
completedAt = pgtype.Timestamptz{Time: now, Valid: true}
}
}
var endedAt pgtype.Timestamptz
if input.Ended {
endedAt = pgtype.Timestamptz{Time: now, Valid: true}
} else {
endedAt = session.EndedAt
}
updated, err := q.UpdateVideoWatchSession(ctx, dbgen.UpdateVideoWatchSessionParams{
ID: sessionID,
MaxPositionSec: session.MaxPositionSec,
VideoDurationSec: session.VideoDurationSec,
LastHeartbeatAt: pgtype.Timestamptz{Time: now, Valid: true},
CompletedAt: completedAt,
EndedAt: endedAt,
})
if err != nil {
return domain.VideoWatchSessionResponse{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.VideoWatchSessionResponse{}, fmt.Errorf("commit: %w", err)
}
return domain.VideoWatchSessionResponse{
SessionID: updated.ID,
SessionNumber: int(updated.SessionNumber),
MaxPositionSec: int(updated.MaxPositionSec),
Completed: updated.CompletedAt.Valid,
}, nil
}
func (s *Store) validateVideoContent(ctx context.Context, contentKind string, contentID int64) error {
switch contentKind {
case domain.VideoContentKindLMSLesson:
lesson, err := s.queries.GetLessonByID(ctx, contentID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrVideoContentNotFound
}
return err
}
if !hasVideoURL(lesson.VideoUrl) {
return ErrVideoContentHasNoURL
}
case domain.VideoContentKindExamPrepLesson:
lesson, err := s.queries.ExamPrepGetUnitModuleLessonByID(ctx, contentID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrVideoContentNotFound
}
return err
}
if !hasVideoURL(lesson.VideoUrl) {
return ErrVideoContentHasNoURL
}
default:
return fmt.Errorf("unsupported content kind: %s", contentKind)
}
return nil
}
func hasVideoURL(url pgtype.Text) bool {
return url.Valid && strings.TrimSpace(url.String) != ""
}
func nullableInt32(v int) pgtype.Int4 {
if v <= 0 {
return pgtype.Int4{Valid: false}
}
return pgtype.Int4{Int32: int32(v), Valid: true}
}

View File

@ -0,0 +1,237 @@
package appversions
import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/ports"
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
)
type Service struct {
store ports.MobileAppVersionStore
}
func NewService(store ports.MobileAppVersionStore) *Service {
return &Service{store: store}
}
func normalizePlatform(raw string) (string, error) {
value := strings.ToUpper(strings.TrimSpace(raw))
switch value {
case domain.MobileAppPlatformAndroid, domain.MobileAppPlatformIOS:
return value, nil
default:
return "", fmt.Errorf("platform must be one of %s, %s", domain.MobileAppPlatformAndroid, domain.MobileAppPlatformIOS)
}
}
func normalizeUpdateType(raw *string) (string, error) {
if raw == nil || strings.TrimSpace(*raw) == "" {
return domain.MobileAppUpdateTypeOptional, nil
}
value := strings.ToUpper(strings.TrimSpace(*raw))
switch value {
case domain.MobileAppUpdateTypeForce, domain.MobileAppUpdateTypeOptional:
return value, nil
default:
return "", fmt.Errorf("update_type must be one of %s, %s", domain.MobileAppUpdateTypeForce, domain.MobileAppUpdateTypeOptional)
}
}
func normalizeStatus(raw *string) (string, error) {
if raw == nil || strings.TrimSpace(*raw) == "" {
return domain.MobileAppVersionStatusActive, nil
}
value := strings.ToUpper(strings.TrimSpace(*raw))
switch value {
case domain.MobileAppVersionStatusActive, domain.MobileAppVersionStatusInactive:
return value, nil
default:
return "", fmt.Errorf("status must be one of %s, %s", domain.MobileAppVersionStatusActive, domain.MobileAppVersionStatusInactive)
}
}
func optionalTrimmedText(raw *string) *string {
if raw == nil {
return nil
}
trimmed := strings.TrimSpace(*raw)
if trimmed == "" {
empty := ""
return &empty
}
return &trimmed
}
func (s *Service) CreateMobileAppVersion(ctx context.Context, input domain.CreateMobileAppVersionInput) (domain.MobileAppVersion, error) {
platform, err := normalizePlatform(input.Platform)
if err != nil {
return domain.MobileAppVersion{}, err
}
input.Platform = platform
input.VersionName = strings.TrimSpace(input.VersionName)
if input.VersionName == "" {
return domain.MobileAppVersion{}, fmt.Errorf("version_name is required")
}
if input.VersionCode <= 0 {
return domain.MobileAppVersion{}, fmt.Errorf("version_code must be a positive integer")
}
if input.MinSupportedVersionCode != nil && *input.MinSupportedVersionCode <= 0 {
return domain.MobileAppVersion{}, fmt.Errorf("min_supported_version_code must be a positive integer")
}
if input.MinSupportedVersionCode != nil && *input.MinSupportedVersionCode > input.VersionCode {
return domain.MobileAppVersion{}, fmt.Errorf("min_supported_version_code cannot exceed version_code")
}
updateType, err := normalizeUpdateType(input.UpdateType)
if err != nil {
return domain.MobileAppVersion{}, err
}
input.UpdateType = &updateType
status, err := normalizeStatus(input.Status)
if err != nil {
return domain.MobileAppVersion{}, err
}
input.Status = &status
input.ReleaseNotes = optionalTrimmedText(input.ReleaseNotes)
input.StoreURL = optionalTrimmedText(input.StoreURL)
return s.store.CreateMobileAppVersion(ctx, input)
}
func (s *Service) UpdateMobileAppVersion(ctx context.Context, id int64, input domain.UpdateMobileAppVersionInput) (domain.MobileAppVersion, error) {
if id <= 0 {
return domain.MobileAppVersion{}, fmt.Errorf("invalid app version id")
}
if input.VersionName != nil {
trimmed := strings.TrimSpace(*input.VersionName)
if trimmed == "" {
return domain.MobileAppVersion{}, fmt.Errorf("version_name cannot be empty")
}
input.VersionName = &trimmed
}
if input.VersionCode != nil && *input.VersionCode <= 0 {
return domain.MobileAppVersion{}, fmt.Errorf("version_code must be a positive integer")
}
if input.MinSupportedVersionCode != nil && *input.MinSupportedVersionCode <= 0 {
return domain.MobileAppVersion{}, fmt.Errorf("min_supported_version_code must be a positive integer")
}
if input.UpdateType != nil {
updateType, err := normalizeUpdateType(input.UpdateType)
if err != nil {
return domain.MobileAppVersion{}, err
}
input.UpdateType = &updateType
}
if input.Status != nil {
status, err := normalizeStatus(input.Status)
if err != nil {
return domain.MobileAppVersion{}, err
}
input.Status = &status
}
input.ReleaseNotes = optionalTrimmedText(input.ReleaseNotes)
input.StoreURL = optionalTrimmedText(input.StoreURL)
updated, err := s.store.UpdateMobileAppVersion(ctx, id, input)
if err != nil {
return domain.MobileAppVersion{}, err
}
if updated.MinSupportedVersionCode != nil && *updated.MinSupportedVersionCode > updated.VersionCode {
return domain.MobileAppVersion{}, fmt.Errorf("min_supported_version_code cannot exceed version_code")
}
return updated, nil
}
func (s *Service) GetMobileAppVersionByID(ctx context.Context, id int64) (domain.MobileAppVersion, error) {
if id <= 0 {
return domain.MobileAppVersion{}, fmt.Errorf("invalid app version id")
}
return s.store.GetMobileAppVersionByID(ctx, id)
}
func (s *Service) ListMobileAppVersions(ctx context.Context, platform *string, status *string, limit int32, offset int32) ([]domain.MobileAppVersion, int64, error) {
if platform != nil {
normalized, err := normalizePlatform(*platform)
if err != nil {
return nil, 0, err
}
platform = &normalized
}
if status != nil {
normalized, err := normalizeStatus(status)
if err != nil {
return nil, 0, err
}
status = &normalized
}
if limit <= 0 {
limit = 20
}
if limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
return s.store.ListMobileAppVersions(ctx, platform, status, limit, offset)
}
func (s *Service) DeleteMobileAppVersion(ctx context.Context, id int64) error {
if id <= 0 {
return fmt.Errorf("invalid app version id")
}
return s.store.DeleteMobileAppVersion(ctx, id)
}
func (s *Service) CheckMobileAppVersion(ctx context.Context, platform string, clientVersionCode int32) (domain.MobileAppVersionCheckResult, error) {
normalizedPlatform, err := normalizePlatform(platform)
if err != nil {
return domain.MobileAppVersionCheckResult{}, err
}
if clientVersionCode <= 0 {
return domain.MobileAppVersionCheckResult{}, fmt.Errorf("version_code must be a positive integer")
}
latest, err := s.store.GetLatestActiveMobileAppVersion(ctx, normalizedPlatform)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.MobileAppVersionCheckResult{
Platform: normalizedPlatform,
ClientVersionCode: clientVersionCode,
UpdateAvailable: false,
}, nil
}
return domain.MobileAppVersionCheckResult{}, err
}
result := domain.MobileAppVersionCheckResult{
Platform: normalizedPlatform,
ClientVersionCode: clientVersionCode,
LatestVersionName: latest.VersionName,
LatestVersionCode: latest.VersionCode,
ReleaseNotes: latest.ReleaseNotes,
StoreURL: latest.StoreURL,
}
if clientVersionCode >= latest.VersionCode {
result.UpdateAvailable = false
return result, nil
}
result.UpdateAvailable = true
result.UpdateType = latest.UpdateType
result.ForceUpdate = latest.UpdateType == domain.MobileAppUpdateTypeForce
if latest.MinSupportedVersionCode != nil && clientVersionCode < *latest.MinSupportedVersionCode {
result.ForceUpdate = true
}
return result, nil
}

View File

@ -59,12 +59,12 @@ func (s *ArifpayService) InitiateSubscriptionPayment(ctx context.Context, userID
} }
// Check if user already has an active subscription // Check if user already has an active subscription
hasActive, err := s.subscriptionStore.HasActiveSubscription(ctx, userID) hasActive, err := s.subscriptionStore.HasActiveSubscriptionByCategory(ctx, userID, plan.Category)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to check active subscription: %w", err) return nil, fmt.Errorf("failed to check active subscription: %w", err)
} }
if hasActive { if hasActive {
return nil, errors.New("user already has an active subscription") return nil, errors.New("user already has an active subscription for this category")
} }
// Generate unique nonce // Generate unique nonce
@ -73,10 +73,14 @@ func (s *ArifpayService) InitiateSubscriptionPayment(ctx context.Context, userID
// Create payment record // Create payment record
payment, err := s.paymentStore.CreatePayment(ctx, domain.CreatePaymentInput{ payment, err := s.paymentStore.CreatePayment(ctx, domain.CreatePaymentInput{
UserID: userID, UserID: userID,
PlanID: &req.PlanID, PlanID: &req.PlanID,
Amount: plan.Price, Amount: plan.Price,
Currency: plan.Currency, Currency: plan.Currency,
PaymentMethod: func() *string {
v := string(domain.PaymentProviderArifPay)
return &v
}(),
Nonce: nonce, Nonce: nonce,
ExpiresAt: &expiresAt, ExpiresAt: &expiresAt,
}) })
@ -569,12 +573,12 @@ func (s *ArifpayService) InitiateDirectPayment(ctx context.Context, userID int64
} }
// Check if user already has an active subscription // Check if user already has an active subscription
hasActive, err := s.subscriptionStore.HasActiveSubscription(ctx, userID) hasActive, err := s.subscriptionStore.HasActiveSubscriptionByCategory(ctx, userID, plan.Category)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to check active subscription: %w", err) return nil, fmt.Errorf("failed to check active subscription: %w", err)
} }
if hasActive { if hasActive {
return nil, errors.New("user already has an active subscription") return nil, errors.New("user already has an active subscription for this category")
} }
// Generate unique nonce // Generate unique nonce

View File

@ -106,7 +106,7 @@ func (s *Service) LoginWithGoogleAndroid(
payload, err := idtoken.Validate(ctx, idToken, clientID) payload, err := idtoken.Validate(ctx, idToken, clientID)
if err != nil { if err != nil {
return domain.LoginSuccess{}, errors.New("invalid google id token") return domain.LoginSuccess{}, fmt.Errorf("invalid google id token: %w", err)
} }
email, _ := payload.Claims["email"].(string) email, _ := payload.Claims["email"].(string)

View File

@ -76,12 +76,12 @@ func (s *Service) InitiateSubscriptionPayment(ctx context.Context, userID int64,
return nil, errors.New("subscription plan is not active") return nil, errors.New("subscription plan is not active")
} }
hasActive, err := s.subscriptionStore.HasActiveSubscription(ctx, userID) hasActive, err := s.subscriptionStore.HasActiveSubscriptionByCategory(ctx, userID, plan.Category)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to check active subscription: %w", err) return nil, fmt.Errorf("failed to check active subscription: %w", err)
} }
if hasActive { if hasActive {
return nil, errors.New("user already has an active subscription") return nil, errors.New("user already has an active subscription for this category")
} }
user, err := s.userStore.GetUserByID(ctx, userID) user, err := s.userStore.GetUserByID(ctx, userID)
@ -115,10 +115,14 @@ func (s *Service) InitiateSubscriptionPayment(ctx context.Context, userID int64,
expiresAt := time.Now().Add(3 * time.Hour) expiresAt := time.Now().Add(3 * time.Hour)
payment, err := s.paymentStore.CreatePayment(ctx, domain.CreatePaymentInput{ payment, err := s.paymentStore.CreatePayment(ctx, domain.CreatePaymentInput{
UserID: userID, UserID: userID,
PlanID: &req.PlanID, PlanID: &req.PlanID,
Amount: plan.Price, Amount: plan.Price,
Currency: plan.Currency, Currency: plan.Currency,
PaymentMethod: func() *string {
v := string(domain.PaymentProviderChapa)
return &v
}(),
Nonce: txRef, Nonce: txRef,
ExpiresAt: &expiresAt, ExpiresAt: &expiresAt,
}) })
@ -279,6 +283,10 @@ func (s *Service) VerifyPayment(ctx context.Context, txRef string) (*domain.Paym
return s.lookupPayment(ctx, txRef) return s.lookupPayment(ctx, txRef)
} }
func (s *Service) LookupPayment(ctx context.Context, ref string) (*domain.Payment, error) {
return s.lookupPayment(ctx, ref)
}
func (s *Service) fetchVerifiedTransaction(ctx context.Context, txRef string) (domain.ChapaTransactionData, error) { func (s *Service) fetchVerifiedTransaction(ctx context.Context, txRef string) (domain.ChapaTransactionData, error) {
url := strings.TrimSuffix(s.cfg.CHAPA_BASE_URL, "/") + "/transaction/verify/" + txRef url := strings.TrimSuffix(s.cfg.CHAPA_BASE_URL, "/") + "/transaction/verify/" + txRef
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)

View File

@ -3,6 +3,7 @@ package lmsprogress
import ( import (
"context" "context"
"errors" "errors"
"math"
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/repository" "Yimaru-Backend/internal/repository"
@ -144,115 +145,175 @@ func (s *Service) CanAccessLesson(ctx context.Context, userID, lessonID int64) (
return true, "", nil return true, "", nil
} }
// ApplyAccessProgram sets p.Access for a learner. Non-learners: clears Access to omit from JSON. // ApplyAccessProgram sets p.Access for learner roles. Staff roles omit Access from JSON.
// STUDENT: is_accessible reflects sequential prerequisites; OPEN_LEARNER: always true.
func (s *Service) ApplyAccessProgram(ctx context.Context, role domain.Role, userID int64, p *domain.Program) error { func (s *Service) ApplyAccessProgram(ctx context.Context, role domain.Role, userID int64, p *domain.Program) error {
if !role.UsesLMSSequentialGating() { if !role.IsCustomerLearnerRole() {
p.Access = nil p.Access = nil
return nil return nil
} }
ok, reason, err := s.CanAccessProgram(ctx, userID, p.ID)
if err != nil {
return err
}
done, err := s.store.LmsUserHasProgramProgress(ctx, userID, p.ID)
if err != nil {
return err
}
comp, tot, err := s.store.LmsUserLessonProgressInProgram(ctx, userID, p.ID) comp, tot, err := s.store.LmsUserLessonProgressInProgram(ctx, userID, p.ID)
if err != nil { if err != nil {
return err return err
} }
c, t, pct := lmsProgressCounts(comp, tot, done) done := lmsProgressComplete(comp, tot)
p.Access = &domain.LMSEntityAccess{ ok, reason := true, ""
IsAccessible: ok, IsCompleted: done, Reason: reasonIf(ok, reason), if role.UsesLMSSequentialGating() {
CompletedCount: c, TotalCount: t, ProgressPercent: pct, ok, reason, err = s.CanAccessProgram(ctx, userID, p.ID)
if err != nil {
return err
}
} }
p.Access = buildLMSEntityAccess(ok, reason, done, comp, tot)
return nil return nil
} }
// ApplyAccessCourse sets c.Access for a learner. // ApplyAccessCourse sets c.Access for learner roles.
func (s *Service) ApplyAccessCourse(ctx context.Context, role domain.Role, userID int64, c *domain.Course) error { func (s *Service) ApplyAccessCourse(ctx context.Context, role domain.Role, userID int64, c *domain.Course) error {
if !role.UsesLMSSequentialGating() { if !role.IsCustomerLearnerRole() {
c.Access = nil c.Access = nil
return nil return nil
} }
ok, reason, err := s.CanAccessCourse(ctx, userID, c.ID)
if err != nil {
return err
}
done, err := s.store.LmsUserHasCourseProgress(ctx, userID, c.ID)
if err != nil {
return err
}
comp, tot, err := s.store.LmsUserLessonProgressInCourse(ctx, userID, c.ID) comp, tot, err := s.store.LmsUserLessonProgressInCourse(ctx, userID, c.ID)
if err != nil { if err != nil {
return err return err
} }
cc, tt, pct := lmsProgressCounts(comp, tot, done) done := lmsProgressComplete(comp, tot)
c.Access = &domain.LMSEntityAccess{ ok, reason := true, ""
IsAccessible: ok, IsCompleted: done, Reason: reasonIf(ok, reason), if role.UsesLMSSequentialGating() {
CompletedCount: cc, TotalCount: tt, ProgressPercent: pct, ok, reason, err = s.CanAccessCourse(ctx, userID, c.ID)
if err != nil {
return err
}
} }
c.Access = buildLMSEntityAccess(ok, reason, done, comp, tot)
return nil return nil
} }
// ApplyAccessModule sets m.Access for a learner. // ApplyAccessModule sets m.Access for learner roles.
func (s *Service) ApplyAccessModule(ctx context.Context, role domain.Role, userID int64, m *domain.Module) error { func (s *Service) ApplyAccessModule(ctx context.Context, role domain.Role, userID int64, m *domain.Module) error {
if !role.UsesLMSSequentialGating() { if !role.IsCustomerLearnerRole() {
m.Access = nil m.Access = nil
return nil return nil
} }
ok, reason, err := s.CanAccessModule(ctx, userID, m.ID)
if err != nil {
return err
}
done, err := s.store.LmsUserHasModuleProgress(ctx, userID, m.ID)
if err != nil {
return err
}
comp, tot, err := s.store.LmsUserLessonProgressInModule(ctx, userID, m.ID) comp, tot, err := s.store.LmsUserLessonProgressInModule(ctx, userID, m.ID)
if err != nil { if err != nil {
return err return err
} }
cc, tt, pct := lmsProgressCounts(comp, tot, done) done := lmsProgressComplete(comp, tot)
m.Access = &domain.LMSEntityAccess{ ok, reason := true, ""
IsAccessible: ok, IsCompleted: done, Reason: reasonIf(ok, reason), if role.UsesLMSSequentialGating() {
CompletedCount: cc, TotalCount: tt, ProgressPercent: pct, ok, reason, err = s.CanAccessModule(ctx, userID, m.ID)
if err != nil {
return err
}
} }
m.Access = buildLMSEntityAccess(ok, reason, done, comp, tot)
return nil return nil
} }
// ApplyAccessLesson sets l.Access for a learner. // ApplyAccessLesson sets l.Access for learner roles.
func (s *Service) ApplyAccessLesson(ctx context.Context, role domain.Role, userID int64, les *domain.Lesson) error { func (s *Service) ApplyAccessLesson(ctx context.Context, role domain.Role, userID int64, les *domain.Lesson) error {
if !role.UsesLMSSequentialGating() { if !role.IsCustomerLearnerRole() {
les.Access = nil les.Access = nil
return nil return nil
} }
ok, reason, err := s.CanAccessLesson(ctx, userID, les.ID) comp, tot, err := s.store.LmsUserPracticeProgressInLesson(ctx, userID, les.ID)
if err != nil { if err != nil {
return err return err
} }
done, err := s.store.LmsUserHasLessonProgress(ctx, userID, les.ID) done := lmsProgressComplete(comp, tot)
if err != nil { ok, reason := true, ""
return err if role.UsesLMSSequentialGating() {
} ok, reason, err = s.CanAccessLesson(ctx, userID, les.ID)
var comp, tot int32 if err != nil {
if done { return err
comp, tot = 1, 1 }
} else {
comp, tot = 0, 1
}
c, t, pct := lmsProgressCounts(comp, tot, done)
les.Access = &domain.LMSEntityAccess{
IsAccessible: ok, IsCompleted: done, Reason: reasonIf(ok, reason),
CompletedCount: c, TotalCount: t, ProgressPercent: pct,
} }
les.Access = buildLMSEntityAccess(ok, reason, done, comp, tot)
return nil return nil
} }
// ApplyExamPrepAccessCatalogCourse sets progress on an exam-prep catalog course for learner roles.
func (s *Service) ApplyExamPrepAccessCatalogCourse(ctx context.Context, role domain.Role, userID int64, cc *domain.ExamPrepCatalogCourse) error {
if !role.IsCustomerLearnerRole() {
cc.Access = nil
return nil
}
comp, tot, err := s.store.ExamPrepUserPracticeProgressInCatalogCourse(ctx, userID, cc.ID)
if err != nil {
return err
}
done := lmsProgressComplete(comp, tot)
cc.Access = buildLMSEntityAccess(true, "", done, comp, tot)
return nil
}
// ApplyExamPrepAccessUnit sets progress on an exam-prep unit for learner roles.
func (s *Service) ApplyExamPrepAccessUnit(ctx context.Context, role domain.Role, userID int64, u *domain.ExamPrepUnit) error {
if !role.IsCustomerLearnerRole() {
u.Access = nil
return nil
}
comp, tot, err := s.store.ExamPrepUserPracticeProgressInUnit(ctx, userID, u.ID)
if err != nil {
return err
}
done := lmsProgressComplete(comp, tot)
u.Access = buildLMSEntityAccess(true, "", done, comp, tot)
return nil
}
// ApplyExamPrepAccessModule sets progress on an exam-prep module for learner roles.
func (s *Service) ApplyExamPrepAccessModule(ctx context.Context, role domain.Role, userID int64, m *domain.ExamPrepModule) error {
if !role.IsCustomerLearnerRole() {
m.Access = nil
return nil
}
comp, tot, err := s.store.ExamPrepUserPracticeProgressInModule(ctx, userID, m.ID)
if err != nil {
return err
}
done := lmsProgressComplete(comp, tot)
m.Access = buildLMSEntityAccess(true, "", done, comp, tot)
return nil
}
// ApplyExamPrepAccessLesson sets progress on an exam-prep lesson for learner roles.
func (s *Service) ApplyExamPrepAccessLesson(ctx context.Context, role domain.Role, userID int64, les *domain.ExamPrepLesson) error {
if !role.IsCustomerLearnerRole() {
les.Access = nil
return nil
}
comp, tot, err := s.store.ExamPrepUserPracticeProgressInLesson(ctx, userID, les.ID)
if err != nil {
return err
}
done := lmsProgressComplete(comp, tot)
les.Access = buildLMSEntityAccess(true, "", done, comp, tot)
return nil
}
func lmsProgressComplete(completed, total int32) bool {
return total > 0 && completed >= total
}
func buildLMSEntityAccess(ok bool, reason string, done bool, completed, total int32) *domain.LMSEntityAccess {
c, t, pct, pctPrecise := lmsProgressCounts(completed, total, done)
return &domain.LMSEntityAccess{
IsAccessible: ok,
IsCompleted: done,
Reason: reasonIf(ok, reason),
CompletedCount: c,
TotalCount: t,
ProgressPercent: pct,
ProgressPercentPrecise: pctPrecise,
}
}
// lmsProgressCounts maps DB lesson completion counts to UI fields. Percent is 0100; completed // lmsProgressCounts maps DB lesson completion counts to UI fields. Percent is 0100; completed
// and total are aligned with isCompleted when the entity is fully done. // and total are aligned with isCompleted when the entity is fully done.
func lmsProgressCounts(completed, total int32, isCompleted bool) (c, t, pct int) { func lmsProgressCounts(completed, total int32, isCompleted bool) (c, t, pct int, pctPrecise float64) {
c, t = int(completed), int(total) c, t = int(completed), int(total)
if t < 0 { if t < 0 {
t = 0 t = 0
@ -262,18 +323,22 @@ func lmsProgressCounts(completed, total int32, isCompleted bool) (c, t, pct int)
} }
if isCompleted { if isCompleted {
if t > 0 { if t > 0 {
return t, t, 100 return t, t, 100, 100
} }
return c, t, 100 return c, t, 100, 100
} }
if t == 0 { if t == 0 {
return 0, 0, 0 return 0, 0, 0, 0
} }
pct = (c * 100) / t pct = (c * 100) / t
if pct > 100 { if pct > 100 {
pct = 100 pct = 100
} }
return c, t, pct pctPrecise = math.Round((float64(c)*10000)/float64(t)) / 100
if pctPrecise > 100 {
pctPrecise = 100
}
return c, t, pct, pctPrecise
} }
func reasonIf(ok bool, r string) string { func reasonIf(ok bool, r string) string {

View File

@ -0,0 +1,97 @@
package lmsprogress
import "testing"
func TestLMSProgressCounts(t *testing.T) {
tests := []struct {
name string
completed int32
total int32
isCompleted bool
wantCompleted int
wantTotal int
wantPercent int
wantPercentFloat float64
}{
{
name: "fractional progress rounds to two decimals",
completed: 1,
total: 3,
wantCompleted: 1,
wantTotal: 3,
wantPercent: 33,
wantPercentFloat: 33.33,
},
{
name: "larger fraction rounds precisely",
completed: 2,
total: 3,
wantCompleted: 2,
wantTotal: 3,
wantPercent: 66,
wantPercentFloat: 66.67,
},
{
name: "completed forces full progress",
completed: 2,
total: 3,
isCompleted: true,
wantCompleted: 3,
wantTotal: 3,
wantPercent: 100,
wantPercentFloat: 100,
},
{
name: "empty scope stays zeroed",
wantCompleted: 0,
wantTotal: 0,
wantPercent: 0,
wantPercentFloat: 0,
},
{
name: "negative counts are sanitized",
completed: -2,
total: -5,
wantCompleted: 0,
wantTotal: 0,
wantPercent: 0,
wantPercentFloat: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotCompleted, gotTotal, gotPercent, gotPercentFloat := lmsProgressCounts(tt.completed, tt.total, tt.isCompleted)
if gotCompleted != tt.wantCompleted || gotTotal != tt.wantTotal {
t.Fatalf("counts=(%d,%d), want (%d,%d)", gotCompleted, gotTotal, tt.wantCompleted, tt.wantTotal)
}
if gotPercent != tt.wantPercent {
t.Fatalf("progress_percent=%d, want %d", gotPercent, tt.wantPercent)
}
if gotPercentFloat != tt.wantPercentFloat {
t.Fatalf("progress_percent_precise=%v, want %v", gotPercentFloat, tt.wantPercentFloat)
}
})
}
}
func TestLMSProgressComplete(t *testing.T) {
tests := []struct {
name string
completed int32
total int32
want bool
}{
{name: "complete when all practices done", completed: 3, total: 3, want: true},
{name: "incomplete when practices remain", completed: 2, total: 3, want: false},
{name: "zero total is not completed", completed: 0, total: 0, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := lmsProgressComplete(tt.completed, tt.total); got != tt.want {
t.Fatalf("lmsProgressComplete(%d, %d)=%v, want %v", tt.completed, tt.total, got, tt.want)
}
})
}
}

View File

@ -0,0 +1,210 @@
package profilefieldoptions
import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/ports"
"context"
"fmt"
"regexp"
"strings"
)
var optionCodePattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`)
type Service struct {
store ports.ProfileFieldOptionStore
}
func NewService(store ports.ProfileFieldOptionStore) *Service {
return &Service{store: store}
}
func normalizeOptionStatus(status *string) (string, error) {
if status == nil || strings.TrimSpace(*status) == "" {
return domain.ProfileFieldOptionStatusActive, nil
}
value := strings.ToUpper(strings.TrimSpace(*status))
switch value {
case domain.ProfileFieldOptionStatusActive, domain.ProfileFieldOptionStatusInactive:
return value, nil
default:
return "", fmt.Errorf("status must be one of %s, %s", domain.ProfileFieldOptionStatusActive, domain.ProfileFieldOptionStatusInactive)
}
}
func normalizeOptionCode(code string) (string, error) {
normalized := strings.ToUpper(strings.TrimSpace(code))
normalized = strings.ReplaceAll(normalized, " ", "_")
normalized = strings.ReplaceAll(normalized, "-", "_")
if !optionCodePattern.MatchString(normalized) {
return "", domain.ErrInvalidOptionCode
}
return normalized, nil
}
var fieldKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
func normalizeFieldKey(fieldKey string) (string, error) {
key := strings.TrimSpace(strings.ToLower(fieldKey))
if !fieldKeyPattern.MatchString(key) {
return "", domain.ErrInvalidFieldKey
}
return key, nil
}
func (s *Service) CreateProfileFieldOption(ctx context.Context, input domain.CreateProfileFieldOptionInput) (domain.ProfileFieldOption, error) {
fieldKey, err := normalizeFieldKey(input.FieldKey)
if err != nil {
return domain.ProfileFieldOption{}, err
}
code, err := normalizeOptionCode(input.Code)
if err != nil {
return domain.ProfileFieldOption{}, err
}
label := strings.TrimSpace(input.Label)
if label == "" {
return domain.ProfileFieldOption{}, fmt.Errorf("label is required")
}
status, err := normalizeOptionStatus(input.Status)
if err != nil {
return domain.ProfileFieldOption{}, err
}
input.FieldKey = fieldKey
input.Code = code
input.Label = label
input.Status = &status
return s.store.CreateProfileFieldOption(ctx, input)
}
func (s *Service) UpdateProfileFieldOption(ctx context.Context, id int64, input domain.UpdateProfileFieldOptionInput) (domain.ProfileFieldOption, error) {
if id <= 0 {
return domain.ProfileFieldOption{}, fmt.Errorf("invalid profile field option id")
}
if input.Label != nil {
trimmed := strings.TrimSpace(*input.Label)
if trimmed == "" {
return domain.ProfileFieldOption{}, fmt.Errorf("label cannot be empty")
}
input.Label = &trimmed
}
if input.Status != nil {
status, err := normalizeOptionStatus(input.Status)
if err != nil {
return domain.ProfileFieldOption{}, err
}
input.Status = &status
}
return s.store.UpdateProfileFieldOption(ctx, id, input)
}
func (s *Service) GetProfileFieldOptionByID(ctx context.Context, id int64, includeInactive bool) (domain.ProfileFieldOption, error) {
if id <= 0 {
return domain.ProfileFieldOption{}, fmt.Errorf("invalid profile field option id")
}
return s.store.GetProfileFieldOptionByID(ctx, id, includeInactive)
}
func (s *Service) ListProfileFieldOptions(ctx context.Context, fieldKey *string, status *string, limit, offset int32) ([]domain.ProfileFieldOption, int64, error) {
if fieldKey != nil {
normalized, err := normalizeFieldKey(*fieldKey)
if err != nil {
return nil, 0, err
}
fieldKey = &normalized
}
if status != nil {
normalized, err := normalizeOptionStatus(status)
if err != nil {
return nil, 0, err
}
status = &normalized
}
if limit <= 0 {
limit = 50
}
if limit > 500 {
limit = 500
}
if offset < 0 {
offset = 0
}
return s.store.ListProfileFieldOptions(ctx, fieldKey, status, limit, offset)
}
func (s *Service) ListActiveOptionsGrouped(ctx context.Context, fieldKey *string) (domain.ProfileFieldOptionsGrouped, error) {
if fieldKey != nil {
normalized, err := normalizeFieldKey(*fieldKey)
if err != nil {
return nil, err
}
fieldKey = &normalized
}
options, err := s.store.ListActiveProfileFieldOptions(ctx, fieldKey)
if err != nil {
return nil, err
}
grouped := make(domain.ProfileFieldOptionsGrouped)
for _, opt := range options {
grouped[opt.FieldKey] = append(grouped[opt.FieldKey], domain.ProfileFieldOptionItem{
Code: opt.Code,
Label: opt.Label,
})
}
return grouped, nil
}
func (s *Service) ListFieldKeys(ctx context.Context, activeOnly bool) ([]string, error) {
return s.store.ListDistinctFieldKeys(ctx, activeOnly)
}
func (s *Service) DeleteProfileFieldOption(ctx context.Context, id int64) error {
if id <= 0 {
return fmt.Errorf("invalid profile field option id")
}
return s.store.DeleteProfileFieldOption(ctx, id)
}
func (s *Service) ValidateUserProfileFieldValues(ctx context.Context, req domain.UpdateUserReq) error {
checks := []struct {
fieldKey string
value string
}{
{domain.ProfileFieldEducationLevel, req.EducationLevel},
{domain.ProfileFieldOccupation, req.Occupation},
{domain.ProfileFieldLearningGoal, req.LearningGoal},
{domain.ProfileFieldLanguageChallange, req.LanguageChallange},
{domain.ProfileFieldLanguageGoal, req.LanguageGoal},
{domain.ProfileFieldFavouriteTopic, req.FavouriteTopic},
}
for _, check := range checks {
value := strings.TrimSpace(check.value)
if value == "" {
continue
}
ok, err := s.store.IsActiveProfileFieldOption(ctx, check.fieldKey, value)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("invalid value for %s: %s", check.fieldKey, value)
}
}
if req.AgeGroup != nil {
age := strings.TrimSpace(string(*req.AgeGroup))
if age != "" {
ok, err := s.store.IsActiveProfileFieldOption(ctx, domain.ProfileFieldAgeGroup, age)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("invalid value for %s: %s", domain.ProfileFieldAgeGroup, age)
}
}
}
return nil
}

View File

@ -117,6 +117,7 @@ var AllPermissions = []domain.PermissionSeed{
{Key: "videos.update", Name: "Update Video", Description: "Update a video", GroupName: "Videos"}, {Key: "videos.update", Name: "Update Video", Description: "Update a video", GroupName: "Videos"},
{Key: "videos.delete", Name: "Delete Video", Description: "Delete a video", GroupName: "Videos"}, {Key: "videos.delete", Name: "Delete Video", Description: "Delete a video", GroupName: "Videos"},
{Key: "videos.reorder", Name: "Reorder Videos", Description: "Reorder videos", GroupName: "Videos"}, {Key: "videos.reorder", Name: "Reorder Videos", Description: "Reorder videos", GroupName: "Videos"},
{Key: "videos.track_engagement", Name: "Track Video Engagement", Description: "Report video playback heartbeats for analytics", GroupName: "Videos"},
// Learning Tree // Learning Tree
{Key: "learning_tree.get", Name: "Get Learning Tree", Description: "Get full learning tree", GroupName: "Learning Tree"}, {Key: "learning_tree.get", Name: "Get Learning Tree", Description: "Get full learning tree", GroupName: "Learning Tree"},
@ -246,6 +247,13 @@ var AllPermissions = []domain.PermissionSeed{
{Key: "faqs.update", Name: "Update FAQ", Description: "Update a FAQ item", GroupName: "FAQs"}, {Key: "faqs.update", Name: "Update FAQ", Description: "Update a FAQ item", GroupName: "FAQs"},
{Key: "faqs.delete", Name: "Delete FAQ", Description: "Delete a FAQ item", GroupName: "FAQs"}, {Key: "faqs.delete", Name: "Delete FAQ", Description: "Delete a FAQ item", GroupName: "FAQs"},
// Mobile app versions
{Key: "app_versions.create", Name: "Create App Version", Description: "Create a mobile app version release", GroupName: "App Versions"},
{Key: "app_versions.list", Name: "List App Versions", Description: "List mobile app versions for admin management", GroupName: "App Versions"},
{Key: "app_versions.get", Name: "Get App Version", Description: "Get mobile app version by ID", GroupName: "App Versions"},
{Key: "app_versions.update", Name: "Update App Version", Description: "Update a mobile app version release", GroupName: "App Versions"},
{Key: "app_versions.delete", Name: "Delete App Version", Description: "Delete a mobile app version release", GroupName: "App Versions"},
// Email templates // Email templates
{Key: "email_templates.create", Name: "Create Email Template", Description: "Create an email template", GroupName: "Email Templates"}, {Key: "email_templates.create", Name: "Create Email Template", Description: "Create an email template", GroupName: "Email Templates"},
{Key: "email_templates.list", Name: "List Email Templates", Description: "List email templates for admin management", GroupName: "Email Templates"}, {Key: "email_templates.list", Name: "List Email Templates", Description: "List email templates for admin management", GroupName: "Email Templates"},
@ -254,6 +262,13 @@ var AllPermissions = []domain.PermissionSeed{
{Key: "email_templates.delete", Name: "Delete Email Template", Description: "Delete a custom email template", GroupName: "Email Templates"}, {Key: "email_templates.delete", Name: "Delete Email Template", Description: "Delete a custom email template", GroupName: "Email Templates"},
{Key: "email_templates.preview", Name: "Preview Email Template", Description: "Preview a rendered email template", GroupName: "Email Templates"}, {Key: "email_templates.preview", Name: "Preview Email Template", Description: "Preview a rendered email template", GroupName: "Email Templates"},
// Field options (dropdown config: profile, countries, etc.)
{Key: "field_options.create", Name: "Create Field Option", Description: "Create a configurable dropdown option", GroupName: "Field Options"},
{Key: "field_options.list", Name: "List Field Options", Description: "List field options for admin management", GroupName: "Field Options"},
{Key: "field_options.get", Name: "Get Field Option", Description: "Get field option by ID", GroupName: "Field Options"},
{Key: "field_options.update", Name: "Update Field Option", Description: "Update a field option", GroupName: "Field Options"},
{Key: "field_options.delete", Name: "Delete Field Option", Description: "Delete a field option", GroupName: "Field Options"},
// Analytics // Analytics
{Key: "analytics.dashboard", Name: "View Dashboard", Description: "View analytics dashboard", GroupName: "Analytics"}, {Key: "analytics.dashboard", Name: "View Dashboard", Description: "View analytics dashboard", GroupName: "Analytics"},
@ -331,7 +346,7 @@ var defaultStudentLearnerPermissions = []string{
"lessons.get", "lessons.list_by_module", "lessons.complete", "lessons.get", "lessons.list_by_module", "lessons.complete",
"practices.get", "practices.list", "practices.get", "practices.list",
"subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active", "subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active",
"videos.get", "videos.list_by_subcourse", "videos.list_published", "videos.get", "videos.list_by_subcourse", "videos.list_published", "videos.track_engagement",
"learning_tree.get", "learning_tree.get",
"programs.list", "programs.get", "programs.list", "programs.get",
@ -460,9 +475,15 @@ var DefaultRolePermissions = map[string][]string{
// FAQs // FAQs
"faqs.create", "faqs.list", "faqs.get", "faqs.update", "faqs.delete", "faqs.create", "faqs.list", "faqs.get", "faqs.update", "faqs.delete",
// Mobile app versions
"app_versions.create", "app_versions.list", "app_versions.get", "app_versions.update", "app_versions.delete",
// Email templates // Email templates
"email_templates.create", "email_templates.list", "email_templates.get", "email_templates.update", "email_templates.delete", "email_templates.preview", "email_templates.create", "email_templates.list", "email_templates.get", "email_templates.update", "email_templates.delete", "email_templates.preview",
// Field options
"field_options.create", "field_options.list", "field_options.get", "field_options.update", "field_options.delete",
// Analytics (previously OnlyAdminAndAbove) // Analytics (previously OnlyAdminAndAbove)
"analytics.dashboard", "analytics.dashboard",
@ -508,7 +529,7 @@ var DefaultRolePermissions = map[string][]string{
"lessons.get", "lessons.list_by_module", "lessons.complete", "lessons.get", "lessons.list_by_module", "lessons.complete",
"practices.get", "practices.list", "practices.get", "practices.list",
"subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active", "subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active",
"videos.get", "videos.list_by_subcourse", "videos.list_published", "videos.get", "videos.list_by_subcourse", "videos.list_published", "videos.track_engagement",
"learning_tree.get", "learning_tree.get",
"programs.list", "programs.get", "programs.list", "programs.get",
@ -569,7 +590,7 @@ var DefaultRolePermissions = map[string][]string{
"lessons.get", "lessons.list_by_module", "lessons.get", "lessons.list_by_module",
"practices.get", "practices.list", "practices.get", "practices.list",
"subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active", "subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active",
"videos.get", "videos.list_by_subcourse", "videos.list_published", "videos.get", "videos.list_by_subcourse", "videos.list_published", "videos.track_engagement",
"learning_tree.get", "learning_tree.get",
"programs.list", "programs.get", "programs.list", "programs.get",

View File

@ -14,7 +14,7 @@ var (
ErrPlanNotFound = errors.New("subscription plan not found") ErrPlanNotFound = errors.New("subscription plan not found")
ErrSubscriptionNotFound = errors.New("subscription not found") ErrSubscriptionNotFound = errors.New("subscription not found")
ErrSubscriptionNotOwned = errors.New("subscription does not belong to this user") ErrSubscriptionNotOwned = errors.New("subscription does not belong to this user")
ErrAlreadySubscribed = errors.New("user already has an active subscription") ErrAlreadySubscribed = errors.New("user already has an active subscription for this category")
ErrInvalidPlan = errors.New("invalid subscription plan") ErrInvalidPlan = errors.New("invalid subscription plan")
) )
@ -56,15 +56,6 @@ func (s *Service) DeletePlan(ctx context.Context, id int64) error {
// Subscribe creates a new subscription for a user // Subscribe creates a new subscription for a user
func (s *Service) Subscribe(ctx context.Context, userID, planID int64, paymentRef, paymentMethod *string) (*domain.UserSubscription, error) { 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 // Get the plan to calculate expiry
plan, err := s.store.GetSubscriptionPlanByID(ctx, planID) plan, err := s.store.GetSubscriptionPlanByID(ctx, planID)
if err != nil { if err != nil {
@ -74,6 +65,14 @@ func (s *Service) Subscribe(ctx context.Context, userID, planID int64, paymentRe
return nil, ErrInvalidPlan return nil, ErrInvalidPlan
} }
hasActive, err := s.store.HasActiveSubscriptionByCategory(ctx, userID, plan.Category)
if err != nil {
return nil, err
}
if hasActive {
return nil, ErrAlreadySubscribed
}
// Calculate expiry date // Calculate expiry date
startsAt := time.Now() startsAt := time.Now()
expiresAt := domain.CalculateExpiryDate(startsAt, plan.DurationValue, plan.DurationUnit) expiresAt := domain.CalculateExpiryDate(startsAt, plan.DurationValue, plan.DurationUnit)
@ -126,6 +125,10 @@ func (s *Service) HasActiveSubscription(ctx context.Context, userID int64) (bool
return s.store.HasActiveSubscription(ctx, userID) return s.store.HasActiveSubscription(ctx, userID)
} }
func (s *Service) HasActiveSubscriptionByCategory(ctx context.Context, userID int64, category domain.SubscriptionCategory) (bool, error) {
return s.store.HasActiveSubscriptionByCategory(ctx, userID, string(category))
}
func (s *Service) subscriptionOwnedBy(ctx context.Context, subscriptionID, userID int64) error { func (s *Service) subscriptionOwnedBy(ctx context.Context, subscriptionID, userID int64) error {
sub, err := s.store.GetUserSubscriptionByID(ctx, subscriptionID) sub, err := s.store.GetUserSubscriptionByID(ctx, subscriptionID)
if err != nil { if err != nil {

View File

@ -21,9 +21,6 @@ func (s *Service) InviteTeamMember(ctx context.Context, req domain.InviteTeamMem
if !domain.TeamRole(req.TeamRole).IsValid() { if !domain.TeamRole(req.TeamRole).IsValid() {
return domain.InviteTeamMemberRes{}, domain.ErrInvalidTeamRole return domain.InviteTeamMemberRes{}, domain.ErrInvalidTeamRole
} }
if req.EmploymentType != "" && !domain.EmploymentType(req.EmploymentType).IsValid() {
return domain.InviteTeamMemberRes{}, domain.ErrInvalidEmploymentType
}
email := strings.TrimSpace(strings.ToLower(req.Email)) email := strings.TrimSpace(strings.ToLower(req.Email))
exists, err := s.teamStore.CheckTeamMemberEmailExists(ctx, email) exists, err := s.teamStore.CheckTeamMemberEmailExists(ctx, email)
@ -43,30 +40,15 @@ func (s *Service) InviteTeamMember(ctx context.Context, req domain.InviteTeamMem
return domain.InviteTeamMemberRes{}, err return domain.InviteTeamMemberRes{}, err
} }
var hireDate *time.Time
if req.HireDate != "" {
parsed, err := time.Parse("2006-01-02", req.HireDate)
if err != nil {
return domain.InviteTeamMemberRes{}, err
}
hireDate = &parsed
}
member := domain.TeamMember{ member := domain.TeamMember{
FirstName: strings.TrimSpace(req.FirstName), FirstName: domain.TeamInvitePlaceholderFirstName,
LastName: strings.TrimSpace(req.LastName), LastName: domain.TeamInvitePlaceholderLastName,
Email: email, Email: email,
PhoneNumber: strings.TrimSpace(req.PhoneNumber), Password: hashedPassword,
Password: hashedPassword, TeamRole: domain.TeamRole(req.TeamRole),
TeamRole: domain.TeamRole(req.TeamRole), Status: domain.TeamMemberStatusInactive,
Department: strings.TrimSpace(req.Department), EmailVerified: false,
JobTitle: strings.TrimSpace(req.JobTitle), CreatedBy: invitedBy,
EmploymentType: domain.EmploymentType(req.EmploymentType),
HireDate: hireDate,
Status: domain.TeamMemberStatusInactive,
EmailVerified: false,
Permissions: req.Permissions,
CreatedBy: invitedBy,
} }
created, err := s.teamStore.CreateTeamMember(ctx, member) created, err := s.teamStore.CreateTeamMember(ctx, member)
@ -160,15 +142,17 @@ func (s *Service) VerifyTeamInvitation(ctx context.Context, token string) (domai
return domain.VerifyTeamInvitationRes{Valid: false, Status: string(domain.TeamInvitationStatusExpired)}, nil return domain.VerifyTeamInvitationRes{Valid: false, Status: string(domain.TeamInvitationStatusExpired)}, nil
} }
return domain.VerifyTeamInvitationRes{ res := domain.VerifyTeamInvitationRes{
Valid: true, Valid: true,
Email: member.Email, Email: member.Email,
FirstName: member.FirstName,
LastName: member.LastName,
TeamRole: string(member.TeamRole), TeamRole: string(member.TeamRole),
ExpiresAt: inv.ExpiresAt, ExpiresAt: inv.ExpiresAt,
Status: string(inv.Status), Status: string(inv.Status),
}, nil }
if domain.IsTeamInvitePlaceholderProfile(member.FirstName, member.LastName) {
res.NeedsProfileSetup = true
}
return res, nil
} }
func (s *Service) AcceptTeamInvitation(ctx context.Context, req domain.AcceptTeamInvitationReq) (domain.TeamMember, error) { func (s *Service) AcceptTeamInvitation(ctx context.Context, req domain.AcceptTeamInvitationReq) (domain.TeamMember, error) {
@ -177,6 +161,29 @@ func (s *Service) AcceptTeamInvitation(ctx context.Context, req domain.AcceptTea
return domain.TeamMember{}, err return domain.TeamMember{}, err
} }
if req.EmploymentType != "" && !domain.EmploymentType(req.EmploymentType).IsValid() {
return domain.TeamMember{}, domain.ErrInvalidEmploymentType
}
updateReq := domain.UpdateTeamMemberReq{
TeamMemberID: member.ID,
UpdatedBy: member.ID,
FirstName: strings.TrimSpace(req.FirstName),
LastName: strings.TrimSpace(req.LastName),
PhoneNumber: strings.TrimSpace(req.PhoneNumber),
Department: strings.TrimSpace(req.Department),
JobTitle: strings.TrimSpace(req.JobTitle),
EmploymentType: strings.TrimSpace(req.EmploymentType),
HireDate: strings.TrimSpace(req.HireDate),
ProfilePictureURL: strings.TrimSpace(req.ProfilePictureURL),
Bio: strings.TrimSpace(req.Bio),
WorkPhone: strings.TrimSpace(req.WorkPhone),
EmergencyContact: strings.TrimSpace(req.EmergencyContact),
}
if err := s.UpdateTeamMember(ctx, updateReq); err != nil {
return domain.TeamMember{}, err
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost) hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost)
if err != nil { if err != nil {
return domain.TeamMember{}, err return domain.TeamMember{}, err
@ -279,8 +286,13 @@ func (s *Service) sendInvitationEmail(ctx context.Context, member domain.TeamMem
return fmt.Errorf("email services are not configured") return fmt.Errorf("email services are not configured")
} }
var firstName string
if !domain.IsTeamInvitePlaceholderProfile(member.FirstName, member.LastName) {
firstName = member.FirstName
}
rendered, err := s.emailTemplateSvc.Render(ctx, domain.EmailTemplateSlugInvitation, map[string]any{ rendered, err := s.emailTemplateSvc.Render(ctx, domain.EmailTemplateSlugInvitation, map[string]any{
"FirstName": member.FirstName, "FirstName": firstName,
"InviterName": inviterName, "InviterName": inviterName,
"InviteLink": inviteLink, "InviteLink": inviteLink,
}) })

View File

@ -5,6 +5,7 @@ import (
"Yimaru-Backend/internal/ports" "Yimaru-Backend/internal/ports"
emailtemplates "Yimaru-Backend/internal/services/emailtemplates" emailtemplates "Yimaru-Backend/internal/services/emailtemplates"
"Yimaru-Backend/internal/services/messenger" "Yimaru-Backend/internal/services/messenger"
profilefieldoptions "Yimaru-Backend/internal/services/profilefieldoptions"
"time" "time"
) )
@ -13,12 +14,13 @@ const (
) )
type Service struct { type Service struct {
tokenStore ports.TokenStore tokenStore ports.TokenStore
userStore ports.UserStore userStore ports.UserStore
otpStore ports.OtpStore otpStore ports.OtpStore
messengerSvc *messenger.Service messengerSvc *messenger.Service
emailTemplateSvc *emailtemplates.Service emailTemplateSvc *emailtemplates.Service
config *config.Config profileFieldOptionSvc *profilefieldoptions.Service
config *config.Config
} }
func NewService( func NewService(
@ -27,14 +29,16 @@ func NewService(
otpStore ports.OtpStore, otpStore ports.OtpStore,
messengerSvc *messenger.Service, messengerSvc *messenger.Service,
emailTemplateSvc *emailtemplates.Service, emailTemplateSvc *emailtemplates.Service,
profileFieldOptionSvc *profilefieldoptions.Service,
cfg *config.Config, cfg *config.Config,
) *Service { ) *Service {
return &Service{ return &Service{
tokenStore: tokenStore, tokenStore: tokenStore,
userStore: userStore, userStore: userStore,
otpStore: otpStore, otpStore: otpStore,
messengerSvc: messengerSvc, messengerSvc: messengerSvc,
emailTemplateSvc: emailTemplateSvc, emailTemplateSvc: emailTemplateSvc,
config: cfg, profileFieldOptionSvc: profileFieldOptionSvc,
config: cfg,
} }
} }

View File

@ -35,7 +35,11 @@ func (s *Service) SearchUserByNameOrPhone(ctx context.Context, searchString stri
} }
func (s *Service) UpdateUser(ctx context.Context, req domain.UpdateUserReq) error { func (s *Service) UpdateUser(ctx context.Context, req domain.UpdateUserReq) error {
// Update user in the store if s.profileFieldOptionSvc != nil {
if err := s.profileFieldOptionSvc.ValidateUserProfileFieldValues(ctx, req); err != nil {
return err
}
}
return s.userStore.UpdateUser(ctx, req) return s.userStore.UpdateUser(ctx, req)
} }

View File

@ -0,0 +1,20 @@
package videoengagement
import (
"context"
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/repository"
)
type Service struct {
store *repository.Store
}
func NewService(store *repository.Store) *Service {
return &Service{store: store}
}
func (s *Service) RecordHeartbeat(ctx context.Context, userID int64, input domain.VideoEngagementHeartbeatInput) (domain.VideoWatchSessionResponse, error) {
return s.store.RecordVideoEngagementHeartbeat(ctx, userID, input)
}

View File

@ -3,7 +3,8 @@ package httpserver
import ( import (
dbgen "Yimaru-Backend/gen/db" dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/config" "Yimaru-Backend/internal/config"
activitylogservice "Yimaru-Backend/internal/services/activity_log" activitylogservice "Yimaru-Backend/internal/services/activity_log"
"Yimaru-Backend/internal/services/appversions"
"Yimaru-Backend/internal/services/arifpay" "Yimaru-Backend/internal/services/arifpay"
"Yimaru-Backend/internal/services/chapa" "Yimaru-Backend/internal/services/chapa"
"Yimaru-Backend/internal/services/assessment" "Yimaru-Backend/internal/services/assessment"
@ -21,6 +22,7 @@ import (
notificationservice "Yimaru-Backend/internal/services/notification" notificationservice "Yimaru-Backend/internal/services/notification"
"Yimaru-Backend/internal/services/personas" "Yimaru-Backend/internal/services/personas"
"Yimaru-Backend/internal/services/practices" "Yimaru-Backend/internal/services/practices"
profilefieldoptions "Yimaru-Backend/internal/services/profilefieldoptions"
"Yimaru-Backend/internal/services/programs" "Yimaru-Backend/internal/services/programs"
"Yimaru-Backend/internal/services/questions" "Yimaru-Backend/internal/services/questions"
ratingsservice "Yimaru-Backend/internal/services/ratings" ratingsservice "Yimaru-Backend/internal/services/ratings"
@ -29,6 +31,7 @@ import (
"Yimaru-Backend/internal/services/subscriptions" "Yimaru-Backend/internal/services/subscriptions"
"Yimaru-Backend/internal/services/team" "Yimaru-Backend/internal/services/team"
vimeoservice "Yimaru-Backend/internal/services/vimeo" vimeoservice "Yimaru-Backend/internal/services/vimeo"
"Yimaru-Backend/internal/services/videoengagement"
"Yimaru-Backend/internal/services/settings" "Yimaru-Backend/internal/services/settings"
"Yimaru-Backend/internal/services/transaction" "Yimaru-Backend/internal/services/transaction"
@ -50,8 +53,10 @@ import (
type App struct { type App struct {
assessmentSvc *assessment.Service assessmentSvc *assessment.Service
questionsSvc *questions.Service questionsSvc *questions.Service
faqSvc *faqs.Service faqSvc *faqs.Service
emailTemplateSvc *emailtemplates.Service appVersionSvc *appversions.Service
emailTemplateSvc *emailtemplates.Service
profileFieldOptionSvc *profilefieldoptions.Service
personaSvc *personas.Service personaSvc *personas.Service
examPrepSvc *examprep.Service examPrepSvc *examprep.Service
programSvc *programs.Service programSvc *programs.Service
@ -85,15 +90,18 @@ type App struct {
Logger *slog.Logger Logger *slog.Logger
mongoLoggerSvc *zap.Logger mongoLoggerSvc *zap.Logger
analyticsDB *dbgen.Queries analyticsDB *dbgen.Queries
rbacSvc *rbacservice.Service rbacSvc *rbacservice.Service
stopPurgeWorker context.CancelFunc videoEngagementSvc *videoengagement.Service
stopPurgeWorker context.CancelFunc
} }
func NewApp( func NewApp(
assessmentSvc *assessment.Service, assessmentSvc *assessment.Service,
questionsSvc *questions.Service, questionsSvc *questions.Service,
faqSvc *faqs.Service, faqSvc *faqs.Service,
appVersionSvc *appversions.Service,
emailTemplateSvc *emailtemplates.Service, emailTemplateSvc *emailtemplates.Service,
profileFieldOptionSvc *profilefieldoptions.Service,
personaSvc *personas.Service, personaSvc *personas.Service,
examPrepSvc *examprep.Service, examPrepSvc *examprep.Service,
programSvc *programs.Service, programSvc *programs.Service,
@ -125,6 +133,7 @@ func NewApp(
mongoLoggerSvc *zap.Logger, mongoLoggerSvc *zap.Logger,
analyticsDB *dbgen.Queries, analyticsDB *dbgen.Queries,
rbacSvc *rbacservice.Service, rbacSvc *rbacservice.Service,
videoEngagementSvc *videoengagement.Service,
) *App { ) *App {
app := fiber.New(fiber.Config{ app := fiber.New(fiber.Config{
CaseSensitive: true, CaseSensitive: true,
@ -146,8 +155,10 @@ func NewApp(
s := &App{ s := &App{
assessmentSvc: assessmentSvc, assessmentSvc: assessmentSvc,
questionsSvc: questionsSvc, questionsSvc: questionsSvc,
faqSvc: faqSvc, faqSvc: faqSvc,
emailTemplateSvc: emailTemplateSvc, appVersionSvc: appVersionSvc,
emailTemplateSvc: emailTemplateSvc,
profileFieldOptionSvc: profileFieldOptionSvc,
personaSvc: personaSvc, personaSvc: personaSvc,
examPrepSvc: examPrepSvc, examPrepSvc: examPrepSvc,
programSvc: programSvc, programSvc: programSvc,
@ -180,8 +191,9 @@ func NewApp(
recommendationSvc: recommendationSvc, recommendationSvc: recommendationSvc,
cfg: cfg, cfg: cfg,
mongoLoggerSvc: mongoLoggerSvc, mongoLoggerSvc: mongoLoggerSvc,
analyticsDB: analyticsDB, analyticsDB: analyticsDB,
rbacSvc: rbacSvc, rbacSvc: rbacSvc,
videoEngagementSvc: videoEngagementSvc,
} }
s.initAppRoutes() s.initAppRoutes()

View File

@ -61,10 +61,30 @@ func (h *Handler) GetAnalyticsDashboard(c *fiber.Ctx) error {
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by age group") return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by age group")
} }
usersByEducation, err := h.analyticsDB.AnalyticsUsersByEducationLevel(ctx, p.UsersByEducationLevel)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by education level")
}
usersByOccupation, err := h.analyticsDB.AnalyticsUsersByOccupation(ctx, p.UsersByOccupation)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by occupation")
}
usersByLearningGoal, err := h.analyticsDB.AnalyticsUsersByLearningGoal(ctx, p.UsersByLearningGoal)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by learning goal")
}
usersByLanguageChallange, err := h.analyticsDB.AnalyticsUsersByLanguageChallange(ctx, p.UsersByLanguageChallange)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by language challenge")
}
usersByKnowledge, err := h.analyticsDB.AnalyticsUsersByKnowledgeLevel(ctx, p.UsersByKnowledgeLevel) usersByKnowledge, err := h.analyticsDB.AnalyticsUsersByKnowledgeLevel(ctx, p.UsersByKnowledgeLevel)
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by knowledge level") return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by knowledge level")
} }
usersByCountry, err := h.analyticsDB.AnalyticsUsersByCountry(ctx, p.UsersByCountry)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by country")
}
usersByRegion, err := h.analyticsDB.AnalyticsUsersByRegion(ctx, p.UsersByRegion) usersByRegion, err := h.analyticsDB.AnalyticsUsersByRegion(ctx, p.UsersByRegion)
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by region") return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by region")
@ -176,13 +196,27 @@ func (h *Handler) GetAnalyticsDashboard(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch team by status") return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch team by status")
} }
videoSummary, err := h.analyticsDB.AnalyticsVideoEngagementSummary(ctx, p.VideoEngagementSummary)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch video engagement analytics")
}
videoDropOff, err := h.analyticsDB.AnalyticsVideoDropOffByCheckpoint(ctx, p.VideoDropOffByCheckpoint)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch video drop-off analytics")
}
dashboard := domain.AnalyticsDashboard{ dashboard := domain.AnalyticsDashboard{
GeneratedAt: time.Now().UTC(), GeneratedAt: time.Now().UTC(),
DateFilter: filter, DateFilter: filter,
Users: mapUsersSection(usersSummary, usersByRole, usersByStatus, usersByAge, usersByKnowledge, usersByRegion, userRegs), Users: mapUsersSection(
usersSummary, usersByRole, usersByStatus, usersByAge,
usersByEducation, usersByOccupation, usersByLearningGoal, usersByLanguageChallange,
usersByKnowledge, usersByCountry, usersByRegion, userRegs,
),
Subscriptions: mapSubscriptionsSection(subsSummary, subsByStatus, revenueByPlan, newSubs30), Subscriptions: mapSubscriptionsSection(subsSummary, subsByStatus, revenueByPlan, newSubs30),
Payments: mapPaymentsSection(paymentsSummary, paymentsByStatus, paymentsByMethod, revenue30, revenueMonthlyRows, monthlyRevenueYear), Payments: mapPaymentsSection(paymentsSummary, paymentsByStatus, paymentsByMethod, revenue30, revenueMonthlyRows, monthlyRevenueYear),
Courses: mapCoursesSection(courseCounts), Courses: mapCoursesSection(courseCounts),
Videos: mapVideosSection(videoSummary, videoDropOff),
Content: mapContentSection(questionsCounts, questionsByType, questionSetsByType), Content: mapContentSection(questionsCounts, questionsByType, questionSetsByType),
Notifications: mapNotificationsSection(notifSummary, notifByChannel, notifByType), Notifications: mapNotificationsSection(notifSummary, notifByChannel, notifByType),
Issues: mapIssuesSection(issuesSummary, issuesByStatus, issuesByType), Issues: mapIssuesSection(issuesSummary, issuesByStatus, issuesByType),
@ -225,7 +259,12 @@ func mapUsersSection(
byRole []dbgen.AnalyticsUsersByRoleRow, byRole []dbgen.AnalyticsUsersByRoleRow,
byStatus []dbgen.AnalyticsUsersByStatusRow, byStatus []dbgen.AnalyticsUsersByStatusRow,
byAge []dbgen.AnalyticsUsersByAgeGroupRow, byAge []dbgen.AnalyticsUsersByAgeGroupRow,
byEducation []dbgen.AnalyticsUsersByEducationLevelRow,
byOccupation []dbgen.AnalyticsUsersByOccupationRow,
byLearningGoal []dbgen.AnalyticsUsersByLearningGoalRow,
byLanguageChallange []dbgen.AnalyticsUsersByLanguageChallangeRow,
byKnowledge []dbgen.AnalyticsUsersByKnowledgeLevelRow, byKnowledge []dbgen.AnalyticsUsersByKnowledgeLevelRow,
byCountry []dbgen.AnalyticsUsersByCountryRow,
byRegion []dbgen.AnalyticsUsersByRegionRow, byRegion []dbgen.AnalyticsUsersByRegionRow,
regs []dbgen.AnalyticsUserRegistrationsLast30DaysRow, regs []dbgen.AnalyticsUserRegistrationsLast30DaysRow,
) domain.AnalyticsUsersSection { ) domain.AnalyticsUsersSection {
@ -241,10 +280,30 @@ func mapUsersSection(
for i, r := range byAge { for i, r := range byAge {
ages[i] = domain.AnalyticsLabelCount{Label: r.AgeGroup, Count: r.Count} ages[i] = domain.AnalyticsLabelCount{Label: r.AgeGroup, Count: r.Count}
} }
education := make([]domain.AnalyticsLabelCount, len(byEducation))
for i, r := range byEducation {
education[i] = domain.AnalyticsLabelCount{Label: r.EducationLevel, Count: r.Count}
}
occupations := make([]domain.AnalyticsLabelCount, len(byOccupation))
for i, r := range byOccupation {
occupations[i] = domain.AnalyticsLabelCount{Label: r.Occupation, Count: r.Count}
}
learningGoals := make([]domain.AnalyticsLabelCount, len(byLearningGoal))
for i, r := range byLearningGoal {
learningGoals[i] = domain.AnalyticsLabelCount{Label: r.LearningGoal, Count: r.Count}
}
languageChallanges := make([]domain.AnalyticsLabelCount, len(byLanguageChallange))
for i, r := range byLanguageChallange {
languageChallanges[i] = domain.AnalyticsLabelCount{Label: r.LanguageChallange, Count: r.Count}
}
knowledge := make([]domain.AnalyticsLabelCount, len(byKnowledge)) knowledge := make([]domain.AnalyticsLabelCount, len(byKnowledge))
for i, r := range byKnowledge { for i, r := range byKnowledge {
knowledge[i] = domain.AnalyticsLabelCount{Label: r.KnowledgeLevel, Count: r.Count} knowledge[i] = domain.AnalyticsLabelCount{Label: r.KnowledgeLevel, Count: r.Count}
} }
countries := make([]domain.AnalyticsLabelCount, len(byCountry))
for i, r := range byCountry {
countries[i] = domain.AnalyticsLabelCount{Label: r.Country, Count: r.Count}
}
regions := make([]domain.AnalyticsLabelCount, len(byRegion)) regions := make([]domain.AnalyticsLabelCount, len(byRegion))
for i, r := range byRegion { for i, r := range byRegion {
regions[i] = domain.AnalyticsLabelCount{Label: r.Region, Count: r.Count} regions[i] = domain.AnalyticsLabelCount{Label: r.Region, Count: r.Count}
@ -261,7 +320,12 @@ func mapUsersSection(
ByRole: roles, ByRole: roles,
ByStatus: statuses, ByStatus: statuses,
ByAgeGroup: ages, ByAgeGroup: ages,
ByEducationLevel: education,
ByOccupation: occupations,
ByLearningGoal: learningGoals,
ByLanguageChallange: languageChallanges,
ByKnowledgeLevel: knowledge, ByKnowledgeLevel: knowledge,
ByCountry: countries,
ByRegion: regions, ByRegion: regions,
RegistrationsLast30Days: timePoints, RegistrationsLast30Days: timePoints,
} }
@ -435,3 +499,37 @@ func mapTeamSection(
ByStatus: statuses, ByStatus: statuses,
} }
} }
func mapVideosSection(
summary dbgen.AnalyticsVideoEngagementSummaryRow,
dropOff []dbgen.AnalyticsVideoDropOffByCheckpointRow,
) domain.AnalyticsVideosSection {
checkpoints := make([]domain.AnalyticsVideoDropOffPoint, len(dropOff))
for i, r := range dropOff {
checkpoints[i] = domain.AnalyticsVideoDropOffPoint{
CheckpointPercent: int(r.CheckpointPercent),
TotalSessions: r.TotalSessions,
ViewersReached: r.ViewersReached,
DropOffRate: r.DropOffRate,
}
}
var completionRate, replayRate float64
if summary.TotalSessions > 0 {
completionRate = float64(summary.CompletedSessions) / float64(summary.TotalSessions)
}
if summary.UniqueVideoStarts > 0 {
replayRate = float64(summary.UsersWhoReplayed) / float64(summary.UniqueVideoStarts)
}
return domain.AnalyticsVideosSection{
TotalWatchSessions: summary.TotalSessions,
CompletedSessions: summary.CompletedSessions,
ReplaySessions: summary.ReplaySessions,
UniqueVideoStarts: summary.UniqueVideoStarts,
UsersWhoReplayed: summary.UsersWhoReplayed,
CompletionRate: completionRate,
ReplayRate: replayRate,
DropOffByCheckpoint: checkpoints,
}
}

View File

@ -12,8 +12,13 @@ type analyticsQueryParams struct {
UsersSummary dbgen.AnalyticsUsersSummaryParams UsersSummary dbgen.AnalyticsUsersSummaryParams
UsersByRole dbgen.AnalyticsUsersByRoleParams UsersByRole dbgen.AnalyticsUsersByRoleParams
UsersByStatus dbgen.AnalyticsUsersByStatusParams UsersByStatus dbgen.AnalyticsUsersByStatusParams
UsersByAgeGroup dbgen.AnalyticsUsersByAgeGroupParams UsersByAgeGroup dbgen.AnalyticsUsersByAgeGroupParams
UsersByKnowledgeLevel dbgen.AnalyticsUsersByKnowledgeLevelParams UsersByEducationLevel dbgen.AnalyticsUsersByEducationLevelParams
UsersByOccupation dbgen.AnalyticsUsersByOccupationParams
UsersByLearningGoal dbgen.AnalyticsUsersByLearningGoalParams
UsersByLanguageChallange dbgen.AnalyticsUsersByLanguageChallangeParams
UsersByKnowledgeLevel dbgen.AnalyticsUsersByKnowledgeLevelParams
UsersByCountry dbgen.AnalyticsUsersByCountryParams
UsersByRegion dbgen.AnalyticsUsersByRegionParams UsersByRegion dbgen.AnalyticsUsersByRegionParams
UserRegistrationsSeries dbgen.AnalyticsUserRegistrationsLast30DaysParams UserRegistrationsSeries dbgen.AnalyticsUserRegistrationsLast30DaysParams
@ -42,6 +47,9 @@ type analyticsQueryParams struct {
TeamSummary dbgen.AnalyticsTeamSummaryParams TeamSummary dbgen.AnalyticsTeamSummaryParams
TeamByRole dbgen.AnalyticsTeamByRoleParams TeamByRole dbgen.AnalyticsTeamByRoleParams
TeamByStatus dbgen.AnalyticsTeamByStatusParams TeamByStatus dbgen.AnalyticsTeamByStatusParams
VideoEngagementSummary dbgen.AnalyticsVideoEngagementSummaryParams
VideoDropOffByCheckpoint dbgen.AnalyticsVideoDropOffByCheckpointParams
} }
func newAnalyticsQueryParams(f domain.AnalyticsDateFilter) analyticsQueryParams { func newAnalyticsQueryParams(f domain.AnalyticsDateFilter) analyticsQueryParams {
@ -58,8 +66,13 @@ func newAnalyticsQueryParams(f domain.AnalyticsDateFilter) analyticsQueryParams
UsersSummary: dbgen.AnalyticsUsersSummaryParams{RefDate: ref, RangeStart: rs, RangeEnd: re}, UsersSummary: dbgen.AnalyticsUsersSummaryParams{RefDate: ref, RangeStart: rs, RangeEnd: re},
UsersByRole: dbgen.AnalyticsUsersByRoleParams{RangeStart: rs, RangeEnd: re}, UsersByRole: dbgen.AnalyticsUsersByRoleParams{RangeStart: rs, RangeEnd: re},
UsersByStatus: dbgen.AnalyticsUsersByStatusParams{RangeStart: rs, RangeEnd: re}, UsersByStatus: dbgen.AnalyticsUsersByStatusParams{RangeStart: rs, RangeEnd: re},
UsersByAgeGroup: dbgen.AnalyticsUsersByAgeGroupParams{RangeStart: rs, RangeEnd: re}, UsersByAgeGroup: dbgen.AnalyticsUsersByAgeGroupParams{RangeStart: rs, RangeEnd: re},
UsersByKnowledgeLevel: dbgen.AnalyticsUsersByKnowledgeLevelParams{RangeStart: rs, RangeEnd: re}, UsersByEducationLevel: dbgen.AnalyticsUsersByEducationLevelParams{RangeStart: rs, RangeEnd: re},
UsersByOccupation: dbgen.AnalyticsUsersByOccupationParams{RangeStart: rs, RangeEnd: re},
UsersByLearningGoal: dbgen.AnalyticsUsersByLearningGoalParams{RangeStart: rs, RangeEnd: re},
UsersByLanguageChallange: dbgen.AnalyticsUsersByLanguageChallangeParams{RangeStart: rs, RangeEnd: re},
UsersByKnowledgeLevel: dbgen.AnalyticsUsersByKnowledgeLevelParams{RangeStart: rs, RangeEnd: re},
UsersByCountry: dbgen.AnalyticsUsersByCountryParams{RangeStart: rs, RangeEnd: re},
UsersByRegion: dbgen.AnalyticsUsersByRegionParams{RangeStart: rs, RangeEnd: re}, UsersByRegion: dbgen.AnalyticsUsersByRegionParams{RangeStart: rs, RangeEnd: re},
UserRegistrationsSeries: series, UserRegistrationsSeries: series,
@ -98,6 +111,9 @@ func newAnalyticsQueryParams(f domain.AnalyticsDateFilter) analyticsQueryParams
TeamSummary: dbgen.AnalyticsTeamSummaryParams{RangeStart: rs, RangeEnd: re}, TeamSummary: dbgen.AnalyticsTeamSummaryParams{RangeStart: rs, RangeEnd: re},
TeamByRole: dbgen.AnalyticsTeamByRoleParams{RangeStart: rs, RangeEnd: re}, TeamByRole: dbgen.AnalyticsTeamByRoleParams{RangeStart: rs, RangeEnd: re},
TeamByStatus: dbgen.AnalyticsTeamByStatusParams{RangeStart: rs, RangeEnd: re}, TeamByStatus: dbgen.AnalyticsTeamByStatusParams{RangeStart: rs, RangeEnd: re},
VideoEngagementSummary: dbgen.AnalyticsVideoEngagementSummaryParams{RangeStart: rs, RangeEnd: re},
VideoDropOffByCheckpoint: dbgen.AnalyticsVideoDropOffByCheckpointParams{RangeStart: rs, RangeEnd: re},
} }
} }

View File

@ -1,7 +1,11 @@
package handlers package handlers
import ( import (
"bytes"
"context"
"errors" "errors"
"fmt"
"html/template"
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/services/chapa" "Yimaru-Backend/internal/services/chapa"
@ -15,9 +19,10 @@ import (
// ===================== // =====================
type initiatePaymentReq struct { type initiatePaymentReq struct {
PlanID int64 `json:"plan_id" validate:"required"` PlanID int64 `json:"plan_id" validate:"required"`
Phone string `json:"phone" validate:"required"` Phone string `json:"phone" validate:"required"`
Email string `json:"email" validate:"required,email"` Email string `json:"email" validate:"required,email"`
Provider string `json:"provider" validate:"required"`
} }
type paymentRes struct { type paymentRes struct {
@ -68,18 +73,22 @@ func (h *Handler) InitiateSubscriptionPayment(c *fiber.Ctx) error {
}) })
} }
result, err := h.chapaSvc.InitiateSubscriptionPayment(c.Context(), userID, domain.InitiateSubscriptionPaymentRequest{ provider, err := domain.ParsePaymentProvider(req.Provider)
PlanID: req.PlanID, if err != nil {
Phone: req.Phone, return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Email: req.Email, Message: "Invalid payment provider",
Error: err.Error(),
})
}
result, err := h.initiateSubscriptionPayment(c.Context(), userID, domain.InitiateSubscriptionPaymentRequest{
PlanID: req.PlanID,
Phone: req.Phone,
Email: req.Email,
Provider: provider,
}) })
if err != nil { if err != nil {
status := fiber.StatusInternalServerError status := paymentInitiationStatus(err)
if errors.Is(err, chapa.ErrChapaNotConfigured) {
status = fiber.StatusServiceUnavailable
} else if err.Error() == "user already has an active subscription" {
status = fiber.StatusConflict
}
return c.Status(status).JSON(domain.ErrorResponse{ return c.Status(status).JSON(domain.ErrorResponse{
Message: "Failed to initiate payment", Message: "Failed to initiate payment",
Error: err.Error(), Error: err.Error(),
@ -110,7 +119,7 @@ func (h *Handler) VerifyPayment(c *fiber.Ctx) error {
}) })
} }
payment, err := h.chapaSvc.VerifyPayment(c.Context(), sessionID) payment, err := h.verifyPaymentByProvider(c.Context(), sessionID)
if err != nil { if err != nil {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Payment not found or verification failed", Message: "Payment not found or verification failed",
@ -246,6 +255,123 @@ func (h *Handler) CancelPayment(c *fiber.Ctx) error {
}) })
} }
func (h *Handler) initiateSubscriptionPayment(ctx context.Context, userID int64, req domain.InitiateSubscriptionPaymentRequest) (*domain.InitiateSubscriptionPaymentResponse, error) {
switch req.Provider {
case domain.PaymentProviderChapa:
return h.chapaSvc.InitiateSubscriptionPayment(ctx, userID, req)
case domain.PaymentProviderArifPay:
return h.arifpaySvc.InitiateSubscriptionPayment(ctx, userID, req)
default:
return nil, fmt.Errorf("unsupported payment provider %q", req.Provider)
}
}
func (h *Handler) verifyPaymentByProvider(ctx context.Context, ref string) (*domain.Payment, error) {
payment, err := h.chapaSvc.LookupPayment(ctx, ref)
if err != nil {
return nil, err
}
if payment.Status == string(domain.PaymentStatusSuccess) ||
payment.Status == string(domain.PaymentStatusFailed) ||
payment.Status == string(domain.PaymentStatusCancelled) ||
payment.Status == string(domain.PaymentStatusExpired) {
return payment, nil
}
if payment.PaymentMethod != nil {
if provider, err := domain.ParsePaymentProvider(*payment.PaymentMethod); err == nil {
switch provider {
case domain.PaymentProviderChapa:
return h.chapaSvc.VerifyPayment(ctx, ref)
case domain.PaymentProviderArifPay:
return h.arifpaySvc.VerifyPayment(ctx, ref)
}
}
}
chapaPayment, chapaErr := h.chapaSvc.VerifyPayment(ctx, ref)
if chapaErr == nil {
return chapaPayment, nil
}
arifpayPayment, arifpayErr := h.arifpaySvc.VerifyPayment(ctx, ref)
if arifpayErr == nil {
return arifpayPayment, nil
}
return nil, fmt.Errorf("chapa verify failed: %v; arifpay verify failed: %v", chapaErr, arifpayErr)
}
func paymentInitiationStatus(err error) int {
switch {
case errors.Is(err, chapa.ErrChapaNotConfigured):
return fiber.StatusServiceUnavailable
case err.Error() == "user already has an active subscription":
return fiber.StatusConflict
case err.Error() == "subscription plan is not active":
return fiber.StatusBadRequest
default:
return fiber.StatusInternalServerError
}
}
// HandleArifpaySuccessPage godoc
// @Summary ArifPay payment success page
// @Description Displays the Yimaru Academy success page after ArifPay redirects the learner back to the backend.
// @Tags payments
// @Produce html
// @Param session_id query string false "ArifPay session identifier"
// @Param sessionId query string false "ArifPay session identifier"
// @Param nonce query string false "Fallback payment nonce"
// @Success 200 {string} string "HTML success page"
// @Router /api/v1/payments/arifpay/success [get]
func (h *Handler) HandleArifpaySuccessPage(c *fiber.Ctx) error {
ref := firstNonEmpty(
c.Query("session_id"),
c.Query("sessionId"),
c.Query("sessionID"),
c.Query("nonce"),
)
page := arifpaySuccessPageData{
Title: "Subscription Payment Successful",
Headline: "Your Yimaru Academy payment was received",
Body: "Thank you for your payment. Your subscription is being activated and you can return to Yimaru Academy shortly.",
BadgeLabel: "Payment successful",
StatusLabel: "Activation in progress",
ActionLabel: "Continue learning",
ActionHref: "/",
}
if ref != "" {
payment, err := h.arifpaySvc.VerifyPayment(c.Context(), ref)
if err != nil {
h.logger.Warn("Failed to verify ArifPay success redirect", "error", err, "ref", ref)
page.Body = "Thank you for your payment. We are confirming it with ArifPay and will activate your subscription shortly."
page.Helper = "You can safely return to Yimaru Academy. If activation takes longer than expected, refresh the app in a moment."
page.Reference = ref
} else {
page.Reference = ref
page.PlanName = derefString(payment.PlanName)
if payment.Status == string(domain.PaymentStatusSuccess) {
page.StatusLabel = "Subscription active"
page.Body = "Your Yimaru Academy subscription is active. You now have access to your learning content."
} else {
page.Body = "Thank you for your payment. We received your success redirect and are finalizing subscription activation."
page.StatusLabel = "Processing confirmation"
}
}
} else {
page.Helper = "Return to Yimaru Academy and refresh your subscription status if you do not see access immediately."
}
html, err := renderArifpaySuccessPage(page)
if err != nil {
return c.Status(fiber.StatusInternalServerError).SendString("Failed to render success page")
}
c.Type("html", "utf-8")
return c.SendString(html)
}
// HandleArifpayWebhook godoc // HandleArifpayWebhook godoc
// @Summary Handle ArifPay webhook // @Summary Handle ArifPay webhook
// @Description Processes payment notifications from ArifPay // @Description Processes payment notifications from ArifPay
@ -459,3 +585,95 @@ func paymentToRes(p *domain.Payment) *paymentRes {
return res return res
} }
type arifpaySuccessPageData struct {
Title string
Headline string
Body string
Helper string
BadgeLabel string
StatusLabel string
Reference string
PlanName string
ActionLabel string
ActionHref string
}
func renderArifpaySuccessPage(data arifpaySuccessPageData) (string, error) {
const tpl = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{.Title}}</title>
</head>
<body style="margin:0;background:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;color:#333;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="min-height:100vh;background:#f4f6fb;padding:24px 16px;">
<tr>
<td align="center">
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="max-width:640px;width:100%;background:#ffffff;border-radius:18px;overflow:hidden;box-shadow:0 14px 40px rgba(157,42,131,0.12);">
<tr>
<td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 55%,#c43a9a 100%);padding:32px 28px;text-align:center;">
<div style="display:inline-block;padding:8px 14px;border-radius:999px;background:rgba(255,255,255,0.15);color:#fff;font-size:12px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;">{{.BadgeLabel}}</div>
<h1 style="margin:18px 0 8px;color:#fff;font-size:30px;line-height:1.2;">Yimaru Academy</h1>
<p style="margin:0;color:rgba(255,255,255,0.88);font-size:16px;">{{.Headline}}</p>
</td>
</tr>
<tr>
<td style="padding:32px 28px;">
<div style="margin:0 auto 24px;width:76px;height:76px;border-radius:50%;background:#eef9f2;border:1px solid #cfead9;text-align:center;line-height:76px;font-size:40px;color:#1f9d55;">&#10003;</div>
<p style="margin:0 0 18px;font-size:16px;line-height:1.7;color:#555;">{{.Body}}</p>
{{if .Helper}}<p style="margin:0 0 22px;font-size:14px;line-height:1.7;color:#777;">{{.Helper}}</p>{{end}}
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="margin:0 0 26px;background:#f8f3f8;border:1px solid #eddced;border-radius:12px;">
<tr>
<td style="padding:18px 20px;">
<p style="margin:0 0 8px;font-size:12px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#9d2a83;">Status</p>
<p style="margin:0;font-size:18px;font-weight:700;color:#333;">{{.StatusLabel}}</p>
{{if .PlanName}}<p style="margin:14px 0 0;font-size:14px;color:#555;"><strong>Plan:</strong> {{.PlanName}}</p>{{end}}
{{if .Reference}}<p style="margin:8px 0 0;font-size:14px;color:#555;word-break:break-word;"><strong>Reference:</strong> {{.Reference}}</p>{{end}}
</td>
</tr>
</table>
<div style="text-align:center;">
<a href="{{.ActionHref}}" style="display:inline-block;padding:14px 24px;border-radius:10px;background:#9d2a83;color:#fff;text-decoration:none;font-size:15px;font-weight:700;">{{.ActionLabel}}</a>
</div>
</td>
</tr>
<tr>
<td style="padding:20px 28px;background:#fafafa;border-top:1px solid #eee;text-align:center;">
<p style="margin:0;font-size:12px;line-height:1.6;color:#8a8a8a;">Yimaru Academy subscription payments are verified securely before access is granted.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`
t, err := template.New("arifpay-success").Parse(tpl)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := t.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
func derefString(value *string) string {
if value == nil {
return ""
}
return *value
}

View File

@ -51,6 +51,11 @@ func (h *Handler) GoogleAndroidLogin(c *fiber.Ctx) error {
loginRes, err := h.authSvc.LoginWithGoogleAndroid(c.Context(), req.IDToken, h.Cfg.GoogleOAuthClientID) loginRes, err := h.authSvc.LoginWithGoogleAndroid(c.Context(), req.IDToken, h.Cfg.GoogleOAuthClientID)
if err != nil { if err != nil {
h.mongoLoggerSvc.Error("Google Android login failed",
zap.Error(err),
zap.Int("id_token_length", len(req.IDToken)),
zap.Bool("google_client_id_configured", h.Cfg.GoogleOAuthClientID != ""),
)
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
Message: "Google login failed", Message: "Google login failed",
Error: err.Error(), Error: err.Error(),

View File

@ -59,6 +59,84 @@ func (h *Handler) CreateExamPrepCatalogCourse(c *fiber.Ctx) error {
func (h *Handler) ListExamPrepCatalogCourses(c *fiber.Ctx) error { func (h *Handler) ListExamPrepCatalogCourses(c *fiber.Ctx) error {
limit, _ := strconv.Atoi(c.Query("limit", "20")) limit, _ := strconv.Atoi(c.Query("limit", "20"))
offset, _ := strconv.Atoi(c.Query("offset", "0")) offset, _ := strconv.Atoi(c.Query("offset", "0"))
role, _ := c.Locals("role").(domain.Role)
if role == domain.RoleStudent || role == domain.RoleOpenLearner {
userID, ok := c.Locals("user_id").(int64)
if !ok || userID == 0 {
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
Message: "Unauthorized",
})
}
hasIELTS, err := h.subscriptionsSvc.HasActiveSubscriptionByCategory(c.Context(), userID, domain.SubscriptionCategoryIELTS)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to verify IELTS subscription",
Error: err.Error(),
})
}
hasDuolingo, err := h.subscriptionsSvc.HasActiveSubscriptionByCategory(c.Context(), userID, domain.SubscriptionCategoryDuolingo)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to verify Duolingo subscription",
Error: err.Error(),
})
}
allItems, _, err := h.examPrepSvc.ListCatalogCourses(c.Context(), 200, 0)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to list catalog courses",
Error: err.Error(),
})
}
filtered := make([]domain.ExamPrepCatalogCourse, 0, len(allItems))
for _, item := range allItems {
switch domain.SubscriptionCategory(item.Category) {
case domain.SubscriptionCategoryIELTS:
if hasIELTS {
filtered = append(filtered, item)
}
case domain.SubscriptionCategoryDuolingo:
if hasDuolingo {
filtered = append(filtered, item)
}
}
}
total := len(filtered)
start := offset
if start > total {
start = total
}
end := start + limit
if end > total {
end = total
}
page := filtered[start:end]
if err := h.applyExamPrepAccessCatalogCourses(c.Context(), c, page); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to build catalog course list",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Catalog courses retrieved successfully",
Data: fiber.Map{
"catalog_courses": page,
"total_count": total,
"limit": limit,
"offset": offset,
},
Success: true,
StatusCode: fiber.StatusOK,
})
}
items, total, err := h.examPrepSvc.ListCatalogCourses(c.Context(), int32(limit), int32(offset)) items, total, err := h.examPrepSvc.ListCatalogCourses(c.Context(), int32(limit), int32(offset))
if err != nil { if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
@ -66,6 +144,12 @@ func (h *Handler) ListExamPrepCatalogCourses(c *fiber.Ctx) error {
Error: err.Error(), Error: err.Error(),
}) })
} }
if err := h.applyExamPrepAccessCatalogCourses(c.Context(), c, items); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to build catalog course list",
Error: err.Error(),
})
}
return c.JSON(domain.Response{ return c.JSON(domain.Response{
Message: "Catalog courses retrieved successfully", Message: "Catalog courses retrieved successfully",
Data: fiber.Map{ Data: fiber.Map{
@ -148,6 +232,12 @@ func (h *Handler) GetExamPrepCatalogCourseByID(c *fiber.Ctx) error {
Error: err.Error(), Error: err.Error(),
}) })
} }
if err := h.applyExamPrepAccessCatalogCourse(c.Context(), c, &out); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to build catalog course",
Error: err.Error(),
})
}
return c.JSON(domain.Response{ return c.JSON(domain.Response{
Message: "Catalog course retrieved successfully", Message: "Catalog course retrieved successfully",
Data: out, Data: out,

View File

@ -87,6 +87,12 @@ func (h *Handler) ListExamPrepLessonsByUnitModule(c *fiber.Ctx) error {
Error: err.Error(), Error: err.Error(),
}) })
} }
if err := h.applyExamPrepAccessLessons(c.Context(), c, items); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to build lesson list",
Error: err.Error(),
})
}
return c.JSON(domain.Response{ return c.JSON(domain.Response{
Message: "Lessons retrieved successfully", Message: "Lessons retrieved successfully",
Data: fiber.Map{ Data: fiber.Map{
@ -175,6 +181,12 @@ func (h *Handler) GetExamPrepLessonByID(c *fiber.Ctx) error {
Error: err.Error(), Error: err.Error(),
}) })
} }
if err := h.applyExamPrepAccessLesson(c.Context(), c, &les); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to build lesson",
Error: err.Error(),
})
}
return c.JSON(domain.Response{ return c.JSON(domain.Response{
Message: "Lesson retrieved successfully", Message: "Lesson retrieved successfully",
Data: les, Data: les,

View File

@ -85,6 +85,12 @@ func (h *Handler) ListExamPrepModulesByUnit(c *fiber.Ctx) error {
Error: err.Error(), Error: err.Error(),
}) })
} }
if err := h.applyExamPrepAccessModules(c.Context(), c, items); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to build module list",
Error: err.Error(),
})
}
return c.JSON(domain.Response{ return c.JSON(domain.Response{
Message: "Modules retrieved successfully", Message: "Modules retrieved successfully",
Data: fiber.Map{ Data: fiber.Map{
@ -175,6 +181,12 @@ func (h *Handler) GetExamPrepModuleByID(c *fiber.Ctx) error {
Error: err.Error(), Error: err.Error(),
}) })
} }
if err := h.applyExamPrepAccessModule(c.Context(), c, &out); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to build module",
Error: err.Error(),
})
}
return c.JSON(domain.Response{ return c.JSON(domain.Response{
Message: "Module retrieved successfully", Message: "Module retrieved successfully",
Data: out, Data: out,

View File

@ -0,0 +1,125 @@
package handlers
import (
"context"
"Yimaru-Backend/internal/domain"
"github.com/gofiber/fiber/v2"
)
func (h *Handler) applyExamPrepAccessCatalogCourses(ctx context.Context, c *fiber.Ctx, items []domain.ExamPrepCatalogCourse) error {
role, _ := c.Locals("role").(domain.Role)
if !role.IsCustomerLearnerRole() {
return nil
}
userID, ok := c.Locals("user_id").(int64)
if !ok || userID == 0 {
return nil
}
for i := range items {
if err := h.lmsProgressSvc.ApplyExamPrepAccessCatalogCourse(ctx, role, userID, &items[i]); err != nil {
return err
}
}
return nil
}
func (h *Handler) applyExamPrepAccessCatalogCourse(ctx context.Context, c *fiber.Ctx, item *domain.ExamPrepCatalogCourse) error {
role, _ := c.Locals("role").(domain.Role)
if !role.IsCustomerLearnerRole() {
return nil
}
userID, ok := c.Locals("user_id").(int64)
if !ok || userID == 0 {
return nil
}
return h.lmsProgressSvc.ApplyExamPrepAccessCatalogCourse(ctx, role, userID, item)
}
func (h *Handler) applyExamPrepAccessUnits(ctx context.Context, c *fiber.Ctx, items []domain.ExamPrepUnit) error {
role, _ := c.Locals("role").(domain.Role)
if !role.IsCustomerLearnerRole() {
return nil
}
userID, ok := c.Locals("user_id").(int64)
if !ok || userID == 0 {
return nil
}
for i := range items {
if err := h.lmsProgressSvc.ApplyExamPrepAccessUnit(ctx, role, userID, &items[i]); err != nil {
return err
}
}
return nil
}
func (h *Handler) applyExamPrepAccessUnit(ctx context.Context, c *fiber.Ctx, item *domain.ExamPrepUnit) error {
role, _ := c.Locals("role").(domain.Role)
if !role.IsCustomerLearnerRole() {
return nil
}
userID, ok := c.Locals("user_id").(int64)
if !ok || userID == 0 {
return nil
}
return h.lmsProgressSvc.ApplyExamPrepAccessUnit(ctx, role, userID, item)
}
func (h *Handler) applyExamPrepAccessModules(ctx context.Context, c *fiber.Ctx, items []domain.ExamPrepModule) error {
role, _ := c.Locals("role").(domain.Role)
if !role.IsCustomerLearnerRole() {
return nil
}
userID, ok := c.Locals("user_id").(int64)
if !ok || userID == 0 {
return nil
}
for i := range items {
if err := h.lmsProgressSvc.ApplyExamPrepAccessModule(ctx, role, userID, &items[i]); err != nil {
return err
}
}
return nil
}
func (h *Handler) applyExamPrepAccessModule(ctx context.Context, c *fiber.Ctx, item *domain.ExamPrepModule) error {
role, _ := c.Locals("role").(domain.Role)
if !role.IsCustomerLearnerRole() {
return nil
}
userID, ok := c.Locals("user_id").(int64)
if !ok || userID == 0 {
return nil
}
return h.lmsProgressSvc.ApplyExamPrepAccessModule(ctx, role, userID, item)
}
func (h *Handler) applyExamPrepAccessLessons(ctx context.Context, c *fiber.Ctx, items []domain.ExamPrepLesson) error {
role, _ := c.Locals("role").(domain.Role)
if !role.IsCustomerLearnerRole() {
return nil
}
userID, ok := c.Locals("user_id").(int64)
if !ok || userID == 0 {
return nil
}
for i := range items {
if err := h.lmsProgressSvc.ApplyExamPrepAccessLesson(ctx, role, userID, &items[i]); err != nil {
return err
}
}
return nil
}
func (h *Handler) applyExamPrepAccessLesson(ctx context.Context, c *fiber.Ctx, item *domain.ExamPrepLesson) error {
role, _ := c.Locals("role").(domain.Role)
if !role.IsCustomerLearnerRole() {
return nil
}
userID, ok := c.Locals("user_id").(int64)
if !ok || userID == 0 {
return nil
}
return h.lmsProgressSvc.ApplyExamPrepAccessLesson(ctx, role, userID, item)
}

View File

@ -92,6 +92,12 @@ func (h *Handler) ListExamPrepUnitsByCatalogCourse(c *fiber.Ctx) error {
Error: err.Error(), Error: err.Error(),
}) })
} }
if err := h.applyExamPrepAccessUnits(c.Context(), c, items); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to build unit list",
Error: err.Error(),
})
}
return c.JSON(domain.Response{ return c.JSON(domain.Response{
Message: "Units retrieved successfully", Message: "Units retrieved successfully",
Data: fiber.Map{ Data: fiber.Map{
@ -183,6 +189,12 @@ func (h *Handler) GetExamPrepUnitByID(c *fiber.Ctx) error {
Error: err.Error(), Error: err.Error(),
}) })
} }
if err := h.applyExamPrepAccessUnit(c.Context(), c, &out); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to build unit",
Error: err.Error(),
})
}
return c.JSON(domain.Response{ return c.JSON(domain.Response{
Message: "Unit retrieved successfully", Message: "Unit retrieved successfully",
Data: out, Data: out,

View File

@ -0,0 +1,303 @@
package handlers
import (
"Yimaru-Backend/internal/domain"
"errors"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
)
type createFieldOptionReq struct {
FieldKey string `json:"field_key" validate:"required"`
Code string `json:"code" validate:"required"`
Label string `json:"label" validate:"required"`
DisplayOrder *int32 `json:"display_order"`
Status *string `json:"status"`
}
type updateFieldOptionReq struct {
Label *string `json:"label"`
DisplayOrder *int32 `json:"display_order"`
Status *string `json:"status"`
}
type fieldOptionRes struct {
ID int64 `json:"id"`
FieldKey string `json:"field_key"`
Code string `json:"code"`
Label string `json:"label"`
DisplayOrder int32 `json:"display_order"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
UpdatedAt *string `json:"updated_at,omitempty"`
}
type listFieldOptionsRes struct {
Options []fieldOptionRes `json:"options"`
TotalCount int64 `json:"total_count"`
}
func mapFieldOptionToRes(o domain.ProfileFieldOption) fieldOptionRes {
var updatedAt *string
if o.UpdatedAt != nil {
v := o.UpdatedAt.Format("2006-01-02T15:04:05Z07:00")
updatedAt = &v
}
return fieldOptionRes{
ID: o.ID,
FieldKey: o.FieldKey,
Code: o.Code,
Label: o.Label,
DisplayOrder: o.DisplayOrder,
Status: o.Status,
CreatedAt: o.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: updatedAt,
}
}
// ListPublicFieldOptions godoc
// @Summary List field options for client dropdowns
// @Description Returns active options grouped by field_key (e.g. education_level, country)
// @Tags field-options
// @Produce json
// @Param field_key query string false "Filter by field key"
// @Success 200 {object} domain.Response
// @Router /api/v1/field-options [get]
func (h *Handler) ListPublicFieldOptions(c *fiber.Ctx) error {
var fieldKeyPtr *string
if raw := strings.TrimSpace(c.Query("field_key")); raw != "" {
fieldKeyPtr = &raw
}
grouped, err := h.profileFieldOptionSvc.ListActiveOptionsGrouped(c.Context(), fieldKeyPtr)
if err != nil {
return fieldOptionError(c, err, "Failed to list field options")
}
return c.JSON(domain.Response{
Message: "Field options retrieved successfully",
Data: grouped,
Success: true,
})
}
// ListFieldKeys godoc
// @Summary List distinct field keys
// @Description Returns field_key values that have options (e.g. education_level, country)
// @Tags field-options
// @Produce json
// @Param active_only query bool false "If true, only keys with active options"
// @Success 200 {object} domain.Response
// @Router /api/v1/field-options/fields [get]
func (h *Handler) ListFieldKeys(c *fiber.Ctx) error {
activeOnly := c.Query("active_only", "true") == "true"
keys, err := h.profileFieldOptionSvc.ListFieldKeys(c.Context(), activeOnly)
if err != nil {
return fieldOptionError(c, err, "Failed to list field keys")
}
return c.JSON(domain.Response{
Message: "Field keys retrieved successfully",
Data: fiber.Map{
"fields": keys,
},
Success: true,
})
}
// ListFieldOptionsAdmin godoc
// @Summary List field options (admin)
// @Tags field-options
// @Produce json
// @Param field_key query string false "Filter by field key"
// @Param status query string false "ACTIVE or INACTIVE"
// @Param limit query int false "Limit"
// @Param offset query int false "Offset"
// @Success 200 {object} domain.Response
// @Router /api/v1/admin/field-options [get]
func (h *Handler) ListFieldOptionsAdmin(c *fiber.Ctx) error {
limit, _ := strconv.Atoi(c.Query("limit", "50"))
offset, _ := strconv.Atoi(c.Query("offset", "0"))
var fieldKeyPtr, statusPtr *string
if raw := strings.TrimSpace(c.Query("field_key")); raw != "" {
fieldKeyPtr = &raw
}
if raw := strings.TrimSpace(c.Query("status")); raw != "" {
statusPtr = &raw
}
options, total, err := h.profileFieldOptionSvc.ListProfileFieldOptions(c.Context(), fieldKeyPtr, statusPtr, int32(limit), int32(offset))
if err != nil {
return fieldOptionError(c, err, "Failed to list field options")
}
out := make([]fieldOptionRes, 0, len(options))
for _, o := range options {
out = append(out, mapFieldOptionToRes(o))
}
return c.JSON(domain.Response{
Message: "Field options retrieved successfully",
Data: listFieldOptionsRes{
Options: out,
TotalCount: total,
},
Success: true,
})
}
// GetFieldOptionByIDAdmin godoc
// @Summary Get field option by ID (admin)
// @Tags field-options
// @Produce json
// @Param id path int true "Option ID"
// @Success 200 {object} domain.Response
// @Router /api/v1/admin/field-options/{id} [get]
func (h *Handler) GetFieldOptionByIDAdmin(c *fiber.Ctx) error {
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil || id <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid option ID",
})
}
option, err := h.profileFieldOptionSvc.GetProfileFieldOptionByID(c.Context(), id, true)
if err != nil {
return fieldOptionError(c, err, "Failed to get field option")
}
return c.JSON(domain.Response{
Message: "Field option retrieved successfully",
Data: mapFieldOptionToRes(option),
Success: true,
})
}
// CreateFieldOption godoc
// @Summary Create field option (admin)
// @Tags field-options
// @Accept json
// @Produce json
// @Param body body createFieldOptionReq true "Create option"
// @Success 201 {object} domain.Response
// @Router /api/v1/admin/field-options [post]
func (h *Handler) CreateFieldOption(c *fiber.Ctx) error {
var req createFieldOptionReq
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: firstValidationError(valErrs),
})
}
option, err := h.profileFieldOptionSvc.CreateProfileFieldOption(c.Context(), domain.CreateProfileFieldOptionInput{
FieldKey: req.FieldKey,
Code: req.Code,
Label: req.Label,
DisplayOrder: req.DisplayOrder,
Status: req.Status,
})
if err != nil {
return fieldOptionError(c, err, "Failed to create field option")
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Field option created successfully",
Data: mapFieldOptionToRes(option),
Success: true,
})
}
// UpdateFieldOption godoc
// @Summary Update field option (admin)
// @Tags field-options
// @Accept json
// @Produce json
// @Param id path int true "Option ID"
// @Param body body updateFieldOptionReq true "Update option"
// @Success 200 {object} domain.Response
// @Router /api/v1/admin/field-options/{id} [put]
func (h *Handler) UpdateFieldOption(c *fiber.Ctx) error {
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil || id <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid option ID",
})
}
var req updateFieldOptionReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
option, err := h.profileFieldOptionSvc.UpdateProfileFieldOption(c.Context(), id, domain.UpdateProfileFieldOptionInput{
Label: req.Label,
DisplayOrder: req.DisplayOrder,
Status: req.Status,
})
if err != nil {
return fieldOptionError(c, err, "Failed to update field option")
}
return c.JSON(domain.Response{
Message: "Field option updated successfully",
Data: mapFieldOptionToRes(option),
Success: true,
})
}
// DeleteFieldOption godoc
// @Summary Delete field option (admin)
// @Tags field-options
// @Produce json
// @Param id path int true "Option ID"
// @Success 200 {object} domain.Response
// @Router /api/v1/admin/field-options/{id} [delete]
func (h *Handler) DeleteFieldOption(c *fiber.Ctx) error {
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil || id <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid option ID",
})
}
if err := h.profileFieldOptionSvc.DeleteProfileFieldOption(c.Context(), id); err != nil {
return fieldOptionError(c, err, "Failed to delete field option")
}
return c.JSON(domain.Response{
Message: "Field option deleted successfully",
Success: true,
})
}
func fieldOptionError(c *fiber.Ctx, err error, message string) error {
switch {
case errors.Is(err, domain.ErrInvalidFieldKey),
errors.Is(err, domain.ErrInvalidOptionCode):
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: message, Error: err.Error()})
case errors.Is(err, pgx.ErrNoRows):
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: message, Error: "Field option not found"})
default:
if strings.Contains(err.Error(), "invalid value for") {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: message, Error: err.Error()})
}
if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique constraint") {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: message, Error: "An option with this field_key and code already exists"})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: message, Error: err.Error()})
}
}

View File

@ -6,7 +6,8 @@ import (
dbgen "Yimaru-Backend/gen/db" dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/config" "Yimaru-Backend/internal/config"
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
activitylogservice "Yimaru-Backend/internal/services/activity_log" activitylogservice "Yimaru-Backend/internal/services/activity_log"
"Yimaru-Backend/internal/services/appversions"
"Yimaru-Backend/internal/services/arifpay" "Yimaru-Backend/internal/services/arifpay"
"Yimaru-Backend/internal/services/chapa" "Yimaru-Backend/internal/services/chapa"
"Yimaru-Backend/internal/services/assessment" "Yimaru-Backend/internal/services/assessment"
@ -24,6 +25,7 @@ import (
notificationservice "Yimaru-Backend/internal/services/notification" notificationservice "Yimaru-Backend/internal/services/notification"
"Yimaru-Backend/internal/services/personas" "Yimaru-Backend/internal/services/personas"
"Yimaru-Backend/internal/services/practices" "Yimaru-Backend/internal/services/practices"
profilefieldoptions "Yimaru-Backend/internal/services/profilefieldoptions"
"Yimaru-Backend/internal/services/programs" "Yimaru-Backend/internal/services/programs"
"Yimaru-Backend/internal/services/questions" "Yimaru-Backend/internal/services/questions"
ratingsservice "Yimaru-Backend/internal/services/ratings" ratingsservice "Yimaru-Backend/internal/services/ratings"
@ -32,6 +34,7 @@ import (
"Yimaru-Backend/internal/services/subscriptions" "Yimaru-Backend/internal/services/subscriptions"
"Yimaru-Backend/internal/services/team" "Yimaru-Backend/internal/services/team"
vimeoservice "Yimaru-Backend/internal/services/vimeo" vimeoservice "Yimaru-Backend/internal/services/vimeo"
"Yimaru-Backend/internal/services/videoengagement"
// referralservice "Yimaru-Backend/internal/services/referal" // referralservice "Yimaru-Backend/internal/services/referal"
@ -49,8 +52,10 @@ import (
type Handler struct { type Handler struct {
assessmentSvc *assessment.Service assessmentSvc *assessment.Service
questionsSvc *questions.Service questionsSvc *questions.Service
faqSvc *faqs.Service faqSvc *faqs.Service
emailTemplateSvc *emailtemplates.Service appVersionSvc *appversions.Service
emailTemplateSvc *emailtemplates.Service
profileFieldOptionSvc *profilefieldoptions.Service
personaSvc *personas.Service personaSvc *personas.Service
examPrepSvc *examprep.Service examPrepSvc *examprep.Service
programSvc *programs.Service programSvc *programs.Service
@ -77,6 +82,7 @@ type Handler struct {
minioSvc *minioservice.Service minioSvc *minioservice.Service
ratingSvc *ratingsservice.Service ratingSvc *ratingsservice.Service
rbacSvc *rbacservice.Service rbacSvc *rbacservice.Service
videoEngagementSvc *videoengagement.Service
jwtConfig jwtutil.JwtConfig jwtConfig jwtutil.JwtConfig
validator *customvalidator.CustomValidator validator *customvalidator.CustomValidator
Cfg *config.Config Cfg *config.Config
@ -88,7 +94,9 @@ func New(
assessmentSvc *assessment.Service, assessmentSvc *assessment.Service,
questionsSvc *questions.Service, questionsSvc *questions.Service,
faqSvc *faqs.Service, faqSvc *faqs.Service,
appVersionSvc *appversions.Service,
emailTemplateSvc *emailtemplates.Service, emailTemplateSvc *emailtemplates.Service,
profileFieldOptionSvc *profilefieldoptions.Service,
personaSvc *personas.Service, personaSvc *personas.Service,
examPrepSvc *examprep.Service, examPrepSvc *examprep.Service,
programSvc *programs.Service, programSvc *programs.Service,
@ -116,6 +124,7 @@ func New(
minioSvc *minioservice.Service, minioSvc *minioservice.Service,
ratingSvc *ratingsservice.Service, ratingSvc *ratingsservice.Service,
rbacSvc *rbacservice.Service, rbacSvc *rbacservice.Service,
videoEngagementSvc *videoengagement.Service,
jwtConfig jwtutil.JwtConfig, jwtConfig jwtutil.JwtConfig,
cfg *config.Config, cfg *config.Config,
mongoLoggerSvc *zap.Logger, mongoLoggerSvc *zap.Logger,
@ -124,8 +133,10 @@ func New(
return &Handler{ return &Handler{
assessmentSvc: assessmentSvc, assessmentSvc: assessmentSvc,
questionsSvc: questionsSvc, questionsSvc: questionsSvc,
faqSvc: faqSvc, faqSvc: faqSvc,
emailTemplateSvc: emailTemplateSvc, appVersionSvc: appVersionSvc,
emailTemplateSvc: emailTemplateSvc,
profileFieldOptionSvc: profileFieldOptionSvc,
personaSvc: personaSvc, personaSvc: personaSvc,
examPrepSvc: examPrepSvc, examPrepSvc: examPrepSvc,
programSvc: programSvc, programSvc: programSvc,
@ -152,8 +163,9 @@ func New(
cloudConvertSvc: cloudConvertSvc, cloudConvertSvc: cloudConvertSvc,
minioSvc: minioSvc, minioSvc: minioSvc,
ratingSvc: ratingSvc, ratingSvc: ratingSvc,
rbacSvc: rbacSvc, rbacSvc: rbacSvc,
jwtConfig: jwtConfig, videoEngagementSvc: videoEngagementSvc,
jwtConfig: jwtConfig,
Cfg: cfg, Cfg: cfg,
mongoLoggerSvc: mongoLoggerSvc, mongoLoggerSvc: mongoLoggerSvc,
analyticsDB: analyticsDB, analyticsDB: analyticsDB,

View File

@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"context"
"errors" "errors"
"strconv" "strconv"
@ -9,9 +10,11 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
const lmsProgressSummaryPageSize int32 = 200
// GetMyLMSProgress godoc // GetMyLMSProgress godoc
// @Summary Get my LMS completion history // @Summary Get my LMS completion history
// @Description Returns completed lesson, module, course, and program IDs for the authenticated user (ordered by completion time, then id). // @Description Returns practice-based completed lesson, module, course, and program IDs for the authenticated user (ordered by completion time, then id).
// @Tags lms // @Tags lms
// @Produce json // @Produce json
// @Success 200 {object} domain.Response // @Success 200 {object} domain.Response
@ -34,6 +37,32 @@ func (h *Handler) GetMyLMSProgress(c *fiber.Ctx) error {
}) })
} }
// GetMyLMSProgressSummary godoc
// @Summary Get my LMS progress summary
// @Description Returns the learner's nested LMS hierarchy with the same access progress data exposed on the individual program, course, module, and lesson APIs.
// @Tags lms
// @Produce json
// @Success 200 {object} domain.Response
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/lms/progress-summary [get]
func (h *Handler) GetMyLMSProgressSummary(c *fiber.Ctx) error {
uid := c.Locals("user_id").(int64)
role := c.Locals("role").(domain.Role)
summary, err := h.buildLMSProgressSummary(c.Context(), role, uid, !h.canManageLessons(c))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to load learning progress summary",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "LMS progress summary retrieved successfully",
Data: summary,
Success: true,
StatusCode: fiber.StatusOK,
})
}
// AdminGetUserLMSLearningActivity godoc // AdminGetUserLMSLearningActivity godoc
// @Summary Get a user's nested LMS learning activity (admin) // @Summary Get a user's nested LMS learning activity (admin)
// @Description Returns programs, courses, modules, and lessons with completion details and completed practices. Only persisted completion signals are included (completed lessons, completed published practices, and rollup completion timestamps—not partial or in-progress attempts). // @Description Returns programs, courses, modules, and lessons with completion details and completed practices. Only persisted completion signals are included (completed lessons, completed published practices, and rollup completion timestamps—not partial or in-progress attempts).
@ -140,3 +169,168 @@ func (h *Handler) AdminGetUserRecentActivity(c *fiber.Ctx) error {
StatusCode: fiber.StatusOK, StatusCode: fiber.StatusOK,
}) })
} }
func (h *Handler) buildLMSProgressSummary(ctx context.Context, role domain.Role, userID int64, publishedOnly bool) (domain.LMSProgressSummary, error) {
programs, err := h.listAllPrograms(ctx)
if err != nil {
return domain.LMSProgressSummary{}, err
}
summary := domain.LMSProgressSummary{
Programs: make([]domain.LMSProgressSummaryProgram, 0, len(programs)),
}
for i := range programs {
if err := h.lmsProgressSvc.ApplyAccessProgram(ctx, role, userID, &programs[i]); err != nil {
return domain.LMSProgressSummary{}, err
}
courses, err := h.listAllCoursesByProgram(ctx, programs[i].ID)
if err != nil {
return domain.LMSProgressSummary{}, err
}
programSummary := domain.LMSProgressSummaryProgram{
ID: programs[i].ID,
Name: programs[i].Name,
Access: programs[i].Access,
Courses: make([]domain.LMSProgressSummaryCourse, 0, len(courses)),
}
for j := range courses {
if err := h.lmsProgressSvc.ApplyAccessCourse(ctx, role, userID, &courses[j]); err != nil {
return domain.LMSProgressSummary{}, err
}
modules, err := h.listAllModulesByCourse(ctx, courses[j].ID)
if err != nil {
return domain.LMSProgressSummary{}, err
}
courseSummary := domain.LMSProgressSummaryCourse{
ID: courses[j].ID,
ProgramID: courses[j].ProgramID,
Name: courses[j].Name,
Access: courses[j].Access,
Modules: make([]domain.LMSProgressSummaryModule, 0, len(modules)),
}
for k := range modules {
if err := h.lmsProgressSvc.ApplyAccessModule(ctx, role, userID, &modules[k]); err != nil {
return domain.LMSProgressSummary{}, err
}
lessons, err := h.listAllLessonsByModule(ctx, modules[k].ID, publishedOnly)
if err != nil {
return domain.LMSProgressSummary{}, err
}
moduleSummary := domain.LMSProgressSummaryModule{
ID: modules[k].ID,
ProgramID: modules[k].ProgramID,
CourseID: modules[k].CourseID,
Name: modules[k].Name,
Access: modules[k].Access,
Lessons: make([]domain.LMSProgressSummaryLesson, 0, len(lessons)),
}
for m := range lessons {
if err := h.lmsProgressSvc.ApplyAccessLesson(ctx, role, userID, &lessons[m]); err != nil {
return domain.LMSProgressSummary{}, err
}
moduleSummary.Lessons = append(moduleSummary.Lessons, domain.LMSProgressSummaryLesson{
ID: lessons[m].ID,
ModuleID: lessons[m].ModuleID,
Title: lessons[m].Title,
Access: lessons[m].Access,
})
}
courseSummary.Modules = append(courseSummary.Modules, moduleSummary)
}
programSummary.Courses = append(programSummary.Courses, courseSummary)
}
summary.Programs = append(summary.Programs, programSummary)
}
return summary, nil
}
func (h *Handler) listAllPrograms(ctx context.Context) ([]domain.Program, error) {
var (
all []domain.Program
offset int32
)
for {
items, total, err := h.programSvc.List(ctx, lmsProgressSummaryPageSize, offset)
if err != nil {
return nil, err
}
all = append(all, items...)
if len(items) == 0 || int64(len(all)) >= total {
if all == nil {
return []domain.Program{}, nil
}
return all, nil
}
offset += lmsProgressSummaryPageSize
}
}
func (h *Handler) listAllCoursesByProgram(ctx context.Context, programID int64) ([]domain.Course, error) {
var (
all []domain.Course
offset int32
)
for {
items, total, err := h.courseSvc.ListByProgram(ctx, programID, lmsProgressSummaryPageSize, offset)
if err != nil {
return nil, err
}
all = append(all, items...)
if len(items) == 0 || int64(len(all)) >= total {
if all == nil {
return []domain.Course{}, nil
}
return all, nil
}
offset += lmsProgressSummaryPageSize
}
}
func (h *Handler) listAllModulesByCourse(ctx context.Context, courseID int64) ([]domain.Module, error) {
var (
all []domain.Module
offset int32
)
for {
items, total, err := h.moduleSvc.ListByCourse(ctx, courseID, lmsProgressSummaryPageSize, offset)
if err != nil {
return nil, err
}
all = append(all, items...)
if len(items) == 0 || int64(len(all)) >= total {
if all == nil {
return []domain.Module{}, nil
}
return all, nil
}
offset += lmsProgressSummaryPageSize
}
}
func (h *Handler) listAllLessonsByModule(ctx context.Context, moduleID int64, publishedOnly bool) ([]domain.Lesson, error) {
var (
all []domain.Lesson
offset int32
)
for {
items, total, err := h.lessonSvc.ListByModule(ctx, moduleID, publishedOnly, lmsProgressSummaryPageSize, offset)
if err != nil {
return nil, err
}
all = append(all, items...)
if len(items) == 0 || int64(len(all)) >= total {
if all == nil {
return []domain.Lesson{}, nil
}
return all, nil
}
offset += lmsProgressSummaryPageSize
}
}

View File

@ -0,0 +1,390 @@
package handlers
import (
"Yimaru-Backend/internal/domain"
"errors"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
)
type createMobileAppVersionReq struct {
Platform string `json:"platform" validate:"required"`
VersionName string `json:"version_name" validate:"required"`
VersionCode int32 `json:"version_code" validate:"required,min=1"`
UpdateType *string `json:"update_type"`
ReleaseNotes *string `json:"release_notes"`
StoreURL *string `json:"store_url"`
MinSupportedVersionCode *int32 `json:"min_supported_version_code"`
Status *string `json:"status"`
}
type updateMobileAppVersionReq struct {
VersionName *string `json:"version_name"`
VersionCode *int32 `json:"version_code"`
UpdateType *string `json:"update_type"`
ReleaseNotes *string `json:"release_notes"`
StoreURL *string `json:"store_url"`
MinSupportedVersionCode *int32 `json:"min_supported_version_code"`
Status *string `json:"status"`
}
type mobileAppVersionRes struct {
ID int64 `json:"id"`
Platform string `json:"platform"`
VersionName string `json:"version_name"`
VersionCode int32 `json:"version_code"`
UpdateType string `json:"update_type"`
ReleaseNotes *string `json:"release_notes,omitempty"`
StoreURL *string `json:"store_url,omitempty"`
MinSupportedVersionCode *int32 `json:"min_supported_version_code,omitempty"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
UpdatedAt *string `json:"updated_at,omitempty"`
}
type listMobileAppVersionsRes struct {
Versions []mobileAppVersionRes `json:"versions"`
TotalCount int64 `json:"total_count"`
}
func mapMobileAppVersionToRes(v domain.MobileAppVersion) mobileAppVersionRes {
var updatedAt *string
if v.UpdatedAt != nil {
value := v.UpdatedAt.String()
updatedAt = &value
}
return mobileAppVersionRes{
ID: v.ID,
Platform: v.Platform,
VersionName: v.VersionName,
VersionCode: v.VersionCode,
UpdateType: v.UpdateType,
ReleaseNotes: v.ReleaseNotes,
StoreURL: v.StoreURL,
MinSupportedVersionCode: v.MinSupportedVersionCode,
Status: v.Status,
CreatedAt: v.CreatedAt.String(),
UpdatedAt: updatedAt,
}
}
// CheckMobileAppVersion godoc
// @Summary Check mobile app version
// @Description Public endpoint for mobile clients to determine if an app update is available (force or optional)
// @Tags app-versions
// @Produce json
// @Param platform query string true "Platform: ANDROID or IOS"
// @Param version_code query int true "Client build number (Android versionCode / iOS build number)"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/app/version/check [get]
func (h *Handler) CheckMobileAppVersion(c *fiber.Ctx) error {
platform := strings.TrimSpace(c.Query("platform"))
if platform == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "platform is required",
Error: "MISSING_PLATFORM",
})
}
versionCodeStr := strings.TrimSpace(c.Query("version_code"))
if versionCodeStr == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "version_code is required",
Error: "MISSING_VERSION_CODE",
})
}
versionCode, err := strconv.ParseInt(versionCodeStr, 10, 32)
if err != nil || versionCode <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "version_code must be a positive integer",
Error: "INVALID_VERSION_CODE",
})
}
result, err := h.appVersionSvc.CheckMobileAppVersion(c.Context(), platform, int32(versionCode))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Failed to check app version",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "App version check completed",
Data: result,
Success: true,
StatusCode: fiber.StatusOK,
})
}
// ListMobileAppVersionsAdmin godoc
// @Summary List mobile app versions (admin)
// @Tags app-versions
// @Produce json
// @Security Bearer
// @Param platform query string false "Filter by ANDROID or IOS"
// @Param status query string false "Filter by ACTIVE or INACTIVE"
// @Param limit query int false "Limit (default 20)"
// @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/admin/app-versions [get]
func (h *Handler) ListMobileAppVersionsAdmin(c *fiber.Ctx) error {
var platformPtr *string
if platform := strings.TrimSpace(c.Query("platform")); platform != "" {
platformPtr = &platform
}
var statusPtr *string
if status := strings.TrimSpace(c.Query("status")); status != "" {
statusPtr = &status
}
limit, err := strconv.Atoi(c.Query("limit", "20"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid limit",
Error: err.Error(),
})
}
offset, err := strconv.Atoi(c.Query("offset", "0"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid offset",
Error: err.Error(),
})
}
versions, total, err := h.appVersionSvc.ListMobileAppVersions(c.Context(), platformPtr, statusPtr, int32(limit), int32(offset))
if err != nil {
code := fiber.StatusInternalServerError
if strings.Contains(err.Error(), "must be one of") {
code = fiber.StatusBadRequest
}
return c.Status(code).JSON(domain.ErrorResponse{
Message: "Failed to list app versions",
Error: err.Error(),
})
}
out := make([]mobileAppVersionRes, 0, len(versions))
for _, v := range versions {
out = append(out, mapMobileAppVersionToRes(v))
}
return c.JSON(domain.Response{
Message: "App versions retrieved successfully",
Data: listMobileAppVersionsRes{
Versions: out,
TotalCount: total,
},
Success: true,
StatusCode: fiber.StatusOK,
})
}
// GetMobileAppVersionByIDAdmin godoc
// @Summary Get mobile app version by ID (admin)
// @Tags app-versions
// @Produce json
// @Security Bearer
// @Param id path int true "App version ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/admin/app-versions/{id} [get]
func (h *Handler) GetMobileAppVersionByIDAdmin(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 app version ID",
Error: err.Error(),
})
}
version, err := h.appVersionSvc.GetMobileAppVersionByID(c.Context(), id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "App version not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to get app version",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "App version retrieved successfully",
Data: mapMobileAppVersionToRes(version),
Success: true,
StatusCode: fiber.StatusOK,
})
}
// CreateMobileAppVersion godoc
// @Summary Create mobile app version (admin)
// @Tags app-versions
// @Accept json
// @Produce json
// @Security Bearer
// @Param body body createMobileAppVersionReq true "App version payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/admin/app-versions [post]
func (h *Handler) CreateMobileAppVersion(c *fiber.Ctx) error {
var req createMobileAppVersionReq
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: firstValidationError(valErrs),
})
}
version, err := h.appVersionSvc.CreateMobileAppVersion(c.Context(), domain.CreateMobileAppVersionInput{
Platform: req.Platform,
VersionName: req.VersionName,
VersionCode: req.VersionCode,
UpdateType: req.UpdateType,
ReleaseNotes: req.ReleaseNotes,
StoreURL: req.StoreURL,
MinSupportedVersionCode: req.MinSupportedVersionCode,
Status: req.Status,
})
if err != nil {
code := fiber.StatusInternalServerError
if strings.Contains(err.Error(), "required") || strings.Contains(err.Error(), "must be") {
code = fiber.StatusBadRequest
}
return c.Status(code).JSON(domain.ErrorResponse{
Message: "Failed to create app version",
Error: err.Error(),
})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "App version created successfully",
Data: mapMobileAppVersionToRes(version),
Success: true,
StatusCode: fiber.StatusCreated,
})
}
// UpdateMobileAppVersion godoc
// @Summary Update mobile app version (admin)
// @Tags app-versions
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path int true "App version ID"
// @Param body body updateMobileAppVersionReq true "App version payload"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/admin/app-versions/{id} [put]
func (h *Handler) UpdateMobileAppVersion(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 app version ID",
Error: err.Error(),
})
}
var req updateMobileAppVersionReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
version, err := h.appVersionSvc.UpdateMobileAppVersion(c.Context(), id, domain.UpdateMobileAppVersionInput{
VersionName: req.VersionName,
VersionCode: req.VersionCode,
UpdateType: req.UpdateType,
ReleaseNotes: req.ReleaseNotes,
StoreURL: req.StoreURL,
MinSupportedVersionCode: req.MinSupportedVersionCode,
Status: req.Status,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "App version not found",
Error: err.Error(),
})
}
code := fiber.StatusInternalServerError
if strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "must be") || strings.Contains(err.Error(), "cannot") {
code = fiber.StatusBadRequest
}
return c.Status(code).JSON(domain.ErrorResponse{
Message: "Failed to update app version",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "App version updated successfully",
Data: mapMobileAppVersionToRes(version),
Success: true,
StatusCode: fiber.StatusOK,
})
}
// DeleteMobileAppVersion godoc
// @Summary Delete mobile app version (admin)
// @Tags app-versions
// @Produce json
// @Security Bearer
// @Param id path int true "App version ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/admin/app-versions/{id} [delete]
func (h *Handler) DeleteMobileAppVersion(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 app version ID",
Error: err.Error(),
})
}
if err := h.appVersionSvc.DeleteMobileAppVersion(c.Context(), id); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "App version not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to delete app version",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "App version deleted successfully",
Success: true,
StatusCode: fiber.StatusOK,
})
}

View File

@ -173,6 +173,12 @@ func (h *Handler) UpdateProgram(c *fiber.Ctx) error {
Error: err.Error(), Error: err.Error(),
}) })
} }
if valErrs, ok := h.validator.Validate(c, req); !ok {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Validation failed",
Error: firstValidationError(valErrs),
})
}
p, err := h.programSvc.Update(c.Context(), id, req) p, err := h.programSvc.Update(c.Context(), id, req)
if err != nil { if err != nil {
if errors.Is(err, programs.ErrProgramNotFound) { if errors.Is(err, programs.ErrProgramNotFound) {

View File

@ -1573,6 +1573,14 @@ func (h *Handler) CompletePractice(c *fiber.Ctx) error {
}) })
} }
set, setErr = h.questionsSvc.GetQuestionSetByID(c.Context(), practice.QuestionSetID) set, setErr = h.questionsSvc.GetQuestionSetByID(c.Context(), practice.QuestionSetID)
} else if examPractice, examPracticeErr := h.examPrepSvc.GetExamPrepPracticeByID(c.Context(), id); examPracticeErr == nil {
if !examPractice.VisibleToLearners() {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "Only published practices can be completed",
})
}
set, setErr = h.questionsSvc.GetQuestionSetByID(c.Context(), examPractice.QuestionSetID)
practiceErr = nil
} else { } else {
// Backward compatibility: also accept question_set.id directly. // Backward compatibility: also accept question_set.id directly.
set, setErr = h.questionsSvc.GetQuestionSetByID(c.Context(), id) set, setErr = h.questionsSvc.GetQuestionSetByID(c.Context(), id)

View File

@ -2,7 +2,6 @@ package handlers
import ( import (
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/services/chapa"
subscriptionsvc "Yimaru-Backend/internal/services/subscriptions" subscriptionsvc "Yimaru-Backend/internal/services/subscriptions"
"context" "context"
"encoding/json" "encoding/json"
@ -20,6 +19,7 @@ import (
type createPlanReq struct { type createPlanReq struct {
Name string `json:"name" validate:"required"` Name string `json:"name" validate:"required"`
Description *string `json:"description"` Description *string `json:"description"`
Category string `json:"category" validate:"required,oneof=LEARN_ENGLISH IELTS DUOLINGO"`
DurationValue int32 `json:"duration_value" validate:"required,min=1"` DurationValue int32 `json:"duration_value" validate:"required,min=1"`
DurationUnit string `json:"duration_unit" validate:"required,oneof=DAY WEEK MONTH YEAR"` DurationUnit string `json:"duration_unit" validate:"required,oneof=DAY WEEK MONTH YEAR"`
Price float64 `json:"price" validate:"required,min=0"` Price float64 `json:"price" validate:"required,min=0"`
@ -30,6 +30,7 @@ type createPlanReq struct {
type updatePlanReq struct { type updatePlanReq struct {
Name *string `json:"name"` Name *string `json:"name"`
Description *string `json:"description"` Description *string `json:"description"`
Category *string `json:"category" validate:"omitempty,oneof=LEARN_ENGLISH IELTS DUOLINGO"`
DurationValue *int32 `json:"duration_value"` DurationValue *int32 `json:"duration_value"`
DurationUnit *string `json:"duration_unit"` DurationUnit *string `json:"duration_unit"`
Price *float64 `json:"price"` Price *float64 `json:"price"`
@ -41,6 +42,7 @@ type planRes struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Category string `json:"category"`
DurationValue int32 `json:"duration_value"` DurationValue int32 `json:"duration_value"`
DurationUnit string `json:"duration_unit"` DurationUnit string `json:"duration_unit"`
Price float64 `json:"price"` Price float64 `json:"price"`
@ -60,9 +62,10 @@ type subscribeReq struct {
} }
type subscribeWithPaymentReq struct { type subscribeWithPaymentReq struct {
PlanID int64 `json:"plan_id" validate:"required"` PlanID int64 `json:"plan_id" validate:"required"`
Phone string `json:"phone" validate:"required"` Phone string `json:"phone" validate:"required"`
Email string `json:"email" validate:"required,email"` Email string `json:"email" validate:"required,email"`
Provider string `json:"provider" validate:"required"`
} }
type subscriptionRes struct { type subscriptionRes struct {
@ -110,10 +113,17 @@ func (h *Handler) CreateSubscriptionPlan(c *fiber.Ctx) error {
Error: err.Error(), Error: err.Error(),
}) })
} }
if valErrs, ok := h.validator.Validate(c, req); !ok {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Validation failed",
Error: firstValidationError(valErrs),
})
}
plan, err := h.subscriptionsSvc.CreatePlan(c.Context(), domain.CreateSubscriptionPlanInput{ plan, err := h.subscriptionsSvc.CreatePlan(c.Context(), domain.CreateSubscriptionPlanInput{
Name: req.Name, Name: req.Name,
Description: req.Description, Description: req.Description,
Category: req.Category,
DurationValue: req.DurationValue, DurationValue: req.DurationValue,
DurationUnit: req.DurationUnit, DurationUnit: req.DurationUnit,
Price: req.Price, Price: req.Price,
@ -228,10 +238,17 @@ func (h *Handler) UpdateSubscriptionPlan(c *fiber.Ctx) error {
Error: err.Error(), Error: err.Error(),
}) })
} }
if valErrs, ok := h.validator.Validate(c, req); !ok {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Validation failed",
Error: firstValidationError(valErrs),
})
}
err = h.subscriptionsSvc.UpdatePlan(c.Context(), id, domain.UpdateSubscriptionPlanInput{ err = h.subscriptionsSvc.UpdatePlan(c.Context(), id, domain.UpdateSubscriptionPlanInput{
Name: req.Name, Name: req.Name,
Description: req.Description, Description: req.Description,
Category: req.Category,
DurationValue: req.DurationValue, DurationValue: req.DurationValue,
DurationUnit: req.DurationUnit, DurationUnit: req.DurationUnit,
Price: req.Price, Price: req.Price,
@ -381,21 +398,22 @@ func (h *Handler) SubscribeWithPayment(c *fiber.Ctx) error {
}) })
} }
// Use ArifPay service to initiate payment provider, err := domain.ParsePaymentProvider(req.Provider)
result, err := h.chapaSvc.InitiateSubscriptionPayment(c.Context(), userID, domain.InitiateSubscriptionPaymentRequest{ if err != nil {
PlanID: req.PlanID, return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Phone: req.Phone, Message: "Invalid payment provider",
Email: req.Email, Error: err.Error(),
})
}
result, err := h.initiateSubscriptionPayment(c.Context(), userID, domain.InitiateSubscriptionPaymentRequest{
PlanID: req.PlanID,
Phone: req.Phone,
Email: req.Email,
Provider: provider,
}) })
if err != nil { if err != nil {
status := fiber.StatusInternalServerError status := paymentInitiationStatus(err)
if errors.Is(err, chapa.ErrChapaNotConfigured) {
status = fiber.StatusServiceUnavailable
} else 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{ return c.Status(status).JSON(domain.ErrorResponse{
Message: "Failed to initiate subscription payment", Message: "Failed to initiate subscription payment",
Error: err.Error(), Error: err.Error(),
@ -622,6 +640,7 @@ func planToRes(p *domain.SubscriptionPlan) *planRes {
ID: p.ID, ID: p.ID,
Name: p.Name, Name: p.Name,
Description: p.Description, Description: p.Description,
Category: p.Category,
DurationValue: p.DurationValue, DurationValue: p.DurationValue,
DurationUnit: p.DurationUnit, DurationUnit: p.DurationUnit,
Price: p.Price, Price: p.Price,

View File

@ -31,12 +31,16 @@ type listTeamInvitationsRes struct {
} }
func mapTeamInvitationListItem(row domain.TeamInvitationWithMember) teamInvitationListItem { func mapTeamInvitationListItem(row domain.TeamInvitationWithMember) teamInvitationListItem {
firstName, lastName := row.FirstName, row.LastName
if domain.IsTeamInvitePlaceholderProfile(firstName, lastName) {
firstName, lastName = "", ""
}
return teamInvitationListItem{ return teamInvitationListItem{
ID: row.ID, ID: row.ID,
TeamMemberID: row.TeamMemberID, TeamMemberID: row.TeamMemberID,
Email: row.Email, Email: row.Email,
FirstName: row.FirstName, FirstName: firstName,
LastName: row.LastName, LastName: lastName,
TeamRole: string(row.TeamRole), TeamRole: string(row.TeamRole),
Status: string(row.Status), Status: string(row.Status),
ExpiresAt: row.ExpiresAt.Format(time.RFC3339), ExpiresAt: row.ExpiresAt.Format(time.RFC3339),
@ -46,7 +50,7 @@ func mapTeamInvitationListItem(row domain.TeamInvitationWithMember) teamInvitati
// InviteTeamMember godoc // InviteTeamMember godoc
// @Summary Invite a team member by email // @Summary Invite a team member by email
// @Description Creates a pending team member and sends an invitation email with a setup link // @Description Creates a pending team member (email + team_role only) and sends an invitation email; profile is completed on accept
// @Tags team // @Tags team
// @Accept json // @Accept json
// @Produce json // @Produce json
@ -236,8 +240,8 @@ func (h *Handler) VerifyTeamInvitation(c *fiber.Ctx) error {
} }
// AcceptTeamInvitation godoc // AcceptTeamInvitation godoc
// @Summary Accept team invitation and set password // @Summary Accept team invitation and complete account setup
// @Description Public endpoint to activate a team member account after following the invite link // @Description Public endpoint to set password and profile details after following the invite link
// @Tags team // @Tags team
// @Accept json // @Accept json
// @Produce json // @Produce json

View File

@ -0,0 +1,106 @@
package handlers
import (
"errors"
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/repository"
"Yimaru-Backend/internal/services/lessons"
"github.com/gofiber/fiber/v2"
)
// RecordVideoEngagementHeartbeat godoc
// @Summary Report video playback progress
// @Description Records playback position for analytics (completion, replay, and drop-off). Send periodic heartbeats while watching; set ended=true when the viewer leaves. A new session starts after 30 minutes of inactivity or when ended=true on the prior session.
// @Tags videos
// @Accept json
// @Produce json
// @Param body body domain.VideoEngagementHeartbeatInput true "Playback heartbeat"
// @Success 200 {object} domain.Response{data=domain.VideoWatchSessionResponse}
// @Failure 400 {object} domain.ErrorResponse
// @Failure 403 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Router /api/v1/videos/engagement/heartbeat [post]
func (h *Handler) RecordVideoEngagementHeartbeat(c *fiber.Ctx) error {
var req domain.VideoEngagementHeartbeatInput
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: firstValidationError(valErrs),
})
}
uid := c.Locals("user_id").(int64)
role := c.Locals("role").(domain.Role)
if req.ContentKind == domain.VideoContentKindLMSLesson {
les, err := h.lessonSvc.GetByID(c.Context(), req.ContentID)
if err != nil {
if errors.Is(err, lessons.ErrLessonNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Lesson not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to load lesson",
Error: err.Error(),
})
}
if role.IsCustomerLearnerRole() && !les.VisibleToLearners() {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "Only published lessons can be tracked",
Error: "LESSON_NOT_PUBLISHED",
})
}
if role.UsesLMSSequentialGating() {
ok, reason, err := h.lmsProgressSvc.CanAccessLesson(c.Context(), uid, req.ContentID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to verify lesson access",
Error: err.Error(),
})
}
if !ok {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: reason,
Error: "LMS_PREREQUISITE_NOT_MET",
})
}
}
}
res, err := h.videoEngagementSvc.RecordHeartbeat(c.Context(), uid, req)
if err != nil {
if errors.Is(err, repository.ErrVideoContentNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Video content not found",
Error: err.Error(),
})
}
if errors.Is(err, repository.ErrVideoContentHasNoURL) {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Content has no video",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to record video engagement",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Video engagement recorded",
Data: res,
Success: true,
StatusCode: fiber.StatusOK,
})
}

View File

@ -2,9 +2,12 @@ package httpserver
import ( import (
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
examprepsvc "Yimaru-Backend/internal/services/examprep"
jwtutil "Yimaru-Backend/internal/web_server/jwt" jwtutil "Yimaru-Backend/internal/web_server/jwt"
"context"
"errors" "errors"
"fmt" "fmt"
"strconv"
"strings" "strings"
"time" "time"
@ -12,6 +15,8 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
var categorySubscriptionGateDisabled = true
func (a *App) authMiddleware(c *fiber.Ctx) error { func (a *App) authMiddleware(c *fiber.Ctx) error {
ip := c.IP() ip := c.IP()
userAgent := c.Get("User-Agent") userAgent := c.Get("User-Agent")
@ -210,6 +215,245 @@ func (a *App) RequireActiveSubscription() fiber.Handler {
} }
} }
func (a *App) RequireSubscriptionCategory(category domain.SubscriptionCategory) fiber.Handler {
return func(c *fiber.Ctx) error {
role, userID, err := subscriptionScopedUser(c)
if err != nil {
return err
}
if bypassSubscriptionForRole(role) {
return c.Next()
}
if role != domain.RoleStudent && role != domain.RoleOpenLearner {
return c.Next()
}
if categorySubscriptionGateDisabled {
// Temporary bypass to disable category-aware learner access checks without changing route wiring.
return c.Next()
}
active, err := a.subscriptionsSvc.HasActiveSubscriptionByCategory(c.Context(), userID, category)
if err != nil {
a.mongoLoggerSvc.Error("category subscription check failed",
zap.Int64("userID", userID),
zap.String("category", string(category)),
zap.String("path", c.Path()),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to verify subscription")
}
if !active {
return fiber.NewError(fiber.StatusForbidden, fmt.Sprintf("An active %s subscription is required", humanizeSubscriptionCategory(category)))
}
return c.Next()
}
}
func (a *App) RequireExamPrepSubscription() fiber.Handler {
return func(c *fiber.Ctx) error {
role, userID, err := subscriptionScopedUser(c)
if err != nil {
return err
}
if bypassSubscriptionForRole(role) {
return c.Next()
}
if role != domain.RoleStudent && role != domain.RoleOpenLearner {
return c.Next()
}
if categorySubscriptionGateDisabled {
// Temporary bypass to disable category-aware learner access checks without changing route wiring.
return c.Next()
}
category, scoped, err := a.resolveExamPrepSubscriptionCategory(c)
if err != nil {
switch {
case errors.Is(err, examprepsvc.ErrCatalogCourseNotFound),
errors.Is(err, examprepsvc.ErrUnitNotFound),
errors.Is(err, examprepsvc.ErrModuleNotFound),
errors.Is(err, examprepsvc.ErrLessonNotFound),
errors.Is(err, examprepsvc.ErrPracticeNotFound):
return fiber.NewError(fiber.StatusNotFound, err.Error())
default:
a.mongoLoggerSvc.Error("exam prep category resolution failed",
zap.Int64("userID", userID),
zap.String("path", c.Path()),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to verify subscription")
}
}
if !scoped {
hasIELTS, err := a.subscriptionsSvc.HasActiveSubscriptionByCategory(c.Context(), userID, domain.SubscriptionCategoryIELTS)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to verify subscription")
}
hasDuolingo, err := a.subscriptionsSvc.HasActiveSubscriptionByCategory(c.Context(), userID, domain.SubscriptionCategoryDuolingo)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to verify subscription")
}
if !hasIELTS && !hasDuolingo {
return fiber.NewError(fiber.StatusForbidden, "An active IELTS or Duolingo subscription is required")
}
return c.Next()
}
active, err := a.subscriptionsSvc.HasActiveSubscriptionByCategory(c.Context(), userID, category)
if err != nil {
a.mongoLoggerSvc.Error("exam prep subscription check failed",
zap.Int64("userID", userID),
zap.String("category", string(category)),
zap.String("path", c.Path()),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to verify subscription")
}
if !active {
return fiber.NewError(fiber.StatusForbidden, fmt.Sprintf("An active %s subscription is required", humanizeSubscriptionCategory(category)))
}
return c.Next()
}
}
func subscriptionScopedUser(c *fiber.Ctx) (domain.Role, int64, error) {
role, ok := c.Locals("role").(domain.Role)
if !ok {
return "", 0, fiber.NewError(fiber.StatusForbidden, "Role not found in context")
}
userID, ok := c.Locals("user_id").(int64)
if !ok || userID == 0 {
return role, 0, fiber.NewError(fiber.StatusUnauthorized, "Unauthorized")
}
return role, userID, nil
}
func bypassSubscriptionForRole(role domain.Role) bool {
switch role {
case domain.RoleSuperAdmin, domain.RoleAdmin, domain.RoleInstructor, domain.RoleSupport:
return true
default:
return false
}
}
func humanizeSubscriptionCategory(category domain.SubscriptionCategory) string {
return strings.ToLower(strings.ReplaceAll(string(category), "_", " "))
}
func parseRouteInt64(c *fiber.Ctx, name string) (int64, bool, error) {
raw := strings.TrimSpace(c.Params(name))
if raw == "" {
return 0, false, nil
}
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return 0, false, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid %s", name))
}
return id, true, nil
}
func (a *App) resolveExamPrepSubscriptionCategory(c *fiber.Ctx) (domain.SubscriptionCategory, bool, error) {
if catalogCourseID, ok, err := parseRouteInt64(c, "catalogCourseId"); err != nil {
return "", false, err
} else if ok {
return a.examPrepCategoryByCatalogCourseID(c.Context(), catalogCourseID)
}
if unitID, ok, err := parseRouteInt64(c, "unitId"); err != nil {
return "", false, err
} else if ok {
return a.examPrepCategoryByUnitID(c.Context(), unitID)
}
if moduleID, ok, err := parseRouteInt64(c, "moduleId"); err != nil {
return "", false, err
} else if ok {
return a.examPrepCategoryByModuleID(c.Context(), moduleID)
}
if lessonID, ok, err := parseRouteInt64(c, "lessonId"); err != nil {
return "", false, err
} else if ok {
return a.examPrepCategoryByLessonID(c.Context(), lessonID)
}
switch routePath := c.Route().Path; {
case strings.Contains(routePath, "/catalog-courses/:id"):
if id, ok, err := parseRouteInt64(c, "id"); err != nil {
return "", false, err
} else if ok {
return a.examPrepCategoryByCatalogCourseID(c.Context(), id)
}
case strings.Contains(routePath, "/units/:id"):
if id, ok, err := parseRouteInt64(c, "id"); err != nil {
return "", false, err
} else if ok {
return a.examPrepCategoryByUnitID(c.Context(), id)
}
case strings.Contains(routePath, "/modules/:id"):
if id, ok, err := parseRouteInt64(c, "id"); err != nil {
return "", false, err
} else if ok {
return a.examPrepCategoryByModuleID(c.Context(), id)
}
case strings.Contains(routePath, "/lessons/:id"):
if id, ok, err := parseRouteInt64(c, "id"); err != nil {
return "", false, err
} else if ok {
return a.examPrepCategoryByLessonID(c.Context(), id)
}
case strings.Contains(routePath, "/practices/:id"):
if id, ok, err := parseRouteInt64(c, "id"); err != nil {
return "", false, err
} else if ok {
return a.examPrepCategoryByPracticeID(c.Context(), id)
}
}
return "", false, nil
}
func (a *App) examPrepCategoryByCatalogCourseID(ctx context.Context, catalogCourseID int64) (domain.SubscriptionCategory, bool, error) {
catalogCourse, err := a.examPrepSvc.GetCatalogCourseByID(ctx, catalogCourseID)
if err != nil {
return "", false, err
}
return domain.SubscriptionCategory(catalogCourse.Category), true, nil
}
func (a *App) examPrepCategoryByUnitID(ctx context.Context, unitID int64) (domain.SubscriptionCategory, bool, error) {
unit, err := a.examPrepSvc.GetUnitByID(ctx, unitID)
if err != nil {
return "", false, err
}
return a.examPrepCategoryByCatalogCourseID(ctx, unit.CatalogCourseID)
}
func (a *App) examPrepCategoryByModuleID(ctx context.Context, moduleID int64) (domain.SubscriptionCategory, bool, error) {
module, err := a.examPrepSvc.GetModuleByID(ctx, moduleID)
if err != nil {
return "", false, err
}
return a.examPrepCategoryByUnitID(ctx, module.UnitID)
}
func (a *App) examPrepCategoryByLessonID(ctx context.Context, lessonID int64) (domain.SubscriptionCategory, bool, error) {
lesson, err := a.examPrepSvc.GetLessonByID(ctx, lessonID)
if err != nil {
return "", false, err
}
return a.examPrepCategoryByModuleID(ctx, lesson.UnitModuleID)
}
func (a *App) examPrepCategoryByPracticeID(ctx context.Context, practiceID int64) (domain.SubscriptionCategory, bool, error) {
practice, err := a.examPrepSvc.GetExamPrepPracticeByID(ctx, practiceID)
if err != nil {
return "", false, err
}
return a.examPrepCategoryByLessonID(ctx, practice.LessonID)
}
func (a *App) RequirePermission(permKey string) fiber.Handler { func (a *App) RequirePermission(permKey string) fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
userRole, ok := c.Locals("role").(domain.Role) userRole, ok := c.Locals("role").(domain.Role)

View File

@ -16,7 +16,9 @@ func (a *App) initAppRoutes() {
a.assessmentSvc, a.assessmentSvc,
a.questionsSvc, a.questionsSvc,
a.faqSvc, a.faqSvc,
a.appVersionSvc,
a.emailTemplateSvc, a.emailTemplateSvc,
a.profileFieldOptionSvc,
a.personaSvc, a.personaSvc,
a.examPrepSvc, a.examPrepSvc,
a.programSvc, a.programSvc,
@ -44,6 +46,7 @@ func (a *App) initAppRoutes() {
a.minioSvc, a.minioSvc,
a.ratingSvc, a.ratingSvc,
a.rbacSvc, a.rbacSvc,
a.videoEngagementSvc,
a.JwtConfig, a.JwtConfig,
a.cfg, a.cfg,
a.mongoLoggerSvc, a.mongoLoggerSvc,
@ -79,15 +82,16 @@ func (a *App) initAppRoutes() {
// Programs (LMS top-level) // Programs (LMS top-level)
groupV1.Post("/programs", a.authMiddleware, a.RequirePermission("programs.create"), h.CreateProgram) groupV1.Post("/programs", a.authMiddleware, a.RequirePermission("programs.create"), h.CreateProgram)
groupV1.Get("/programs", a.authMiddleware, a.RequirePermission("programs.list"), h.ListPrograms) groupV1.Get("/programs", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("programs.list"), h.ListPrograms)
groupV1.Put("/programs/reorder", a.authMiddleware, a.RequirePermission("programs.reorder"), h.ReorderPrograms) groupV1.Put("/programs/reorder", a.authMiddleware, a.RequirePermission("programs.reorder"), h.ReorderPrograms)
groupV1.Get("/lms/progress", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lms.get_my_progress"), h.GetMyLMSProgress) groupV1.Get("/lms/progress", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("lms.get_my_progress"), h.GetMyLMSProgress)
groupV1.Get("/programs/:id", a.authMiddleware, a.RequirePermission("programs.get"), h.GetProgram) groupV1.Get("/lms/progress-summary", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("lms.get_my_progress"), h.GetMyLMSProgressSummary)
groupV1.Get("/programs/:id", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("programs.get"), h.GetProgram)
groupV1.Put("/programs/:id", a.authMiddleware, a.RequirePermission("programs.update"), h.UpdateProgram) groupV1.Put("/programs/:id", a.authMiddleware, a.RequirePermission("programs.update"), h.UpdateProgram)
groupV1.Delete("/programs/:id", a.authMiddleware, a.RequirePermission("programs.delete"), h.DeleteProgram) groupV1.Delete("/programs/:id", a.authMiddleware, a.RequirePermission("programs.delete"), h.DeleteProgram)
// Exam prep (schema exam_prep — separate from LMS Learn English). Students need an active subscription. // Exam prep (schema exam_prep — separate from LMS Learn English). Students need an active subscription.
examPrep := groupV1.Group("/exam-prep", a.authMiddleware, a.RequireActiveSubscription()) examPrep := groupV1.Group("/exam-prep", a.authMiddleware, a.RequireExamPrepSubscription())
examPrep.Post("/catalog-courses", a.RequirePermission("exam_prep.catalog_courses.create"), h.CreateExamPrepCatalogCourse) examPrep.Post("/catalog-courses", a.RequirePermission("exam_prep.catalog_courses.create"), h.CreateExamPrepCatalogCourse)
examPrep.Get("/catalog-courses", a.RequirePermission("exam_prep.catalog_courses.list"), h.ListExamPrepCatalogCourses) examPrep.Get("/catalog-courses", a.RequirePermission("exam_prep.catalog_courses.list"), h.ListExamPrepCatalogCourses)
examPrep.Put("/catalog-courses/reorder", a.RequirePermission("exam_prep.catalog_courses.reorder"), h.ReorderExamPrepCatalogCourses) examPrep.Put("/catalog-courses/reorder", a.RequirePermission("exam_prep.catalog_courses.reorder"), h.ReorderExamPrepCatalogCourses)
@ -126,31 +130,32 @@ func (a *App) initAppRoutes() {
// Courses // Courses
groupV1.Post("/programs/:id/courses", a.authMiddleware, a.RequirePermission("courses.create"), h.CreateCourse) groupV1.Post("/programs/:id/courses", a.authMiddleware, a.RequirePermission("courses.create"), h.CreateCourse)
groupV1.Put("/programs/:id/courses/reorder", a.authMiddleware, a.RequirePermission("courses.reorder"), h.ReorderCoursesInProgram) groupV1.Put("/programs/:id/courses/reorder", a.authMiddleware, a.RequirePermission("courses.reorder"), h.ReorderCoursesInProgram)
groupV1.Get("/programs/:id/courses", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("courses.list_by_program"), h.ListCoursesByProgram) groupV1.Get("/programs/:id/courses", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("courses.list_by_program"), h.ListCoursesByProgram)
groupV1.Get("/courses/:id/practices", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("practices.list"), h.ListPracticesByCourse) groupV1.Get("/courses/:id/practices", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("practices.list"), h.ListPracticesByCourse)
groupV1.Get("/courses/:id", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("courses.get"), h.GetCourse) groupV1.Get("/courses/:id", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("courses.get"), h.GetCourse)
groupV1.Put("/courses/:id", a.authMiddleware, a.RequirePermission("courses.update"), h.UpdateCourse) groupV1.Put("/courses/:id", a.authMiddleware, a.RequirePermission("courses.update"), h.UpdateCourse)
groupV1.Delete("/courses/:id", a.authMiddleware, a.RequirePermission("courses.delete"), h.DeleteCourse) groupV1.Delete("/courses/:id", a.authMiddleware, a.RequirePermission("courses.delete"), h.DeleteCourse)
groupV1.Post("/courses/:courseId/modules", a.authMiddleware, a.RequirePermission("modules.create"), h.CreateModule) groupV1.Post("/courses/:courseId/modules", a.authMiddleware, a.RequirePermission("modules.create"), h.CreateModule)
groupV1.Put("/courses/:courseId/modules/reorder", a.authMiddleware, a.RequirePermission("modules.reorder"), h.ReorderModulesInCourse) groupV1.Put("/courses/:courseId/modules/reorder", a.authMiddleware, a.RequirePermission("modules.reorder"), h.ReorderModulesInCourse)
groupV1.Get("/courses/:courseId/modules", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("modules.list_by_course"), h.ListModulesByCourse) groupV1.Get("/courses/:courseId/modules", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("modules.list_by_course"), h.ListModulesByCourse)
// /modules/:moduleId/lessons before /modules/:id; /modules/:id/practices before /modules/:id // /modules/:moduleId/lessons before /modules/:id; /modules/:id/practices before /modules/:id
groupV1.Post("/modules/:moduleId/lessons", a.authMiddleware, a.RequirePermission("lessons.create"), h.CreateLesson) groupV1.Post("/modules/:moduleId/lessons", a.authMiddleware, a.RequirePermission("lessons.create"), h.CreateLesson)
groupV1.Get("/modules/:moduleId/lessons", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lessons.list_by_module"), h.ListLessonsByModule) groupV1.Get("/modules/:moduleId/lessons", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("lessons.list_by_module"), h.ListLessonsByModule)
groupV1.Get("/modules/:id/practices", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("practices.list"), h.ListPracticesByModule) groupV1.Get("/modules/:id/practices", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("practices.list"), h.ListPracticesByModule)
groupV1.Get("/modules/:id", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("modules.get"), h.GetModule) groupV1.Get("/modules/:id", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("modules.get"), h.GetModule)
groupV1.Put("/modules/:id", a.authMiddleware, a.RequirePermission("modules.update"), h.UpdateModule) groupV1.Put("/modules/:id", a.authMiddleware, a.RequirePermission("modules.update"), h.UpdateModule)
groupV1.Delete("/modules/:id", a.authMiddleware, a.RequirePermission("modules.delete"), h.DeleteModule) groupV1.Delete("/modules/:id", a.authMiddleware, a.RequirePermission("modules.delete"), h.DeleteModule)
groupV1.Get("/lessons/:id/practices", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("practices.list"), h.ListPracticesByLesson) groupV1.Get("/lessons/:id/practices", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("practices.list"), h.ListPracticesByLesson)
groupV1.Post("/lessons/:id/complete", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lessons.complete"), h.CompleteLesson) groupV1.Post("/lessons/:id/complete", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("lessons.complete"), h.CompleteLesson)
groupV1.Post("/progress/practices/:id/complete", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("progress.complete"), h.CompletePractice) groupV1.Post("/videos/engagement/heartbeat", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("videos.track_engagement"), h.RecordVideoEngagementHeartbeat)
groupV1.Get("/lessons/:id", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lessons.get"), h.GetLesson) groupV1.Post("/progress/practices/:id/complete", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("progress.complete"), h.CompletePractice)
groupV1.Get("/lessons/:id", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("lessons.get"), h.GetLesson)
groupV1.Put("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.update"), h.UpdateLesson) groupV1.Put("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.update"), h.UpdateLesson)
groupV1.Delete("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.delete"), h.DeleteLesson) groupV1.Delete("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.delete"), h.DeleteLesson)
groupV1.Post("/practices", a.authMiddleware, a.RequirePermission("practices.create"), h.CreatePractice) groupV1.Post("/practices", a.authMiddleware, a.RequirePermission("practices.create"), h.CreatePractice)
groupV1.Get("/practices/:id", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("practices.get"), h.GetPractice) groupV1.Get("/practices/:id", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("practices.get"), h.GetPractice)
groupV1.Put("/practices/:id", a.authMiddleware, a.RequirePermission("practices.update"), h.UpdatePractice) groupV1.Put("/practices/:id", a.authMiddleware, a.RequirePermission("practices.update"), h.UpdatePractice)
groupV1.Delete("/practices/:id", a.authMiddleware, a.RequirePermission("practices.delete"), h.DeletePractice) groupV1.Delete("/practices/:id", a.authMiddleware, a.RequirePermission("practices.delete"), h.DeletePractice)
@ -197,6 +202,14 @@ func (a *App) initAppRoutes() {
groupV1.Put("/admin/faqs/:id", a.authMiddleware, a.RequirePermission("faqs.update"), h.UpdateFAQ) groupV1.Put("/admin/faqs/:id", a.authMiddleware, a.RequirePermission("faqs.update"), h.UpdateFAQ)
groupV1.Delete("/admin/faqs/:id", a.authMiddleware, a.RequirePermission("faqs.delete"), h.DeleteFAQ) groupV1.Delete("/admin/faqs/:id", a.authMiddleware, a.RequirePermission("faqs.delete"), h.DeleteFAQ)
// Mobile app versions (Play Store / App Store)
groupV1.Get("/app/version/check", h.CheckMobileAppVersion)
groupV1.Get("/admin/app-versions", a.authMiddleware, a.RequirePermission("app_versions.list"), h.ListMobileAppVersionsAdmin)
groupV1.Get("/admin/app-versions/:id", a.authMiddleware, a.RequirePermission("app_versions.get"), h.GetMobileAppVersionByIDAdmin)
groupV1.Post("/admin/app-versions", a.authMiddleware, a.RequirePermission("app_versions.create"), h.CreateMobileAppVersion)
groupV1.Put("/admin/app-versions/:id", a.authMiddleware, a.RequirePermission("app_versions.update"), h.UpdateMobileAppVersion)
groupV1.Delete("/admin/app-versions/:id", a.authMiddleware, a.RequirePermission("app_versions.delete"), h.DeleteMobileAppVersion)
// Email templates // Email templates
groupV1.Get("/admin/email-templates", a.authMiddleware, a.RequirePermission("email_templates.list"), h.ListEmailTemplatesAdmin) groupV1.Get("/admin/email-templates", a.authMiddleware, a.RequirePermission("email_templates.list"), h.ListEmailTemplatesAdmin)
groupV1.Get("/admin/email-templates/slug/:slug", a.authMiddleware, a.RequirePermission("email_templates.get"), h.GetEmailTemplateBySlugAdmin) groupV1.Get("/admin/email-templates/slug/:slug", a.authMiddleware, a.RequirePermission("email_templates.get"), h.GetEmailTemplateBySlugAdmin)
@ -207,6 +220,15 @@ func (a *App) initAppRoutes() {
groupV1.Put("/admin/email-templates/:id", a.authMiddleware, a.RequirePermission("email_templates.update"), h.UpdateEmailTemplate) groupV1.Put("/admin/email-templates/:id", a.authMiddleware, a.RequirePermission("email_templates.update"), h.UpdateEmailTemplate)
groupV1.Delete("/admin/email-templates/:id", a.authMiddleware, a.RequirePermission("email_templates.delete"), h.DeleteEmailTemplate) groupV1.Delete("/admin/email-templates/:id", a.authMiddleware, a.RequirePermission("email_templates.delete"), h.DeleteEmailTemplate)
// Field options (configurable dropdown values: profile fields, countries, etc.)
groupV1.Get("/field-options", h.ListPublicFieldOptions)
groupV1.Get("/field-options/fields", h.ListFieldKeys)
groupV1.Get("/admin/field-options", a.authMiddleware, a.RequirePermission("field_options.list"), h.ListFieldOptionsAdmin)
groupV1.Get("/admin/field-options/:id", a.authMiddleware, a.RequirePermission("field_options.get"), h.GetFieldOptionByIDAdmin)
groupV1.Post("/admin/field-options", a.authMiddleware, a.RequirePermission("field_options.create"), h.CreateFieldOption)
groupV1.Put("/admin/field-options/:id", a.authMiddleware, a.RequirePermission("field_options.update"), h.UpdateFieldOption)
groupV1.Delete("/admin/field-options/:id", a.authMiddleware, a.RequirePermission("field_options.delete"), h.DeleteFieldOption)
// Question Sets // Question Sets
groupV1.Post("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.create"), h.CreateQuestionSet) groupV1.Post("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.create"), h.CreateQuestionSet)
groupV1.Get("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetQuestionSetsByType) groupV1.Get("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetQuestionSetsByType)
@ -218,7 +240,7 @@ func (a *App) initAppRoutes() {
// Question Set Items // Question Set Items
groupV1.Post("/question-sets/:setId/questions", a.authMiddleware, a.RequirePermission("question_set_items.add"), h.AddQuestionToSet) groupV1.Post("/question-sets/:setId/questions", a.authMiddleware, a.RequirePermission("question_set_items.add"), h.AddQuestionToSet)
groupV1.Get("/question-sets/:setId/questions", a.authMiddleware, a.RequirePermission("question_set_items.list"), h.GetQuestionsInSet) groupV1.Get("/question-sets/:setId/questions", a.authMiddleware, a.RequirePermission("question_set_items.list"), h.GetQuestionsInSet)
groupV1.Get("/practices/:practiceId/questions", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("question_set_items.list"), h.GetQuestionsByPractice) groupV1.Get("/practices/:practiceId/questions", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("question_set_items.list"), h.GetQuestionsByPractice)
groupV1.Delete("/question-sets/:setId/questions/:questionId", a.authMiddleware, a.RequirePermission("question_set_items.remove"), h.RemoveQuestionFromSet) groupV1.Delete("/question-sets/:setId/questions/:questionId", a.authMiddleware, a.RequirePermission("question_set_items.remove"), h.RemoveQuestionFromSet)
groupV1.Put("/question-sets/:setId/questions/:questionId/order", a.authMiddleware, a.RequirePermission("question_set_items.update_order"), h.UpdateQuestionOrderInSet) groupV1.Put("/question-sets/:setId/questions/:questionId/order", a.authMiddleware, a.RequirePermission("question_set_items.update_order"), h.UpdateQuestionOrderInSet)
@ -251,6 +273,7 @@ func (a *App) initAppRoutes() {
groupV1.Get("/payments/:id", a.authMiddleware, a.RequirePermission("payments.get"), h.GetPaymentByID) groupV1.Get("/payments/:id", a.authMiddleware, a.RequirePermission("payments.get"), h.GetPaymentByID)
groupV1.Post("/payments/:id/cancel", a.authMiddleware, a.RequirePermission("payments.cancel"), h.CancelPayment) groupV1.Post("/payments/:id/cancel", a.authMiddleware, a.RequirePermission("payments.cancel"), h.CancelPayment)
groupV1.Post("/payments/webhook", h.HandleChapaWebhook) groupV1.Post("/payments/webhook", h.HandleChapaWebhook)
groupV1.Get("/payments/arifpay/success", h.HandleArifpaySuccessPage)
groupV1.Get("/payments/chapa/callback", h.HandleChapaCallback) groupV1.Get("/payments/chapa/callback", h.HandleChapaCallback)
// Direct Payments // Direct Payments

View File

@ -0,0 +1,848 @@
{
"info": {
"_postman_id": "c4e8a1b2-7f3d-4a9e-b6c1-2d8f9e0a1b2c",
"name": "Mobile App Versions - Complete Flow",
"description": "Complete collection for mobile app version management: public version check (Play Store / App Store) and admin CRUD with force vs optional update policies.",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{access_token}}",
"type": "string"
}
]
},
"variable": [
{
"key": "base_url",
"value": "http://localhost:8080"
},
{
"key": "access_token",
"value": ""
},
{
"key": "app_version_id",
"value": ""
},
{
"key": "app_version_id_optional",
"value": ""
},
{
"key": "client_version_code",
"value": "10"
}
],
"item": [
{
"name": "01 - Admin App Version CRUD",
"item": [
{
"name": "Create Android Version (FORCE update)",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 201\", function () {",
" pm.response.to.have.status(201);",
"});",
"const body = pm.response.json();",
"pm.test(\"App version ID exists\", function () {",
" pm.expect(body.data.id).to.be.a(\"number\");",
"});",
"pm.test(\"Update type is FORCE\", function () {",
" pm.expect(body.data.update_type).to.eql(\"FORCE\");",
"});",
"pm.collectionVariables.set(\"app_version_id\", body.data.id);"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"platform\": \"ANDROID\",\n \"version_name\": \"1.3.0\",\n \"version_code\": 15,\n \"update_type\": \"FORCE\",\n \"release_notes\": \"Critical security update and performance improvements.\",\n \"store_url\": \"https://play.google.com/store/apps/details?id=com.yimaru.app\",\n \"min_supported_version_code\": 12,\n \"status\": \"ACTIVE\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/admin/app-versions",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"admin",
"app-versions"
]
}
},
"response": []
},
{
"name": "Create Android Version (OPTIONAL update)",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 201\", function () {",
" pm.response.to.have.status(201);",
"});",
"const body = pm.response.json();",
"pm.collectionVariables.set(\"app_version_id_optional\", body.data.id);"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"platform\": \"ANDROID\",\n \"version_name\": \"1.2.0\",\n \"version_code\": 12,\n \"update_type\": \"OPTIONAL\",\n \"release_notes\": \"Minor bug fixes.\",\n \"store_url\": \"https://play.google.com/store/apps/details?id=com.yimaru.app\",\n \"status\": \"ACTIVE\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/admin/app-versions",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"admin",
"app-versions"
]
}
},
"response": []
},
{
"name": "Create iOS Version (OPTIONAL update)",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"platform\": \"IOS\",\n \"version_name\": \"1.3.0\",\n \"version_code\": 15,\n \"update_type\": \"OPTIONAL\",\n \"release_notes\": \"New lessons and UI polish.\",\n \"store_url\": \"https://apps.apple.com/app/id000000000\",\n \"status\": \"ACTIVE\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/admin/app-versions",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"admin",
"app-versions"
]
}
},
"response": []
},
{
"name": "List App Versions (Admin - All)",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/admin/app-versions?limit=20&offset=0",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"admin",
"app-versions"
],
"query": [
{
"key": "limit",
"value": "20"
},
{
"key": "offset",
"value": "0"
}
]
}
},
"response": []
},
{
"name": "List App Versions (Admin - ANDROID only)",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/admin/app-versions?platform=ANDROID&limit=20&offset=0",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"admin",
"app-versions"
],
"query": [
{
"key": "platform",
"value": "ANDROID"
},
{
"key": "limit",
"value": "20"
},
{
"key": "offset",
"value": "0"
}
]
}
},
"response": []
},
{
"name": "List App Versions (Admin - ACTIVE only)",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/admin/app-versions?status=ACTIVE&limit=20&offset=0",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"admin",
"app-versions"
],
"query": [
{
"key": "status",
"value": "ACTIVE"
},
{
"key": "limit",
"value": "20"
},
{
"key": "offset",
"value": "0"
}
]
}
},
"response": []
},
{
"name": "Get App Version By ID (Admin)",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/admin/app-versions/{{app_version_id}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"admin",
"app-versions",
"{{app_version_id}}"
]
}
},
"response": []
},
{
"name": "Update App Version (Admin - change to OPTIONAL)",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 200\", function () {",
" pm.response.to.have.status(200);",
"});",
"const body = pm.response.json();",
"pm.test(\"Update type is OPTIONAL\", function () {",
" pm.expect(body.data.update_type).to.eql(\"OPTIONAL\");",
"});"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"update_type\": \"OPTIONAL\",\n \"release_notes\": \"Updated policy: optional update for this release.\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/admin/app-versions/{{app_version_id}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"admin",
"app-versions",
"{{app_version_id}}"
]
}
},
"response": []
},
{
"name": "Update App Version (Admin - set INACTIVE)",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"status\": \"INACTIVE\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/admin/app-versions/{{app_version_id_optional}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"admin",
"app-versions",
"{{app_version_id_optional}}"
]
}
},
"response": []
}
]
},
{
"name": "02 - Public Mobile Version Check",
"item": [
{
"name": "Check Version - Client up to date",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 200\", function () {",
" pm.response.to.have.status(200);",
"});",
"const body = pm.response.json();",
"pm.test(\"No update available\", function () {",
" pm.expect(body.data.update_available).to.eql(false);",
"});"
],
"type": "text/javascript"
}
}
],
"request": {
"auth": {
"type": "noauth"
},
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/app/version/check?platform=ANDROID&version_code=15",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"app",
"version",
"check"
],
"query": [
{
"key": "platform",
"value": "ANDROID"
},
{
"key": "version_code",
"value": "15"
}
]
}
},
"response": []
},
{
"name": "Check Version - Update available (optional)",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 200\", function () {",
" pm.response.to.have.status(200);",
"});",
"const body = pm.response.json();",
"pm.test(\"Update available\", function () {",
" pm.expect(body.data.update_available).to.eql(true);",
"});"
],
"type": "text/javascript"
}
}
],
"request": {
"auth": {
"type": "noauth"
},
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/app/version/check?platform=ANDROID&version_code={{client_version_code}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"app",
"version",
"check"
],
"query": [
{
"key": "platform",
"value": "ANDROID"
},
{
"key": "version_code",
"value": "{{client_version_code}}"
}
]
}
},
"response": []
},
{
"name": "Check Version - Force update (below min_supported)",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 200\", function () {",
" pm.response.to.have.status(200);",
"});",
"const body = pm.response.json();",
"pm.test(\"Force update required\", function () {",
" pm.expect(body.data.force_update).to.eql(true);",
"});"
],
"type": "text/javascript"
}
}
],
"request": {
"auth": {
"type": "noauth"
},
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/app/version/check?platform=ANDROID&version_code=5",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"app",
"version",
"check"
],
"query": [
{
"key": "platform",
"value": "ANDROID"
},
{
"key": "version_code",
"value": "5"
}
]
}
},
"response": []
},
{
"name": "Check Version - iOS",
"request": {
"auth": {
"type": "noauth"
},
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/app/version/check?platform=IOS&version_code=10",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"app",
"version",
"check"
],
"query": [
{
"key": "platform",
"value": "IOS"
},
{
"key": "version_code",
"value": "10"
}
]
}
},
"response": []
}
]
},
{
"name": "03 - Validation & Auth Errors",
"item": [
{
"name": "Create Version Missing platform - Expect 400",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 400\", function () {",
" pm.response.to.have.status(400);",
"});"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"version_name\": \"1.0.0\",\n \"version_code\": 1,\n \"update_type\": \"OPTIONAL\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/admin/app-versions",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"admin",
"app-versions"
]
}
},
"response": []
},
{
"name": "Create Version Invalid update_type - Expect 400",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 400\", function () {",
" pm.response.to.have.status(400);",
"});"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"platform\": \"ANDROID\",\n \"version_name\": \"1.0.0\",\n \"version_code\": 1,\n \"update_type\": \"MANDATORY\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/admin/app-versions",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"admin",
"app-versions"
]
}
},
"response": []
},
{
"name": "Check Version Missing platform - Expect 400",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 400\", function () {",
" pm.response.to.have.status(400);",
"});"
],
"type": "text/javascript"
}
}
],
"request": {
"auth": {
"type": "noauth"
},
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/app/version/check?version_code=10",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"app",
"version",
"check"
],
"query": [
{
"key": "version_code",
"value": "10"
}
]
}
},
"response": []
},
{
"name": "Check Version Invalid version_code - Expect 400",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 400\", function () {",
" pm.response.to.have.status(400);",
"});"
],
"type": "text/javascript"
}
}
],
"request": {
"auth": {
"type": "noauth"
},
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/app/version/check?platform=ANDROID&version_code=0",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"app",
"version",
"check"
],
"query": [
{
"key": "platform",
"value": "ANDROID"
},
{
"key": "version_code",
"value": "0"
}
]
}
},
"response": []
},
{
"name": "List Admin Versions Without Auth - Expect 401/403",
"request": {
"auth": {
"type": "noauth"
},
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/admin/app-versions",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"admin",
"app-versions"
]
}
},
"response": []
},
{
"name": "Get Missing App Version (Admin) - Expect 404",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 404\", function () {",
" pm.response.to.have.status(404);",
"});"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/admin/app-versions/99999999",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"admin",
"app-versions",
"99999999"
]
}
},
"response": []
}
]
},
{
"name": "04 - Cleanup",
"item": [
{
"name": "Delete App Version (FORCE/updated)",
"request": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/admin/app-versions/{{app_version_id}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"admin",
"app-versions",
"{{app_version_id}}"
]
}
},
"response": []
},
{
"name": "Delete App Version (OPTIONAL/inactive)",
"request": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/admin/app-versions/{{app_version_id_optional}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"admin",
"app-versions",
"{{app_version_id_optional}}"
]
}
},
"response": []
}
]
}
]
}