Compare commits

...

16 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -43,32 +43,47 @@ type TeamInvitationWithMember struct {
TeamRole TeamRole
}
const (
TeamInvitePlaceholderFirstName = "Pending"
TeamInvitePlaceholderLastName = "Invite"
)
func IsTeamInvitePlaceholderProfile(firstName, lastName string) bool {
return firstName == TeamInvitePlaceholderFirstName && lastName == TeamInvitePlaceholderLastName
}
// InviteTeamMemberReq only requires email and role; profile fields are collected on accept.
type InviteTeamMemberReq struct {
FirstName string `json:"first_name" validate:"required"`
LastName string `json:"last_name" validate:"required"`
Email string `json:"email" validate:"required,email"`
PhoneNumber string `json:"phone_number"`
TeamRole string `json:"team_role" validate:"required"`
Department string `json:"department"`
JobTitle string `json:"job_title"`
EmploymentType string `json:"employment_type"`
HireDate string `json:"hire_date"`
Permissions []string `json:"permissions"`
Email string `json:"email" validate:"required,email"`
TeamRole string `json:"team_role" validate:"required"`
}
type AcceptTeamInvitationReq struct {
Token string `json:"token" validate:"required"`
Password string `json:"password" validate:"required,min=8"`
FirstName string `json:"first_name" validate:"required"`
LastName string `json:"last_name" validate:"required"`
PhoneNumber string `json:"phone_number"`
Department string `json:"department"`
JobTitle string `json:"job_title"`
EmploymentType string `json:"employment_type"`
HireDate string `json:"hire_date"` // YYYY-MM-DD
ProfilePictureURL string `json:"profile_picture_url"`
Bio string `json:"bio"`
WorkPhone string `json:"work_phone"`
EmergencyContact string `json:"emergency_contact"`
}
type VerifyTeamInvitationRes struct {
Valid bool `json:"valid"`
Email string `json:"email,omitempty"`
FirstName string `json:"first_name,omitempty"`
LastName string `json:"last_name,omitempty"`
TeamRole string `json:"team_role,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
Status string `json:"status,omitempty"`
Valid bool `json:"valid"`
Email string `json:"email,omitempty"`
TeamRole string `json:"team_role,omitempty"`
NeedsProfileSetup bool `json:"needs_profile_setup,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
Status string `json:"status,omitempty"`
}
type InviteTeamMemberRes struct {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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": []
}
]
}
]
}