Add mobile app version management and refresh profile field seeds.

Introduce admin CRUD and public version check APIs for Play Store/App Store releases with force or optional update policies, and update profile dropdown seed data for countries, regions, and learner profile fields.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-05-25 06:52:20 -07:00
parent 3f73afb4bf
commit a719c0daca
15 changed files with 2030 additions and 76 deletions

View File

@ -14,6 +14,7 @@ import (
"Yimaru-Backend/internal/repository" "Yimaru-Backend/internal/repository"
activitylogservice "Yimaru-Backend/internal/services/activity_log" activitylogservice "Yimaru-Backend/internal/services/activity_log"
"Yimaru-Backend/internal/services/arifpay" "Yimaru-Backend/internal/services/arifpay"
"Yimaru-Backend/internal/services/appversions"
"Yimaru-Backend/internal/services/chapa" "Yimaru-Backend/internal/services/chapa"
"Yimaru-Backend/internal/services/assessment" "Yimaru-Backend/internal/services/assessment"
"Yimaru-Backend/internal/services/authentication" "Yimaru-Backend/internal/services/authentication"
@ -400,6 +401,7 @@ func main() {
// Questions service (unified questions system) // Questions service (unified questions system)
questionsSvc := questions.NewService(store) questionsSvc := questions.NewService(store)
faqSvc := faqs.NewService(repository.NewFAQStore(store)) faqSvc := faqs.NewService(repository.NewFAQStore(store))
appVersionSvc := appversions.NewService(repository.NewMobileAppVersionStore(store))
personasSvc := personasservice.NewService(store) personasSvc := personasservice.NewService(store)
examPrepSvc := examprep.NewService(store) examPrepSvc := examprep.NewService(store)
@ -480,6 +482,7 @@ func main() {
assessmentSvc, assessmentSvc,
questionsSvc, questionsSvc,
faqSvc, faqSvc,
appVersionSvc,
emailTemplateSvc, emailTemplateSvc,
profileFieldOptionSvc, profileFieldOptionSvc,
personasSvc, personasSvc,

View File

@ -26,13 +26,13 @@ INSERT INTO field_options (field_key, code, label, display_order, status) VALUES
('education_level', 'DOCTORATE', 'Doctorate', 8, 'ACTIVE'), ('education_level', 'DOCTORATE', 'Doctorate', 8, 'ACTIVE'),
('education_level', 'OTHER', 'Other', 99, 'ACTIVE'), ('education_level', 'OTHER', 'Other', 99, 'ACTIVE'),
('occupation', 'STUDENT', 'Student', 1, 'ACTIVE'), ('occupation', 'STUDENTS', 'Students (High school & University)', 1, 'ACTIVE'),
('occupation', 'EMPLOYED', 'Employed', 2, 'ACTIVE'), ('occupation', 'JOB_SEEKERS', 'Job Seekers / Fresh Graduates', 2, 'ACTIVE'),
('occupation', 'SELF_EMPLOYED', 'Self-employed', 3, 'ACTIVE'), ('occupation', 'WORKING_PROFESSIONALS', 'Working Professionals (Corporate/Office)', 3, 'ACTIVE'),
('occupation', 'UNEMPLOYED', 'Unemployed', 4, 'ACTIVE'), ('occupation', 'GOVERNMENT_NGO', 'Government & NGO Workers', 4, 'ACTIVE'),
('occupation', 'HOMEMAKER', 'Homemaker', 5, 'ACTIVE'), ('occupation', 'ENTREPRENEURS', 'Entrepreneurs & Small Business Owners', 5, 'ACTIVE'),
('occupation', 'RETIRED', 'Retired', 6, 'ACTIVE'), ('occupation', 'HOSPITALITY_TOURISM', 'Hospitality & Tourism Workers', 6, 'ACTIVE'),
('occupation', 'OTHER', 'Other', 99, 'ACTIVE'), ('occupation', 'FREELANCERS_REMOTE', 'Freelancers / Remote Workers (Digital Economy)', 7, 'ACTIVE'),
('age_group', 'UNDER_13', 'Under 13', 1, 'ACTIVE'), ('age_group', 'UNDER_13', 'Under 13', 1, 'ACTIVE'),
('age_group', '13_17', '1317', 2, 'ACTIVE'), ('age_group', '13_17', '1317', 2, 'ACTIVE'),
@ -51,64 +51,186 @@ INSERT INTO field_options (field_key, code, label, display_order, status) VALUES
('learning_goal', 'OTHER', 'Other', 99, 'ACTIVE'), ('learning_goal', 'OTHER', 'Other', 99, 'ACTIVE'),
('language_challange', 'PRONUNCIATION', 'Pronunciation', 1, 'ACTIVE'), ('language_challange', 'PRONUNCIATION', 'Pronunciation', 1, 'ACTIVE'),
('language_challange', 'GRAMMAR', 'Grammar', 2, 'ACTIVE'), ('language_challange', 'WORDS_GRAMMAR', 'Finding words or grammar quickly', 2, 'ACTIVE'),
('language_challange', 'VOCABULARY', 'Vocabulary', 3, 'ACTIVE'), ('language_challange', 'CONFIDENCE', 'Feeling nervous or lacking confidence', 3, 'ACTIVE'),
('language_challange', 'LISTENING', 'Listening', 4, 'ACTIVE'), ('language_challange', 'ACCENTS_FAST_SPEECH', 'Understanding accents or fast speech', 4, 'ACTIVE'),
('language_challange', 'SPEAKING', 'Speaking confidence', 5, 'ACTIVE'),
('language_challange', 'WRITING', 'Writing', 6, 'ACTIVE'),
('language_challange', 'READING', 'Reading', 7, 'ACTIVE'),
('language_challange', 'OTHER', 'Other', 99, 'ACTIVE'), ('language_challange', 'OTHER', 'Other', 99, 'ACTIVE'),
('language_goal', 'BASIC', 'Basic communication', 1, 'ACTIVE'), ('language_goal', 'SPEAK_CONFIDENTLY', 'Speak confidently at work or school', 1, 'ACTIVE'),
('language_goal', 'CONVERSATIONAL', 'Conversational fluency', 2, 'ACTIVE'), ('language_goal', 'TRAVEL_DAILY', 'Travel or handle daily situations', 2, 'ACTIVE'),
('language_goal', 'PROFESSIONAL', 'Professional proficiency', 3, 'ACTIVE'), ('language_goal', 'FAMILY_FRIENDS', 'Connect with family or friends', 3, 'ACTIVE'),
('language_goal', 'ACADEMIC', 'Academic proficiency', 4, 'ACTIVE'), ('language_goal', 'GENERAL_SKILLS', 'General skills expansion', 4, 'ACTIVE'),
('language_goal', 'NATIVE_LIKE', 'Near-native fluency', 5, 'ACTIVE'), ('language_goal', 'OTHER', 'Other', 99, 'ACTIVE'),
('favourite_topic', 'BUSINESS', 'Business', 1, 'ACTIVE'), ('favourite_topic', 'FOOD_COOKING', 'Food & Cooking', 1, 'ACTIVE'),
('favourite_topic', 'TECHNOLOGY', 'Technology', 2, 'ACTIVE'), ('favourite_topic', 'HOBBIES_SPORTS_MUSIC', 'Hobbies, Sports, Music', 2, 'ACTIVE'),
('favourite_topic', 'HEALTH', 'Health', 3, 'ACTIVE'), ('favourite_topic', 'TECH_NEWS_BUSINESS', 'Tech, News, Business', 3, 'ACTIVE'),
('favourite_topic', 'CULTURE', 'Culture', 4, 'ACTIVE'), ('favourite_topic', 'TRAVEL_PLACES_CULTURE', 'Travel, Places, Culture', 4, 'ACTIVE'),
('favourite_topic', 'TRAVEL', 'Travel', 5, 'ACTIVE'),
('favourite_topic', 'ENTERTAINMENT', 'Entertainment', 6, 'ACTIVE'),
('favourite_topic', 'OTHER', 'Other', 99, 'ACTIVE'), ('favourite_topic', 'OTHER', 'Other', 99, 'ACTIVE'),
('country', 'ET', 'Ethiopia', 1, 'ACTIVE'), ('country', 'AF', 'Afghanistan', 1, 'ACTIVE'),
('country', 'ER', 'Eritrea', 2, 'ACTIVE'), ('country', 'AL', 'Albania', 2, 'ACTIVE'),
('country', 'DJ', 'Djibouti', 3, 'ACTIVE'), ('country', 'DZ', 'Algeria', 3, 'ACTIVE'),
('country', 'SO', 'Somalia', 4, 'ACTIVE'), ('country', 'AD', 'Andorra', 4, 'ACTIVE'),
('country', 'KE', 'Kenya', 5, 'ACTIVE'), ('country', 'AO', 'Angola', 5, 'ACTIVE'),
('country', 'SD', 'Sudan', 6, 'ACTIVE'), ('country', 'AR', 'Argentina', 6, 'ACTIVE'),
('country', 'SS', 'South Sudan', 7, 'ACTIVE'), ('country', 'AM', 'Armenia', 7, 'ACTIVE'),
('country', 'UG', 'Uganda', 8, 'ACTIVE'), ('country', 'AU', 'Australia', 8, 'ACTIVE'),
('country', 'RW', 'Rwanda', 9, 'ACTIVE'), ('country', 'AT', 'Austria', 9, 'ACTIVE'),
('country', 'TZ', 'Tanzania', 10, 'ACTIVE'), ('country', 'AZ', 'Azerbaijan', 10, 'ACTIVE'),
('country', 'EG', 'Egypt', 11, 'ACTIVE'), ('country', 'BH', 'Bahrain', 11, 'ACTIVE'),
('country', 'NG', 'Nigeria', 12, 'ACTIVE'), ('country', 'BD', 'Bangladesh', 12, 'ACTIVE'),
('country', 'ZA', 'South Africa', 13, 'ACTIVE'), ('country', 'BY', 'Belarus', 13, 'ACTIVE'),
('country', 'US', 'United States', 20, 'ACTIVE'), ('country', 'BE', 'Belgium', 14, 'ACTIVE'),
('country', 'GB', 'United Kingdom', 21, 'ACTIVE'), ('country', 'BZ', 'Belize', 15, 'ACTIVE'),
('country', 'CA', 'Canada', 22, 'ACTIVE'), ('country', 'BJ', 'Benin', 16, 'ACTIVE'),
('country', 'DE', 'Germany', 23, 'ACTIVE'), ('country', 'BT', 'Bhutan', 17, 'ACTIVE'),
('country', 'FR', 'France', 24, 'ACTIVE'), ('country', 'BO', 'Bolivia', 18, 'ACTIVE'),
('country', 'IN', 'India', 25, 'ACTIVE'), ('country', 'BA', 'Bosnia and Herzegovina', 19, 'ACTIVE'),
('country', 'CN', 'China', 26, 'ACTIVE'), ('country', 'BW', 'Botswana', 20, 'ACTIVE'),
('country', 'SA', 'Saudi Arabia', 27, 'ACTIVE'), ('country', 'BR', 'Brazil', 21, 'ACTIVE'),
('country', 'AE', 'United Arab Emirates', 28, 'ACTIVE'), ('country', 'BN', 'Brunei', 22, 'ACTIVE'),
('country', 'OTHER', 'Other', 99, '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', 'ADDIS_ABABA', 'Addis Ababa', 1, 'ACTIVE'),
('ethiopia_regions', 'DIRE_DAWA', 'Dire Dawa', 2, 'ACTIVE'), ('ethiopia_regions', 'AFAR', 'Afar', 2, 'ACTIVE'),
('ethiopia_regions', 'TIGRAY', 'Tigray', 3, 'ACTIVE'), ('ethiopia_regions', 'AMHARA', 'Amhara', 3, 'ACTIVE'),
('ethiopia_regions', 'AFAR', 'Afar', 4, 'ACTIVE'), ('ethiopia_regions', 'BENISHANGUL_GUMUZ', 'Benishangul-Gumuz', 4, 'ACTIVE'),
('ethiopia_regions', 'AMHARA', 'Amhara', 5, 'ACTIVE'), ('ethiopia_regions', 'CENTRAL_ETHIOPIA', 'Central Ethiopia', 5, 'ACTIVE'),
('ethiopia_regions', 'OROMIA', 'Oromia', 6, 'ACTIVE'), ('ethiopia_regions', 'DIRE_DAWA', 'Dire Dawa', 6, 'ACTIVE'),
('ethiopia_regions', 'SOMALI', 'Somali', 7, 'ACTIVE'), ('ethiopia_regions', 'GAMBELA', 'Gambela', 7, 'ACTIVE'),
('ethiopia_regions', 'BENISHANGUL_GUMUZ', 'Benishangul-Gumuz', 8, 'ACTIVE'), ('ethiopia_regions', 'HARARI', 'Harari', 8, 'ACTIVE'),
('ethiopia_regions', 'GAMBELA', 'Gambela', 9, 'ACTIVE'), ('ethiopia_regions', 'OROMIA', 'Oromia', 9, 'ACTIVE'),
('ethiopia_regions', 'HARARI', 'Harari', 10, 'ACTIVE'), ('ethiopia_regions', 'SIDAMA', 'Sidama', 10, 'ACTIVE'),
('ethiopia_regions', 'SIDAMA', 'Sidama', 11, 'ACTIVE'), ('ethiopia_regions', 'SOMALI', 'Somali', 11, 'ACTIVE'),
('ethiopia_regions', 'SOUTH_ETHIOPIA', 'South Ethiopia', 12, 'ACTIVE'), ('ethiopia_regions', 'SOUTH_ETHIOPIA', 'South Ethiopia', 12, 'ACTIVE'),
('ethiopia_regions', 'SOUTH_WEST_ETHIOPIA', 'South West Ethiopia', 13, 'ACTIVE'), ('ethiopia_regions', 'SOUTH_WEST_ETHIOPIA_PEOPLES', 'South West Ethiopia Peoples', 13, 'ACTIVE'),
('ethiopia_regions', 'CENTRAL_ETHIOPIA', 'Central Ethiopia', 14, 'ACTIVE'), ('ethiopia_regions', 'TIGRAY', 'Tigray', 14, 'ACTIVE');
('ethiopia_regions', 'OTHER', 'Other', 99, 'ACTIVE');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -247,6 +247,13 @@ var AllPermissions = []domain.PermissionSeed{
{Key: "faqs.update", Name: "Update FAQ", Description: "Update a FAQ item", GroupName: "FAQs"}, {Key: "faqs.update", Name: "Update FAQ", Description: "Update a FAQ item", GroupName: "FAQs"},
{Key: "faqs.delete", Name: "Delete FAQ", Description: "Delete a FAQ item", GroupName: "FAQs"}, {Key: "faqs.delete", Name: "Delete FAQ", Description: "Delete a FAQ item", GroupName: "FAQs"},
// Mobile app versions
{Key: "app_versions.create", Name: "Create App Version", Description: "Create a mobile app version release", GroupName: "App Versions"},
{Key: "app_versions.list", Name: "List App Versions", Description: "List mobile app versions for admin management", GroupName: "App Versions"},
{Key: "app_versions.get", Name: "Get App Version", Description: "Get mobile app version by ID", GroupName: "App Versions"},
{Key: "app_versions.update", Name: "Update App Version", Description: "Update a mobile app version release", GroupName: "App Versions"},
{Key: "app_versions.delete", Name: "Delete App Version", Description: "Delete a mobile app version release", GroupName: "App Versions"},
// Email templates // Email templates
{Key: "email_templates.create", Name: "Create Email Template", Description: "Create an email template", GroupName: "Email Templates"}, {Key: "email_templates.create", Name: "Create Email Template", Description: "Create an email template", GroupName: "Email Templates"},
{Key: "email_templates.list", Name: "List Email Templates", Description: "List email templates for admin management", GroupName: "Email Templates"}, {Key: "email_templates.list", Name: "List Email Templates", Description: "List email templates for admin management", GroupName: "Email Templates"},
@ -468,6 +475,9 @@ var DefaultRolePermissions = map[string][]string{
// FAQs // FAQs
"faqs.create", "faqs.list", "faqs.get", "faqs.update", "faqs.delete", "faqs.create", "faqs.list", "faqs.get", "faqs.update", "faqs.delete",
// Mobile app versions
"app_versions.create", "app_versions.list", "app_versions.get", "app_versions.update", "app_versions.delete",
// Email templates // Email templates
"email_templates.create", "email_templates.list", "email_templates.get", "email_templates.update", "email_templates.delete", "email_templates.preview", "email_templates.create", "email_templates.list", "email_templates.get", "email_templates.update", "email_templates.delete", "email_templates.preview",

View File

@ -3,7 +3,8 @@ package httpserver
import ( import (
dbgen "Yimaru-Backend/gen/db" dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/config" "Yimaru-Backend/internal/config"
activitylogservice "Yimaru-Backend/internal/services/activity_log" activitylogservice "Yimaru-Backend/internal/services/activity_log"
"Yimaru-Backend/internal/services/appversions"
"Yimaru-Backend/internal/services/arifpay" "Yimaru-Backend/internal/services/arifpay"
"Yimaru-Backend/internal/services/chapa" "Yimaru-Backend/internal/services/chapa"
"Yimaru-Backend/internal/services/assessment" "Yimaru-Backend/internal/services/assessment"
@ -53,6 +54,7 @@ type App struct {
assessmentSvc *assessment.Service assessmentSvc *assessment.Service
questionsSvc *questions.Service questionsSvc *questions.Service
faqSvc *faqs.Service faqSvc *faqs.Service
appVersionSvc *appversions.Service
emailTemplateSvc *emailtemplates.Service emailTemplateSvc *emailtemplates.Service
profileFieldOptionSvc *profilefieldoptions.Service profileFieldOptionSvc *profilefieldoptions.Service
personaSvc *personas.Service personaSvc *personas.Service
@ -97,6 +99,7 @@ func NewApp(
assessmentSvc *assessment.Service, assessmentSvc *assessment.Service,
questionsSvc *questions.Service, questionsSvc *questions.Service,
faqSvc *faqs.Service, faqSvc *faqs.Service,
appVersionSvc *appversions.Service,
emailTemplateSvc *emailtemplates.Service, emailTemplateSvc *emailtemplates.Service,
profileFieldOptionSvc *profilefieldoptions.Service, profileFieldOptionSvc *profilefieldoptions.Service,
personaSvc *personas.Service, personaSvc *personas.Service,
@ -153,6 +156,7 @@ func NewApp(
assessmentSvc: assessmentSvc, assessmentSvc: assessmentSvc,
questionsSvc: questionsSvc, questionsSvc: questionsSvc,
faqSvc: faqSvc, faqSvc: faqSvc,
appVersionSvc: appVersionSvc,
emailTemplateSvc: emailTemplateSvc, emailTemplateSvc: emailTemplateSvc,
profileFieldOptionSvc: profileFieldOptionSvc, profileFieldOptionSvc: profileFieldOptionSvc,
personaSvc: personaSvc, personaSvc: personaSvc,

View File

@ -6,7 +6,8 @@ import (
dbgen "Yimaru-Backend/gen/db" dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/config" "Yimaru-Backend/internal/config"
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
activitylogservice "Yimaru-Backend/internal/services/activity_log" activitylogservice "Yimaru-Backend/internal/services/activity_log"
"Yimaru-Backend/internal/services/appversions"
"Yimaru-Backend/internal/services/arifpay" "Yimaru-Backend/internal/services/arifpay"
"Yimaru-Backend/internal/services/chapa" "Yimaru-Backend/internal/services/chapa"
"Yimaru-Backend/internal/services/assessment" "Yimaru-Backend/internal/services/assessment"
@ -52,6 +53,7 @@ type Handler struct {
assessmentSvc *assessment.Service assessmentSvc *assessment.Service
questionsSvc *questions.Service questionsSvc *questions.Service
faqSvc *faqs.Service faqSvc *faqs.Service
appVersionSvc *appversions.Service
emailTemplateSvc *emailtemplates.Service emailTemplateSvc *emailtemplates.Service
profileFieldOptionSvc *profilefieldoptions.Service profileFieldOptionSvc *profilefieldoptions.Service
personaSvc *personas.Service personaSvc *personas.Service
@ -92,6 +94,7 @@ func New(
assessmentSvc *assessment.Service, assessmentSvc *assessment.Service,
questionsSvc *questions.Service, questionsSvc *questions.Service,
faqSvc *faqs.Service, faqSvc *faqs.Service,
appVersionSvc *appversions.Service,
emailTemplateSvc *emailtemplates.Service, emailTemplateSvc *emailtemplates.Service,
profileFieldOptionSvc *profilefieldoptions.Service, profileFieldOptionSvc *profilefieldoptions.Service,
personaSvc *personas.Service, personaSvc *personas.Service,
@ -131,6 +134,7 @@ func New(
assessmentSvc: assessmentSvc, assessmentSvc: assessmentSvc,
questionsSvc: questionsSvc, questionsSvc: questionsSvc,
faqSvc: faqSvc, faqSvc: faqSvc,
appVersionSvc: appVersionSvc,
emailTemplateSvc: emailTemplateSvc, emailTemplateSvc: emailTemplateSvc,
profileFieldOptionSvc: profileFieldOptionSvc, profileFieldOptionSvc: profileFieldOptionSvc,
personaSvc: personaSvc, personaSvc: personaSvc,

View File

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

View File

@ -16,6 +16,7 @@ func (a *App) initAppRoutes() {
a.assessmentSvc, a.assessmentSvc,
a.questionsSvc, a.questionsSvc,
a.faqSvc, a.faqSvc,
a.appVersionSvc,
a.emailTemplateSvc, a.emailTemplateSvc,
a.profileFieldOptionSvc, a.profileFieldOptionSvc,
a.personaSvc, a.personaSvc,
@ -200,6 +201,14 @@ func (a *App) initAppRoutes() {
groupV1.Put("/admin/faqs/:id", a.authMiddleware, a.RequirePermission("faqs.update"), h.UpdateFAQ) groupV1.Put("/admin/faqs/:id", a.authMiddleware, a.RequirePermission("faqs.update"), h.UpdateFAQ)
groupV1.Delete("/admin/faqs/:id", a.authMiddleware, a.RequirePermission("faqs.delete"), h.DeleteFAQ) groupV1.Delete("/admin/faqs/:id", a.authMiddleware, a.RequirePermission("faqs.delete"), h.DeleteFAQ)
// Mobile app versions (Play Store / App Store)
groupV1.Get("/app/version/check", h.CheckMobileAppVersion)
groupV1.Get("/admin/app-versions", a.authMiddleware, a.RequirePermission("app_versions.list"), h.ListMobileAppVersionsAdmin)
groupV1.Get("/admin/app-versions/:id", a.authMiddleware, a.RequirePermission("app_versions.get"), h.GetMobileAppVersionByIDAdmin)
groupV1.Post("/admin/app-versions", a.authMiddleware, a.RequirePermission("app_versions.create"), h.CreateMobileAppVersion)
groupV1.Put("/admin/app-versions/:id", a.authMiddleware, a.RequirePermission("app_versions.update"), h.UpdateMobileAppVersion)
groupV1.Delete("/admin/app-versions/:id", a.authMiddleware, a.RequirePermission("app_versions.delete"), h.DeleteMobileAppVersion)
// Email templates // Email templates
groupV1.Get("/admin/email-templates", a.authMiddleware, a.RequirePermission("email_templates.list"), h.ListEmailTemplatesAdmin) groupV1.Get("/admin/email-templates", a.authMiddleware, a.RequirePermission("email_templates.list"), h.ListEmailTemplatesAdmin)
groupV1.Get("/admin/email-templates/slug/:slug", a.authMiddleware, a.RequirePermission("email_templates.get"), h.GetEmailTemplateBySlugAdmin) groupV1.Get("/admin/email-templates/slug/:slug", a.authMiddleware, a.RequirePermission("email_templates.get"), h.GetEmailTemplateBySlugAdmin)

View File

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