Compare commits

...

28 Commits

Author SHA1 Message Date
685e1d104f feat: add GET notification by ID endpoint
Expose read-only in-app notification details at GET /notifications/:id with owner scoping and list-all admin access.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 05:31:46 -07:00
bb03ee1668 feat: paginate question type definitions list API
Add limit and offset query params to GET /questions/type-definitions with total_count metadata. Update integration docs, Postman collection, and page clamping tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 04:33:41 -07:00
b5b9ef03b5 refactor: consolidate stimulus audio under AUDIO_PROMPT
Remove AUDIO_CLIP from the stimulus component catalog and use AUDIO_PROMPT for all question-side audio. Update integration, practice, and Postman docs accordingly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 03:34:01 -07:00
ab986a08f0 feat: expand builder docs, add practice LMS guide, remove CHART stimulus kinds
Document schema slot label assignment and usage in the admin integration guide. Add a step-by-step dynamic practice creation guide for course/module/lesson scopes. Remove CHART and FLOW_CHART from the stimulus component catalog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 02:54:44 -07:00
33355a4b23 feat: PDF_ATTACHMENT stimulus, dynamic question_text rules, admin builder docs
Add PDF_ATTACHMENT stimulus kind and MinIO pdf upload (media_type=pdf) for question-side PDFs.

Reject top-level question_text on DYNAMIC create/update; omit it from API responses and derive stored text from stimulus only.

Expand DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md with full API request/response reference and workflows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 11:07:02 -07:00
08a2886654 feat: optional dynamic question_text and OPEN_LEARNER completed access
Derive question_text from QUESTION_TEXT, INSTRUCTION, or TEXT_PASSAGE stimulus for DYNAMIC questions so the top-level field is no longer required on create.

OPEN_LEARNER access responses now set is_accessible and is_completed to true on all LMS and exam-prep content, with full progress when totals exist.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 09:48:33 -07:00
2605877f12 Resolve question-set question-types to builder definitions.
Map legacy runtime types like AUDIO to catalog keys (e.g. audio_conversation_type) so the endpoint matches type-definitions API output.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 06:46:54 -07:00
a75700ffaa Add GET question-sets question-types endpoint for practice sets.
Returns distinct question_type values with per-type counts so clients can resolve types from question_set_id without loading full questions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 06:35:46 -07:00
256183ae64 Align learner progress rollups with practice-scoped lesson counts.
Count only children that have published practices at module and above for LMS and exam prep; keep lesson at 100% after one practice and module at 100% after direct module practice.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 02:56:12 -07:00
a83745fd93 Fix Chapa verify JSON parsing when amount is numeric.
Accept string or number for amount in verify and webhook payloads so GET /payments/verify can complete successfully.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 02:27:13 -07:00
632371c3d0 apple sign in 2026-06-01 01:02:28 -07:00
c00ab684c5 Fix LMS sequential gating when sort_order has gaps.
Resolve the immediate predecessor by sort_order (and id) instead of requiring sort_order - 1, so learners cannot skip locked programs, courses, or modules when ordering numbers are non-consecutive.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 04:04:38 -07:00
fbad083ca4 Add admin payments list API with filters and fix /admin route conflict.
Expose GET /api/v1/admin/payments for filtered gateway transaction listing, constrain /admin/:id to integers so /admin/payments is not mistaken for an admin id, and grant payments.list_all to ADMIN.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 05:50:46 -07:00
6423bb261e Sanitize Chapa checkout customization text for initialize API.
Strip disallowed characters from customization title and description so subscription payments pass Chapa validation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 04:38:55 -07:00
d3bbd8c95a Add backend Chapa payment success HTML page.
Serve /payment/success and /api/v1/payments/chapa/success to verify tx_ref on redirect and activate subscriptions, and share the payment success template with ArifPay.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 04:29:05 -07:00
ed743cf841 field options update 2026-05-28 06:34:31 -07:00
038df4e3db Normalize ArifPay checkout payment URLs before returning.
Clean malformed provider paths (such as /checkout//{session_id}) so subscribe responses persist and return stable payment_url values.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 03:25:41 -07:00
71e605a07a Revert "Normalize ArifPay checkout payment URLs before returning."
This reverts commit cd4e3b7811.
2026-05-28 03:16:21 -07:00
cd4e3b7811 Normalize ArifPay checkout payment URLs before returning.
Clean malformed provider paths (such as /checkout//{session_id}) so subscribe responses persist and return stable payment_url values.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 03:08:03 -07:00
853bd730bb Fix payment status update parameter typing for ArifPay verification.
Use explicit SQL casts and named sqlc args to avoid PostgreSQL 42P08 ambiguity during nonce/session status updates, and align repository bindings with regenerated sqlc types.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 02:07:18 -07:00
d225b45166 Fix ArifPay verification when nonce is missing.
Resolve payments by nonce or session_id in webhook/verify processing so status checks can complete and activate subscriptions even when ArifPay verify responses omit nonce.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 02:01:47 -07:00
408cd3fd7d Use practice completion to unlock next student lesson.
Switch lesson accessibility gating from deprecated lesson-complete records to published practice completion of the previous lesson so unlocking follows /progress/practices completion flow.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 01:09:46 -07:00
fc67de935d Publish question sets by default and backfill existing sets.
Set question_set creation fallback status to PUBLISHED and add a migration that publishes existing draft/inactive sets while updating the database default status for future records.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 00:41:31 -07:00
c77a97b40d Return learner-visible practices with access metadata.
Expose practices to learner roles based on practice shell publish state and include per-practice access fields derived from linked question set readiness so clients can manage completion/access UX explicitly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 00:32:44 -07:00
ffbb885d06 Fix LMS practice visibility and completion publish checks.
Require question_sets.status to be PUBLISHED for learner-visible practices and reject completion for non-published practice sets so learner progress reflects only publish-ready content.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 00:11:48 -07:00
474bf3282a Fix hierarchical learner progress percentage rollups.
Compute program/course/module/lesson progress using lesson-completion rollups from completed practices, with direct module/course practice completion forcing parent completion as required.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 23:57:21 -07:00
8eaac9206e subscription enforced reversed 2026-05-27 11:40:24 -07:00
2e1f9432f6 subscription enforced 2026-05-27 09:56:23 -07:00
73 changed files with 5630 additions and 662 deletions

View File

@ -133,33 +133,6 @@ func main() {
)
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
// topic := "wallet-balance-topic"
@ -174,85 +147,8 @@ func main() {
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)
// 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)
// chapaClient := chapa.NewClient(cfg.CHAPA_BASE_URL, cfg.CHAPA_SECRET_KEY)
@ -291,15 +187,6 @@ func main() {
// 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)
// instSvc := institutions.New(bankRepository)
// Initialize report worker with CSV exporter
@ -320,11 +207,6 @@ func main() {
// userSvc,
// )
// enetPulseSvc := enetpulse.New(
// *cfg,
// store,
// )
// Initialize wallet monitoring service
// walletMonitorSvc := monitor.NewService(
// ,
@ -454,11 +336,6 @@ func main() {
cfg.TeamInviteExpiry,
)
// santimpayClient := santimpay.NewSantimPayClient(cfg)
// santimpaySvc := santimpay.NewSantimPayService(santimpayClient, cfg, transferStore)
// telebirrSvc := telebirr.NewTelebirrService(cfg, transferStore)
// Activity Log service
activityLogSvc := activitylogservice.NewService(store, domain.MongoDBLogger)

View File

@ -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', 'OTHER', 'Other', 99, 'ACTIVE'),
('language_goal', 'SPEAK_CONFIDENTLY', 'Speak confidently at work or school', 1, 'ACTIVE'),
('language_goal', 'TRAVEL_DAILY', 'Travel or handle daily situations', 2, 'ACTIVE'),
('language_goal', 'FAMILY_FRIENDS', 'Connect with family or friends', 3, 'ACTIVE'),
('language_goal', 'GENERAL_SKILLS', 'General skills expansion', 4, 'ACTIVE'),
('language_goal', 'OTHER', 'Other', 99, 'ACTIVE'),
('language_goal', 'LEARN_TO_SPEAK_ENGLISH', 'Learn to Speak English', 1, 'ACTIVE'),
('language_goal', 'PRACTICE_TO_SPEAK_ENGLISH', 'Practice Speaking English', 2, 'ACTIVE'),
('language_goal', 'SKILL_BASED_COURSES', 'Skill-based Courses', 3, 'ACTIVE'),
-- ('language_goal', 'GENERAL_SKILLS', 'General skills expansion', 4, 'ACTIVE'),
-- ('language_goal', 'OTHER', 'Other', 99, 'ACTIVE'),
('favourite_topic', 'FOOD_COOKING', 'Food & Cooking', 1, 'ACTIVE'),
('favourite_topic', 'HOBBIES_SPORTS_MUSIC', 'Hobbies, Sports, Music', 2, 'ACTIVE'),

View File

@ -0,0 +1,2 @@
ALTER TABLE question_sets
ALTER COLUMN status SET DEFAULT 'DRAFT';

View File

@ -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';

View 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;

View 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;

View File

@ -1,11 +1,23 @@
-- name: GetPreviousProgram :one
-- Immediate predecessor by sort_order within the same category (gaps in sort_order are allowed).
SELECT
p2.*
FROM
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
p1.id = $1;
p1.id = $1
ORDER BY
p2.sort_order DESC,
p2.id DESC
LIMIT 1;
-- name: GetPreviousCourseInProgram :one
SELECT
@ -13,9 +25,19 @@ SELECT
FROM
courses AS c1
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
c1.id = $1;
c1.id = $1
ORDER BY
c2.sort_order DESC,
c2.id DESC
LIMIT 1;
-- name: GetPreviousModuleInCourse :one
SELECT
@ -23,9 +45,19 @@ SELECT
FROM
modules AS m1
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
m1.id = $1;
m1.id = $1
ORDER BY
m2.sort_order DESC,
m2.id DESC
LIMIT 1;
-- name: GetPreviousLessonInModule :one
SELECT
@ -570,6 +602,62 @@ WHERE
AND qs.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
SELECT
id,

View File

@ -46,22 +46,22 @@ WHERE id = $2;
-- name: UpdatePaymentStatusBySessionID :exec
UPDATE payments
SET
status = $1,
transaction_id = COALESCE($2, transaction_id),
payment_method = COALESCE($3, payment_method),
paid_at = CASE WHEN $1 = 'SUCCESS' THEN CURRENT_TIMESTAMP ELSE paid_at END,
status = sqlc.arg(status)::varchar,
transaction_id = COALESCE(sqlc.arg(transaction_id)::text, transaction_id),
payment_method = COALESCE(sqlc.arg(payment_method)::text, payment_method),
paid_at = CASE WHEN sqlc.arg(status)::varchar = 'SUCCESS' THEN CURRENT_TIMESTAMP ELSE paid_at END,
updated_at = CURRENT_TIMESTAMP
WHERE session_id = $4;
WHERE session_id = sqlc.arg(session_id)::text;
-- name: UpdatePaymentStatusByNonce :exec
UPDATE payments
SET
status = $1,
transaction_id = COALESCE($2, transaction_id),
payment_method = COALESCE($3, payment_method),
paid_at = CASE WHEN $1 = 'SUCCESS' THEN CURRENT_TIMESTAMP ELSE paid_at END,
status = sqlc.arg(status)::varchar,
transaction_id = COALESCE(sqlc.arg(transaction_id)::text, transaction_id),
payment_method = COALESCE(sqlc.arg(payment_method)::text, payment_method),
paid_at = CASE WHEN sqlc.arg(status)::varchar = 'SUCCESS' THEN CURRENT_TIMESTAMP ELSE paid_at END,
updated_at = CURRENT_TIMESTAMP
WHERE nonce = $4;
WHERE nonce = sqlc.arg(nonce)::text;
-- name: UpdatePaymentSessionID :exec
UPDATE payments
@ -93,3 +93,77 @@ WHERE id = $1;
-- name: CountUserPayments :one
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 || '%'
);

View File

@ -102,6 +102,18 @@ JOIN questions q ON q.id = qsi.question_id
WHERE qsi.set_id = $1
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
SELECT qs.*
FROM question_sets qs

View File

@ -13,7 +13,7 @@ INSERT INTO question_sets (
status,
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 *;
-- name: GetQuestionSetByID :one

View File

@ -23,6 +23,30 @@ SET
updated_at = CURRENT_TIMESTAMP
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
SELECT
CASE WHEN status = 'PENDING' THEN true ELSE false END AS is_pending
@ -156,6 +180,10 @@ SELECT *
FROM users
WHERE google_id = $1;
-- name: GetUserByAppleID :one
SELECT *
FROM users
WHERE apple_id = $1;
-- name: GetAllUsers :many
SELECT

View File

@ -16,7 +16,7 @@ CHAPA_PUBLIC_KEY=CHAPUBK_TEST-xxxxxxxx
CHAPA_WEBHOOK_SECRET=your_webhook_secret_from_dashboard
CHAPA_BASE_URL=https://api.chapa.co/v1
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=
```
@ -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`.
2. Backend creates a pending payment and calls Chapa `POST /transaction/initialize`.
3. Client redirects the user to `payment_url` (`checkout_url` from Chapa).
4. After payment, Chapa calls `callback_url` and sends a webhook.
5. Backend verifies via `GET /transaction/verify/{tx_ref}` and activates the subscription.
6. Client may poll `GET /api/v1/payments/verify/{tx_ref}` (`session_id` path param is the `tx_ref`).
4. After payment, Chapa redirects the learner to `return_url` (`/payment/success`) and calls `callback_url`.
5. The success page and callback both verify via Chapa `GET /transaction/verify/{tx_ref}` and activate the subscription when successful.
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
@ -41,8 +41,37 @@ Configure the same webhook URL in the Chapa dashboard:
| POST | `/api/v1/payments/subscribe` | Yes | Same as checkout |
| GET | `/api/v1/payments/verify/:session_id` | Yes | Verify by `tx_ref` |
| 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/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

View 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 routes 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 definitions 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

View File

@ -65,7 +65,7 @@ If you create/update dynamic definitions:
## 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
@ -74,7 +74,7 @@ Use this when question content references audio/image URLs.
### Form fields
- `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)
@ -267,7 +267,6 @@ Capture:
```json
{
"question_text": "Listen and respond as Speaker B.",
"question_type": "DYNAMIC",
"question_type_definition_id": 123,
"difficulty_level": "MEDIUM",

View File

@ -14182,9 +14182,6 @@ const docTemplate = `{
},
"handlers.createQuestionReq": {
"type": "object",
"required": [
"question_text"
],
"properties": {
"audio_correct_answer_text": {
"type": "string"

View File

@ -14174,9 +14174,6 @@
},
"handlers.createQuestionReq": {
"type": "object",
"required": [
"question_text"
],
"properties": {
"audio_correct_answer_text": {
"type": "string"

View File

@ -1988,8 +1988,6 @@ definitions:
type: string
voice_prompt:
type: string
required:
- question_text
type: object
handlers.createQuestionSetReq:
properties:

View File

@ -99,6 +99,48 @@ func (q *Queries) CountModulesInCourse(ctx context.Context, courseID int64) (int
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
SELECT
count(*)::int AS n
@ -349,6 +391,62 @@ func (q *Queries) CountUserCompletedModulesInCourse(ctx context.Context, arg Cou
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
SELECT
count(*)::int AS n
@ -549,9 +647,19 @@ SELECT
FROM
courses AS c1
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
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) {
@ -617,9 +725,19 @@ SELECT
FROM
modules AS m1
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
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) {
@ -644,11 +762,23 @@ SELECT
p2.id, p2.name, p2.description, p2.thumbnail, p2.created_at, p2.updated_at, p2.sort_order, p2.category
FROM
programs AS p1
INNER JOIN programs AS p2 ON p2.sort_order = p1.sort_order - 1
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
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) {
row := q.db.QueryRow(ctx, GetPreviousProgram, id)
var i Program

View File

@ -571,6 +571,8 @@ type User struct {
DeletionRequestedAt pgtype.Timestamptz `json:"deletion_requested_at"`
DeletionScheduledAt pgtype.Timestamptz `json:"deletion_scheduled_at"`
DeletionCancelledAt pgtype.Timestamptz `json:"deletion_cancelled_at"`
AppleID pgtype.Text `json:"apple_id"`
AppleEmailVerified pgtype.Bool `json:"apple_email_verified"`
}
type UserAudioResponse struct {

View File

@ -11,6 +11,73 @@ import (
"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
SELECT COUNT(*) FROM payments WHERE user_id = $1
`
@ -391,6 +458,160 @@ func (q *Queries) LinkPaymentToSubscription(ctx context.Context, arg LinkPayment
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
UPDATE payments
SET
@ -432,18 +653,18 @@ func (q *Queries) UpdatePaymentStatus(ctx context.Context, arg UpdatePaymentStat
const UpdatePaymentStatusByNonce = `-- name: UpdatePaymentStatusByNonce :exec
UPDATE payments
SET
status = $1,
transaction_id = COALESCE($2, transaction_id),
payment_method = COALESCE($3, payment_method),
paid_at = CASE WHEN $1 = 'SUCCESS' THEN CURRENT_TIMESTAMP ELSE paid_at END,
status = $1::varchar,
transaction_id = COALESCE($2::text, transaction_id),
payment_method = COALESCE($3::text, payment_method),
paid_at = CASE WHEN $1::varchar = 'SUCCESS' THEN CURRENT_TIMESTAMP ELSE paid_at END,
updated_at = CURRENT_TIMESTAMP
WHERE nonce = $4
WHERE nonce = $4::text
`
type UpdatePaymentStatusByNonceParams struct {
Status string `json:"status"`
TransactionID pgtype.Text `json:"transaction_id"`
PaymentMethod pgtype.Text `json:"payment_method"`
TransactionID string `json:"transaction_id"`
PaymentMethod string `json:"payment_method"`
Nonce string `json:"nonce"`
}
@ -460,19 +681,19 @@ func (q *Queries) UpdatePaymentStatusByNonce(ctx context.Context, arg UpdatePaym
const UpdatePaymentStatusBySessionID = `-- name: UpdatePaymentStatusBySessionID :exec
UPDATE payments
SET
status = $1,
transaction_id = COALESCE($2, transaction_id),
payment_method = COALESCE($3, payment_method),
paid_at = CASE WHEN $1 = 'SUCCESS' THEN CURRENT_TIMESTAMP ELSE paid_at END,
status = $1::varchar,
transaction_id = COALESCE($2::text, transaction_id),
payment_method = COALESCE($3::text, payment_method),
paid_at = CASE WHEN $1::varchar = 'SUCCESS' THEN CURRENT_TIMESTAMP ELSE paid_at END,
updated_at = CURRENT_TIMESTAMP
WHERE session_id = $4
WHERE session_id = $4::text
`
type UpdatePaymentStatusBySessionIDParams struct {
Status string `json:"status"`
TransactionID pgtype.Text `json:"transaction_id"`
PaymentMethod pgtype.Text `json:"payment_method"`
SessionID pgtype.Text `json:"session_id"`
TransactionID string `json:"transaction_id"`
PaymentMethod string `json:"payment_method"`
SessionID string `json:"session_id"`
}
func (q *Queries) UpdatePaymentStatusBySessionID(ctx context.Context, arg UpdatePaymentStatusBySessionIDParams) error {

View File

@ -362,6 +362,45 @@ func (q *Queries) GetQuestionSetsContainingQuestion(ctx context.Context, questio
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
DELETE FROM question_set_items
WHERE set_id = $1 AND question_id = $2

View File

@ -66,7 +66,7 @@ INSERT INTO question_sets (
status,
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
`

View File

@ -86,6 +86,89 @@ func (q *Queries) CheckPhoneEmailExist(ctx context.Context, arg CheckPhoneEmailE
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
INSERT INTO users (
first_name,
@ -101,7 +184,7 @@ INSERT INTO users (
VALUES (
$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 {
@ -164,6 +247,8 @@ func (q *Queries) CreateGoogleUser(ctx context.Context, arg CreateGoogleUserPara
&i.DeletionRequestedAt,
&i.DeletionScheduledAt,
&i.DeletionCancelledAt,
&i.AppleID,
&i.AppleEmailVerified,
)
return i, err
}
@ -621,6 +706,58 @@ func (q *Queries) GetTotalUsers(ctx context.Context, role string) (int64, error)
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
@ -768,7 +905,7 @@ func (q *Queries) GetUserByEmailPhone(ctx context.Context, arg GetUserByEmailPho
}
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
WHERE google_id = $1
`
@ -813,12 +950,14 @@ func (q *Queries) GetUserByGoogleID(ctx context.Context, googleID pgtype.Text) (
&i.DeletionRequestedAt,
&i.DeletionScheduledAt,
&i.DeletionCancelledAt,
&i.AppleID,
&i.AppleEmailVerified,
)
return i, err
}
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
WHERE id = $1
`
@ -863,6 +1002,8 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
&i.DeletionRequestedAt,
&i.DeletionScheduledAt,
&i.DeletionCancelledAt,
&i.AppleID,
&i.AppleEmailVerified,
)
return i, err
}
@ -930,6 +1071,26 @@ func (q *Queries) IsUserPending(ctx context.Context, id int64) (bool, error) {
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
UPDATE users
SET

4
go.mod
View File

@ -29,7 +29,7 @@ require (
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/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/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // 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-logr/logr v1.4.3 // 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/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect

View File

@ -99,6 +99,8 @@ type Config struct {
GoogleOAuthClientID string
GoogleOAuthClientSecret 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"`
Vimeo VimeoConfig `mapstructure:"vimeo_config"`
CloudConvert CloudConvertConfig `mapstructure:"cloudconvert_config"`
@ -174,6 +176,7 @@ func (c *Config) loadEnv() error {
c.GoogleOAuthClientID = os.Getenv("GOOGLE_OAUTH_CLIENT_ID")
c.GoogleOAuthClientSecret = os.Getenv("GOOGLE_OAUTH_CLIENT_SECRET")
c.GoogleOAuthRedirectURL = os.Getenv("GOOGLE_OAUTH_REDIRECT_URL")
c.AppleSignInClientIDs = os.Getenv("APPLE_SIGN_IN_CLIENT_IDS")
c.APP_VERSION = os.Getenv("APP_VERSION")

View File

@ -13,6 +13,15 @@ type GoogleUser struct {
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 {
UserId int64
Role Role

View File

@ -1,5 +1,41 @@
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.
type ChapaInitializeRequest struct {
Amount string `json:"amount"`
@ -34,7 +70,7 @@ type ChapaVerifyResponse struct {
type ChapaTransactionData struct {
TxRef string `json:"tx_ref"`
Reference string `json:"reference"`
Amount string `json:"amount"`
Amount ChapaFlexibleString `json:"amount"`
Currency string `json:"currency"`
Status string `json:"status"`
PaymentMethod string `json:"payment_method"`
@ -48,7 +84,7 @@ type ChapaWebhookPayload struct {
TxRef string `json:"tx_ref"`
Reference string `json:"reference"`
Status string `json:"status"`
Amount string `json:"amount"`
Amount ChapaFlexibleString `json:"amount"`
Currency string `json:"currency"`
PaymentMethod string `json:"payment_method"`
Mode string `json:"mode"`

View 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())
}
}

View File

@ -2,7 +2,7 @@ package domain
// 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.
// 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
// entity's scope. progress_percent keeps the legacy whole-number value; use
// progress_percent_precise for decimal precision in learner UIs.

View File

@ -35,6 +35,38 @@ type Payment struct {
CreatedAt time.Time
UpdatedAt *time.Time
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 {

View File

@ -51,10 +51,16 @@ type Practice struct {
QuestionSetID int64 `json:"question_set_id"`
PublishStatus PracticePublishStatus `json:"publish_status"`
QuickTips *string `json:"quick_tips,omitempty"`
Access *PracticeAccess `json:"access,omitempty"`
CreatedAt time.Time `json:"created_at"`
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.
func (p Practice) VisibleToLearners() bool {
return p.PublishStatus == PracticePublishPublished

View File

@ -13,15 +13,15 @@ const (
StimulusQuestionText StimulusComponentKind = "QUESTION_TEXT"
StimulusPrepTime StimulusComponentKind = "PREP_TIME"
StimulusInstruction StimulusComponentKind = "INSTRUCTION"
// StimulusAudioPrompt is the single stimulus-side audio kind (prompts, clips, listening passages).
StimulusAudioPrompt StimulusComponentKind = "AUDIO_PROMPT"
StimulusAudioClip StimulusComponentKind = "AUDIO_CLIP"
StimulusTextPassage StimulusComponentKind = "TEXT_PASSAGE"
StimulusImage StimulusComponentKind = "IMAGE"
StimulusChart StimulusComponentKind = "CHART"
StimulusMatchingInputs StimulusComponentKind = "MATCHING_INPUTS"
StimulusSelectMissingWords StimulusComponentKind = "SELECT_MISSING_WORDS"
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).
@ -67,14 +67,12 @@ var (
StimulusPrepTime,
StimulusInstruction,
StimulusAudioPrompt,
StimulusAudioClip,
StimulusTextPassage,
StimulusImage,
StimulusChart,
StimulusMatchingInputs,
StimulusSelectMissingWords,
StimulusTable,
StimulusFlowChart,
StimulusPDFAttachment,
}
stimulusSet map[string]struct{}
@ -213,6 +211,130 @@ func ValidateDynamicQuestionTypeDefinition(stimulusKinds, responseKinds []string
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
// existing question execution paths. Empty string means the definition cannot be executed yet.
func ResolveRuntimeQuestionTypeFromDefinition(key string, responseKinds []string) string {
@ -432,3 +554,80 @@ func onlyAuxiliaryResponseKinds(response []string) bool {
}
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))
}
}

View File

@ -5,6 +5,20 @@ import (
"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) {
err := ValidateDynamicQuestionTypeDefinition(
[]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) {
err := ValidateDynamicQuestionTypeDefinition(
[]string{"NOT_A_KIND"},
@ -139,3 +163,114 @@ func TestValidateDynamicPayloadAgainstDefinition_requiredMissing(t *testing.T) {
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])
}
}

View File

@ -120,6 +120,28 @@ type QuestionSetItem struct {
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 {
QuestionSetItem
QuestionText string

View File

@ -6,7 +6,7 @@ const (
RoleSuperAdmin Role = "SUPER_ADMIN"
RoleAdmin Role = "ADMIN"
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"
RoleInstructor Role = "INSTRUCTOR"
RoleSupport Role = "SUPPORT"

View File

@ -7,6 +7,7 @@ import (
)
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)
CountUnreadNotifications(ctx context.Context, recipient_id int64) (int64, error)
GetAllNotifications(ctx context.Context, limit, offset int) ([]domain.Notification, error)

View File

@ -20,4 +20,5 @@ type PaymentStore interface {
LinkPaymentToSubscription(ctx context.Context, paymentID, subscriptionID int64) error
GetExpiredPendingPayments(ctx context.Context) ([]domain.Payment, error)
ExpirePayment(ctx context.Context, id int64) error
ListPaymentsAdmin(ctx context.Context, filter domain.PaymentListFilter) (domain.PaymentListPage, error)
}

View File

@ -9,7 +9,7 @@ type QuestionStore interface {
// Question Type Definitions (dynamic builder presets)
CreateQuestionTypeDefinition(ctx context.Context, input domain.CreateQuestionTypeDefinitionInput) (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
DeleteQuestionTypeDefinition(ctx context.Context, id int64) error
@ -58,6 +58,7 @@ type QuestionStore interface {
RemoveQuestionFromSet(ctx context.Context, setID, questionID int64) error
UpdateQuestionOrder(ctx context.Context, setID, questionID int64, displayOrder int32) 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)
// User Personas in Question Sets

View File

@ -15,6 +15,8 @@ type ProfileCompletionStatus struct {
type UserStore interface {
CreateGoogleUser(ctx context.Context, gUser domain.GoogleUser) (domain.User, 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)
UpdateUserStatus(ctx context.Context, req domain.UpdateUserStatusReq) error
// GetCorrectOptionForQuestion(
@ -45,6 +47,10 @@ type UserStore interface {
ctx context.Context,
googleId string,
) (domain.User, error)
GetUserByAppleID(
ctx context.Context,
appleID string,
) (domain.User, error)
GetUserByID(
ctx context.Context,
id int64,

View File

@ -6,6 +6,21 @@ import (
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.
func (s *Store) ExamPrepUserPracticeProgressInLesson(ctx context.Context, userID, lessonID int64) (completed, total int32, err error) {
total, err = s.queries.CountPublishedExamPrepPracticesInLesson(ctx, lessonID)

View File

@ -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})
}
// 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.
func (s *Store) LmsUserPracticeProgressInLesson(ctx context.Context, userID, lessonID int64) (completed, total int32, err error) {
lessonIDPG := toPgInt8(&lessonID)
@ -102,3 +117,35 @@ func (s *Store) LmsUserLessonProgressInProgram(ctx context.Context, userID, prog
}
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
}

View File

@ -3,12 +3,14 @@ package repository
import (
"context"
"encoding/json"
"errors"
"strconv"
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/ports"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
@ -53,6 +55,20 @@ func (r *Store) CreateNotification(
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(
ctx context.Context,
userID int64,

View File

@ -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 {
return s.queries.UpdatePaymentStatusBySessionID(ctx, dbgen.UpdatePaymentStatusBySessionIDParams{
Status: status,
TransactionID: toPgText(&transactionID),
PaymentMethod: toPgText(&paymentMethod),
SessionID: toPgText(&sessionID),
TransactionID: transactionID,
PaymentMethod: paymentMethod,
SessionID: sessionID,
})
}
func (s *Store) UpdatePaymentStatusByNonce(ctx context.Context, nonce, status, transactionID, paymentMethod string) error {
return s.queries.UpdatePaymentStatusByNonce(ctx, dbgen.UpdatePaymentStatusByNonceParams{
Status: status,
TransactionID: toPgText(&transactionID),
PaymentMethod: toPgText(&paymentMethod),
TransactionID: transactionID,
PaymentMethod: paymentMethod,
Nonce: nonce,
})
}
@ -166,6 +166,103 @@ func (s *Store) ExpirePayment(ctx context.Context, id int64) error {
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
func paymentToDomain(p dbgen.Payment) *domain.Payment {

View File

@ -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
}
func (s *Store) ListQuestionTypeDefinitions(ctx context.Context, status *string, includeSystem bool) ([]domain.QuestionTypeDefinition, error) {
rows, err := s.conn.Query(ctx, `
SELECT id, key, display_name, description, stimulus_component_kinds, response_component_kinds, stimulus_schema, response_schema, is_system, status, created_at, updated_at
func (s *Store) ListQuestionTypeDefinitions(ctx context.Context, status *string, includeSystem bool, limit, offset int32) ([]domain.QuestionTypeDefinition, int64, error) {
const baseWhere = `
FROM question_type_definitions
WHERE ($1::VARCHAR IS NULL OR status = $1)
AND ($2::BOOLEAN = TRUE OR is_system = FALSE)
ORDER BY is_system DESC, display_name ASC
`, status, includeSystem)
AND ($2::BOOLEAN = TRUE OR is_system = FALSE)`
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 {
return nil, err
return nil, 0, err
}
defer rows.Close()
var out []domain.QuestionTypeDefinition
out := make([]domain.QuestionTypeDefinition, 0)
for rows.Next() {
var (
id int64
@ -398,12 +412,12 @@ func (s *Store) ListQuestionTypeDefinitions(ctx context.Context, status *string,
updatedAt pgtype.Timestamptz
)
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))
}
return out, rows.Err()
return out, total, rows.Err()
}
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)
}
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) {
sets, err := s.queries.GetQuestionSetsContainingQuestion(ctx, questionID)
if err != nil {

View File

@ -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(
ctx context.Context,
gUser domain.GoogleUser,

View File

@ -8,6 +8,8 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"path"
"strings"
"time"
@ -167,7 +169,7 @@ func (s *ArifpayService) InitiateSubscriptionPayment(ctx context.Context, userID
}
sessionID := fmt.Sprintf("%v", data["sessionId"])
paymentURL := fmt.Sprintf("%v", data["paymentUrl"])
paymentURL := normalizeExternalURL(fmt.Sprintf("%v", data["paymentUrl"]))
// Update payment with session info
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
}
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
func (s *ArifpayService) ProcessPaymentWebhook(ctx context.Context, req domain.WebhookRequest) error {
// Get payment by nonce
payment, err := s.paymentStore.GetPaymentByNonce(ctx, req.Nonce)
// ArifPay verify/webhook payloads are inconsistent: some responses include nonce, others only sessionId.
payment, err := s.resolvePaymentForWebhook(ctx, req)
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) {
return ErrPaymentAlreadyPaid
@ -216,12 +235,16 @@ func (s *ArifpayService) ProcessPaymentWebhook(ctx context.Context, req domain.W
}
// Update payment status
paymentMethod := req.PaymentMethod
if paymentMethod == "" && payment.PaymentMethod != nil {
paymentMethod = *payment.PaymentMethod
}
if err := s.paymentStore.UpdatePaymentStatusByNonce(
ctx,
req.Nonce,
nonce,
newStatus,
req.Transaction.TransactionID,
req.PaymentMethod,
paymentMethod,
); err != nil {
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)
activeStatus := string(domain.SubscriptionStatusActive)
autoRenew := false
paymentRef := payment.Nonce
paymentMethod := req.PaymentMethod
paymentRef := nonce
subscription, err := s.subscriptionStore.CreateUserSubscription(ctx, domain.CreateUserSubscriptionInput{
UserID: payment.UserID,
@ -263,6 +285,23 @@ func (s *ArifpayService) ProcessPaymentWebhook(ctx context.Context, req domain.W
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
func (s *ArifpayService) VerifyPayment(ctx context.Context, sessionID string) (*domain.Payment, error) {
// 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 {
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
if err := s.ProcessPaymentWebhook(ctx, result); err != nil && err != ErrPaymentAlreadyPaid {

View 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
}

View 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)
}
}

View File

@ -141,8 +141,8 @@ func (s *Service) InitiateSubscriptionPayment(ctx context.Context, userID int64,
CallbackURL: s.cfg.CHAPA_CALLBACK_URL,
ReturnURL: s.cfg.CHAPA_RETURN_URL,
}
initReq.Customization.Title = "Yimaru LMS"
initReq.Customization.Description = fmt.Sprintf("Subscription: %s", plan.Name)
initReq.Customization.Title = sanitizeChapaCustomization("Yimaru LMS")
initReq.Customization.Description = chapaSubscriptionDescription(plan.Name)
checkoutURL, err := s.initializeTransaction(ctx, initReq)
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)
}
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) {
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)
}
// 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 {
c := strings.TrimSpace(strings.ToUpper(currency))
if c == "" {

View File

@ -118,7 +118,8 @@ func (s *Service) CanAccessModule(ctx context.Context, userID, moduleID int64) (
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) {
lesson, err := s.store.GetLessonByID(ctx, lessonID)
if err != nil {
@ -135,28 +136,29 @@ func (s *Service) CanAccessLesson(ctx context.Context, userID, lessonID int64) (
}
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 {
return false, "", err
}
if !has {
if !lmsProgressComplete(prevCompletedPractices, prevTotalPractices) {
return false, errPrevLesson, nil
}
return true, "", nil
}
// 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 {
if !role.IsCustomerLearnerRole() {
p.Access = 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 {
return err
}
done := lmsProgressComplete(comp, tot)
ok, reason := true, ""
if role.UsesLMSSequentialGating() {
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
}
}
p.Access = buildLMSEntityAccess(ok, reason, done, comp, tot)
p.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction))
return nil
}
@ -174,11 +176,10 @@ func (s *Service) ApplyAccessCourse(ctx context.Context, role domain.Role, userI
c.Access = 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 {
return err
}
done := lmsProgressComplete(comp, tot)
ok, reason := true, ""
if role.UsesLMSSequentialGating() {
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
}
}
c.Access = buildLMSEntityAccess(ok, reason, done, comp, tot)
c.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction))
return nil
}
@ -196,11 +197,10 @@ func (s *Service) ApplyAccessModule(ctx context.Context, role domain.Role, userI
m.Access = 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 {
return err
}
done := lmsProgressComplete(comp, tot)
ok, reason := true, ""
if role.UsesLMSSequentialGating() {
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
}
}
m.Access = buildLMSEntityAccess(ok, reason, done, comp, tot)
m.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction))
return nil
}
@ -218,11 +218,10 @@ func (s *Service) ApplyAccessLesson(ctx context.Context, role domain.Role, userI
les.Access = 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 {
return err
}
done := lmsProgressComplete(comp, tot)
ok, reason := true, ""
if role.UsesLMSSequentialGating() {
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
}
}
les.Access = buildLMSEntityAccess(ok, reason, done, comp, tot)
les.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction))
return nil
}
@ -240,12 +239,11 @@ func (s *Service) ApplyExamPrepAccessCatalogCourse(ctx context.Context, role dom
cc.Access = 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 {
return err
}
done := lmsProgressComplete(comp, tot)
cc.Access = buildLMSEntityAccess(true, "", done, comp, tot)
cc.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction))
return nil
}
@ -255,12 +253,11 @@ func (s *Service) ApplyExamPrepAccessUnit(ctx context.Context, role domain.Role,
u.Access = 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 {
return err
}
done := lmsProgressComplete(comp, tot)
u.Access = buildLMSEntityAccess(true, "", done, comp, tot)
u.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction))
return nil
}
@ -270,12 +267,11 @@ func (s *Service) ApplyExamPrepAccessModule(ctx context.Context, role domain.Rol
m.Access = 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 {
return err
}
done := lmsProgressComplete(comp, tot)
m.Access = buildLMSEntityAccess(true, "", done, comp, tot)
m.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction))
return nil
}
@ -285,19 +281,265 @@ func (s *Service) ApplyExamPrepAccessLesson(ctx context.Context, role domain.Rol
les.Access = 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 {
return err
}
done := lmsProgressComplete(comp, tot)
les.Access = buildLMSEntityAccess(true, "", done, comp, tot)
les.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction))
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 {
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 {
c, t, pct, pctPrecise := lmsProgressCounts(completed, total, done)
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 0100; completed
// and total are aligned with isCompleted when the entity is fully done.
func lmsProgressCounts(completed, total int32, isCompleted bool) (c, t, pct int, pctPrecise float64) {

View File

@ -1,6 +1,10 @@
package lmsprogress
import "testing"
import (
"testing"
"Yimaru-Backend/internal/domain"
)
func TestLMSProgressCounts(t *testing.T) {
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) {
tests := []struct {
name string

View File

@ -0,0 +1,8 @@
package notificationservice
import "errors"
var (
ErrNotificationNotFound = errors.New("notification not found")
ErrNotificationForbidden = errors.New("notification access denied")
)

View File

@ -10,6 +10,7 @@ import (
"Yimaru-Backend/internal/web_server/ws"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@ -26,6 +27,7 @@ import (
firebase "firebase.google.com/go/v4"
"firebase.google.com/go/v4/messaging"
"github.com/gorilla/websocket"
"github.com/jackc/pgx/v5"
"github.com/resend/resend-go/v2"
"go.uber.org/zap"
"google.golang.org/api/option"
@ -341,6 +343,27 @@ func (s *Service) SendNotification(ctx context.Context, notification *domain.Not
// 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) {
notifications, total, err := s.store.GetUserNotifications(ctx, recipientID, limit, offset)
if err != nil {

View 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)
}
}

View File

@ -26,8 +26,22 @@ func (s *Service) GetQuestionTypeDefinitionByID(ctx context.Context, id int64) (
return s.questionStore.GetQuestionTypeDefinitionByID(ctx, id)
}
func (s *Service) ListQuestionTypeDefinitions(ctx context.Context, status *string, includeSystem bool) ([]domain.QuestionTypeDefinition, error) {
return s.questionStore.ListQuestionTypeDefinitions(ctx, status, includeSystem)
func clampQuestionTypeDefinitionPage(limit, offset int32) (int32, int32) {
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 {
@ -192,6 +206,31 @@ func (s *Service) CountQuestionsInSet(ctx context.Context, setID int64) (int64,
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) {
return s.questionStore.GetQuestionSetsContainingQuestion(ctx, questionID)
}

View 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)
}
}

View File

@ -172,6 +172,7 @@ var AllPermissions = []domain.PermissionSeed{
{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_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
{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.status", "subscriptions.cancel", "subscriptions.set_auto_renew",
"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.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",

View File

@ -1,11 +1,9 @@
package handlers
import (
"bytes"
"context"
"errors"
"fmt"
"html/template"
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/services/chapa"
@ -332,15 +330,7 @@ func (h *Handler) HandleArifpaySuccessPage(c *fiber.Ctx) error {
c.Query("nonce"),
)
page := arifpaySuccessPageData{
Title: "Subscription Payment Successful",
Headline: "Your Yimaru Academy payment was received",
Body: "Thank you for your payment. Your subscription is being activated and you can return to Yimaru Academy shortly.",
BadgeLabel: "Payment successful",
StatusLabel: "Activation in progress",
ActionLabel: "Continue learning",
ActionHref: "/",
}
page := defaultPaymentSuccessPage()
if 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."
}
html, err := renderArifpaySuccessPage(page)
html, err := renderPaymentSuccessPage(page)
if err != nil {
return c.Status(fiber.StatusInternalServerError).SendString("Failed to render success page")
}
@ -585,95 +575,3 @@ func paymentToRes(p *domain.Payment) *paymentRes {
return res
}
type arifpaySuccessPageData struct {
Title string
Headline string
Body string
Helper string
BadgeLabel string
StatusLabel string
Reference string
PlanName string
ActionLabel string
ActionHref string
}
func renderArifpaySuccessPage(data arifpaySuccessPageData) (string, error) {
const tpl = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{.Title}}</title>
</head>
<body style="margin:0;background:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;color:#333;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="min-height:100vh;background:#f4f6fb;padding:24px 16px;">
<tr>
<td align="center">
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="max-width:640px;width:100%;background:#ffffff;border-radius:18px;overflow:hidden;box-shadow:0 14px 40px rgba(157,42,131,0.12);">
<tr>
<td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 55%,#c43a9a 100%);padding:32px 28px;text-align:center;">
<div style="display:inline-block;padding:8px 14px;border-radius:999px;background:rgba(255,255,255,0.15);color:#fff;font-size:12px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;">{{.BadgeLabel}}</div>
<h1 style="margin:18px 0 8px;color:#fff;font-size:30px;line-height:1.2;">Yimaru Academy</h1>
<p style="margin:0;color:rgba(255,255,255,0.88);font-size:16px;">{{.Headline}}</p>
</td>
</tr>
<tr>
<td style="padding:32px 28px;">
<div style="margin:0 auto 24px;width:76px;height:76px;border-radius:50%;background:#eef9f2;border:1px solid #cfead9;text-align:center;line-height:76px;font-size:40px;color:#1f9d55;">&#10003;</div>
<p style="margin:0 0 18px;font-size:16px;line-height:1.7;color:#555;">{{.Body}}</p>
{{if .Helper}}<p style="margin:0 0 22px;font-size:14px;line-height:1.7;color:#777;">{{.Helper}}</p>{{end}}
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="margin:0 0 26px;background:#f8f3f8;border:1px solid #eddced;border-radius:12px;">
<tr>
<td style="padding:18px 20px;">
<p style="margin:0 0 8px;font-size:12px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#9d2a83;">Status</p>
<p style="margin:0;font-size:18px;font-weight:700;color:#333;">{{.StatusLabel}}</p>
{{if .PlanName}}<p style="margin:14px 0 0;font-size:14px;color:#555;"><strong>Plan:</strong> {{.PlanName}}</p>{{end}}
{{if .Reference}}<p style="margin:8px 0 0;font-size:14px;color:#555;word-break:break-word;"><strong>Reference:</strong> {{.Reference}}</p>{{end}}
</td>
</tr>
</table>
<div style="text-align:center;">
<a href="{{.ActionHref}}" style="display:inline-block;padding:14px 24px;border-radius:10px;background:#9d2a83;color:#fff;text-decoration:none;font-size:15px;font-weight:700;">{{.ActionLabel}}</a>
</div>
</td>
</tr>
<tr>
<td style="padding:20px 28px;background:#fafafa;border-top:1px solid #eee;text-align:center;">
<p style="margin:0;font-size:12px;line-height:1.6;color:#8a8a8a;">Yimaru Academy subscription payments are verified securely before access is granted.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`
t, err := template.New("arifpay-success").Parse(tpl)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := t.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
func derefString(value *string) string {
if value == nil {
return ""
}
return *value
}

View File

@ -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
// @Summary Google login redirect
// @Tags auth

View File

@ -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
// @Summary Get Chapa payment methods
// @Description Returns payment methods available on Chapa checkout

View File

@ -8,6 +8,7 @@ import (
"io"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"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
// @Tags files
// @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"
// @Success 200 {object} domain.Response
// @Router /api/v1/files/upload [post]
@ -205,10 +206,10 @@ func (h *Handler) UploadMedia(c *fiber.Ctx) error {
if mediaType == "" {
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{
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
case "audio":
maxSize = 50 * 1024 * 1024
case "pdf":
maxSize = 25 * 1024 * 1024
case "video":
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",
})
}
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{
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/") {
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

View 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")
}
}

View File

@ -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{
Message: "Validation error",
Error: "question_text is required",
Message: "Invalid question_text",
Error: err.Error(),
})
}
@ -54,8 +56,8 @@ func (h *Handler) CreateAssessmentQuestion(c *fiber.Ctx) error {
}
input := domain.CreateQuestionInput{
QuestionText: req.QuestionText,
QuestionType: req.QuestionType,
QuestionText: questionText,
QuestionType: questionType,
DifficultyLevel: req.DifficultyLevel,
Points: req.Points,
Explanation: req.Explanation,
@ -81,7 +83,7 @@ func (h *Handler) CreateAssessmentQuestion(c *fiber.Ctx) error {
Success: true,
Data: questionRes{
ID: question.ID,
QuestionText: question.QuestionText,
QuestionText: questionTextField(question.QuestionType, question.QuestionText),
QuestionType: question.QuestionType,
Status: question.Status,
CreatedAt: question.CreatedAt.String(),
@ -129,7 +131,7 @@ func (h *Handler) ListAssessmentQuestions(c *fiber.Ctx) error {
questionResponses = append(questionResponses, questionRes{
ID: q.ID,
QuestionText: q.QuestionText,
QuestionText: questionTextField(q.QuestionType, q.QuestionText),
QuestionType: q.QuestionType,
DifficultyLevel: q.DifficultyLevel,
Points: q.Points,
@ -200,7 +202,7 @@ func (h *Handler) GetAssessmentQuestionByID(c *fiber.Ctx) error {
Message: "Question fetched successfully",
Data: questionRes{
ID: question.ID,
QuestionText: question.QuestionText,
QuestionText: questionTextField(question.QuestionType, question.QuestionText),
QuestionType: question.QuestionType,
DifficultyLevel: question.DifficultyLevel,
Points: question.Points,

View File

@ -2,6 +2,7 @@ package handlers
import (
"Yimaru-Backend/internal/domain"
notificationservice "Yimaru-Backend/internal/services/notification"
"Yimaru-Backend/internal/web_server/ws"
"bufio"
"context"
@ -120,6 +121,74 @@ func (w *hijackResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
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 {
idStr := c.Params("id")
id, err := strconv.ParseInt(idStr, 10, 64)

View 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;">&#10003;</div>
<p style="margin:0 0 18px;font-size:16px;line-height:1.7;color:#555;">{{.Body}}</p>
{{if .Helper}}<p style="margin:0 0 22px;font-size:14px;line-height:1.7;color:#777;">{{.Helper}}</p>{{end}}
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="margin:0 0 26px;background:#f8f3f8;border:1px solid #eddced;border-radius:12px;">
<tr>
<td style="padding:18px 20px;">
<p style="margin:0 0 8px;font-size:12px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#9d2a83;">Status</p>
<p style="margin:0;font-size:18px;font-weight:700;color:#333;">{{.StatusLabel}}</p>
{{if .PlanName}}<p style="margin:14px 0 0;font-size:14px;color:#555;"><strong>Plan:</strong> {{.PlanName}}</p>{{end}}
{{if .Reference}}<p style="margin:8px 0 0;font-size:14px;color:#555;word-break:break-word;"><strong>Reference:</strong> {{.Reference}}</p>{{end}}
</td>
</tr>
</table>
<div style="text-align:center;">
<a href="{{.ActionHref}}" style="display:inline-block;padding:14px 24px;border-radius:10px;background:#9d2a83;color:#fff;text-decoration:none;font-size:15px;font-weight:700;">{{.ActionLabel}}</a>
</div>
</td>
</tr>
<tr>
<td style="padding:20px 28px;background:#fafafa;border-top:1px solid #eee;text-align:center;">
<p style="margin:0;font-size:12px;line-height:1.6;color:#8a8a8a;">Yimaru Academy subscription payments are verified securely before access is granted.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`
t, err := template.New("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
}

View 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
}

View File

@ -9,6 +9,7 @@ import (
"context"
"errors"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
)
@ -76,7 +77,8 @@ func (h *Handler) ListPracticesByCourse(c *fiber.Ctx) error {
}
limit, _ := strconv.Atoi(c.Query("limit", "20"))
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))
if err != nil {
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()})
}
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{
Message: "Practices retrieved successfully",
Data: fiber.Map{
@ -108,7 +113,8 @@ func (h *Handler) ListPracticesByModule(c *fiber.Ctx) error {
}
limit, _ := strconv.Atoi(c.Query("limit", "20"))
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))
if err != nil {
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()})
}
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{
Message: "Practices retrieved successfully",
Data: fiber.Map{
@ -140,7 +149,8 @@ func (h *Handler) ListPracticesByLesson(c *fiber.Ctx) error {
}
limit, _ := strconv.Atoi(c.Query("limit", "20"))
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))
if err != nil {
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()})
}
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{
Message: "Practices retrieved successfully",
Data: fiber.Map{
@ -180,9 +193,49 @@ func (h *Handler) GetPractice(c *fiber.Ctx) error {
if !p.VisibleToLearners() && !h.canManageLMSPractices(c) {
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})
}
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
// @Tags practices
// @Param id path int true "Practice ID"

View File

@ -16,6 +16,13 @@ type componentCatalogRes struct {
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 {
StimulusComponentKinds []string `json:"stimulus_component_kinds"`
ResponseComponentKinds []string `json:"response_component_kinds"`
@ -167,8 +174,11 @@ func (h *Handler) CreateQuestionTypeDefinition(c *fiber.Ctx) error {
// @Tags questions
// @Produce json
// @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
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/questions/type-definitions [get]
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")
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 {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to list question type definitions",
Error: err.Error(),
})
}
if defs == nil {
defs = []domain.QuestionTypeDefinition{}
}
return c.JSON(domain.Response{
Message: "Question type definitions",
Data: defs,
Data: listQuestionTypeDefinitionsData{
QuestionTypeDefinitions: defs,
TotalCount: total,
Limit: limit,
Offset: offset,
},
Success: true,
StatusCode: fiber.StatusOK,
})
}

View File

@ -26,7 +26,7 @@ type shortAnswerInput struct {
}
type createQuestionReq struct {
QuestionText string `json:"question_text" validate:"required"`
QuestionText *string `json:"question_text,omitempty"`
QuestionType string `json:"question_type"`
QuestionTypeDefinitionID *int64 `json:"question_type_definition_id"`
DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload"`
@ -58,7 +58,7 @@ type shortAnswerRes struct {
type questionRes struct {
ID int64 `json:"id"`
QuestionText string `json:"question_text"`
QuestionText *string `json:"question_text,omitempty"`
QuestionType string `json:"question_type"`
QuestionTypeDefinitionID *int64 `json:"question_type_definition_id,omitempty"`
DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload,omitempty"`
@ -89,9 +89,43 @@ func normalizeRuntimeQuestionType(v string) string {
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
// @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
// @Accept 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{
QuestionText: req.QuestionText,
QuestionText: questionText,
QuestionType: questionType,
QuestionTypeDefinitionID: req.QuestionTypeDefinitionID,
DynamicPayload: req.DynamicPayload,
@ -220,13 +262,13 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
"question_type": question.QuestionType,
"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{
Message: "Question created successfully",
Data: questionRes{
ID: question.ID,
QuestionText: question.QuestionText,
QuestionText: questionTextField(question.QuestionType, question.QuestionText),
QuestionType: question.QuestionType,
QuestionTypeDefinitionID: question.QuestionTypeDefinitionID,
DynamicPayload: question.DynamicPayload,
@ -299,7 +341,7 @@ func (h *Handler) GetQuestionByID(c *fiber.Ctx) error {
Message: "Question retrieved successfully",
Data: questionRes{
ID: question.ID,
QuestionText: question.QuestionText,
QuestionText: questionTextField(question.QuestionType, question.QuestionText),
QuestionType: question.QuestionType,
QuestionTypeDefinitionID: question.QuestionTypeDefinitionID,
DynamicPayload: question.DynamicPayload,
@ -366,7 +408,7 @@ func (h *Handler) ListQuestions(c *fiber.Ctx) error {
for _, q := range questions {
questionResponses = append(questionResponses, questionRes{
ID: q.ID,
QuestionText: q.QuestionText,
QuestionText: questionTextField(q.QuestionType, q.QuestionText),
QuestionType: q.QuestionType,
QuestionTypeDefinitionID: q.QuestionTypeDefinitionID,
DynamicPayload: q.DynamicPayload,
@ -426,7 +468,7 @@ func (h *Handler) SearchQuestions(c *fiber.Ctx) error {
for _, q := range questions {
questionResponses = append(questionResponses, questionRes{
ID: q.ID,
QuestionText: q.QuestionText,
QuestionText: questionTextField(q.QuestionType, q.QuestionText),
QuestionType: q.QuestionType,
QuestionTypeDefinitionID: q.QuestionTypeDefinitionID,
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)
if req.QuestionType != nil {
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{
QuestionText: questionText,
QuestionType: questionType,
@ -1216,7 +1262,7 @@ type questionSetItemRes struct {
SetID int64 `json:"set_id"`
QuestionID int64 `json:"question_id"`
DisplayOrder int32 `json:"display_order"`
QuestionText string `json:"question_text"`
QuestionText *string `json:"question_text,omitempty"`
QuestionType string `json:"question_type"`
DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload,omitempty"`
DifficultyLevel *string `json:"difficulty_level,omitempty"`
@ -1245,7 +1291,7 @@ func questionSetItemsToRes(items []domain.QuestionSetItemWithQuestion) []questio
SetID: item.SetID,
QuestionID: item.QuestionID,
DisplayOrder: item.DisplayOrder,
QuestionText: item.QuestionText,
QuestionText: questionTextField(item.QuestionType, item.QuestionText),
QuestionType: item.QuestionType,
DynamicPayload: item.DynamicPayload,
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
// @Summary Get questions in set
// @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{
ID: question.ID,
QuestionText: question.QuestionText,
QuestionText: questionTextField(question.QuestionType, question.QuestionText),
QuestionType: question.QuestionType,
QuestionTypeDefinitionID: question.QuestionTypeDefinitionID,
DynamicPayload: question.DynamicPayload,
@ -1594,6 +1683,11 @@ func (h *Handler) CompletePractice(c *fiber.Ctx) error {
if !strings.EqualFold(set.SetType, string(domain.QuestionSetTypePractice)) {
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 err := h.forbidCompletingDraftPractice(c, set.ID); err != nil {
code := fiber.StatusInternalServerError

View File

@ -205,7 +205,6 @@ func (a *App) RequireActiveSubscription() fiber.Handler {
}
if !active {
// 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()
@ -228,7 +227,6 @@ func (a *App) RequireSubscriptionCategory(category domain.SubscriptionCategory)
return c.Next()
}
if categorySubscriptionGateDisabled {
// Temporary bypass to disable category-aware learner access checks without changing route wiring.
return c.Next()
}
active, err := a.subscriptionsSvc.HasActiveSubscriptionByCategory(c.Context(), userID, category)
@ -262,7 +260,6 @@ func (a *App) RequireExamPrepSubscription() fiber.Handler {
return c.Next()
}
if categorySubscriptionGateDisabled {
// Temporary bypass to disable category-aware learner access checks without changing route wiring.
return c.Next()
}

View File

@ -229,6 +229,10 @@ func (a *App) initAppRoutes() {
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)
// 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
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)
@ -239,6 +243,7 @@ func (a *App) initAppRoutes() {
// Question Set Items
groupV1.Post("/question-sets/:setId/questions", a.authMiddleware, a.RequirePermission("question_set_items.add"), h.AddQuestionToSet)
groupV1.Get("/question-sets/:setId/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("/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)
@ -274,7 +279,9 @@ func (a *App) initAppRoutes() {
groupV1.Post("/payments/:id/cancel", a.authMiddleware, a.RequirePermission("payments.cancel"), h.CancelPayment)
groupV1.Post("/payments/webhook", h.HandleChapaWebhook)
groupV1.Get("/payments/arifpay/success", h.HandleArifpaySuccessPage)
groupV1.Get("/payments/chapa/success", h.HandleChapaSuccessPage)
groupV1.Get("/payments/chapa/callback", h.HandleChapaCallback)
a.fiber.Get("/payment/success", h.HandleChapaSuccessPage)
// Direct Payments
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.Get("/auth/google/login", h.GoogleLogin)
groupV1.Get("/auth/google/callback", h.GoogleCallback)
groupV1.Post("/auth/apple", h.AppleLogin)
groupV1.Post("/auth/customer-login", h.LoginUser)
groupV1.Post("/auth/admin-login", h.LoginAdmin)
groupV1.Post("/auth/super-login", h.LoginSuper)
@ -346,9 +354,9 @@ func (a *App) initAppRoutes() {
// Admin management
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.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-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/: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.Get("/notifications/:id", a.authMiddleware, a.RequirePermission("notifications.list_mine"), h.GetNotificationByID)
// Issues
groupV1.Post("/issues", a.authMiddleware, a.RequirePermission("issues.create"), h.CreateIssue)

View File

@ -228,7 +228,7 @@
"method": "GET",
"header": [],
"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": [
"{{base_url}}"
],
@ -595,7 +595,7 @@
],
"body": {
"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": {
"raw": "{{base_url}}/api/v1/questions",