Compare commits
28 Commits
main
...
production
| Author | SHA1 | Date | |
|---|---|---|---|
| 685e1d104f | |||
| bb03ee1668 | |||
| b5b9ef03b5 | |||
| ab986a08f0 | |||
| 33355a4b23 | |||
| 08a2886654 | |||
| 2605877f12 | |||
| a75700ffaa | |||
| 256183ae64 | |||
| a83745fd93 | |||
| 632371c3d0 | |||
| c00ab684c5 | |||
| fbad083ca4 | |||
| 6423bb261e | |||
| d3bbd8c95a | |||
| ed743cf841 | |||
| 038df4e3db | |||
| 71e605a07a | |||
| cd4e3b7811 | |||
| 853bd730bb | |||
| d225b45166 | |||
| 408cd3fd7d | |||
| fc67de935d | |||
| c77a97b40d | |||
| ffbb885d06 | |||
| 474bf3282a | |||
| 8eaac9206e | |||
| 2e1f9432f6 |
123
cmd/main.go
123
cmd/main.go
|
|
@ -133,33 +133,6 @@ func main() {
|
||||||
)
|
)
|
||||||
|
|
||||||
authSvc.InitGoogleOAuth(cfg.GoogleOAuthClientID, cfg.GoogleOAuthClientSecret, cfg.GoogleOAuthRedirectURL)
|
authSvc.InitGoogleOAuth(cfg.GoogleOAuthClientID, cfg.GoogleOAuthClientSecret, cfg.GoogleOAuthRedirectURL)
|
||||||
// leagueSvc := league.New(repository.NewLeagueStore(store))
|
|
||||||
// eventSvc := event.New(
|
|
||||||
// cfg.Bet365Token,
|
|
||||||
// repository.NewEventStore(store),
|
|
||||||
// repository.NewEventHistoryStore(store),
|
|
||||||
// *leagueSvc,
|
|
||||||
// settingSvc,
|
|
||||||
// domain.MongoDBLogger,
|
|
||||||
// cfg,
|
|
||||||
// )
|
|
||||||
|
|
||||||
// marketSettingRepo := repository.NewMarketSettingStore(store)
|
|
||||||
|
|
||||||
// if err := marketSettingRepo.EnsureAllMarketSettingsExist(context.Background()); err != nil {
|
|
||||||
// log.Fatalf("failed to ensure market settings: %v", err)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// oddsSvc := odds.New(
|
|
||||||
// repository.NewOddStore(store),
|
|
||||||
// marketSettingRepo,
|
|
||||||
// cfg,
|
|
||||||
// eventSvc,
|
|
||||||
// logger,
|
|
||||||
// domain.MongoDBLogger,
|
|
||||||
// )
|
|
||||||
|
|
||||||
// virtuaGamesRepo := repository.NewVirtualGameRepository(store)
|
|
||||||
|
|
||||||
// Initialize producer
|
// Initialize producer
|
||||||
// topic := "wallet-balance-topic"
|
// topic := "wallet-balance-topic"
|
||||||
|
|
@ -174,85 +147,8 @@ func main() {
|
||||||
userSvc,
|
userSvc,
|
||||||
)
|
)
|
||||||
|
|
||||||
// / := wallet.NewService(
|
|
||||||
// repository.NewWalletStore(store),
|
|
||||||
// repository.NewTransferStore(store),
|
|
||||||
// // repository.NewDirectDepositStore(store),
|
|
||||||
// notificationSvc,
|
|
||||||
// userSvc,
|
|
||||||
// domain.MongoDBLogger,
|
|
||||||
// logger,
|
|
||||||
// )
|
|
||||||
|
|
||||||
// branchSvc := branch.NewService(repository.NewBranchStore(store))
|
|
||||||
// companySvc := company.NewService(repository.NewCompanyStore(store))
|
|
||||||
|
|
||||||
// ticketSvc := ticke.NewService(
|
|
||||||
// repository.NewTicketStore(store),
|
|
||||||
// // eventSvc,
|
|
||||||
// // *oddsSvc,
|
|
||||||
// domain.MongoDBLogger,
|
|
||||||
// settingSvc,
|
|
||||||
// )
|
|
||||||
// betSvc := bet.NewService(
|
|
||||||
// repository.NewBetStore(store),
|
|
||||||
// eventSvc,
|
|
||||||
// *oddsSvc,
|
|
||||||
// ,
|
|
||||||
// *branchSvc,
|
|
||||||
// *companySvc,
|
|
||||||
// *settingSvc,
|
|
||||||
// *userSvc,
|
|
||||||
// notificationSvc,
|
|
||||||
// logger,
|
|
||||||
// domain.MongoDBLogger,
|
|
||||||
// )
|
|
||||||
|
|
||||||
// resultSvc := result.NewService(
|
|
||||||
// repository.NewResultLogStore(store),
|
|
||||||
// cfg,
|
|
||||||
// logger,
|
|
||||||
// domain.MongoDBLogger,
|
|
||||||
// // *betSvc,
|
|
||||||
// // *oddsSvc,
|
|
||||||
// // eventSvc,
|
|
||||||
// // leagueSvc,
|
|
||||||
// notificationSvc,
|
|
||||||
// messengerSvc,
|
|
||||||
// *userSvc,
|
|
||||||
// )
|
|
||||||
|
|
||||||
// bonusSvc := bonus.NewService(
|
|
||||||
// repository.NewBonusStore(store),
|
|
||||||
// settingSvc,
|
|
||||||
// notificationSvc,
|
|
||||||
// domain.MongoDBLogger,
|
|
||||||
// )
|
|
||||||
// virtualGamesRepo := repository.NewVirtualGameRepository(store)
|
|
||||||
recommendationRepo := repository.NewRecommendationRepository(store)
|
recommendationRepo := repository.NewRecommendationRepository(store)
|
||||||
|
|
||||||
// referalSvc := referralservice.New(
|
|
||||||
// repository.NewReferralStore(store),
|
|
||||||
// *settingSvc,
|
|
||||||
// cfg,
|
|
||||||
// logger,
|
|
||||||
// domain.MongoDBLogger,
|
|
||||||
// )
|
|
||||||
// raffleSvc := raffle.NewService(
|
|
||||||
// repository.NewRaffleStore(store),
|
|
||||||
// )
|
|
||||||
// virtualGameSvc := virtualgameservice.New(virtualGamesRepo,, store, cfg, logger)
|
|
||||||
// aleaService := alea.NewAleaPlayService(virtualGamesRepo,, cfg, logger)
|
|
||||||
// veliCLient := veli.NewClient(cfg)
|
|
||||||
// veliVirtualGameService := veli.New(virtualGameSvc, virtualGamesRepo, *store, veliCLient, repository.NewTransferStore(store), domain.MongoDBLogger, cfg)
|
|
||||||
// orchestrationSvc := orchestration.New(
|
|
||||||
// virtualGameSvc,
|
|
||||||
// virtualGamesRepo,
|
|
||||||
// cfg,
|
|
||||||
// veliCLient,
|
|
||||||
// )
|
|
||||||
// atlasClient := atlas.NewClient(cfg)
|
|
||||||
// atlasVirtualGameService := atlas.New(virtualGameSvc, virtualGamesRepo, atlasClient, repository.NewTransferStore(store), cfg)
|
|
||||||
recommendationSvc := recommendation.NewService(recommendationRepo)
|
recommendationSvc := recommendation.NewService(recommendationRepo)
|
||||||
// chapaClient := chapa.NewClient(cfg.CHAPA_BASE_URL, cfg.CHAPA_SECRET_KEY)
|
// chapaClient := chapa.NewClient(cfg.CHAPA_BASE_URL, cfg.CHAPA_SECRET_KEY)
|
||||||
|
|
||||||
|
|
@ -291,15 +187,6 @@ func main() {
|
||||||
// cfg,
|
// cfg,
|
||||||
// )
|
// )
|
||||||
|
|
||||||
// enePulseSvc := enetpulse.New(
|
|
||||||
// *cfg,
|
|
||||||
// store,
|
|
||||||
// )
|
|
||||||
|
|
||||||
// go httpserver.StartEnetPulseCron(enePulseSvc, domain.MongoDBLogger)
|
|
||||||
// go httpserver.SetupReportandVirtualGameCronJobs(context.Background(), reportSvc, orchestrationSvc, "C:/Users/User/Desktop")
|
|
||||||
// go httpserver.ProcessBetCashback(context.TODO(), betSvc)
|
|
||||||
|
|
||||||
// bankRepository := repository.NewBankRepository(store)
|
// bankRepository := repository.NewBankRepository(store)
|
||||||
// instSvc := institutions.New(bankRepository)
|
// instSvc := institutions.New(bankRepository)
|
||||||
// Initialize report worker with CSV exporter
|
// Initialize report worker with CSV exporter
|
||||||
|
|
@ -320,11 +207,6 @@ func main() {
|
||||||
// userSvc,
|
// userSvc,
|
||||||
// )
|
// )
|
||||||
|
|
||||||
// enetPulseSvc := enetpulse.New(
|
|
||||||
// *cfg,
|
|
||||||
// store,
|
|
||||||
// )
|
|
||||||
|
|
||||||
// Initialize wallet monitoring service
|
// Initialize wallet monitoring service
|
||||||
// walletMonitorSvc := monitor.NewService(
|
// walletMonitorSvc := monitor.NewService(
|
||||||
// ,
|
// ,
|
||||||
|
|
@ -454,11 +336,6 @@ func main() {
|
||||||
cfg.TeamInviteExpiry,
|
cfg.TeamInviteExpiry,
|
||||||
)
|
)
|
||||||
|
|
||||||
// santimpayClient := santimpay.NewSantimPayClient(cfg)
|
|
||||||
|
|
||||||
// santimpaySvc := santimpay.NewSantimPayService(santimpayClient, cfg, transferStore)
|
|
||||||
// telebirrSvc := telebirr.NewTelebirrService(cfg, transferStore)
|
|
||||||
|
|
||||||
// Activity Log service
|
// Activity Log service
|
||||||
activityLogSvc := activitylogservice.NewService(store, domain.MongoDBLogger)
|
activityLogSvc := activitylogservice.NewService(store, domain.MongoDBLogger)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,11 +56,11 @@ INSERT INTO field_options (field_key, code, label, display_order, status) VALUES
|
||||||
('language_challange', 'ACCENTS_FAST_SPEECH', 'Understanding accents or fast speech', 4, 'ACTIVE'),
|
('language_challange', 'ACCENTS_FAST_SPEECH', 'Understanding accents or fast speech', 4, 'ACTIVE'),
|
||||||
('language_challange', 'OTHER', 'Other', 99, 'ACTIVE'),
|
('language_challange', 'OTHER', 'Other', 99, 'ACTIVE'),
|
||||||
|
|
||||||
('language_goal', 'SPEAK_CONFIDENTLY', 'Speak confidently at work or school', 1, 'ACTIVE'),
|
('language_goal', 'LEARN_TO_SPEAK_ENGLISH', 'Learn to Speak English', 1, 'ACTIVE'),
|
||||||
('language_goal', 'TRAVEL_DAILY', 'Travel or handle daily situations', 2, 'ACTIVE'),
|
('language_goal', 'PRACTICE_TO_SPEAK_ENGLISH', 'Practice Speaking English', 2, 'ACTIVE'),
|
||||||
('language_goal', 'FAMILY_FRIENDS', 'Connect with family or friends', 3, 'ACTIVE'),
|
('language_goal', 'SKILL_BASED_COURSES', 'Skill-based Courses', 3, 'ACTIVE'),
|
||||||
('language_goal', 'GENERAL_SKILLS', 'General skills expansion', 4, 'ACTIVE'),
|
-- ('language_goal', 'GENERAL_SKILLS', 'General skills expansion', 4, 'ACTIVE'),
|
||||||
('language_goal', 'OTHER', 'Other', 99, 'ACTIVE'),
|
-- ('language_goal', 'OTHER', 'Other', 99, 'ACTIVE'),
|
||||||
|
|
||||||
('favourite_topic', 'FOOD_COOKING', 'Food & Cooking', 1, 'ACTIVE'),
|
('favourite_topic', 'FOOD_COOKING', 'Food & Cooking', 1, 'ACTIVE'),
|
||||||
('favourite_topic', 'HOBBIES_SPORTS_MUSIC', 'Hobbies, Sports, Music', 2, 'ACTIVE'),
|
('favourite_topic', 'HOBBIES_SPORTS_MUSIC', 'Hobbies, Sports, Music', 2, 'ACTIVE'),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE question_sets
|
||||||
|
ALTER COLUMN status SET DEFAULT 'DRAFT';
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
-- Publish existing non-archived question sets and default future ones to PUBLISHED.
|
||||||
|
UPDATE question_sets
|
||||||
|
SET status = 'PUBLISHED',
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE status IN ('DRAFT', 'INACTIVE');
|
||||||
|
|
||||||
|
ALTER TABLE question_sets
|
||||||
|
ALTER COLUMN status SET DEFAULT 'PUBLISHED';
|
||||||
5
db/migrations/000076_apple_sign_in.down.sql
Normal file
5
db/migrations/000076_apple_sign_in.down.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
DROP INDEX IF EXISTS idx_users_apple_id;
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
DROP COLUMN IF EXISTS apple_id,
|
||||||
|
DROP COLUMN IF EXISTS apple_email_verified;
|
||||||
7
db/migrations/000076_apple_sign_in.up.sql
Normal file
7
db/migrations/000076_apple_sign_in.up.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS apple_id VARCHAR(255),
|
||||||
|
ADD COLUMN IF NOT EXISTS apple_email_verified BOOLEAN DEFAULT FALSE;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_apple_id
|
||||||
|
ON users (apple_id)
|
||||||
|
WHERE apple_id IS NOT NULL;
|
||||||
|
|
@ -1,11 +1,23 @@
|
||||||
-- name: GetPreviousProgram :one
|
-- name: GetPreviousProgram :one
|
||||||
|
-- Immediate predecessor by sort_order within the same category (gaps in sort_order are allowed).
|
||||||
SELECT
|
SELECT
|
||||||
p2.*
|
p2.*
|
||||||
FROM
|
FROM
|
||||||
programs AS p1
|
programs AS p1
|
||||||
INNER JOIN programs AS p2 ON p2.sort_order = p1.sort_order - 1
|
INNER JOIN programs AS p2 ON p2.category = p1.category
|
||||||
|
AND (
|
||||||
|
p2.sort_order < p1.sort_order
|
||||||
|
OR (
|
||||||
|
p2.sort_order = p1.sort_order
|
||||||
|
AND p2.id < p1.id
|
||||||
|
)
|
||||||
|
)
|
||||||
WHERE
|
WHERE
|
||||||
p1.id = $1;
|
p1.id = $1
|
||||||
|
ORDER BY
|
||||||
|
p2.sort_order DESC,
|
||||||
|
p2.id DESC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
-- name: GetPreviousCourseInProgram :one
|
-- name: GetPreviousCourseInProgram :one
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -13,9 +25,19 @@ SELECT
|
||||||
FROM
|
FROM
|
||||||
courses AS c1
|
courses AS c1
|
||||||
INNER JOIN courses AS c2 ON c2.program_id = c1.program_id
|
INNER JOIN courses AS c2 ON c2.program_id = c1.program_id
|
||||||
AND c2.sort_order = c1.sort_order - 1
|
AND (
|
||||||
|
c2.sort_order < c1.sort_order
|
||||||
|
OR (
|
||||||
|
c2.sort_order = c1.sort_order
|
||||||
|
AND c2.id < c1.id
|
||||||
|
)
|
||||||
|
)
|
||||||
WHERE
|
WHERE
|
||||||
c1.id = $1;
|
c1.id = $1
|
||||||
|
ORDER BY
|
||||||
|
c2.sort_order DESC,
|
||||||
|
c2.id DESC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
-- name: GetPreviousModuleInCourse :one
|
-- name: GetPreviousModuleInCourse :one
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -23,9 +45,19 @@ SELECT
|
||||||
FROM
|
FROM
|
||||||
modules AS m1
|
modules AS m1
|
||||||
INNER JOIN modules AS m2 ON m2.course_id = m1.course_id
|
INNER JOIN modules AS m2 ON m2.course_id = m1.course_id
|
||||||
AND m2.sort_order = m1.sort_order - 1
|
AND (
|
||||||
|
m2.sort_order < m1.sort_order
|
||||||
|
OR (
|
||||||
|
m2.sort_order = m1.sort_order
|
||||||
|
AND m2.id < m1.id
|
||||||
|
)
|
||||||
|
)
|
||||||
WHERE
|
WHERE
|
||||||
m1.id = $1;
|
m1.id = $1
|
||||||
|
ORDER BY
|
||||||
|
m2.sort_order DESC,
|
||||||
|
m2.id DESC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
-- name: GetPreviousLessonInModule :one
|
-- name: GetPreviousLessonInModule :one
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -570,6 +602,62 @@ WHERE
|
||||||
AND qs.status = 'PUBLISHED'
|
AND qs.status = 'PUBLISHED'
|
||||||
AND lp.publish_status = 'PUBLISHED';
|
AND lp.publish_status = 'PUBLISHED';
|
||||||
|
|
||||||
|
-- Published practices directly attached to module_id (not via lesson_id).
|
||||||
|
-- name: CountPublishedDirectPracticesInModule :one
|
||||||
|
SELECT
|
||||||
|
count(*)::int AS n
|
||||||
|
FROM
|
||||||
|
lms_practices lp
|
||||||
|
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||||
|
WHERE
|
||||||
|
lp.module_id = $1
|
||||||
|
AND qs.set_type = 'PRACTICE'
|
||||||
|
AND qs.status = 'PUBLISHED'
|
||||||
|
AND lp.publish_status = 'PUBLISHED';
|
||||||
|
|
||||||
|
-- name: CountUserCompletedPublishedDirectPracticesInModule :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.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 lp.publish_status = 'PUBLISHED';
|
||||||
|
|
||||||
|
-- Published practices directly attached to course_id (not via module_id/lesson_id).
|
||||||
|
-- name: CountPublishedDirectPracticesInCourse :one
|
||||||
|
SELECT
|
||||||
|
count(*)::int AS n
|
||||||
|
FROM
|
||||||
|
lms_practices lp
|
||||||
|
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||||
|
WHERE
|
||||||
|
lp.course_id = $1
|
||||||
|
AND qs.set_type = 'PRACTICE'
|
||||||
|
AND qs.status = 'PUBLISHED'
|
||||||
|
AND lp.publish_status = 'PUBLISHED';
|
||||||
|
|
||||||
|
-- name: CountUserCompletedPublishedDirectPracticesInCourse :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.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';
|
||||||
|
|
||||||
-- name: GetPracticeScopeByQuestionSetID :one
|
-- name: GetPracticeScopeByQuestionSetID :one
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
|
|
|
||||||
|
|
@ -46,22 +46,22 @@ WHERE id = $2;
|
||||||
-- name: UpdatePaymentStatusBySessionID :exec
|
-- name: UpdatePaymentStatusBySessionID :exec
|
||||||
UPDATE payments
|
UPDATE payments
|
||||||
SET
|
SET
|
||||||
status = $1,
|
status = sqlc.arg(status)::varchar,
|
||||||
transaction_id = COALESCE($2, transaction_id),
|
transaction_id = COALESCE(sqlc.arg(transaction_id)::text, transaction_id),
|
||||||
payment_method = COALESCE($3, payment_method),
|
payment_method = COALESCE(sqlc.arg(payment_method)::text, payment_method),
|
||||||
paid_at = CASE WHEN $1 = 'SUCCESS' THEN CURRENT_TIMESTAMP ELSE paid_at END,
|
paid_at = CASE WHEN sqlc.arg(status)::varchar = 'SUCCESS' THEN CURRENT_TIMESTAMP ELSE paid_at END,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE session_id = $4;
|
WHERE session_id = sqlc.arg(session_id)::text;
|
||||||
|
|
||||||
-- name: UpdatePaymentStatusByNonce :exec
|
-- name: UpdatePaymentStatusByNonce :exec
|
||||||
UPDATE payments
|
UPDATE payments
|
||||||
SET
|
SET
|
||||||
status = $1,
|
status = sqlc.arg(status)::varchar,
|
||||||
transaction_id = COALESCE($2, transaction_id),
|
transaction_id = COALESCE(sqlc.arg(transaction_id)::text, transaction_id),
|
||||||
payment_method = COALESCE($3, payment_method),
|
payment_method = COALESCE(sqlc.arg(payment_method)::text, payment_method),
|
||||||
paid_at = CASE WHEN $1 = 'SUCCESS' THEN CURRENT_TIMESTAMP ELSE paid_at END,
|
paid_at = CASE WHEN sqlc.arg(status)::varchar = 'SUCCESS' THEN CURRENT_TIMESTAMP ELSE paid_at END,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE nonce = $4;
|
WHERE nonce = sqlc.arg(nonce)::text;
|
||||||
|
|
||||||
-- name: UpdatePaymentSessionID :exec
|
-- name: UpdatePaymentSessionID :exec
|
||||||
UPDATE payments
|
UPDATE payments
|
||||||
|
|
@ -93,3 +93,77 @@ WHERE id = $1;
|
||||||
|
|
||||||
-- name: CountUserPayments :one
|
-- name: CountUserPayments :one
|
||||||
SELECT COUNT(*) FROM payments WHERE user_id = $1;
|
SELECT COUNT(*) FROM payments WHERE user_id = $1;
|
||||||
|
|
||||||
|
-- name: ListPaymentsAdmin :many
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
p.user_id,
|
||||||
|
p.plan_id,
|
||||||
|
p.subscription_id,
|
||||||
|
p.session_id,
|
||||||
|
p.transaction_id,
|
||||||
|
p.nonce,
|
||||||
|
p.amount,
|
||||||
|
p.currency,
|
||||||
|
p.payment_method,
|
||||||
|
p.status,
|
||||||
|
p.payment_url,
|
||||||
|
p.paid_at,
|
||||||
|
p.expires_at,
|
||||||
|
p.created_at,
|
||||||
|
p.updated_at,
|
||||||
|
sp.name AS plan_name,
|
||||||
|
sp.category AS plan_category,
|
||||||
|
u.email AS user_email,
|
||||||
|
u.first_name AS user_first_name,
|
||||||
|
u.last_name AS user_last_name
|
||||||
|
FROM payments p
|
||||||
|
LEFT JOIN subscription_plans sp ON sp.id = p.plan_id
|
||||||
|
LEFT JOIN users u ON u.id = p.user_id
|
||||||
|
WHERE (sqlc.narg('payment_id')::bigint IS NULL OR p.id = sqlc.narg('payment_id')::bigint)
|
||||||
|
AND (sqlc.narg('user_id')::bigint IS NULL OR p.user_id = sqlc.narg('user_id')::bigint)
|
||||||
|
AND (sqlc.narg('plan_id')::bigint IS NULL OR p.plan_id = sqlc.narg('plan_id')::bigint)
|
||||||
|
AND (sqlc.narg('subscription_id')::bigint IS NULL OR p.subscription_id = sqlc.narg('subscription_id')::bigint)
|
||||||
|
AND (sqlc.narg('status')::varchar IS NULL OR p.status = sqlc.narg('status')::varchar)
|
||||||
|
AND (sqlc.narg('payment_method')::varchar IS NULL OR UPPER(COALESCE(p.payment_method, '')) = UPPER(sqlc.narg('payment_method')::varchar))
|
||||||
|
AND (sqlc.narg('currency')::varchar IS NULL OR UPPER(p.currency) = UPPER(sqlc.narg('currency')::varchar))
|
||||||
|
AND (sqlc.narg('plan_category')::varchar IS NULL OR sp.category = sqlc.narg('plan_category')::varchar)
|
||||||
|
AND (sqlc.narg('created_from')::timestamptz IS NULL OR p.created_at >= sqlc.narg('created_from')::timestamptz)
|
||||||
|
AND (sqlc.narg('created_to')::timestamptz IS NULL OR p.created_at < sqlc.narg('created_to')::timestamptz)
|
||||||
|
AND (sqlc.narg('paid_from')::timestamptz IS NULL OR p.paid_at >= sqlc.narg('paid_from')::timestamptz)
|
||||||
|
AND (sqlc.narg('paid_to')::timestamptz IS NULL OR p.paid_at < sqlc.narg('paid_to')::timestamptz)
|
||||||
|
AND (sqlc.narg('min_amount')::numeric IS NULL OR p.amount >= sqlc.narg('min_amount')::numeric)
|
||||||
|
AND (sqlc.narg('max_amount')::numeric IS NULL OR p.amount <= sqlc.narg('max_amount')::numeric)
|
||||||
|
AND (
|
||||||
|
sqlc.narg('reference')::text IS NULL
|
||||||
|
OR p.session_id ILIKE '%' || sqlc.narg('reference')::text || '%'
|
||||||
|
OR p.nonce ILIKE '%' || sqlc.narg('reference')::text || '%'
|
||||||
|
OR p.transaction_id ILIKE '%' || sqlc.narg('reference')::text || '%'
|
||||||
|
)
|
||||||
|
ORDER BY p.created_at DESC
|
||||||
|
LIMIT sqlc.arg('limit') OFFSET sqlc.arg('offset');
|
||||||
|
|
||||||
|
-- name: CountPaymentsAdmin :one
|
||||||
|
SELECT COUNT(*)::bigint AS total
|
||||||
|
FROM payments p
|
||||||
|
LEFT JOIN subscription_plans sp ON sp.id = p.plan_id
|
||||||
|
WHERE (sqlc.narg('payment_id')::bigint IS NULL OR p.id = sqlc.narg('payment_id')::bigint)
|
||||||
|
AND (sqlc.narg('user_id')::bigint IS NULL OR p.user_id = sqlc.narg('user_id')::bigint)
|
||||||
|
AND (sqlc.narg('plan_id')::bigint IS NULL OR p.plan_id = sqlc.narg('plan_id')::bigint)
|
||||||
|
AND (sqlc.narg('subscription_id')::bigint IS NULL OR p.subscription_id = sqlc.narg('subscription_id')::bigint)
|
||||||
|
AND (sqlc.narg('status')::varchar IS NULL OR p.status = sqlc.narg('status')::varchar)
|
||||||
|
AND (sqlc.narg('payment_method')::varchar IS NULL OR UPPER(COALESCE(p.payment_method, '')) = UPPER(sqlc.narg('payment_method')::varchar))
|
||||||
|
AND (sqlc.narg('currency')::varchar IS NULL OR UPPER(p.currency) = UPPER(sqlc.narg('currency')::varchar))
|
||||||
|
AND (sqlc.narg('plan_category')::varchar IS NULL OR sp.category = sqlc.narg('plan_category')::varchar)
|
||||||
|
AND (sqlc.narg('created_from')::timestamptz IS NULL OR p.created_at >= sqlc.narg('created_from')::timestamptz)
|
||||||
|
AND (sqlc.narg('created_to')::timestamptz IS NULL OR p.created_at < sqlc.narg('created_to')::timestamptz)
|
||||||
|
AND (sqlc.narg('paid_from')::timestamptz IS NULL OR p.paid_at >= sqlc.narg('paid_from')::timestamptz)
|
||||||
|
AND (sqlc.narg('paid_to')::timestamptz IS NULL OR p.paid_at < sqlc.narg('paid_to')::timestamptz)
|
||||||
|
AND (sqlc.narg('min_amount')::numeric IS NULL OR p.amount >= sqlc.narg('min_amount')::numeric)
|
||||||
|
AND (sqlc.narg('max_amount')::numeric IS NULL OR p.amount <= sqlc.narg('max_amount')::numeric)
|
||||||
|
AND (
|
||||||
|
sqlc.narg('reference')::text IS NULL
|
||||||
|
OR p.session_id ILIKE '%' || sqlc.narg('reference')::text || '%'
|
||||||
|
OR p.nonce ILIKE '%' || sqlc.narg('reference')::text || '%'
|
||||||
|
OR p.transaction_id ILIKE '%' || sqlc.narg('reference')::text || '%'
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,18 @@ JOIN questions q ON q.id = qsi.question_id
|
||||||
WHERE qsi.set_id = $1
|
WHERE qsi.set_id = $1
|
||||||
AND q.status != 'ARCHIVED';
|
AND q.status != 'ARCHIVED';
|
||||||
|
|
||||||
|
-- name: GetQuestionTypeCountsInSet :many
|
||||||
|
SELECT
|
||||||
|
q.question_type_definition_id,
|
||||||
|
q.question_type,
|
||||||
|
COUNT(*)::bigint AS question_count
|
||||||
|
FROM question_set_items qsi
|
||||||
|
JOIN questions q ON q.id = qsi.question_id
|
||||||
|
WHERE qsi.set_id = $1
|
||||||
|
AND q.status != 'ARCHIVED'
|
||||||
|
GROUP BY q.question_type_definition_id, q.question_type
|
||||||
|
ORDER BY q.question_type;
|
||||||
|
|
||||||
-- name: GetQuestionSetsContainingQuestion :many
|
-- name: GetQuestionSetsContainingQuestion :many
|
||||||
SELECT qs.*
|
SELECT qs.*
|
||||||
FROM question_sets qs
|
FROM question_sets qs
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ INSERT INTO question_sets (
|
||||||
status,
|
status,
|
||||||
intro_video_url
|
intro_video_url
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, false), COALESCE($11, 'DRAFT'), $12)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, false), COALESCE($11, 'PUBLISHED'), $12)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetQuestionSetByID :one
|
-- name: GetQuestionSetByID :one
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,30 @@ SET
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: CreateAppleUser :one
|
||||||
|
INSERT INTO users (
|
||||||
|
first_name,
|
||||||
|
last_name,
|
||||||
|
email,
|
||||||
|
apple_id,
|
||||||
|
apple_email_verified,
|
||||||
|
role,
|
||||||
|
status,
|
||||||
|
email_verified
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8
|
||||||
|
)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: LinkAppleAccount :exec
|
||||||
|
UPDATE users
|
||||||
|
SET
|
||||||
|
apple_id = $2,
|
||||||
|
apple_email_verified = $3,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $1;
|
||||||
|
|
||||||
-- name: IsUserPending :one
|
-- name: IsUserPending :one
|
||||||
SELECT
|
SELECT
|
||||||
CASE WHEN status = 'PENDING' THEN true ELSE false END AS is_pending
|
CASE WHEN status = 'PENDING' THEN true ELSE false END AS is_pending
|
||||||
|
|
@ -156,6 +180,10 @@ SELECT *
|
||||||
FROM users
|
FROM users
|
||||||
WHERE google_id = $1;
|
WHERE google_id = $1;
|
||||||
|
|
||||||
|
-- name: GetUserByAppleID :one
|
||||||
|
SELECT *
|
||||||
|
FROM users
|
||||||
|
WHERE apple_id = $1;
|
||||||
|
|
||||||
-- name: GetAllUsers :many
|
-- name: GetAllUsers :many
|
||||||
SELECT
|
SELECT
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ CHAPA_PUBLIC_KEY=CHAPUBK_TEST-xxxxxxxx
|
||||||
CHAPA_WEBHOOK_SECRET=your_webhook_secret_from_dashboard
|
CHAPA_WEBHOOK_SECRET=your_webhook_secret_from_dashboard
|
||||||
CHAPA_BASE_URL=https://api.chapa.co/v1
|
CHAPA_BASE_URL=https://api.chapa.co/v1
|
||||||
CHAPA_CALLBACK_URL=https://your-api.example.com/api/v1/payments/chapa/callback
|
CHAPA_CALLBACK_URL=https://your-api.example.com/api/v1/payments/chapa/callback
|
||||||
CHAPA_RETURN_URL=https://your-app.example.com/payment/success
|
CHAPA_RETURN_URL=https://your-api.example.com/payment/success
|
||||||
CHAPA_RECEIPT_URL=
|
CHAPA_RECEIPT_URL=
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -29,9 +29,9 @@ Configure the same webhook URL in the Chapa dashboard:
|
||||||
1. Learner calls `POST /api/v1/subscriptions/checkout` or `POST /api/v1/payments/subscribe`.
|
1. Learner calls `POST /api/v1/subscriptions/checkout` or `POST /api/v1/payments/subscribe`.
|
||||||
2. Backend creates a pending payment and calls Chapa `POST /transaction/initialize`.
|
2. Backend creates a pending payment and calls Chapa `POST /transaction/initialize`.
|
||||||
3. Client redirects the user to `payment_url` (`checkout_url` from Chapa).
|
3. Client redirects the user to `payment_url` (`checkout_url` from Chapa).
|
||||||
4. After payment, Chapa calls `callback_url` and sends a webhook.
|
4. After payment, Chapa redirects the learner to `return_url` (`/payment/success`) and calls `callback_url`.
|
||||||
5. Backend verifies via `GET /transaction/verify/{tx_ref}` and activates the subscription.
|
5. The success page and callback both verify via Chapa `GET /transaction/verify/{tx_ref}` and activate the subscription when successful.
|
||||||
6. Client may poll `GET /api/v1/payments/verify/{tx_ref}` (`session_id` path param is the `tx_ref`).
|
6. Chapa also sends a webhook; client may poll `GET /api/v1/payments/verify/{tx_ref}` (`session_id` path param is the `tx_ref`).
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
|
|
@ -41,8 +41,37 @@ Configure the same webhook URL in the Chapa dashboard:
|
||||||
| POST | `/api/v1/payments/subscribe` | Yes | Same as checkout |
|
| POST | `/api/v1/payments/subscribe` | Yes | Same as checkout |
|
||||||
| GET | `/api/v1/payments/verify/:session_id` | Yes | Verify by `tx_ref` |
|
| GET | `/api/v1/payments/verify/:session_id` | Yes | Verify by `tx_ref` |
|
||||||
| POST | `/api/v1/payments/webhook` | No | Chapa webhook (HMAC signature required) |
|
| POST | `/api/v1/payments/webhook` | No | Chapa webhook (HMAC signature required) |
|
||||||
| GET | `/api/v1/payments/chapa/callback` | No | Chapa redirect callback |
|
| GET | `/api/v1/payments/chapa/callback` | No | Chapa server callback (JSON) |
|
||||||
|
| GET | `/api/v1/payments/chapa/success` | No | Chapa learner success page (HTML) |
|
||||||
|
| GET | `/payment/success` | No | Same HTML success page (`CHAPA_RETURN_URL`) |
|
||||||
| GET | `/api/v1/payments/methods` | No | Supported Chapa methods |
|
| GET | `/api/v1/payments/methods` | No | Supported Chapa methods |
|
||||||
|
| GET | `/api/v1/admin/payments` | Yes (admin) | List/filter all gateway payments (Chapa + ArifPay) |
|
||||||
|
| GET | `/api/v1/admin/payments/:id` | Yes (admin) | Get any payment by ID |
|
||||||
|
|
||||||
|
### Admin: list all gateway payments
|
||||||
|
|
||||||
|
`GET /api/v1/admin/payments` requires permission `payments.list_all` (assigned to `ADMIN` by default).
|
||||||
|
|
||||||
|
Query filters (all optional):
|
||||||
|
|
||||||
|
| Parameter | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `user_id` | Learner user ID |
|
||||||
|
| `plan_id` | Subscription plan ID |
|
||||||
|
| `subscription_id` | Linked subscription ID |
|
||||||
|
| `status` | `PENDING`, `PROCESSING`, `SUCCESS`, `FAILED`, `CANCELLED`, `EXPIRED` |
|
||||||
|
| `provider` or `payment_method` | `CHAPA` or `ARIFPAY` |
|
||||||
|
| `currency` | e.g. `ETB` |
|
||||||
|
| `plan_category` | `LEARN_ENGLISH`, `IELTS`, `DUOLINGO` |
|
||||||
|
| `reference` | Partial match on `session_id`, `nonce`, or `transaction_id` |
|
||||||
|
| `created_from`, `created_to` | RFC3339 or `YYYY-MM-DD` |
|
||||||
|
| `paid_from`, `paid_to` | RFC3339 or `YYYY-MM-DD` |
|
||||||
|
| `min_amount`, `max_amount` | Amount range |
|
||||||
|
| `limit`, `offset` | Pagination (default limit 20, max 100) |
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
`GET /api/v1/admin/payments?provider=CHAPA&status=SUCCESS&limit=50&offset=0`
|
||||||
|
|
||||||
### Initiate payment request
|
### Initiate payment request
|
||||||
|
|
||||||
|
|
|
||||||
995
docs/DYNAMIC_PRACTICE_CREATION_LMS_GUIDE.md
Normal file
995
docs/DYNAMIC_PRACTICE_CREATION_LMS_GUIDE.md
Normal file
|
|
@ -0,0 +1,995 @@
|
||||||
|
# Dynamic Practice Creation — LMS Guide (Course / Module / Lesson)
|
||||||
|
|
||||||
|
This guide explains **step by step** how to create **practices** in the Learn English LMS hierarchy using **dynamic question types** (`DYNAMIC` questions with `question_type_definition_id` + `dynamic_payload`).
|
||||||
|
|
||||||
|
It is the companion to:
|
||||||
|
|
||||||
|
- **Type builder (definitions + components):** `docs/DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md`
|
||||||
|
- **Lesson-only quick path (legacy + dynamic):** `docs/PRACTICE_CREATION_API_GUIDE.md`
|
||||||
|
|
||||||
|
**Base URL:** `{API_HOST}/api/v1`
|
||||||
|
**Auth:** `Authorization: Bearer <access_token>`
|
||||||
|
**Content-Type:** `application/json` (except file upload)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of contents
|
||||||
|
|
||||||
|
1. [Architecture](#1-architecture)
|
||||||
|
2. [Prerequisites and permissions](#2-prerequisites-and-permissions)
|
||||||
|
3. [Standard response envelopes](#3-standard-response-envelopes)
|
||||||
|
4. [ID map (what to store after each step)](#4-id-map-what-to-store-after-each-step)
|
||||||
|
5. [Publishing model](#5-publishing-model)
|
||||||
|
6. [End-to-end flow overview](#6-end-to-end-flow-overview)
|
||||||
|
7. [Step 0 — Resolve LMS parent IDs](#7-step-0--resolve-lms-parent-ids)
|
||||||
|
8. [Step 1 — (Optional) Upload media](#8-step-1--optional-upload-media)
|
||||||
|
9. [Step 2 — Create or select a question type definition](#9-step-2--create-or-select-a-question-type-definition)
|
||||||
|
10. [Step 3 — Create dynamic question(s)](#10-step-3--create-dynamic-questions)
|
||||||
|
11. [Step 4 — Create PRACTICE question set](#11-step-4--create-practice-question-set)
|
||||||
|
12. [Step 5 — Add questions to the set](#12-step-5--add-questions-to-the-set)
|
||||||
|
13. [Step 6 — Create practice shell (course / module / lesson)](#13-step-6--create-practice-shell-course--module--lesson)
|
||||||
|
14. [Step 7 — Verify and inspect](#14-step-7--verify-and-inspect)
|
||||||
|
15. [Optional — Reorder, update, publish](#15-optional--reorder-update-publish)
|
||||||
|
16. [Worked example — Lesson practice with TABLE + OPTION](#16-worked-example--lesson-practice-with-table--option)
|
||||||
|
17. [Scope-specific quick reference](#17-scope-specific-quick-reference)
|
||||||
|
18. [API index](#18-api-index)
|
||||||
|
19. [QA checklist](#19-qa-checklist)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Architecture
|
||||||
|
|
||||||
|
### LMS hierarchy
|
||||||
|
|
||||||
|
```
|
||||||
|
Program
|
||||||
|
└── Course
|
||||||
|
└── Module
|
||||||
|
└── Lesson
|
||||||
|
```
|
||||||
|
|
||||||
|
A **practice** is a learner-facing activity (story, persona, tips) backed by a **question set** containing one or more **questions**.
|
||||||
|
|
||||||
|
### Database rule (one parent only)
|
||||||
|
|
||||||
|
Each row in `lms_practices` attaches to **exactly one** of:
|
||||||
|
|
||||||
|
| Scope | `parent_kind` | `parent_id` refers to |
|
||||||
|
|-------|---------------|------------------------|
|
||||||
|
| Course-level practice | `COURSE` | `courses.id` |
|
||||||
|
| Module-level practice | `MODULE` | `modules.id` |
|
||||||
|
| Lesson-level practice | `LESSON` | `lessons.id` |
|
||||||
|
|
||||||
|
You cannot attach one practice to multiple parents. Choose the scope that matches your curriculum design.
|
||||||
|
|
||||||
|
### How dynamic questions fit in
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph definitions
|
||||||
|
DEF[Question type definition]
|
||||||
|
end
|
||||||
|
subgraph content
|
||||||
|
Q1[Dynamic question 1]
|
||||||
|
Q2[Dynamic question 2]
|
||||||
|
end
|
||||||
|
subgraph packaging
|
||||||
|
QS[Question set set_type=PRACTICE]
|
||||||
|
P[Practice shell]
|
||||||
|
end
|
||||||
|
DEF --> Q1
|
||||||
|
DEF --> Q2
|
||||||
|
Q1 --> QS
|
||||||
|
Q2 --> QS
|
||||||
|
QS --> P
|
||||||
|
P --> L[Lesson / Module / Course]
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Definition** — template (which stimulus/response slots exist).
|
||||||
|
2. **Questions** — instances with `dynamic_payload` (real TABLE rows, OPTION choices, PDF URLs, etc.).
|
||||||
|
3. **Question set** — ordered list of question IDs (`set_type: "PRACTICE"`).
|
||||||
|
4. **Practice** — links `question_set_id` to a course, module, or lesson.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Prerequisites and permissions
|
||||||
|
|
||||||
|
### Minimum permissions (admin authoring)
|
||||||
|
|
||||||
|
| Permission | Used for |
|
||||||
|
|------------|----------|
|
||||||
|
| `questions.list` | Component catalog, list definitions |
|
||||||
|
| `questions.create` | Definitions, dynamic questions |
|
||||||
|
| `questions.get` | Load question / definition details |
|
||||||
|
| `questions.update` | Update questions, definitions, publish practice |
|
||||||
|
| `question_sets.create` | Create PRACTICE set |
|
||||||
|
| `question_set_items.add` | Link questions to set |
|
||||||
|
| `question_set_items.list` | List questions in set, type summary |
|
||||||
|
| `question_set_items.update_order` | Reorder questions |
|
||||||
|
| `practices.create` | Create practice shell |
|
||||||
|
| `practices.list` | List practices under course/module/lesson |
|
||||||
|
| `practices.get` | Get practice by id |
|
||||||
|
| `practices.update` | Publish practice (`publish_status`) |
|
||||||
|
| `lessons.get` / `modules.get` / `courses.get` | Resolve parent IDs (as needed) |
|
||||||
|
|
||||||
|
File upload: authenticated user only (`POST /files/upload`).
|
||||||
|
|
||||||
|
### Related docs
|
||||||
|
|
||||||
|
- Full definition API reference: `DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md`
|
||||||
|
- Postman collection: `postman/Dynamic-Question-Type-Builder.postman_collection.json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Standard response envelopes
|
||||||
|
|
||||||
|
### Success — `domain.Response`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Human-readable summary",
|
||||||
|
"data": {},
|
||||||
|
"success": true,
|
||||||
|
"status_code": 200,
|
||||||
|
"metadata": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`status_code` in the body may be `200` or `201` depending on the endpoint.
|
||||||
|
|
||||||
|
### Error — `domain.ErrorResponse`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Short error title",
|
||||||
|
"error": "Detailed validation or system message"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. ID map (what to store after each step)
|
||||||
|
|
||||||
|
| Step | Capture | Used in |
|
||||||
|
|------|---------|---------|
|
||||||
|
| Upload media | `url`, `object_key` | `dynamic_payload` stimulus `value` |
|
||||||
|
| Create definition | `question_type_definition_id` | Create each dynamic question |
|
||||||
|
| Create question | `question_id` | Add to set (repeat per question) |
|
||||||
|
| Create question set | `question_set_id` (`set_id`) | Create practice |
|
||||||
|
| Create practice | `practice_id` | Admin UI, learner routes |
|
||||||
|
| Parent resolution | `course_id` / `module_id` / `lesson_id` | `parent_id` + `owner_id` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Publishing model
|
||||||
|
|
||||||
|
Three layers can affect learner visibility:
|
||||||
|
|
||||||
|
| Layer | Field | Values | Notes |
|
||||||
|
|-------|--------|--------|-------|
|
||||||
|
| Question | `status` | `DRAFT`, `PUBLISHED`, `INACTIVE`, `ARCHIVED` | Use `PUBLISHED` for live content |
|
||||||
|
| Question set | `status` | `DRAFT`, `PUBLISHED`, … | Use `PUBLISHED` for live sets |
|
||||||
|
| Practice shell | `publish_status` | `DRAFT`, `PUBLISHED` | Omit or `PUBLISHED` on create; use `DRAFT` to hide until ready |
|
||||||
|
|
||||||
|
**Recommendation for go-live:** set question `status`, question set `status`, and practice `publish_status` to published when learners should see the practice immediately.
|
||||||
|
|
||||||
|
**Draft practice:** create with `"publish_status": "DRAFT"`, then `PUT /practices/:id` with `"publish_status": "PUBLISHED"` when ready.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. End-to-end flow overview
|
||||||
|
|
||||||
|
| Step | Action | APIs (typical) |
|
||||||
|
|------|--------|----------------|
|
||||||
|
| 0 | Resolve `parent_id` (course / module / lesson) | `GET /courses/:id`, `GET /modules/:id`, `GET /lessons/:id` |
|
||||||
|
| 1 | Upload images / PDF / audio (if needed) | `POST /files/upload` |
|
||||||
|
| 2 | Create or pick question type definition | `GET /questions/type-definitions`, `POST /questions/type-definitions` |
|
||||||
|
| 3 | Create one or more dynamic questions | `POST /questions` (repeat) |
|
||||||
|
| 4 | Create PRACTICE question set | `POST /question-sets` |
|
||||||
|
| 5 | Add each question to set (ordered) | `POST /question-sets/:setId/questions` (repeat) |
|
||||||
|
| 6 | Create practice at chosen scope | `POST /practices` |
|
||||||
|
| 7 | Verify | `GET /lessons/:id/practices` (or course/module), `GET /question-sets/:setId/questions` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Step 0 — Resolve LMS parent IDs
|
||||||
|
|
||||||
|
Before creating a practice, know the target **`parent_id`** and matching **`owner_type`** for the question set.
|
||||||
|
|
||||||
|
### List lessons in a module (example)
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **GET** | `/modules/:moduleId/lessons` |
|
||||||
|
| **Permission** | `lessons.list_by_module` |
|
||||||
|
|
||||||
|
**Query (optional):** `limit`, `offset` (see lesson list handler defaults).
|
||||||
|
|
||||||
|
**Success `200` — `data`:** array of lessons; capture `id` for `parent_id` when scope is `LESSON`.
|
||||||
|
|
||||||
|
### Get lesson / module / course
|
||||||
|
|
||||||
|
| Entity | Method / path | Permission |
|
||||||
|
|--------|---------------|------------|
|
||||||
|
| Lesson | `GET /lessons/:id` | `lessons.get` |
|
||||||
|
| Module | `GET /modules/:id` | `modules.get` |
|
||||||
|
| Course | `GET /courses/:id` | `courses.get` |
|
||||||
|
|
||||||
|
Use these to confirm the parent exists and to display titles in the admin UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Step 1 — (Optional) Upload media
|
||||||
|
|
||||||
|
Required when the definition uses `IMAGE`, `AUDIO_PROMPT`, or `PDF_ATTACHMENT` stimulus slots.
|
||||||
|
|
||||||
|
### POST `/files/upload`
|
||||||
|
|
||||||
|
**Content-Type:** `multipart/form-data`
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|--------|
|
||||||
|
| `file` | Binary |
|
||||||
|
| `media_type` | `image`, `audio`, `video`, or `pdf` |
|
||||||
|
|
||||||
|
**Success `200` — `data`:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"object_key": "pdf/a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf",
|
||||||
|
"url": "https://minio.example.com/bucket/pdf/a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf?X-Amz-Algorithm=...",
|
||||||
|
"content_type": "application/pdf",
|
||||||
|
"media_type": "pdf",
|
||||||
|
"provider": "MINIO"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use in `dynamic_payload`:** set stimulus `value` to `data.url` (or store `minio://{object_key}` and resolve with `GET /files/url?key=...`).
|
||||||
|
|
||||||
|
**Errors `400`:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Invalid media_type",
|
||||||
|
"error": "media_type must be one of: image, audio, video, pdf"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Step 2 — Create or select a question type definition
|
||||||
|
|
||||||
|
Skip creation if reusing an existing ACTIVE definition.
|
||||||
|
|
||||||
|
### 9.1 List existing definitions
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **GET** | `/questions/type-definitions?include_system=true&status=ACTIVE` |
|
||||||
|
| **Permission** | `questions.list` |
|
||||||
|
|
||||||
|
**Success `200` — `data`:** array of definitions (see shape in type builder doc).
|
||||||
|
|
||||||
|
### 9.2 (Optional) Validate kinds
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **POST** | `/questions/validate-question-type-definition` |
|
||||||
|
| **Permission** | `questions.create` |
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"stimulus_component_kinds": ["QUESTION_TEXT", "TABLE", "IMAGE"],
|
||||||
|
"response_component_kinds": ["OPTION", "ANSWER_TIMER"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success `200` — `data`:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"valid": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 Create definition (example with TABLE)
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **POST** | `/questions/type-definitions` |
|
||||||
|
| **Permission** | `questions.create` |
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"key": "lesson_table_mcq_v1",
|
||||||
|
"display_name": "Lesson Table MCQ",
|
||||||
|
"description": "Prompt + optional table + image; MCQ response",
|
||||||
|
"stimulus_component_kinds": ["QUESTION_TEXT", "TABLE", "IMAGE"],
|
||||||
|
"response_component_kinds": ["OPTION"],
|
||||||
|
"stimulus_schema": [
|
||||||
|
{
|
||||||
|
"id": "prompt",
|
||||||
|
"kind": "QUESTION_TEXT",
|
||||||
|
"label": "Question prompt",
|
||||||
|
"required": true,
|
||||||
|
"config": { "max_length": 2000 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "data_table",
|
||||||
|
"kind": "TABLE",
|
||||||
|
"label": "Reference table",
|
||||||
|
"required": true,
|
||||||
|
"config": { "max_rows": 30, "max_columns": 10 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "illustration",
|
||||||
|
"kind": "IMAGE",
|
||||||
|
"label": "Supporting image",
|
||||||
|
"required": false,
|
||||||
|
"config": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"response_schema": [
|
||||||
|
{
|
||||||
|
"id": "choices",
|
||||||
|
"kind": "OPTION",
|
||||||
|
"label": "Answer choices",
|
||||||
|
"required": true,
|
||||||
|
"config": { "min_options": 2, "max_options": 6 }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"status": "ACTIVE"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success `201` — `data` (full `QuestionTypeDefinition`):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"key": "lesson_table_mcq_v1",
|
||||||
|
"display_name": "Lesson Table MCQ",
|
||||||
|
"description": "Prompt + optional table + image; MCQ response",
|
||||||
|
"stimulus_component_kinds": ["QUESTION_TEXT", "TABLE", "IMAGE"],
|
||||||
|
"response_component_kinds": ["OPTION"],
|
||||||
|
"stimulus_schema": [ ],
|
||||||
|
"response_schema": [ ],
|
||||||
|
"is_system": false,
|
||||||
|
"status": "ACTIVE",
|
||||||
|
"created_at": "2026-06-04T10:00:00Z",
|
||||||
|
"updated_at": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Capture:** `data.id` → `question_type_definition_id` (e.g. `42`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Step 3 — Create dynamic question(s)
|
||||||
|
|
||||||
|
Repeat this step for each question in the practice.
|
||||||
|
|
||||||
|
### POST `/questions`
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **Permission** | `questions.create` |
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
|
||||||
|
- `question_type` must be `"DYNAMIC"`.
|
||||||
|
- `question_type_definition_id` is **required**.
|
||||||
|
- `dynamic_payload` is **required**.
|
||||||
|
- Do **not** send top-level `question_text` (prompt lives in stimulus).
|
||||||
|
- Do **not** send legacy `options` / `short_answers` for pure dynamic MCQ (use `OPTION` in payload).
|
||||||
|
|
||||||
|
**Request (TABLE + OPTION example):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"question_type": "DYNAMIC",
|
||||||
|
"question_type_definition_id": 42,
|
||||||
|
"difficulty_level": "MEDIUM",
|
||||||
|
"points": 2,
|
||||||
|
"status": "PUBLISHED",
|
||||||
|
"dynamic_payload": {
|
||||||
|
"stimulus": [
|
||||||
|
{
|
||||||
|
"id": "prompt",
|
||||||
|
"kind": "QUESTION_TEXT",
|
||||||
|
"value": "Using the table, choose the correct past tense."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "data_table",
|
||||||
|
"kind": "TABLE",
|
||||||
|
"value": {
|
||||||
|
"columns": ["Verb", "Past Form"],
|
||||||
|
"rows": [
|
||||||
|
["go", "went"],
|
||||||
|
["write", "wrote"],
|
||||||
|
["see", "saw"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "illustration",
|
||||||
|
"kind": "IMAGE",
|
||||||
|
"value": "https://minio.example.com/bucket/image/uuid.jpg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"response": [
|
||||||
|
{
|
||||||
|
"id": "choices",
|
||||||
|
"kind": "OPTION",
|
||||||
|
"value": {
|
||||||
|
"options": [
|
||||||
|
{ "id": "a", "text": "He goed home.", "is_correct": false },
|
||||||
|
{ "id": "b", "text": "He went home.", "is_correct": true },
|
||||||
|
{ "id": "c", "text": "He go home.", "is_correct": false }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**TABLE `value` contract:**
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `columns` | `string[]` | Header labels |
|
||||||
|
| `rows` | `string[][]` | Each row length should match `columns.length` |
|
||||||
|
|
||||||
|
**Success `201` — `data`:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1001,
|
||||||
|
"question_type": "DYNAMIC",
|
||||||
|
"question_type_definition_id": 42,
|
||||||
|
"dynamic_payload": {
|
||||||
|
"stimulus": [ ],
|
||||||
|
"response": [ ]
|
||||||
|
},
|
||||||
|
"difficulty_level": "MEDIUM",
|
||||||
|
"points": 2,
|
||||||
|
"status": "PUBLISHED",
|
||||||
|
"created_at": "2026-06-04T11:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `question_text` is **omitted** from the JSON response for `DYNAMIC` questions.
|
||||||
|
|
||||||
|
**Error `400` examples:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Invalid dynamic_payload",
|
||||||
|
"error": "dynamic_payload.stimulus: required element id \"data_table\" is missing"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Invalid question_text",
|
||||||
|
"error": "question_text is not used for DYNAMIC questions; set prompt text in dynamic_payload stimulus (QUESTION_TEXT, INSTRUCTION, or TEXT_PASSAGE)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Capture:** `data.id` → `question_id` (repeat list: `[1001, 1002, ...]`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Step 4 — Create PRACTICE question set
|
||||||
|
|
||||||
|
The question set groups questions. Its `owner_type` / `owner_id` should match the practice scope (recommended for reporting and sequence gating).
|
||||||
|
|
||||||
|
### POST `/question-sets`
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **Permission** | `question_sets.create` |
|
||||||
|
|
||||||
|
**Request — lesson scope:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Lesson 12 — Dynamic drill",
|
||||||
|
"description": "Practice question set for lesson 12",
|
||||||
|
"set_type": "PRACTICE",
|
||||||
|
"owner_type": "LESSON",
|
||||||
|
"owner_id": 12,
|
||||||
|
"status": "PUBLISHED",
|
||||||
|
"shuffle_questions": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request — module scope:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Module 3 — Review set",
|
||||||
|
"set_type": "PRACTICE",
|
||||||
|
"owner_type": "MODULE",
|
||||||
|
"owner_id": 3,
|
||||||
|
"status": "PUBLISHED",
|
||||||
|
"shuffle_questions": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request — course scope:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Course 1 — Capstone practice",
|
||||||
|
"set_type": "PRACTICE",
|
||||||
|
"owner_type": "COURSE",
|
||||||
|
"owner_id": 1,
|
||||||
|
"status": "PUBLISHED",
|
||||||
|
"shuffle_questions": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Required | Notes |
|
||||||
|
|-------|----------|-------|
|
||||||
|
| `title` | Yes | Admin display |
|
||||||
|
| `set_type` | Yes | Must be `"PRACTICE"` for LMS practices |
|
||||||
|
| `owner_type` | Recommended | `LESSON`, `MODULE`, or `COURSE` (match practice parent) |
|
||||||
|
| `owner_id` | Recommended | ID of that entity |
|
||||||
|
| `description` | No | |
|
||||||
|
| `status` | No | Default `DRAFT`; use `PUBLISHED` for learners |
|
||||||
|
| `shuffle_questions` | No | Default `false` |
|
||||||
|
| `time_limit_minutes` | No | Optional |
|
||||||
|
| `passing_score` | No | Optional |
|
||||||
|
| `intro_video_url` | No | Optional |
|
||||||
|
|
||||||
|
**Success `201` — `data`:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 55,
|
||||||
|
"title": "Lesson 12 — Dynamic drill",
|
||||||
|
"description": "Practice question set for lesson 12",
|
||||||
|
"set_type": "PRACTICE",
|
||||||
|
"owner_type": "LESSON",
|
||||||
|
"owner_id": 12,
|
||||||
|
"shuffle_questions": false,
|
||||||
|
"status": "PUBLISHED",
|
||||||
|
"created_at": "2026-06-04T11:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Capture:** `data.id` → `question_set_id` / `set_id` (e.g. `55`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Step 5 — Add questions to the set
|
||||||
|
|
||||||
|
Run once per `question_id`. `display_order` controls sequence (important for `STUDENT` practice gating).
|
||||||
|
|
||||||
|
### POST `/question-sets/:setId/questions`
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **Permission** | `question_set_items.add` |
|
||||||
|
|
||||||
|
**Path:** `setId` = question set id from Step 4.
|
||||||
|
|
||||||
|
**Request (first question):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"question_id": 1001,
|
||||||
|
"display_order": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request (second question):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"question_id": 1002,
|
||||||
|
"display_order": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required |
|
||||||
|
|-------|------|----------|
|
||||||
|
| `question_id` | `int64` | Yes |
|
||||||
|
| `display_order` | `int32` | No |
|
||||||
|
|
||||||
|
**Success `201` — `data`:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 901,
|
||||||
|
"set_id": 55,
|
||||||
|
"question_id": 1001,
|
||||||
|
"display_order": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors:** `400` invalid ids; `500` link failure.
|
||||||
|
|
||||||
|
### (Optional) Question type summary for set
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **GET** | `/question-sets/:setId/question-types` |
|
||||||
|
| **Permission** | `question_set_items.list` |
|
||||||
|
|
||||||
|
**Success `200` — `data`:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"question_set_id": 55,
|
||||||
|
"total_questions": 2,
|
||||||
|
"question_types": [
|
||||||
|
{
|
||||||
|
"question_type_definition_id": 42,
|
||||||
|
"key": "lesson_table_mcq_v1",
|
||||||
|
"display_name": "Lesson Table MCQ",
|
||||||
|
"count": 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Step 6 — Create practice shell (course / module / lesson)
|
||||||
|
|
||||||
|
Links the question set to exactly one LMS parent.
|
||||||
|
|
||||||
|
### POST `/practices`
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **Permission** | `practices.create` |
|
||||||
|
|
||||||
|
**Request — lesson:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"parent_kind": "LESSON",
|
||||||
|
"parent_id": 12,
|
||||||
|
"title": "Lesson 12 — Table MCQ practice",
|
||||||
|
"story_description": "Read the table and choose the best answer.",
|
||||||
|
"story_image": "https://minio.example.com/bucket/image/story.webp",
|
||||||
|
"question_set_id": 55,
|
||||||
|
"quick_tips": "Check every row in the table before selecting.",
|
||||||
|
"publish_status": "DRAFT"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request — module:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"parent_kind": "MODULE",
|
||||||
|
"parent_id": 3,
|
||||||
|
"title": "Module 3 review",
|
||||||
|
"question_set_id": 55,
|
||||||
|
"publish_status": "PUBLISHED"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request — course:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"parent_kind": "COURSE",
|
||||||
|
"parent_id": 1,
|
||||||
|
"title": "Course-wide practice",
|
||||||
|
"question_set_id": 55,
|
||||||
|
"publish_status": "PUBLISHED"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|-------|------|----------|-------|
|
||||||
|
| `parent_kind` | string | Yes | `COURSE`, `MODULE`, or `LESSON` |
|
||||||
|
| `parent_id` | int64 | Yes | Target entity id |
|
||||||
|
| `question_set_id` | int64 | Yes | From Step 4 |
|
||||||
|
| `title` | string | No | Empty string allowed |
|
||||||
|
| `story_description` | string | No | |
|
||||||
|
| `story_image` | string | No | URL |
|
||||||
|
| `persona_id` | int64 | No | `lms_personas` catalog id |
|
||||||
|
| `quick_tips` | string | No | |
|
||||||
|
| `publish_status` | string | No | `DRAFT` or `PUBLISHED`; default `PUBLISHED` if omitted |
|
||||||
|
|
||||||
|
**Success `201` — `data` (`domain.Practice`):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 37,
|
||||||
|
"parent_kind": "LESSON",
|
||||||
|
"parent_id": 12,
|
||||||
|
"title": "Lesson 12 — Table MCQ practice",
|
||||||
|
"story_description": "Read the table and choose the best answer.",
|
||||||
|
"story_image": "https://minio.example.com/bucket/image/story.webp",
|
||||||
|
"persona_id": null,
|
||||||
|
"question_set_id": 55,
|
||||||
|
"publish_status": "DRAFT",
|
||||||
|
"quick_tips": "Check every row in the table before selecting.",
|
||||||
|
"created_at": "2026-06-04T12:00:00Z",
|
||||||
|
"updated_at": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors:**
|
||||||
|
|
||||||
|
| Status | `message` | Typical `error` |
|
||||||
|
|--------|-----------|-----------------|
|
||||||
|
| `404` | Lesson not found | Parent id invalid |
|
||||||
|
| `404` | Question set not found | Bad `question_set_id` |
|
||||||
|
| `404` | Persona not found | Bad `persona_id` |
|
||||||
|
| `400` | Invalid parent | Bad `parent_kind` |
|
||||||
|
|
||||||
|
**Capture:** `data.id` → `practice_id` (e.g. `37`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Step 7 — Verify and inspect
|
||||||
|
|
||||||
|
### 14.1 List practices under parent
|
||||||
|
|
||||||
|
| Scope | GET |
|
||||||
|
|-------|-----|
|
||||||
|
| Lesson | `/lessons/:id/practices?limit=20&offset=0` |
|
||||||
|
| Module | `/modules/:id/practices?limit=20&offset=0` |
|
||||||
|
| Course | `/courses/:id/practices?limit=20&offset=0` |
|
||||||
|
|
||||||
|
**Permission:** `practices.list`
|
||||||
|
|
||||||
|
**Success `200` — `data`:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"practices": [
|
||||||
|
{
|
||||||
|
"id": 37,
|
||||||
|
"parent_kind": "LESSON",
|
||||||
|
"parent_id": 12,
|
||||||
|
"title": "Lesson 12 — Table MCQ practice",
|
||||||
|
"question_set_id": 55,
|
||||||
|
"publish_status": "DRAFT",
|
||||||
|
"created_at": "2026-06-04T12:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_count": 1,
|
||||||
|
"limit": 20,
|
||||||
|
"offset": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 14.2 Get practice by id
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **GET** | `/practices/:id` |
|
||||||
|
| **Permission** | `practices.get` |
|
||||||
|
|
||||||
|
**Success `200` — `data`:** full `Practice` object (includes `question_set_id`).
|
||||||
|
|
||||||
|
### 14.3 List questions in set (admin — full dynamic payload)
|
||||||
|
|
||||||
|
Use **`question_set_id`** from the practice record (not `practice_id`).
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **GET** | `/question-sets/:setId/questions` |
|
||||||
|
| **Permission** | `question_set_items.list` |
|
||||||
|
|
||||||
|
**Success `200` — `data`:** array of full questions including `dynamic_payload` and `question_type_definition_id`.
|
||||||
|
|
||||||
|
For paginated learner-style listing with filters:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **GET** | `/practices/:practiceId/questions?limit=10&offset=0&question_type=DYNAMIC` |
|
||||||
|
|
||||||
|
**Note:** This route’s path parameter is named `practiceId` in OpenAPI but is implemented against **`question_sets.id`**. For admin, prefer **`GET /question-sets/:setId/questions`** using `practice.question_set_id` from Step 14.2.
|
||||||
|
|
||||||
|
**Paginated response shape (`GET /practices/.../questions`):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"id": 901,
|
||||||
|
"set_id": 55,
|
||||||
|
"question_id": 1001,
|
||||||
|
"display_order": 1,
|
||||||
|
"question_type": "DYNAMIC",
|
||||||
|
"dynamic_payload": { "stimulus": [ ], "response": [ ] },
|
||||||
|
"points": 2,
|
||||||
|
"question_status": "PUBLISHED"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_count": 1,
|
||||||
|
"limit": 10,
|
||||||
|
"offset": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Optional — Reorder, update, publish
|
||||||
|
|
||||||
|
### Reorder question in set
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **PUT** | `/question-sets/:setId/questions/:questionId/order` |
|
||||||
|
| **Permission** | `question_set_items.update_order` |
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"display_order": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success `200`:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Question order updated successfully",
|
||||||
|
"success": true,
|
||||||
|
"status_code": 200
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Publish practice shell
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **PUT** | `/practices/:id` |
|
||||||
|
| **Permission** | `practices.update` |
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"publish_status": "PUBLISHED"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success `200` — `data`:** updated `Practice` with `publish_status: "PUBLISHED"`.
|
||||||
|
|
||||||
|
### Update dynamic question content
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **PUT** | `/questions/:id` |
|
||||||
|
| **Permission** | `questions.update` |
|
||||||
|
|
||||||
|
Send updated `dynamic_payload` (and optional metadata). Do not send `question_text` for `DYNAMIC`.
|
||||||
|
|
||||||
|
### Remove question from set
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **DELETE** | `/question-sets/:setId/questions/:questionId` |
|
||||||
|
| **Permission** | `question_set_items.remove` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Worked example — Lesson practice with TABLE + OPTION
|
||||||
|
|
||||||
|
**Goal:** Lesson `12` gets one practice with one dynamic TABLE+MCQ question.
|
||||||
|
|
||||||
|
| Step | API | Key ids |
|
||||||
|
|------|-----|---------|
|
||||||
|
| 1 | `POST /questions/type-definitions` | `definition_id = 42` |
|
||||||
|
| 2 | `POST /questions` | `question_id = 1001` |
|
||||||
|
| 3 | `POST /question-sets` (`owner_type: LESSON`, `owner_id: 12`) | `set_id = 55` |
|
||||||
|
| 4 | `POST /question-sets/55/questions` | links `1001` order `1` |
|
||||||
|
| 5 | `POST /practices` (`parent_kind: LESSON`, `parent_id: 12`, `question_set_id: 55`) | `practice_id = 37` |
|
||||||
|
| 6 | `GET /lessons/12/practices` | confirms practice listed |
|
||||||
|
| 7 | `GET /question-sets/55/questions` | confirms TABLE payload |
|
||||||
|
| 8 | `PUT /practices/37` `{ "publish_status": "PUBLISHED" }` | go live |
|
||||||
|
|
||||||
|
**Admin UI table editor → API:** bind columns/rows UI to stimulus slot `data_table` / kind `TABLE` before Step 2 (`POST /questions`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Scope-specific quick reference
|
||||||
|
|
||||||
|
### Lesson practice
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Question set
|
||||||
|
{ "owner_type": "LESSON", "owner_id": <lesson_id>, "set_type": "PRACTICE" }
|
||||||
|
|
||||||
|
// Practice
|
||||||
|
{ "parent_kind": "LESSON", "parent_id": <lesson_id>, "question_set_id": <set_id> }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verify:** `GET /lessons/<lesson_id>/practices`
|
||||||
|
|
||||||
|
### Module practice
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "owner_type": "MODULE", "owner_id": <module_id> }
|
||||||
|
{ "parent_kind": "MODULE", "parent_id": <module_id> }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verify:** `GET /modules/<module_id>/practices`
|
||||||
|
|
||||||
|
### Course practice
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "owner_type": "COURSE", "owner_id": <course_id> }
|
||||||
|
{ "parent_kind": "COURSE", "parent_id": <course_id> }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verify:** `GET /courses/<course_id>/practices`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. API index
|
||||||
|
|
||||||
|
| # | Method | Path | Permission |
|
||||||
|
|---|--------|------|------------|
|
||||||
|
| 1 | GET | `/questions/component-catalog` | `questions.list` |
|
||||||
|
| 2 | GET | `/questions/type-definitions` | `questions.list` |
|
||||||
|
| 3 | POST | `/questions/type-definitions` | `questions.create` |
|
||||||
|
| 4 | POST | `/questions/validate-question-type-definition` | `questions.create` |
|
||||||
|
| 5 | POST | `/files/upload` | auth |
|
||||||
|
| 6 | GET | `/files/url` | auth |
|
||||||
|
| 7 | POST | `/questions` | `questions.create` |
|
||||||
|
| 8 | PUT | `/questions/:id` | `questions.update` |
|
||||||
|
| 9 | POST | `/question-sets` | `question_sets.create` |
|
||||||
|
| 10 | POST | `/question-sets/:setId/questions` | `question_set_items.add` |
|
||||||
|
| 11 | GET | `/question-sets/:setId/questions` | `question_set_items.list` |
|
||||||
|
| 12 | GET | `/question-sets/:setId/question-types` | `question_set_items.list` |
|
||||||
|
| 13 | PUT | `/question-sets/:setId/questions/:questionId/order` | `question_set_items.update_order` |
|
||||||
|
| 14 | DELETE | `/question-sets/:setId/questions/:questionId` | `question_set_items.remove` |
|
||||||
|
| 15 | POST | `/practices` | `practices.create` |
|
||||||
|
| 16 | GET | `/practices/:id` | `practices.get` |
|
||||||
|
| 17 | PUT | `/practices/:id` | `practices.update` |
|
||||||
|
| 18 | DELETE | `/practices/:id` | `practices.delete` |
|
||||||
|
| 19 | GET | `/lessons/:id/practices` | `practices.list` |
|
||||||
|
| 20 | GET | `/modules/:id/practices` | `practices.list` |
|
||||||
|
| 21 | GET | `/courses/:id/practices` | `practices.list` |
|
||||||
|
| 22 | GET | `/practices/:practiceId/questions` | `question_set_items.list` (see §14.3 note) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. QA checklist
|
||||||
|
|
||||||
|
- [ ] Parent course/module/lesson exists (`GET` returns 200)
|
||||||
|
- [ ] Definition includes `TABLE` (or other) slots used in payload
|
||||||
|
- [ ] Dynamic question created without `question_text` in request
|
||||||
|
- [ ] TABLE `value` has `columns` + `rows` aligned
|
||||||
|
- [ ] Question set `set_type` is `PRACTICE` and `owner_type` matches practice scope
|
||||||
|
- [ ] All questions added to set with correct `display_order`
|
||||||
|
- [ ] Practice `question_set_id` matches set id
|
||||||
|
- [ ] `parent_kind` / `parent_id` match intended scope
|
||||||
|
- [ ] `GET` list practices under parent shows new practice
|
||||||
|
- [ ] `GET /question-sets/:id/questions` shows `dynamic_payload`
|
||||||
|
- [ ] Publish: question `PUBLISHED`, set `PUBLISHED`, practice `publish_status: PUBLISHED` when going live
|
||||||
|
- [ ] `OPEN_LEARNER` sees unlocked content; `STUDENT` respects practice sequence on same owner scope
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pitfalls
|
||||||
|
|
||||||
|
1. **Do not send `question_text`** on dynamic question create/update — use `QUESTION_TEXT` (or `INSTRUCTION`) in `dynamic_payload.stimulus`.
|
||||||
|
2. **`owner_type` on question set** should match **`parent_kind` on practice** for consistent gating and admin filters.
|
||||||
|
3. **One practice → one `question_set_id`** in normal authoring; add multiple questions to the **same set**, not multiple sets per practice.
|
||||||
|
4. **TABLE content is per question** — the definition only declares the slot; each `POST /questions` supplies its own `columns` / `rows`.
|
||||||
|
5. **`GET /practices/:practiceId/questions`** — use `question_set_id` from practice when calling set-based list endpoints (see §14.3).
|
||||||
|
6. **Dynamic scoring runtime** — verify learner app supports your definition’s response shapes before release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last aligned with backend: LMS practices (`COURSE`/`MODULE`/`LESSON`), dynamic questions, `PDF_ATTACHMENT`, `TABLE` stimulus, practice `publish_status`, DYNAMIC `question_text` API omission.*
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -65,7 +65,7 @@ If you create/update dynamic definitions:
|
||||||
|
|
||||||
## Step 0 (Optional): Upload Media
|
## Step 0 (Optional): Upload Media
|
||||||
|
|
||||||
Use this when question content references audio/image URLs.
|
Use this when question content references audio/image/PDF URLs (e.g. dynamic `IMAGE`, `AUDIO_PROMPT`, or `PDF_ATTACHMENT` stimulus).
|
||||||
|
|
||||||
### Endpoint
|
### Endpoint
|
||||||
|
|
||||||
|
|
@ -74,7 +74,7 @@ Use this when question content references audio/image URLs.
|
||||||
### Form fields
|
### Form fields
|
||||||
|
|
||||||
- `file`: binary
|
- `file`: binary
|
||||||
- `media_type`: `image` or `audio` or `video`
|
- `media_type`: `image`, `audio`, `video`, or `pdf` (PDF is stored in MinIO; response includes presigned `url` and `object_key`)
|
||||||
|
|
||||||
### Example success response (shape)
|
### Example success response (shape)
|
||||||
|
|
||||||
|
|
@ -267,7 +267,6 @@ Capture:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"question_text": "Listen and respond as Speaker B.",
|
|
||||||
"question_type": "DYNAMIC",
|
"question_type": "DYNAMIC",
|
||||||
"question_type_definition_id": 123,
|
"question_type_definition_id": 123,
|
||||||
"difficulty_level": "MEDIUM",
|
"difficulty_level": "MEDIUM",
|
||||||
|
|
|
||||||
|
|
@ -14182,9 +14182,6 @@ const docTemplate = `{
|
||||||
},
|
},
|
||||||
"handlers.createQuestionReq": {
|
"handlers.createQuestionReq": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
|
||||||
"question_text"
|
|
||||||
],
|
|
||||||
"properties": {
|
"properties": {
|
||||||
"audio_correct_answer_text": {
|
"audio_correct_answer_text": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
|
|
||||||
|
|
@ -14174,9 +14174,6 @@
|
||||||
},
|
},
|
||||||
"handlers.createQuestionReq": {
|
"handlers.createQuestionReq": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
|
||||||
"question_text"
|
|
||||||
],
|
|
||||||
"properties": {
|
"properties": {
|
||||||
"audio_correct_answer_text": {
|
"audio_correct_answer_text": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
|
|
||||||
|
|
@ -1988,8 +1988,6 @@ definitions:
|
||||||
type: string
|
type: string
|
||||||
voice_prompt:
|
voice_prompt:
|
||||||
type: string
|
type: string
|
||||||
required:
|
|
||||||
- question_text
|
|
||||||
type: object
|
type: object
|
||||||
handlers.createQuestionSetReq:
|
handlers.createQuestionSetReq:
|
||||||
properties:
|
properties:
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,48 @@ func (q *Queries) CountModulesInCourse(ctx context.Context, courseID int64) (int
|
||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CountPublishedDirectPracticesInCourse = `-- name: CountPublishedDirectPracticesInCourse :one
|
||||||
|
SELECT
|
||||||
|
count(*)::int AS n
|
||||||
|
FROM
|
||||||
|
lms_practices lp
|
||||||
|
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||||
|
WHERE
|
||||||
|
lp.course_id = $1
|
||||||
|
AND qs.set_type = 'PRACTICE'
|
||||||
|
AND qs.status = 'PUBLISHED'
|
||||||
|
AND lp.publish_status = 'PUBLISHED'
|
||||||
|
`
|
||||||
|
|
||||||
|
// Published practices directly attached to course_id (not via module_id/lesson_id).
|
||||||
|
func (q *Queries) CountPublishedDirectPracticesInCourse(ctx context.Context, courseID pgtype.Int8) (int32, error) {
|
||||||
|
row := q.db.QueryRow(ctx, CountPublishedDirectPracticesInCourse, courseID)
|
||||||
|
var n int32
|
||||||
|
err := row.Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const CountPublishedDirectPracticesInModule = `-- name: CountPublishedDirectPracticesInModule :one
|
||||||
|
SELECT
|
||||||
|
count(*)::int AS n
|
||||||
|
FROM
|
||||||
|
lms_practices lp
|
||||||
|
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||||
|
WHERE
|
||||||
|
lp.module_id = $1
|
||||||
|
AND qs.set_type = 'PRACTICE'
|
||||||
|
AND qs.status = 'PUBLISHED'
|
||||||
|
AND lp.publish_status = 'PUBLISHED'
|
||||||
|
`
|
||||||
|
|
||||||
|
// Published practices directly attached to module_id (not via lesson_id).
|
||||||
|
func (q *Queries) CountPublishedDirectPracticesInModule(ctx context.Context, moduleID pgtype.Int8) (int32, error) {
|
||||||
|
row := q.db.QueryRow(ctx, CountPublishedDirectPracticesInModule, moduleID)
|
||||||
|
var n int32
|
||||||
|
err := row.Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
const CountPublishedPracticesInCourse = `-- name: CountPublishedPracticesInCourse :one
|
const CountPublishedPracticesInCourse = `-- name: CountPublishedPracticesInCourse :one
|
||||||
SELECT
|
SELECT
|
||||||
count(*)::int AS n
|
count(*)::int AS n
|
||||||
|
|
@ -349,6 +391,62 @@ func (q *Queries) CountUserCompletedModulesInCourse(ctx context.Context, arg Cou
|
||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CountUserCompletedPublishedDirectPracticesInCourse = `-- name: CountUserCompletedPublishedDirectPracticesInCourse :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.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 CountUserCompletedPublishedDirectPracticesInCourseParams struct {
|
||||||
|
CourseID pgtype.Int8 `json:"course_id"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CountUserCompletedPublishedDirectPracticesInCourse(ctx context.Context, arg CountUserCompletedPublishedDirectPracticesInCourseParams) (int32, error) {
|
||||||
|
row := q.db.QueryRow(ctx, CountUserCompletedPublishedDirectPracticesInCourse, arg.CourseID, arg.UserID)
|
||||||
|
var n int32
|
||||||
|
err := row.Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const CountUserCompletedPublishedDirectPracticesInModule = `-- name: CountUserCompletedPublishedDirectPracticesInModule :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.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 lp.publish_status = 'PUBLISHED'
|
||||||
|
`
|
||||||
|
|
||||||
|
type CountUserCompletedPublishedDirectPracticesInModuleParams struct {
|
||||||
|
ModuleID pgtype.Int8 `json:"module_id"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CountUserCompletedPublishedDirectPracticesInModule(ctx context.Context, arg CountUserCompletedPublishedDirectPracticesInModuleParams) (int32, error) {
|
||||||
|
row := q.db.QueryRow(ctx, CountUserCompletedPublishedDirectPracticesInModule, arg.ModuleID, arg.UserID)
|
||||||
|
var n int32
|
||||||
|
err := row.Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
const CountUserCompletedPublishedPracticesInCourse = `-- name: CountUserCompletedPublishedPracticesInCourse :one
|
const CountUserCompletedPublishedPracticesInCourse = `-- name: CountUserCompletedPublishedPracticesInCourse :one
|
||||||
SELECT
|
SELECT
|
||||||
count(*)::int AS n
|
count(*)::int AS n
|
||||||
|
|
@ -549,9 +647,19 @@ SELECT
|
||||||
FROM
|
FROM
|
||||||
courses AS c1
|
courses AS c1
|
||||||
INNER JOIN courses AS c2 ON c2.program_id = c1.program_id
|
INNER JOIN courses AS c2 ON c2.program_id = c1.program_id
|
||||||
AND c2.sort_order = c1.sort_order - 1
|
AND (
|
||||||
|
c2.sort_order < c1.sort_order
|
||||||
|
OR (
|
||||||
|
c2.sort_order = c1.sort_order
|
||||||
|
AND c2.id < c1.id
|
||||||
|
)
|
||||||
|
)
|
||||||
WHERE
|
WHERE
|
||||||
c1.id = $1
|
c1.id = $1
|
||||||
|
ORDER BY
|
||||||
|
c2.sort_order DESC,
|
||||||
|
c2.id DESC
|
||||||
|
LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetPreviousCourseInProgram(ctx context.Context, id int64) (Course, error) {
|
func (q *Queries) GetPreviousCourseInProgram(ctx context.Context, id int64) (Course, error) {
|
||||||
|
|
@ -617,9 +725,19 @@ SELECT
|
||||||
FROM
|
FROM
|
||||||
modules AS m1
|
modules AS m1
|
||||||
INNER JOIN modules AS m2 ON m2.course_id = m1.course_id
|
INNER JOIN modules AS m2 ON m2.course_id = m1.course_id
|
||||||
AND m2.sort_order = m1.sort_order - 1
|
AND (
|
||||||
|
m2.sort_order < m1.sort_order
|
||||||
|
OR (
|
||||||
|
m2.sort_order = m1.sort_order
|
||||||
|
AND m2.id < m1.id
|
||||||
|
)
|
||||||
|
)
|
||||||
WHERE
|
WHERE
|
||||||
m1.id = $1
|
m1.id = $1
|
||||||
|
ORDER BY
|
||||||
|
m2.sort_order DESC,
|
||||||
|
m2.id DESC
|
||||||
|
LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetPreviousModuleInCourse(ctx context.Context, id int64) (Module, error) {
|
func (q *Queries) GetPreviousModuleInCourse(ctx context.Context, id int64) (Module, error) {
|
||||||
|
|
@ -644,11 +762,23 @@ SELECT
|
||||||
p2.id, p2.name, p2.description, p2.thumbnail, p2.created_at, p2.updated_at, p2.sort_order, p2.category
|
p2.id, p2.name, p2.description, p2.thumbnail, p2.created_at, p2.updated_at, p2.sort_order, p2.category
|
||||||
FROM
|
FROM
|
||||||
programs AS p1
|
programs AS p1
|
||||||
INNER JOIN programs AS p2 ON p2.sort_order = p1.sort_order - 1
|
INNER JOIN programs AS p2 ON p2.category = p1.category
|
||||||
|
AND (
|
||||||
|
p2.sort_order < p1.sort_order
|
||||||
|
OR (
|
||||||
|
p2.sort_order = p1.sort_order
|
||||||
|
AND p2.id < p1.id
|
||||||
|
)
|
||||||
|
)
|
||||||
WHERE
|
WHERE
|
||||||
p1.id = $1
|
p1.id = $1
|
||||||
|
ORDER BY
|
||||||
|
p2.sort_order DESC,
|
||||||
|
p2.id DESC
|
||||||
|
LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// Immediate predecessor by sort_order within the same category (gaps in sort_order are allowed).
|
||||||
func (q *Queries) GetPreviousProgram(ctx context.Context, id int64) (Program, error) {
|
func (q *Queries) GetPreviousProgram(ctx context.Context, id int64) (Program, error) {
|
||||||
row := q.db.QueryRow(ctx, GetPreviousProgram, id)
|
row := q.db.QueryRow(ctx, GetPreviousProgram, id)
|
||||||
var i Program
|
var i Program
|
||||||
|
|
|
||||||
|
|
@ -571,6 +571,8 @@ type User struct {
|
||||||
DeletionRequestedAt pgtype.Timestamptz `json:"deletion_requested_at"`
|
DeletionRequestedAt pgtype.Timestamptz `json:"deletion_requested_at"`
|
||||||
DeletionScheduledAt pgtype.Timestamptz `json:"deletion_scheduled_at"`
|
DeletionScheduledAt pgtype.Timestamptz `json:"deletion_scheduled_at"`
|
||||||
DeletionCancelledAt pgtype.Timestamptz `json:"deletion_cancelled_at"`
|
DeletionCancelledAt pgtype.Timestamptz `json:"deletion_cancelled_at"`
|
||||||
|
AppleID pgtype.Text `json:"apple_id"`
|
||||||
|
AppleEmailVerified pgtype.Bool `json:"apple_email_verified"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserAudioResponse struct {
|
type UserAudioResponse struct {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,73 @@ import (
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const CountPaymentsAdmin = `-- name: CountPaymentsAdmin :one
|
||||||
|
SELECT COUNT(*)::bigint AS total
|
||||||
|
FROM payments p
|
||||||
|
LEFT JOIN subscription_plans sp ON sp.id = p.plan_id
|
||||||
|
WHERE ($1::bigint IS NULL OR p.id = $1::bigint)
|
||||||
|
AND ($2::bigint IS NULL OR p.user_id = $2::bigint)
|
||||||
|
AND ($3::bigint IS NULL OR p.plan_id = $3::bigint)
|
||||||
|
AND ($4::bigint IS NULL OR p.subscription_id = $4::bigint)
|
||||||
|
AND ($5::varchar IS NULL OR p.status = $5::varchar)
|
||||||
|
AND ($6::varchar IS NULL OR UPPER(COALESCE(p.payment_method, '')) = UPPER($6::varchar))
|
||||||
|
AND ($7::varchar IS NULL OR UPPER(p.currency) = UPPER($7::varchar))
|
||||||
|
AND ($8::varchar IS NULL OR sp.category = $8::varchar)
|
||||||
|
AND ($9::timestamptz IS NULL OR p.created_at >= $9::timestamptz)
|
||||||
|
AND ($10::timestamptz IS NULL OR p.created_at < $10::timestamptz)
|
||||||
|
AND ($11::timestamptz IS NULL OR p.paid_at >= $11::timestamptz)
|
||||||
|
AND ($12::timestamptz IS NULL OR p.paid_at < $12::timestamptz)
|
||||||
|
AND ($13::numeric IS NULL OR p.amount >= $13::numeric)
|
||||||
|
AND ($14::numeric IS NULL OR p.amount <= $14::numeric)
|
||||||
|
AND (
|
||||||
|
$15::text IS NULL
|
||||||
|
OR p.session_id ILIKE '%' || $15::text || '%'
|
||||||
|
OR p.nonce ILIKE '%' || $15::text || '%'
|
||||||
|
OR p.transaction_id ILIKE '%' || $15::text || '%'
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
type CountPaymentsAdminParams struct {
|
||||||
|
PaymentID pgtype.Int8 `json:"payment_id"`
|
||||||
|
UserID pgtype.Int8 `json:"user_id"`
|
||||||
|
PlanID pgtype.Int8 `json:"plan_id"`
|
||||||
|
SubscriptionID pgtype.Int8 `json:"subscription_id"`
|
||||||
|
Status pgtype.Text `json:"status"`
|
||||||
|
PaymentMethod pgtype.Text `json:"payment_method"`
|
||||||
|
Currency pgtype.Text `json:"currency"`
|
||||||
|
PlanCategory pgtype.Text `json:"plan_category"`
|
||||||
|
CreatedFrom pgtype.Timestamptz `json:"created_from"`
|
||||||
|
CreatedTo pgtype.Timestamptz `json:"created_to"`
|
||||||
|
PaidFrom pgtype.Timestamptz `json:"paid_from"`
|
||||||
|
PaidTo pgtype.Timestamptz `json:"paid_to"`
|
||||||
|
MinAmount pgtype.Numeric `json:"min_amount"`
|
||||||
|
MaxAmount pgtype.Numeric `json:"max_amount"`
|
||||||
|
Reference pgtype.Text `json:"reference"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CountPaymentsAdmin(ctx context.Context, arg CountPaymentsAdminParams) (int64, error) {
|
||||||
|
row := q.db.QueryRow(ctx, CountPaymentsAdmin,
|
||||||
|
arg.PaymentID,
|
||||||
|
arg.UserID,
|
||||||
|
arg.PlanID,
|
||||||
|
arg.SubscriptionID,
|
||||||
|
arg.Status,
|
||||||
|
arg.PaymentMethod,
|
||||||
|
arg.Currency,
|
||||||
|
arg.PlanCategory,
|
||||||
|
arg.CreatedFrom,
|
||||||
|
arg.CreatedTo,
|
||||||
|
arg.PaidFrom,
|
||||||
|
arg.PaidTo,
|
||||||
|
arg.MinAmount,
|
||||||
|
arg.MaxAmount,
|
||||||
|
arg.Reference,
|
||||||
|
)
|
||||||
|
var total int64
|
||||||
|
err := row.Scan(&total)
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
|
||||||
const CountUserPayments = `-- name: CountUserPayments :one
|
const CountUserPayments = `-- name: CountUserPayments :one
|
||||||
SELECT COUNT(*) FROM payments WHERE user_id = $1
|
SELECT COUNT(*) FROM payments WHERE user_id = $1
|
||||||
`
|
`
|
||||||
|
|
@ -391,6 +458,160 @@ func (q *Queries) LinkPaymentToSubscription(ctx context.Context, arg LinkPayment
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ListPaymentsAdmin = `-- name: ListPaymentsAdmin :many
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
p.user_id,
|
||||||
|
p.plan_id,
|
||||||
|
p.subscription_id,
|
||||||
|
p.session_id,
|
||||||
|
p.transaction_id,
|
||||||
|
p.nonce,
|
||||||
|
p.amount,
|
||||||
|
p.currency,
|
||||||
|
p.payment_method,
|
||||||
|
p.status,
|
||||||
|
p.payment_url,
|
||||||
|
p.paid_at,
|
||||||
|
p.expires_at,
|
||||||
|
p.created_at,
|
||||||
|
p.updated_at,
|
||||||
|
sp.name AS plan_name,
|
||||||
|
sp.category AS plan_category,
|
||||||
|
u.email AS user_email,
|
||||||
|
u.first_name AS user_first_name,
|
||||||
|
u.last_name AS user_last_name
|
||||||
|
FROM payments p
|
||||||
|
LEFT JOIN subscription_plans sp ON sp.id = p.plan_id
|
||||||
|
LEFT JOIN users u ON u.id = p.user_id
|
||||||
|
WHERE ($1::bigint IS NULL OR p.id = $1::bigint)
|
||||||
|
AND ($2::bigint IS NULL OR p.user_id = $2::bigint)
|
||||||
|
AND ($3::bigint IS NULL OR p.plan_id = $3::bigint)
|
||||||
|
AND ($4::bigint IS NULL OR p.subscription_id = $4::bigint)
|
||||||
|
AND ($5::varchar IS NULL OR p.status = $5::varchar)
|
||||||
|
AND ($6::varchar IS NULL OR UPPER(COALESCE(p.payment_method, '')) = UPPER($6::varchar))
|
||||||
|
AND ($7::varchar IS NULL OR UPPER(p.currency) = UPPER($7::varchar))
|
||||||
|
AND ($8::varchar IS NULL OR sp.category = $8::varchar)
|
||||||
|
AND ($9::timestamptz IS NULL OR p.created_at >= $9::timestamptz)
|
||||||
|
AND ($10::timestamptz IS NULL OR p.created_at < $10::timestamptz)
|
||||||
|
AND ($11::timestamptz IS NULL OR p.paid_at >= $11::timestamptz)
|
||||||
|
AND ($12::timestamptz IS NULL OR p.paid_at < $12::timestamptz)
|
||||||
|
AND ($13::numeric IS NULL OR p.amount >= $13::numeric)
|
||||||
|
AND ($14::numeric IS NULL OR p.amount <= $14::numeric)
|
||||||
|
AND (
|
||||||
|
$15::text IS NULL
|
||||||
|
OR p.session_id ILIKE '%' || $15::text || '%'
|
||||||
|
OR p.nonce ILIKE '%' || $15::text || '%'
|
||||||
|
OR p.transaction_id ILIKE '%' || $15::text || '%'
|
||||||
|
)
|
||||||
|
ORDER BY p.created_at DESC
|
||||||
|
LIMIT $17 OFFSET $16
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListPaymentsAdminParams struct {
|
||||||
|
PaymentID pgtype.Int8 `json:"payment_id"`
|
||||||
|
UserID pgtype.Int8 `json:"user_id"`
|
||||||
|
PlanID pgtype.Int8 `json:"plan_id"`
|
||||||
|
SubscriptionID pgtype.Int8 `json:"subscription_id"`
|
||||||
|
Status pgtype.Text `json:"status"`
|
||||||
|
PaymentMethod pgtype.Text `json:"payment_method"`
|
||||||
|
Currency pgtype.Text `json:"currency"`
|
||||||
|
PlanCategory pgtype.Text `json:"plan_category"`
|
||||||
|
CreatedFrom pgtype.Timestamptz `json:"created_from"`
|
||||||
|
CreatedTo pgtype.Timestamptz `json:"created_to"`
|
||||||
|
PaidFrom pgtype.Timestamptz `json:"paid_from"`
|
||||||
|
PaidTo pgtype.Timestamptz `json:"paid_to"`
|
||||||
|
MinAmount pgtype.Numeric `json:"min_amount"`
|
||||||
|
MaxAmount pgtype.Numeric `json:"max_amount"`
|
||||||
|
Reference pgtype.Text `json:"reference"`
|
||||||
|
Offset int32 `json:"offset"`
|
||||||
|
Limit int32 `json:"limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListPaymentsAdminRow struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
PlanID pgtype.Int8 `json:"plan_id"`
|
||||||
|
SubscriptionID pgtype.Int8 `json:"subscription_id"`
|
||||||
|
SessionID pgtype.Text `json:"session_id"`
|
||||||
|
TransactionID pgtype.Text `json:"transaction_id"`
|
||||||
|
Nonce string `json:"nonce"`
|
||||||
|
Amount pgtype.Numeric `json:"amount"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
PaymentMethod pgtype.Text `json:"payment_method"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
PaymentUrl pgtype.Text `json:"payment_url"`
|
||||||
|
PaidAt pgtype.Timestamptz `json:"paid_at"`
|
||||||
|
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
PlanName pgtype.Text `json:"plan_name"`
|
||||||
|
PlanCategory pgtype.Text `json:"plan_category"`
|
||||||
|
UserEmail pgtype.Text `json:"user_email"`
|
||||||
|
UserFirstName pgtype.Text `json:"user_first_name"`
|
||||||
|
UserLastName pgtype.Text `json:"user_last_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListPaymentsAdmin(ctx context.Context, arg ListPaymentsAdminParams) ([]ListPaymentsAdminRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, ListPaymentsAdmin,
|
||||||
|
arg.PaymentID,
|
||||||
|
arg.UserID,
|
||||||
|
arg.PlanID,
|
||||||
|
arg.SubscriptionID,
|
||||||
|
arg.Status,
|
||||||
|
arg.PaymentMethod,
|
||||||
|
arg.Currency,
|
||||||
|
arg.PlanCategory,
|
||||||
|
arg.CreatedFrom,
|
||||||
|
arg.CreatedTo,
|
||||||
|
arg.PaidFrom,
|
||||||
|
arg.PaidTo,
|
||||||
|
arg.MinAmount,
|
||||||
|
arg.MaxAmount,
|
||||||
|
arg.Reference,
|
||||||
|
arg.Offset,
|
||||||
|
arg.Limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListPaymentsAdminRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListPaymentsAdminRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.PlanID,
|
||||||
|
&i.SubscriptionID,
|
||||||
|
&i.SessionID,
|
||||||
|
&i.TransactionID,
|
||||||
|
&i.Nonce,
|
||||||
|
&i.Amount,
|
||||||
|
&i.Currency,
|
||||||
|
&i.PaymentMethod,
|
||||||
|
&i.Status,
|
||||||
|
&i.PaymentUrl,
|
||||||
|
&i.PaidAt,
|
||||||
|
&i.ExpiresAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.PlanName,
|
||||||
|
&i.PlanCategory,
|
||||||
|
&i.UserEmail,
|
||||||
|
&i.UserFirstName,
|
||||||
|
&i.UserLastName,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const UpdatePaymentSessionID = `-- name: UpdatePaymentSessionID :exec
|
const UpdatePaymentSessionID = `-- name: UpdatePaymentSessionID :exec
|
||||||
UPDATE payments
|
UPDATE payments
|
||||||
SET
|
SET
|
||||||
|
|
@ -432,18 +653,18 @@ func (q *Queries) UpdatePaymentStatus(ctx context.Context, arg UpdatePaymentStat
|
||||||
const UpdatePaymentStatusByNonce = `-- name: UpdatePaymentStatusByNonce :exec
|
const UpdatePaymentStatusByNonce = `-- name: UpdatePaymentStatusByNonce :exec
|
||||||
UPDATE payments
|
UPDATE payments
|
||||||
SET
|
SET
|
||||||
status = $1,
|
status = $1::varchar,
|
||||||
transaction_id = COALESCE($2, transaction_id),
|
transaction_id = COALESCE($2::text, transaction_id),
|
||||||
payment_method = COALESCE($3, payment_method),
|
payment_method = COALESCE($3::text, payment_method),
|
||||||
paid_at = CASE WHEN $1 = 'SUCCESS' THEN CURRENT_TIMESTAMP ELSE paid_at END,
|
paid_at = CASE WHEN $1::varchar = 'SUCCESS' THEN CURRENT_TIMESTAMP ELSE paid_at END,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE nonce = $4
|
WHERE nonce = $4::text
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdatePaymentStatusByNonceParams struct {
|
type UpdatePaymentStatusByNonceParams struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
TransactionID pgtype.Text `json:"transaction_id"`
|
TransactionID string `json:"transaction_id"`
|
||||||
PaymentMethod pgtype.Text `json:"payment_method"`
|
PaymentMethod string `json:"payment_method"`
|
||||||
Nonce string `json:"nonce"`
|
Nonce string `json:"nonce"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -460,19 +681,19 @@ func (q *Queries) UpdatePaymentStatusByNonce(ctx context.Context, arg UpdatePaym
|
||||||
const UpdatePaymentStatusBySessionID = `-- name: UpdatePaymentStatusBySessionID :exec
|
const UpdatePaymentStatusBySessionID = `-- name: UpdatePaymentStatusBySessionID :exec
|
||||||
UPDATE payments
|
UPDATE payments
|
||||||
SET
|
SET
|
||||||
status = $1,
|
status = $1::varchar,
|
||||||
transaction_id = COALESCE($2, transaction_id),
|
transaction_id = COALESCE($2::text, transaction_id),
|
||||||
payment_method = COALESCE($3, payment_method),
|
payment_method = COALESCE($3::text, payment_method),
|
||||||
paid_at = CASE WHEN $1 = 'SUCCESS' THEN CURRENT_TIMESTAMP ELSE paid_at END,
|
paid_at = CASE WHEN $1::varchar = 'SUCCESS' THEN CURRENT_TIMESTAMP ELSE paid_at END,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE session_id = $4
|
WHERE session_id = $4::text
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdatePaymentStatusBySessionIDParams struct {
|
type UpdatePaymentStatusBySessionIDParams struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
TransactionID pgtype.Text `json:"transaction_id"`
|
TransactionID string `json:"transaction_id"`
|
||||||
PaymentMethod pgtype.Text `json:"payment_method"`
|
PaymentMethod string `json:"payment_method"`
|
||||||
SessionID pgtype.Text `json:"session_id"`
|
SessionID string `json:"session_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpdatePaymentStatusBySessionID(ctx context.Context, arg UpdatePaymentStatusBySessionIDParams) error {
|
func (q *Queries) UpdatePaymentStatusBySessionID(ctx context.Context, arg UpdatePaymentStatusBySessionIDParams) error {
|
||||||
|
|
|
||||||
|
|
@ -362,6 +362,45 @@ func (q *Queries) GetQuestionSetsContainingQuestion(ctx context.Context, questio
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GetQuestionTypeCountsInSet = `-- name: GetQuestionTypeCountsInSet :many
|
||||||
|
SELECT
|
||||||
|
q.question_type_definition_id,
|
||||||
|
q.question_type,
|
||||||
|
COUNT(*)::bigint AS question_count
|
||||||
|
FROM question_set_items qsi
|
||||||
|
JOIN questions q ON q.id = qsi.question_id
|
||||||
|
WHERE qsi.set_id = $1
|
||||||
|
AND q.status != 'ARCHIVED'
|
||||||
|
GROUP BY q.question_type_definition_id, q.question_type
|
||||||
|
ORDER BY q.question_type
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetQuestionTypeCountsInSetRow struct {
|
||||||
|
QuestionTypeDefinitionID pgtype.Int8 `json:"question_type_definition_id"`
|
||||||
|
QuestionType string `json:"question_type"`
|
||||||
|
QuestionCount int64 `json:"question_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetQuestionTypeCountsInSet(ctx context.Context, setID int64) ([]GetQuestionTypeCountsInSetRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, GetQuestionTypeCountsInSet, setID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetQuestionTypeCountsInSetRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetQuestionTypeCountsInSetRow
|
||||||
|
if err := rows.Scan(&i.QuestionTypeDefinitionID, &i.QuestionType, &i.QuestionCount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const RemoveQuestionFromSet = `-- name: RemoveQuestionFromSet :exec
|
const RemoveQuestionFromSet = `-- name: RemoveQuestionFromSet :exec
|
||||||
DELETE FROM question_set_items
|
DELETE FROM question_set_items
|
||||||
WHERE set_id = $1 AND question_id = $2
|
WHERE set_id = $1 AND question_id = $2
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ INSERT INTO question_sets (
|
||||||
status,
|
status,
|
||||||
intro_video_url
|
intro_video_url
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, false), COALESCE($11, 'DRAFT'), $12)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, false), COALESCE($11, 'PUBLISHED'), $12)
|
||||||
RETURNING id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, display_order, intro_video_url
|
RETURNING id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, display_order, intro_video_url
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,89 @@ func (q *Queries) CheckPhoneEmailExist(ctx context.Context, arg CheckPhoneEmailE
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CreateAppleUser = `-- name: CreateAppleUser :one
|
||||||
|
INSERT INTO users (
|
||||||
|
first_name,
|
||||||
|
last_name,
|
||||||
|
email,
|
||||||
|
apple_id,
|
||||||
|
apple_email_verified,
|
||||||
|
role,
|
||||||
|
status,
|
||||||
|
email_verified
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8
|
||||||
|
)
|
||||||
|
RETURNING id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group, google_id, google_email_verified, profile_completion_percentage, deletion_requested_at, deletion_scheduled_at, deletion_cancelled_at, apple_id, apple_email_verified
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateAppleUserParams struct {
|
||||||
|
FirstName pgtype.Text `json:"first_name"`
|
||||||
|
LastName pgtype.Text `json:"last_name"`
|
||||||
|
Email pgtype.Text `json:"email"`
|
||||||
|
AppleID pgtype.Text `json:"apple_id"`
|
||||||
|
AppleEmailVerified pgtype.Bool `json:"apple_email_verified"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
EmailVerified bool `json:"email_verified"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateAppleUser(ctx context.Context, arg CreateAppleUserParams) (User, error) {
|
||||||
|
row := q.db.QueryRow(ctx, CreateAppleUser,
|
||||||
|
arg.FirstName,
|
||||||
|
arg.LastName,
|
||||||
|
arg.Email,
|
||||||
|
arg.AppleID,
|
||||||
|
arg.AppleEmailVerified,
|
||||||
|
arg.Role,
|
||||||
|
arg.Status,
|
||||||
|
arg.EmailVerified,
|
||||||
|
)
|
||||||
|
var i User
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.FirstName,
|
||||||
|
&i.LastName,
|
||||||
|
&i.Gender,
|
||||||
|
&i.BirthDay,
|
||||||
|
&i.Email,
|
||||||
|
&i.PhoneNumber,
|
||||||
|
&i.Role,
|
||||||
|
&i.Password,
|
||||||
|
&i.EducationLevel,
|
||||||
|
&i.Country,
|
||||||
|
&i.Region,
|
||||||
|
&i.KnowledgeLevel,
|
||||||
|
&i.NickName,
|
||||||
|
&i.Occupation,
|
||||||
|
&i.LearningGoal,
|
||||||
|
&i.LanguageGoal,
|
||||||
|
&i.LanguageChallange,
|
||||||
|
&i.FavouriteTopic,
|
||||||
|
&i.InitialAssessmentCompleted,
|
||||||
|
&i.EmailVerified,
|
||||||
|
&i.PhoneVerified,
|
||||||
|
&i.Status,
|
||||||
|
&i.LastLogin,
|
||||||
|
&i.ProfileCompleted,
|
||||||
|
&i.ProfilePictureUrl,
|
||||||
|
&i.PreferredLanguage,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.AgeGroup,
|
||||||
|
&i.GoogleID,
|
||||||
|
&i.GoogleEmailVerified,
|
||||||
|
&i.ProfileCompletionPercentage,
|
||||||
|
&i.DeletionRequestedAt,
|
||||||
|
&i.DeletionScheduledAt,
|
||||||
|
&i.DeletionCancelledAt,
|
||||||
|
&i.AppleID,
|
||||||
|
&i.AppleEmailVerified,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const CreateGoogleUser = `-- name: CreateGoogleUser :one
|
const CreateGoogleUser = `-- name: CreateGoogleUser :one
|
||||||
INSERT INTO users (
|
INSERT INTO users (
|
||||||
first_name,
|
first_name,
|
||||||
|
|
@ -101,7 +184,7 @@ INSERT INTO users (
|
||||||
VALUES (
|
VALUES (
|
||||||
$1, $2, $3, $4, $5, $6, $7, true, $8
|
$1, $2, $3, $4, $5, $6, $7, true, $8
|
||||||
)
|
)
|
||||||
RETURNING id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group, google_id, google_email_verified, profile_completion_percentage, deletion_requested_at, deletion_scheduled_at, deletion_cancelled_at
|
RETURNING id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group, google_id, google_email_verified, profile_completion_percentage, deletion_requested_at, deletion_scheduled_at, deletion_cancelled_at, apple_id, apple_email_verified
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateGoogleUserParams struct {
|
type CreateGoogleUserParams struct {
|
||||||
|
|
@ -164,6 +247,8 @@ func (q *Queries) CreateGoogleUser(ctx context.Context, arg CreateGoogleUserPara
|
||||||
&i.DeletionRequestedAt,
|
&i.DeletionRequestedAt,
|
||||||
&i.DeletionScheduledAt,
|
&i.DeletionScheduledAt,
|
||||||
&i.DeletionCancelledAt,
|
&i.DeletionCancelledAt,
|
||||||
|
&i.AppleID,
|
||||||
|
&i.AppleEmailVerified,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -621,6 +706,58 @@ func (q *Queries) GetTotalUsers(ctx context.Context, role string) (int64, error)
|
||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GetUserByAppleID = `-- name: GetUserByAppleID :one
|
||||||
|
SELECT id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group, google_id, google_email_verified, profile_completion_percentage, deletion_requested_at, deletion_scheduled_at, deletion_cancelled_at, apple_id, apple_email_verified
|
||||||
|
FROM users
|
||||||
|
WHERE apple_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetUserByAppleID(ctx context.Context, appleID pgtype.Text) (User, error) {
|
||||||
|
row := q.db.QueryRow(ctx, GetUserByAppleID, appleID)
|
||||||
|
var i User
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.FirstName,
|
||||||
|
&i.LastName,
|
||||||
|
&i.Gender,
|
||||||
|
&i.BirthDay,
|
||||||
|
&i.Email,
|
||||||
|
&i.PhoneNumber,
|
||||||
|
&i.Role,
|
||||||
|
&i.Password,
|
||||||
|
&i.EducationLevel,
|
||||||
|
&i.Country,
|
||||||
|
&i.Region,
|
||||||
|
&i.KnowledgeLevel,
|
||||||
|
&i.NickName,
|
||||||
|
&i.Occupation,
|
||||||
|
&i.LearningGoal,
|
||||||
|
&i.LanguageGoal,
|
||||||
|
&i.LanguageChallange,
|
||||||
|
&i.FavouriteTopic,
|
||||||
|
&i.InitialAssessmentCompleted,
|
||||||
|
&i.EmailVerified,
|
||||||
|
&i.PhoneVerified,
|
||||||
|
&i.Status,
|
||||||
|
&i.LastLogin,
|
||||||
|
&i.ProfileCompleted,
|
||||||
|
&i.ProfilePictureUrl,
|
||||||
|
&i.PreferredLanguage,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.AgeGroup,
|
||||||
|
&i.GoogleID,
|
||||||
|
&i.GoogleEmailVerified,
|
||||||
|
&i.ProfileCompletionPercentage,
|
||||||
|
&i.DeletionRequestedAt,
|
||||||
|
&i.DeletionScheduledAt,
|
||||||
|
&i.DeletionCancelledAt,
|
||||||
|
&i.AppleID,
|
||||||
|
&i.AppleEmailVerified,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const GetUserByEmailPhone = `-- name: GetUserByEmailPhone :one
|
const GetUserByEmailPhone = `-- name: GetUserByEmailPhone :one
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -768,7 +905,7 @@ func (q *Queries) GetUserByEmailPhone(ctx context.Context, arg GetUserByEmailPho
|
||||||
}
|
}
|
||||||
|
|
||||||
const GetUserByGoogleID = `-- name: GetUserByGoogleID :one
|
const GetUserByGoogleID = `-- name: GetUserByGoogleID :one
|
||||||
SELECT id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group, google_id, google_email_verified, profile_completion_percentage, deletion_requested_at, deletion_scheduled_at, deletion_cancelled_at
|
SELECT id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group, google_id, google_email_verified, profile_completion_percentage, deletion_requested_at, deletion_scheduled_at, deletion_cancelled_at, apple_id, apple_email_verified
|
||||||
FROM users
|
FROM users
|
||||||
WHERE google_id = $1
|
WHERE google_id = $1
|
||||||
`
|
`
|
||||||
|
|
@ -813,12 +950,14 @@ func (q *Queries) GetUserByGoogleID(ctx context.Context, googleID pgtype.Text) (
|
||||||
&i.DeletionRequestedAt,
|
&i.DeletionRequestedAt,
|
||||||
&i.DeletionScheduledAt,
|
&i.DeletionScheduledAt,
|
||||||
&i.DeletionCancelledAt,
|
&i.DeletionCancelledAt,
|
||||||
|
&i.AppleID,
|
||||||
|
&i.AppleEmailVerified,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const GetUserByID = `-- name: GetUserByID :one
|
const GetUserByID = `-- name: GetUserByID :one
|
||||||
SELECT id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group, google_id, google_email_verified, profile_completion_percentage, deletion_requested_at, deletion_scheduled_at, deletion_cancelled_at
|
SELECT id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group, google_id, google_email_verified, profile_completion_percentage, deletion_requested_at, deletion_scheduled_at, deletion_cancelled_at, apple_id, apple_email_verified
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
@ -863,6 +1002,8 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
|
||||||
&i.DeletionRequestedAt,
|
&i.DeletionRequestedAt,
|
||||||
&i.DeletionScheduledAt,
|
&i.DeletionScheduledAt,
|
||||||
&i.DeletionCancelledAt,
|
&i.DeletionCancelledAt,
|
||||||
|
&i.AppleID,
|
||||||
|
&i.AppleEmailVerified,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -930,6 +1071,26 @@ func (q *Queries) IsUserPending(ctx context.Context, id int64) (bool, error) {
|
||||||
return is_pending, err
|
return is_pending, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LinkAppleAccount = `-- name: LinkAppleAccount :exec
|
||||||
|
UPDATE users
|
||||||
|
SET
|
||||||
|
apple_id = $2,
|
||||||
|
apple_email_verified = $3,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type LinkAppleAccountParams struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
AppleID pgtype.Text `json:"apple_id"`
|
||||||
|
AppleEmailVerified pgtype.Bool `json:"apple_email_verified"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) LinkAppleAccount(ctx context.Context, arg LinkAppleAccountParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, LinkAppleAccount, arg.ID, arg.AppleID, arg.AppleEmailVerified)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const LinkGoogleAccount = `-- name: LinkGoogleAccount :exec
|
const LinkGoogleAccount = `-- name: LinkGoogleAccount :exec
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET
|
SET
|
||||||
|
|
|
||||||
4
go.mod
4
go.mod
|
|
@ -29,7 +29,7 @@ require (
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect
|
||||||
github.com/MicahParks/keyfunc v1.9.0 // indirect
|
github.com/MicahParks/keyfunc v1.9.0 // direct
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect
|
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
|
@ -40,7 +40,7 @@ require (
|
||||||
github.com/go-jose/go-jose/v4 v4.1.2 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.2 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
github.com/golang-jwt/jwt/v4 v4.5.2 // direct
|
||||||
github.com/golang/protobuf v1.5.4 // indirect
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
github.com/google/s2a-go v0.1.9 // indirect
|
github.com/google/s2a-go v0.1.9 // indirect
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
|
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,8 @@ type Config struct {
|
||||||
GoogleOAuthClientID string
|
GoogleOAuthClientID string
|
||||||
GoogleOAuthClientSecret string
|
GoogleOAuthClientSecret string
|
||||||
GoogleOAuthRedirectURL string
|
GoogleOAuthRedirectURL string
|
||||||
|
// AppleSignInClientIDs is a comma-separated list of allowed "aud" values (iOS bundle ID, Services ID, etc.).
|
||||||
|
AppleSignInClientIDs string
|
||||||
AFROSMSConfig AFROSMSConfig `mapstructure:"afro_sms_config"`
|
AFROSMSConfig AFROSMSConfig `mapstructure:"afro_sms_config"`
|
||||||
Vimeo VimeoConfig `mapstructure:"vimeo_config"`
|
Vimeo VimeoConfig `mapstructure:"vimeo_config"`
|
||||||
CloudConvert CloudConvertConfig `mapstructure:"cloudconvert_config"`
|
CloudConvert CloudConvertConfig `mapstructure:"cloudconvert_config"`
|
||||||
|
|
@ -174,6 +176,7 @@ func (c *Config) loadEnv() error {
|
||||||
c.GoogleOAuthClientID = os.Getenv("GOOGLE_OAUTH_CLIENT_ID")
|
c.GoogleOAuthClientID = os.Getenv("GOOGLE_OAUTH_CLIENT_ID")
|
||||||
c.GoogleOAuthClientSecret = os.Getenv("GOOGLE_OAUTH_CLIENT_SECRET")
|
c.GoogleOAuthClientSecret = os.Getenv("GOOGLE_OAUTH_CLIENT_SECRET")
|
||||||
c.GoogleOAuthRedirectURL = os.Getenv("GOOGLE_OAUTH_REDIRECT_URL")
|
c.GoogleOAuthRedirectURL = os.Getenv("GOOGLE_OAUTH_REDIRECT_URL")
|
||||||
|
c.AppleSignInClientIDs = os.Getenv("APPLE_SIGN_IN_CLIENT_IDS")
|
||||||
|
|
||||||
c.APP_VERSION = os.Getenv("APP_VERSION")
|
c.APP_VERSION = os.Getenv("APP_VERSION")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,15 @@ type GoogleUser struct {
|
||||||
Picture string
|
Picture string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AppleUser is populated from a validated Sign in with Apple identity token and optional client-supplied profile fields.
|
||||||
|
type AppleUser struct {
|
||||||
|
ID string
|
||||||
|
Email string
|
||||||
|
VerifiedEmail bool
|
||||||
|
GivenName string
|
||||||
|
FamilyName string
|
||||||
|
}
|
||||||
|
|
||||||
type LoginSuccess struct {
|
type LoginSuccess struct {
|
||||||
UserId int64
|
UserId int64
|
||||||
Role Role
|
Role Role
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,41 @@
|
||||||
package domain
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChapaFlexibleString unmarshals JSON string or number (Chapa verify/webhook payloads vary).
|
||||||
|
type ChapaFlexibleString string
|
||||||
|
|
||||||
|
func (s *ChapaFlexibleString) UnmarshalJSON(data []byte) error {
|
||||||
|
if len(data) == 0 || string(data) == "null" {
|
||||||
|
*s = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch data[0] {
|
||||||
|
case '"':
|
||||||
|
var v string
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*s = ChapaFlexibleString(v)
|
||||||
|
case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-':
|
||||||
|
var n json.Number
|
||||||
|
if err := json.Unmarshal(data, &n); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*s = ChapaFlexibleString(n.String())
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("chapa flexible string: unsupported json type %q", data[0])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ChapaFlexibleString) String() string {
|
||||||
|
return string(s)
|
||||||
|
}
|
||||||
|
|
||||||
// ChapaInitializeRequest is sent to POST /transaction/initialize.
|
// ChapaInitializeRequest is sent to POST /transaction/initialize.
|
||||||
type ChapaInitializeRequest struct {
|
type ChapaInitializeRequest struct {
|
||||||
Amount string `json:"amount"`
|
Amount string `json:"amount"`
|
||||||
|
|
@ -34,7 +70,7 @@ type ChapaVerifyResponse struct {
|
||||||
type ChapaTransactionData struct {
|
type ChapaTransactionData struct {
|
||||||
TxRef string `json:"tx_ref"`
|
TxRef string `json:"tx_ref"`
|
||||||
Reference string `json:"reference"`
|
Reference string `json:"reference"`
|
||||||
Amount string `json:"amount"`
|
Amount ChapaFlexibleString `json:"amount"`
|
||||||
Currency string `json:"currency"`
|
Currency string `json:"currency"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
PaymentMethod string `json:"payment_method"`
|
PaymentMethod string `json:"payment_method"`
|
||||||
|
|
@ -48,7 +84,7 @@ type ChapaWebhookPayload struct {
|
||||||
TxRef string `json:"tx_ref"`
|
TxRef string `json:"tx_ref"`
|
||||||
Reference string `json:"reference"`
|
Reference string `json:"reference"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Amount string `json:"amount"`
|
Amount ChapaFlexibleString `json:"amount"`
|
||||||
Currency string `json:"currency"`
|
Currency string `json:"currency"`
|
||||||
PaymentMethod string `json:"payment_method"`
|
PaymentMethod string `json:"payment_method"`
|
||||||
Mode string `json:"mode"`
|
Mode string `json:"mode"`
|
||||||
|
|
|
||||||
44
internal/domain/chapa_test.go
Normal file
44
internal/domain/chapa_test.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestChapaFlexibleString_UnmarshalJSON(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
want string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "string amount", raw: `"500.00"`, want: "500.00"},
|
||||||
|
{name: "number amount", raw: `500`, want: "500"},
|
||||||
|
{name: "float amount", raw: `499.99`, want: "499.99"},
|
||||||
|
{name: "null", raw: `null`, want: ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var s ChapaFlexibleString
|
||||||
|
err := json.Unmarshal([]byte(tt.raw), &s)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("err=%v wantErr=%v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if s.String() != tt.want {
|
||||||
|
t.Fatalf("got %q want %q", s.String(), tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChapaVerifyResponse_UnmarshalNumberAmount(t *testing.T) {
|
||||||
|
raw := `{"status":"success","message":"ok","data":{"tx_ref":"tx-1","reference":"ref-1","amount":500,"currency":"ETB","status":"success","payment_method":"telebirr"}}`
|
||||||
|
var resp ChapaVerifyResponse
|
||||||
|
if err := json.Unmarshal([]byte(raw), &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Data.Amount.String() != "500" {
|
||||||
|
t.Fatalf("amount=%q want 500", resp.Data.Amount.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ package domain
|
||||||
|
|
||||||
// LMSEntityAccess describes learner gating for a program, course, module, or lesson.
|
// LMSEntityAccess describes learner gating for a program, course, module, or lesson.
|
||||||
// Included for STUDENT and OPEN_LEARNER; omitted (nil) for staff roles in API responses.
|
// 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.
|
// OPEN_LEARNER always has is_accessible and is_completed true; STUDENT reflects real progress and gating.
|
||||||
// Progress fields count completed published practices vs total published practices in the
|
// Progress fields count completed published practices vs total published practices in the
|
||||||
// entity's scope. progress_percent keeps the legacy whole-number value; use
|
// entity's scope. progress_percent keeps the legacy whole-number value; use
|
||||||
// progress_percent_precise for decimal precision in learner UIs.
|
// progress_percent_precise for decimal precision in learner UIs.
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,38 @@ type Payment struct {
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt *time.Time
|
UpdatedAt *time.Time
|
||||||
PlanName *string
|
PlanName *string
|
||||||
|
PlanCategory *string
|
||||||
|
UserEmail *string
|
||||||
|
UserFirstName *string
|
||||||
|
UserLastName *string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaymentListFilter is used by admin payment listing.
|
||||||
|
type PaymentListFilter struct {
|
||||||
|
PaymentID *int64
|
||||||
|
UserID *int64
|
||||||
|
PlanID *int64
|
||||||
|
SubscriptionID *int64
|
||||||
|
Status *string
|
||||||
|
PaymentMethod *string
|
||||||
|
Currency *string
|
||||||
|
PlanCategory *string
|
||||||
|
Reference *string
|
||||||
|
CreatedFrom *time.Time
|
||||||
|
CreatedTo *time.Time
|
||||||
|
PaidFrom *time.Time
|
||||||
|
PaidTo *time.Time
|
||||||
|
MinAmount *float64
|
||||||
|
MaxAmount *float64
|
||||||
|
Limit int32
|
||||||
|
Offset int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaymentListPage struct {
|
||||||
|
Items []Payment
|
||||||
|
Total int64
|
||||||
|
Limit int32
|
||||||
|
Offset int32
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreatePaymentInput struct {
|
type CreatePaymentInput struct {
|
||||||
|
|
|
||||||
|
|
@ -51,10 +51,16 @@ type Practice struct {
|
||||||
QuestionSetID int64 `json:"question_set_id"`
|
QuestionSetID int64 `json:"question_set_id"`
|
||||||
PublishStatus PracticePublishStatus `json:"publish_status"`
|
PublishStatus PracticePublishStatus `json:"publish_status"`
|
||||||
QuickTips *string `json:"quick_tips,omitempty"`
|
QuickTips *string `json:"quick_tips,omitempty"`
|
||||||
|
Access *PracticeAccess `json:"access,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PracticeAccess struct {
|
||||||
|
IsAccessible bool `json:"is_accessible"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// VisibleToLearners is true when the practice shell should appear in subscribed learner catalogs and progression.
|
// VisibleToLearners is true when the practice shell should appear in subscribed learner catalogs and progression.
|
||||||
func (p Practice) VisibleToLearners() bool {
|
func (p Practice) VisibleToLearners() bool {
|
||||||
return p.PublishStatus == PracticePublishPublished
|
return p.PublishStatus == PracticePublishPublished
|
||||||
|
|
|
||||||
|
|
@ -13,15 +13,15 @@ const (
|
||||||
StimulusQuestionText StimulusComponentKind = "QUESTION_TEXT"
|
StimulusQuestionText StimulusComponentKind = "QUESTION_TEXT"
|
||||||
StimulusPrepTime StimulusComponentKind = "PREP_TIME"
|
StimulusPrepTime StimulusComponentKind = "PREP_TIME"
|
||||||
StimulusInstruction StimulusComponentKind = "INSTRUCTION"
|
StimulusInstruction StimulusComponentKind = "INSTRUCTION"
|
||||||
|
// StimulusAudioPrompt is the single stimulus-side audio kind (prompts, clips, listening passages).
|
||||||
StimulusAudioPrompt StimulusComponentKind = "AUDIO_PROMPT"
|
StimulusAudioPrompt StimulusComponentKind = "AUDIO_PROMPT"
|
||||||
StimulusAudioClip StimulusComponentKind = "AUDIO_CLIP"
|
|
||||||
StimulusTextPassage StimulusComponentKind = "TEXT_PASSAGE"
|
StimulusTextPassage StimulusComponentKind = "TEXT_PASSAGE"
|
||||||
StimulusImage StimulusComponentKind = "IMAGE"
|
StimulusImage StimulusComponentKind = "IMAGE"
|
||||||
StimulusChart StimulusComponentKind = "CHART"
|
|
||||||
StimulusMatchingInputs StimulusComponentKind = "MATCHING_INPUTS"
|
StimulusMatchingInputs StimulusComponentKind = "MATCHING_INPUTS"
|
||||||
StimulusSelectMissingWords StimulusComponentKind = "SELECT_MISSING_WORDS"
|
StimulusSelectMissingWords StimulusComponentKind = "SELECT_MISSING_WORDS"
|
||||||
StimulusTable StimulusComponentKind = "TABLE"
|
StimulusTable StimulusComponentKind = "TABLE"
|
||||||
StimulusFlowChart StimulusComponentKind = "FLOW_CHART"
|
// StimulusPDFAttachment is question-side PDF content (URL from MinIO upload or HTTPS).
|
||||||
|
StimulusPDFAttachment StimulusComponentKind = "PDF_ATTACHMENT"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Response-side components for the question-type builder (Section B — answer types).
|
// Response-side components for the question-type builder (Section B — answer types).
|
||||||
|
|
@ -67,14 +67,12 @@ var (
|
||||||
StimulusPrepTime,
|
StimulusPrepTime,
|
||||||
StimulusInstruction,
|
StimulusInstruction,
|
||||||
StimulusAudioPrompt,
|
StimulusAudioPrompt,
|
||||||
StimulusAudioClip,
|
|
||||||
StimulusTextPassage,
|
StimulusTextPassage,
|
||||||
StimulusImage,
|
StimulusImage,
|
||||||
StimulusChart,
|
|
||||||
StimulusMatchingInputs,
|
StimulusMatchingInputs,
|
||||||
StimulusSelectMissingWords,
|
StimulusSelectMissingWords,
|
||||||
StimulusTable,
|
StimulusTable,
|
||||||
StimulusFlowChart,
|
StimulusPDFAttachment,
|
||||||
}
|
}
|
||||||
stimulusSet map[string]struct{}
|
stimulusSet map[string]struct{}
|
||||||
|
|
||||||
|
|
@ -213,6 +211,130 @@ func ValidateDynamicQuestionTypeDefinition(stimulusKinds, responseKinds []string
|
||||||
return fmt.Errorf("%s", strings.Join(errs, "; "))
|
return fmt.Errorf("%s", strings.Join(errs, "; "))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var legacyRuntimeToDefinitionKey = map[string]string{
|
||||||
|
"MCQ": "multiple_choice",
|
||||||
|
"TRUE_FALSE": "true_false",
|
||||||
|
"SHORT_ANSWER": "short_answer",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveQuestionTypeDefinitionForQuestion maps a stored question row to its builder definition.
|
||||||
|
// Legacy rows without question_type_definition_id are matched via runtime question_type and the catalog.
|
||||||
|
func ResolveQuestionTypeDefinitionForQuestion(definitionID *int64, runtimeQuestionType string, catalog []QuestionTypeDefinition) QuestionTypeDefinition {
|
||||||
|
byID := make(map[int64]QuestionTypeDefinition, len(catalog))
|
||||||
|
byKey := make(map[string]QuestionTypeDefinition, len(catalog))
|
||||||
|
for _, d := range catalog {
|
||||||
|
byID[d.ID] = d
|
||||||
|
byKey[d.Key] = d
|
||||||
|
}
|
||||||
|
|
||||||
|
if definitionID != nil && *definitionID > 0 {
|
||||||
|
if d, ok := byID[*definitionID]; ok {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime := strings.ToUpper(strings.TrimSpace(runtimeQuestionType))
|
||||||
|
if key, ok := legacyRuntimeToDefinitionKey[runtime]; ok {
|
||||||
|
if d, ok := byKey[key]; ok {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return QuestionTypeDefinition{Key: key, DisplayName: humanizeDefinitionKey(key)}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch runtime {
|
||||||
|
case "AUDIO":
|
||||||
|
return resolveDefinitionForRuntimeAUDIO(catalog)
|
||||||
|
case "DYNAMIC":
|
||||||
|
return QuestionTypeDefinition{Key: "dynamic", DisplayName: "Dynamic"}
|
||||||
|
default:
|
||||||
|
if runtime == "" {
|
||||||
|
return QuestionTypeDefinition{Key: "unknown", DisplayName: "Unknown"}
|
||||||
|
}
|
||||||
|
key := strings.ToLower(runtime)
|
||||||
|
if d, ok := byKey[key]; ok {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return QuestionTypeDefinition{Key: key, DisplayName: runtime}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveDefinitionForRuntimeAUDIO(catalog []QuestionTypeDefinition) QuestionTypeDefinition {
|
||||||
|
var candidates []QuestionTypeDefinition
|
||||||
|
for _, d := range catalog {
|
||||||
|
if ResolveRuntimeQuestionTypeFromDefinition(d.Key, d.ResponseComponentKinds) == "AUDIO" {
|
||||||
|
candidates = append(candidates, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return QuestionTypeDefinition{Key: "audio", DisplayName: "Audio"}
|
||||||
|
}
|
||||||
|
for _, d := range candidates {
|
||||||
|
if d.Key == "audio_conversation_type" {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(candidates) == 1 {
|
||||||
|
return candidates[0]
|
||||||
|
}
|
||||||
|
for _, d := range candidates {
|
||||||
|
if !d.IsSystem {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func humanizeDefinitionKey(key string) string {
|
||||||
|
parts := strings.Split(strings.ReplaceAll(key, "-", "_"), "_")
|
||||||
|
for i, p := range parts {
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts[i] = strings.ToUpper(p[:1]) + strings.ToLower(p[1:])
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SummarizeQuestionSetQuestionTypes merges raw DB groups into definition-keyed counts for API responses.
|
||||||
|
func SummarizeQuestionSetQuestionTypes(groups []QuestionSetQuestionTypeGroup, catalog []QuestionTypeDefinition) []QuestionSetQuestionTypeCount {
|
||||||
|
merged := make(map[string]*QuestionSetQuestionTypeCount)
|
||||||
|
for _, g := range groups {
|
||||||
|
def := ResolveQuestionTypeDefinitionForQuestion(g.QuestionTypeDefinitionID, g.QuestionType, catalog)
|
||||||
|
entry, ok := merged[def.Key]
|
||||||
|
if !ok {
|
||||||
|
var defID *int64
|
||||||
|
if def.ID > 0 {
|
||||||
|
id := def.ID
|
||||||
|
defID = &id
|
||||||
|
}
|
||||||
|
merged[def.Key] = &QuestionSetQuestionTypeCount{
|
||||||
|
QuestionTypeDefinitionID: defID,
|
||||||
|
Key: def.Key,
|
||||||
|
DisplayName: def.DisplayName,
|
||||||
|
Count: g.Count,
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entry.Count += g.Count
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]QuestionSetQuestionTypeCount, 0, len(merged))
|
||||||
|
for _, v := range merged {
|
||||||
|
out = append(out, *v)
|
||||||
|
}
|
||||||
|
sortQuestionSetQuestionTypeCounts(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortQuestionSetQuestionTypeCounts(counts []QuestionSetQuestionTypeCount) {
|
||||||
|
sort.Slice(counts, func(i, j int) bool {
|
||||||
|
if counts[i].DisplayName == counts[j].DisplayName {
|
||||||
|
return counts[i].Key < counts[j].Key
|
||||||
|
}
|
||||||
|
return counts[i].DisplayName < counts[j].DisplayName
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ResolveRuntimeQuestionTypeFromDefinition derives the legacy runtime question_type code used by
|
// ResolveRuntimeQuestionTypeFromDefinition derives the legacy runtime question_type code used by
|
||||||
// existing question execution paths. Empty string means the definition cannot be executed yet.
|
// existing question execution paths. Empty string means the definition cannot be executed yet.
|
||||||
func ResolveRuntimeQuestionTypeFromDefinition(key string, responseKinds []string) string {
|
func ResolveRuntimeQuestionTypeFromDefinition(key string, responseKinds []string) string {
|
||||||
|
|
@ -432,3 +554,80 @@ func onlyAuxiliaryResponseKinds(response []string) bool {
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stimulusTextKindsForQuestionText are checked in order when deriving question_text from dynamic_payload.
|
||||||
|
var stimulusTextKindsForQuestionText = []string{
|
||||||
|
string(StimulusQuestionText),
|
||||||
|
string(StimulusInstruction),
|
||||||
|
string(StimulusTextPassage),
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsesDynamicQuestionPayload reports whether the runtime type stores prompt content in dynamic_payload only.
|
||||||
|
func UsesDynamicQuestionPayload(questionType string) bool {
|
||||||
|
return strings.ToUpper(strings.TrimSpace(questionType)) == "DYNAMIC"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateQuestionTextNotAllowedForDynamic rejects top-level question_text on DYNAMIC create/update requests.
|
||||||
|
func ValidateQuestionTextNotAllowedForDynamic(questionType string, explicit string) error {
|
||||||
|
if !UsesDynamicQuestionPayload(questionType) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(explicit) != "" {
|
||||||
|
return fmt.Errorf("question_text is not used for DYNAMIC questions; set prompt text in dynamic_payload stimulus (QUESTION_TEXT, INSTRUCTION, or TEXT_PASSAGE)")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveDynamicStoredQuestionText returns the questions.question_text column value for DYNAMIC rows
|
||||||
|
// (search/activity logs only; clients read prompt text from dynamic_payload).
|
||||||
|
func ResolveDynamicStoredQuestionText(payload *DynamicQuestionPayload, existing string) (string, error) {
|
||||||
|
if derived := StimulusTextFromPayload(payload); derived != "" {
|
||||||
|
return derived, nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(existing) != "" {
|
||||||
|
return strings.TrimSpace(existing), nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("dynamic_payload must include prompt text (QUESTION_TEXT, INSTRUCTION, or TEXT_PASSAGE)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuestionTextJSONField returns question_text for API responses. Omitted (nil) for DYNAMIC questions.
|
||||||
|
func QuestionTextJSONField(questionType string, stored string) *string {
|
||||||
|
if UsesDynamicQuestionPayload(questionType) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
stored = strings.TrimSpace(stored)
|
||||||
|
if stored == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &stored
|
||||||
|
}
|
||||||
|
|
||||||
|
// StimulusTextFromPayload extracts the first non-empty prompt string from allowed stimulus kinds.
|
||||||
|
func StimulusTextFromPayload(payload *DynamicQuestionPayload) string {
|
||||||
|
if payload == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, want := range stimulusTextKindsForQuestionText {
|
||||||
|
for _, el := range payload.Stimulus {
|
||||||
|
if strings.TrimSpace(el.Kind) != want {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s := stringFromDynamicElementValue(el.Value); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringFromDynamicElementValue(v interface{}) string {
|
||||||
|
if v == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch t := v.(type) {
|
||||||
|
case string:
|
||||||
|
return strings.TrimSpace(t)
|
||||||
|
default:
|
||||||
|
return strings.TrimSpace(fmt.Sprint(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,20 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestStimulusComponentCatalog_includesPDFAttachment(t *testing.T) {
|
||||||
|
catalog := StimulusComponentCatalog()
|
||||||
|
found := false
|
||||||
|
for _, k := range catalog {
|
||||||
|
if k == string(StimulusPDFAttachment) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("expected PDF_ATTACHMENT in stimulus catalog, got %v", catalog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateDynamicQuestionTypeDefinition_valid(t *testing.T) {
|
func TestValidateDynamicQuestionTypeDefinition_valid(t *testing.T) {
|
||||||
err := ValidateDynamicQuestionTypeDefinition(
|
err := ValidateDynamicQuestionTypeDefinition(
|
||||||
[]string{"INSTRUCTION", "IMAGE"},
|
[]string{"INSTRUCTION", "IMAGE"},
|
||||||
|
|
@ -15,6 +29,16 @@ func TestValidateDynamicQuestionTypeDefinition_valid(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateDynamicQuestionTypeDefinition_pdfAttachmentStimulus(t *testing.T) {
|
||||||
|
err := ValidateDynamicQuestionTypeDefinition(
|
||||||
|
[]string{"QUESTION_TEXT", "PDF_ATTACHMENT"},
|
||||||
|
[]string{"SHORT_ANSWER"},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateDynamicQuestionTypeDefinition_unknownStimulus(t *testing.T) {
|
func TestValidateDynamicQuestionTypeDefinition_unknownStimulus(t *testing.T) {
|
||||||
err := ValidateDynamicQuestionTypeDefinition(
|
err := ValidateDynamicQuestionTypeDefinition(
|
||||||
[]string{"NOT_A_KIND"},
|
[]string{"NOT_A_KIND"},
|
||||||
|
|
@ -139,3 +163,114 @@ func TestValidateDynamicPayloadAgainstDefinition_requiredMissing(t *testing.T) {
|
||||||
t.Fatalf("expected required element error, got %v", err)
|
t.Fatalf("expected required element error, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testQuestionTypeCatalog() []QuestionTypeDefinition {
|
||||||
|
return []QuestionTypeDefinition{
|
||||||
|
{ID: 1, Key: "multiple_choice", DisplayName: "Multiple Choice", IsSystem: true},
|
||||||
|
{ID: 2, Key: "true_false", DisplayName: "True / False", IsSystem: true},
|
||||||
|
{ID: 3, Key: "fill_in_the_blank", DisplayName: "Fill In The Blank", IsSystem: true},
|
||||||
|
{ID: 4, Key: "short_answer", DisplayName: "Short Answer", IsSystem: true},
|
||||||
|
{
|
||||||
|
ID: 10,
|
||||||
|
Key: "audio_conversation_type",
|
||||||
|
DisplayName: "Audio Conversation Type",
|
||||||
|
ResponseComponentKinds: []string{"AUDIO_RESPONSE", "TEXT_INPUT"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveQuestionTypeDefinitionForQuestion_legacyMCQ(t *testing.T) {
|
||||||
|
def := ResolveQuestionTypeDefinitionForQuestion(nil, "MCQ", testQuestionTypeCatalog())
|
||||||
|
if def.Key != "multiple_choice" {
|
||||||
|
t.Fatalf("expected multiple_choice, got %q", def.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveQuestionTypeDefinitionForQuestion_legacyAUDIO(t *testing.T) {
|
||||||
|
def := ResolveQuestionTypeDefinitionForQuestion(nil, "AUDIO", testQuestionTypeCatalog())
|
||||||
|
if def.Key != "audio_conversation_type" {
|
||||||
|
t.Fatalf("expected audio_conversation_type, got %q", def.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveQuestionTypeDefinitionForQuestion_linkedDefinition(t *testing.T) {
|
||||||
|
id := int64(10)
|
||||||
|
def := ResolveQuestionTypeDefinitionForQuestion(&id, "DYNAMIC", testQuestionTypeCatalog())
|
||||||
|
if def.Key != "audio_conversation_type" {
|
||||||
|
t.Fatalf("expected audio_conversation_type, got %q", def.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateQuestionTextNotAllowedForDynamic(t *testing.T) {
|
||||||
|
if err := ValidateQuestionTextNotAllowedForDynamic("DYNAMIC", "nope"); err == nil {
|
||||||
|
t.Fatal("expected error when question_text sent for DYNAMIC")
|
||||||
|
}
|
||||||
|
if err := ValidateQuestionTextNotAllowedForDynamic("DYNAMIC", ""); err != nil {
|
||||||
|
t.Fatalf("expected nil for empty explicit, got %v", err)
|
||||||
|
}
|
||||||
|
if err := ValidateQuestionTextNotAllowedForDynamic("MCQ", "ok"); err != nil {
|
||||||
|
t.Fatalf("expected nil for legacy, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDynamicStoredQuestionText_fromQUESTION_TEXTStimulus(t *testing.T) {
|
||||||
|
payload := &DynamicQuestionPayload{
|
||||||
|
Stimulus: []DynamicElementInstance{
|
||||||
|
{ID: "prompt", Kind: "QUESTION_TEXT", Value: "Pick the correct answer."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got, err := ResolveDynamicStoredQuestionText(payload, "")
|
||||||
|
if err != nil || got != "Pick the correct answer." {
|
||||||
|
t.Fatalf("expected derived text, got %q err=%v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDynamicStoredQuestionText_fromINSTRUCTIONStimulus(t *testing.T) {
|
||||||
|
payload := &DynamicQuestionPayload{
|
||||||
|
Stimulus: []DynamicElementInstance{
|
||||||
|
{ID: "prompt", Kind: "INSTRUCTION", Value: "Choose true or false."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got, err := ResolveDynamicStoredQuestionText(payload, "")
|
||||||
|
if err != nil || got != "Choose true or false." {
|
||||||
|
t.Fatalf("expected derived text, got %q err=%v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDynamicStoredQuestionText_missingText(t *testing.T) {
|
||||||
|
_, err := ResolveDynamicStoredQuestionText(&DynamicQuestionPayload{}, "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when no prompt in payload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDynamicStoredQuestionText_updateKeepsExisting(t *testing.T) {
|
||||||
|
got, err := ResolveDynamicStoredQuestionText(nil, "Previous title")
|
||||||
|
if err != nil || got != "Previous title" {
|
||||||
|
t.Fatalf("expected existing text, got %q err=%v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuestionTextJSONField_omitsDynamic(t *testing.T) {
|
||||||
|
if got := QuestionTextJSONField("DYNAMIC", "stored"); got != nil {
|
||||||
|
t.Fatalf("expected nil for DYNAMIC, got %v", got)
|
||||||
|
}
|
||||||
|
if got := QuestionTextJSONField("MCQ", "Pick one"); got == nil || *got != "Pick one" {
|
||||||
|
t.Fatalf("expected MCQ text, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSummarizeQuestionSetQuestionTypes_mergesLegacyAndLinked(t *testing.T) {
|
||||||
|
id := int64(10)
|
||||||
|
summary := SummarizeQuestionSetQuestionTypes([]QuestionSetQuestionTypeGroup{
|
||||||
|
{QuestionType: "AUDIO", Count: 7},
|
||||||
|
{QuestionTypeDefinitionID: &id, QuestionType: "DYNAMIC", Count: 3},
|
||||||
|
}, testQuestionTypeCatalog())
|
||||||
|
|
||||||
|
if len(summary) != 1 {
|
||||||
|
t.Fatalf("expected 1 merged entry, got %d", len(summary))
|
||||||
|
}
|
||||||
|
if summary[0].Key != "audio_conversation_type" || summary[0].Count != 10 {
|
||||||
|
t.Fatalf("unexpected summary: %+v", summary[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,28 @@ type QuestionSetItem struct {
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QuestionSetQuestionTypeGroup is a raw DB aggregate before resolving builder definitions.
|
||||||
|
type QuestionSetQuestionTypeGroup struct {
|
||||||
|
QuestionTypeDefinitionID *int64
|
||||||
|
QuestionType string
|
||||||
|
Count int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuestionSetQuestionTypeCount is one builder question type present in a set with how many questions use it.
|
||||||
|
type QuestionSetQuestionTypeCount struct {
|
||||||
|
QuestionTypeDefinitionID *int64 `json:"question_type_definition_id,omitempty"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuestionSetQuestionTypesSummary summarizes distinct question types in a question set (e.g. linked practice).
|
||||||
|
type QuestionSetQuestionTypesSummary struct {
|
||||||
|
QuestionSetID int64 `json:"question_set_id"`
|
||||||
|
QuestionTypes []QuestionSetQuestionTypeCount `json:"question_types"`
|
||||||
|
TotalQuestions int64 `json:"total_questions"`
|
||||||
|
}
|
||||||
|
|
||||||
type QuestionSetItemWithQuestion struct {
|
type QuestionSetItemWithQuestion struct {
|
||||||
QuestionSetItem
|
QuestionSetItem
|
||||||
QuestionText string
|
QuestionText string
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ const (
|
||||||
RoleSuperAdmin Role = "SUPER_ADMIN"
|
RoleSuperAdmin Role = "SUPER_ADMIN"
|
||||||
RoleAdmin Role = "ADMIN"
|
RoleAdmin Role = "ADMIN"
|
||||||
RoleStudent Role = "STUDENT"
|
RoleStudent Role = "STUDENT"
|
||||||
// RoleOpenLearner can consume LMS content like a learner but without sequential prerequisite locking (step-by-step gates).
|
// RoleOpenLearner can consume LMS content like a learner without sequential locks; access APIs show all content accessible and completed.
|
||||||
RoleOpenLearner Role = "OPEN_LEARNER"
|
RoleOpenLearner Role = "OPEN_LEARNER"
|
||||||
RoleInstructor Role = "INSTRUCTOR"
|
RoleInstructor Role = "INSTRUCTOR"
|
||||||
RoleSupport Role = "SUPPORT"
|
RoleSupport Role = "SUPPORT"
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type NotificationStore interface {
|
type NotificationStore interface {
|
||||||
|
GetNotification(ctx context.Context, id int64) (*domain.Notification, error)
|
||||||
GetUserNotifications(ctx context.Context, recipientID int64, limit, offset int) ([]domain.Notification, int64, error)
|
GetUserNotifications(ctx context.Context, recipientID int64, limit, offset int) ([]domain.Notification, int64, error)
|
||||||
CountUnreadNotifications(ctx context.Context, recipient_id int64) (int64, error)
|
CountUnreadNotifications(ctx context.Context, recipient_id int64) (int64, error)
|
||||||
GetAllNotifications(ctx context.Context, limit, offset int) ([]domain.Notification, error)
|
GetAllNotifications(ctx context.Context, limit, offset int) ([]domain.Notification, error)
|
||||||
|
|
|
||||||
|
|
@ -20,4 +20,5 @@ type PaymentStore interface {
|
||||||
LinkPaymentToSubscription(ctx context.Context, paymentID, subscriptionID int64) error
|
LinkPaymentToSubscription(ctx context.Context, paymentID, subscriptionID int64) error
|
||||||
GetExpiredPendingPayments(ctx context.Context) ([]domain.Payment, error)
|
GetExpiredPendingPayments(ctx context.Context) ([]domain.Payment, error)
|
||||||
ExpirePayment(ctx context.Context, id int64) error
|
ExpirePayment(ctx context.Context, id int64) error
|
||||||
|
ListPaymentsAdmin(ctx context.Context, filter domain.PaymentListFilter) (domain.PaymentListPage, error)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ type QuestionStore interface {
|
||||||
// Question Type Definitions (dynamic builder presets)
|
// Question Type Definitions (dynamic builder presets)
|
||||||
CreateQuestionTypeDefinition(ctx context.Context, input domain.CreateQuestionTypeDefinitionInput) (domain.QuestionTypeDefinition, error)
|
CreateQuestionTypeDefinition(ctx context.Context, input domain.CreateQuestionTypeDefinitionInput) (domain.QuestionTypeDefinition, error)
|
||||||
GetQuestionTypeDefinitionByID(ctx context.Context, id int64) (domain.QuestionTypeDefinition, error)
|
GetQuestionTypeDefinitionByID(ctx context.Context, id int64) (domain.QuestionTypeDefinition, error)
|
||||||
ListQuestionTypeDefinitions(ctx context.Context, status *string, includeSystem bool) ([]domain.QuestionTypeDefinition, error)
|
ListQuestionTypeDefinitions(ctx context.Context, status *string, includeSystem bool, limit, offset int32) ([]domain.QuestionTypeDefinition, int64, error)
|
||||||
UpdateQuestionTypeDefinition(ctx context.Context, id int64, input domain.UpdateQuestionTypeDefinitionInput) error
|
UpdateQuestionTypeDefinition(ctx context.Context, id int64, input domain.UpdateQuestionTypeDefinitionInput) error
|
||||||
DeleteQuestionTypeDefinition(ctx context.Context, id int64) error
|
DeleteQuestionTypeDefinition(ctx context.Context, id int64) error
|
||||||
|
|
||||||
|
|
@ -58,6 +58,7 @@ type QuestionStore interface {
|
||||||
RemoveQuestionFromSet(ctx context.Context, setID, questionID int64) error
|
RemoveQuestionFromSet(ctx context.Context, setID, questionID int64) error
|
||||||
UpdateQuestionOrder(ctx context.Context, setID, questionID int64, displayOrder int32) error
|
UpdateQuestionOrder(ctx context.Context, setID, questionID int64, displayOrder int32) error
|
||||||
CountQuestionsInSet(ctx context.Context, setID int64) (int64, error)
|
CountQuestionsInSet(ctx context.Context, setID int64) (int64, error)
|
||||||
|
GetQuestionTypeCountsInSet(ctx context.Context, setID int64) ([]domain.QuestionSetQuestionTypeGroup, error)
|
||||||
GetQuestionSetsContainingQuestion(ctx context.Context, questionID int64) ([]domain.QuestionSet, error)
|
GetQuestionSetsContainingQuestion(ctx context.Context, questionID int64) ([]domain.QuestionSet, error)
|
||||||
|
|
||||||
// User Personas in Question Sets
|
// User Personas in Question Sets
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ type ProfileCompletionStatus struct {
|
||||||
type UserStore interface {
|
type UserStore interface {
|
||||||
CreateGoogleUser(ctx context.Context, gUser domain.GoogleUser) (domain.User, error)
|
CreateGoogleUser(ctx context.Context, gUser domain.GoogleUser) (domain.User, error)
|
||||||
LinkGoogleAccount(ctx context.Context, userID int64, googleID string) error
|
LinkGoogleAccount(ctx context.Context, userID int64, googleID string) error
|
||||||
|
CreateAppleUser(ctx context.Context, aUser domain.AppleUser) (domain.User, error)
|
||||||
|
LinkAppleAccount(ctx context.Context, userID int64, appleID string, emailVerified bool) error
|
||||||
GetProfileCompletionStatus(ctx context.Context, userId int64) (ProfileCompletionStatus, error)
|
GetProfileCompletionStatus(ctx context.Context, userId int64) (ProfileCompletionStatus, error)
|
||||||
UpdateUserStatus(ctx context.Context, req domain.UpdateUserStatusReq) error
|
UpdateUserStatus(ctx context.Context, req domain.UpdateUserStatusReq) error
|
||||||
// GetCorrectOptionForQuestion(
|
// GetCorrectOptionForQuestion(
|
||||||
|
|
@ -45,6 +47,10 @@ type UserStore interface {
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
googleId string,
|
googleId string,
|
||||||
) (domain.User, error)
|
) (domain.User, error)
|
||||||
|
GetUserByAppleID(
|
||||||
|
ctx context.Context,
|
||||||
|
appleID string,
|
||||||
|
) (domain.User, error)
|
||||||
GetUserByID(
|
GetUserByID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
id int64,
|
id int64,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,21 @@ import (
|
||||||
dbgen "Yimaru-Backend/gen/db"
|
dbgen "Yimaru-Backend/gen/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ExamPrepCountPublishedPracticesInLesson returns published practices attached to an exam-prep lesson.
|
||||||
|
func (s *Store) ExamPrepCountPublishedPracticesInLesson(ctx context.Context, lessonID int64) (int32, error) {
|
||||||
|
return s.queries.CountPublishedExamPrepPracticesInLesson(ctx, lessonID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExamPrepCountPublishedPracticesInModule returns published practices under an exam-prep module (via lessons).
|
||||||
|
func (s *Store) ExamPrepCountPublishedPracticesInModule(ctx context.Context, moduleID int64) (int32, error) {
|
||||||
|
return s.queries.CountPublishedExamPrepPracticesInModule(ctx, moduleID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExamPrepCountPublishedPracticesInUnit returns published practices under an exam-prep unit.
|
||||||
|
func (s *Store) ExamPrepCountPublishedPracticesInUnit(ctx context.Context, unitID int64) (int32, error) {
|
||||||
|
return s.queries.CountPublishedExamPrepPracticesInUnit(ctx, unitID)
|
||||||
|
}
|
||||||
|
|
||||||
// ExamPrepUserPracticeProgressInLesson returns published practice completion counts scoped to an exam-prep lesson.
|
// 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) {
|
func (s *Store) ExamPrepUserPracticeProgressInLesson(ctx context.Context, userID, lessonID int64) (completed, total int32, err error) {
|
||||||
total, err = s.queries.CountPublishedExamPrepPracticesInLesson(ctx, lessonID)
|
total, err = s.queries.CountPublishedExamPrepPracticesInLesson(ctx, lessonID)
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,21 @@ func (s *Store) LmsUserHasLessonProgress(ctx context.Context, userID, lessonID i
|
||||||
return s.queries.UserHasLessonProgress(ctx, dbgen.UserHasLessonProgressParams{UserID: userID, LessonID: lessonID})
|
return s.queries.UserHasLessonProgress(ctx, dbgen.UserHasLessonProgressParams{UserID: userID, LessonID: lessonID})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LmsCountPublishedPracticesInLesson returns how many published practices are attached to a lesson.
|
||||||
|
func (s *Store) LmsCountPublishedPracticesInLesson(ctx context.Context, lessonID int64) (int32, error) {
|
||||||
|
return s.queries.CountPublishedPracticesInLesson(ctx, toPgInt8(&lessonID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// LmsCountPublishedPracticesInModule returns published practices in a module (direct + lesson-attached).
|
||||||
|
func (s *Store) LmsCountPublishedPracticesInModule(ctx context.Context, moduleID int64) (int32, error) {
|
||||||
|
return s.queries.CountPublishedPracticesInModule(ctx, toPgInt8(&moduleID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// LmsCountPublishedPracticesInCourse returns published practices in a course (direct + module + lesson scope).
|
||||||
|
func (s *Store) LmsCountPublishedPracticesInCourse(ctx context.Context, courseID int64) (int32, error) {
|
||||||
|
return s.queries.CountPublishedPracticesInCourse(ctx, toPgInt8(&courseID))
|
||||||
|
}
|
||||||
|
|
||||||
// LmsUserPracticeProgressInLesson returns published practice completion counts scoped to a lesson.
|
// 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) {
|
func (s *Store) LmsUserPracticeProgressInLesson(ctx context.Context, userID, lessonID int64) (completed, total int32, err error) {
|
||||||
lessonIDPG := toPgInt8(&lessonID)
|
lessonIDPG := toPgInt8(&lessonID)
|
||||||
|
|
@ -102,3 +117,35 @@ func (s *Store) LmsUserLessonProgressInProgram(ctx context.Context, userID, prog
|
||||||
}
|
}
|
||||||
return completed, total, nil
|
return completed, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) LmsUserDirectPracticeProgressInModule(ctx context.Context, userID, moduleID int64) (completed, total int32, err error) {
|
||||||
|
moduleIDPG := toPgInt8(&moduleID)
|
||||||
|
total, err = s.queries.CountPublishedDirectPracticesInModule(ctx, moduleIDPG)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
completed, err = s.queries.CountUserCompletedPublishedDirectPracticesInModule(ctx, dbgen.CountUserCompletedPublishedDirectPracticesInModuleParams{
|
||||||
|
ModuleID: moduleIDPG,
|
||||||
|
UserID: userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
return completed, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) LmsUserDirectPracticeProgressInCourse(ctx context.Context, userID, courseID int64) (completed, total int32, err error) {
|
||||||
|
courseIDPG := toPgInt8(&courseID)
|
||||||
|
total, err = s.queries.CountPublishedDirectPracticesInCourse(ctx, courseIDPG)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
completed, err = s.queries.CountUserCompletedPublishedDirectPracticesInCourse(ctx, dbgen.CountUserCompletedPublishedDirectPracticesInCourseParams{
|
||||||
|
CourseID: courseIDPG,
|
||||||
|
UserID: userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
return completed, total, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,14 @@ package repository
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
dbgen "Yimaru-Backend/gen/db"
|
dbgen "Yimaru-Backend/gen/db"
|
||||||
"Yimaru-Backend/internal/domain"
|
"Yimaru-Backend/internal/domain"
|
||||||
"Yimaru-Backend/internal/ports"
|
"Yimaru-Backend/internal/ports"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -53,6 +55,20 @@ func (r *Store) CreateNotification(
|
||||||
Read
|
Read
|
||||||
========================= */
|
========================= */
|
||||||
|
|
||||||
|
func (r *Store) GetNotification(
|
||||||
|
ctx context.Context,
|
||||||
|
id int64,
|
||||||
|
) (*domain.Notification, error) {
|
||||||
|
dbNotif, err := r.queries.GetNotification(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return mapDBToDomain(&dbNotif), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Store) GetUserNotifications(
|
func (r *Store) GetUserNotifications(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
userID int64,
|
userID int64,
|
||||||
|
|
|
||||||
|
|
@ -119,17 +119,17 @@ func (s *Store) UpdatePaymentStatus(ctx context.Context, id int64, status string
|
||||||
func (s *Store) UpdatePaymentStatusBySessionID(ctx context.Context, sessionID, status, transactionID, paymentMethod string) error {
|
func (s *Store) UpdatePaymentStatusBySessionID(ctx context.Context, sessionID, status, transactionID, paymentMethod string) error {
|
||||||
return s.queries.UpdatePaymentStatusBySessionID(ctx, dbgen.UpdatePaymentStatusBySessionIDParams{
|
return s.queries.UpdatePaymentStatusBySessionID(ctx, dbgen.UpdatePaymentStatusBySessionIDParams{
|
||||||
Status: status,
|
Status: status,
|
||||||
TransactionID: toPgText(&transactionID),
|
TransactionID: transactionID,
|
||||||
PaymentMethod: toPgText(&paymentMethod),
|
PaymentMethod: paymentMethod,
|
||||||
SessionID: toPgText(&sessionID),
|
SessionID: sessionID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UpdatePaymentStatusByNonce(ctx context.Context, nonce, status, transactionID, paymentMethod string) error {
|
func (s *Store) UpdatePaymentStatusByNonce(ctx context.Context, nonce, status, transactionID, paymentMethod string) error {
|
||||||
return s.queries.UpdatePaymentStatusByNonce(ctx, dbgen.UpdatePaymentStatusByNonceParams{
|
return s.queries.UpdatePaymentStatusByNonce(ctx, dbgen.UpdatePaymentStatusByNonceParams{
|
||||||
Status: status,
|
Status: status,
|
||||||
TransactionID: toPgText(&transactionID),
|
TransactionID: transactionID,
|
||||||
PaymentMethod: toPgText(&paymentMethod),
|
PaymentMethod: paymentMethod,
|
||||||
Nonce: nonce,
|
Nonce: nonce,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -166,6 +166,103 @@ func (s *Store) ExpirePayment(ctx context.Context, id int64) error {
|
||||||
return s.queries.ExpirePayment(ctx, id)
|
return s.queries.ExpirePayment(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListPaymentsAdmin(ctx context.Context, filter domain.PaymentListFilter) (domain.PaymentListPage, error) {
|
||||||
|
params := buildPaymentsAdminFilterParams(filter)
|
||||||
|
|
||||||
|
total, err := s.queries.CountPaymentsAdmin(ctx, dbgen.CountPaymentsAdminParams(params))
|
||||||
|
if err != nil {
|
||||||
|
return domain.PaymentListPage{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.queries.ListPaymentsAdmin(ctx, dbgen.ListPaymentsAdminParams{
|
||||||
|
PaymentID: int64PtrToPgInt8(filter.PaymentID),
|
||||||
|
UserID: params.UserID,
|
||||||
|
PlanID: params.PlanID,
|
||||||
|
SubscriptionID: params.SubscriptionID,
|
||||||
|
Status: params.Status,
|
||||||
|
PaymentMethod: params.PaymentMethod,
|
||||||
|
Currency: params.Currency,
|
||||||
|
PlanCategory: params.PlanCategory,
|
||||||
|
CreatedFrom: params.CreatedFrom,
|
||||||
|
CreatedTo: params.CreatedTo,
|
||||||
|
PaidFrom: params.PaidFrom,
|
||||||
|
PaidTo: params.PaidTo,
|
||||||
|
MinAmount: params.MinAmount,
|
||||||
|
MaxAmount: params.MaxAmount,
|
||||||
|
Reference: params.Reference,
|
||||||
|
Limit: filter.Limit,
|
||||||
|
Offset: filter.Offset,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return domain.PaymentListPage{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]domain.Payment, len(rows))
|
||||||
|
for i, row := range rows {
|
||||||
|
items[i] = paymentAdminRowToDomain(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain.PaymentListPage{
|
||||||
|
Items: items,
|
||||||
|
Total: total,
|
||||||
|
Limit: filter.Limit,
|
||||||
|
Offset: filter.Offset,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPaymentsAdminFilterParams(filter domain.PaymentListFilter) dbgen.CountPaymentsAdminParams {
|
||||||
|
return dbgen.CountPaymentsAdminParams{
|
||||||
|
PaymentID: int64PtrToPgInt8(filter.PaymentID),
|
||||||
|
UserID: int64PtrToPgInt8(filter.UserID),
|
||||||
|
PlanID: int64PtrToPgInt8(filter.PlanID),
|
||||||
|
SubscriptionID: int64PtrToPgInt8(filter.SubscriptionID),
|
||||||
|
Status: toPgText(filter.Status),
|
||||||
|
PaymentMethod: toPgText(filter.PaymentMethod),
|
||||||
|
Currency: toPgText(filter.Currency),
|
||||||
|
PlanCategory: toPgText(filter.PlanCategory),
|
||||||
|
CreatedFrom: toPgTimestamptzPtr(filter.CreatedFrom),
|
||||||
|
CreatedTo: toPgTimestamptzPtr(filter.CreatedTo),
|
||||||
|
PaidFrom: toPgTimestamptzPtr(filter.PaidFrom),
|
||||||
|
PaidTo: toPgTimestamptzPtr(filter.PaidTo),
|
||||||
|
MinAmount: toPgNumericPtr(filter.MinAmount),
|
||||||
|
MaxAmount: toPgNumericPtr(filter.MaxAmount),
|
||||||
|
Reference: toPgText(filter.Reference),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func paymentAdminRowToDomain(row dbgen.ListPaymentsAdminRow) domain.Payment {
|
||||||
|
return domain.Payment{
|
||||||
|
ID: row.ID,
|
||||||
|
UserID: row.UserID,
|
||||||
|
PlanID: int8PtrToInt64Ptr(row.PlanID),
|
||||||
|
SubscriptionID: int8PtrToInt64Ptr(row.SubscriptionID),
|
||||||
|
SessionID: fromPgTextPtr(row.SessionID),
|
||||||
|
TransactionID: fromPgTextPtr(row.TransactionID),
|
||||||
|
Nonce: row.Nonce,
|
||||||
|
Amount: fromPgNumeric(row.Amount),
|
||||||
|
Currency: row.Currency,
|
||||||
|
PaymentMethod: fromPgTextPtr(row.PaymentMethod),
|
||||||
|
Status: row.Status,
|
||||||
|
PaymentURL: fromPgTextPtr(row.PaymentUrl),
|
||||||
|
PaidAt: timePtr(row.PaidAt),
|
||||||
|
ExpiresAt: timePtr(row.ExpiresAt),
|
||||||
|
CreatedAt: row.CreatedAt.Time,
|
||||||
|
UpdatedAt: timePtr(row.UpdatedAt),
|
||||||
|
PlanName: fromPgTextPtr(row.PlanName),
|
||||||
|
PlanCategory: fromPgTextPtr(row.PlanCategory),
|
||||||
|
UserEmail: fromPgTextPtr(row.UserEmail),
|
||||||
|
UserFirstName: fromPgTextPtr(row.UserFirstName),
|
||||||
|
UserLastName: fromPgTextPtr(row.UserLastName),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toPgNumericPtr(val *float64) pgtype.Numeric {
|
||||||
|
if val == nil {
|
||||||
|
return pgtype.Numeric{Valid: false}
|
||||||
|
}
|
||||||
|
return toPgNumeric(*val)
|
||||||
|
}
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
|
|
||||||
func paymentToDomain(p dbgen.Payment) *domain.Payment {
|
func paymentToDomain(p dbgen.Payment) *domain.Payment {
|
||||||
|
|
|
||||||
|
|
@ -368,20 +368,34 @@ func (s *Store) GetQuestionTypeDefinitionByID(ctx context.Context, id int64) (do
|
||||||
return questionTypeDefinitionToDomain(id, key, displayName, description, stimulus, response, stimulusSch, responseSch, isSystem, status, createdAt, updatedAt), nil
|
return questionTypeDefinitionToDomain(id, key, displayName, description, stimulus, response, stimulusSch, responseSch, isSystem, status, createdAt, updatedAt), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListQuestionTypeDefinitions(ctx context.Context, status *string, includeSystem bool) ([]domain.QuestionTypeDefinition, error) {
|
func (s *Store) ListQuestionTypeDefinitions(ctx context.Context, status *string, includeSystem bool, limit, offset int32) ([]domain.QuestionTypeDefinition, int64, error) {
|
||||||
rows, err := s.conn.Query(ctx, `
|
const baseWhere = `
|
||||||
SELECT id, key, display_name, description, stimulus_component_kinds, response_component_kinds, stimulus_schema, response_schema, is_system, status, created_at, updated_at
|
|
||||||
FROM question_type_definitions
|
FROM question_type_definitions
|
||||||
WHERE ($1::VARCHAR IS NULL OR status = $1)
|
WHERE ($1::VARCHAR IS NULL OR status = $1)
|
||||||
AND ($2::BOOLEAN = TRUE OR is_system = FALSE)
|
AND ($2::BOOLEAN = TRUE OR is_system = FALSE)`
|
||||||
ORDER BY is_system DESC, display_name ASC
|
|
||||||
`, status, includeSystem)
|
var total int64
|
||||||
|
if err := s.conn.QueryRow(ctx, `SELECT COUNT(*)`+baseWhere, status, includeSystem).Scan(&total); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
listSQL := `
|
||||||
|
SELECT id, key, display_name, description, stimulus_component_kinds, response_component_kinds, stimulus_schema, response_schema, is_system, status, created_at, updated_at` + baseWhere + `
|
||||||
|
ORDER BY is_system DESC, display_name ASC`
|
||||||
|
|
||||||
|
args := []interface{}{status, includeSystem}
|
||||||
|
if limit > 0 {
|
||||||
|
listSQL += ` LIMIT $3 OFFSET $4`
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.conn.Query(ctx, listSQL, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var out []domain.QuestionTypeDefinition
|
out := make([]domain.QuestionTypeDefinition, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var (
|
var (
|
||||||
id int64
|
id int64
|
||||||
|
|
@ -398,12 +412,12 @@ func (s *Store) ListQuestionTypeDefinitions(ctx context.Context, status *string,
|
||||||
updatedAt pgtype.Timestamptz
|
updatedAt pgtype.Timestamptz
|
||||||
)
|
)
|
||||||
if err := rows.Scan(&id, &key, &displayName, &description, &stimulus, &response, &stimulusSch, &responseSch, &isSystem, &defStatus, &createdAt, &updatedAt); err != nil {
|
if err := rows.Scan(&id, &key, &displayName, &description, &stimulus, &response, &stimulusSch, &responseSch, &isSystem, &defStatus, &createdAt, &updatedAt); err != nil {
|
||||||
return nil, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
out = append(out, questionTypeDefinitionToDomain(id, key, displayName, description, stimulus, response, stimulusSch, responseSch, isSystem, defStatus, createdAt, updatedAt))
|
out = append(out, questionTypeDefinitionToDomain(id, key, displayName, description, stimulus, response, stimulusSch, responseSch, isSystem, defStatus, createdAt, updatedAt))
|
||||||
}
|
}
|
||||||
|
|
||||||
return out, rows.Err()
|
return out, total, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UpdateQuestionTypeDefinition(ctx context.Context, id int64, input domain.UpdateQuestionTypeDefinitionInput) error {
|
func (s *Store) UpdateQuestionTypeDefinition(ctx context.Context, id int64, input domain.UpdateQuestionTypeDefinitionInput) error {
|
||||||
|
|
@ -1226,6 +1240,22 @@ func (s *Store) CountQuestionsInSet(ctx context.Context, setID int64) (int64, er
|
||||||
return s.queries.CountQuestionsInSet(ctx, setID)
|
return s.queries.CountQuestionsInSet(ctx, setID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetQuestionTypeCountsInSet(ctx context.Context, setID int64) ([]domain.QuestionSetQuestionTypeGroup, error) {
|
||||||
|
rows, err := s.queries.GetQuestionTypeCountsInSet(ctx, setID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]domain.QuestionSetQuestionTypeGroup, len(rows))
|
||||||
|
for i, r := range rows {
|
||||||
|
result[i] = domain.QuestionSetQuestionTypeGroup{
|
||||||
|
QuestionTypeDefinitionID: fromPgInt8(r.QuestionTypeDefinitionID),
|
||||||
|
QuestionType: r.QuestionType,
|
||||||
|
Count: r.QuestionCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) GetQuestionSetsContainingQuestion(ctx context.Context, questionID int64) ([]domain.QuestionSet, error) {
|
func (s *Store) GetQuestionSetsContainingQuestion(ctx context.Context, questionID int64) ([]domain.QuestionSet, error) {
|
||||||
sets, err := s.queries.GetQuestionSetsContainingQuestion(ctx, questionID)
|
sets, err := s.queries.GetQuestionSetsContainingQuestion(ctx, questionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,53 @@ func (s *Store) LinkGoogleAccount(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) LinkAppleAccount(
|
||||||
|
ctx context.Context,
|
||||||
|
userID int64,
|
||||||
|
appleID string,
|
||||||
|
emailVerified bool,
|
||||||
|
) error {
|
||||||
|
return s.queries.LinkAppleAccount(ctx, dbgen.LinkAppleAccountParams{
|
||||||
|
ID: userID,
|
||||||
|
AppleID: pgtype.Text{String: appleID, Valid: true},
|
||||||
|
AppleEmailVerified: pgtype.Bool{Bool: emailVerified, Valid: true},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateAppleUser(
|
||||||
|
ctx context.Context,
|
||||||
|
aUser domain.AppleUser,
|
||||||
|
) (domain.User, error) {
|
||||||
|
res, err := s.queries.CreateAppleUser(ctx, dbgen.CreateAppleUserParams{
|
||||||
|
FirstName: pgtype.Text{String: aUser.GivenName, Valid: aUser.GivenName != ""},
|
||||||
|
LastName: pgtype.Text{String: aUser.FamilyName, Valid: aUser.FamilyName != ""},
|
||||||
|
Email: pgtype.Text{String: aUser.Email, Valid: aUser.Email != ""},
|
||||||
|
AppleID: pgtype.Text{String: aUser.ID, Valid: true},
|
||||||
|
AppleEmailVerified: pgtype.Bool{Bool: aUser.VerifiedEmail, Valid: true},
|
||||||
|
Role: string(domain.RoleStudent),
|
||||||
|
Status: string(domain.UserStatusActive),
|
||||||
|
EmailVerified: aUser.VerifiedEmail,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return domain.User{}, err
|
||||||
|
}
|
||||||
|
return mapDBUser(res, nil, nil), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetUserByAppleID(
|
||||||
|
ctx context.Context,
|
||||||
|
appleID string,
|
||||||
|
) (domain.User, error) {
|
||||||
|
u, err := s.queries.GetUserByAppleID(ctx, pgtype.Text{String: appleID, Valid: appleID != ""})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return domain.User{}, domain.ErrUserNotFound
|
||||||
|
}
|
||||||
|
return domain.User{}, err
|
||||||
|
}
|
||||||
|
return mapDBUser(u, nil, nil), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) CreateGoogleUser(
|
func (s *Store) CreateGoogleUser(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
gUser domain.GoogleUser,
|
gUser domain.GoogleUser,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -167,7 +169,7 @@ func (s *ArifpayService) InitiateSubscriptionPayment(ctx context.Context, userID
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionID := fmt.Sprintf("%v", data["sessionId"])
|
sessionID := fmt.Sprintf("%v", data["sessionId"])
|
||||||
paymentURL := fmt.Sprintf("%v", data["paymentUrl"])
|
paymentURL := normalizeExternalURL(fmt.Sprintf("%v", data["paymentUrl"]))
|
||||||
|
|
||||||
// Update payment with session info
|
// Update payment with session info
|
||||||
if err := s.paymentStore.UpdatePaymentSessionID(ctx, payment.ID, sessionID, paymentURL); err != nil {
|
if err := s.paymentStore.UpdatePaymentSessionID(ctx, payment.ID, sessionID, paymentURL); err != nil {
|
||||||
|
|
@ -184,13 +186,30 @@ func (s *ArifpayService) InitiateSubscriptionPayment(ctx context.Context, userID
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeExternalURL(raw string) string {
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
if u.Path == "" {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
cleaned := path.Clean(u.Path)
|
||||||
|
if strings.HasSuffix(u.Path, "/") && !strings.HasSuffix(cleaned, "/") {
|
||||||
|
cleaned += "/"
|
||||||
|
}
|
||||||
|
u.Path = cleaned
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
// ProcessPaymentWebhook handles the webhook callback from ArifPay
|
// ProcessPaymentWebhook handles the webhook callback from ArifPay
|
||||||
func (s *ArifpayService) ProcessPaymentWebhook(ctx context.Context, req domain.WebhookRequest) error {
|
func (s *ArifpayService) ProcessPaymentWebhook(ctx context.Context, req domain.WebhookRequest) error {
|
||||||
// Get payment by nonce
|
// ArifPay verify/webhook payloads are inconsistent: some responses include nonce, others only sessionId.
|
||||||
payment, err := s.paymentStore.GetPaymentByNonce(ctx, req.Nonce)
|
payment, err := s.resolvePaymentForWebhook(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("payment not found for nonce %s: %w", req.Nonce, err)
|
return err
|
||||||
}
|
}
|
||||||
|
nonce := payment.Nonce
|
||||||
|
|
||||||
if payment.Status == string(domain.PaymentStatusSuccess) {
|
if payment.Status == string(domain.PaymentStatusSuccess) {
|
||||||
return ErrPaymentAlreadyPaid
|
return ErrPaymentAlreadyPaid
|
||||||
|
|
@ -216,12 +235,16 @@ func (s *ArifpayService) ProcessPaymentWebhook(ctx context.Context, req domain.W
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update payment status
|
// Update payment status
|
||||||
|
paymentMethod := req.PaymentMethod
|
||||||
|
if paymentMethod == "" && payment.PaymentMethod != nil {
|
||||||
|
paymentMethod = *payment.PaymentMethod
|
||||||
|
}
|
||||||
if err := s.paymentStore.UpdatePaymentStatusByNonce(
|
if err := s.paymentStore.UpdatePaymentStatusByNonce(
|
||||||
ctx,
|
ctx,
|
||||||
req.Nonce,
|
nonce,
|
||||||
newStatus,
|
newStatus,
|
||||||
req.Transaction.TransactionID,
|
req.Transaction.TransactionID,
|
||||||
req.PaymentMethod,
|
paymentMethod,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("failed to update payment status: %w", err)
|
return fmt.Errorf("failed to update payment status: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -237,8 +260,7 @@ func (s *ArifpayService) ProcessPaymentWebhook(ctx context.Context, req domain.W
|
||||||
expiresAt := domain.CalculateExpiryDate(startsAt, plan.DurationValue, plan.DurationUnit)
|
expiresAt := domain.CalculateExpiryDate(startsAt, plan.DurationValue, plan.DurationUnit)
|
||||||
activeStatus := string(domain.SubscriptionStatusActive)
|
activeStatus := string(domain.SubscriptionStatusActive)
|
||||||
autoRenew := false
|
autoRenew := false
|
||||||
paymentRef := payment.Nonce
|
paymentRef := nonce
|
||||||
paymentMethod := req.PaymentMethod
|
|
||||||
|
|
||||||
subscription, err := s.subscriptionStore.CreateUserSubscription(ctx, domain.CreateUserSubscriptionInput{
|
subscription, err := s.subscriptionStore.CreateUserSubscription(ctx, domain.CreateUserSubscriptionInput{
|
||||||
UserID: payment.UserID,
|
UserID: payment.UserID,
|
||||||
|
|
@ -263,6 +285,23 @@ func (s *ArifpayService) ProcessPaymentWebhook(ctx context.Context, req domain.W
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ArifpayService) resolvePaymentForWebhook(ctx context.Context, req domain.WebhookRequest) (*domain.Payment, error) {
|
||||||
|
if req.Nonce != "" {
|
||||||
|
payment, err := s.paymentStore.GetPaymentByNonce(ctx, req.Nonce)
|
||||||
|
if err == nil {
|
||||||
|
return payment, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.SessionID != "" {
|
||||||
|
payment, err := s.paymentStore.GetPaymentBySessionID(ctx, req.SessionID)
|
||||||
|
if err == nil {
|
||||||
|
return payment, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("payment not found for session %s: %w", req.SessionID, err)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("payment not found for nonce %s: %w", req.Nonce, ErrPaymentNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
// VerifyPayment checks the status of a payment with ArifPay
|
// VerifyPayment checks the status of a payment with ArifPay
|
||||||
func (s *ArifpayService) VerifyPayment(ctx context.Context, sessionID string) (*domain.Payment, error) {
|
func (s *ArifpayService) VerifyPayment(ctx context.Context, sessionID string) (*domain.Payment, error) {
|
||||||
// Get local payment record
|
// Get local payment record
|
||||||
|
|
@ -304,6 +343,10 @@ func (s *ArifpayService) VerifyPayment(ctx context.Context, sessionID string) (*
|
||||||
if err := json.Unmarshal(respBytes, &result); err != nil {
|
if err := json.Unmarshal(respBytes, &result); err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse ArifPay response: %w", err)
|
return nil, fmt.Errorf("failed to parse ArifPay response: %w", err)
|
||||||
}
|
}
|
||||||
|
// Ensure fallback lookup key when ArifPay omits nonce in verify response.
|
||||||
|
if result.SessionID == "" {
|
||||||
|
result.SessionID = sessionID
|
||||||
|
}
|
||||||
|
|
||||||
// Process the verification result same as webhook
|
// Process the verification result same as webhook
|
||||||
if err := s.ProcessPaymentWebhook(ctx, result); err != nil && err != ErrPaymentAlreadyPaid {
|
if err := s.ProcessPaymentWebhook(ctx, result); err != nil && err != ErrPaymentAlreadyPaid {
|
||||||
|
|
|
||||||
231
internal/services/authentication/apple.go
Normal file
231
internal/services/authentication/apple.go
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
package authentication
|
||||||
|
|
||||||
|
import (
|
||||||
|
"Yimaru-Backend/internal/domain"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/MicahParks/keyfunc"
|
||||||
|
"github.com/golang-jwt/jwt/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
const appleJWKSURL = "https://appleid.apple.com/auth/keys"
|
||||||
|
|
||||||
|
var (
|
||||||
|
appleJWKS *keyfunc.JWKS
|
||||||
|
appleJWKSOnce sync.Once
|
||||||
|
appleJWKSErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrAppleSignInNotConfigured = errors.New("apple sign in is not configured")
|
||||||
|
|
||||||
|
func appleAllowedClientIDs(clientIDs string) []string {
|
||||||
|
parts := strings.Split(clientIDs, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
if id := strings.TrimSpace(p); id != "" {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAppleJWKS() (*keyfunc.JWKS, error) {
|
||||||
|
appleJWKSOnce.Do(func() {
|
||||||
|
appleJWKS, appleJWKSErr = keyfunc.Get(appleJWKSURL, keyfunc.Options{
|
||||||
|
RefreshInterval: time.Hour,
|
||||||
|
RefreshTimeout: 10 * time.Second,
|
||||||
|
RefreshUnknownKID: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return appleJWKS, appleJWKSErr
|
||||||
|
}
|
||||||
|
|
||||||
|
type appleIdentityClaims struct {
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
Email string `json:"email"`
|
||||||
|
EmailVerified any `json:"email_verified"`
|
||||||
|
IsPrivateEmail any `json:"is_private_email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func claimEmailVerified(v any) bool {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case bool:
|
||||||
|
return t
|
||||||
|
case string:
|
||||||
|
return strings.EqualFold(t, "true")
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ValidateAppleIdentityToken(
|
||||||
|
ctx context.Context,
|
||||||
|
idToken string,
|
||||||
|
allowedClientIDs []string,
|
||||||
|
) (domain.AppleUser, error) {
|
||||||
|
if len(allowedClientIDs) == 0 {
|
||||||
|
return domain.AppleUser{}, ErrAppleSignInNotConfigured
|
||||||
|
}
|
||||||
|
if idToken == "" {
|
||||||
|
return domain.AppleUser{}, errors.New("missing apple identity token")
|
||||||
|
}
|
||||||
|
|
||||||
|
jwks, err := getAppleJWKS()
|
||||||
|
if err != nil {
|
||||||
|
return domain.AppleUser{}, fmt.Errorf("failed to load apple jwks: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := jwt.ParseWithClaims(idToken, &appleIdentityClaims{}, jwks.Keyfunc)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AppleUser{}, fmt.Errorf("invalid apple identity token: %w", err)
|
||||||
|
}
|
||||||
|
if !token.Valid {
|
||||||
|
return domain.AppleUser{}, errors.New("invalid apple identity token")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(*appleIdentityClaims)
|
||||||
|
if !ok {
|
||||||
|
return domain.AppleUser{}, errors.New("invalid apple token claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims.Issuer != "https://appleid.apple.com" {
|
||||||
|
return domain.AppleUser{}, errors.New("invalid apple token issuer")
|
||||||
|
}
|
||||||
|
|
||||||
|
audOK := false
|
||||||
|
for _, aud := range claims.Audience {
|
||||||
|
for _, allowed := range allowedClientIDs {
|
||||||
|
if aud == allowed {
|
||||||
|
audOK = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if audOK {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !audOK {
|
||||||
|
return domain.AppleUser{}, errors.New("apple token audience mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims.Subject == "" {
|
||||||
|
return domain.AppleUser{}, errors.New("apple token missing subject")
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain.AppleUser{
|
||||||
|
ID: claims.Subject,
|
||||||
|
Email: strings.TrimSpace(claims.Email),
|
||||||
|
VerifiedEmail: claimEmailVerified(claims.EmailVerified),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginWithAppleMobile validates the identity token and signs the user in (iOS / Android / web credential).
|
||||||
|
func (s *Service) LoginWithAppleMobile(
|
||||||
|
ctx context.Context,
|
||||||
|
idToken string,
|
||||||
|
allowedClientIDs string,
|
||||||
|
profile domain.AppleUser,
|
||||||
|
) (domain.LoginSuccess, error) {
|
||||||
|
aUser, err := s.ValidateAppleIdentityToken(ctx, idToken, appleAllowedClientIDs(allowedClientIDs))
|
||||||
|
if err != nil {
|
||||||
|
return domain.LoginSuccess{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if aUser.Email == "" {
|
||||||
|
aUser.Email = strings.TrimSpace(profile.Email)
|
||||||
|
}
|
||||||
|
if aUser.GivenName == "" {
|
||||||
|
aUser.GivenName = strings.TrimSpace(profile.GivenName)
|
||||||
|
}
|
||||||
|
if aUser.FamilyName == "" {
|
||||||
|
aUser.FamilyName = strings.TrimSpace(profile.FamilyName)
|
||||||
|
}
|
||||||
|
if !aUser.VerifiedEmail && profile.VerifiedEmail {
|
||||||
|
aUser.VerifiedEmail = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.LoginWithApple(ctx, aUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) LoginWithApple(
|
||||||
|
ctx context.Context,
|
||||||
|
aUser domain.AppleUser,
|
||||||
|
) (domain.LoginSuccess, error) {
|
||||||
|
if aUser.ID == "" {
|
||||||
|
return domain.LoginSuccess{}, errors.New("missing apple user id")
|
||||||
|
}
|
||||||
|
|
||||||
|
var user domain.User
|
||||||
|
var err error
|
||||||
|
|
||||||
|
user, err = s.userStore.GetUserByAppleID(ctx, aUser.ID)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, domain.ErrUserNotFound) {
|
||||||
|
return domain.LoginSuccess{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if aUser.Email == "" {
|
||||||
|
return domain.LoginSuccess{}, errors.New("email is required on first sign in with apple")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err = s.userStore.GetUserByEmailPhone(ctx, aUser.Email, "")
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, domain.ErrUserNotFound) {
|
||||||
|
return domain.LoginSuccess{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err = s.userStore.CreateAppleUser(ctx, aUser)
|
||||||
|
if err != nil {
|
||||||
|
return domain.LoginSuccess{}, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := s.userStore.LinkAppleAccount(ctx, user.ID, aUser.ID, aUser.VerifiedEmail); err != nil {
|
||||||
|
return domain.LoginSuccess{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Status == domain.UserStatusPending {
|
||||||
|
return domain.LoginSuccess{}, domain.ErrUserNotVerified
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Status == domain.UserStatusSuspended {
|
||||||
|
return domain.LoginSuccess{}, ErrUserSuspended
|
||||||
|
}
|
||||||
|
|
||||||
|
oldToken, err := s.tokenStore.GetRefreshTokenByUserID(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, ErrRefreshTokenNotFound) {
|
||||||
|
return domain.LoginSuccess{}, err
|
||||||
|
}
|
||||||
|
} else if !oldToken.Revoked {
|
||||||
|
if err := s.tokenStore.RevokeRefreshToken(ctx, oldToken.Token); err != nil {
|
||||||
|
return domain.LoginSuccess{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken, err := generateRefreshToken()
|
||||||
|
if err != nil {
|
||||||
|
return domain.LoginSuccess{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.tokenStore.CreateRefreshToken(ctx, domain.RefreshToken{
|
||||||
|
Token: refreshToken,
|
||||||
|
UserID: user.ID,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
ExpiresAt: time.Now().Add(time.Duration(s.RefreshExpiry) * time.Second),
|
||||||
|
}); err != nil {
|
||||||
|
return domain.LoginSuccess{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain.LoginSuccess{
|
||||||
|
UserId: user.ID,
|
||||||
|
Role: user.Role,
|
||||||
|
RfToken: refreshToken,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
28
internal/services/chapa/customization_test.go
Normal file
28
internal/services/chapa/customization_test.go
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
package chapa
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSanitizeChapaCustomization(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"Subscription: Premium", "Subscription Premium"},
|
||||||
|
{"New Test Monthly Premium", "New Test Monthly Premium"},
|
||||||
|
{"IELTS (Prep) / Monthly", "IELTS Prep Monthly"},
|
||||||
|
{"", "Yimaru subscription"},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
if got := sanitizeChapaCustomization(tc.in); got != tc.want {
|
||||||
|
t.Fatalf("sanitizeChapaCustomization(%q) = %q, want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChapaSubscriptionDescription(t *testing.T) {
|
||||||
|
got := chapaSubscriptionDescription("New Test Monthly Premium")
|
||||||
|
want := "Subscription New Test Monthly Premium"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("chapaSubscriptionDescription() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -141,8 +141,8 @@ func (s *Service) InitiateSubscriptionPayment(ctx context.Context, userID int64,
|
||||||
CallbackURL: s.cfg.CHAPA_CALLBACK_URL,
|
CallbackURL: s.cfg.CHAPA_CALLBACK_URL,
|
||||||
ReturnURL: s.cfg.CHAPA_RETURN_URL,
|
ReturnURL: s.cfg.CHAPA_RETURN_URL,
|
||||||
}
|
}
|
||||||
initReq.Customization.Title = "Yimaru LMS"
|
initReq.Customization.Title = sanitizeChapaCustomization("Yimaru LMS")
|
||||||
initReq.Customization.Description = fmt.Sprintf("Subscription: %s", plan.Name)
|
initReq.Customization.Description = chapaSubscriptionDescription(plan.Name)
|
||||||
|
|
||||||
checkoutURL, err := s.initializeTransaction(ctx, initReq)
|
checkoutURL, err := s.initializeTransaction(ctx, initReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -403,6 +403,25 @@ func (s *Service) GetPaymentsByUser(ctx context.Context, userID int64, limit, of
|
||||||
return s.paymentStore.GetPaymentsByUserID(ctx, userID, limit, offset)
|
return s.paymentStore.GetPaymentsByUserID(ctx, userID, limit, offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListPaymentsAdmin(ctx context.Context, filter domain.PaymentListFilter) (domain.PaymentListPage, error) {
|
||||||
|
return s.paymentStore.ListPaymentsAdmin(ctx, filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetPaymentAdminByID(ctx context.Context, id int64) (*domain.Payment, error) {
|
||||||
|
page, err := s.paymentStore.ListPaymentsAdmin(ctx, domain.PaymentListFilter{
|
||||||
|
PaymentID: &id,
|
||||||
|
Limit: 1,
|
||||||
|
Offset: 0,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(page.Items) == 0 {
|
||||||
|
return nil, ErrPaymentNotFound
|
||||||
|
}
|
||||||
|
return &page.Items[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) GetPaymentByID(ctx context.Context, id int64) (*domain.Payment, error) {
|
func (s *Service) GetPaymentByID(ctx context.Context, id int64) (*domain.Payment, error) {
|
||||||
return s.paymentStore.GetPaymentByID(ctx, id)
|
return s.paymentStore.GetPaymentByID(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
@ -453,6 +472,31 @@ func formatAmount(amount float64) string {
|
||||||
return strconv.FormatFloat(math.Round(amount*100)/100, 'f', 2, 64)
|
return strconv.FormatFloat(math.Round(amount*100)/100, 'f', 2, 64)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sanitizeChapaCustomization keeps only characters allowed by Chapa customization fields.
|
||||||
|
func sanitizeChapaCustomization(value string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(value))
|
||||||
|
for _, r := range value {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||||||
|
b.WriteRune(r)
|
||||||
|
case r == '-', r == '_', r == ' ', r == '.':
|
||||||
|
b.WriteRune(r)
|
||||||
|
default:
|
||||||
|
b.WriteRune(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleaned := strings.Join(strings.Fields(b.String()), " ")
|
||||||
|
if cleaned == "" {
|
||||||
|
return "Yimaru subscription"
|
||||||
|
}
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
func chapaSubscriptionDescription(planName string) string {
|
||||||
|
return sanitizeChapaCustomization("Subscription " + strings.TrimSpace(planName))
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeCurrency(currency string) string {
|
func normalizeCurrency(currency string) string {
|
||||||
c := strings.TrimSpace(strings.ToUpper(currency))
|
c := strings.TrimSpace(strings.ToUpper(currency))
|
||||||
if c == "" {
|
if c == "" {
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,8 @@ func (s *Service) CanAccessModule(ctx context.Context, userID, moduleID int64) (
|
||||||
return true, "", nil
|
return true, "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanAccessLesson requires the module chain to be accessible and the previous lesson in the module to be completed.
|
// CanAccessLesson requires the module chain to be accessible and the previous lesson in the module
|
||||||
|
// to be completed based on published practice completion in that lesson.
|
||||||
func (s *Service) CanAccessLesson(ctx context.Context, userID, lessonID int64) (ok bool, reason string, err error) {
|
func (s *Service) CanAccessLesson(ctx context.Context, userID, lessonID int64) (ok bool, reason string, err error) {
|
||||||
lesson, err := s.store.GetLessonByID(ctx, lessonID)
|
lesson, err := s.store.GetLessonByID(ctx, lessonID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -135,28 +136,29 @@ func (s *Service) CanAccessLesson(ctx context.Context, userID, lessonID int64) (
|
||||||
}
|
}
|
||||||
return false, "", err
|
return false, "", err
|
||||||
}
|
}
|
||||||
has, err := s.store.LmsUserHasLessonProgress(ctx, userID, prev.ID)
|
|
||||||
|
// Lesson unlock for STUDENT now follows practice completion, not deprecated lesson-complete writes.
|
||||||
|
prevCompletedPractices, prevTotalPractices, err := s.store.LmsUserPracticeProgressInLesson(ctx, userID, prev.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, "", err
|
return false, "", err
|
||||||
}
|
}
|
||||||
if !has {
|
if !lmsProgressComplete(prevCompletedPractices, prevTotalPractices) {
|
||||||
return false, errPrevLesson, nil
|
return false, errPrevLesson, nil
|
||||||
}
|
}
|
||||||
return true, "", nil
|
return true, "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyAccessProgram sets p.Access for learner roles. Staff roles omit Access 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.
|
// STUDENT: is_accessible reflects sequential prerequisites; OPEN_LEARNER: is_accessible and is_completed always true.
|
||||||
func (s *Service) ApplyAccessProgram(ctx context.Context, role domain.Role, userID int64, p *domain.Program) error {
|
func (s *Service) ApplyAccessProgram(ctx context.Context, role domain.Role, userID int64, p *domain.Program) error {
|
||||||
if !role.IsCustomerLearnerRole() {
|
if !role.IsCustomerLearnerRole() {
|
||||||
p.Access = nil
|
p.Access = nil
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
comp, tot, err := s.store.LmsUserLessonProgressInProgram(ctx, userID, p.ID)
|
fraction, done, comp, tot, err := s.lmsProgramProgress(ctx, userID, p.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
done := lmsProgressComplete(comp, tot)
|
|
||||||
ok, reason := true, ""
|
ok, reason := true, ""
|
||||||
if role.UsesLMSSequentialGating() {
|
if role.UsesLMSSequentialGating() {
|
||||||
ok, reason, err = s.CanAccessProgram(ctx, userID, p.ID)
|
ok, reason, err = s.CanAccessProgram(ctx, userID, p.ID)
|
||||||
|
|
@ -164,7 +166,7 @@ func (s *Service) ApplyAccessProgram(ctx context.Context, role domain.Role, user
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
p.Access = buildLMSEntityAccess(ok, reason, done, comp, tot)
|
p.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -174,11 +176,10 @@ func (s *Service) ApplyAccessCourse(ctx context.Context, role domain.Role, userI
|
||||||
c.Access = nil
|
c.Access = nil
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
comp, tot, err := s.store.LmsUserLessonProgressInCourse(ctx, userID, c.ID)
|
fraction, done, comp, tot, err := s.lmsCourseProgress(ctx, userID, c.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
done := lmsProgressComplete(comp, tot)
|
|
||||||
ok, reason := true, ""
|
ok, reason := true, ""
|
||||||
if role.UsesLMSSequentialGating() {
|
if role.UsesLMSSequentialGating() {
|
||||||
ok, reason, err = s.CanAccessCourse(ctx, userID, c.ID)
|
ok, reason, err = s.CanAccessCourse(ctx, userID, c.ID)
|
||||||
|
|
@ -186,7 +187,7 @@ func (s *Service) ApplyAccessCourse(ctx context.Context, role domain.Role, userI
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.Access = buildLMSEntityAccess(ok, reason, done, comp, tot)
|
c.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -196,11 +197,10 @@ func (s *Service) ApplyAccessModule(ctx context.Context, role domain.Role, userI
|
||||||
m.Access = nil
|
m.Access = nil
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
comp, tot, err := s.store.LmsUserLessonProgressInModule(ctx, userID, m.ID)
|
fraction, done, comp, tot, err := s.lmsModuleProgress(ctx, userID, m.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
done := lmsProgressComplete(comp, tot)
|
|
||||||
ok, reason := true, ""
|
ok, reason := true, ""
|
||||||
if role.UsesLMSSequentialGating() {
|
if role.UsesLMSSequentialGating() {
|
||||||
ok, reason, err = s.CanAccessModule(ctx, userID, m.ID)
|
ok, reason, err = s.CanAccessModule(ctx, userID, m.ID)
|
||||||
|
|
@ -208,7 +208,7 @@ func (s *Service) ApplyAccessModule(ctx context.Context, role domain.Role, userI
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.Access = buildLMSEntityAccess(ok, reason, done, comp, tot)
|
m.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -218,11 +218,10 @@ func (s *Service) ApplyAccessLesson(ctx context.Context, role domain.Role, userI
|
||||||
les.Access = nil
|
les.Access = nil
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
comp, tot, err := s.store.LmsUserPracticeProgressInLesson(ctx, userID, les.ID)
|
fraction, done, comp, tot, err := s.lmsLessonProgress(ctx, userID, les.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
done := lmsProgressComplete(comp, tot)
|
|
||||||
ok, reason := true, ""
|
ok, reason := true, ""
|
||||||
if role.UsesLMSSequentialGating() {
|
if role.UsesLMSSequentialGating() {
|
||||||
ok, reason, err = s.CanAccessLesson(ctx, userID, les.ID)
|
ok, reason, err = s.CanAccessLesson(ctx, userID, les.ID)
|
||||||
|
|
@ -230,7 +229,7 @@ func (s *Service) ApplyAccessLesson(ctx context.Context, role domain.Role, userI
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
les.Access = buildLMSEntityAccess(ok, reason, done, comp, tot)
|
les.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -240,12 +239,11 @@ func (s *Service) ApplyExamPrepAccessCatalogCourse(ctx context.Context, role dom
|
||||||
cc.Access = nil
|
cc.Access = nil
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
comp, tot, err := s.store.ExamPrepUserPracticeProgressInCatalogCourse(ctx, userID, cc.ID)
|
fraction, done, comp, tot, err := s.examPrepCatalogCourseProgress(ctx, userID, cc.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
done := lmsProgressComplete(comp, tot)
|
cc.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction))
|
||||||
cc.Access = buildLMSEntityAccess(true, "", done, comp, tot)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -255,12 +253,11 @@ func (s *Service) ApplyExamPrepAccessUnit(ctx context.Context, role domain.Role,
|
||||||
u.Access = nil
|
u.Access = nil
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
comp, tot, err := s.store.ExamPrepUserPracticeProgressInUnit(ctx, userID, u.ID)
|
fraction, done, comp, tot, err := s.examPrepUnitProgress(ctx, userID, u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
done := lmsProgressComplete(comp, tot)
|
u.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction))
|
||||||
u.Access = buildLMSEntityAccess(true, "", done, comp, tot)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -270,12 +267,11 @@ func (s *Service) ApplyExamPrepAccessModule(ctx context.Context, role domain.Rol
|
||||||
m.Access = nil
|
m.Access = nil
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
comp, tot, err := s.store.ExamPrepUserPracticeProgressInModule(ctx, userID, m.ID)
|
fraction, done, comp, tot, err := s.examPrepModuleProgress(ctx, userID, m.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
done := lmsProgressComplete(comp, tot)
|
m.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction))
|
||||||
m.Access = buildLMSEntityAccess(true, "", done, comp, tot)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -285,19 +281,265 @@ func (s *Service) ApplyExamPrepAccessLesson(ctx context.Context, role domain.Rol
|
||||||
les.Access = nil
|
les.Access = nil
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
comp, tot, err := s.store.ExamPrepUserPracticeProgressInLesson(ctx, userID, les.ID)
|
fraction, done, comp, tot, err := s.examPrepLessonProgress(ctx, userID, les.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
done := lmsProgressComplete(comp, tot)
|
les.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction))
|
||||||
les.Access = buildLMSEntityAccess(true, "", done, comp, tot)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) lmsLessonProgress(ctx context.Context, userID, lessonID int64) (fraction float64, done bool, completed, total int32, err error) {
|
||||||
|
completed, total, err = s.store.LmsUserPracticeProgressInLesson(ctx, userID, lessonID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, 0, 0, err
|
||||||
|
}
|
||||||
|
// Lesson is complete once any published practice in that lesson is completed.
|
||||||
|
if total > 0 && completed > 0 {
|
||||||
|
return 1, true, 1, 1, nil
|
||||||
|
}
|
||||||
|
return 0, false, 0, 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) lmsModuleProgress(ctx context.Context, userID, moduleID int64) (fraction float64, done bool, completed, total int32, err error) {
|
||||||
|
directDone, directErr := s.hasCompletedDirectModulePractice(ctx, userID, moduleID)
|
||||||
|
if directErr != nil {
|
||||||
|
return 0, false, 0, 0, directErr
|
||||||
|
}
|
||||||
|
if directDone {
|
||||||
|
return 1, true, 1, 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lessons, _, err := s.store.ListLessonsByModuleID(ctx, moduleID, true, 10000, 0)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, 0, 0, err
|
||||||
|
}
|
||||||
|
var doneLessons int32
|
||||||
|
for _, lesson := range lessons {
|
||||||
|
practiceCount, err := s.store.LmsCountPublishedPracticesInLesson(ctx, lesson.ID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, 0, 0, err
|
||||||
|
}
|
||||||
|
if practiceCount == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total++
|
||||||
|
lessonFraction, _, _, _, err := s.lmsLessonProgress(ctx, userID, lesson.ID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, 0, 0, err
|
||||||
|
}
|
||||||
|
if lessonFraction >= 1 {
|
||||||
|
doneLessons++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fraction, done, completed, total = practiceScopeFraction(doneLessons, total)
|
||||||
|
return fraction, done, completed, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) lmsCourseProgress(ctx context.Context, userID, courseID int64) (fraction float64, done bool, completed, total int32, err error) {
|
||||||
|
directDone, directErr := s.hasCompletedDirectCoursePractice(ctx, userID, courseID)
|
||||||
|
if directErr != nil {
|
||||||
|
return 0, false, 0, 0, directErr
|
||||||
|
}
|
||||||
|
if directDone {
|
||||||
|
return 1, true, 1, 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
moduleIDs, err := s.store.ListModuleIDsByCourse(ctx, courseID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var doneModules int32
|
||||||
|
for _, moduleID := range moduleIDs {
|
||||||
|
practiceCount, err := s.store.LmsCountPublishedPracticesInModule(ctx, moduleID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, 0, 0, err
|
||||||
|
}
|
||||||
|
if practiceCount == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total++
|
||||||
|
_, moduleDone, _, _, err := s.lmsModuleProgress(ctx, userID, moduleID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, 0, 0, err
|
||||||
|
}
|
||||||
|
if moduleDone {
|
||||||
|
doneModules++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fraction, done, completed, total = practiceScopeFraction(doneModules, total)
|
||||||
|
return fraction, done, completed, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) lmsProgramProgress(ctx context.Context, userID, programID int64) (fraction float64, done bool, completed, total int32, err error) {
|
||||||
|
courseIDs, err := s.store.ListCourseIDsByProgram(ctx, programID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var doneCourses int32
|
||||||
|
for _, courseID := range courseIDs {
|
||||||
|
practiceCount, err := s.store.LmsCountPublishedPracticesInCourse(ctx, courseID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, 0, 0, err
|
||||||
|
}
|
||||||
|
if practiceCount == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total++
|
||||||
|
_, courseDone, _, _, err := s.lmsCourseProgress(ctx, userID, courseID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, 0, 0, err
|
||||||
|
}
|
||||||
|
if courseDone {
|
||||||
|
doneCourses++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fraction, done, completed, total = practiceScopeFraction(doneCourses, total)
|
||||||
|
return fraction, done, completed, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) hasCompletedDirectModulePractice(ctx context.Context, userID, moduleID int64) (bool, error) {
|
||||||
|
completed, total, err := s.store.LmsUserDirectPracticeProgressInModule(ctx, userID, moduleID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return total > 0 && completed > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) hasCompletedDirectCoursePractice(ctx context.Context, userID, courseID int64) (bool, error) {
|
||||||
|
completed, total, err := s.store.LmsUserDirectPracticeProgressInCourse(ctx, userID, courseID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return total > 0 && completed > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) examPrepLessonProgress(ctx context.Context, userID, lessonID int64) (fraction float64, done bool, completed, total int32, err error) {
|
||||||
|
completed, total, err = s.store.ExamPrepUserPracticeProgressInLesson(ctx, userID, lessonID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, 0, 0, err
|
||||||
|
}
|
||||||
|
if total > 0 && completed > 0 {
|
||||||
|
return 1, true, 1, 1, nil
|
||||||
|
}
|
||||||
|
return 0, false, 0, 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) examPrepModuleProgress(ctx context.Context, userID, moduleID int64) (fraction float64, done bool, completed, total int32, err error) {
|
||||||
|
lessonIDs, err := s.store.ListExamPrepUnitModuleLessonIDsByUnitModule(ctx, moduleID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var doneLessons int32
|
||||||
|
for _, lessonID := range lessonIDs {
|
||||||
|
practiceCount, err := s.store.ExamPrepCountPublishedPracticesInLesson(ctx, lessonID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, 0, 0, err
|
||||||
|
}
|
||||||
|
if practiceCount == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total++
|
||||||
|
lessonFraction, _, _, _, err := s.examPrepLessonProgress(ctx, userID, lessonID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, 0, 0, err
|
||||||
|
}
|
||||||
|
if lessonFraction >= 1 {
|
||||||
|
doneLessons++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fraction, done, completed, total = practiceScopeFraction(doneLessons, total)
|
||||||
|
return fraction, done, completed, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) examPrepUnitProgress(ctx context.Context, userID, unitID int64) (fraction float64, done bool, completed, total int32, err error) {
|
||||||
|
moduleIDs, err := s.store.ListExamPrepUnitModuleIDsByUnit(ctx, unitID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var doneModules int32
|
||||||
|
for _, moduleID := range moduleIDs {
|
||||||
|
practiceCount, err := s.store.ExamPrepCountPublishedPracticesInModule(ctx, moduleID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, 0, 0, err
|
||||||
|
}
|
||||||
|
if practiceCount == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total++
|
||||||
|
_, moduleDone, _, _, err := s.examPrepModuleProgress(ctx, userID, moduleID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, 0, 0, err
|
||||||
|
}
|
||||||
|
if moduleDone {
|
||||||
|
doneModules++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fraction, done, completed, total = practiceScopeFraction(doneModules, total)
|
||||||
|
return fraction, done, completed, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) examPrepCatalogCourseProgress(ctx context.Context, userID, catalogCourseID int64) (fraction float64, done bool, completed, total int32, err error) {
|
||||||
|
unitIDs, err := s.store.ListExamPrepUnitIDsByCatalogCourse(ctx, catalogCourseID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var doneUnits int32
|
||||||
|
for _, unitID := range unitIDs {
|
||||||
|
practiceCount, err := s.store.ExamPrepCountPublishedPracticesInUnit(ctx, unitID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, 0, 0, err
|
||||||
|
}
|
||||||
|
if practiceCount == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total++
|
||||||
|
_, unitDone, _, _, err := s.examPrepUnitProgress(ctx, userID, unitID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, 0, 0, err
|
||||||
|
}
|
||||||
|
if unitDone {
|
||||||
|
doneUnits++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fraction, done, completed, total = practiceScopeFraction(doneUnits, total)
|
||||||
|
return fraction, done, completed, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// practiceScopeFraction returns done/total for entities that only count children with published practices.
|
||||||
|
func practiceScopeFraction(done, total int32) (fraction float64, complete bool, completed, totalOut int32) {
|
||||||
|
if total == 0 {
|
||||||
|
return 0, false, 0, 0
|
||||||
|
}
|
||||||
|
fraction = float64(done) / float64(total)
|
||||||
|
return fraction, fraction >= 1, done, total
|
||||||
|
}
|
||||||
|
|
||||||
func lmsProgressComplete(completed, total int32) bool {
|
func lmsProgressComplete(completed, total int32) bool {
|
||||||
return total > 0 && completed >= total
|
return total > 0 && completed >= total
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// finalizeOpenLearnerAccess forces OPEN_LEARNER access JSON to show every item as accessible and completed.
|
||||||
|
func finalizeOpenLearnerAccess(role domain.Role, access *domain.LMSEntityAccess) *domain.LMSEntityAccess {
|
||||||
|
if access == nil || role != domain.RoleOpenLearner {
|
||||||
|
return access
|
||||||
|
}
|
||||||
|
access.IsAccessible = true
|
||||||
|
access.IsCompleted = true
|
||||||
|
access.Reason = ""
|
||||||
|
if access.TotalCount > 0 {
|
||||||
|
access.CompletedCount = access.TotalCount
|
||||||
|
access.ProgressPercent = 100
|
||||||
|
access.ProgressPercentPrecise = 100
|
||||||
|
}
|
||||||
|
return access
|
||||||
|
}
|
||||||
|
|
||||||
func buildLMSEntityAccess(ok bool, reason string, done bool, completed, total int32) *domain.LMSEntityAccess {
|
func buildLMSEntityAccess(ok bool, reason string, done bool, completed, total int32) *domain.LMSEntityAccess {
|
||||||
c, t, pct, pctPrecise := lmsProgressCounts(completed, total, done)
|
c, t, pct, pctPrecise := lmsProgressCounts(completed, total, done)
|
||||||
return &domain.LMSEntityAccess{
|
return &domain.LMSEntityAccess{
|
||||||
|
|
@ -311,6 +553,41 @@ func buildLMSEntityAccess(ok bool, reason string, done bool, completed, total in
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildLMSEntityAccessFromFraction(ok bool, reason string, done bool, completed, total int32, fraction float64) *domain.LMSEntityAccess {
|
||||||
|
c := int(completed)
|
||||||
|
t := int(total)
|
||||||
|
if c < 0 {
|
||||||
|
c = 0
|
||||||
|
}
|
||||||
|
if t < 0 {
|
||||||
|
t = 0
|
||||||
|
}
|
||||||
|
if done && t > 0 {
|
||||||
|
c = t
|
||||||
|
fraction = 1
|
||||||
|
}
|
||||||
|
if fraction < 0 {
|
||||||
|
fraction = 0
|
||||||
|
}
|
||||||
|
if fraction > 1 {
|
||||||
|
fraction = 1
|
||||||
|
}
|
||||||
|
pctPrecise := math.Round(fraction*10000) / 100
|
||||||
|
pct := int(pctPrecise)
|
||||||
|
if pct > 100 {
|
||||||
|
pct = 100
|
||||||
|
}
|
||||||
|
return &domain.LMSEntityAccess{
|
||||||
|
IsAccessible: ok,
|
||||||
|
IsCompleted: done,
|
||||||
|
Reason: reasonIf(ok, reason),
|
||||||
|
CompletedCount: c,
|
||||||
|
TotalCount: t,
|
||||||
|
ProgressPercent: pct,
|
||||||
|
ProgressPercentPrecise: pctPrecise,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// lmsProgressCounts maps DB lesson completion counts to UI fields. Percent is 0–100; completed
|
// lmsProgressCounts maps DB lesson completion counts to UI fields. Percent is 0–100; completed
|
||||||
// and total are aligned with isCompleted when the entity is fully done.
|
// and total are aligned with isCompleted when the entity is fully done.
|
||||||
func lmsProgressCounts(completed, total int32, isCompleted bool) (c, t, pct int, pctPrecise float64) {
|
func lmsProgressCounts(completed, total int32, isCompleted bool) (c, t, pct int, pctPrecise float64) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
package lmsprogress
|
package lmsprogress
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"Yimaru-Backend/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
func TestLMSProgressCounts(t *testing.T) {
|
func TestLMSProgressCounts(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
|
@ -75,6 +79,43 @@ func TestLMSProgressCounts(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPracticeScopeFraction(t *testing.T) {
|
||||||
|
fraction, done, completed, total := practiceScopeFraction(1, 5)
|
||||||
|
if fraction != 0.2 || done || completed != 1 || total != 5 {
|
||||||
|
t.Fatalf("practiceScopeFraction(1,5)=(%v,%v,%d,%d), want (0.2,false,1,5)", fraction, done, completed, total)
|
||||||
|
}
|
||||||
|
fraction, done, completed, total = practiceScopeFraction(5, 5)
|
||||||
|
if fraction != 1 || !done || completed != 5 || total != 5 {
|
||||||
|
t.Fatalf("practiceScopeFraction(5,5)=(%v,%v,%d,%d), want (1,true,5,5)", fraction, done, completed, total)
|
||||||
|
}
|
||||||
|
fraction, done, completed, total = practiceScopeFraction(0, 0)
|
||||||
|
if fraction != 0 || done || completed != 0 || total != 0 {
|
||||||
|
t.Fatalf("practiceScopeFraction(0,0)=(%v,%v,%d,%d), want (0,false,0,0)", fraction, done, completed, total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinalizeOpenLearnerAccess(t *testing.T) {
|
||||||
|
incomplete := buildLMSEntityAccessFromFraction(false, "locked", false, 1, 5, 0.2)
|
||||||
|
got := finalizeOpenLearnerAccess(domain.RoleOpenLearner, incomplete)
|
||||||
|
if got == nil || !got.IsAccessible || !got.IsCompleted {
|
||||||
|
t.Fatalf("expected accessible and completed, got %+v", got)
|
||||||
|
}
|
||||||
|
if got.CompletedCount != 5 || got.ProgressPercent != 100 {
|
||||||
|
t.Fatalf("expected full progress, got %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
studentAccess := buildLMSEntityAccessFromFraction(false, "locked", false, 1, 5, 0.2)
|
||||||
|
unchanged := finalizeOpenLearnerAccess(domain.RoleStudent, studentAccess)
|
||||||
|
if unchanged != studentAccess || unchanged.IsCompleted || unchanged.IsAccessible {
|
||||||
|
t.Fatalf("STUDENT access should be unchanged, got %+v", unchanged)
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyScope := finalizeOpenLearnerAccess(domain.RoleOpenLearner, buildLMSEntityAccessFromFraction(true, "", false, 0, 0, 0))
|
||||||
|
if emptyScope == nil || !emptyScope.IsCompleted {
|
||||||
|
t.Fatalf("expected completed even with zero total, got %+v", emptyScope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLMSProgressComplete(t *testing.T) {
|
func TestLMSProgressComplete(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
8
internal/services/notification/errors.go
Normal file
8
internal/services/notification/errors.go
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
package notificationservice
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotificationNotFound = errors.New("notification not found")
|
||||||
|
ErrNotificationForbidden = errors.New("notification access denied")
|
||||||
|
)
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"Yimaru-Backend/internal/web_server/ws"
|
"Yimaru-Backend/internal/web_server/ws"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -26,6 +27,7 @@ import (
|
||||||
firebase "firebase.google.com/go/v4"
|
firebase "firebase.google.com/go/v4"
|
||||||
"firebase.google.com/go/v4/messaging"
|
"firebase.google.com/go/v4/messaging"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/resend/resend-go/v2"
|
"github.com/resend/resend-go/v2"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"google.golang.org/api/option"
|
"google.golang.org/api/option"
|
||||||
|
|
@ -341,6 +343,27 @@ func (s *Service) SendNotification(ctx context.Context, notification *domain.Not
|
||||||
// return nil
|
// return nil
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
func (s *Service) GetNotificationByID(ctx context.Context, id, requesterID int64, allowAll bool) (*domain.Notification, error) {
|
||||||
|
notification, err := s.store.GetNotification(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrNotificationNotFound
|
||||||
|
}
|
||||||
|
s.mongoLogger.Error("[NotificationSvc.GetNotificationByID] Failed to get notification",
|
||||||
|
zap.Int64("notificationID", id),
|
||||||
|
zap.Error(err),
|
||||||
|
zap.Time("timestamp", time.Now()),
|
||||||
|
)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !allowAll && notification.RecipientID != requesterID {
|
||||||
|
return nil, ErrNotificationForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
return notification, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) GetUserNotifications(ctx context.Context, recipientID int64, limit, offset int) ([]domain.Notification, int64, error) {
|
func (s *Service) GetUserNotifications(ctx context.Context, recipientID int64, limit, offset int) ([]domain.Notification, int64, error) {
|
||||||
notifications, total, err := s.store.GetUserNotifications(ctx, recipientID, limit, offset)
|
notifications, total, err := s.store.GetUserNotifications(ctx, recipientID, limit, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
93
internal/services/notification/service_get_test.go
Normal file
93
internal/services/notification/service_get_test.go
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
package notificationservice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"Yimaru-Backend/internal/domain"
|
||||||
|
"Yimaru-Backend/internal/ports"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type getNotificationStoreStub struct {
|
||||||
|
ports.NotificationStore
|
||||||
|
notification *domain.Notification
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *getNotificationStoreStub) GetNotification(_ context.Context, id int64) (*domain.Notification, error) {
|
||||||
|
if s.err != nil {
|
||||||
|
return nil, s.err
|
||||||
|
}
|
||||||
|
if s.notification == nil {
|
||||||
|
return nil, pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
return s.notification, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetNotificationByID_ownNotification(t *testing.T) {
|
||||||
|
svc := &Service{
|
||||||
|
store: &getNotificationStoreStub{
|
||||||
|
notification: &domain.Notification{
|
||||||
|
ID: "10",
|
||||||
|
RecipientID: 42,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := svc.GetNotificationByID(context.Background(), 10, 42, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected nil error, got %v", err)
|
||||||
|
}
|
||||||
|
if got.RecipientID != 42 {
|
||||||
|
t.Fatalf("expected recipient 42, got %d", got.RecipientID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetNotificationByID_forbiddenForOtherUser(t *testing.T) {
|
||||||
|
svc := &Service{
|
||||||
|
store: &getNotificationStoreStub{
|
||||||
|
notification: &domain.Notification{
|
||||||
|
ID: "10",
|
||||||
|
RecipientID: 99,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := svc.GetNotificationByID(context.Background(), 10, 42, false)
|
||||||
|
if !errors.Is(err, ErrNotificationForbidden) {
|
||||||
|
t.Fatalf("expected ErrNotificationForbidden, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetNotificationByID_adminCanReadAny(t *testing.T) {
|
||||||
|
svc := &Service{
|
||||||
|
store: &getNotificationStoreStub{
|
||||||
|
notification: &domain.Notification{
|
||||||
|
ID: "10",
|
||||||
|
RecipientID: 99,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := svc.GetNotificationByID(context.Background(), 10, 42, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected nil error, got %v", err)
|
||||||
|
}
|
||||||
|
if got.RecipientID != 99 {
|
||||||
|
t.Fatalf("expected recipient 99, got %d", got.RecipientID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetNotificationByID_notFound(t *testing.T) {
|
||||||
|
svc := &Service{
|
||||||
|
store: &getNotificationStoreStub{},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := svc.GetNotificationByID(context.Background(), 10, 42, false)
|
||||||
|
if !errors.Is(err, ErrNotificationNotFound) {
|
||||||
|
t.Fatalf("expected ErrNotificationNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,8 +26,22 @@ func (s *Service) GetQuestionTypeDefinitionByID(ctx context.Context, id int64) (
|
||||||
return s.questionStore.GetQuestionTypeDefinitionByID(ctx, id)
|
return s.questionStore.GetQuestionTypeDefinitionByID(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ListQuestionTypeDefinitions(ctx context.Context, status *string, includeSystem bool) ([]domain.QuestionTypeDefinition, error) {
|
func clampQuestionTypeDefinitionPage(limit, offset int32) (int32, int32) {
|
||||||
return s.questionStore.ListQuestionTypeDefinitions(ctx, status, includeSystem)
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
if limit > 200 {
|
||||||
|
limit = 200
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
return limit, offset
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListQuestionTypeDefinitions(ctx context.Context, status *string, includeSystem bool, limit, offset int32) ([]domain.QuestionTypeDefinition, int64, error) {
|
||||||
|
limit, offset = clampQuestionTypeDefinitionPage(limit, offset)
|
||||||
|
return s.questionStore.ListQuestionTypeDefinitions(ctx, status, includeSystem, limit, offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) UpdateQuestionTypeDefinition(ctx context.Context, id int64, input domain.UpdateQuestionTypeDefinitionInput) error {
|
func (s *Service) UpdateQuestionTypeDefinition(ctx context.Context, id int64, input domain.UpdateQuestionTypeDefinitionInput) error {
|
||||||
|
|
@ -192,6 +206,31 @@ func (s *Service) CountQuestionsInSet(ctx context.Context, setID int64) (int64,
|
||||||
return s.questionStore.CountQuestionsInSet(ctx, setID)
|
return s.questionStore.CountQuestionsInSet(ctx, setID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetQuestionTypesInSet(ctx context.Context, setID int64) (domain.QuestionSetQuestionTypesSummary, error) {
|
||||||
|
groups, err := s.questionStore.GetQuestionTypeCountsInSet(ctx, setID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.QuestionSetQuestionTypesSummary{}, err
|
||||||
|
}
|
||||||
|
active := "ACTIVE"
|
||||||
|
catalog, _, err := s.questionStore.ListQuestionTypeDefinitions(ctx, &active, true, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
return domain.QuestionSetQuestionTypesSummary{}, err
|
||||||
|
}
|
||||||
|
counts := domain.SummarizeQuestionSetQuestionTypes(groups, catalog)
|
||||||
|
var total int64
|
||||||
|
for _, c := range counts {
|
||||||
|
total += c.Count
|
||||||
|
}
|
||||||
|
if counts == nil {
|
||||||
|
counts = []domain.QuestionSetQuestionTypeCount{}
|
||||||
|
}
|
||||||
|
return domain.QuestionSetQuestionTypesSummary{
|
||||||
|
QuestionSetID: setID,
|
||||||
|
QuestionTypes: counts,
|
||||||
|
TotalQuestions: total,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) GetQuestionSetsContainingQuestion(ctx context.Context, questionID int64) ([]domain.QuestionSet, error) {
|
func (s *Service) GetQuestionSetsContainingQuestion(ctx context.Context, questionID int64) ([]domain.QuestionSet, error) {
|
||||||
return s.questionStore.GetQuestionSetsContainingQuestion(ctx, questionID)
|
return s.questionStore.GetQuestionSetsContainingQuestion(ctx, questionID)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
20
internal/services/questions/service_test.go
Normal file
20
internal/services/questions/service_test.go
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
package questions
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestClampQuestionTypeDefinitionPage_defaultsAndCaps(t *testing.T) {
|
||||||
|
limit, offset := clampQuestionTypeDefinitionPage(0, -5)
|
||||||
|
if limit != 20 || offset != 0 {
|
||||||
|
t.Fatalf("expected default limit=20 offset=0, got limit=%d offset=%d", limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, offset = clampQuestionTypeDefinitionPage(500, 10)
|
||||||
|
if limit != 200 || offset != 10 {
|
||||||
|
t.Fatalf("expected capped limit=200 offset=10, got limit=%d offset=%d", limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, offset = clampQuestionTypeDefinitionPage(50, 25)
|
||||||
|
if limit != 50 || offset != 25 {
|
||||||
|
t.Fatalf("expected limit=50 offset=25, got limit=%d offset=%d", limit, offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -172,6 +172,7 @@ var AllPermissions = []domain.PermissionSeed{
|
||||||
{Key: "payments.cancel", Name: "Cancel Payment", Description: "Cancel a payment", GroupName: "Payments"},
|
{Key: "payments.cancel", Name: "Cancel Payment", Description: "Cancel a payment", GroupName: "Payments"},
|
||||||
{Key: "payments.direct_initiate", Name: "Initiate Direct Payment", Description: "Initiate direct payment", GroupName: "Payments"},
|
{Key: "payments.direct_initiate", Name: "Initiate Direct Payment", Description: "Initiate direct payment", GroupName: "Payments"},
|
||||||
{Key: "payments.direct_verify_otp", Name: "Verify Direct Payment OTP", Description: "Verify OTP for direct payment", GroupName: "Payments"},
|
{Key: "payments.direct_verify_otp", Name: "Verify Direct Payment OTP", Description: "Verify OTP for direct payment", GroupName: "Payments"},
|
||||||
|
{Key: "payments.list_all", Name: "List All Payments", Description: "List and filter all gateway payments (admin)", GroupName: "Payments"},
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
{Key: "users.list", Name: "List Users", Description: "List all users", GroupName: "Users"},
|
{Key: "users.list", Name: "List Users", Description: "List all users", GroupName: "Users"},
|
||||||
|
|
@ -444,7 +445,7 @@ var DefaultRolePermissions = map[string][]string{
|
||||||
"subscriptions.create", "subscriptions.checkout", "subscriptions.get_mine", "subscriptions.history",
|
"subscriptions.create", "subscriptions.checkout", "subscriptions.get_mine", "subscriptions.history",
|
||||||
"subscriptions.status", "subscriptions.cancel", "subscriptions.set_auto_renew",
|
"subscriptions.status", "subscriptions.cancel", "subscriptions.set_auto_renew",
|
||||||
"payments.initiate", "payments.verify", "payments.list_mine", "payments.get", "payments.cancel",
|
"payments.initiate", "payments.verify", "payments.list_mine", "payments.get", "payments.cancel",
|
||||||
"payments.direct_initiate", "payments.direct_verify_otp",
|
"payments.direct_initiate", "payments.direct_verify_otp", "payments.list_all",
|
||||||
|
|
||||||
// Users (full access)
|
// Users (full access)
|
||||||
"users.list", "users.get", "users.update_self", "users.update_status", "users.delete", "users.delete_self", "users.cancel_delete_self", "users.purge_due_deletions", "users.deletion_requests.list", "users.search",
|
"users.list", "users.get", "users.update_self", "users.update_status", "users.delete", "users.delete_self", "users.cancel_delete_self", "users.purge_due_deletions", "users.deletion_requests.list", "users.search",
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
|
||||||
|
|
||||||
"Yimaru-Backend/internal/domain"
|
"Yimaru-Backend/internal/domain"
|
||||||
"Yimaru-Backend/internal/services/chapa"
|
"Yimaru-Backend/internal/services/chapa"
|
||||||
|
|
@ -332,15 +330,7 @@ func (h *Handler) HandleArifpaySuccessPage(c *fiber.Ctx) error {
|
||||||
c.Query("nonce"),
|
c.Query("nonce"),
|
||||||
)
|
)
|
||||||
|
|
||||||
page := arifpaySuccessPageData{
|
page := defaultPaymentSuccessPage()
|
||||||
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 != "" {
|
if ref != "" {
|
||||||
payment, err := h.arifpaySvc.VerifyPayment(c.Context(), ref)
|
payment, err := h.arifpaySvc.VerifyPayment(c.Context(), ref)
|
||||||
|
|
@ -364,7 +354,7 @@ func (h *Handler) HandleArifpaySuccessPage(c *fiber.Ctx) error {
|
||||||
page.Helper = "Return to Yimaru Academy and refresh your subscription status if you do not see access immediately."
|
page.Helper = "Return to Yimaru Academy and refresh your subscription status if you do not see access immediately."
|
||||||
}
|
}
|
||||||
|
|
||||||
html, err := renderArifpaySuccessPage(page)
|
html, err := renderPaymentSuccessPage(page)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).SendString("Failed to render success page")
|
return c.Status(fiber.StatusInternalServerError).SendString("Failed to render success page")
|
||||||
}
|
}
|
||||||
|
|
@ -585,95 +575,3 @@ func paymentToRes(p *domain.Payment) *paymentRes {
|
||||||
|
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
type arifpaySuccessPageData struct {
|
|
||||||
Title string
|
|
||||||
Headline string
|
|
||||||
Body string
|
|
||||||
Helper string
|
|
||||||
BadgeLabel string
|
|
||||||
StatusLabel string
|
|
||||||
Reference string
|
|
||||||
PlanName string
|
|
||||||
ActionLabel string
|
|
||||||
ActionHref string
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderArifpaySuccessPage(data arifpaySuccessPageData) (string, error) {
|
|
||||||
const tpl = `<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>{{.Title}}</title>
|
|
||||||
</head>
|
|
||||||
<body style="margin:0;background:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;color:#333;">
|
|
||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="min-height:100vh;background:#f4f6fb;padding:24px 16px;">
|
|
||||||
<tr>
|
|
||||||
<td align="center">
|
|
||||||
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="max-width:640px;width:100%;background:#ffffff;border-radius:18px;overflow:hidden;box-shadow:0 14px 40px rgba(157,42,131,0.12);">
|
|
||||||
<tr>
|
|
||||||
<td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 55%,#c43a9a 100%);padding:32px 28px;text-align:center;">
|
|
||||||
<div style="display:inline-block;padding:8px 14px;border-radius:999px;background:rgba(255,255,255,0.15);color:#fff;font-size:12px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;">{{.BadgeLabel}}</div>
|
|
||||||
<h1 style="margin:18px 0 8px;color:#fff;font-size:30px;line-height:1.2;">Yimaru Academy</h1>
|
|
||||||
<p style="margin:0;color:rgba(255,255,255,0.88);font-size:16px;">{{.Headline}}</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding:32px 28px;">
|
|
||||||
<div style="margin:0 auto 24px;width:76px;height:76px;border-radius:50%;background:#eef9f2;border:1px solid #cfead9;text-align:center;line-height:76px;font-size:40px;color:#1f9d55;">✓</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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,76 @@ func (h *Handler) GoogleAndroidLogin(c *fiber.Ctx) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AppleLogin godoc
|
||||||
|
// @Summary Login via Sign in with Apple identity token
|
||||||
|
// @Description Validates an Apple identity token (iOS, Android, or web). On first sign-in, include email and name if Apple only returns them to the client once.
|
||||||
|
// @Tags auth
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body object true "Apple login payload"
|
||||||
|
// @Router /api/v1/auth/apple [post]
|
||||||
|
func (h *Handler) AppleLogin(c *fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
IDToken string `json:"id_token" validate:"required"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
FirstName string `json:"first_name"`
|
||||||
|
LastName string `json:"last_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Invalid request body",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.Cfg.AppleSignInClientIDs == "" {
|
||||||
|
return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Apple Sign In is not configured",
|
||||||
|
Error: "APPLE_SIGN_IN_CLIENT_IDS is not set",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
loginRes, err := h.authSvc.LoginWithAppleMobile(c.Context(), req.IDToken, h.Cfg.AppleSignInClientIDs, domain.AppleUser{
|
||||||
|
Email: req.Email,
|
||||||
|
GivenName: req.FirstName,
|
||||||
|
FamilyName: req.LastName,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.mongoLoggerSvc.Error("Apple login failed",
|
||||||
|
zap.Error(err),
|
||||||
|
zap.Int("id_token_length", len(req.IDToken)),
|
||||||
|
)
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Apple login failed",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, err := jwtutil.CreateJwt(
|
||||||
|
loginRes.UserId,
|
||||||
|
loginRes.Role,
|
||||||
|
h.jwtConfig.JwtAccessKey,
|
||||||
|
h.jwtConfig.JwtAccessExpiry,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Token generation failed",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
||||||
|
Message: "Login successful",
|
||||||
|
Data: loginUserRes{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
RefreshToken: loginRes.RfToken,
|
||||||
|
Role: string(loginRes.Role),
|
||||||
|
UserID: loginRes.UserId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// GoogleLogin godoc
|
// GoogleLogin godoc
|
||||||
// @Summary Google login redirect
|
// @Summary Google login redirect
|
||||||
// @Tags auth
|
// @Tags auth
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,56 @@ func (h *Handler) HandleChapaCallback(c *fiber.Ctx) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleChapaSuccessPage godoc
|
||||||
|
// @Summary Chapa payment success page
|
||||||
|
// @Description Displays the Yimaru Academy success page after Chapa redirects the learner to return_url
|
||||||
|
// @Tags payments
|
||||||
|
// @Produce html
|
||||||
|
// @Param trx_ref query string false "Chapa transaction reference (tx_ref)"
|
||||||
|
// @Param tx_ref query string false "Chapa transaction reference"
|
||||||
|
// @Param ref_id query string false "Chapa reference ID"
|
||||||
|
// @Param status query string false "Payment status from Chapa redirect"
|
||||||
|
// @Success 200 {string} string "HTML success page"
|
||||||
|
// @Router /api/v1/payments/chapa/success [get]
|
||||||
|
// @Router /payment/success [get]
|
||||||
|
func (h *Handler) HandleChapaSuccessPage(c *fiber.Ctx) error {
|
||||||
|
txRef := firstNonEmpty(
|
||||||
|
c.Query("trx_ref"),
|
||||||
|
c.Query("tx_ref"),
|
||||||
|
)
|
||||||
|
|
||||||
|
page := defaultPaymentSuccessPage()
|
||||||
|
|
||||||
|
if txRef != "" {
|
||||||
|
payment, err := h.chapaSvc.VerifyPayment(c.Context(), txRef)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("Failed to verify Chapa success redirect", "error", err, "tx_ref", txRef)
|
||||||
|
page.Body = "Thank you for your payment. We are confirming it with Chapa 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 = txRef
|
||||||
|
} else {
|
||||||
|
page.Reference = txRef
|
||||||
|
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 := renderPaymentSuccessPage(page)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).SendString("Failed to render success page")
|
||||||
|
}
|
||||||
|
c.Type("html", "utf-8")
|
||||||
|
return c.SendString(html)
|
||||||
|
}
|
||||||
|
|
||||||
// GetChapaPaymentMethods godoc
|
// GetChapaPaymentMethods godoc
|
||||||
// @Summary Get Chapa payment methods
|
// @Summary Get Chapa payment methods
|
||||||
// @Description Returns payment methods available on Chapa checkout
|
// @Description Returns payment methods available on Chapa checkout
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"path"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -174,11 +175,11 @@ func (h *Handler) RefreshFileURL(c *fiber.Ctx) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadMedia uploads an image/audio/video file and returns its URL and key.
|
// UploadMedia uploads an image/audio/video/pdf file and returns its URL and key.
|
||||||
// @Summary Upload media file
|
// @Summary Upload media file
|
||||||
// @Tags files
|
// @Tags files
|
||||||
// @Accept multipart/form-data
|
// @Accept multipart/form-data
|
||||||
// @Param media_type formData string true "Media type: image|audio|video"
|
// @Param media_type formData string true "Media type: image|audio|video|pdf"
|
||||||
// @Param file formData file true "Media file"
|
// @Param file formData file true "Media file"
|
||||||
// @Success 200 {object} domain.Response
|
// @Success 200 {object} domain.Response
|
||||||
// @Router /api/v1/files/upload [post]
|
// @Router /api/v1/files/upload [post]
|
||||||
|
|
@ -205,10 +206,10 @@ func (h *Handler) UploadMedia(c *fiber.Ctx) error {
|
||||||
if mediaType == "" {
|
if mediaType == "" {
|
||||||
mediaType = "file"
|
mediaType = "file"
|
||||||
}
|
}
|
||||||
if mediaType != "image" && mediaType != "audio" && mediaType != "video" {
|
if mediaType != "image" && mediaType != "audio" && mediaType != "video" && mediaType != "pdf" {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
Message: "Invalid media_type",
|
Message: "Invalid media_type",
|
||||||
Error: "media_type must be one of: image, audio, video",
|
Error: "media_type must be one of: image, audio, video, pdf",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -218,6 +219,8 @@ func (h *Handler) UploadMedia(c *fiber.Ctx) error {
|
||||||
maxSize = 10 * 1024 * 1024
|
maxSize = 10 * 1024 * 1024
|
||||||
case "audio":
|
case "audio":
|
||||||
maxSize = 50 * 1024 * 1024
|
maxSize = 50 * 1024 * 1024
|
||||||
|
case "pdf":
|
||||||
|
maxSize = 25 * 1024 * 1024
|
||||||
case "video":
|
case "video":
|
||||||
maxSize = 500 * 1024 * 1024
|
maxSize = 500 * 1024 * 1024
|
||||||
}
|
}
|
||||||
|
|
@ -226,9 +229,9 @@ func (h *Handler) UploadMedia(c *fiber.Ctx) error {
|
||||||
Message: "Vimeo service is not available for video uploads",
|
Message: "Vimeo service is not available for video uploads",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (mediaType == "image" || mediaType == "audio") && h.minioSvc == nil {
|
if (mediaType == "image" || mediaType == "audio" || mediaType == "pdf") && h.minioSvc == nil {
|
||||||
return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{
|
return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{
|
||||||
Message: "MinIO service is not available for image/audio uploads",
|
Message: "MinIO service is not available for image/audio/pdf uploads",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -396,6 +399,15 @@ func normalizeAndValidateMediaContentType(mediaType, contentType, fileName strin
|
||||||
if !strings.HasPrefix(contentType, "video/") {
|
if !strings.HasPrefix(contentType, "video/") {
|
||||||
return "", fmt.Errorf("only video files are allowed")
|
return "", fmt.Errorf("only video files are allowed")
|
||||||
}
|
}
|
||||||
|
case "pdf":
|
||||||
|
if contentType == "application/octet-stream" {
|
||||||
|
if ext := strings.ToLower(path.Ext(fileName)); ext == ".pdf" {
|
||||||
|
contentType = "application/pdf"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if contentType != "application/pdf" {
|
||||||
|
return "", fmt.Errorf("only PDF files are allowed")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return contentType, nil
|
return contentType, nil
|
||||||
|
|
|
||||||
20
internal/web_server/handlers/file_handler_media_test.go
Normal file
20
internal/web_server/handlers/file_handler_media_test.go
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNormalizeAndValidateMediaContentType_pdf(t *testing.T) {
|
||||||
|
got, err := normalizeAndValidateMediaContentType("pdf", "application/pdf", "reading-passage.pdf")
|
||||||
|
if err != nil || got != "application/pdf" {
|
||||||
|
t.Fatalf("expected application/pdf, got %q err=%v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err = normalizeAndValidateMediaContentType("pdf", "application/octet-stream", "notes.pdf")
|
||||||
|
if err != nil || got != "application/pdf" {
|
||||||
|
t.Fatalf("expected pdf from extension, got %q err=%v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = normalizeAndValidateMediaContentType("pdf", "image/png", "file.png")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-pdf content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,10 +27,12 @@ func (h *Handler) CreateAssessmentQuestion(c *fiber.Ctx) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.QuestionText == "" {
|
questionType := normalizeRuntimeQuestionType(req.QuestionType)
|
||||||
|
questionText, err := resolveStoredQuestionText(questionType, req.QuestionText, req.DynamicPayload, "")
|
||||||
|
if err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
Message: "Validation error",
|
Message: "Invalid question_text",
|
||||||
Error: "question_text is required",
|
Error: err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,8 +56,8 @@ func (h *Handler) CreateAssessmentQuestion(c *fiber.Ctx) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
input := domain.CreateQuestionInput{
|
input := domain.CreateQuestionInput{
|
||||||
QuestionText: req.QuestionText,
|
QuestionText: questionText,
|
||||||
QuestionType: req.QuestionType,
|
QuestionType: questionType,
|
||||||
DifficultyLevel: req.DifficultyLevel,
|
DifficultyLevel: req.DifficultyLevel,
|
||||||
Points: req.Points,
|
Points: req.Points,
|
||||||
Explanation: req.Explanation,
|
Explanation: req.Explanation,
|
||||||
|
|
@ -81,7 +83,7 @@ func (h *Handler) CreateAssessmentQuestion(c *fiber.Ctx) error {
|
||||||
Success: true,
|
Success: true,
|
||||||
Data: questionRes{
|
Data: questionRes{
|
||||||
ID: question.ID,
|
ID: question.ID,
|
||||||
QuestionText: question.QuestionText,
|
QuestionText: questionTextField(question.QuestionType, question.QuestionText),
|
||||||
QuestionType: question.QuestionType,
|
QuestionType: question.QuestionType,
|
||||||
Status: question.Status,
|
Status: question.Status,
|
||||||
CreatedAt: question.CreatedAt.String(),
|
CreatedAt: question.CreatedAt.String(),
|
||||||
|
|
@ -129,7 +131,7 @@ func (h *Handler) ListAssessmentQuestions(c *fiber.Ctx) error {
|
||||||
|
|
||||||
questionResponses = append(questionResponses, questionRes{
|
questionResponses = append(questionResponses, questionRes{
|
||||||
ID: q.ID,
|
ID: q.ID,
|
||||||
QuestionText: q.QuestionText,
|
QuestionText: questionTextField(q.QuestionType, q.QuestionText),
|
||||||
QuestionType: q.QuestionType,
|
QuestionType: q.QuestionType,
|
||||||
DifficultyLevel: q.DifficultyLevel,
|
DifficultyLevel: q.DifficultyLevel,
|
||||||
Points: q.Points,
|
Points: q.Points,
|
||||||
|
|
@ -200,7 +202,7 @@ func (h *Handler) GetAssessmentQuestionByID(c *fiber.Ctx) error {
|
||||||
Message: "Question fetched successfully",
|
Message: "Question fetched successfully",
|
||||||
Data: questionRes{
|
Data: questionRes{
|
||||||
ID: question.ID,
|
ID: question.ID,
|
||||||
QuestionText: question.QuestionText,
|
QuestionText: questionTextField(question.QuestionType, question.QuestionText),
|
||||||
QuestionType: question.QuestionType,
|
QuestionType: question.QuestionType,
|
||||||
DifficultyLevel: question.DifficultyLevel,
|
DifficultyLevel: question.DifficultyLevel,
|
||||||
Points: question.Points,
|
Points: question.Points,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"Yimaru-Backend/internal/domain"
|
"Yimaru-Backend/internal/domain"
|
||||||
|
notificationservice "Yimaru-Backend/internal/services/notification"
|
||||||
"Yimaru-Backend/internal/web_server/ws"
|
"Yimaru-Backend/internal/web_server/ws"
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -120,6 +121,74 @@ func (w *hijackResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||||
return w.conn, w.brw, nil
|
return w.conn, w.brw, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetNotificationByID godoc
|
||||||
|
// @Summary Get in-app notification by ID
|
||||||
|
// @Description Returns a single in-app notification. Users may only fetch their own notifications unless they have list-all access.
|
||||||
|
// @Tags notifications
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path int true "Notification ID"
|
||||||
|
// @Success 200 {object} domain.Response
|
||||||
|
// @Failure 400 {object} domain.ErrorResponse
|
||||||
|
// @Failure 403 {object} domain.ErrorResponse
|
||||||
|
// @Failure 404 {object} domain.ErrorResponse
|
||||||
|
// @Failure 500 {object} domain.ErrorResponse
|
||||||
|
// @Router /api/v1/notifications/{id} [get]
|
||||||
|
func (h *Handler) GetNotificationByID(c *fiber.Ctx) error {
|
||||||
|
idStr := c.Params("id")
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Invalid notification ID",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, ok := c.Locals("user_id").(int64)
|
||||||
|
if !ok || userID == 0 {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Invalid user identification",
|
||||||
|
Error: "User ID not found in context",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
role, _ := c.Locals("role").(domain.Role)
|
||||||
|
allowAll := h.rbacSvc.HasPermission(string(role), "notifications.list_all")
|
||||||
|
|
||||||
|
notification, err := h.notificationSvc.GetNotificationByID(c.Context(), id, userID, allowAll)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, notificationservice.ErrNotificationNotFound):
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Notification not found",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
case errors.Is(err, notificationservice.ErrNotificationForbidden):
|
||||||
|
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Notification access denied",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
h.mongoLoggerSvc.Error("[NotificationHandler.GetNotificationByID] Failed to get notification",
|
||||||
|
zap.Int64("notificationID", id),
|
||||||
|
zap.Int64("userID", userID),
|
||||||
|
zap.Error(err),
|
||||||
|
zap.Time("timestamp", time.Now()),
|
||||||
|
)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Failed to get notification",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
||||||
|
Message: "Notification retrieved successfully",
|
||||||
|
Success: true,
|
||||||
|
StatusCode: fiber.StatusOK,
|
||||||
|
Data: notification,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) MarkNotificationAsRead(c *fiber.Ctx) error {
|
func (h *Handler) MarkNotificationAsRead(c *fiber.Ctx) error {
|
||||||
idStr := c.Params("id")
|
idStr := c.Params("id")
|
||||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
|
|
||||||
110
internal/web_server/handlers/payment_success_page.go
Normal file
110
internal/web_server/handlers/payment_success_page.go
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"html/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
type paymentSuccessPageData struct {
|
||||||
|
Title string
|
||||||
|
Headline string
|
||||||
|
Body string
|
||||||
|
Helper string
|
||||||
|
BadgeLabel string
|
||||||
|
StatusLabel string
|
||||||
|
Reference string
|
||||||
|
PlanName string
|
||||||
|
ActionLabel string
|
||||||
|
ActionHref string
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderPaymentSuccessPage(data paymentSuccessPageData) (string, error) {
|
||||||
|
const tpl = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;background:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;color:#333;">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="min-height:100vh;background:#f4f6fb;padding:24px 16px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="max-width:640px;width:100%;background:#ffffff;border-radius:18px;overflow:hidden;box-shadow:0 14px 40px rgba(157,42,131,0.12);">
|
||||||
|
<tr>
|
||||||
|
<td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 55%,#c43a9a 100%);padding:32px 28px;text-align:center;">
|
||||||
|
<div style="display:inline-block;padding:8px 14px;border-radius:999px;background:rgba(255,255,255,0.15);color:#fff;font-size:12px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;">{{.BadgeLabel}}</div>
|
||||||
|
<h1 style="margin:18px 0 8px;color:#fff;font-size:30px;line-height:1.2;">Yimaru Academy</h1>
|
||||||
|
<p style="margin:0;color:rgba(255,255,255,0.88);font-size:16px;">{{.Headline}}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:32px 28px;">
|
||||||
|
<div style="margin:0 auto 24px;width:76px;height:76px;border-radius:50%;background:#eef9f2;border:1px solid #cfead9;text-align:center;line-height:76px;font-size:40px;color:#1f9d55;">✓</div>
|
||||||
|
<p style="margin:0 0 18px;font-size:16px;line-height:1.7;color:#555;">{{.Body}}</p>
|
||||||
|
{{if .Helper}}<p style="margin:0 0 22px;font-size:14px;line-height:1.7;color:#777;">{{.Helper}}</p>{{end}}
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="margin:0 0 26px;background:#f8f3f8;border:1px solid #eddced;border-radius:12px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:18px 20px;">
|
||||||
|
<p style="margin:0 0 8px;font-size:12px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#9d2a83;">Status</p>
|
||||||
|
<p style="margin:0;font-size:18px;font-weight:700;color:#333;">{{.StatusLabel}}</p>
|
||||||
|
{{if .PlanName}}<p style="margin:14px 0 0;font-size:14px;color:#555;"><strong>Plan:</strong> {{.PlanName}}</p>{{end}}
|
||||||
|
{{if .Reference}}<p style="margin:8px 0 0;font-size:14px;color:#555;word-break:break-word;"><strong>Reference:</strong> {{.Reference}}</p>{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<div style="text-align:center;">
|
||||||
|
<a href="{{.ActionHref}}" style="display:inline-block;padding:14px 24px;border-radius:10px;background:#9d2a83;color:#fff;text-decoration:none;font-size:15px;font-weight:700;">{{.ActionLabel}}</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px 28px;background:#fafafa;border-top:1px solid #eee;text-align:center;">
|
||||||
|
<p style="margin:0;font-size:12px;line-height:1.6;color:#8a8a8a;">Yimaru Academy subscription payments are verified securely before access is granted.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
t, err := template.New("payment-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 defaultPaymentSuccessPage() paymentSuccessPageData {
|
||||||
|
return paymentSuccessPageData{
|
||||||
|
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: "/",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
307
internal/web_server/handlers/payments_admin.go
Normal file
307
internal/web_server/handlers/payments_admin.go
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"Yimaru-Backend/internal/domain"
|
||||||
|
"Yimaru-Backend/internal/services/chapa"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type adminPaymentRes struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
PlanID *int64 `json:"plan_id,omitempty"`
|
||||||
|
SubscriptionID *int64 `json:"subscription_id,omitempty"`
|
||||||
|
SessionID *string `json:"session_id,omitempty"`
|
||||||
|
TransactionID *string `json:"transaction_id,omitempty"`
|
||||||
|
Nonce string `json:"nonce"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
PaymentMethod *string `json:"payment_method,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
PaymentURL *string `json:"payment_url,omitempty"`
|
||||||
|
PlanName *string `json:"plan_name,omitempty"`
|
||||||
|
PlanCategory *string `json:"plan_category,omitempty"`
|
||||||
|
UserEmail *string `json:"user_email,omitempty"`
|
||||||
|
UserFirstName *string `json:"user_first_name,omitempty"`
|
||||||
|
UserLastName *string `json:"user_last_name,omitempty"`
|
||||||
|
PaidAt *string `json:"paid_at,omitempty"`
|
||||||
|
ExpiresAt *string `json:"expires_at,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt *string `json:"updated_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type listAdminPaymentsRes struct {
|
||||||
|
Payments []adminPaymentRes `json:"payments"`
|
||||||
|
TotalCount int64 `json:"total_count"`
|
||||||
|
Limit int32 `json:"limit"`
|
||||||
|
Offset int32 `json:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAdminPayments godoc
|
||||||
|
// @Summary List all payments (admin)
|
||||||
|
// @Description Returns paginated payments across Chapa and ArifPay with optional filters
|
||||||
|
// @Tags payments
|
||||||
|
// @Produce json
|
||||||
|
// @Param user_id query int false "Filter by learner user ID"
|
||||||
|
// @Param plan_id query int false "Filter by subscription plan ID"
|
||||||
|
// @Param subscription_id query int false "Filter by user subscription ID"
|
||||||
|
// @Param status query string false "Payment status (PENDING, PROCESSING, SUCCESS, FAILED, CANCELLED, EXPIRED)"
|
||||||
|
// @Param provider query string false "Payment provider (CHAPA, ARIFPAY)"
|
||||||
|
// @Param payment_method query string false "Alias for provider"
|
||||||
|
// @Param currency query string false "Currency code (e.g. ETB)"
|
||||||
|
// @Param plan_category query string false "Plan category (LEARN_ENGLISH, IELTS, DUOLINGO)"
|
||||||
|
// @Param reference query string false "Search session_id, nonce, or transaction_id"
|
||||||
|
// @Param created_from query string false "Created at from (RFC3339 or YYYY-MM-DD)"
|
||||||
|
// @Param created_to query string false "Created at to (exclusive, RFC3339 or YYYY-MM-DD)"
|
||||||
|
// @Param paid_from query string false "Paid at from (RFC3339 or YYYY-MM-DD)"
|
||||||
|
// @Param paid_to query string false "Paid at to (exclusive, RFC3339 or YYYY-MM-DD)"
|
||||||
|
// @Param min_amount query number false "Minimum amount"
|
||||||
|
// @Param max_amount query number false "Maximum amount"
|
||||||
|
// @Param limit query int false "Page size" default(20)
|
||||||
|
// @Param offset query int false "Page offset" default(0)
|
||||||
|
// @Success 200 {object} domain.Response
|
||||||
|
// @Failure 400 {object} domain.ErrorResponse
|
||||||
|
// @Router /api/v1/admin/payments [get]
|
||||||
|
func (h *Handler) ListAdminPayments(c *fiber.Ctx) error {
|
||||||
|
filter, err := parsePaymentListFilter(c)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Invalid query parameters",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
page, err := h.chapaSvc.ListPaymentsAdmin(c.Context(), filter)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Failed to list payments",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]adminPaymentRes, len(page.Items))
|
||||||
|
for i := range page.Items {
|
||||||
|
out[i] = adminPaymentToRes(&page.Items[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(domain.Response{
|
||||||
|
Message: "Payments retrieved successfully",
|
||||||
|
Data: listAdminPaymentsRes{
|
||||||
|
Payments: out,
|
||||||
|
TotalCount: page.Total,
|
||||||
|
Limit: page.Limit,
|
||||||
|
Offset: page.Offset,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAdminPayment godoc
|
||||||
|
// @Summary Get payment by ID (admin)
|
||||||
|
// @Description Returns any payment record by ID without learner ownership restriction
|
||||||
|
// @Tags payments
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path int true "Payment ID"
|
||||||
|
// @Success 200 {object} domain.Response
|
||||||
|
// @Failure 404 {object} domain.ErrorResponse
|
||||||
|
// @Router /api/v1/admin/payments/{id} [get]
|
||||||
|
func (h *Handler) GetAdminPayment(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 payment ID",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
payment, err := h.chapaSvc.GetPaymentAdminByID(c.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, chapa.ErrPaymentNotFound) {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Payment not found",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Failed to get payment",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(domain.Response{
|
||||||
|
Message: "Payment retrieved successfully",
|
||||||
|
Data: adminPaymentToRes(payment),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePaymentListFilter(c *fiber.Ctx) (domain.PaymentListFilter, error) {
|
||||||
|
limit, err := strconv.Atoi(c.Query("limit", "20"))
|
||||||
|
if err != nil || limit < 1 {
|
||||||
|
return domain.PaymentListFilter{}, fmt.Errorf("invalid limit")
|
||||||
|
}
|
||||||
|
if limit > 100 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
offset, err := strconv.Atoi(c.Query("offset", "0"))
|
||||||
|
if err != nil || offset < 0 {
|
||||||
|
return domain.PaymentListFilter{}, fmt.Errorf("invalid offset")
|
||||||
|
}
|
||||||
|
|
||||||
|
filter := domain.PaymentListFilter{
|
||||||
|
Limit: int32(limit),
|
||||||
|
Offset: int32(offset),
|
||||||
|
}
|
||||||
|
|
||||||
|
if v := strings.TrimSpace(c.Query("user_id")); v != "" {
|
||||||
|
id, err := strconv.ParseInt(v, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PaymentListFilter{}, fmt.Errorf("invalid user_id")
|
||||||
|
}
|
||||||
|
filter.UserID = &id
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(c.Query("plan_id")); v != "" {
|
||||||
|
id, err := strconv.ParseInt(v, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PaymentListFilter{}, fmt.Errorf("invalid plan_id")
|
||||||
|
}
|
||||||
|
filter.PlanID = &id
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(c.Query("subscription_id")); v != "" {
|
||||||
|
id, err := strconv.ParseInt(v, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PaymentListFilter{}, fmt.Errorf("invalid subscription_id")
|
||||||
|
}
|
||||||
|
filter.SubscriptionID = &id
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(c.Query("status")); v != "" {
|
||||||
|
status := strings.ToUpper(v)
|
||||||
|
if !isValidPaymentStatus(status) {
|
||||||
|
return domain.PaymentListFilter{}, fmt.Errorf("invalid status")
|
||||||
|
}
|
||||||
|
filter.Status = &status
|
||||||
|
}
|
||||||
|
provider := firstNonEmpty(strings.TrimSpace(c.Query("provider")), strings.TrimSpace(c.Query("payment_method")))
|
||||||
|
if provider != "" {
|
||||||
|
p, err := domain.ParsePaymentProvider(provider)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PaymentListFilter{}, err
|
||||||
|
}
|
||||||
|
method := string(p)
|
||||||
|
filter.PaymentMethod = &method
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(c.Query("currency")); v != "" {
|
||||||
|
cur := strings.ToUpper(v)
|
||||||
|
filter.Currency = &cur
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(c.Query("plan_category")); v != "" {
|
||||||
|
cat := strings.ToUpper(v)
|
||||||
|
if cat != string(domain.SubscriptionCategoryLearnEnglish) &&
|
||||||
|
cat != string(domain.SubscriptionCategoryIELTS) &&
|
||||||
|
cat != string(domain.SubscriptionCategoryDuolingo) {
|
||||||
|
return domain.PaymentListFilter{}, fmt.Errorf("invalid plan_category")
|
||||||
|
}
|
||||||
|
filter.PlanCategory = &cat
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(c.Query("reference")); v != "" {
|
||||||
|
filter.Reference = &v
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pair := range []struct {
|
||||||
|
q string
|
||||||
|
dest **time.Time
|
||||||
|
}{
|
||||||
|
{"created_from", &filter.CreatedFrom},
|
||||||
|
{"created_to", &filter.CreatedTo},
|
||||||
|
{"paid_from", &filter.PaidFrom},
|
||||||
|
{"paid_to", &filter.PaidTo},
|
||||||
|
} {
|
||||||
|
if v := strings.TrimSpace(c.Query(pair.q)); v != "" {
|
||||||
|
t, err := parseQueryTime(v)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PaymentListFilter{}, fmt.Errorf("invalid %s", pair.q)
|
||||||
|
}
|
||||||
|
*pair.dest = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if v := strings.TrimSpace(c.Query("min_amount")); v != "" {
|
||||||
|
amount, err := strconv.ParseFloat(v, 64)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PaymentListFilter{}, fmt.Errorf("invalid min_amount")
|
||||||
|
}
|
||||||
|
filter.MinAmount = &amount
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(c.Query("max_amount")); v != "" {
|
||||||
|
amount, err := strconv.ParseFloat(v, 64)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PaymentListFilter{}, fmt.Errorf("invalid max_amount")
|
||||||
|
}
|
||||||
|
filter.MaxAmount = &amount
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidPaymentStatus(status string) bool {
|
||||||
|
switch status {
|
||||||
|
case string(domain.PaymentStatusPending),
|
||||||
|
string(domain.PaymentStatusProcessing),
|
||||||
|
string(domain.PaymentStatusSuccess),
|
||||||
|
string(domain.PaymentStatusFailed),
|
||||||
|
string(domain.PaymentStatusCancelled),
|
||||||
|
string(domain.PaymentStatusExpired):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseQueryTime(raw string) (time.Time, error) {
|
||||||
|
if t, err := time.Parse(time.RFC3339, raw); err == nil {
|
||||||
|
return t.UTC(), nil
|
||||||
|
}
|
||||||
|
if t, err := time.Parse("2006-01-02", raw); err == nil {
|
||||||
|
return t.UTC(), nil
|
||||||
|
}
|
||||||
|
return time.Time{}, fmt.Errorf("unsupported time format")
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminPaymentToRes(p *domain.Payment) adminPaymentRes {
|
||||||
|
res := adminPaymentRes{
|
||||||
|
ID: p.ID,
|
||||||
|
UserID: p.UserID,
|
||||||
|
PlanID: p.PlanID,
|
||||||
|
SubscriptionID: p.SubscriptionID,
|
||||||
|
SessionID: p.SessionID,
|
||||||
|
TransactionID: p.TransactionID,
|
||||||
|
Nonce: p.Nonce,
|
||||||
|
Amount: p.Amount,
|
||||||
|
Currency: p.Currency,
|
||||||
|
PaymentMethod: p.PaymentMethod,
|
||||||
|
Status: p.Status,
|
||||||
|
PaymentURL: p.PaymentURL,
|
||||||
|
PlanName: p.PlanName,
|
||||||
|
PlanCategory: p.PlanCategory,
|
||||||
|
UserEmail: p.UserEmail,
|
||||||
|
UserFirstName: p.UserFirstName,
|
||||||
|
UserLastName: p.UserLastName,
|
||||||
|
CreatedAt: p.CreatedAt.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if p.PaidAt != nil {
|
||||||
|
t := p.PaidAt.Format(time.RFC3339)
|
||||||
|
res.PaidAt = &t
|
||||||
|
}
|
||||||
|
if p.ExpiresAt != nil {
|
||||||
|
t := p.ExpiresAt.Format(time.RFC3339)
|
||||||
|
res.ExpiresAt = &t
|
||||||
|
}
|
||||||
|
if p.UpdatedAt != nil {
|
||||||
|
t := p.UpdatedAt.Format(time.RFC3339)
|
||||||
|
res.UpdatedAt = &t
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
|
|
@ -76,7 +77,8 @@ func (h *Handler) ListPracticesByCourse(c *fiber.Ctx) error {
|
||||||
}
|
}
|
||||||
limit, _ := strconv.Atoi(c.Query("limit", "20"))
|
limit, _ := strconv.Atoi(c.Query("limit", "20"))
|
||||||
offset, _ := strconv.Atoi(c.Query("offset", "0"))
|
offset, _ := strconv.Atoi(c.Query("offset", "0"))
|
||||||
publishedOnly := !h.canManageLMSPractices(c)
|
role, _ := c.Locals("role").(domain.Role)
|
||||||
|
publishedOnly := role.IsCustomerLearnerRole() || !h.canManageLMSPractices(c)
|
||||||
items, total, err := h.practiceSvc.ListByCourse(c.Context(), courseID, publishedOnly, int32(limit), int32(offset))
|
items, total, err := h.practiceSvc.ListByCourse(c.Context(), courseID, publishedOnly, int32(limit), int32(offset))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, courses.ErrCourseNotFound) {
|
if errors.Is(err, courses.ErrCourseNotFound) {
|
||||||
|
|
@ -84,6 +86,9 @@ func (h *Handler) ListPracticesByCourse(c *fiber.Ctx) error {
|
||||||
}
|
}
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to list practices", Error: err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to list practices", Error: err.Error()})
|
||||||
}
|
}
|
||||||
|
if err := h.applyPracticeAccess(c.Context(), c, items); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to build practices", Error: err.Error()})
|
||||||
|
}
|
||||||
return c.JSON(domain.Response{
|
return c.JSON(domain.Response{
|
||||||
Message: "Practices retrieved successfully",
|
Message: "Practices retrieved successfully",
|
||||||
Data: fiber.Map{
|
Data: fiber.Map{
|
||||||
|
|
@ -108,7 +113,8 @@ func (h *Handler) ListPracticesByModule(c *fiber.Ctx) error {
|
||||||
}
|
}
|
||||||
limit, _ := strconv.Atoi(c.Query("limit", "20"))
|
limit, _ := strconv.Atoi(c.Query("limit", "20"))
|
||||||
offset, _ := strconv.Atoi(c.Query("offset", "0"))
|
offset, _ := strconv.Atoi(c.Query("offset", "0"))
|
||||||
publishedOnly := !h.canManageLMSPractices(c)
|
role, _ := c.Locals("role").(domain.Role)
|
||||||
|
publishedOnly := role.IsCustomerLearnerRole() || !h.canManageLMSPractices(c)
|
||||||
items, total, err := h.practiceSvc.ListByModule(c.Context(), moduleID, publishedOnly, int32(limit), int32(offset))
|
items, total, err := h.practiceSvc.ListByModule(c.Context(), moduleID, publishedOnly, int32(limit), int32(offset))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, modules.ErrModuleNotFound) {
|
if errors.Is(err, modules.ErrModuleNotFound) {
|
||||||
|
|
@ -116,6 +122,9 @@ func (h *Handler) ListPracticesByModule(c *fiber.Ctx) error {
|
||||||
}
|
}
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to list practices", Error: err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to list practices", Error: err.Error()})
|
||||||
}
|
}
|
||||||
|
if err := h.applyPracticeAccess(c.Context(), c, items); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to build practices", Error: err.Error()})
|
||||||
|
}
|
||||||
return c.JSON(domain.Response{
|
return c.JSON(domain.Response{
|
||||||
Message: "Practices retrieved successfully",
|
Message: "Practices retrieved successfully",
|
||||||
Data: fiber.Map{
|
Data: fiber.Map{
|
||||||
|
|
@ -140,7 +149,8 @@ func (h *Handler) ListPracticesByLesson(c *fiber.Ctx) error {
|
||||||
}
|
}
|
||||||
limit, _ := strconv.Atoi(c.Query("limit", "20"))
|
limit, _ := strconv.Atoi(c.Query("limit", "20"))
|
||||||
offset, _ := strconv.Atoi(c.Query("offset", "0"))
|
offset, _ := strconv.Atoi(c.Query("offset", "0"))
|
||||||
publishedOnly := !h.canManageLMSPractices(c)
|
role, _ := c.Locals("role").(domain.Role)
|
||||||
|
publishedOnly := role.IsCustomerLearnerRole() || !h.canManageLMSPractices(c)
|
||||||
items, total, err := h.practiceSvc.ListByLesson(c.Context(), lessonID, publishedOnly, int32(limit), int32(offset))
|
items, total, err := h.practiceSvc.ListByLesson(c.Context(), lessonID, publishedOnly, int32(limit), int32(offset))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, lessons.ErrLessonNotFound) {
|
if errors.Is(err, lessons.ErrLessonNotFound) {
|
||||||
|
|
@ -148,6 +158,9 @@ func (h *Handler) ListPracticesByLesson(c *fiber.Ctx) error {
|
||||||
}
|
}
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to list practices", Error: err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to list practices", Error: err.Error()})
|
||||||
}
|
}
|
||||||
|
if err := h.applyPracticeAccess(c.Context(), c, items); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to build practices", Error: err.Error()})
|
||||||
|
}
|
||||||
return c.JSON(domain.Response{
|
return c.JSON(domain.Response{
|
||||||
Message: "Practices retrieved successfully",
|
Message: "Practices retrieved successfully",
|
||||||
Data: fiber.Map{
|
Data: fiber.Map{
|
||||||
|
|
@ -180,9 +193,49 @@ func (h *Handler) GetPractice(c *fiber.Ctx) error {
|
||||||
if !p.VisibleToLearners() && !h.canManageLMSPractices(c) {
|
if !p.VisibleToLearners() && !h.canManageLMSPractices(c) {
|
||||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Practice not found"})
|
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Practice not found"})
|
||||||
}
|
}
|
||||||
|
if err := h.applyPracticeAccess(c.Context(), c, []domain.Practice{p}); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to build practice", Error: err.Error()})
|
||||||
|
}
|
||||||
|
role, _ := c.Locals("role").(domain.Role)
|
||||||
|
if role.IsCustomerLearnerRole() {
|
||||||
|
if set, err := h.questionsSvc.GetQuestionSetByID(c.Context(), p.QuestionSetID); err == nil {
|
||||||
|
p.Access = practiceAccessForQuestionSet(set)
|
||||||
|
} else {
|
||||||
|
p.Access = &domain.PracticeAccess{IsAccessible: false, Reason: "Question set not found"}
|
||||||
|
}
|
||||||
|
}
|
||||||
return c.JSON(domain.Response{Message: "Practice retrieved successfully", Data: p, Success: true, StatusCode: fiber.StatusOK})
|
return c.JSON(domain.Response{Message: "Practice retrieved successfully", Data: p, Success: true, StatusCode: fiber.StatusOK})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) applyPracticeAccess(ctx context.Context, c *fiber.Ctx, items []domain.Practice) error {
|
||||||
|
role, _ := c.Locals("role").(domain.Role)
|
||||||
|
if !role.IsCustomerLearnerRole() {
|
||||||
|
for i := range items {
|
||||||
|
items[i].Access = nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for i := range items {
|
||||||
|
set, err := h.questionsSvc.GetQuestionSetByID(ctx, items[i].QuestionSetID)
|
||||||
|
if err != nil {
|
||||||
|
items[i].Access = &domain.PracticeAccess{IsAccessible: false, Reason: "Question set not found"}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items[i].Access = practiceAccessForQuestionSet(set)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func practiceAccessForQuestionSet(set domain.QuestionSet) *domain.PracticeAccess {
|
||||||
|
if !strings.EqualFold(set.SetType, string(domain.QuestionSetTypePractice)) {
|
||||||
|
return &domain.PracticeAccess{IsAccessible: false, Reason: "Question set is not a practice"}
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(set.Status, "PUBLISHED") {
|
||||||
|
return &domain.PracticeAccess{IsAccessible: false, Reason: "Practice is not published yet"}
|
||||||
|
}
|
||||||
|
return &domain.PracticeAccess{IsAccessible: true}
|
||||||
|
}
|
||||||
|
|
||||||
// UpdatePractice godoc
|
// UpdatePractice godoc
|
||||||
// @Tags practices
|
// @Tags practices
|
||||||
// @Param id path int true "Practice ID"
|
// @Param id path int true "Practice ID"
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,13 @@ type componentCatalogRes struct {
|
||||||
ResponseKinds []string `json:"response_component_kinds"`
|
ResponseKinds []string `json:"response_component_kinds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type listQuestionTypeDefinitionsData struct {
|
||||||
|
QuestionTypeDefinitions []domain.QuestionTypeDefinition `json:"question_type_definitions"`
|
||||||
|
TotalCount int64 `json:"total_count"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
type validateQuestionTypeDefinitionReq struct {
|
type validateQuestionTypeDefinitionReq struct {
|
||||||
StimulusComponentKinds []string `json:"stimulus_component_kinds"`
|
StimulusComponentKinds []string `json:"stimulus_component_kinds"`
|
||||||
ResponseComponentKinds []string `json:"response_component_kinds"`
|
ResponseComponentKinds []string `json:"response_component_kinds"`
|
||||||
|
|
@ -167,8 +174,11 @@ func (h *Handler) CreateQuestionTypeDefinition(c *fiber.Ctx) error {
|
||||||
// @Tags questions
|
// @Tags questions
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param status query string false "Filter by status (ACTIVE, INACTIVE)"
|
// @Param status query string false "Filter by status (ACTIVE, INACTIVE)"
|
||||||
// @Param include_system query bool false "Include system seeded definitions"
|
// @Param include_system query bool false "Include system seeded definitions" default(true)
|
||||||
|
// @Param limit query int false "Page size (default 20, max 200)" default(20)
|
||||||
|
// @Param offset query int false "Page offset" default(0)
|
||||||
// @Success 200 {object} domain.Response
|
// @Success 200 {object} domain.Response
|
||||||
|
// @Failure 400 {object} domain.ErrorResponse
|
||||||
// @Failure 500 {object} domain.ErrorResponse
|
// @Failure 500 {object} domain.ErrorResponse
|
||||||
// @Router /api/v1/questions/type-definitions [get]
|
// @Router /api/v1/questions/type-definitions [get]
|
||||||
func (h *Handler) ListQuestionTypeDefinitions(c *fiber.Ctx) error {
|
func (h *Handler) ListQuestionTypeDefinitions(c *fiber.Ctx) error {
|
||||||
|
|
@ -179,17 +189,42 @@ func (h *Handler) ListQuestionTypeDefinitions(c *fiber.Ctx) error {
|
||||||
}
|
}
|
||||||
includeSystem := strings.EqualFold(c.Query("include_system", "true"), "true")
|
includeSystem := strings.EqualFold(c.Query("include_system", "true"), "true")
|
||||||
|
|
||||||
defs, err := h.questionsSvc.ListQuestionTypeDefinitions(c.Context(), statusPtr, includeSystem)
|
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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
defs, total, err := h.questionsSvc.ListQuestionTypeDefinitions(c.Context(), statusPtr, includeSystem, int32(limit), int32(offset))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
Message: "Failed to list question type definitions",
|
Message: "Failed to list question type definitions",
|
||||||
Error: err.Error(),
|
Error: err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if defs == nil {
|
||||||
|
defs = []domain.QuestionTypeDefinition{}
|
||||||
|
}
|
||||||
|
|
||||||
return c.JSON(domain.Response{
|
return c.JSON(domain.Response{
|
||||||
Message: "Question type definitions",
|
Message: "Question type definitions",
|
||||||
Data: defs,
|
Data: listQuestionTypeDefinitionsData{
|
||||||
|
QuestionTypeDefinitions: defs,
|
||||||
|
TotalCount: total,
|
||||||
|
Limit: limit,
|
||||||
|
Offset: offset,
|
||||||
|
},
|
||||||
|
Success: true,
|
||||||
|
StatusCode: fiber.StatusOK,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ type shortAnswerInput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type createQuestionReq struct {
|
type createQuestionReq struct {
|
||||||
QuestionText string `json:"question_text" validate:"required"`
|
QuestionText *string `json:"question_text,omitempty"`
|
||||||
QuestionType string `json:"question_type"`
|
QuestionType string `json:"question_type"`
|
||||||
QuestionTypeDefinitionID *int64 `json:"question_type_definition_id"`
|
QuestionTypeDefinitionID *int64 `json:"question_type_definition_id"`
|
||||||
DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload"`
|
DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload"`
|
||||||
|
|
@ -58,7 +58,7 @@ type shortAnswerRes struct {
|
||||||
|
|
||||||
type questionRes struct {
|
type questionRes struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
QuestionText string `json:"question_text"`
|
QuestionText *string `json:"question_text,omitempty"`
|
||||||
QuestionType string `json:"question_type"`
|
QuestionType string `json:"question_type"`
|
||||||
QuestionTypeDefinitionID *int64 `json:"question_type_definition_id,omitempty"`
|
QuestionTypeDefinitionID *int64 `json:"question_type_definition_id,omitempty"`
|
||||||
DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload,omitempty"`
|
DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload,omitempty"`
|
||||||
|
|
@ -89,9 +89,43 @@ func normalizeRuntimeQuestionType(v string) string {
|
||||||
return strings.ToUpper(strings.TrimSpace(v))
|
return strings.ToUpper(strings.TrimSpace(v))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func optionalTrimmedString(s *string) string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(*s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveStoredQuestionText(questionType string, explicit *string, payload *domain.DynamicQuestionPayload, existing string) (string, error) {
|
||||||
|
exp := optionalTrimmedString(explicit)
|
||||||
|
if err := domain.ValidateQuestionTextNotAllowedForDynamic(questionType, exp); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if domain.UsesDynamicQuestionPayload(questionType) {
|
||||||
|
return domain.ResolveDynamicStoredQuestionText(payload, existing)
|
||||||
|
}
|
||||||
|
if exp == "" {
|
||||||
|
return "", fmt.Errorf("question_text is required")
|
||||||
|
}
|
||||||
|
return exp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func questionTextField(questionType string, stored string) *string {
|
||||||
|
return domain.QuestionTextJSONField(questionType, stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
func activityLogQuestionSummary(questionType string, stored string, payload *domain.DynamicQuestionPayload) string {
|
||||||
|
if domain.UsesDynamicQuestionPayload(questionType) {
|
||||||
|
if s := domain.StimulusTextFromPayload(payload); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stored
|
||||||
|
}
|
||||||
|
|
||||||
// CreateQuestion godoc
|
// CreateQuestion godoc
|
||||||
// @Summary Create a new question
|
// @Summary Create a new question
|
||||||
// @Description Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER). Supports question_type_definition_id for dynamic builder-linked questions.
|
// @Description Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER). DYNAMIC questions must not send question_text; use dynamic_payload stimulus instead. Legacy types require question_text.
|
||||||
// @Tags questions
|
// @Tags questions
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
|
|
@ -186,8 +220,16 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
questionText, err := resolveStoredQuestionText(questionType, req.QuestionText, req.DynamicPayload, "")
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Invalid question_text",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
input := domain.CreateQuestionInput{
|
input := domain.CreateQuestionInput{
|
||||||
QuestionText: req.QuestionText,
|
QuestionText: questionText,
|
||||||
QuestionType: questionType,
|
QuestionType: questionType,
|
||||||
QuestionTypeDefinitionID: req.QuestionTypeDefinitionID,
|
QuestionTypeDefinitionID: req.QuestionTypeDefinitionID,
|
||||||
DynamicPayload: req.DynamicPayload,
|
DynamicPayload: req.DynamicPayload,
|
||||||
|
|
@ -220,13 +262,13 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
|
||||||
"question_type": question.QuestionType,
|
"question_type": question.QuestionType,
|
||||||
"question_type_definition_id": req.QuestionTypeDefinitionID,
|
"question_type_definition_id": req.QuestionTypeDefinitionID,
|
||||||
})
|
})
|
||||||
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionCreated, domain.ResourceQuestion, &question.ID, "Created question: "+question.QuestionText, meta, &ip, &ua)
|
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionCreated, domain.ResourceQuestion, &question.ID, "Created question: "+activityLogQuestionSummary(question.QuestionType, question.QuestionText, question.DynamicPayload), meta, &ip, &ua)
|
||||||
|
|
||||||
return c.Status(fiber.StatusCreated).JSON(domain.Response{
|
return c.Status(fiber.StatusCreated).JSON(domain.Response{
|
||||||
Message: "Question created successfully",
|
Message: "Question created successfully",
|
||||||
Data: questionRes{
|
Data: questionRes{
|
||||||
ID: question.ID,
|
ID: question.ID,
|
||||||
QuestionText: question.QuestionText,
|
QuestionText: questionTextField(question.QuestionType, question.QuestionText),
|
||||||
QuestionType: question.QuestionType,
|
QuestionType: question.QuestionType,
|
||||||
QuestionTypeDefinitionID: question.QuestionTypeDefinitionID,
|
QuestionTypeDefinitionID: question.QuestionTypeDefinitionID,
|
||||||
DynamicPayload: question.DynamicPayload,
|
DynamicPayload: question.DynamicPayload,
|
||||||
|
|
@ -299,7 +341,7 @@ func (h *Handler) GetQuestionByID(c *fiber.Ctx) error {
|
||||||
Message: "Question retrieved successfully",
|
Message: "Question retrieved successfully",
|
||||||
Data: questionRes{
|
Data: questionRes{
|
||||||
ID: question.ID,
|
ID: question.ID,
|
||||||
QuestionText: question.QuestionText,
|
QuestionText: questionTextField(question.QuestionType, question.QuestionText),
|
||||||
QuestionType: question.QuestionType,
|
QuestionType: question.QuestionType,
|
||||||
QuestionTypeDefinitionID: question.QuestionTypeDefinitionID,
|
QuestionTypeDefinitionID: question.QuestionTypeDefinitionID,
|
||||||
DynamicPayload: question.DynamicPayload,
|
DynamicPayload: question.DynamicPayload,
|
||||||
|
|
@ -366,7 +408,7 @@ func (h *Handler) ListQuestions(c *fiber.Ctx) error {
|
||||||
for _, q := range questions {
|
for _, q := range questions {
|
||||||
questionResponses = append(questionResponses, questionRes{
|
questionResponses = append(questionResponses, questionRes{
|
||||||
ID: q.ID,
|
ID: q.ID,
|
||||||
QuestionText: q.QuestionText,
|
QuestionText: questionTextField(q.QuestionType, q.QuestionText),
|
||||||
QuestionType: q.QuestionType,
|
QuestionType: q.QuestionType,
|
||||||
QuestionTypeDefinitionID: q.QuestionTypeDefinitionID,
|
QuestionTypeDefinitionID: q.QuestionTypeDefinitionID,
|
||||||
DynamicPayload: q.DynamicPayload,
|
DynamicPayload: q.DynamicPayload,
|
||||||
|
|
@ -426,7 +468,7 @@ func (h *Handler) SearchQuestions(c *fiber.Ctx) error {
|
||||||
for _, q := range questions {
|
for _, q := range questions {
|
||||||
questionResponses = append(questionResponses, questionRes{
|
questionResponses = append(questionResponses, questionRes{
|
||||||
ID: q.ID,
|
ID: q.ID,
|
||||||
QuestionText: q.QuestionText,
|
QuestionText: questionTextField(q.QuestionType, q.QuestionText),
|
||||||
QuestionType: q.QuestionType,
|
QuestionType: q.QuestionType,
|
||||||
QuestionTypeDefinitionID: q.QuestionTypeDefinitionID,
|
QuestionTypeDefinitionID: q.QuestionTypeDefinitionID,
|
||||||
DynamicPayload: q.DynamicPayload,
|
DynamicPayload: q.DynamicPayload,
|
||||||
|
|
@ -519,10 +561,6 @@ func (h *Handler) UpdateQuestion(c *fiber.Ctx) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
questionText := existingQuestion.QuestionText
|
|
||||||
if req.QuestionText != nil {
|
|
||||||
questionText = *req.QuestionText
|
|
||||||
}
|
|
||||||
questionType := normalizeRuntimeQuestionType(existingQuestion.QuestionType)
|
questionType := normalizeRuntimeQuestionType(existingQuestion.QuestionType)
|
||||||
if req.QuestionType != nil {
|
if req.QuestionType != nil {
|
||||||
questionType = normalizeRuntimeQuestionType(*req.QuestionType)
|
questionType = normalizeRuntimeQuestionType(*req.QuestionType)
|
||||||
|
|
@ -588,6 +626,14 @@ func (h *Handler) UpdateQuestion(c *fiber.Ctx) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
questionText, err := resolveStoredQuestionText(questionType, req.QuestionText, effectiveDynamicPayload, existingQuestion.QuestionText)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Invalid question_text",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
input := domain.CreateQuestionInput{
|
input := domain.CreateQuestionInput{
|
||||||
QuestionText: questionText,
|
QuestionText: questionText,
|
||||||
QuestionType: questionType,
|
QuestionType: questionType,
|
||||||
|
|
@ -1216,7 +1262,7 @@ type questionSetItemRes struct {
|
||||||
SetID int64 `json:"set_id"`
|
SetID int64 `json:"set_id"`
|
||||||
QuestionID int64 `json:"question_id"`
|
QuestionID int64 `json:"question_id"`
|
||||||
DisplayOrder int32 `json:"display_order"`
|
DisplayOrder int32 `json:"display_order"`
|
||||||
QuestionText string `json:"question_text"`
|
QuestionText *string `json:"question_text,omitempty"`
|
||||||
QuestionType string `json:"question_type"`
|
QuestionType string `json:"question_type"`
|
||||||
DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload,omitempty"`
|
DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload,omitempty"`
|
||||||
DifficultyLevel *string `json:"difficulty_level,omitempty"`
|
DifficultyLevel *string `json:"difficulty_level,omitempty"`
|
||||||
|
|
@ -1245,7 +1291,7 @@ func questionSetItemsToRes(items []domain.QuestionSetItemWithQuestion) []questio
|
||||||
SetID: item.SetID,
|
SetID: item.SetID,
|
||||||
QuestionID: item.QuestionID,
|
QuestionID: item.QuestionID,
|
||||||
DisplayOrder: item.DisplayOrder,
|
DisplayOrder: item.DisplayOrder,
|
||||||
QuestionText: item.QuestionText,
|
QuestionText: questionTextField(item.QuestionType, item.QuestionText),
|
||||||
QuestionType: item.QuestionType,
|
QuestionType: item.QuestionType,
|
||||||
DynamicPayload: item.DynamicPayload,
|
DynamicPayload: item.DynamicPayload,
|
||||||
DifficultyLevel: item.DifficultyLevel,
|
DifficultyLevel: item.DifficultyLevel,
|
||||||
|
|
@ -1322,6 +1368,49 @@ func (h *Handler) AddQuestionToSet(c *fiber.Ctx) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetQuestionTypesInSet godoc
|
||||||
|
// @Summary List question types in a question set
|
||||||
|
// @Description Returns distinct question type definitions (key, display_name, counts) for non-archived questions in the set. Legacy stored question_type values (e.g. AUDIO) are resolved to builder definitions when possible.
|
||||||
|
// @Tags question-set-items
|
||||||
|
// @Produce json
|
||||||
|
// @Param setId path int true "Question Set ID"
|
||||||
|
// @Success 200 {object} domain.Response
|
||||||
|
// @Failure 400 {object} domain.ErrorResponse
|
||||||
|
// @Failure 404 {object} domain.ErrorResponse
|
||||||
|
// @Failure 500 {object} domain.ErrorResponse
|
||||||
|
// @Router /api/v1/question-sets/{setId}/question-types [get]
|
||||||
|
func (h *Handler) GetQuestionTypesInSet(c *fiber.Ctx) error {
|
||||||
|
setID, err := strconv.ParseInt(c.Params("setId"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Invalid set ID",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := h.questionsSvc.GetQuestionSetByID(c.Context(), setID); err != nil {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Question set not found",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := h.questionsSvc.GetQuestionTypesInSet(c.Context(), setID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Failed to get question types in set",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(domain.Response{
|
||||||
|
Message: "Question types retrieved successfully",
|
||||||
|
Success: true,
|
||||||
|
StatusCode: fiber.StatusOK,
|
||||||
|
Data: summary,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// GetQuestionSetItems godoc
|
// GetQuestionSetItems godoc
|
||||||
// @Summary Get questions in set
|
// @Summary Get questions in set
|
||||||
// @Description Returns all questions in a question set with details
|
// @Description Returns all questions in a question set with details
|
||||||
|
|
@ -1405,7 +1494,7 @@ func (h *Handler) GetQuestionsInSet(c *fiber.Ctx) error {
|
||||||
|
|
||||||
questionResponses = append(questionResponses, questionRes{
|
questionResponses = append(questionResponses, questionRes{
|
||||||
ID: question.ID,
|
ID: question.ID,
|
||||||
QuestionText: question.QuestionText,
|
QuestionText: questionTextField(question.QuestionType, question.QuestionText),
|
||||||
QuestionType: question.QuestionType,
|
QuestionType: question.QuestionType,
|
||||||
QuestionTypeDefinitionID: question.QuestionTypeDefinitionID,
|
QuestionTypeDefinitionID: question.QuestionTypeDefinitionID,
|
||||||
DynamicPayload: question.DynamicPayload,
|
DynamicPayload: question.DynamicPayload,
|
||||||
|
|
@ -1594,6 +1683,11 @@ func (h *Handler) CompletePractice(c *fiber.Ctx) error {
|
||||||
if !strings.EqualFold(set.SetType, string(domain.QuestionSetTypePractice)) {
|
if !strings.EqualFold(set.SetType, string(domain.QuestionSetTypePractice)) {
|
||||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Practice not found"})
|
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Practice not found"})
|
||||||
}
|
}
|
||||||
|
if !strings.EqualFold(set.Status, "PUBLISHED") {
|
||||||
|
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Only published practices can be completed",
|
||||||
|
})
|
||||||
|
}
|
||||||
if practiceErr != nil {
|
if practiceErr != nil {
|
||||||
if err := h.forbidCompletingDraftPractice(c, set.ID); err != nil {
|
if err := h.forbidCompletingDraftPractice(c, set.ID); err != nil {
|
||||||
code := fiber.StatusInternalServerError
|
code := fiber.StatusInternalServerError
|
||||||
|
|
|
||||||
|
|
@ -205,7 +205,6 @@ func (a *App) RequireActiveSubscription() fiber.Handler {
|
||||||
}
|
}
|
||||||
if !active {
|
if !active {
|
||||||
// Temporary bypass: allow unsubscribed learners to access content.
|
// Temporary bypass: allow unsubscribed learners to access content.
|
||||||
// Re-enable the previous 403 response when subscription gating is turned back on.
|
|
||||||
return c.Next()
|
return c.Next()
|
||||||
}
|
}
|
||||||
return c.Next()
|
return c.Next()
|
||||||
|
|
@ -228,7 +227,6 @@ func (a *App) RequireSubscriptionCategory(category domain.SubscriptionCategory)
|
||||||
return c.Next()
|
return c.Next()
|
||||||
}
|
}
|
||||||
if categorySubscriptionGateDisabled {
|
if categorySubscriptionGateDisabled {
|
||||||
// Temporary bypass to disable category-aware learner access checks without changing route wiring.
|
|
||||||
return c.Next()
|
return c.Next()
|
||||||
}
|
}
|
||||||
active, err := a.subscriptionsSvc.HasActiveSubscriptionByCategory(c.Context(), userID, category)
|
active, err := a.subscriptionsSvc.HasActiveSubscriptionByCategory(c.Context(), userID, category)
|
||||||
|
|
@ -262,7 +260,6 @@ func (a *App) RequireExamPrepSubscription() fiber.Handler {
|
||||||
return c.Next()
|
return c.Next()
|
||||||
}
|
}
|
||||||
if categorySubscriptionGateDisabled {
|
if categorySubscriptionGateDisabled {
|
||||||
// Temporary bypass to disable category-aware learner access checks without changing route wiring.
|
|
||||||
return c.Next()
|
return c.Next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,10 @@ func (a *App) initAppRoutes() {
|
||||||
groupV1.Put("/admin/field-options/:id", a.authMiddleware, a.RequirePermission("field_options.update"), h.UpdateFieldOption)
|
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)
|
groupV1.Delete("/admin/field-options/:id", a.authMiddleware, a.RequirePermission("field_options.delete"), h.DeleteFieldOption)
|
||||||
|
|
||||||
|
// Admin payments (register before /admin/:id<int> so "payments" is not captured as an admin id)
|
||||||
|
groupV1.Get("/admin/payments", a.authMiddleware, a.RequirePermission("payments.list_all"), h.ListAdminPayments)
|
||||||
|
groupV1.Get("/admin/payments/:id", a.authMiddleware, a.RequirePermission("payments.list_all"), h.GetAdminPayment)
|
||||||
|
|
||||||
// Question Sets
|
// Question Sets
|
||||||
groupV1.Post("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.create"), h.CreateQuestionSet)
|
groupV1.Post("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.create"), h.CreateQuestionSet)
|
||||||
groupV1.Get("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetQuestionSetsByType)
|
groupV1.Get("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetQuestionSetsByType)
|
||||||
|
|
@ -239,6 +243,7 @@ func (a *App) initAppRoutes() {
|
||||||
|
|
||||||
// Question Set Items
|
// Question Set Items
|
||||||
groupV1.Post("/question-sets/:setId/questions", a.authMiddleware, a.RequirePermission("question_set_items.add"), h.AddQuestionToSet)
|
groupV1.Post("/question-sets/:setId/questions", a.authMiddleware, a.RequirePermission("question_set_items.add"), h.AddQuestionToSet)
|
||||||
|
groupV1.Get("/question-sets/:setId/question-types", a.authMiddleware, a.RequirePermission("question_set_items.list"), h.GetQuestionTypesInSet)
|
||||||
groupV1.Get("/question-sets/:setId/questions", a.authMiddleware, a.RequirePermission("question_set_items.list"), h.GetQuestionsInSet)
|
groupV1.Get("/question-sets/:setId/questions", a.authMiddleware, a.RequirePermission("question_set_items.list"), h.GetQuestionsInSet)
|
||||||
groupV1.Get("/practices/:practiceId/questions", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("question_set_items.list"), h.GetQuestionsByPractice)
|
groupV1.Get("/practices/:practiceId/questions", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("question_set_items.list"), h.GetQuestionsByPractice)
|
||||||
groupV1.Delete("/question-sets/:setId/questions/:questionId", a.authMiddleware, a.RequirePermission("question_set_items.remove"), h.RemoveQuestionFromSet)
|
groupV1.Delete("/question-sets/:setId/questions/:questionId", a.authMiddleware, a.RequirePermission("question_set_items.remove"), h.RemoveQuestionFromSet)
|
||||||
|
|
@ -274,7 +279,9 @@ func (a *App) initAppRoutes() {
|
||||||
groupV1.Post("/payments/:id/cancel", a.authMiddleware, a.RequirePermission("payments.cancel"), h.CancelPayment)
|
groupV1.Post("/payments/:id/cancel", a.authMiddleware, a.RequirePermission("payments.cancel"), h.CancelPayment)
|
||||||
groupV1.Post("/payments/webhook", h.HandleChapaWebhook)
|
groupV1.Post("/payments/webhook", h.HandleChapaWebhook)
|
||||||
groupV1.Get("/payments/arifpay/success", h.HandleArifpaySuccessPage)
|
groupV1.Get("/payments/arifpay/success", h.HandleArifpaySuccessPage)
|
||||||
|
groupV1.Get("/payments/chapa/success", h.HandleChapaSuccessPage)
|
||||||
groupV1.Get("/payments/chapa/callback", h.HandleChapaCallback)
|
groupV1.Get("/payments/chapa/callback", h.HandleChapaCallback)
|
||||||
|
a.fiber.Get("/payment/success", h.HandleChapaSuccessPage)
|
||||||
|
|
||||||
// Direct Payments
|
// Direct Payments
|
||||||
groupV1.Post("/payments/direct", a.authMiddleware, a.RequirePermission("payments.direct_initiate"), h.InitiateDirectPayment)
|
groupV1.Post("/payments/direct", a.authMiddleware, a.RequirePermission("payments.direct_initiate"), h.InitiateDirectPayment)
|
||||||
|
|
@ -285,6 +292,7 @@ func (a *App) initAppRoutes() {
|
||||||
groupV1.Post("/auth/google/android", h.GoogleAndroidLogin)
|
groupV1.Post("/auth/google/android", h.GoogleAndroidLogin)
|
||||||
groupV1.Get("/auth/google/login", h.GoogleLogin)
|
groupV1.Get("/auth/google/login", h.GoogleLogin)
|
||||||
groupV1.Get("/auth/google/callback", h.GoogleCallback)
|
groupV1.Get("/auth/google/callback", h.GoogleCallback)
|
||||||
|
groupV1.Post("/auth/apple", h.AppleLogin)
|
||||||
groupV1.Post("/auth/customer-login", h.LoginUser)
|
groupV1.Post("/auth/customer-login", h.LoginUser)
|
||||||
groupV1.Post("/auth/admin-login", h.LoginAdmin)
|
groupV1.Post("/auth/admin-login", h.LoginAdmin)
|
||||||
groupV1.Post("/auth/super-login", h.LoginSuper)
|
groupV1.Post("/auth/super-login", h.LoginSuper)
|
||||||
|
|
@ -346,9 +354,9 @@ func (a *App) initAppRoutes() {
|
||||||
|
|
||||||
// Admin management
|
// Admin management
|
||||||
groupV1.Get("/admin", a.authMiddleware, a.RequirePermission("admins.list"), h.GetAllAdmins)
|
groupV1.Get("/admin", a.authMiddleware, a.RequirePermission("admins.list"), h.GetAllAdmins)
|
||||||
groupV1.Get("/admin/:id", a.authMiddleware, a.RequirePermission("admins.get"), h.GetAdminByID)
|
groupV1.Get("/admin/:id<int>", a.authMiddleware, a.RequirePermission("admins.get"), h.GetAdminByID)
|
||||||
groupV1.Post("/admin", a.authMiddleware, a.RequirePermission("admins.create"), h.CreateAdmin)
|
groupV1.Post("/admin", a.authMiddleware, a.RequirePermission("admins.create"), h.CreateAdmin)
|
||||||
groupV1.Put("/admin/:id", a.authMiddleware, a.RequirePermission("admins.update"), h.UpdateAdmin)
|
groupV1.Put("/admin/:id<int>", a.authMiddleware, a.RequirePermission("admins.update"), h.UpdateAdmin)
|
||||||
groupV1.Post("/admin/roles/:role/bulk-deactivate", a.authMiddleware, h.BulkDeactivateAccountsByRole)
|
groupV1.Post("/admin/roles/:role/bulk-deactivate", a.authMiddleware, h.BulkDeactivateAccountsByRole)
|
||||||
groupV1.Post("/admin/roles/:role/bulk-reactivate", a.authMiddleware, h.BulkReactivateAccountsByRole)
|
groupV1.Post("/admin/roles/:role/bulk-reactivate", a.authMiddleware, h.BulkReactivateAccountsByRole)
|
||||||
|
|
||||||
|
|
@ -381,6 +389,7 @@ func (a *App) initAppRoutes() {
|
||||||
groupV1.Get("/notifications/scheduled", a.authMiddleware, a.RequirePermission("notifications_scheduled.list"), h.ListScheduledNotifications)
|
groupV1.Get("/notifications/scheduled", a.authMiddleware, a.RequirePermission("notifications_scheduled.list"), h.ListScheduledNotifications)
|
||||||
groupV1.Get("/notifications/scheduled/:id", a.authMiddleware, a.RequirePermission("notifications_scheduled.get"), h.GetScheduledNotification)
|
groupV1.Get("/notifications/scheduled/:id", a.authMiddleware, a.RequirePermission("notifications_scheduled.get"), h.GetScheduledNotification)
|
||||||
groupV1.Post("/notifications/scheduled/:id/cancel", a.authMiddleware, a.RequirePermission("notifications_scheduled.cancel"), h.CancelScheduledNotification)
|
groupV1.Post("/notifications/scheduled/:id/cancel", a.authMiddleware, a.RequirePermission("notifications_scheduled.cancel"), h.CancelScheduledNotification)
|
||||||
|
groupV1.Get("/notifications/:id", a.authMiddleware, a.RequirePermission("notifications.list_mine"), h.GetNotificationByID)
|
||||||
|
|
||||||
// Issues
|
// Issues
|
||||||
groupV1.Post("/issues", a.authMiddleware, a.RequirePermission("issues.create"), h.CreateIssue)
|
groupV1.Post("/issues", a.authMiddleware, a.RequirePermission("issues.create"), h.CreateIssue)
|
||||||
|
|
|
||||||
|
|
@ -228,7 +228,7 @@
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"header": [],
|
"header": [],
|
||||||
"url": {
|
"url": {
|
||||||
"raw": "{{base_url}}/api/v1/questions/type-definitions?include_system=true&status=ACTIVE",
|
"raw": "{{base_url}}/api/v1/questions/type-definitions?include_system=true&status=ACTIVE&limit=20&offset=0",
|
||||||
"host": [
|
"host": [
|
||||||
"{{base_url}}"
|
"{{base_url}}"
|
||||||
],
|
],
|
||||||
|
|
@ -595,7 +595,7 @@
|
||||||
],
|
],
|
||||||
"body": {
|
"body": {
|
||||||
"mode": "raw",
|
"mode": "raw",
|
||||||
"raw": "{\n \"question_text\": \"Should fail because AUDIO_CLIP is not allowed by selected definition\",\n \"question_type\": \"DYNAMIC\",\n \"question_type_definition_id\": {{dynamic_definition_id}},\n \"dynamic_payload\": {\n \"stimulus\": [\n {\n \"id\": \"audio1\",\n \"kind\": \"AUDIO_CLIP\",\n \"value\": \"https://cdn.example.com/audio/not-allowed.mp3\"\n }\n ],\n \"response\": [\n {\n \"id\": \"choices\",\n \"kind\": \"OPTION\",\n \"value\": {\n \"options\": [\n {\"id\": \"a\", \"text\": \"A\", \"is_correct\": true},\n {\"id\": \"b\", \"text\": \"B\", \"is_correct\": false}\n ]\n }\n }\n ]\n }\n}"
|
"raw": "{\n \"question_type\": \"DYNAMIC\",\n \"question_type_definition_id\": {{dynamic_definition_id}},\n \"dynamic_payload\": {\n \"stimulus\": [\n {\n \"id\": \"data_table\",\n \"kind\": \"TABLE\",\n \"value\": {\n \"columns\": [\"A\"],\n \"rows\": [[\"1\"]]\n }\n }\n ],\n \"response\": [\n {\n \"id\": \"choices\",\n \"kind\": \"OPTION\",\n \"value\": {\n \"options\": [\n {\"id\": \"a\", \"text\": \"A\", \"is_correct\": true},\n {\"id\": \"b\", \"text\": \"B\", \"is_correct\": false}\n ]\n }\n }\n ]\n }\n}"
|
||||||
},
|
},
|
||||||
"url": {
|
"url": {
|
||||||
"raw": "{{base_url}}/api/v1/questions",
|
"raw": "{{base_url}}/api/v1/questions",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user