Compare commits
16 Commits
79851d31b3
...
a1c6b3c15a
| Author | SHA1 | Date | |
|---|---|---|---|
| a1c6b3c15a | |||
| d3225ca61a | |||
| 79fb95ce36 | |||
| 7a4253edf4 | |||
| 82de00b1e7 | |||
| 56cc009579 | |||
| afdd07d65d | |||
| a719c0daca | |||
| 3f73afb4bf | |||
| 56089fa8fd | |||
| e957eacf80 | |||
| f7d4b5c3fb | |||
| a5acd00637 | |||
| 176f78515d | |||
| 215a4bd1dc | |||
| 0ad7f094cf |
11
cmd/main.go
11
cmd/main.go
|
|
@ -14,6 +14,7 @@ import (
|
|||
"Yimaru-Backend/internal/repository"
|
||||
activitylogservice "Yimaru-Backend/internal/services/activity_log"
|
||||
"Yimaru-Backend/internal/services/arifpay"
|
||||
"Yimaru-Backend/internal/services/appversions"
|
||||
"Yimaru-Backend/internal/services/chapa"
|
||||
"Yimaru-Backend/internal/services/assessment"
|
||||
"Yimaru-Backend/internal/services/authentication"
|
||||
|
|
@ -31,6 +32,7 @@ import (
|
|||
notificationservice "Yimaru-Backend/internal/services/notification"
|
||||
personasservice "Yimaru-Backend/internal/services/personas"
|
||||
practicesservice "Yimaru-Backend/internal/services/practices"
|
||||
profilefieldoptions "Yimaru-Backend/internal/services/profilefieldoptions"
|
||||
programsservice "Yimaru-Backend/internal/services/programs"
|
||||
"Yimaru-Backend/internal/services/questions"
|
||||
ratingsservice "Yimaru-Backend/internal/services/ratings"
|
||||
|
|
@ -45,6 +47,7 @@ import (
|
|||
// referralservice "Yimaru-Backend/internal/services/referal"
|
||||
"Yimaru-Backend/internal/services/transaction"
|
||||
"Yimaru-Backend/internal/services/user"
|
||||
videoengagementservice "Yimaru-Backend/internal/services/videoengagement"
|
||||
httpserver "Yimaru-Backend/internal/web_server"
|
||||
jwtutil "Yimaru-Backend/internal/web_server/jwt"
|
||||
customvalidator "Yimaru-Backend/internal/web_server/validator"
|
||||
|
|
@ -109,6 +112,7 @@ func main() {
|
|||
|
||||
messengerSvc := messenger.NewService(settingSvc, cfg)
|
||||
emailTemplateSvc := emailtemplates.NewService(repository.NewEmailTemplateStore(store))
|
||||
profileFieldOptionSvc := profilefieldoptions.NewService(repository.NewProfileFieldOptionStore(store))
|
||||
|
||||
userSvc := user.NewService(
|
||||
repository.NewTokenStore(store),
|
||||
|
|
@ -116,6 +120,7 @@ func main() {
|
|||
repository.NewOTPStore(store),
|
||||
messengerSvc,
|
||||
emailTemplateSvc,
|
||||
profileFieldOptionSvc,
|
||||
cfg,
|
||||
)
|
||||
|
||||
|
|
@ -396,6 +401,7 @@ func main() {
|
|||
// Questions service (unified questions system)
|
||||
questionsSvc := questions.NewService(store)
|
||||
faqSvc := faqs.NewService(repository.NewFAQStore(store))
|
||||
appVersionSvc := appversions.NewService(repository.NewMobileAppVersionStore(store))
|
||||
personasSvc := personasservice.NewService(store)
|
||||
examPrepSvc := examprep.NewService(store)
|
||||
|
||||
|
|
@ -413,6 +419,8 @@ func main() {
|
|||
|
||||
lmsProgressSvc := lmsprogress.NewService(store)
|
||||
|
||||
videoEngagementSvc := videoengagementservice.NewService(store)
|
||||
|
||||
// LMS practices (under course, module, or lesson)
|
||||
practiceSvc := practicesservice.NewService(store, store, store, store, store, store)
|
||||
|
||||
|
|
@ -474,7 +482,9 @@ func main() {
|
|||
assessmentSvc,
|
||||
questionsSvc,
|
||||
faqSvc,
|
||||
appVersionSvc,
|
||||
emailTemplateSvc,
|
||||
profileFieldOptionSvc,
|
||||
personasSvc,
|
||||
examPrepSvc,
|
||||
programSvc,
|
||||
|
|
@ -510,6 +520,7 @@ func main() {
|
|||
domain.MongoDBLogger,
|
||||
analyticsDB,
|
||||
rbacSvc,
|
||||
videoEngagementSvc,
|
||||
)
|
||||
|
||||
logger.Info("Starting server", "port", cfg.Port)
|
||||
|
|
|
|||
1
db/migrations/000069_profile_field_options.down.sql
Normal file
1
db/migrations/000069_profile_field_options.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS field_options;
|
||||
236
db/migrations/000069_profile_field_options.up.sql
Normal file
236
db/migrations/000069_profile_field_options.up.sql
Normal 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', '13–17', 2, 'ACTIVE'),
|
||||
('age_group', '18_24', '18–24', 3, 'ACTIVE'),
|
||||
('age_group', '25_34', '25–34', 4, 'ACTIVE'),
|
||||
('age_group', '35_44', '35–44', 5, 'ACTIVE'),
|
||||
('age_group', '45_54', '45–54', 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');
|
||||
|
|
@ -0,0 +1 @@
|
|||
-- No-op: keep field_options table name on rollback of 070 alone.
|
||||
|
|
@ -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 $$;
|
||||
|
|
@ -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;
|
||||
30
db/migrations/000070_subscription_content_categories.up.sql
Normal file
30
db/migrations/000070_subscription_content_categories.up.sql
Normal 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);
|
||||
7
db/migrations/000071_seed_country_field_options.down.sql
Normal file
7
db/migrations/000071_seed_country_field_options.down.sql
Normal 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'
|
||||
);
|
||||
25
db/migrations/000071_seed_country_field_options.up.sql
Normal file
25
db/migrations/000071_seed_country_field_options.up.sql
Normal 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;
|
||||
|
|
@ -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'
|
||||
);
|
||||
|
|
@ -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;
|
||||
1
db/migrations/000073_user_video_watch_sessions.down.sql
Normal file
1
db/migrations/000073_user_video_watch_sessions.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS user_video_watch_sessions;
|
||||
18
db/migrations/000073_user_video_watch_sessions.up.sql
Normal file
18
db/migrations/000073_user_video_watch_sessions.up.sql
Normal 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);
|
||||
1
db/migrations/000074_mobile_app_versions.down.sql
Normal file
1
db/migrations/000074_mobile_app_versions.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS mobile_app_versions;
|
||||
19
db/migrations/000074_mobile_app_versions.up.sql
Normal file
19
db/migrations/000074_mobile_app_versions.up.sql
Normal 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);
|
||||
|
|
@ -68,6 +68,56 @@ WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR u.created_at >= sqlc.nar
|
|||
GROUP BY u.knowledge_level
|
||||
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
|
||||
SELECT
|
||||
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.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
|
||||
-- =====================
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
-- 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
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
coalesce((
|
||||
SELECT
|
||||
max(c.sort_order)
|
||||
|
|
@ -42,6 +43,7 @@ SELECT
|
|||
c.id,
|
||||
c.name,
|
||||
c.description,
|
||||
c.category,
|
||||
c.thumbnail,
|
||||
c.sort_order,
|
||||
COALESCE(cc.units_count, 0)::BIGINT AS units_count,
|
||||
|
|
@ -73,6 +75,7 @@ UPDATE exam_prep.catalog_courses
|
|||
SET
|
||||
name = coalesce(sqlc.narg('name')::varchar, name),
|
||||
description = coalesce(sqlc.narg('description')::text, description),
|
||||
category = coalesce(sqlc.narg('category')::varchar, category),
|
||||
thumbnail = coalesce(sqlc.narg('thumbnail')::text, thumbnail),
|
||||
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
|
|
|
|||
119
db/query/exam_prep_progress.sql
Normal file
119
db/query/exam_prep_progress.sql
Normal 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';
|
||||
|
|
@ -117,6 +117,33 @@ INSERT INTO lms_user_program_progress (user_id, program_id)
|
|||
ON CONFLICT (user_id, program_id)
|
||||
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
|
||||
SELECT
|
||||
count(*)::int AS n
|
||||
|
|
@ -175,47 +202,147 @@ WHERE
|
|||
|
||||
-- name: ListLMSCompletedLessonIDsByUser :many
|
||||
SELECT
|
||||
ulp.lesson_id
|
||||
lp.lesson_id
|
||||
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
|
||||
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
|
||||
ulp.completed_at ASC,
|
||||
ulp.lesson_id ASC;
|
||||
max(upp.completed_at) ASC,
|
||||
lp.lesson_id ASC;
|
||||
|
||||
-- name: ListLMSCompletedModuleIDsByUser :many
|
||||
SELECT
|
||||
ump.module_id
|
||||
FROM
|
||||
lms_user_module_progress AS ump
|
||||
WHERE
|
||||
ump.user_id = $1
|
||||
scoped.module_id
|
||||
FROM (
|
||||
SELECT
|
||||
m.id AS module_id,
|
||||
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
|
||||
ump.completed_at ASC,
|
||||
ump.module_id ASC;
|
||||
max(upp.completed_at) ASC,
|
||||
scoped.module_id ASC;
|
||||
|
||||
-- name: ListLMSCompletedCourseIDsByUser :many
|
||||
SELECT
|
||||
ucp.course_id
|
||||
FROM
|
||||
lms_user_course_progress AS ucp
|
||||
WHERE
|
||||
ucp.user_id = $1
|
||||
scoped.course_id
|
||||
FROM (
|
||||
SELECT
|
||||
c.id AS course_id,
|
||||
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
|
||||
ucp.completed_at ASC,
|
||||
ucp.course_id ASC;
|
||||
max(upp.completed_at) ASC,
|
||||
scoped.course_id ASC;
|
||||
|
||||
-- name: ListLMSCompletedProgramIDsByUser :many
|
||||
SELECT
|
||||
upp.program_id
|
||||
FROM
|
||||
lms_user_program_progress AS upp
|
||||
WHERE
|
||||
upp.user_id = $1
|
||||
scoped.program_id
|
||||
FROM (
|
||||
SELECT
|
||||
c.program_id,
|
||||
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
|
||||
upp.completed_at ASC,
|
||||
upp.program_id ASC;
|
||||
max(upp.completed_at) ASC,
|
||||
scoped.program_id ASC;
|
||||
|
||||
-- Lesson-based progress within a course (all modules).
|
||||
-- name: CountLessonsInCourse :one
|
||||
|
|
@ -265,7 +392,7 @@ WHERE
|
|||
AND ulp.user_id = $2
|
||||
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
|
||||
SELECT
|
||||
count(*)::int AS n
|
||||
|
|
@ -273,7 +400,15 @@ FROM
|
|||
lms_practices lp
|
||||
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||
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.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 user_practice_progress upp ON upp.question_set_id = lp.question_set_id
|
||||
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.completed_at IS NOT NULL
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
|
|
@ -300,7 +443,23 @@ FROM
|
|||
lms_practices lp
|
||||
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||
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.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 user_practice_progress upp ON upp.question_set_id = lp.question_set_id
|
||||
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.completed_at IS NOT NULL
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED';
|
||||
AND qs.status = 'PUBLISHED'
|
||||
AND lp.publish_status = 'PUBLISHED';
|
||||
|
||||
-- name: CountPublishedPracticesInProgram :one
|
||||
SELECT
|
||||
count(*)::int AS n
|
||||
FROM
|
||||
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
|
||||
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.status = 'PUBLISHED'
|
||||
AND lp.publish_status = 'PUBLISHED';
|
||||
|
|
@ -337,11 +536,34 @@ SELECT
|
|||
count(*)::int AS n
|
||||
FROM
|
||||
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 user_practice_progress upp ON upp.question_set_id = lp.question_set_id
|
||||
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.completed_at IS NOT NULL
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
-- name: CreateProgram :one
|
||||
INSERT INTO programs (name, description, thumbnail, sort_order)
|
||||
INSERT INTO programs (name, description, category, thumbnail, sort_order)
|
||||
SELECT
|
||||
sqlc.arg('name'),
|
||||
sqlc.arg('description'),
|
||||
sqlc.arg('category'),
|
||||
sqlc.arg('thumbnail'),
|
||||
COALESCE(sqlc.narg('sort_order')::int, COALESCE((
|
||||
SELECT
|
||||
|
|
@ -30,6 +31,7 @@ SELECT
|
|||
p.id,
|
||||
p.name,
|
||||
p.description,
|
||||
p.category,
|
||||
p.thumbnail,
|
||||
p.sort_order,
|
||||
p.created_at,
|
||||
|
|
@ -43,6 +45,7 @@ UPDATE programs
|
|||
SET
|
||||
name = COALESCE(sqlc.narg('name')::varchar, name),
|
||||
description = COALESCE(sqlc.narg('description')::text, description),
|
||||
category = COALESCE(sqlc.narg('category')::varchar, category),
|
||||
thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail),
|
||||
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
|
||||
-- name: CreateSubscriptionPlan :one
|
||||
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 *;
|
||||
|
||||
-- name: GetSubscriptionPlanByID :one
|
||||
|
|
@ -25,15 +25,16 @@ ORDER BY price ASC;
|
|||
-- name: UpdateSubscriptionPlan :exec
|
||||
UPDATE subscription_plans
|
||||
SET
|
||||
name = COALESCE($1, name),
|
||||
description = COALESCE($2, description),
|
||||
duration_value = COALESCE($3, duration_value),
|
||||
duration_unit = COALESCE($4, duration_unit),
|
||||
price = COALESCE($5, price),
|
||||
currency = COALESCE($6, currency),
|
||||
is_active = COALESCE($7, is_active),
|
||||
name = COALESCE(sqlc.narg('name')::varchar, name),
|
||||
description = COALESCE(sqlc.narg('description')::text, description),
|
||||
category = COALESCE(sqlc.narg('category')::varchar, category),
|
||||
duration_value = COALESCE(sqlc.narg('duration_value')::int, duration_value),
|
||||
duration_unit = COALESCE(sqlc.narg('duration_unit')::varchar, duration_unit),
|
||||
price = COALESCE(sqlc.narg('price')::numeric, price),
|
||||
currency = COALESCE(sqlc.narg('currency')::varchar, currency),
|
||||
is_active = COALESCE(sqlc.narg('is_active')::boolean, is_active),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $8;
|
||||
WHERE id = sqlc.arg('id');
|
||||
|
||||
-- name: DeleteSubscriptionPlan :exec
|
||||
DELETE FROM subscription_plans WHERE id = $1;
|
||||
|
|
@ -186,6 +187,17 @@ SELECT EXISTS(
|
|||
AND expires_at > CURRENT_TIMESTAMP
|
||||
) 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
|
||||
UPDATE user_subscriptions
|
||||
SET
|
||||
|
|
|
|||
|
|
@ -98,22 +98,22 @@ WHERE (
|
|||
-- name: UpdateTeamMember :exec
|
||||
UPDATE team_members
|
||||
SET
|
||||
first_name = COALESCE($1, first_name),
|
||||
last_name = COALESCE($2, last_name),
|
||||
phone_number = COALESCE($3, phone_number),
|
||||
team_role = COALESCE($4, team_role),
|
||||
department = COALESCE($5, department),
|
||||
job_title = COALESCE($6, job_title),
|
||||
employment_type = COALESCE($7, employment_type),
|
||||
hire_date = COALESCE($8, hire_date),
|
||||
profile_picture_url = COALESCE($9, profile_picture_url),
|
||||
bio = COALESCE($10, bio),
|
||||
work_phone = COALESCE($11, work_phone),
|
||||
emergency_contact = COALESCE($12, emergency_contact),
|
||||
permissions = COALESCE($13, permissions),
|
||||
updated_by = $14,
|
||||
first_name = COALESCE(sqlc.narg('first_name')::VARCHAR, first_name),
|
||||
last_name = COALESCE(sqlc.narg('last_name')::VARCHAR, last_name),
|
||||
phone_number = COALESCE(sqlc.narg('phone_number'), phone_number),
|
||||
team_role = COALESCE(sqlc.narg('team_role')::VARCHAR, team_role),
|
||||
department = COALESCE(sqlc.narg('department'), department),
|
||||
job_title = COALESCE(sqlc.narg('job_title'), job_title),
|
||||
employment_type = COALESCE(sqlc.narg('employment_type'), employment_type),
|
||||
hire_date = COALESCE(sqlc.narg('hire_date'), hire_date),
|
||||
profile_picture_url = COALESCE(sqlc.narg('profile_picture_url'), profile_picture_url),
|
||||
bio = COALESCE(sqlc.narg('bio'), bio),
|
||||
work_phone = COALESCE(sqlc.narg('work_phone'), work_phone),
|
||||
emergency_contact = COALESCE(sqlc.narg('emergency_contact'), emergency_contact),
|
||||
permissions = COALESCE(sqlc.narg('permissions'), permissions),
|
||||
updated_by = @updated_by,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $15;
|
||||
WHERE id = @id;
|
||||
|
||||
-- name: UpdateTeamMemberStatus :exec
|
||||
UPDATE team_members
|
||||
|
|
|
|||
74
db/query/video_engagement.sql
Normal file
74
db/query/video_engagement.sql
Normal 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;
|
||||
1797
docs/docs.go
1797
docs/docs.go
File diff suppressed because it is too large
Load Diff
1797
docs/swagger.json
1797
docs/swagger.json
File diff suppressed because it is too large
Load Diff
1195
docs/swagger.yaml
1195
docs/swagger.yaml
File diff suppressed because it is too large
Load Diff
|
|
@ -1080,6 +1080,88 @@ func (q *Queries) AnalyticsUsersByAgeGroup(ctx context.Context, arg AnalyticsUse
|
|||
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
|
||||
SELECT
|
||||
COALESCE(u.knowledge_level, 'unknown') AS knowledge_level,
|
||||
|
|
@ -1121,6 +1203,129 @@ func (q *Queries) AnalyticsUsersByKnowledgeLevel(ctx context.Context, arg Analyt
|
|||
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
|
||||
SELECT
|
||||
COALESCE(u.region, 'unknown') AS region,
|
||||
|
|
@ -1305,3 +1510,139 @@ func (q *Queries) AnalyticsUsersSummary(ctx context.Context, arg AnalyticsUsersS
|
|||
)
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,27 +12,34 @@ import (
|
|||
)
|
||||
|
||||
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
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
coalesce((
|
||||
SELECT
|
||||
max(c.sort_order)
|
||||
FROM exam_prep.catalog_courses AS c), 0) + 1
|
||||
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 {
|
||||
Name string `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Category string `json:"category"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
}
|
||||
|
||||
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
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
|
|
@ -42,6 +49,7 @@ func (q *Queries) ExamPrepCreateCatalogCourse(ctx context.Context, arg ExamPrepC
|
|||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Category,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -58,7 +66,7 @@ func (q *Queries) ExamPrepDeleteCatalogCourse(ctx context.Context, id int64) err
|
|||
|
||||
const ExamPrepGetCatalogCourseByID = `-- name: ExamPrepGetCatalogCourseByID :one
|
||||
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 (
|
||||
SELECT 1
|
||||
FROM exam_prep.lesson_practices p
|
||||
|
|
@ -79,6 +87,7 @@ type ExamPrepGetCatalogCourseByIDRow struct {
|
|||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
Category string `json:"category"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
}
|
||||
|
||||
|
|
@ -93,6 +102,7 @@ func (q *Queries) ExamPrepGetCatalogCourseByID(ctx context.Context, id int64) (E
|
|||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Category,
|
||||
&i.HasPractice,
|
||||
)
|
||||
return i, err
|
||||
|
|
@ -142,6 +152,7 @@ SELECT
|
|||
c.id,
|
||||
c.name,
|
||||
c.description,
|
||||
c.category,
|
||||
c.thumbnail,
|
||||
c.sort_order,
|
||||
COALESCE(cc.units_count, 0)::BIGINT AS units_count,
|
||||
|
|
@ -173,6 +184,7 @@ type ExamPrepListCatalogCoursesRow struct {
|
|||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Category string `json:"category"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
UnitsCount int64 `json:"units_count"`
|
||||
|
|
@ -197,6 +209,7 @@ func (q *Queries) ExamPrepListCatalogCourses(ctx context.Context, arg ExamPrepLi
|
|||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Category,
|
||||
&i.Thumbnail,
|
||||
&i.SortOrder,
|
||||
&i.UnitsCount,
|
||||
|
|
@ -221,17 +234,19 @@ UPDATE exam_prep.catalog_courses
|
|||
SET
|
||||
name = coalesce($1::varchar, name),
|
||||
description = coalesce($2::text, description),
|
||||
thumbnail = coalesce($3::text, thumbnail),
|
||||
sort_order = coalesce($4::int, sort_order),
|
||||
category = coalesce($3::varchar, category),
|
||||
thumbnail = coalesce($4::text, thumbnail),
|
||||
sort_order = coalesce($5::int, sort_order),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $5
|
||||
WHERE id = $6
|
||||
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 {
|
||||
Name pgtype.Text `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Category pgtype.Text `json:"category"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
SortOrder pgtype.Int4 `json:"sort_order"`
|
||||
ID int64 `json:"id"`
|
||||
|
|
@ -241,6 +256,7 @@ func (q *Queries) ExamPrepUpdateCatalogCourse(ctx context.Context, arg ExamPrepU
|
|||
row := q.db.QueryRow(ctx, ExamPrepUpdateCatalogCourse,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.Category,
|
||||
arg.Thumbnail,
|
||||
arg.SortOrder,
|
||||
arg.ID,
|
||||
|
|
@ -254,6 +270,7 @@ func (q *Queries) ExamPrepUpdateCatalogCourse(ctx context.Context, arg ExamPrepU
|
|||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Category,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
|
|||
214
gen/db/exam_prep_progress.sql.go
Normal file
214
gen/db/exam_prep_progress.sql.go
Normal 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
|
||||
}
|
||||
|
|
@ -106,7 +106,23 @@ FROM
|
|||
lms_practices lp
|
||||
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||
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.status = 'PUBLISHED'
|
||||
AND lp.publish_status = 'PUBLISHED'
|
||||
|
|
@ -119,6 +135,26 @@ func (q *Queries) CountPublishedPracticesInCourse(ctx context.Context, courseID
|
|||
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
|
||||
SELECT
|
||||
count(*)::int AS n
|
||||
|
|
@ -126,13 +162,21 @@ FROM
|
|||
lms_practices lp
|
||||
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||
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.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) {
|
||||
row := q.db.QueryRow(ctx, CountPublishedPracticesInModule, moduleID)
|
||||
var n int32
|
||||
|
|
@ -145,10 +189,33 @@ SELECT
|
|||
count(*)::int AS n
|
||||
FROM
|
||||
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
|
||||
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.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 user_practice_progress upp ON upp.question_set_id = lp.question_set_id
|
||||
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.completed_at IS NOT NULL
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
AND lp.publish_status = 'PUBLISHED'
|
||||
`
|
||||
|
||||
type CountUserCompletedPublishedPracticesInCourseParams struct {
|
||||
|
|
@ -309,6 +393,34 @@ func (q *Queries) CountUserCompletedPublishedPracticesInCourse(ctx context.Conte
|
|||
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
|
||||
SELECT
|
||||
count(*)::int AS n
|
||||
|
|
@ -317,7 +429,15 @@ FROM
|
|||
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.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.completed_at IS NOT NULL
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
|
|
@ -342,11 +462,34 @@ SELECT
|
|||
count(*)::int AS n
|
||||
FROM
|
||||
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 user_practice_progress upp ON upp.question_set_id = lp.question_set_id
|
||||
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.completed_at IS NOT NULL
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
|
|
@ -498,7 +641,7 @@ func (q *Queries) GetPreviousModuleInCourse(ctx context.Context, id int64) (Modu
|
|||
|
||||
const GetPreviousProgram = `-- name: GetPreviousProgram :one
|
||||
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
|
||||
programs AS p1
|
||||
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.UpdatedAt,
|
||||
&i.SortOrder,
|
||||
&i.Category,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -591,14 +735,46 @@ func (q *Queries) InsertUserProgramProgress(ctx context.Context, arg InsertUserP
|
|||
|
||||
const ListLMSCompletedCourseIDsByUser = `-- name: ListLMSCompletedCourseIDsByUser :many
|
||||
SELECT
|
||||
ucp.course_id
|
||||
FROM
|
||||
lms_user_course_progress AS ucp
|
||||
WHERE
|
||||
ucp.user_id = $1
|
||||
scoped.course_id
|
||||
FROM (
|
||||
SELECT
|
||||
c.id AS course_id,
|
||||
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
|
||||
ucp.completed_at ASC,
|
||||
ucp.course_id ASC
|
||||
max(upp.completed_at) ASC,
|
||||
scoped.course_id ASC
|
||||
`
|
||||
|
||||
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
|
||||
SELECT
|
||||
ulp.lesson_id
|
||||
lp.lesson_id
|
||||
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
|
||||
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
|
||||
ulp.completed_at ASC,
|
||||
ulp.lesson_id ASC
|
||||
max(upp.completed_at) 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []int64
|
||||
var items []pgtype.Int8
|
||||
for rows.Next() {
|
||||
var lesson_id int64
|
||||
var lesson_id pgtype.Int8
|
||||
if err := rows.Scan(&lesson_id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -655,14 +843,38 @@ func (q *Queries) ListLMSCompletedLessonIDsByUser(ctx context.Context, userID in
|
|||
|
||||
const ListLMSCompletedModuleIDsByUser = `-- name: ListLMSCompletedModuleIDsByUser :many
|
||||
SELECT
|
||||
ump.module_id
|
||||
FROM
|
||||
lms_user_module_progress AS ump
|
||||
WHERE
|
||||
ump.user_id = $1
|
||||
scoped.module_id
|
||||
FROM (
|
||||
SELECT
|
||||
m.id AS module_id,
|
||||
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
|
||||
ump.completed_at ASC,
|
||||
ump.module_id ASC
|
||||
max(upp.completed_at) ASC,
|
||||
scoped.module_id ASC
|
||||
`
|
||||
|
||||
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
|
||||
SELECT
|
||||
upp.program_id
|
||||
FROM
|
||||
lms_user_program_progress AS upp
|
||||
WHERE
|
||||
upp.user_id = $1
|
||||
scoped.program_id
|
||||
FROM (
|
||||
SELECT
|
||||
c.program_id,
|
||||
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
|
||||
upp.completed_at ASC,
|
||||
upp.program_id ASC
|
||||
max(upp.completed_at) ASC,
|
||||
scoped.program_id ASC
|
||||
`
|
||||
|
||||
func (q *Queries) ListLMSCompletedProgramIDsByUser(ctx context.Context, userID int64) ([]int64, error) {
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ type ExamPrepCatalogCourse struct {
|
|||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
Category string `json:"category"`
|
||||
}
|
||||
|
||||
type ExamPrepLessonPractice struct {
|
||||
|
|
@ -127,6 +128,17 @@ type Faq struct {
|
|||
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 {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
|
|
@ -203,6 +215,20 @@ type LmsUserProgramProgress struct {
|
|||
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 {
|
||||
ID int64 `json:"id"`
|
||||
ProgramID int64 `json:"program_id"`
|
||||
|
|
@ -284,6 +310,7 @@ type Program struct {
|
|||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
Category string `json:"category"`
|
||||
}
|
||||
|
||||
type Question struct {
|
||||
|
|
@ -457,6 +484,7 @@ type SubscriptionPlan struct {
|
|||
IsActive bool `json:"is_active"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
Category string `json:"category"`
|
||||
}
|
||||
|
||||
type TeamInvitation struct {
|
||||
|
|
@ -577,3 +605,17 @@ type UserSubscription struct {
|
|||
CreatedAt pgtype.Timestamptz `json:"created_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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,22 +12,24 @@ import (
|
|||
)
|
||||
|
||||
const CreateProgram = `-- name: CreateProgram :one
|
||||
INSERT INTO programs (name, description, thumbnail, sort_order)
|
||||
INSERT INTO programs (name, description, category, thumbnail, sort_order)
|
||||
SELECT
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
COALESCE($4::int, COALESCE((
|
||||
$4,
|
||||
COALESCE($5::int, COALESCE((
|
||||
SELECT
|
||||
max(p.sort_order)
|
||||
FROM programs AS p), 0) + 1)
|
||||
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 {
|
||||
Name string `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Category string `json:"category"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
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,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.Category,
|
||||
arg.Thumbnail,
|
||||
arg.SortOrder,
|
||||
)
|
||||
|
|
@ -48,6 +51,7 @@ func (q *Queries) CreateProgram(ctx context.Context, arg CreateProgramParams) (P
|
|||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SortOrder,
|
||||
&i.Category,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -63,7 +67,7 @@ func (q *Queries) DeleteProgram(ctx context.Context, id int64) error {
|
|||
}
|
||||
|
||||
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
|
||||
WHERE id = $1
|
||||
`
|
||||
|
|
@ -79,6 +83,7 @@ func (q *Queries) GetProgramByID(ctx context.Context, id int64) (Program, error)
|
|||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SortOrder,
|
||||
&i.Category,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -118,6 +123,7 @@ SELECT
|
|||
p.id,
|
||||
p.name,
|
||||
p.description,
|
||||
p.category,
|
||||
p.thumbnail,
|
||||
p.sort_order,
|
||||
p.created_at,
|
||||
|
|
@ -137,6 +143,7 @@ type ListProgramsRow struct {
|
|||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Category string `json:"category"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
|
|
@ -157,6 +164,7 @@ func (q *Queries) ListPrograms(ctx context.Context, arg ListProgramsParams) ([]L
|
|||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Category,
|
||||
&i.Thumbnail,
|
||||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
|
|
@ -177,18 +185,20 @@ UPDATE programs
|
|||
SET
|
||||
name = COALESCE($1::varchar, name),
|
||||
description = COALESCE($2::text, description),
|
||||
thumbnail = COALESCE($3::text, thumbnail),
|
||||
sort_order = coalesce($4::int, sort_order),
|
||||
category = COALESCE($3::varchar, category),
|
||||
thumbnail = COALESCE($4::text, thumbnail),
|
||||
sort_order = coalesce($5::int, sort_order),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE
|
||||
id = $5
|
||||
id = $6
|
||||
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 {
|
||||
Name pgtype.Text `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Category pgtype.Text `json:"category"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
SortOrder pgtype.Int4 `json:"sort_order"`
|
||||
ID int64 `json:"id"`
|
||||
|
|
@ -198,6 +208,7 @@ func (q *Queries) UpdateProgram(ctx context.Context, arg UpdateProgramParams) (P
|
|||
row := q.db.QueryRow(ctx, UpdateProgram,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.Category,
|
||||
arg.Thumbnail,
|
||||
arg.SortOrder,
|
||||
arg.ID,
|
||||
|
|
@ -211,6 +222,7 @@ func (q *Queries) UpdateProgram(ctx context.Context, arg UpdateProgramParams) (P
|
|||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SortOrder,
|
||||
&i.Category,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,20 +40,21 @@ func (q *Queries) CountUserSubscriptions(ctx context.Context, userID int64) (int
|
|||
const CreateSubscriptionPlan = `-- name: CreateSubscriptionPlan :one
|
||||
|
||||
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))
|
||||
RETURNING id, name, description, duration_value, duration_unit, price, currency, is_active, created_at, updated_at
|
||||
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, category
|
||||
`
|
||||
|
||||
type CreateSubscriptionPlanParams struct {
|
||||
Name string `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Category string `json:"category"`
|
||||
DurationValue int32 `json:"duration_value"`
|
||||
DurationUnit string `json:"duration_unit"`
|
||||
Price pgtype.Numeric `json:"price"`
|
||||
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,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.Category,
|
||||
arg.DurationValue,
|
||||
arg.DurationUnit,
|
||||
arg.Price,
|
||||
arg.Currency,
|
||||
arg.Column7,
|
||||
arg.Column8,
|
||||
)
|
||||
var i SubscriptionPlan
|
||||
err := row.Scan(
|
||||
|
|
@ -81,6 +83,7 @@ func (q *Queries) CreateSubscriptionPlan(ctx context.Context, arg CreateSubscrip
|
|||
&i.IsActive,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Category,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -387,7 +390,7 @@ func (q *Queries) GetSubscriptionDisplayStatusByUserID(ctx context.Context, user
|
|||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -404,6 +407,7 @@ func (q *Queries) GetSubscriptionPlanByID(ctx context.Context, id int64) (Subscr
|
|||
&i.IsActive,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Category,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -562,8 +566,32 @@ func (q *Queries) HasActiveSubscription(ctx context.Context, userID int64) (bool
|
|||
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
|
||||
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
|
||||
ORDER BY price ASC
|
||||
`
|
||||
|
|
@ -588,6 +616,7 @@ func (q *Queries) ListActiveSubscriptionPlans(ctx context.Context) ([]Subscripti
|
|||
&i.IsActive,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Category,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -646,7 +675,7 @@ func (q *Queries) ListSubscriptionDisplayStatusesByUserIDs(ctx context.Context,
|
|||
}
|
||||
|
||||
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)
|
||||
ORDER BY price ASC
|
||||
`
|
||||
|
|
@ -671,6 +700,7 @@ func (q *Queries) ListSubscriptionPlans(ctx context.Context, dollar_1 bool) ([]S
|
|||
&i.IsActive,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Category,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -703,25 +733,27 @@ func (q *Queries) UpdateAutoRenew(ctx context.Context, arg UpdateAutoRenewParams
|
|||
const UpdateSubscriptionPlan = `-- name: UpdateSubscriptionPlan :exec
|
||||
UPDATE subscription_plans
|
||||
SET
|
||||
name = COALESCE($1, name),
|
||||
description = COALESCE($2, description),
|
||||
duration_value = COALESCE($3, duration_value),
|
||||
duration_unit = COALESCE($4, duration_unit),
|
||||
price = COALESCE($5, price),
|
||||
currency = COALESCE($6, currency),
|
||||
is_active = COALESCE($7, is_active),
|
||||
name = COALESCE($1::varchar, name),
|
||||
description = COALESCE($2::text, description),
|
||||
category = COALESCE($3::varchar, category),
|
||||
duration_value = COALESCE($4::int, duration_value),
|
||||
duration_unit = COALESCE($5::varchar, duration_unit),
|
||||
price = COALESCE($6::numeric, price),
|
||||
currency = COALESCE($7::varchar, currency),
|
||||
is_active = COALESCE($8::boolean, is_active),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $8
|
||||
WHERE id = $9
|
||||
`
|
||||
|
||||
type UpdateSubscriptionPlanParams struct {
|
||||
Name string `json:"name"`
|
||||
Name pgtype.Text `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
DurationValue int32 `json:"duration_value"`
|
||||
DurationUnit string `json:"duration_unit"`
|
||||
Category pgtype.Text `json:"category"`
|
||||
DurationValue pgtype.Int4 `json:"duration_value"`
|
||||
DurationUnit pgtype.Text `json:"duration_unit"`
|
||||
Price pgtype.Numeric `json:"price"`
|
||||
Currency string `json:"currency"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Currency pgtype.Text `json:"currency"`
|
||||
IsActive pgtype.Bool `json:"is_active"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
|
|
@ -729,6 +761,7 @@ func (q *Queries) UpdateSubscriptionPlan(ctx context.Context, arg UpdateSubscrip
|
|||
_, err := q.db.Exec(ctx, UpdateSubscriptionPlan,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.Category,
|
||||
arg.DurationValue,
|
||||
arg.DurationUnit,
|
||||
arg.Price,
|
||||
|
|
|
|||
|
|
@ -637,10 +637,10 @@ func (q *Queries) SearchTeamMembers(ctx context.Context, arg SearchTeamMembersPa
|
|||
const UpdateTeamMember = `-- name: UpdateTeamMember :exec
|
||||
UPDATE team_members
|
||||
SET
|
||||
first_name = COALESCE($1, first_name),
|
||||
last_name = COALESCE($2, last_name),
|
||||
first_name = COALESCE($1::VARCHAR, first_name),
|
||||
last_name = COALESCE($2::VARCHAR, last_name),
|
||||
phone_number = COALESCE($3, phone_number),
|
||||
team_role = COALESCE($4, team_role),
|
||||
team_role = COALESCE($4::VARCHAR, team_role),
|
||||
department = COALESCE($5, department),
|
||||
job_title = COALESCE($6, job_title),
|
||||
employment_type = COALESCE($7, employment_type),
|
||||
|
|
@ -656,10 +656,10 @@ WHERE id = $15
|
|||
`
|
||||
|
||||
type UpdateTeamMemberParams struct {
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
FirstName pgtype.Text `json:"first_name"`
|
||||
LastName pgtype.Text `json:"last_name"`
|
||||
PhoneNumber pgtype.Text `json:"phone_number"`
|
||||
TeamRole string `json:"team_role"`
|
||||
TeamRole pgtype.Text `json:"team_role"`
|
||||
Department pgtype.Text `json:"department"`
|
||||
JobTitle pgtype.Text `json:"job_title"`
|
||||
EmploymentType pgtype.Text `json:"employment_type"`
|
||||
|
|
|
|||
205
gen/db/video_engagement.sql.go
Normal file
205
gen/db/video_engagement.sql.go
Normal 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
|
||||
}
|
||||
|
|
@ -44,11 +44,16 @@ type AnalyticsUsersSection struct {
|
|||
NewWeek int64 `json:"new_week"`
|
||||
NewMonth int64 `json:"new_month"`
|
||||
|
||||
ByRole []AnalyticsLabelCount `json:"by_role"`
|
||||
ByStatus []AnalyticsLabelCount `json:"by_status"`
|
||||
ByAgeGroup []AnalyticsLabelCount `json:"by_age_group"`
|
||||
ByKnowledgeLevel []AnalyticsLabelCount `json:"by_knowledge_level"`
|
||||
ByRegion []AnalyticsLabelCount `json:"by_region"`
|
||||
ByRole []AnalyticsLabelCount `json:"by_role"`
|
||||
ByStatus []AnalyticsLabelCount `json:"by_status"`
|
||||
ByAgeGroup []AnalyticsLabelCount `json:"by_age_group"`
|
||||
ByEducationLevel []AnalyticsLabelCount `json:"by_education_level"`
|
||||
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"`
|
||||
}
|
||||
|
|
@ -149,6 +154,24 @@ type AnalyticsTeamSection struct {
|
|||
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 {
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
DateFilter AnalyticsDateFilter `json:"date_filter"`
|
||||
|
|
@ -156,6 +179,7 @@ type AnalyticsDashboard struct {
|
|||
Subscriptions AnalyticsSubscriptionsSection `json:"subscriptions"`
|
||||
Payments AnalyticsPaymentsSection `json:"payments"`
|
||||
Courses AnalyticsCoursesSection `json:"courses"`
|
||||
Videos AnalyticsVideosSection `json:"videos"`
|
||||
Content AnalyticsContentSection `json:"content"`
|
||||
Notifications AnalyticsNotificationsSection `json:"notifications"`
|
||||
Issues AnalyticsIssuesSection `json:"issues"`
|
||||
|
|
|
|||
|
|
@ -7,25 +7,29 @@ type ExamPrepCatalogCourse struct {
|
|||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Category string `json:"category"`
|
||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
UnitsCount *int64 `json:"units_count,omitempty"`
|
||||
ModulesCount *int64 `json:"modules_count,omitempty"`
|
||||
LessonsCount *int64 `json:"lessons_count,omitempty"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
Access *LMSEntityAccess `json:"access,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type CreateExamPrepCatalogCourseInput struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Category string `json:"category" validate:"required,oneof=IELTS DUOLINGO"`
|
||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateExamPrepCatalogCourseInput struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Category *string `json:"category,omitempty" validate:"omitempty,oneof=IELTS DUOLINGO"`
|
||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
SortOrder *int `json:"sort_order,omitempty"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ type ExamPrepLesson struct {
|
|||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
Access *LMSEntityAccess `json:"access,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,8 +13,9 @@ type ExamPrepModule struct {
|
|||
SortOrder int `json:"sort_order"`
|
||||
LessonsCount *int64 `json:"lessons_count,omitempty"`
|
||||
PracticesCount *int64 `json:"practices_count,omitempty"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
Access *LMSEntityAccess `json:"access,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,8 +13,9 @@ type ExamPrepUnit struct {
|
|||
ModulesCount *int64 `json:"modules_count,omitempty"`
|
||||
LessonsCount *int64 `json:"lessons_count,omitempty"`
|
||||
PracticesCount *int64 `json:"practices_count,omitempty"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
Access *LMSEntityAccess `json:"access,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,23 @@
|
|||
package domain
|
||||
|
||||
// LMSEntityAccess describes learner gating for a program, course, module, or lesson.
|
||||
// It is omitted (nil) for non-learner roles in API responses.
|
||||
// Progress fields count completed lessons vs total lessons in that entity’s scope (lesson: 0 or 1 of 1).
|
||||
// Included for STUDENT and OPEN_LEARNER; omitted (nil) for staff roles in API responses.
|
||||
// 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 {
|
||||
IsAccessible bool `json:"is_accessible"`
|
||||
IsCompleted bool `json:"is_completed"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
CompletedCount int `json:"completed_count"`
|
||||
TotalCount int `json:"total_count"`
|
||||
ProgressPercent int `json:"progress_percent"`
|
||||
IsAccessible bool `json:"is_accessible"`
|
||||
IsCompleted bool `json:"is_completed"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
CompletedCount int `json:"completed_count"`
|
||||
TotalCount int `json:"total_count"`
|
||||
ProgressPercent int `json:"progress_percent"`
|
||||
ProgressPercentPrecise float64 `json:"progress_percent_precise"`
|
||||
}
|
||||
|
||||
// LMSUserProgress lists entity IDs the authenticated user has fully completed
|
||||
// (lessons as marked complete; module/course/program when rollup conditions were met).
|
||||
// LMSUserProgress lists entity IDs the authenticated user has fully completed based on
|
||||
// published practice completion in each LMS scope.
|
||||
type LMSUserProgress struct {
|
||||
LessonIDs []int64 `json:"lesson_ids"`
|
||||
ModuleIDs []int64 `json:"module_ids"`
|
||||
|
|
|
|||
38
internal/domain/lms_progress_summary.go
Normal file
38
internal/domain/lms_progress_summary.go
Normal 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"`
|
||||
}
|
||||
62
internal/domain/mobile_app_version.go
Normal file
62
internal/domain/mobile_app_version.go
Normal 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"`
|
||||
}
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
package domain
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PaymentStatus string
|
||||
|
||||
|
|
@ -43,10 +47,29 @@ type CreatePaymentInput struct {
|
|||
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 {
|
||||
PlanID int64 `json:"plan_id" validate:"required"`
|
||||
Phone string `json:"phone" validate:"required"`
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
PlanID int64 `json:"plan_id" validate:"required"`
|
||||
Phone string `json:"phone" validate:"required"`
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Provider PaymentProvider `json:"provider"`
|
||||
}
|
||||
|
||||
type InitiateSubscriptionPaymentResponse struct {
|
||||
|
|
|
|||
36
internal/domain/payment_test.go
Normal file
36
internal/domain/payment_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
77
internal/domain/profile_field_option.go
Normal file
77
internal/domain/profile_field_option.go
Normal 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"`
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ type Program struct {
|
|||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Category string `json:"category"`
|
||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
|
@ -17,6 +18,7 @@ type Program struct {
|
|||
type CreateProgramInput struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Category string `json:"category" validate:"required,oneof=LEARN_ENGLISH IELTS DUOLINGO"`
|
||||
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 *int `json:"sort_order,omitempty" validate:"omitempty,min=0"`
|
||||
|
|
@ -25,6 +27,7 @@ type CreateProgramInput struct {
|
|||
type UpdateProgramInput struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Category *string `json:"category,omitempty" validate:"omitempty,oneof=LEARN_ENGLISH IELTS DUOLINGO"`
|
||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
SortOrder *int `json:"sort_order,omitempty"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,14 @@ import (
|
|||
"time"
|
||||
)
|
||||
|
||||
type SubscriptionCategory string
|
||||
|
||||
const (
|
||||
SubscriptionCategoryLearnEnglish SubscriptionCategory = "LEARN_ENGLISH"
|
||||
SubscriptionCategoryIELTS SubscriptionCategory = "IELTS"
|
||||
SubscriptionCategoryDuolingo SubscriptionCategory = "DUOLINGO"
|
||||
)
|
||||
|
||||
type DurationUnit string
|
||||
|
||||
const (
|
||||
|
|
@ -26,6 +34,7 @@ type SubscriptionPlan struct {
|
|||
ID int64
|
||||
Name string
|
||||
Description *string
|
||||
Category string
|
||||
DurationValue int32
|
||||
DurationUnit string
|
||||
Price float64
|
||||
|
|
@ -59,6 +68,7 @@ type UserSubscription struct {
|
|||
type CreateSubscriptionPlanInput struct {
|
||||
Name string
|
||||
Description *string
|
||||
Category string
|
||||
DurationValue int32
|
||||
DurationUnit string
|
||||
Price float64
|
||||
|
|
@ -69,6 +79,7 @@ type CreateSubscriptionPlanInput struct {
|
|||
type UpdateSubscriptionPlanInput struct {
|
||||
Name *string
|
||||
Description *string
|
||||
Category *string
|
||||
DurationValue *int32
|
||||
DurationUnit *string
|
||||
Price *float64
|
||||
|
|
|
|||
|
|
@ -43,32 +43,47 @@ type TeamInvitationWithMember struct {
|
|||
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 {
|
||||
FirstName string `json:"first_name" validate:"required"`
|
||||
LastName string `json:"last_name" validate:"required"`
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
PhoneNumber string `json:"phone_number"`
|
||||
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"`
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
TeamRole string `json:"team_role" validate:"required"`
|
||||
}
|
||||
|
||||
type AcceptTeamInvitationReq struct {
|
||||
Token string `json:"token" validate:"required"`
|
||||
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 {
|
||||
Valid bool `json:"valid"`
|
||||
Email string `json:"email,omitempty"`
|
||||
FirstName string `json:"first_name,omitempty"`
|
||||
LastName string `json:"last_name,omitempty"`
|
||||
TeamRole string `json:"team_role,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Valid bool `json:"valid"`
|
||||
Email string `json:"email,omitempty"`
|
||||
TeamRole string `json:"team_role,omitempty"`
|
||||
NeedsProfileSetup bool `json:"needs_profile_setup,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type InviteTeamMemberRes struct {
|
||||
|
|
|
|||
23
internal/domain/video_engagement.go
Normal file
23
internal/domain/video_engagement.go
Normal 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"`
|
||||
}
|
||||
15
internal/ports/mobile_app_version.go
Normal file
15
internal/ports/mobile_app_version.go
Normal 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)
|
||||
}
|
||||
17
internal/ports/profile_field_option.go
Normal file
17
internal/ports/profile_field_option.go
Normal 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
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ type SubscriptionStore interface {
|
|||
GetSubscriptionDisplayStatusByUserID(ctx context.Context, userID int64) (string, error)
|
||||
GetUserSubscriptionHistory(ctx context.Context, userID int64, limit, offset int32) ([]domain.UserSubscription, 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
|
||||
UpdateSubscriptionStatus(ctx context.Context, id int64, status string) error
|
||||
UpdateAutoRenew(ctx context.Context, id int64, autoRenew bool) error
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ func examPrepCatalogCourseToDomain(c dbgen.ExamPrepCatalogCourse) domain.ExamPre
|
|||
out := domain.ExamPrepCatalogCourse{
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
Category: c.Category,
|
||||
SortOrder: int(c.SortOrder),
|
||||
}
|
||||
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{
|
||||
Name: input.Name,
|
||||
Description: toPgText(input.Description),
|
||||
Category: input.Category,
|
||||
Thumbnail: toPgText(input.Thumbnail),
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -51,6 +53,7 @@ func (s *Store) GetExamPrepCatalogCourseByID(ctx context.Context, id int64) (dom
|
|||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
Description: c.Description,
|
||||
Category: c.Category,
|
||||
Thumbnail: c.Thumbnail,
|
||||
SortOrder: c.SortOrder,
|
||||
CreatedAt: c.CreatedAt,
|
||||
|
|
@ -81,6 +84,7 @@ func (s *Store) ListExamPrepCatalogCourses(ctx context.Context, limit, offset in
|
|||
ID: r.ID,
|
||||
Name: r.Name,
|
||||
Description: r.Description,
|
||||
Category: r.Category,
|
||||
Thumbnail: r.Thumbnail,
|
||||
SortOrder: r.SortOrder,
|
||||
CreatedAt: r.CreatedAt,
|
||||
|
|
@ -110,6 +114,7 @@ func (s *Store) UpdateExamPrepCatalogCourse(ctx context.Context, id int64, input
|
|||
ID: id,
|
||||
Name: nameText,
|
||||
Description: optionalTextUpdate(input.Description),
|
||||
Category: optionalTextUpdate(input.Category),
|
||||
Thumbnail: optionalTextUpdate(input.Thumbnail),
|
||||
SortOrder: optionalInt4Update(input.SortOrder),
|
||||
})
|
||||
|
|
|
|||
71
internal/repository/exam_prep_progress.go
Normal file
71
internal/repository/exam_prep_progress.go
Normal 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
|
||||
}
|
||||
|
|
@ -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})
|
||||
}
|
||||
|
||||
// LmsUserLessonProgressInModule returns combined completed/total counts for lessons + published practices in a module.
|
||||
func (s *Store) LmsUserLessonProgressInModule(ctx context.Context, userID, moduleID int64) (completed, total int32, err error) {
|
||||
lessonTotal, err := s.queries.CountLessonsInModule(ctx, moduleID)
|
||||
// LmsUserPracticeProgressInLesson returns published practice completion counts scoped to a lesson.
|
||||
func (s *Store) LmsUserPracticeProgressInLesson(ctx context.Context, userID, lessonID int64) (completed, total int32, err error) {
|
||||
lessonIDPG := toPgInt8(&lessonID)
|
||||
total, err = s.queries.CountPublishedPracticesInLesson(ctx, lessonIDPG)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
lessonCompleted, err := s.queries.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{
|
||||
ModuleID: moduleID,
|
||||
completed, err = s.queries.CountUserCompletedPublishedPracticesInLesson(ctx, dbgen.CountUserCompletedPublishedPracticesInLessonParams{
|
||||
LessonID: lessonIDPG,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
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 {
|
||||
return 0, 0, err
|
||||
}
|
||||
practiceCompleted, err := s.queries.CountUserCompletedPublishedPracticesInModule(ctx, dbgen.CountUserCompletedPublishedPracticesInModuleParams{
|
||||
completed, err = s.queries.CountUserCompletedPublishedPracticesInModule(ctx, dbgen.CountUserCompletedPublishedPracticesInModuleParams{
|
||||
ModuleID: toPgInt8(&moduleID),
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
total = lessonTotal + practiceTotal
|
||||
completed = lessonCompleted + practiceCompleted
|
||||
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) {
|
||||
lessonTotal, err := s.queries.CountLessonsInCourse(ctx, courseID)
|
||||
total, err = s.queries.CountPublishedPracticesInCourse(ctx, toPgInt8(&courseID))
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
lessonCompleted, err := s.queries.CountUserCompletedLessonsInCourse(ctx, dbgen.CountUserCompletedLessonsInCourseParams{
|
||||
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{
|
||||
completed, err = s.queries.CountUserCompletedPublishedPracticesInCourse(ctx, dbgen.CountUserCompletedPublishedPracticesInCourseParams{
|
||||
CourseID: toPgInt8(&courseID),
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
total = lessonTotal + practiceTotal
|
||||
completed = lessonCompleted + practiceCompleted
|
||||
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) {
|
||||
lessonTotal, err := s.queries.CountLessonsInProgram(ctx, programID)
|
||||
total, err = s.queries.CountPublishedPracticesInProgram(ctx, programID)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
lessonCompleted, err := s.queries.CountUserCompletedLessonsInProgram(ctx, dbgen.CountUserCompletedLessonsInProgramParams{
|
||||
completed, err = s.queries.CountUserCompletedPublishedPracticesInProgram(ctx, dbgen.CountUserCompletedPublishedPracticesInProgramParams{
|
||||
ProgramID: programID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,15 @@ package repository
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
dbgen "Yimaru-Backend/gen/db"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// CompleteLessonForUser records lesson completion and cascades completion upward when
|
||||
// both lesson and related practice requirements are satisfied.
|
||||
// CompleteLessonForUser records lesson completion for sequential lesson gating and
|
||||
// re-evaluates higher-level practice-based rollups.
|
||||
func (s *Store) CompleteLessonForUser(ctx context.Context, userID, lessonID int64) error {
|
||||
q, tx, err := s.BeginTx(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -42,8 +44,8 @@ func (s *Store) CompleteLessonForUser(ctx context.Context, userID, lessonID int6
|
|||
return nil
|
||||
}
|
||||
|
||||
// CompletePracticeForUser records practice completion and cascades completion upward when
|
||||
// both lesson and related practice requirements are satisfied.
|
||||
// CompletePracticeForUser records practice completion and cascades practice-based
|
||||
// completion upward when all published practices in scope are complete.
|
||||
func (s *Store) CompletePracticeForUser(ctx context.Context, userID, questionSetID int64) error {
|
||||
q, tx, err := s.BeginTx(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -60,6 +62,13 @@ func (s *Store) CompletePracticeForUser(ctx context.Context, userID, questionSet
|
|||
|
||||
scope, err := q.GetPracticeScopeByQuestionSetID(ctx, questionSetID)
|
||||
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
|
||||
}
|
||||
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 {
|
||||
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))
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -133,9 +131,8 @@ func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, user
|
|||
return err
|
||||
}
|
||||
|
||||
moduleLessonsComplete := moduleLessonsTotal > 0 && moduleLessonsDone >= moduleLessonsTotal
|
||||
modulePracticesComplete := modulePracticesDone >= modulePracticesTotal
|
||||
if !moduleLessonsComplete || !modulePracticesComplete {
|
||||
modulePracticesComplete := modulePracticesTotal > 0 && modulePracticesDone >= modulePracticesTotal
|
||||
if !modulePracticesComplete {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -169,7 +166,7 @@ func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, user
|
|||
}
|
||||
|
||||
courseModulesComplete := nMods > 0 && nDoneMods >= nMods
|
||||
coursePracticesComplete := coursePracticesDone >= coursePracticesTotal
|
||||
coursePracticesComplete := coursePracticesTotal > 0 && coursePracticesDone >= coursePracticesTotal
|
||||
if !courseModulesComplete || !coursePracticesComplete {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -203,7 +200,7 @@ func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, user
|
|||
}
|
||||
|
||||
programCoursesComplete := nCr > 0 && nCrDone >= nCr
|
||||
programPracticesComplete := programPracticesDone >= programPracticesTotal
|
||||
programPracticesComplete := programPracticesTotal > 0 && programPracticesDone >= programPracticesTotal
|
||||
if !programCoursesComplete || !programPracticesComplete {
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ import (
|
|||
|
||||
dbgen "Yimaru-Backend/gen/db"
|
||||
"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) {
|
||||
lessons, err := s.queries.ListLMSCompletedLessonIDsByUser(ctx, userID)
|
||||
if err != nil {
|
||||
|
|
@ -26,13 +28,31 @@ func (s *Store) GetLMSUserProgressSnapshot(ctx context.Context, userID int64) (d
|
|||
return domain.LMSUserProgress{}, err
|
||||
}
|
||||
return domain.LMSUserProgress{
|
||||
LessonIDs: lessons,
|
||||
ModuleIDs: mods,
|
||||
CourseIDs: courses,
|
||||
ProgramIDs: programs,
|
||||
LessonIDs: pgInt8IDsToInt64(lessons),
|
||||
ModuleIDs: int64IDsOrEmpty(mods),
|
||||
CourseIDs: int64IDsOrEmpty(courses),
|
||||
ProgramIDs: int64IDsOrEmpty(programs),
|
||||
}, 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).
|
||||
func (s *Store) ListUserLMSFlatLearningActivity(ctx context.Context, userID int64) ([]dbgen.ListUserLMSFlatLearningActivityByUserRow, error) {
|
||||
return s.queries.ListUserLMSFlatLearningActivityByUser(ctx, userID)
|
||||
|
|
|
|||
41
internal/repository/lms_user_progress_snapshot_test.go
Normal file
41
internal/repository/lms_user_progress_snapshot_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
228
internal/repository/mobile_app_versions.go
Normal file
228
internal/repository/mobile_app_versions.go
Normal 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)
|
||||
}
|
||||
214
internal/repository/profile_field_options.go
Normal file
214
internal/repository/profile_field_options.go
Normal 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
|
||||
}
|
||||
|
|
@ -14,8 +14,9 @@ import (
|
|||
|
||||
func programToDomain(p dbgen.Program) domain.Program {
|
||||
out := domain.Program{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
Category: p.Category,
|
||||
}
|
||||
out.Description = fromPgText(p.Description)
|
||||
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{
|
||||
Name: input.Name,
|
||||
Description: toPgText(input.Description),
|
||||
Category: input.Category,
|
||||
Thumbnail: toPgText(input.Thumbnail),
|
||||
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{
|
||||
Name: input.Name,
|
||||
Description: toPgText(input.Description),
|
||||
Category: input.Category,
|
||||
Thumbnail: toPgText(input.Thumbnail),
|
||||
SortOrder: pgtype.Int4{Valid: false},
|
||||
})
|
||||
|
|
@ -102,6 +105,7 @@ func (s *Store) ListPrograms(ctx context.Context, limit, offset int32) ([]domain
|
|||
ID: r.ID,
|
||||
Name: r.Name,
|
||||
Description: r.Description,
|
||||
Category: r.Category,
|
||||
Thumbnail: r.Thumbnail,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
|
|
@ -166,6 +170,7 @@ func (s *Store) UpdateProgram(ctx context.Context, id int64, input domain.Update
|
|||
ID: id,
|
||||
Name: nameText,
|
||||
Description: optionalTextUpdate(input.Description),
|
||||
Category: optionalTextUpdate(input.Category),
|
||||
Thumbnail: optionalTextUpdate(input.Thumbnail),
|
||||
SortOrder: pgtype.Int4{Valid: false},
|
||||
})
|
||||
|
|
@ -190,6 +195,7 @@ func (s *Store) UpdateProgram(ctx context.Context, id int64, input domain.Update
|
|||
ID: id,
|
||||
Name: nameText,
|
||||
Description: optionalTextUpdate(input.Description),
|
||||
Category: optionalTextUpdate(input.Category),
|
||||
Thumbnail: optionalTextUpdate(input.Thumbnail),
|
||||
SortOrder: sortParam,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -45,11 +45,12 @@ func (s *Store) CreateSubscriptionPlan(ctx context.Context, input domain.CreateS
|
|||
plan, err := s.queries.CreateSubscriptionPlan(ctx, dbgen.CreateSubscriptionPlanParams{
|
||||
Name: input.Name,
|
||||
Description: toPgText(input.Description),
|
||||
Category: input.Category,
|
||||
DurationValue: input.DurationValue,
|
||||
DurationUnit: input.DurationUnit,
|
||||
Price: toPgNumeric(input.Price),
|
||||
Currency: input.Currency,
|
||||
Column7: input.IsActive,
|
||||
Column8: input.IsActive,
|
||||
})
|
||||
if err != nil {
|
||||
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 {
|
||||
return s.queries.UpdateSubscriptionPlan(ctx, dbgen.UpdateSubscriptionPlanParams{
|
||||
Name: stringVal(input.Name),
|
||||
Description: toPgText(input.Description),
|
||||
DurationValue: int32Val(input.DurationValue),
|
||||
DurationUnit: stringVal(input.DurationUnit),
|
||||
Name: optionalTextUpdate(input.Name),
|
||||
Description: optionalTextUpdate(input.Description),
|
||||
Category: optionalTextUpdate(input.Category),
|
||||
DurationValue: optionalInt4(input.DurationValue),
|
||||
DurationUnit: optionalTextUpdate(input.DurationUnit),
|
||||
Price: numericPtrToNumeric(input.Price),
|
||||
Currency: stringVal(input.Currency),
|
||||
IsActive: boolPtrToBool(input.IsActive),
|
||||
Currency: optionalTextUpdate(input.Currency),
|
||||
IsActive: optionalBool(input.IsActive),
|
||||
ID: id,
|
||||
})
|
||||
}
|
||||
|
|
@ -215,6 +217,13 @@ func (s *Store) HasActiveSubscription(ctx context.Context, userID int64) (bool,
|
|||
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 {
|
||||
return s.queries.CancelUserSubscription(ctx, id)
|
||||
}
|
||||
|
|
@ -247,6 +256,7 @@ func subscriptionPlanToDomain(p dbgen.SubscriptionPlan) *domain.SubscriptionPlan
|
|||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
Description: fromPgText(p.Description),
|
||||
Category: p.Category,
|
||||
DurationValue: p.DurationValue,
|
||||
DurationUnit: p.DurationUnit,
|
||||
Price: fromPgNumeric(p.Price),
|
||||
|
|
@ -296,18 +306,11 @@ func userSubscriptionWithPlanToDomain(s dbgen.GetUserSubscriptionByIDRow) *domai
|
|||
}
|
||||
}
|
||||
|
||||
func stringVal(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
func optionalInt4(v *int32) pgtype.Int4 {
|
||||
if v == nil {
|
||||
return pgtype.Int4{Valid: false}
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
func int32Val(i *int32) int32 {
|
||||
if i == nil {
|
||||
return 0
|
||||
}
|
||||
return *i
|
||||
return pgtype.Int4{Int32: *v, Valid: true}
|
||||
}
|
||||
|
||||
func numericPtrToNumeric(val *float64) pgtype.Numeric {
|
||||
|
|
@ -317,11 +320,11 @@ func numericPtrToNumeric(val *float64) pgtype.Numeric {
|
|||
return toPgNumeric(*val)
|
||||
}
|
||||
|
||||
func boolPtrToBool(b *bool) bool {
|
||||
func optionalBool(b *bool) pgtype.Bool {
|
||||
if b == nil {
|
||||
return false
|
||||
return pgtype.Bool{Valid: false}
|
||||
}
|
||||
return *b
|
||||
return pgtype.Bool{Bool: *b, Valid: true}
|
||||
}
|
||||
|
||||
func float64Ptr(f float64) *float64 {
|
||||
|
|
|
|||
|
|
@ -178,18 +178,18 @@ func (s *Store) UpdateTeamMember(ctx context.Context, req domain.UpdateTeamMembe
|
|||
}
|
||||
|
||||
return s.queries.UpdateTeamMember(ctx, dbgen.UpdateTeamMemberParams{
|
||||
FirstName: req.FirstName,
|
||||
LastName: req.LastName,
|
||||
PhoneNumber: pgtype.Text{String: req.PhoneNumber, Valid: req.PhoneNumber != ""},
|
||||
TeamRole: req.TeamRole,
|
||||
Department: pgtype.Text{String: req.Department, Valid: req.Department != ""},
|
||||
JobTitle: pgtype.Text{String: req.JobTitle, Valid: req.JobTitle != ""},
|
||||
EmploymentType: pgtype.Text{String: req.EmploymentType, Valid: req.EmploymentType != ""},
|
||||
FirstName: optionalPgText(req.FirstName),
|
||||
LastName: optionalPgText(req.LastName),
|
||||
PhoneNumber: optionalPgText(req.PhoneNumber),
|
||||
TeamRole: optionalPgText(req.TeamRole),
|
||||
Department: optionalPgText(req.Department),
|
||||
JobTitle: optionalPgText(req.JobTitle),
|
||||
EmploymentType: optionalPgText(req.EmploymentType),
|
||||
HireDate: hireDate,
|
||||
ProfilePictureUrl: pgtype.Text{String: req.ProfilePictureURL, Valid: req.ProfilePictureURL != ""},
|
||||
Bio: pgtype.Text{String: req.Bio, Valid: req.Bio != ""},
|
||||
WorkPhone: pgtype.Text{String: req.WorkPhone, Valid: req.WorkPhone != ""},
|
||||
EmergencyContact: pgtype.Text{String: req.EmergencyContact, Valid: req.EmergencyContact != ""},
|
||||
ProfilePictureUrl: optionalPgText(req.ProfilePictureURL),
|
||||
Bio: optionalPgText(req.Bio),
|
||||
WorkPhone: optionalPgText(req.WorkPhone),
|
||||
EmergencyContact: optionalPgText(req.EmergencyContact),
|
||||
Permissions: permissionsJSON,
|
||||
UpdatedBy: pgtype.Int8{Int64: req.UpdatedBy, Valid: req.UpdatedBy > 0},
|
||||
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 {
|
||||
return s.queries.RevokeTeamRefreshTokenByToken(ctx, token)
|
||||
}
|
||||
|
||||
func optionalPgText(value string) pgtype.Text {
|
||||
if value == "" {
|
||||
return pgtype.Text{}
|
||||
}
|
||||
return pgtype.Text{String: value, Valid: true}
|
||||
}
|
||||
|
|
|
|||
165
internal/repository/video_engagement.go
Normal file
165
internal/repository/video_engagement.go
Normal 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}
|
||||
}
|
||||
237
internal/services/appversions/service.go
Normal file
237
internal/services/appversions/service.go
Normal 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
|
||||
}
|
||||
|
|
@ -59,12 +59,12 @@ func (s *ArifpayService) InitiateSubscriptionPayment(ctx context.Context, userID
|
|||
}
|
||||
|
||||
// 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 {
|
||||
return nil, fmt.Errorf("failed to check active subscription: %w", err)
|
||||
}
|
||||
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
|
||||
|
|
@ -73,10 +73,14 @@ func (s *ArifpayService) InitiateSubscriptionPayment(ctx context.Context, userID
|
|||
|
||||
// Create payment record
|
||||
payment, err := s.paymentStore.CreatePayment(ctx, domain.CreatePaymentInput{
|
||||
UserID: userID,
|
||||
PlanID: &req.PlanID,
|
||||
Amount: plan.Price,
|
||||
Currency: plan.Currency,
|
||||
UserID: userID,
|
||||
PlanID: &req.PlanID,
|
||||
Amount: plan.Price,
|
||||
Currency: plan.Currency,
|
||||
PaymentMethod: func() *string {
|
||||
v := string(domain.PaymentProviderArifPay)
|
||||
return &v
|
||||
}(),
|
||||
Nonce: nonce,
|
||||
ExpiresAt: &expiresAt,
|
||||
})
|
||||
|
|
@ -569,12 +573,12 @@ func (s *ArifpayService) InitiateDirectPayment(ctx context.Context, userID int64
|
|||
}
|
||||
|
||||
// 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 {
|
||||
return nil, fmt.Errorf("failed to check active subscription: %w", err)
|
||||
}
|
||||
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
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ func (s *Service) LoginWithGoogleAndroid(
|
|||
|
||||
payload, err := idtoken.Validate(ctx, idToken, clientID)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -76,12 +76,12 @@ func (s *Service) InitiateSubscriptionPayment(ctx context.Context, userID int64,
|
|||
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 {
|
||||
return nil, fmt.Errorf("failed to check active subscription: %w", err)
|
||||
}
|
||||
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)
|
||||
|
|
@ -115,10 +115,14 @@ func (s *Service) InitiateSubscriptionPayment(ctx context.Context, userID int64,
|
|||
expiresAt := time.Now().Add(3 * time.Hour)
|
||||
|
||||
payment, err := s.paymentStore.CreatePayment(ctx, domain.CreatePaymentInput{
|
||||
UserID: userID,
|
||||
PlanID: &req.PlanID,
|
||||
Amount: plan.Price,
|
||||
Currency: plan.Currency,
|
||||
UserID: userID,
|
||||
PlanID: &req.PlanID,
|
||||
Amount: plan.Price,
|
||||
Currency: plan.Currency,
|
||||
PaymentMethod: func() *string {
|
||||
v := string(domain.PaymentProviderChapa)
|
||||
return &v
|
||||
}(),
|
||||
Nonce: txRef,
|
||||
ExpiresAt: &expiresAt,
|
||||
})
|
||||
|
|
@ -279,6 +283,10 @@ func (s *Service) VerifyPayment(ctx context.Context, txRef string) (*domain.Paym
|
|||
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) {
|
||||
url := strings.TrimSuffix(s.cfg.CHAPA_BASE_URL, "/") + "/transaction/verify/" + txRef
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package lmsprogress
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
|
||||
"Yimaru-Backend/internal/domain"
|
||||
"Yimaru-Backend/internal/repository"
|
||||
|
|
@ -144,115 +145,175 @@ func (s *Service) CanAccessLesson(ctx context.Context, userID, lessonID int64) (
|
|||
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 {
|
||||
if !role.UsesLMSSequentialGating() {
|
||||
if !role.IsCustomerLearnerRole() {
|
||||
p.Access = 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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c, t, pct := lmsProgressCounts(comp, tot, done)
|
||||
p.Access = &domain.LMSEntityAccess{
|
||||
IsAccessible: ok, IsCompleted: done, Reason: reasonIf(ok, reason),
|
||||
CompletedCount: c, TotalCount: t, ProgressPercent: pct,
|
||||
done := lmsProgressComplete(comp, tot)
|
||||
ok, reason := true, ""
|
||||
if role.UsesLMSSequentialGating() {
|
||||
ok, reason, err = s.CanAccessProgram(ctx, userID, p.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
p.Access = buildLMSEntityAccess(ok, reason, done, comp, tot)
|
||||
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 {
|
||||
if !role.UsesLMSSequentialGating() {
|
||||
if !role.IsCustomerLearnerRole() {
|
||||
c.Access = 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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cc, tt, pct := lmsProgressCounts(comp, tot, done)
|
||||
c.Access = &domain.LMSEntityAccess{
|
||||
IsAccessible: ok, IsCompleted: done, Reason: reasonIf(ok, reason),
|
||||
CompletedCount: cc, TotalCount: tt, ProgressPercent: pct,
|
||||
done := lmsProgressComplete(comp, tot)
|
||||
ok, reason := true, ""
|
||||
if role.UsesLMSSequentialGating() {
|
||||
ok, reason, err = s.CanAccessCourse(ctx, userID, c.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
c.Access = buildLMSEntityAccess(ok, reason, done, comp, tot)
|
||||
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 {
|
||||
if !role.UsesLMSSequentialGating() {
|
||||
if !role.IsCustomerLearnerRole() {
|
||||
m.Access = 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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cc, tt, pct := lmsProgressCounts(comp, tot, done)
|
||||
m.Access = &domain.LMSEntityAccess{
|
||||
IsAccessible: ok, IsCompleted: done, Reason: reasonIf(ok, reason),
|
||||
CompletedCount: cc, TotalCount: tt, ProgressPercent: pct,
|
||||
done := lmsProgressComplete(comp, tot)
|
||||
ok, reason := true, ""
|
||||
if role.UsesLMSSequentialGating() {
|
||||
ok, reason, err = s.CanAccessModule(ctx, userID, m.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
m.Access = buildLMSEntityAccess(ok, reason, done, comp, tot)
|
||||
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 {
|
||||
if !role.UsesLMSSequentialGating() {
|
||||
if !role.IsCustomerLearnerRole() {
|
||||
les.Access = 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 {
|
||||
return err
|
||||
}
|
||||
done, err := s.store.LmsUserHasLessonProgress(ctx, userID, les.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var comp, tot int32
|
||||
if done {
|
||||
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,
|
||||
done := lmsProgressComplete(comp, tot)
|
||||
ok, reason := true, ""
|
||||
if role.UsesLMSSequentialGating() {
|
||||
ok, reason, err = s.CanAccessLesson(ctx, userID, les.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
les.Access = buildLMSEntityAccess(ok, reason, done, comp, tot)
|
||||
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 0–100; completed
|
||||
// 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)
|
||||
if t < 0 {
|
||||
t = 0
|
||||
|
|
@ -262,18 +323,22 @@ func lmsProgressCounts(completed, total int32, isCompleted bool) (c, t, pct int)
|
|||
}
|
||||
if isCompleted {
|
||||
if t > 0 {
|
||||
return t, t, 100
|
||||
return t, t, 100, 100
|
||||
}
|
||||
return c, t, 100
|
||||
return c, t, 100, 100
|
||||
}
|
||||
if t == 0 {
|
||||
return 0, 0, 0
|
||||
return 0, 0, 0, 0
|
||||
}
|
||||
pct = (c * 100) / t
|
||||
if 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 {
|
||||
|
|
|
|||
97
internal/services/lmsprogress/service_test.go
Normal file
97
internal/services/lmsprogress/service_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
210
internal/services/profilefieldoptions/service.go
Normal file
210
internal/services/profilefieldoptions/service.go
Normal 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
|
||||
}
|
||||
|
|
@ -117,6 +117,7 @@ var AllPermissions = []domain.PermissionSeed{
|
|||
{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.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
|
||||
{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.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
|
||||
{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"},
|
||||
|
|
@ -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.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
|
||||
{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",
|
||||
"practices.get", "practices.list",
|
||||
"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",
|
||||
|
||||
"programs.list", "programs.get",
|
||||
|
|
@ -460,9 +475,15 @@ var DefaultRolePermissions = map[string][]string{
|
|||
// FAQs
|
||||
"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.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.dashboard",
|
||||
|
||||
|
|
@ -508,7 +529,7 @@ var DefaultRolePermissions = map[string][]string{
|
|||
"lessons.get", "lessons.list_by_module", "lessons.complete",
|
||||
"practices.get", "practices.list",
|
||||
"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",
|
||||
|
||||
"programs.list", "programs.get",
|
||||
|
|
@ -569,7 +590,7 @@ var DefaultRolePermissions = map[string][]string{
|
|||
"lessons.get", "lessons.list_by_module",
|
||||
"practices.get", "practices.list",
|
||||
"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",
|
||||
|
||||
"programs.list", "programs.get",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ var (
|
|||
ErrPlanNotFound = errors.New("subscription plan not found")
|
||||
ErrSubscriptionNotFound = errors.New("subscription not found")
|
||||
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")
|
||||
)
|
||||
|
||||
|
|
@ -56,15 +56,6 @@ func (s *Service) DeletePlan(ctx context.Context, id int64) error {
|
|||
|
||||
// Subscribe creates a new subscription for a user
|
||||
func (s *Service) Subscribe(ctx context.Context, userID, planID int64, paymentRef, paymentMethod *string) (*domain.UserSubscription, error) {
|
||||
// Check if user already has an active subscription
|
||||
hasActive, err := s.store.HasActiveSubscription(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hasActive {
|
||||
return nil, ErrAlreadySubscribed
|
||||
}
|
||||
|
||||
// Get the plan to calculate expiry
|
||||
plan, err := s.store.GetSubscriptionPlanByID(ctx, planID)
|
||||
if err != nil {
|
||||
|
|
@ -74,6 +65,14 @@ func (s *Service) Subscribe(ctx context.Context, userID, planID int64, paymentRe
|
|||
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
|
||||
startsAt := time.Now()
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
sub, err := s.store.GetUserSubscriptionByID(ctx, subscriptionID)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -21,9 +21,6 @@ func (s *Service) InviteTeamMember(ctx context.Context, req domain.InviteTeamMem
|
|||
if !domain.TeamRole(req.TeamRole).IsValid() {
|
||||
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))
|
||||
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
|
||||
}
|
||||
|
||||
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{
|
||||
FirstName: strings.TrimSpace(req.FirstName),
|
||||
LastName: strings.TrimSpace(req.LastName),
|
||||
Email: email,
|
||||
PhoneNumber: strings.TrimSpace(req.PhoneNumber),
|
||||
Password: hashedPassword,
|
||||
TeamRole: domain.TeamRole(req.TeamRole),
|
||||
Department: strings.TrimSpace(req.Department),
|
||||
JobTitle: strings.TrimSpace(req.JobTitle),
|
||||
EmploymentType: domain.EmploymentType(req.EmploymentType),
|
||||
HireDate: hireDate,
|
||||
Status: domain.TeamMemberStatusInactive,
|
||||
EmailVerified: false,
|
||||
Permissions: req.Permissions,
|
||||
CreatedBy: invitedBy,
|
||||
FirstName: domain.TeamInvitePlaceholderFirstName,
|
||||
LastName: domain.TeamInvitePlaceholderLastName,
|
||||
Email: email,
|
||||
Password: hashedPassword,
|
||||
TeamRole: domain.TeamRole(req.TeamRole),
|
||||
Status: domain.TeamMemberStatusInactive,
|
||||
EmailVerified: false,
|
||||
CreatedBy: invitedBy,
|
||||
}
|
||||
|
||||
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{
|
||||
res := domain.VerifyTeamInvitationRes{
|
||||
Valid: true,
|
||||
Email: member.Email,
|
||||
FirstName: member.FirstName,
|
||||
LastName: member.LastName,
|
||||
TeamRole: string(member.TeamRole),
|
||||
ExpiresAt: inv.ExpiresAt,
|
||||
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) {
|
||||
|
|
@ -177,6 +161,29 @@ func (s *Service) AcceptTeamInvitation(ctx context.Context, req domain.AcceptTea
|
|||
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)
|
||||
if err != nil {
|
||||
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")
|
||||
}
|
||||
|
||||
var firstName string
|
||||
if !domain.IsTeamInvitePlaceholderProfile(member.FirstName, member.LastName) {
|
||||
firstName = member.FirstName
|
||||
}
|
||||
|
||||
rendered, err := s.emailTemplateSvc.Render(ctx, domain.EmailTemplateSlugInvitation, map[string]any{
|
||||
"FirstName": member.FirstName,
|
||||
"FirstName": firstName,
|
||||
"InviterName": inviterName,
|
||||
"InviteLink": inviteLink,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"Yimaru-Backend/internal/ports"
|
||||
emailtemplates "Yimaru-Backend/internal/services/emailtemplates"
|
||||
"Yimaru-Backend/internal/services/messenger"
|
||||
profilefieldoptions "Yimaru-Backend/internal/services/profilefieldoptions"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
|
@ -13,12 +14,13 @@ const (
|
|||
)
|
||||
|
||||
type Service struct {
|
||||
tokenStore ports.TokenStore
|
||||
userStore ports.UserStore
|
||||
otpStore ports.OtpStore
|
||||
messengerSvc *messenger.Service
|
||||
emailTemplateSvc *emailtemplates.Service
|
||||
config *config.Config
|
||||
tokenStore ports.TokenStore
|
||||
userStore ports.UserStore
|
||||
otpStore ports.OtpStore
|
||||
messengerSvc *messenger.Service
|
||||
emailTemplateSvc *emailtemplates.Service
|
||||
profileFieldOptionSvc *profilefieldoptions.Service
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
func NewService(
|
||||
|
|
@ -27,14 +29,16 @@ func NewService(
|
|||
otpStore ports.OtpStore,
|
||||
messengerSvc *messenger.Service,
|
||||
emailTemplateSvc *emailtemplates.Service,
|
||||
profileFieldOptionSvc *profilefieldoptions.Service,
|
||||
cfg *config.Config,
|
||||
) *Service {
|
||||
return &Service{
|
||||
tokenStore: tokenStore,
|
||||
userStore: userStore,
|
||||
otpStore: otpStore,
|
||||
messengerSvc: messengerSvc,
|
||||
emailTemplateSvc: emailTemplateSvc,
|
||||
config: cfg,
|
||||
tokenStore: tokenStore,
|
||||
userStore: userStore,
|
||||
otpStore: otpStore,
|
||||
messengerSvc: messengerSvc,
|
||||
emailTemplateSvc: emailTemplateSvc,
|
||||
profileFieldOptionSvc: profileFieldOptionSvc,
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,11 @@ func (s *Service) SearchUserByNameOrPhone(ctx context.Context, searchString stri
|
|||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
|
|||
20
internal/services/videoengagement/service.go
Normal file
20
internal/services/videoengagement/service.go
Normal 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)
|
||||
}
|
||||
|
|
@ -3,7 +3,8 @@ package httpserver
|
|||
import (
|
||||
dbgen "Yimaru-Backend/gen/db"
|
||||
"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/chapa"
|
||||
"Yimaru-Backend/internal/services/assessment"
|
||||
|
|
@ -21,6 +22,7 @@ import (
|
|||
notificationservice "Yimaru-Backend/internal/services/notification"
|
||||
"Yimaru-Backend/internal/services/personas"
|
||||
"Yimaru-Backend/internal/services/practices"
|
||||
profilefieldoptions "Yimaru-Backend/internal/services/profilefieldoptions"
|
||||
"Yimaru-Backend/internal/services/programs"
|
||||
"Yimaru-Backend/internal/services/questions"
|
||||
ratingsservice "Yimaru-Backend/internal/services/ratings"
|
||||
|
|
@ -29,6 +31,7 @@ import (
|
|||
"Yimaru-Backend/internal/services/subscriptions"
|
||||
"Yimaru-Backend/internal/services/team"
|
||||
vimeoservice "Yimaru-Backend/internal/services/vimeo"
|
||||
"Yimaru-Backend/internal/services/videoengagement"
|
||||
|
||||
"Yimaru-Backend/internal/services/settings"
|
||||
"Yimaru-Backend/internal/services/transaction"
|
||||
|
|
@ -50,8 +53,10 @@ import (
|
|||
type App struct {
|
||||
assessmentSvc *assessment.Service
|
||||
questionsSvc *questions.Service
|
||||
faqSvc *faqs.Service
|
||||
emailTemplateSvc *emailtemplates.Service
|
||||
faqSvc *faqs.Service
|
||||
appVersionSvc *appversions.Service
|
||||
emailTemplateSvc *emailtemplates.Service
|
||||
profileFieldOptionSvc *profilefieldoptions.Service
|
||||
personaSvc *personas.Service
|
||||
examPrepSvc *examprep.Service
|
||||
programSvc *programs.Service
|
||||
|
|
@ -85,15 +90,18 @@ type App struct {
|
|||
Logger *slog.Logger
|
||||
mongoLoggerSvc *zap.Logger
|
||||
analyticsDB *dbgen.Queries
|
||||
rbacSvc *rbacservice.Service
|
||||
stopPurgeWorker context.CancelFunc
|
||||
rbacSvc *rbacservice.Service
|
||||
videoEngagementSvc *videoengagement.Service
|
||||
stopPurgeWorker context.CancelFunc
|
||||
}
|
||||
|
||||
func NewApp(
|
||||
assessmentSvc *assessment.Service,
|
||||
questionsSvc *questions.Service,
|
||||
faqSvc *faqs.Service,
|
||||
appVersionSvc *appversions.Service,
|
||||
emailTemplateSvc *emailtemplates.Service,
|
||||
profileFieldOptionSvc *profilefieldoptions.Service,
|
||||
personaSvc *personas.Service,
|
||||
examPrepSvc *examprep.Service,
|
||||
programSvc *programs.Service,
|
||||
|
|
@ -125,6 +133,7 @@ func NewApp(
|
|||
mongoLoggerSvc *zap.Logger,
|
||||
analyticsDB *dbgen.Queries,
|
||||
rbacSvc *rbacservice.Service,
|
||||
videoEngagementSvc *videoengagement.Service,
|
||||
) *App {
|
||||
app := fiber.New(fiber.Config{
|
||||
CaseSensitive: true,
|
||||
|
|
@ -146,8 +155,10 @@ func NewApp(
|
|||
s := &App{
|
||||
assessmentSvc: assessmentSvc,
|
||||
questionsSvc: questionsSvc,
|
||||
faqSvc: faqSvc,
|
||||
emailTemplateSvc: emailTemplateSvc,
|
||||
faqSvc: faqSvc,
|
||||
appVersionSvc: appVersionSvc,
|
||||
emailTemplateSvc: emailTemplateSvc,
|
||||
profileFieldOptionSvc: profileFieldOptionSvc,
|
||||
personaSvc: personaSvc,
|
||||
examPrepSvc: examPrepSvc,
|
||||
programSvc: programSvc,
|
||||
|
|
@ -180,8 +191,9 @@ func NewApp(
|
|||
recommendationSvc: recommendationSvc,
|
||||
cfg: cfg,
|
||||
mongoLoggerSvc: mongoLoggerSvc,
|
||||
analyticsDB: analyticsDB,
|
||||
rbacSvc: rbacSvc,
|
||||
analyticsDB: analyticsDB,
|
||||
rbacSvc: rbacSvc,
|
||||
videoEngagementSvc: videoEngagementSvc,
|
||||
}
|
||||
|
||||
s.initAppRoutes()
|
||||
|
|
|
|||
|
|
@ -61,10 +61,30 @@ func (h *Handler) GetAnalyticsDashboard(c *fiber.Ctx) error {
|
|||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
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")
|
||||
}
|
||||
|
||||
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{
|
||||
GeneratedAt: time.Now().UTC(),
|
||||
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),
|
||||
Payments: mapPaymentsSection(paymentsSummary, paymentsByStatus, paymentsByMethod, revenue30, revenueMonthlyRows, monthlyRevenueYear),
|
||||
Courses: mapCoursesSection(courseCounts),
|
||||
Videos: mapVideosSection(videoSummary, videoDropOff),
|
||||
Content: mapContentSection(questionsCounts, questionsByType, questionSetsByType),
|
||||
Notifications: mapNotificationsSection(notifSummary, notifByChannel, notifByType),
|
||||
Issues: mapIssuesSection(issuesSummary, issuesByStatus, issuesByType),
|
||||
|
|
@ -225,7 +259,12 @@ func mapUsersSection(
|
|||
byRole []dbgen.AnalyticsUsersByRoleRow,
|
||||
byStatus []dbgen.AnalyticsUsersByStatusRow,
|
||||
byAge []dbgen.AnalyticsUsersByAgeGroupRow,
|
||||
byEducation []dbgen.AnalyticsUsersByEducationLevelRow,
|
||||
byOccupation []dbgen.AnalyticsUsersByOccupationRow,
|
||||
byLearningGoal []dbgen.AnalyticsUsersByLearningGoalRow,
|
||||
byLanguageChallange []dbgen.AnalyticsUsersByLanguageChallangeRow,
|
||||
byKnowledge []dbgen.AnalyticsUsersByKnowledgeLevelRow,
|
||||
byCountry []dbgen.AnalyticsUsersByCountryRow,
|
||||
byRegion []dbgen.AnalyticsUsersByRegionRow,
|
||||
regs []dbgen.AnalyticsUserRegistrationsLast30DaysRow,
|
||||
) domain.AnalyticsUsersSection {
|
||||
|
|
@ -241,10 +280,30 @@ func mapUsersSection(
|
|||
for i, r := range byAge {
|
||||
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))
|
||||
for i, r := range byKnowledge {
|
||||
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))
|
||||
for i, r := range byRegion {
|
||||
regions[i] = domain.AnalyticsLabelCount{Label: r.Region, Count: r.Count}
|
||||
|
|
@ -261,7 +320,12 @@ func mapUsersSection(
|
|||
ByRole: roles,
|
||||
ByStatus: statuses,
|
||||
ByAgeGroup: ages,
|
||||
ByEducationLevel: education,
|
||||
ByOccupation: occupations,
|
||||
ByLearningGoal: learningGoals,
|
||||
ByLanguageChallange: languageChallanges,
|
||||
ByKnowledgeLevel: knowledge,
|
||||
ByCountry: countries,
|
||||
ByRegion: regions,
|
||||
RegistrationsLast30Days: timePoints,
|
||||
}
|
||||
|
|
@ -435,3 +499,37 @@ func mapTeamSection(
|
|||
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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,13 @@ type analyticsQueryParams struct {
|
|||
UsersSummary dbgen.AnalyticsUsersSummaryParams
|
||||
UsersByRole dbgen.AnalyticsUsersByRoleParams
|
||||
UsersByStatus dbgen.AnalyticsUsersByStatusParams
|
||||
UsersByAgeGroup dbgen.AnalyticsUsersByAgeGroupParams
|
||||
UsersByKnowledgeLevel dbgen.AnalyticsUsersByKnowledgeLevelParams
|
||||
UsersByAgeGroup dbgen.AnalyticsUsersByAgeGroupParams
|
||||
UsersByEducationLevel dbgen.AnalyticsUsersByEducationLevelParams
|
||||
UsersByOccupation dbgen.AnalyticsUsersByOccupationParams
|
||||
UsersByLearningGoal dbgen.AnalyticsUsersByLearningGoalParams
|
||||
UsersByLanguageChallange dbgen.AnalyticsUsersByLanguageChallangeParams
|
||||
UsersByKnowledgeLevel dbgen.AnalyticsUsersByKnowledgeLevelParams
|
||||
UsersByCountry dbgen.AnalyticsUsersByCountryParams
|
||||
UsersByRegion dbgen.AnalyticsUsersByRegionParams
|
||||
UserRegistrationsSeries dbgen.AnalyticsUserRegistrationsLast30DaysParams
|
||||
|
||||
|
|
@ -42,6 +47,9 @@ type analyticsQueryParams struct {
|
|||
TeamSummary dbgen.AnalyticsTeamSummaryParams
|
||||
TeamByRole dbgen.AnalyticsTeamByRoleParams
|
||||
TeamByStatus dbgen.AnalyticsTeamByStatusParams
|
||||
|
||||
VideoEngagementSummary dbgen.AnalyticsVideoEngagementSummaryParams
|
||||
VideoDropOffByCheckpoint dbgen.AnalyticsVideoDropOffByCheckpointParams
|
||||
}
|
||||
|
||||
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},
|
||||
UsersByRole: dbgen.AnalyticsUsersByRoleParams{RangeStart: rs, RangeEnd: re},
|
||||
UsersByStatus: dbgen.AnalyticsUsersByStatusParams{RangeStart: rs, RangeEnd: re},
|
||||
UsersByAgeGroup: dbgen.AnalyticsUsersByAgeGroupParams{RangeStart: rs, RangeEnd: re},
|
||||
UsersByKnowledgeLevel: dbgen.AnalyticsUsersByKnowledgeLevelParams{RangeStart: rs, RangeEnd: re},
|
||||
UsersByAgeGroup: dbgen.AnalyticsUsersByAgeGroupParams{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},
|
||||
UserRegistrationsSeries: series,
|
||||
|
||||
|
|
@ -98,6 +111,9 @@ func newAnalyticsQueryParams(f domain.AnalyticsDateFilter) analyticsQueryParams
|
|||
TeamSummary: dbgen.AnalyticsTeamSummaryParams{RangeStart: rs, RangeEnd: re},
|
||||
TeamByRole: dbgen.AnalyticsTeamByRoleParams{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},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
|
||||
"Yimaru-Backend/internal/domain"
|
||||
"Yimaru-Backend/internal/services/chapa"
|
||||
|
|
@ -15,9 +19,10 @@ import (
|
|||
// =====================
|
||||
|
||||
type initiatePaymentReq struct {
|
||||
PlanID int64 `json:"plan_id" validate:"required"`
|
||||
Phone string `json:"phone" validate:"required"`
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
PlanID int64 `json:"plan_id" validate:"required"`
|
||||
Phone string `json:"phone" validate:"required"`
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Provider string `json:"provider" validate:"required"`
|
||||
}
|
||||
|
||||
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{
|
||||
PlanID: req.PlanID,
|
||||
Phone: req.Phone,
|
||||
Email: req.Email,
|
||||
provider, err := domain.ParsePaymentProvider(req.Provider)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
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 {
|
||||
status := fiber.StatusInternalServerError
|
||||
if errors.Is(err, chapa.ErrChapaNotConfigured) {
|
||||
status = fiber.StatusServiceUnavailable
|
||||
} else if err.Error() == "user already has an active subscription" {
|
||||
status = fiber.StatusConflict
|
||||
}
|
||||
status := paymentInitiationStatus(err)
|
||||
return c.Status(status).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to initiate payment",
|
||||
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 {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
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
|
||||
// @Summary Handle ArifPay webhook
|
||||
// @Description Processes payment notifications from ArifPay
|
||||
|
|
@ -459,3 +585,95 @@ func paymentToRes(p *domain.Payment) *paymentRes {
|
|||
|
||||
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;">✓</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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,11 @@ func (h *Handler) GoogleAndroidLogin(c *fiber.Ctx) error {
|
|||
|
||||
loginRes, err := h.authSvc.LoginWithGoogleAndroid(c.Context(), req.IDToken, h.Cfg.GoogleOAuthClientID)
|
||||
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{
|
||||
Message: "Google login failed",
|
||||
Error: err.Error(),
|
||||
|
|
|
|||
|
|
@ -59,6 +59,84 @@ func (h *Handler) CreateExamPrepCatalogCourse(c *fiber.Ctx) error {
|
|||
func (h *Handler) ListExamPrepCatalogCourses(c *fiber.Ctx) error {
|
||||
limit, _ := strconv.Atoi(c.Query("limit", "20"))
|
||||
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))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
|
|
@ -66,6 +144,12 @@ func (h *Handler) ListExamPrepCatalogCourses(c *fiber.Ctx) 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{
|
||||
Message: "Catalog courses retrieved successfully",
|
||||
Data: fiber.Map{
|
||||
|
|
@ -148,6 +232,12 @@ func (h *Handler) GetExamPrepCatalogCourseByID(c *fiber.Ctx) 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{
|
||||
Message: "Catalog course retrieved successfully",
|
||||
Data: out,
|
||||
|
|
|
|||
|
|
@ -87,6 +87,12 @@ func (h *Handler) ListExamPrepLessonsByUnitModule(c *fiber.Ctx) 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{
|
||||
Message: "Lessons retrieved successfully",
|
||||
Data: fiber.Map{
|
||||
|
|
@ -175,6 +181,12 @@ func (h *Handler) GetExamPrepLessonByID(c *fiber.Ctx) 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{
|
||||
Message: "Lesson retrieved successfully",
|
||||
Data: les,
|
||||
|
|
|
|||
|
|
@ -85,6 +85,12 @@ func (h *Handler) ListExamPrepModulesByUnit(c *fiber.Ctx) 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{
|
||||
Message: "Modules retrieved successfully",
|
||||
Data: fiber.Map{
|
||||
|
|
@ -175,6 +181,12 @@ func (h *Handler) GetExamPrepModuleByID(c *fiber.Ctx) 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{
|
||||
Message: "Module retrieved successfully",
|
||||
Data: out,
|
||||
|
|
|
|||
125
internal/web_server/handlers/exam_prep_progress_helper.go
Normal file
125
internal/web_server/handlers/exam_prep_progress_helper.go
Normal 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)
|
||||
}
|
||||
|
|
@ -92,6 +92,12 @@ func (h *Handler) ListExamPrepUnitsByCatalogCourse(c *fiber.Ctx) 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{
|
||||
Message: "Units retrieved successfully",
|
||||
Data: fiber.Map{
|
||||
|
|
@ -183,6 +189,12 @@ func (h *Handler) GetExamPrepUnitByID(c *fiber.Ctx) 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{
|
||||
Message: "Unit retrieved successfully",
|
||||
Data: out,
|
||||
|
|
|
|||
303
internal/web_server/handlers/field_option.go
Normal file
303
internal/web_server/handlers/field_option.go
Normal 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()})
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,8 @@ import (
|
|||
dbgen "Yimaru-Backend/gen/db"
|
||||
"Yimaru-Backend/internal/config"
|
||||
"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/chapa"
|
||||
"Yimaru-Backend/internal/services/assessment"
|
||||
|
|
@ -24,6 +25,7 @@ import (
|
|||
notificationservice "Yimaru-Backend/internal/services/notification"
|
||||
"Yimaru-Backend/internal/services/personas"
|
||||
"Yimaru-Backend/internal/services/practices"
|
||||
profilefieldoptions "Yimaru-Backend/internal/services/profilefieldoptions"
|
||||
"Yimaru-Backend/internal/services/programs"
|
||||
"Yimaru-Backend/internal/services/questions"
|
||||
ratingsservice "Yimaru-Backend/internal/services/ratings"
|
||||
|
|
@ -32,6 +34,7 @@ import (
|
|||
"Yimaru-Backend/internal/services/subscriptions"
|
||||
"Yimaru-Backend/internal/services/team"
|
||||
vimeoservice "Yimaru-Backend/internal/services/vimeo"
|
||||
"Yimaru-Backend/internal/services/videoengagement"
|
||||
|
||||
// referralservice "Yimaru-Backend/internal/services/referal"
|
||||
|
||||
|
|
@ -49,8 +52,10 @@ import (
|
|||
type Handler struct {
|
||||
assessmentSvc *assessment.Service
|
||||
questionsSvc *questions.Service
|
||||
faqSvc *faqs.Service
|
||||
emailTemplateSvc *emailtemplates.Service
|
||||
faqSvc *faqs.Service
|
||||
appVersionSvc *appversions.Service
|
||||
emailTemplateSvc *emailtemplates.Service
|
||||
profileFieldOptionSvc *profilefieldoptions.Service
|
||||
personaSvc *personas.Service
|
||||
examPrepSvc *examprep.Service
|
||||
programSvc *programs.Service
|
||||
|
|
@ -77,6 +82,7 @@ type Handler struct {
|
|||
minioSvc *minioservice.Service
|
||||
ratingSvc *ratingsservice.Service
|
||||
rbacSvc *rbacservice.Service
|
||||
videoEngagementSvc *videoengagement.Service
|
||||
jwtConfig jwtutil.JwtConfig
|
||||
validator *customvalidator.CustomValidator
|
||||
Cfg *config.Config
|
||||
|
|
@ -88,7 +94,9 @@ func New(
|
|||
assessmentSvc *assessment.Service,
|
||||
questionsSvc *questions.Service,
|
||||
faqSvc *faqs.Service,
|
||||
appVersionSvc *appversions.Service,
|
||||
emailTemplateSvc *emailtemplates.Service,
|
||||
profileFieldOptionSvc *profilefieldoptions.Service,
|
||||
personaSvc *personas.Service,
|
||||
examPrepSvc *examprep.Service,
|
||||
programSvc *programs.Service,
|
||||
|
|
@ -116,6 +124,7 @@ func New(
|
|||
minioSvc *minioservice.Service,
|
||||
ratingSvc *ratingsservice.Service,
|
||||
rbacSvc *rbacservice.Service,
|
||||
videoEngagementSvc *videoengagement.Service,
|
||||
jwtConfig jwtutil.JwtConfig,
|
||||
cfg *config.Config,
|
||||
mongoLoggerSvc *zap.Logger,
|
||||
|
|
@ -124,8 +133,10 @@ func New(
|
|||
return &Handler{
|
||||
assessmentSvc: assessmentSvc,
|
||||
questionsSvc: questionsSvc,
|
||||
faqSvc: faqSvc,
|
||||
emailTemplateSvc: emailTemplateSvc,
|
||||
faqSvc: faqSvc,
|
||||
appVersionSvc: appVersionSvc,
|
||||
emailTemplateSvc: emailTemplateSvc,
|
||||
profileFieldOptionSvc: profileFieldOptionSvc,
|
||||
personaSvc: personaSvc,
|
||||
examPrepSvc: examPrepSvc,
|
||||
programSvc: programSvc,
|
||||
|
|
@ -152,8 +163,9 @@ func New(
|
|||
cloudConvertSvc: cloudConvertSvc,
|
||||
minioSvc: minioSvc,
|
||||
ratingSvc: ratingSvc,
|
||||
rbacSvc: rbacSvc,
|
||||
jwtConfig: jwtConfig,
|
||||
rbacSvc: rbacSvc,
|
||||
videoEngagementSvc: videoEngagementSvc,
|
||||
jwtConfig: jwtConfig,
|
||||
Cfg: cfg,
|
||||
mongoLoggerSvc: mongoLoggerSvc,
|
||||
analyticsDB: analyticsDB,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
|
|
@ -9,9 +10,11 @@ import (
|
|||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
const lmsProgressSummaryPageSize int32 = 200
|
||||
|
||||
// GetMyLMSProgress godoc
|
||||
// @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
|
||||
// @Produce json
|
||||
// @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
|
||||
// @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).
|
||||
|
|
@ -140,3 +169,168 @@ func (h *Handler) AdminGetUserRecentActivity(c *fiber.Ctx) error {
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
390
internal/web_server/handlers/mobile_app_version_handler.go
Normal file
390
internal/web_server/handlers/mobile_app_version_handler.go
Normal 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,
|
||||
})
|
||||
}
|
||||
|
|
@ -173,6 +173,12 @@ func (h *Handler) UpdateProgram(c *fiber.Ctx) 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)
|
||||
if err != nil {
|
||||
if errors.Is(err, programs.ErrProgramNotFound) {
|
||||
|
|
|
|||
|
|
@ -1573,6 +1573,14 @@ func (h *Handler) CompletePractice(c *fiber.Ctx) error {
|
|||
})
|
||||
}
|
||||
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 {
|
||||
// Backward compatibility: also accept question_set.id directly.
|
||||
set, setErr = h.questionsSvc.GetQuestionSetByID(c.Context(), id)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package handlers
|
|||
|
||||
import (
|
||||
"Yimaru-Backend/internal/domain"
|
||||
"Yimaru-Backend/internal/services/chapa"
|
||||
subscriptionsvc "Yimaru-Backend/internal/services/subscriptions"
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
|
@ -20,6 +19,7 @@ import (
|
|||
type createPlanReq struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Description *string `json:"description"`
|
||||
Category string `json:"category" validate:"required,oneof=LEARN_ENGLISH IELTS DUOLINGO"`
|
||||
DurationValue int32 `json:"duration_value" validate:"required,min=1"`
|
||||
DurationUnit string `json:"duration_unit" validate:"required,oneof=DAY WEEK MONTH YEAR"`
|
||||
Price float64 `json:"price" validate:"required,min=0"`
|
||||
|
|
@ -30,6 +30,7 @@ type createPlanReq struct {
|
|||
type updatePlanReq struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Category *string `json:"category" validate:"omitempty,oneof=LEARN_ENGLISH IELTS DUOLINGO"`
|
||||
DurationValue *int32 `json:"duration_value"`
|
||||
DurationUnit *string `json:"duration_unit"`
|
||||
Price *float64 `json:"price"`
|
||||
|
|
@ -41,6 +42,7 @@ type planRes struct {
|
|||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Category string `json:"category"`
|
||||
DurationValue int32 `json:"duration_value"`
|
||||
DurationUnit string `json:"duration_unit"`
|
||||
Price float64 `json:"price"`
|
||||
|
|
@ -60,9 +62,10 @@ type subscribeReq struct {
|
|||
}
|
||||
|
||||
type subscribeWithPaymentReq struct {
|
||||
PlanID int64 `json:"plan_id" validate:"required"`
|
||||
Phone string `json:"phone" validate:"required"`
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
PlanID int64 `json:"plan_id" validate:"required"`
|
||||
Phone string `json:"phone" validate:"required"`
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Provider string `json:"provider" validate:"required"`
|
||||
}
|
||||
|
||||
type subscriptionRes struct {
|
||||
|
|
@ -110,10 +113,17 @@ func (h *Handler) CreateSubscriptionPlan(c *fiber.Ctx) 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{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Category: req.Category,
|
||||
DurationValue: req.DurationValue,
|
||||
DurationUnit: req.DurationUnit,
|
||||
Price: req.Price,
|
||||
|
|
@ -228,10 +238,17 @@ func (h *Handler) UpdateSubscriptionPlan(c *fiber.Ctx) 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{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Category: req.Category,
|
||||
DurationValue: req.DurationValue,
|
||||
DurationUnit: req.DurationUnit,
|
||||
Price: req.Price,
|
||||
|
|
@ -381,21 +398,22 @@ func (h *Handler) SubscribeWithPayment(c *fiber.Ctx) error {
|
|||
})
|
||||
}
|
||||
|
||||
// Use ArifPay service to initiate payment
|
||||
result, err := h.chapaSvc.InitiateSubscriptionPayment(c.Context(), userID, domain.InitiateSubscriptionPaymentRequest{
|
||||
PlanID: req.PlanID,
|
||||
Phone: req.Phone,
|
||||
Email: req.Email,
|
||||
provider, err := domain.ParsePaymentProvider(req.Provider)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
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 {
|
||||
status := fiber.StatusInternalServerError
|
||||
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
|
||||
}
|
||||
status := paymentInitiationStatus(err)
|
||||
return c.Status(status).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to initiate subscription payment",
|
||||
Error: err.Error(),
|
||||
|
|
@ -622,6 +640,7 @@ func planToRes(p *domain.SubscriptionPlan) *planRes {
|
|||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
Description: p.Description,
|
||||
Category: p.Category,
|
||||
DurationValue: p.DurationValue,
|
||||
DurationUnit: p.DurationUnit,
|
||||
Price: p.Price,
|
||||
|
|
|
|||
|
|
@ -31,12 +31,16 @@ type listTeamInvitationsRes struct {
|
|||
}
|
||||
|
||||
func mapTeamInvitationListItem(row domain.TeamInvitationWithMember) teamInvitationListItem {
|
||||
firstName, lastName := row.FirstName, row.LastName
|
||||
if domain.IsTeamInvitePlaceholderProfile(firstName, lastName) {
|
||||
firstName, lastName = "", ""
|
||||
}
|
||||
return teamInvitationListItem{
|
||||
ID: row.ID,
|
||||
TeamMemberID: row.TeamMemberID,
|
||||
Email: row.Email,
|
||||
FirstName: row.FirstName,
|
||||
LastName: row.LastName,
|
||||
FirstName: firstName,
|
||||
LastName: lastName,
|
||||
TeamRole: string(row.TeamRole),
|
||||
Status: string(row.Status),
|
||||
ExpiresAt: row.ExpiresAt.Format(time.RFC3339),
|
||||
|
|
@ -46,7 +50,7 @@ func mapTeamInvitationListItem(row domain.TeamInvitationWithMember) teamInvitati
|
|||
|
||||
// InviteTeamMember godoc
|
||||
// @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
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
|
|
@ -236,8 +240,8 @@ func (h *Handler) VerifyTeamInvitation(c *fiber.Ctx) error {
|
|||
}
|
||||
|
||||
// AcceptTeamInvitation godoc
|
||||
// @Summary Accept team invitation and set password
|
||||
// @Description Public endpoint to activate a team member account after following the invite link
|
||||
// @Summary Accept team invitation and complete account setup
|
||||
// @Description Public endpoint to set password and profile details after following the invite link
|
||||
// @Tags team
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
|
|
|
|||
106
internal/web_server/handlers/video_engagement_handler.go
Normal file
106
internal/web_server/handlers/video_engagement_handler.go
Normal 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,
|
||||
})
|
||||
}
|
||||
|
|
@ -2,9 +2,12 @@ package httpserver
|
|||
|
||||
import (
|
||||
"Yimaru-Backend/internal/domain"
|
||||
examprepsvc "Yimaru-Backend/internal/services/examprep"
|
||||
jwtutil "Yimaru-Backend/internal/web_server/jwt"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -12,6 +15,8 @@ import (
|
|||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var categorySubscriptionGateDisabled = true
|
||||
|
||||
func (a *App) authMiddleware(c *fiber.Ctx) error {
|
||||
ip := c.IP()
|
||||
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 {
|
||||
return func(c *fiber.Ctx) error {
|
||||
userRole, ok := c.Locals("role").(domain.Role)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ func (a *App) initAppRoutes() {
|
|||
a.assessmentSvc,
|
||||
a.questionsSvc,
|
||||
a.faqSvc,
|
||||
a.appVersionSvc,
|
||||
a.emailTemplateSvc,
|
||||
a.profileFieldOptionSvc,
|
||||
a.personaSvc,
|
||||
a.examPrepSvc,
|
||||
a.programSvc,
|
||||
|
|
@ -44,6 +46,7 @@ func (a *App) initAppRoutes() {
|
|||
a.minioSvc,
|
||||
a.ratingSvc,
|
||||
a.rbacSvc,
|
||||
a.videoEngagementSvc,
|
||||
a.JwtConfig,
|
||||
a.cfg,
|
||||
a.mongoLoggerSvc,
|
||||
|
|
@ -79,15 +82,16 @@ func (a *App) initAppRoutes() {
|
|||
|
||||
// Programs (LMS top-level)
|
||||
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.Get("/lms/progress", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lms.get_my_progress"), h.GetMyLMSProgress)
|
||||
groupV1.Get("/programs/:id", a.authMiddleware, a.RequirePermission("programs.get"), h.GetProgram)
|
||||
groupV1.Get("/lms/progress", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("lms.get_my_progress"), h.GetMyLMSProgress)
|
||||
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.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.
|
||||
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.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)
|
||||
|
|
@ -126,31 +130,32 @@ func (a *App) initAppRoutes() {
|
|||
// Courses
|
||||
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.Get("/programs/:id/courses", a.authMiddleware, a.RequireActiveSubscription(), 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", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("courses.get"), h.GetCourse)
|
||||
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.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("practices.list"), h.ListPracticesByCourse)
|
||||
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.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.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
|
||||
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/:id/practices", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("practices.list"), h.ListPracticesByModule)
|
||||
groupV1.Get("/modules/:id", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("modules.get"), h.GetModule)
|
||||
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.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("practices.list"), h.ListPracticesByModule)
|
||||
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.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.Post("/lessons/:id/complete", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lessons.complete"), h.CompleteLesson)
|
||||
groupV1.Post("/progress/practices/:id/complete", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("progress.complete"), h.CompletePractice)
|
||||
groupV1.Get("/lessons/:id", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lessons.get"), h.GetLesson)
|
||||
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.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("lessons.complete"), h.CompleteLesson)
|
||||
groupV1.Post("/videos/engagement/heartbeat", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("videos.track_engagement"), h.RecordVideoEngagementHeartbeat)
|
||||
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.Delete("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.delete"), h.DeleteLesson)
|
||||
|
||||
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.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.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
|
||||
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)
|
||||
|
|
@ -207,6 +220,15 @@ func (a *App) initAppRoutes() {
|
|||
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)
|
||||
|
||||
// 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
|
||||
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)
|
||||
|
|
@ -218,7 +240,7 @@ func (a *App) initAppRoutes() {
|
|||
// Question Set Items
|
||||
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("/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.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.Post("/payments/:id/cancel", a.authMiddleware, a.RequirePermission("payments.cancel"), h.CancelPayment)
|
||||
groupV1.Post("/payments/webhook", h.HandleChapaWebhook)
|
||||
groupV1.Get("/payments/arifpay/success", h.HandleArifpaySuccessPage)
|
||||
groupV1.Get("/payments/chapa/callback", h.HandleChapaCallback)
|
||||
|
||||
// Direct Payments
|
||||
|
|
|
|||
848
postman/Mobile-App-Versions.postman_collection.json
Normal file
848
postman/Mobile-App-Versions.postman_collection.json
Normal 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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user