From a719c0dacae2b27a0646650f24d2ef47d2db6591 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Mon, 25 May 2026 06:52:20 -0700 Subject: [PATCH] Add mobile app version management and refresh profile field seeds. Introduce admin CRUD and public version check APIs for Play Store/App Store releases with force or optional update policies, and update profile dropdown seed data for countries, regions, and learner profile fields. Co-authored-by: Cursor --- cmd/main.go | 3 + .../000069_profile_field_options.up.sql | 242 +++-- ...seed_ethiopia_regions_field_options.up.sql | 30 +- .../000074_mobile_app_versions.down.sql | 1 + .../000074_mobile_app_versions.up.sql | 19 + internal/domain/mobile_app_version.go | 62 ++ internal/ports/mobile_app_version.go | 15 + internal/repository/mobile_app_versions.go | 228 +++++ internal/services/appversions/service.go | 237 +++++ internal/services/rbac/seeds.go | 10 + internal/web_server/app.go | 6 +- internal/web_server/handlers/handlers.go | 6 +- .../handlers/mobile_app_version_handler.go | 390 ++++++++ internal/web_server/routes.go | 9 + ...obile-App-Versions.postman_collection.json | 848 ++++++++++++++++++ 15 files changed, 2030 insertions(+), 76 deletions(-) create mode 100644 db/migrations/000074_mobile_app_versions.down.sql create mode 100644 db/migrations/000074_mobile_app_versions.up.sql create mode 100644 internal/domain/mobile_app_version.go create mode 100644 internal/ports/mobile_app_version.go create mode 100644 internal/repository/mobile_app_versions.go create mode 100644 internal/services/appversions/service.go create mode 100644 internal/web_server/handlers/mobile_app_version_handler.go create mode 100644 postman/Mobile-App-Versions.postman_collection.json diff --git a/cmd/main.go b/cmd/main.go index 6153469..9c0ce32 100644 --- a/cmd/main.go +++ b/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" @@ -400,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) @@ -480,6 +482,7 @@ func main() { assessmentSvc, questionsSvc, faqSvc, + appVersionSvc, emailTemplateSvc, profileFieldOptionSvc, personasSvc, diff --git a/db/migrations/000069_profile_field_options.up.sql b/db/migrations/000069_profile_field_options.up.sql index d2db48f..b68f876 100644 --- a/db/migrations/000069_profile_field_options.up.sql +++ b/db/migrations/000069_profile_field_options.up.sql @@ -26,13 +26,13 @@ INSERT INTO field_options (field_key, code, label, display_order, status) VALUES ('education_level', 'DOCTORATE', 'Doctorate', 8, 'ACTIVE'), ('education_level', 'OTHER', 'Other', 99, 'ACTIVE'), - ('occupation', 'STUDENT', 'Student', 1, 'ACTIVE'), - ('occupation', 'EMPLOYED', 'Employed', 2, 'ACTIVE'), - ('occupation', 'SELF_EMPLOYED', 'Self-employed', 3, 'ACTIVE'), - ('occupation', 'UNEMPLOYED', 'Unemployed', 4, 'ACTIVE'), - ('occupation', 'HOMEMAKER', 'Homemaker', 5, 'ACTIVE'), - ('occupation', 'RETIRED', 'Retired', 6, 'ACTIVE'), - ('occupation', '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'), @@ -51,64 +51,186 @@ INSERT INTO field_options (field_key, code, label, display_order, status) VALUES ('learning_goal', 'OTHER', 'Other', 99, 'ACTIVE'), ('language_challange', 'PRONUNCIATION', 'Pronunciation', 1, 'ACTIVE'), - ('language_challange', 'GRAMMAR', 'Grammar', 2, 'ACTIVE'), - ('language_challange', 'VOCABULARY', 'Vocabulary', 3, 'ACTIVE'), - ('language_challange', 'LISTENING', 'Listening', 4, 'ACTIVE'), - ('language_challange', 'SPEAKING', 'Speaking confidence', 5, 'ACTIVE'), - ('language_challange', 'WRITING', 'Writing', 6, 'ACTIVE'), - ('language_challange', 'READING', 'Reading', 7, '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', 'BASIC', 'Basic communication', 1, 'ACTIVE'), - ('language_goal', 'CONVERSATIONAL', 'Conversational fluency', 2, 'ACTIVE'), - ('language_goal', 'PROFESSIONAL', 'Professional proficiency', 3, 'ACTIVE'), - ('language_goal', 'ACADEMIC', 'Academic proficiency', 4, 'ACTIVE'), - ('language_goal', 'NATIVE_LIKE', 'Near-native fluency', 5, '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', 'BUSINESS', 'Business', 1, 'ACTIVE'), - ('favourite_topic', 'TECHNOLOGY', 'Technology', 2, 'ACTIVE'), - ('favourite_topic', 'HEALTH', 'Health', 3, 'ACTIVE'), - ('favourite_topic', 'CULTURE', 'Culture', 4, 'ACTIVE'), - ('favourite_topic', 'TRAVEL', 'Travel', 5, 'ACTIVE'), - ('favourite_topic', 'ENTERTAINMENT', 'Entertainment', 6, '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', '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'), + ('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', 'DIRE_DAWA', 'Dire Dawa', 2, 'ACTIVE'), - ('ethiopia_regions', 'TIGRAY', 'Tigray', 3, 'ACTIVE'), - ('ethiopia_regions', 'AFAR', 'Afar', 4, 'ACTIVE'), - ('ethiopia_regions', 'AMHARA', 'Amhara', 5, 'ACTIVE'), - ('ethiopia_regions', 'OROMIA', 'Oromia', 6, 'ACTIVE'), - ('ethiopia_regions', 'SOMALI', 'Somali', 7, 'ACTIVE'), - ('ethiopia_regions', 'BENISHANGUL_GUMUZ', 'Benishangul-Gumuz', 8, 'ACTIVE'), - ('ethiopia_regions', 'GAMBELA', 'Gambela', 9, 'ACTIVE'), - ('ethiopia_regions', 'HARARI', 'Harari', 10, 'ACTIVE'), - ('ethiopia_regions', 'SIDAMA', 'Sidama', 11, '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', 'South West Ethiopia', 13, 'ACTIVE'), - ('ethiopia_regions', 'CENTRAL_ETHIOPIA', 'Central Ethiopia', 14, 'ACTIVE'), - ('ethiopia_regions', 'OTHER', 'Other', 99, 'ACTIVE'); + ('ethiopia_regions', 'SOUTH_WEST_ETHIOPIA_PEOPLES', 'South West Ethiopia Peoples', 13, 'ACTIVE'), + ('ethiopia_regions', 'TIGRAY', 'Tigray', 14, 'ACTIVE'); diff --git a/db/migrations/000072_seed_ethiopia_regions_field_options.up.sql b/db/migrations/000072_seed_ethiopia_regions_field_options.up.sql index c753c4b..0e98815 100644 --- a/db/migrations/000072_seed_ethiopia_regions_field_options.up.sql +++ b/db/migrations/000072_seed_ethiopia_regions_field_options.up.sql @@ -1,17 +1,19 @@ INSERT INTO field_options (field_key, code, label, display_order, status) VALUES ('ethiopia_regions', 'ADDIS_ABABA', 'Addis Ababa', 1, 'ACTIVE'), - ('ethiopia_regions', 'DIRE_DAWA', 'Dire Dawa', 2, 'ACTIVE'), - ('ethiopia_regions', 'TIGRAY', 'Tigray', 3, 'ACTIVE'), - ('ethiopia_regions', 'AFAR', 'Afar', 4, 'ACTIVE'), - ('ethiopia_regions', 'AMHARA', 'Amhara', 5, 'ACTIVE'), - ('ethiopia_regions', 'OROMIA', 'Oromia', 6, 'ACTIVE'), - ('ethiopia_regions', 'SOMALI', 'Somali', 7, 'ACTIVE'), - ('ethiopia_regions', 'BENISHANGUL_GUMUZ', 'Benishangul-Gumuz', 8, 'ACTIVE'), - ('ethiopia_regions', 'GAMBELA', 'Gambela', 9, 'ACTIVE'), - ('ethiopia_regions', 'HARARI', 'Harari', 10, 'ACTIVE'), - ('ethiopia_regions', 'SIDAMA', 'Sidama', 11, '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', 'South West Ethiopia', 13, 'ACTIVE'), - ('ethiopia_regions', 'CENTRAL_ETHIOPIA', 'Central Ethiopia', 14, 'ACTIVE'), - ('ethiopia_regions', 'OTHER', 'Other', 99, 'ACTIVE') -ON CONFLICT (field_key, code) DO NOTHING; + ('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; diff --git a/db/migrations/000074_mobile_app_versions.down.sql b/db/migrations/000074_mobile_app_versions.down.sql new file mode 100644 index 0000000..f3d7804 --- /dev/null +++ b/db/migrations/000074_mobile_app_versions.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS mobile_app_versions; diff --git a/db/migrations/000074_mobile_app_versions.up.sql b/db/migrations/000074_mobile_app_versions.up.sql new file mode 100644 index 0000000..e0dd134 --- /dev/null +++ b/db/migrations/000074_mobile_app_versions.up.sql @@ -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); diff --git a/internal/domain/mobile_app_version.go b/internal/domain/mobile_app_version.go new file mode 100644 index 0000000..be3e515 --- /dev/null +++ b/internal/domain/mobile_app_version.go @@ -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"` +} diff --git a/internal/ports/mobile_app_version.go b/internal/ports/mobile_app_version.go new file mode 100644 index 0000000..aa6830b --- /dev/null +++ b/internal/ports/mobile_app_version.go @@ -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) +} diff --git a/internal/repository/mobile_app_versions.go b/internal/repository/mobile_app_versions.go new file mode 100644 index 0000000..23ad1e3 --- /dev/null +++ b/internal/repository/mobile_app_versions.go @@ -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) +} diff --git a/internal/services/appversions/service.go b/internal/services/appversions/service.go new file mode 100644 index 0000000..1b0e508 --- /dev/null +++ b/internal/services/appversions/service.go @@ -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 +} diff --git a/internal/services/rbac/seeds.go b/internal/services/rbac/seeds.go index 707a49b..d7917b7 100644 --- a/internal/services/rbac/seeds.go +++ b/internal/services/rbac/seeds.go @@ -247,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"}, @@ -468,6 +475,9 @@ 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", diff --git a/internal/web_server/app.go b/internal/web_server/app.go index 69ee93c..c1722c9 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -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" @@ -53,6 +54,7 @@ type App struct { assessmentSvc *assessment.Service questionsSvc *questions.Service faqSvc *faqs.Service + appVersionSvc *appversions.Service emailTemplateSvc *emailtemplates.Service profileFieldOptionSvc *profilefieldoptions.Service personaSvc *personas.Service @@ -97,6 +99,7 @@ func NewApp( assessmentSvc *assessment.Service, questionsSvc *questions.Service, faqSvc *faqs.Service, + appVersionSvc *appversions.Service, emailTemplateSvc *emailtemplates.Service, profileFieldOptionSvc *profilefieldoptions.Service, personaSvc *personas.Service, @@ -153,6 +156,7 @@ func NewApp( assessmentSvc: assessmentSvc, questionsSvc: questionsSvc, faqSvc: faqSvc, + appVersionSvc: appVersionSvc, emailTemplateSvc: emailTemplateSvc, profileFieldOptionSvc: profileFieldOptionSvc, personaSvc: personaSvc, diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index b81c7da..9b1a00a 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -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" @@ -52,6 +53,7 @@ type Handler struct { assessmentSvc *assessment.Service questionsSvc *questions.Service faqSvc *faqs.Service + appVersionSvc *appversions.Service emailTemplateSvc *emailtemplates.Service profileFieldOptionSvc *profilefieldoptions.Service personaSvc *personas.Service @@ -92,6 +94,7 @@ func New( assessmentSvc *assessment.Service, questionsSvc *questions.Service, faqSvc *faqs.Service, + appVersionSvc *appversions.Service, emailTemplateSvc *emailtemplates.Service, profileFieldOptionSvc *profilefieldoptions.Service, personaSvc *personas.Service, @@ -131,6 +134,7 @@ func New( assessmentSvc: assessmentSvc, questionsSvc: questionsSvc, faqSvc: faqSvc, + appVersionSvc: appVersionSvc, emailTemplateSvc: emailTemplateSvc, profileFieldOptionSvc: profileFieldOptionSvc, personaSvc: personaSvc, diff --git a/internal/web_server/handlers/mobile_app_version_handler.go b/internal/web_server/handlers/mobile_app_version_handler.go new file mode 100644 index 0000000..df3bbba --- /dev/null +++ b/internal/web_server/handlers/mobile_app_version_handler.go @@ -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, + }) +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 37dea7c..88463f9 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -16,6 +16,7 @@ func (a *App) initAppRoutes() { a.assessmentSvc, a.questionsSvc, a.faqSvc, + a.appVersionSvc, a.emailTemplateSvc, a.profileFieldOptionSvc, a.personaSvc, @@ -200,6 +201,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) diff --git a/postman/Mobile-App-Versions.postman_collection.json b/postman/Mobile-App-Versions.postman_collection.json new file mode 100644 index 0000000..c60d610 --- /dev/null +++ b/postman/Mobile-App-Versions.postman_collection.json @@ -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": [] + } + ] + } + ] +}