Add category-based subscription controls for LMS and exam prep.
Introduce plan and content categories across programs and exam-prep catalog roots, wire category-aware checkout and access checks, and keep learner gating temporarily bypassed until data migration is ready. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
7a4253edf4
commit
79fb95ce36
|
|
@ -0,0 +1,16 @@
|
||||||
|
DROP INDEX IF EXISTS idx_exam_prep_catalog_courses_category;
|
||||||
|
DROP INDEX IF EXISTS idx_programs_category;
|
||||||
|
DROP INDEX IF EXISTS idx_subscription_plans_category;
|
||||||
|
|
||||||
|
ALTER TABLE exam_prep.catalog_courses
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_exam_prep_catalog_courses_category,
|
||||||
|
ALTER COLUMN category DROP DEFAULT,
|
||||||
|
DROP COLUMN IF EXISTS category;
|
||||||
|
|
||||||
|
ALTER TABLE subscription_plans
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_subscription_plans_category,
|
||||||
|
DROP COLUMN IF EXISTS category;
|
||||||
|
|
||||||
|
ALTER TABLE programs
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_programs_category,
|
||||||
|
DROP COLUMN IF EXISTS category;
|
||||||
30
db/migrations/000070_subscription_content_categories.up.sql
Normal file
30
db/migrations/000070_subscription_content_categories.up.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
ALTER TABLE subscription_plans
|
||||||
|
ADD COLUMN category VARCHAR(32) NOT NULL DEFAULT 'LEARN_ENGLISH',
|
||||||
|
ADD CONSTRAINT chk_subscription_plans_category
|
||||||
|
CHECK (category IN ('LEARN_ENGLISH', 'IELTS', 'DUOLINGO'));
|
||||||
|
|
||||||
|
ALTER TABLE programs
|
||||||
|
ADD COLUMN category VARCHAR(32) NOT NULL DEFAULT 'LEARN_ENGLISH',
|
||||||
|
ADD CONSTRAINT chk_programs_category
|
||||||
|
CHECK (category IN ('LEARN_ENGLISH', 'IELTS', 'DUOLINGO'));
|
||||||
|
|
||||||
|
ALTER TABLE exam_prep.catalog_courses
|
||||||
|
ADD COLUMN category VARCHAR(32);
|
||||||
|
|
||||||
|
UPDATE exam_prep.catalog_courses
|
||||||
|
SET category = CASE
|
||||||
|
WHEN upper(name) LIKE '%DUOLINGO%' OR upper(name) LIKE '%DET%' THEN 'DUOLINGO'
|
||||||
|
WHEN upper(name) LIKE '%IELTS%' THEN 'IELTS'
|
||||||
|
ELSE 'IELTS'
|
||||||
|
END
|
||||||
|
WHERE category IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE exam_prep.catalog_courses
|
||||||
|
ALTER COLUMN category SET NOT NULL,
|
||||||
|
ALTER COLUMN category SET DEFAULT 'IELTS',
|
||||||
|
ADD CONSTRAINT chk_exam_prep_catalog_courses_category
|
||||||
|
CHECK (category IN ('IELTS', 'DUOLINGO'));
|
||||||
|
|
||||||
|
CREATE INDEX idx_subscription_plans_category ON subscription_plans(category);
|
||||||
|
CREATE INDEX idx_programs_category ON programs(category);
|
||||||
|
CREATE INDEX idx_exam_prep_catalog_courses_category ON exam_prep.catalog_courses(category);
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
-- name: ExamPrepCreateCatalogCourse :one
|
-- name: ExamPrepCreateCatalogCourse :one
|
||||||
INSERT INTO exam_prep.catalog_courses (name, description, thumbnail, sort_order)
|
INSERT INTO exam_prep.catalog_courses (name, description, category, thumbnail, sort_order)
|
||||||
SELECT
|
SELECT
|
||||||
$1,
|
$1,
|
||||||
$2,
|
$2,
|
||||||
$3,
|
$3,
|
||||||
|
$4,
|
||||||
coalesce((
|
coalesce((
|
||||||
SELECT
|
SELECT
|
||||||
max(c.sort_order)
|
max(c.sort_order)
|
||||||
|
|
@ -42,6 +43,7 @@ SELECT
|
||||||
c.id,
|
c.id,
|
||||||
c.name,
|
c.name,
|
||||||
c.description,
|
c.description,
|
||||||
|
c.category,
|
||||||
c.thumbnail,
|
c.thumbnail,
|
||||||
c.sort_order,
|
c.sort_order,
|
||||||
COALESCE(cc.units_count, 0)::BIGINT AS units_count,
|
COALESCE(cc.units_count, 0)::BIGINT AS units_count,
|
||||||
|
|
@ -73,6 +75,7 @@ UPDATE exam_prep.catalog_courses
|
||||||
SET
|
SET
|
||||||
name = coalesce(sqlc.narg('name')::varchar, name),
|
name = coalesce(sqlc.narg('name')::varchar, name),
|
||||||
description = coalesce(sqlc.narg('description')::text, description),
|
description = coalesce(sqlc.narg('description')::text, description),
|
||||||
|
category = coalesce(sqlc.narg('category')::varchar, category),
|
||||||
thumbnail = coalesce(sqlc.narg('thumbnail')::text, thumbnail),
|
thumbnail = coalesce(sqlc.narg('thumbnail')::text, thumbnail),
|
||||||
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
|
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
-- name: CreateProgram :one
|
-- name: CreateProgram :one
|
||||||
INSERT INTO programs (name, description, thumbnail, sort_order)
|
INSERT INTO programs (name, description, category, thumbnail, sort_order)
|
||||||
SELECT
|
SELECT
|
||||||
sqlc.arg('name'),
|
sqlc.arg('name'),
|
||||||
sqlc.arg('description'),
|
sqlc.arg('description'),
|
||||||
|
sqlc.arg('category'),
|
||||||
sqlc.arg('thumbnail'),
|
sqlc.arg('thumbnail'),
|
||||||
COALESCE(sqlc.narg('sort_order')::int, COALESCE((
|
COALESCE(sqlc.narg('sort_order')::int, COALESCE((
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -30,6 +31,7 @@ SELECT
|
||||||
p.id,
|
p.id,
|
||||||
p.name,
|
p.name,
|
||||||
p.description,
|
p.description,
|
||||||
|
p.category,
|
||||||
p.thumbnail,
|
p.thumbnail,
|
||||||
p.sort_order,
|
p.sort_order,
|
||||||
p.created_at,
|
p.created_at,
|
||||||
|
|
@ -43,6 +45,7 @@ UPDATE programs
|
||||||
SET
|
SET
|
||||||
name = COALESCE(sqlc.narg('name')::varchar, name),
|
name = COALESCE(sqlc.narg('name')::varchar, name),
|
||||||
description = COALESCE(sqlc.narg('description')::text, description),
|
description = COALESCE(sqlc.narg('description')::text, description),
|
||||||
|
category = COALESCE(sqlc.narg('category')::varchar, category),
|
||||||
thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail),
|
thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail),
|
||||||
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
|
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
|
|
||||||
-- name: CreateSubscriptionPlan :one
|
-- name: CreateSubscriptionPlan :one
|
||||||
INSERT INTO subscription_plans (
|
INSERT INTO subscription_plans (
|
||||||
name, description, duration_value, duration_unit, price, currency, is_active
|
name, description, category, duration_value, duration_unit, price, currency, is_active
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, true))
|
VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE($8, true))
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetSubscriptionPlanByID :one
|
-- name: GetSubscriptionPlanByID :one
|
||||||
|
|
@ -25,15 +25,16 @@ ORDER BY price ASC;
|
||||||
-- name: UpdateSubscriptionPlan :exec
|
-- name: UpdateSubscriptionPlan :exec
|
||||||
UPDATE subscription_plans
|
UPDATE subscription_plans
|
||||||
SET
|
SET
|
||||||
name = COALESCE($1, name),
|
name = COALESCE(sqlc.narg('name')::varchar, name),
|
||||||
description = COALESCE($2, description),
|
description = COALESCE(sqlc.narg('description')::text, description),
|
||||||
duration_value = COALESCE($3, duration_value),
|
category = COALESCE(sqlc.narg('category')::varchar, category),
|
||||||
duration_unit = COALESCE($4, duration_unit),
|
duration_value = COALESCE(sqlc.narg('duration_value')::int, duration_value),
|
||||||
price = COALESCE($5, price),
|
duration_unit = COALESCE(sqlc.narg('duration_unit')::varchar, duration_unit),
|
||||||
currency = COALESCE($6, currency),
|
price = COALESCE(sqlc.narg('price')::numeric, price),
|
||||||
is_active = COALESCE($7, is_active),
|
currency = COALESCE(sqlc.narg('currency')::varchar, currency),
|
||||||
|
is_active = COALESCE(sqlc.narg('is_active')::boolean, is_active),
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $8;
|
WHERE id = sqlc.arg('id');
|
||||||
|
|
||||||
-- name: DeleteSubscriptionPlan :exec
|
-- name: DeleteSubscriptionPlan :exec
|
||||||
DELETE FROM subscription_plans WHERE id = $1;
|
DELETE FROM subscription_plans WHERE id = $1;
|
||||||
|
|
@ -186,6 +187,17 @@ SELECT EXISTS(
|
||||||
AND expires_at > CURRENT_TIMESTAMP
|
AND expires_at > CURRENT_TIMESTAMP
|
||||||
) AS has_subscription;
|
) AS has_subscription;
|
||||||
|
|
||||||
|
-- name: HasActiveSubscriptionByCategory :one
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1
|
||||||
|
FROM user_subscriptions us
|
||||||
|
JOIN subscription_plans sp ON sp.id = us.plan_id
|
||||||
|
WHERE us.user_id = $1
|
||||||
|
AND sp.category = $2
|
||||||
|
AND us.status = 'ACTIVE'
|
||||||
|
AND us.expires_at > CURRENT_TIMESTAMP
|
||||||
|
) AS has_subscription;
|
||||||
|
|
||||||
-- name: ExtendSubscription :exec
|
-- name: ExtendSubscription :exec
|
||||||
UPDATE user_subscriptions
|
UPDATE user_subscriptions
|
||||||
SET
|
SET
|
||||||
|
|
|
||||||
89
docs/docs.go
89
docs/docs.go
|
|
@ -4619,6 +4619,46 @@ const docTemplate = `{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/payments/arifpay/success": {
|
||||||
|
"get": {
|
||||||
|
"description": "Displays the Yimaru Academy success page after ArifPay redirects the learner back to the backend.",
|
||||||
|
"produces": [
|
||||||
|
"text/html"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"payments"
|
||||||
|
],
|
||||||
|
"summary": "ArifPay payment success page",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "ArifPay session identifier",
|
||||||
|
"name": "session_id",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "ArifPay session identifier",
|
||||||
|
"name": "sessionId",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Fallback payment nonce",
|
||||||
|
"name": "nonce",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "HTML success page",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/payments/chapa/callback": {
|
"/api/v1/payments/chapa/callback": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Verifies payment after Chapa redirects to callback_url",
|
"description": "Verifies payment after Chapa redirects to callback_url",
|
||||||
|
|
@ -11925,9 +11965,17 @@ const docTemplate = `{
|
||||||
"domain.CreateExamPrepCatalogCourseInput": {
|
"domain.CreateExamPrepCatalogCourseInput": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
"category",
|
||||||
"name"
|
"name"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"category": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"IELTS",
|
||||||
|
"DUOLINGO"
|
||||||
|
]
|
||||||
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|
@ -12172,9 +12220,18 @@ const docTemplate = `{
|
||||||
"domain.CreateProgramInput": {
|
"domain.CreateProgramInput": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
"category",
|
||||||
"name"
|
"name"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"category": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"LEARN_ENGLISH",
|
||||||
|
"IELTS",
|
||||||
|
"DUOLINGO"
|
||||||
|
]
|
||||||
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|
@ -12928,6 +12985,13 @@ const docTemplate = `{
|
||||||
"domain.UpdateExamPrepCatalogCourseInput": {
|
"domain.UpdateExamPrepCatalogCourseInput": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"category": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"IELTS",
|
||||||
|
"DUOLINGO"
|
||||||
|
]
|
||||||
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|
@ -13104,6 +13168,14 @@ const docTemplate = `{
|
||||||
"domain.UpdateProgramInput": {
|
"domain.UpdateProgramInput": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"category": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"LEARN_ENGLISH",
|
||||||
|
"IELTS",
|
||||||
|
"DUOLINGO"
|
||||||
|
]
|
||||||
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|
@ -14061,6 +14133,7 @@ const docTemplate = `{
|
||||||
"handlers.createPlanReq": {
|
"handlers.createPlanReq": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
"category",
|
||||||
"currency",
|
"currency",
|
||||||
"duration_unit",
|
"duration_unit",
|
||||||
"duration_value",
|
"duration_value",
|
||||||
|
|
@ -14068,6 +14141,14 @@ const docTemplate = `{
|
||||||
"price"
|
"price"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"category": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"LEARN_ENGLISH",
|
||||||
|
"IELTS",
|
||||||
|
"DUOLINGO"
|
||||||
|
]
|
||||||
|
},
|
||||||
"currency": {
|
"currency": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|
@ -14668,6 +14749,14 @@ const docTemplate = `{
|
||||||
"handlers.updatePlanReq": {
|
"handlers.updatePlanReq": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"category": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"LEARN_ENGLISH",
|
||||||
|
"IELTS",
|
||||||
|
"DUOLINGO"
|
||||||
|
]
|
||||||
|
},
|
||||||
"currency": {
|
"currency": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -4611,6 +4611,46 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/payments/arifpay/success": {
|
||||||
|
"get": {
|
||||||
|
"description": "Displays the Yimaru Academy success page after ArifPay redirects the learner back to the backend.",
|
||||||
|
"produces": [
|
||||||
|
"text/html"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"payments"
|
||||||
|
],
|
||||||
|
"summary": "ArifPay payment success page",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "ArifPay session identifier",
|
||||||
|
"name": "session_id",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "ArifPay session identifier",
|
||||||
|
"name": "sessionId",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Fallback payment nonce",
|
||||||
|
"name": "nonce",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "HTML success page",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/payments/chapa/callback": {
|
"/api/v1/payments/chapa/callback": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Verifies payment after Chapa redirects to callback_url",
|
"description": "Verifies payment after Chapa redirects to callback_url",
|
||||||
|
|
@ -11917,9 +11957,17 @@
|
||||||
"domain.CreateExamPrepCatalogCourseInput": {
|
"domain.CreateExamPrepCatalogCourseInput": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
"category",
|
||||||
"name"
|
"name"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"category": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"IELTS",
|
||||||
|
"DUOLINGO"
|
||||||
|
]
|
||||||
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|
@ -12164,9 +12212,18 @@
|
||||||
"domain.CreateProgramInput": {
|
"domain.CreateProgramInput": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
"category",
|
||||||
"name"
|
"name"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"category": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"LEARN_ENGLISH",
|
||||||
|
"IELTS",
|
||||||
|
"DUOLINGO"
|
||||||
|
]
|
||||||
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|
@ -12920,6 +12977,13 @@
|
||||||
"domain.UpdateExamPrepCatalogCourseInput": {
|
"domain.UpdateExamPrepCatalogCourseInput": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"category": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"IELTS",
|
||||||
|
"DUOLINGO"
|
||||||
|
]
|
||||||
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|
@ -13096,6 +13160,14 @@
|
||||||
"domain.UpdateProgramInput": {
|
"domain.UpdateProgramInput": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"category": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"LEARN_ENGLISH",
|
||||||
|
"IELTS",
|
||||||
|
"DUOLINGO"
|
||||||
|
]
|
||||||
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|
@ -14053,6 +14125,7 @@
|
||||||
"handlers.createPlanReq": {
|
"handlers.createPlanReq": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
"category",
|
||||||
"currency",
|
"currency",
|
||||||
"duration_unit",
|
"duration_unit",
|
||||||
"duration_value",
|
"duration_value",
|
||||||
|
|
@ -14060,6 +14133,14 @@
|
||||||
"price"
|
"price"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"category": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"LEARN_ENGLISH",
|
||||||
|
"IELTS",
|
||||||
|
"DUOLINGO"
|
||||||
|
]
|
||||||
|
},
|
||||||
"currency": {
|
"currency": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|
@ -14660,6 +14741,14 @@
|
||||||
"handlers.updatePlanReq": {
|
"handlers.updatePlanReq": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"category": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"LEARN_ENGLISH",
|
||||||
|
"IELTS",
|
||||||
|
"DUOLINGO"
|
||||||
|
]
|
||||||
|
},
|
||||||
"currency": {
|
"currency": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -440,6 +440,11 @@ definitions:
|
||||||
type: object
|
type: object
|
||||||
domain.CreateExamPrepCatalogCourseInput:
|
domain.CreateExamPrepCatalogCourseInput:
|
||||||
properties:
|
properties:
|
||||||
|
category:
|
||||||
|
enum:
|
||||||
|
- IELTS
|
||||||
|
- DUOLINGO
|
||||||
|
type: string
|
||||||
description:
|
description:
|
||||||
type: string
|
type: string
|
||||||
name:
|
name:
|
||||||
|
|
@ -447,6 +452,7 @@ definitions:
|
||||||
thumbnail:
|
thumbnail:
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
|
- category
|
||||||
- name
|
- name
|
||||||
type: object
|
type: object
|
||||||
domain.CreateExamPrepLessonInput:
|
domain.CreateExamPrepLessonInput:
|
||||||
|
|
@ -612,6 +618,12 @@ definitions:
|
||||||
type: object
|
type: object
|
||||||
domain.CreateProgramInput:
|
domain.CreateProgramInput:
|
||||||
properties:
|
properties:
|
||||||
|
category:
|
||||||
|
enum:
|
||||||
|
- LEARN_ENGLISH
|
||||||
|
- IELTS
|
||||||
|
- DUOLINGO
|
||||||
|
type: string
|
||||||
description:
|
description:
|
||||||
type: string
|
type: string
|
||||||
name:
|
name:
|
||||||
|
|
@ -624,6 +636,7 @@ definitions:
|
||||||
thumbnail:
|
thumbnail:
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
|
- category
|
||||||
- name
|
- name
|
||||||
type: object
|
type: object
|
||||||
domain.CreateRoleReq:
|
domain.CreateRoleReq:
|
||||||
|
|
@ -1131,6 +1144,11 @@ definitions:
|
||||||
type: object
|
type: object
|
||||||
domain.UpdateExamPrepCatalogCourseInput:
|
domain.UpdateExamPrepCatalogCourseInput:
|
||||||
properties:
|
properties:
|
||||||
|
category:
|
||||||
|
enum:
|
||||||
|
- IELTS
|
||||||
|
- DUOLINGO
|
||||||
|
type: string
|
||||||
description:
|
description:
|
||||||
type: string
|
type: string
|
||||||
name:
|
name:
|
||||||
|
|
@ -1249,6 +1267,12 @@ definitions:
|
||||||
type: object
|
type: object
|
||||||
domain.UpdateProgramInput:
|
domain.UpdateProgramInput:
|
||||||
properties:
|
properties:
|
||||||
|
category:
|
||||||
|
enum:
|
||||||
|
- LEARN_ENGLISH
|
||||||
|
- IELTS
|
||||||
|
- DUOLINGO
|
||||||
|
type: string
|
||||||
description:
|
description:
|
||||||
type: string
|
type: string
|
||||||
name:
|
name:
|
||||||
|
|
@ -1893,6 +1917,12 @@ definitions:
|
||||||
type: object
|
type: object
|
||||||
handlers.createPlanReq:
|
handlers.createPlanReq:
|
||||||
properties:
|
properties:
|
||||||
|
category:
|
||||||
|
enum:
|
||||||
|
- LEARN_ENGLISH
|
||||||
|
- IELTS
|
||||||
|
- DUOLINGO
|
||||||
|
type: string
|
||||||
currency:
|
currency:
|
||||||
type: string
|
type: string
|
||||||
description:
|
description:
|
||||||
|
|
@ -1915,6 +1945,7 @@ definitions:
|
||||||
minimum: 0
|
minimum: 0
|
||||||
type: number
|
type: number
|
||||||
required:
|
required:
|
||||||
|
- category
|
||||||
- currency
|
- currency
|
||||||
- duration_unit
|
- duration_unit
|
||||||
- duration_value
|
- duration_value
|
||||||
|
|
@ -2304,6 +2335,12 @@ definitions:
|
||||||
type: object
|
type: object
|
||||||
handlers.updatePlanReq:
|
handlers.updatePlanReq:
|
||||||
properties:
|
properties:
|
||||||
|
category:
|
||||||
|
enum:
|
||||||
|
- LEARN_ENGLISH
|
||||||
|
- IELTS
|
||||||
|
- DUOLINGO
|
||||||
|
type: string
|
||||||
currency:
|
currency:
|
||||||
type: string
|
type: string
|
||||||
description:
|
description:
|
||||||
|
|
@ -5932,6 +5969,33 @@ paths:
|
||||||
summary: Cancel a pending payment
|
summary: Cancel a pending payment
|
||||||
tags:
|
tags:
|
||||||
- payments
|
- payments
|
||||||
|
/api/v1/payments/arifpay/success:
|
||||||
|
get:
|
||||||
|
description: Displays the Yimaru Academy success page after ArifPay redirects
|
||||||
|
the learner back to the backend.
|
||||||
|
parameters:
|
||||||
|
- description: ArifPay session identifier
|
||||||
|
in: query
|
||||||
|
name: session_id
|
||||||
|
type: string
|
||||||
|
- description: ArifPay session identifier
|
||||||
|
in: query
|
||||||
|
name: sessionId
|
||||||
|
type: string
|
||||||
|
- description: Fallback payment nonce
|
||||||
|
in: query
|
||||||
|
name: nonce
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- text/html
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: HTML success page
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: ArifPay payment success page
|
||||||
|
tags:
|
||||||
|
- payments
|
||||||
/api/v1/payments/chapa/callback:
|
/api/v1/payments/chapa/callback:
|
||||||
get:
|
get:
|
||||||
description: Verifies payment after Chapa redirects to callback_url
|
description: Verifies payment after Chapa redirects to callback_url
|
||||||
|
|
|
||||||
|
|
@ -12,27 +12,34 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const ExamPrepCreateCatalogCourse = `-- name: ExamPrepCreateCatalogCourse :one
|
const ExamPrepCreateCatalogCourse = `-- name: ExamPrepCreateCatalogCourse :one
|
||||||
INSERT INTO exam_prep.catalog_courses (name, description, thumbnail, sort_order)
|
INSERT INTO exam_prep.catalog_courses (name, description, category, thumbnail, sort_order)
|
||||||
SELECT
|
SELECT
|
||||||
$1,
|
$1,
|
||||||
$2,
|
$2,
|
||||||
$3,
|
$3,
|
||||||
|
$4,
|
||||||
coalesce((
|
coalesce((
|
||||||
SELECT
|
SELECT
|
||||||
max(c.sort_order)
|
max(c.sort_order)
|
||||||
FROM exam_prep.catalog_courses AS c), 0) + 1
|
FROM exam_prep.catalog_courses AS c), 0) + 1
|
||||||
RETURNING
|
RETURNING
|
||||||
id, name, description, thumbnail, sort_order, created_at, updated_at
|
id, name, description, thumbnail, sort_order, created_at, updated_at, category
|
||||||
`
|
`
|
||||||
|
|
||||||
type ExamPrepCreateCatalogCourseParams struct {
|
type ExamPrepCreateCatalogCourseParams struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description pgtype.Text `json:"description"`
|
Description pgtype.Text `json:"description"`
|
||||||
|
Category string `json:"category"`
|
||||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) ExamPrepCreateCatalogCourse(ctx context.Context, arg ExamPrepCreateCatalogCourseParams) (ExamPrepCatalogCourse, error) {
|
func (q *Queries) ExamPrepCreateCatalogCourse(ctx context.Context, arg ExamPrepCreateCatalogCourseParams) (ExamPrepCatalogCourse, error) {
|
||||||
row := q.db.QueryRow(ctx, ExamPrepCreateCatalogCourse, arg.Name, arg.Description, arg.Thumbnail)
|
row := q.db.QueryRow(ctx, ExamPrepCreateCatalogCourse,
|
||||||
|
arg.Name,
|
||||||
|
arg.Description,
|
||||||
|
arg.Category,
|
||||||
|
arg.Thumbnail,
|
||||||
|
)
|
||||||
var i ExamPrepCatalogCourse
|
var i ExamPrepCatalogCourse
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
|
|
@ -42,6 +49,7 @@ func (q *Queries) ExamPrepCreateCatalogCourse(ctx context.Context, arg ExamPrepC
|
||||||
&i.SortOrder,
|
&i.SortOrder,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.Category,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -58,7 +66,7 @@ func (q *Queries) ExamPrepDeleteCatalogCourse(ctx context.Context, id int64) err
|
||||||
|
|
||||||
const ExamPrepGetCatalogCourseByID = `-- name: ExamPrepGetCatalogCourseByID :one
|
const ExamPrepGetCatalogCourseByID = `-- name: ExamPrepGetCatalogCourseByID :one
|
||||||
SELECT
|
SELECT
|
||||||
c.id, c.name, c.description, c.thumbnail, c.sort_order, c.created_at, c.updated_at,
|
c.id, c.name, c.description, c.thumbnail, c.sort_order, c.created_at, c.updated_at, c.category,
|
||||||
EXISTS (
|
EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM exam_prep.lesson_practices p
|
FROM exam_prep.lesson_practices p
|
||||||
|
|
@ -79,6 +87,7 @@ type ExamPrepGetCatalogCourseByIDRow struct {
|
||||||
SortOrder int32 `json:"sort_order"`
|
SortOrder int32 `json:"sort_order"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
Category string `json:"category"`
|
||||||
HasPractice bool `json:"has_practice"`
|
HasPractice bool `json:"has_practice"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,6 +102,7 @@ func (q *Queries) ExamPrepGetCatalogCourseByID(ctx context.Context, id int64) (E
|
||||||
&i.SortOrder,
|
&i.SortOrder,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.Category,
|
||||||
&i.HasPractice,
|
&i.HasPractice,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
|
|
@ -142,6 +152,7 @@ SELECT
|
||||||
c.id,
|
c.id,
|
||||||
c.name,
|
c.name,
|
||||||
c.description,
|
c.description,
|
||||||
|
c.category,
|
||||||
c.thumbnail,
|
c.thumbnail,
|
||||||
c.sort_order,
|
c.sort_order,
|
||||||
COALESCE(cc.units_count, 0)::BIGINT AS units_count,
|
COALESCE(cc.units_count, 0)::BIGINT AS units_count,
|
||||||
|
|
@ -173,6 +184,7 @@ type ExamPrepListCatalogCoursesRow struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description pgtype.Text `json:"description"`
|
Description pgtype.Text `json:"description"`
|
||||||
|
Category string `json:"category"`
|
||||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||||
SortOrder int32 `json:"sort_order"`
|
SortOrder int32 `json:"sort_order"`
|
||||||
UnitsCount int64 `json:"units_count"`
|
UnitsCount int64 `json:"units_count"`
|
||||||
|
|
@ -197,6 +209,7 @@ func (q *Queries) ExamPrepListCatalogCourses(ctx context.Context, arg ExamPrepLi
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.Name,
|
&i.Name,
|
||||||
&i.Description,
|
&i.Description,
|
||||||
|
&i.Category,
|
||||||
&i.Thumbnail,
|
&i.Thumbnail,
|
||||||
&i.SortOrder,
|
&i.SortOrder,
|
||||||
&i.UnitsCount,
|
&i.UnitsCount,
|
||||||
|
|
@ -221,17 +234,19 @@ UPDATE exam_prep.catalog_courses
|
||||||
SET
|
SET
|
||||||
name = coalesce($1::varchar, name),
|
name = coalesce($1::varchar, name),
|
||||||
description = coalesce($2::text, description),
|
description = coalesce($2::text, description),
|
||||||
thumbnail = coalesce($3::text, thumbnail),
|
category = coalesce($3::varchar, category),
|
||||||
sort_order = coalesce($4::int, sort_order),
|
thumbnail = coalesce($4::text, thumbnail),
|
||||||
|
sort_order = coalesce($5::int, sort_order),
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $5
|
WHERE id = $6
|
||||||
RETURNING
|
RETURNING
|
||||||
id, name, description, thumbnail, sort_order, created_at, updated_at
|
id, name, description, thumbnail, sort_order, created_at, updated_at, category
|
||||||
`
|
`
|
||||||
|
|
||||||
type ExamPrepUpdateCatalogCourseParams struct {
|
type ExamPrepUpdateCatalogCourseParams struct {
|
||||||
Name pgtype.Text `json:"name"`
|
Name pgtype.Text `json:"name"`
|
||||||
Description pgtype.Text `json:"description"`
|
Description pgtype.Text `json:"description"`
|
||||||
|
Category pgtype.Text `json:"category"`
|
||||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||||
SortOrder pgtype.Int4 `json:"sort_order"`
|
SortOrder pgtype.Int4 `json:"sort_order"`
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
|
|
@ -241,6 +256,7 @@ func (q *Queries) ExamPrepUpdateCatalogCourse(ctx context.Context, arg ExamPrepU
|
||||||
row := q.db.QueryRow(ctx, ExamPrepUpdateCatalogCourse,
|
row := q.db.QueryRow(ctx, ExamPrepUpdateCatalogCourse,
|
||||||
arg.Name,
|
arg.Name,
|
||||||
arg.Description,
|
arg.Description,
|
||||||
|
arg.Category,
|
||||||
arg.Thumbnail,
|
arg.Thumbnail,
|
||||||
arg.SortOrder,
|
arg.SortOrder,
|
||||||
arg.ID,
|
arg.ID,
|
||||||
|
|
@ -254,6 +270,7 @@ func (q *Queries) ExamPrepUpdateCatalogCourse(ctx context.Context, arg ExamPrepU
|
||||||
&i.SortOrder,
|
&i.SortOrder,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.Category,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -546,7 +546,7 @@ func (q *Queries) GetPreviousModuleInCourse(ctx context.Context, id int64) (Modu
|
||||||
|
|
||||||
const GetPreviousProgram = `-- name: GetPreviousProgram :one
|
const GetPreviousProgram = `-- name: GetPreviousProgram :one
|
||||||
SELECT
|
SELECT
|
||||||
p2.id, p2.name, p2.description, p2.thumbnail, p2.created_at, p2.updated_at, p2.sort_order
|
p2.id, p2.name, p2.description, p2.thumbnail, p2.created_at, p2.updated_at, p2.sort_order, p2.category
|
||||||
FROM
|
FROM
|
||||||
programs AS p1
|
programs AS p1
|
||||||
INNER JOIN programs AS p2 ON p2.sort_order = p1.sort_order - 1
|
INNER JOIN programs AS p2 ON p2.sort_order = p1.sort_order - 1
|
||||||
|
|
@ -565,6 +565,7 @@ func (q *Queries) GetPreviousProgram(ctx context.Context, id int64) (Program, er
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.SortOrder,
|
&i.SortOrder,
|
||||||
|
&i.Category,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ type ExamPrepCatalogCourse struct {
|
||||||
SortOrder int32 `json:"sort_order"`
|
SortOrder int32 `json:"sort_order"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
Category string `json:"category"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExamPrepLessonPractice struct {
|
type ExamPrepLessonPractice struct {
|
||||||
|
|
@ -309,6 +310,7 @@ type Program struct {
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
SortOrder int32 `json:"sort_order"`
|
SortOrder int32 `json:"sort_order"`
|
||||||
|
Category string `json:"category"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Question struct {
|
type Question struct {
|
||||||
|
|
@ -482,6 +484,7 @@ type SubscriptionPlan struct {
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
Category string `json:"category"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TeamInvitation struct {
|
type TeamInvitation struct {
|
||||||
|
|
|
||||||
|
|
@ -12,22 +12,24 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const CreateProgram = `-- name: CreateProgram :one
|
const CreateProgram = `-- name: CreateProgram :one
|
||||||
INSERT INTO programs (name, description, thumbnail, sort_order)
|
INSERT INTO programs (name, description, category, thumbnail, sort_order)
|
||||||
SELECT
|
SELECT
|
||||||
$1,
|
$1,
|
||||||
$2,
|
$2,
|
||||||
$3,
|
$3,
|
||||||
COALESCE($4::int, COALESCE((
|
$4,
|
||||||
|
COALESCE($5::int, COALESCE((
|
||||||
SELECT
|
SELECT
|
||||||
max(p.sort_order)
|
max(p.sort_order)
|
||||||
FROM programs AS p), 0) + 1)
|
FROM programs AS p), 0) + 1)
|
||||||
RETURNING
|
RETURNING
|
||||||
id, name, description, thumbnail, created_at, updated_at, sort_order
|
id, name, description, thumbnail, created_at, updated_at, sort_order, category
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateProgramParams struct {
|
type CreateProgramParams struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description pgtype.Text `json:"description"`
|
Description pgtype.Text `json:"description"`
|
||||||
|
Category string `json:"category"`
|
||||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||||
SortOrder pgtype.Int4 `json:"sort_order"`
|
SortOrder pgtype.Int4 `json:"sort_order"`
|
||||||
}
|
}
|
||||||
|
|
@ -36,6 +38,7 @@ func (q *Queries) CreateProgram(ctx context.Context, arg CreateProgramParams) (P
|
||||||
row := q.db.QueryRow(ctx, CreateProgram,
|
row := q.db.QueryRow(ctx, CreateProgram,
|
||||||
arg.Name,
|
arg.Name,
|
||||||
arg.Description,
|
arg.Description,
|
||||||
|
arg.Category,
|
||||||
arg.Thumbnail,
|
arg.Thumbnail,
|
||||||
arg.SortOrder,
|
arg.SortOrder,
|
||||||
)
|
)
|
||||||
|
|
@ -48,6 +51,7 @@ func (q *Queries) CreateProgram(ctx context.Context, arg CreateProgramParams) (P
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.SortOrder,
|
&i.SortOrder,
|
||||||
|
&i.Category,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -63,7 +67,7 @@ func (q *Queries) DeleteProgram(ctx context.Context, id int64) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
const GetProgramByID = `-- name: GetProgramByID :one
|
const GetProgramByID = `-- name: GetProgramByID :one
|
||||||
SELECT id, name, description, thumbnail, created_at, updated_at, sort_order
|
SELECT id, name, description, thumbnail, created_at, updated_at, sort_order, category
|
||||||
FROM programs
|
FROM programs
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
@ -79,6 +83,7 @@ func (q *Queries) GetProgramByID(ctx context.Context, id int64) (Program, error)
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.SortOrder,
|
&i.SortOrder,
|
||||||
|
&i.Category,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -118,6 +123,7 @@ SELECT
|
||||||
p.id,
|
p.id,
|
||||||
p.name,
|
p.name,
|
||||||
p.description,
|
p.description,
|
||||||
|
p.category,
|
||||||
p.thumbnail,
|
p.thumbnail,
|
||||||
p.sort_order,
|
p.sort_order,
|
||||||
p.created_at,
|
p.created_at,
|
||||||
|
|
@ -137,6 +143,7 @@ type ListProgramsRow struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description pgtype.Text `json:"description"`
|
Description pgtype.Text `json:"description"`
|
||||||
|
Category string `json:"category"`
|
||||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||||
SortOrder int32 `json:"sort_order"`
|
SortOrder int32 `json:"sort_order"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
|
@ -157,6 +164,7 @@ func (q *Queries) ListPrograms(ctx context.Context, arg ListProgramsParams) ([]L
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.Name,
|
&i.Name,
|
||||||
&i.Description,
|
&i.Description,
|
||||||
|
&i.Category,
|
||||||
&i.Thumbnail,
|
&i.Thumbnail,
|
||||||
&i.SortOrder,
|
&i.SortOrder,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
|
|
@ -177,18 +185,20 @@ UPDATE programs
|
||||||
SET
|
SET
|
||||||
name = COALESCE($1::varchar, name),
|
name = COALESCE($1::varchar, name),
|
||||||
description = COALESCE($2::text, description),
|
description = COALESCE($2::text, description),
|
||||||
thumbnail = COALESCE($3::text, thumbnail),
|
category = COALESCE($3::varchar, category),
|
||||||
sort_order = coalesce($4::int, sort_order),
|
thumbnail = COALESCE($4::text, thumbnail),
|
||||||
|
sort_order = coalesce($5::int, sort_order),
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE
|
WHERE
|
||||||
id = $5
|
id = $6
|
||||||
RETURNING
|
RETURNING
|
||||||
id, name, description, thumbnail, created_at, updated_at, sort_order
|
id, name, description, thumbnail, created_at, updated_at, sort_order, category
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateProgramParams struct {
|
type UpdateProgramParams struct {
|
||||||
Name pgtype.Text `json:"name"`
|
Name pgtype.Text `json:"name"`
|
||||||
Description pgtype.Text `json:"description"`
|
Description pgtype.Text `json:"description"`
|
||||||
|
Category pgtype.Text `json:"category"`
|
||||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||||
SortOrder pgtype.Int4 `json:"sort_order"`
|
SortOrder pgtype.Int4 `json:"sort_order"`
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
|
|
@ -198,6 +208,7 @@ func (q *Queries) UpdateProgram(ctx context.Context, arg UpdateProgramParams) (P
|
||||||
row := q.db.QueryRow(ctx, UpdateProgram,
|
row := q.db.QueryRow(ctx, UpdateProgram,
|
||||||
arg.Name,
|
arg.Name,
|
||||||
arg.Description,
|
arg.Description,
|
||||||
|
arg.Category,
|
||||||
arg.Thumbnail,
|
arg.Thumbnail,
|
||||||
arg.SortOrder,
|
arg.SortOrder,
|
||||||
arg.ID,
|
arg.ID,
|
||||||
|
|
@ -211,6 +222,7 @@ func (q *Queries) UpdateProgram(ctx context.Context, arg UpdateProgramParams) (P
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.SortOrder,
|
&i.SortOrder,
|
||||||
|
&i.Category,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,20 +40,21 @@ func (q *Queries) CountUserSubscriptions(ctx context.Context, userID int64) (int
|
||||||
const CreateSubscriptionPlan = `-- name: CreateSubscriptionPlan :one
|
const CreateSubscriptionPlan = `-- name: CreateSubscriptionPlan :one
|
||||||
|
|
||||||
INSERT INTO subscription_plans (
|
INSERT INTO subscription_plans (
|
||||||
name, description, duration_value, duration_unit, price, currency, is_active
|
name, description, category, duration_value, duration_unit, price, currency, is_active
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, true))
|
VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE($8, true))
|
||||||
RETURNING id, name, description, duration_value, duration_unit, price, currency, is_active, created_at, updated_at
|
RETURNING id, name, description, duration_value, duration_unit, price, currency, is_active, created_at, updated_at, category
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateSubscriptionPlanParams struct {
|
type CreateSubscriptionPlanParams struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description pgtype.Text `json:"description"`
|
Description pgtype.Text `json:"description"`
|
||||||
|
Category string `json:"category"`
|
||||||
DurationValue int32 `json:"duration_value"`
|
DurationValue int32 `json:"duration_value"`
|
||||||
DurationUnit string `json:"duration_unit"`
|
DurationUnit string `json:"duration_unit"`
|
||||||
Price pgtype.Numeric `json:"price"`
|
Price pgtype.Numeric `json:"price"`
|
||||||
Currency string `json:"currency"`
|
Currency string `json:"currency"`
|
||||||
Column7 interface{} `json:"column_7"`
|
Column8 interface{} `json:"column_8"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================
|
// =====================
|
||||||
|
|
@ -63,11 +64,12 @@ func (q *Queries) CreateSubscriptionPlan(ctx context.Context, arg CreateSubscrip
|
||||||
row := q.db.QueryRow(ctx, CreateSubscriptionPlan,
|
row := q.db.QueryRow(ctx, CreateSubscriptionPlan,
|
||||||
arg.Name,
|
arg.Name,
|
||||||
arg.Description,
|
arg.Description,
|
||||||
|
arg.Category,
|
||||||
arg.DurationValue,
|
arg.DurationValue,
|
||||||
arg.DurationUnit,
|
arg.DurationUnit,
|
||||||
arg.Price,
|
arg.Price,
|
||||||
arg.Currency,
|
arg.Currency,
|
||||||
arg.Column7,
|
arg.Column8,
|
||||||
)
|
)
|
||||||
var i SubscriptionPlan
|
var i SubscriptionPlan
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
|
|
@ -81,6 +83,7 @@ func (q *Queries) CreateSubscriptionPlan(ctx context.Context, arg CreateSubscrip
|
||||||
&i.IsActive,
|
&i.IsActive,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.Category,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -387,7 +390,7 @@ func (q *Queries) GetSubscriptionDisplayStatusByUserID(ctx context.Context, user
|
||||||
}
|
}
|
||||||
|
|
||||||
const GetSubscriptionPlanByID = `-- name: GetSubscriptionPlanByID :one
|
const GetSubscriptionPlanByID = `-- name: GetSubscriptionPlanByID :one
|
||||||
SELECT id, name, description, duration_value, duration_unit, price, currency, is_active, created_at, updated_at FROM subscription_plans WHERE id = $1
|
SELECT id, name, description, duration_value, duration_unit, price, currency, is_active, created_at, updated_at, category FROM subscription_plans WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetSubscriptionPlanByID(ctx context.Context, id int64) (SubscriptionPlan, error) {
|
func (q *Queries) GetSubscriptionPlanByID(ctx context.Context, id int64) (SubscriptionPlan, error) {
|
||||||
|
|
@ -404,6 +407,7 @@ func (q *Queries) GetSubscriptionPlanByID(ctx context.Context, id int64) (Subscr
|
||||||
&i.IsActive,
|
&i.IsActive,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.Category,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -562,8 +566,32 @@ func (q *Queries) HasActiveSubscription(ctx context.Context, userID int64) (bool
|
||||||
return has_subscription, err
|
return has_subscription, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HasActiveSubscriptionByCategory = `-- name: HasActiveSubscriptionByCategory :one
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1
|
||||||
|
FROM user_subscriptions us
|
||||||
|
JOIN subscription_plans sp ON sp.id = us.plan_id
|
||||||
|
WHERE us.user_id = $1
|
||||||
|
AND sp.category = $2
|
||||||
|
AND us.status = 'ACTIVE'
|
||||||
|
AND us.expires_at > CURRENT_TIMESTAMP
|
||||||
|
) AS has_subscription
|
||||||
|
`
|
||||||
|
|
||||||
|
type HasActiveSubscriptionByCategoryParams struct {
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) HasActiveSubscriptionByCategory(ctx context.Context, arg HasActiveSubscriptionByCategoryParams) (bool, error) {
|
||||||
|
row := q.db.QueryRow(ctx, HasActiveSubscriptionByCategory, arg.UserID, arg.Category)
|
||||||
|
var has_subscription bool
|
||||||
|
err := row.Scan(&has_subscription)
|
||||||
|
return has_subscription, err
|
||||||
|
}
|
||||||
|
|
||||||
const ListActiveSubscriptionPlans = `-- name: ListActiveSubscriptionPlans :many
|
const ListActiveSubscriptionPlans = `-- name: ListActiveSubscriptionPlans :many
|
||||||
SELECT id, name, description, duration_value, duration_unit, price, currency, is_active, created_at, updated_at FROM subscription_plans
|
SELECT id, name, description, duration_value, duration_unit, price, currency, is_active, created_at, updated_at, category FROM subscription_plans
|
||||||
WHERE is_active = true
|
WHERE is_active = true
|
||||||
ORDER BY price ASC
|
ORDER BY price ASC
|
||||||
`
|
`
|
||||||
|
|
@ -588,6 +616,7 @@ func (q *Queries) ListActiveSubscriptionPlans(ctx context.Context) ([]Subscripti
|
||||||
&i.IsActive,
|
&i.IsActive,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.Category,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -646,7 +675,7 @@ func (q *Queries) ListSubscriptionDisplayStatusesByUserIDs(ctx context.Context,
|
||||||
}
|
}
|
||||||
|
|
||||||
const ListSubscriptionPlans = `-- name: ListSubscriptionPlans :many
|
const ListSubscriptionPlans = `-- name: ListSubscriptionPlans :many
|
||||||
SELECT id, name, description, duration_value, duration_unit, price, currency, is_active, created_at, updated_at FROM subscription_plans
|
SELECT id, name, description, duration_value, duration_unit, price, currency, is_active, created_at, updated_at, category FROM subscription_plans
|
||||||
WHERE ($1::BOOLEAN IS NULL OR $1 = true AND is_active = true OR $1 = false)
|
WHERE ($1::BOOLEAN IS NULL OR $1 = true AND is_active = true OR $1 = false)
|
||||||
ORDER BY price ASC
|
ORDER BY price ASC
|
||||||
`
|
`
|
||||||
|
|
@ -671,6 +700,7 @@ func (q *Queries) ListSubscriptionPlans(ctx context.Context, dollar_1 bool) ([]S
|
||||||
&i.IsActive,
|
&i.IsActive,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.Category,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -703,25 +733,27 @@ func (q *Queries) UpdateAutoRenew(ctx context.Context, arg UpdateAutoRenewParams
|
||||||
const UpdateSubscriptionPlan = `-- name: UpdateSubscriptionPlan :exec
|
const UpdateSubscriptionPlan = `-- name: UpdateSubscriptionPlan :exec
|
||||||
UPDATE subscription_plans
|
UPDATE subscription_plans
|
||||||
SET
|
SET
|
||||||
name = COALESCE($1, name),
|
name = COALESCE($1::varchar, name),
|
||||||
description = COALESCE($2, description),
|
description = COALESCE($2::text, description),
|
||||||
duration_value = COALESCE($3, duration_value),
|
category = COALESCE($3::varchar, category),
|
||||||
duration_unit = COALESCE($4, duration_unit),
|
duration_value = COALESCE($4::int, duration_value),
|
||||||
price = COALESCE($5, price),
|
duration_unit = COALESCE($5::varchar, duration_unit),
|
||||||
currency = COALESCE($6, currency),
|
price = COALESCE($6::numeric, price),
|
||||||
is_active = COALESCE($7, is_active),
|
currency = COALESCE($7::varchar, currency),
|
||||||
|
is_active = COALESCE($8::boolean, is_active),
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $8
|
WHERE id = $9
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateSubscriptionPlanParams struct {
|
type UpdateSubscriptionPlanParams struct {
|
||||||
Name string `json:"name"`
|
Name pgtype.Text `json:"name"`
|
||||||
Description pgtype.Text `json:"description"`
|
Description pgtype.Text `json:"description"`
|
||||||
DurationValue int32 `json:"duration_value"`
|
Category pgtype.Text `json:"category"`
|
||||||
DurationUnit string `json:"duration_unit"`
|
DurationValue pgtype.Int4 `json:"duration_value"`
|
||||||
|
DurationUnit pgtype.Text `json:"duration_unit"`
|
||||||
Price pgtype.Numeric `json:"price"`
|
Price pgtype.Numeric `json:"price"`
|
||||||
Currency string `json:"currency"`
|
Currency pgtype.Text `json:"currency"`
|
||||||
IsActive bool `json:"is_active"`
|
IsActive pgtype.Bool `json:"is_active"`
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -729,6 +761,7 @@ func (q *Queries) UpdateSubscriptionPlan(ctx context.Context, arg UpdateSubscrip
|
||||||
_, err := q.db.Exec(ctx, UpdateSubscriptionPlan,
|
_, err := q.db.Exec(ctx, UpdateSubscriptionPlan,
|
||||||
arg.Name,
|
arg.Name,
|
||||||
arg.Description,
|
arg.Description,
|
||||||
|
arg.Category,
|
||||||
arg.DurationValue,
|
arg.DurationValue,
|
||||||
arg.DurationUnit,
|
arg.DurationUnit,
|
||||||
arg.Price,
|
arg.Price,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ type ExamPrepCatalogCourse struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
|
Category string `json:"category"`
|
||||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||||
SortOrder int `json:"sort_order"`
|
SortOrder int `json:"sort_order"`
|
||||||
UnitsCount *int64 `json:"units_count,omitempty"`
|
UnitsCount *int64 `json:"units_count,omitempty"`
|
||||||
|
|
@ -20,12 +21,14 @@ type ExamPrepCatalogCourse struct {
|
||||||
type CreateExamPrepCatalogCourseInput struct {
|
type CreateExamPrepCatalogCourseInput struct {
|
||||||
Name string `json:"name" validate:"required"`
|
Name string `json:"name" validate:"required"`
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
|
Category string `json:"category" validate:"required,oneof=IELTS DUOLINGO"`
|
||||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateExamPrepCatalogCourseInput struct {
|
type UpdateExamPrepCatalogCourseInput struct {
|
||||||
Name *string `json:"name,omitempty"`
|
Name *string `json:"name,omitempty"`
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
|
Category *string `json:"category,omitempty" validate:"omitempty,oneof=IELTS DUOLINGO"`
|
||||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||||
SortOrder *int `json:"sort_order,omitempty"`
|
SortOrder *int `json:"sort_order,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ type Program struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
|
Category string `json:"category"`
|
||||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||||
SortOrder int `json:"sort_order"`
|
SortOrder int `json:"sort_order"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|
@ -17,6 +18,7 @@ type Program struct {
|
||||||
type CreateProgramInput struct {
|
type CreateProgramInput struct {
|
||||||
Name string `json:"name" validate:"required"`
|
Name string `json:"name" validate:"required"`
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
|
Category string `json:"category" validate:"required,oneof=LEARN_ENGLISH IELTS DUOLINGO"`
|
||||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||||
// SortOrder inserts at this global program order when set; omit to append after current max (sort_order uniqueness is enforced).
|
// SortOrder inserts at this global program order when set; omit to append after current max (sort_order uniqueness is enforced).
|
||||||
SortOrder *int `json:"sort_order,omitempty" validate:"omitempty,min=0"`
|
SortOrder *int `json:"sort_order,omitempty" validate:"omitempty,min=0"`
|
||||||
|
|
@ -25,6 +27,7 @@ type CreateProgramInput struct {
|
||||||
type UpdateProgramInput struct {
|
type UpdateProgramInput struct {
|
||||||
Name *string `json:"name,omitempty"`
|
Name *string `json:"name,omitempty"`
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
|
Category *string `json:"category,omitempty" validate:"omitempty,oneof=LEARN_ENGLISH IELTS DUOLINGO"`
|
||||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||||
SortOrder *int `json:"sort_order,omitempty"`
|
SortOrder *int `json:"sort_order,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,14 @@ import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type SubscriptionCategory string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SubscriptionCategoryLearnEnglish SubscriptionCategory = "LEARN_ENGLISH"
|
||||||
|
SubscriptionCategoryIELTS SubscriptionCategory = "IELTS"
|
||||||
|
SubscriptionCategoryDuolingo SubscriptionCategory = "DUOLINGO"
|
||||||
|
)
|
||||||
|
|
||||||
type DurationUnit string
|
type DurationUnit string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -26,6 +34,7 @@ type SubscriptionPlan struct {
|
||||||
ID int64
|
ID int64
|
||||||
Name string
|
Name string
|
||||||
Description *string
|
Description *string
|
||||||
|
Category string
|
||||||
DurationValue int32
|
DurationValue int32
|
||||||
DurationUnit string
|
DurationUnit string
|
||||||
Price float64
|
Price float64
|
||||||
|
|
@ -59,6 +68,7 @@ type UserSubscription struct {
|
||||||
type CreateSubscriptionPlanInput struct {
|
type CreateSubscriptionPlanInput struct {
|
||||||
Name string
|
Name string
|
||||||
Description *string
|
Description *string
|
||||||
|
Category string
|
||||||
DurationValue int32
|
DurationValue int32
|
||||||
DurationUnit string
|
DurationUnit string
|
||||||
Price float64
|
Price float64
|
||||||
|
|
@ -69,6 +79,7 @@ type CreateSubscriptionPlanInput struct {
|
||||||
type UpdateSubscriptionPlanInput struct {
|
type UpdateSubscriptionPlanInput struct {
|
||||||
Name *string
|
Name *string
|
||||||
Description *string
|
Description *string
|
||||||
|
Category *string
|
||||||
DurationValue *int32
|
DurationValue *int32
|
||||||
DurationUnit *string
|
DurationUnit *string
|
||||||
Price *float64
|
Price *float64
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ type SubscriptionStore interface {
|
||||||
GetSubscriptionDisplayStatusByUserID(ctx context.Context, userID int64) (string, error)
|
GetSubscriptionDisplayStatusByUserID(ctx context.Context, userID int64) (string, error)
|
||||||
GetUserSubscriptionHistory(ctx context.Context, userID int64, limit, offset int32) ([]domain.UserSubscription, error)
|
GetUserSubscriptionHistory(ctx context.Context, userID int64, limit, offset int32) ([]domain.UserSubscription, error)
|
||||||
HasActiveSubscription(ctx context.Context, userID int64) (bool, error)
|
HasActiveSubscription(ctx context.Context, userID int64) (bool, error)
|
||||||
|
HasActiveSubscriptionByCategory(ctx context.Context, userID int64, category string) (bool, error)
|
||||||
CancelUserSubscription(ctx context.Context, id int64) error
|
CancelUserSubscription(ctx context.Context, id int64) error
|
||||||
UpdateSubscriptionStatus(ctx context.Context, id int64, status string) error
|
UpdateSubscriptionStatus(ctx context.Context, id int64, status string) error
|
||||||
UpdateAutoRenew(ctx context.Context, id int64, autoRenew bool) error
|
UpdateAutoRenew(ctx context.Context, id int64, autoRenew bool) error
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ func examPrepCatalogCourseToDomain(c dbgen.ExamPrepCatalogCourse) domain.ExamPre
|
||||||
out := domain.ExamPrepCatalogCourse{
|
out := domain.ExamPrepCatalogCourse{
|
||||||
ID: c.ID,
|
ID: c.ID,
|
||||||
Name: c.Name,
|
Name: c.Name,
|
||||||
|
Category: c.Category,
|
||||||
SortOrder: int(c.SortOrder),
|
SortOrder: int(c.SortOrder),
|
||||||
}
|
}
|
||||||
out.Description = fromPgText(c.Description)
|
out.Description = fromPgText(c.Description)
|
||||||
|
|
@ -31,6 +32,7 @@ func (s *Store) CreateExamPrepCatalogCourse(ctx context.Context, input domain.Cr
|
||||||
c, err := s.queries.ExamPrepCreateCatalogCourse(ctx, dbgen.ExamPrepCreateCatalogCourseParams{
|
c, err := s.queries.ExamPrepCreateCatalogCourse(ctx, dbgen.ExamPrepCreateCatalogCourseParams{
|
||||||
Name: input.Name,
|
Name: input.Name,
|
||||||
Description: toPgText(input.Description),
|
Description: toPgText(input.Description),
|
||||||
|
Category: input.Category,
|
||||||
Thumbnail: toPgText(input.Thumbnail),
|
Thumbnail: toPgText(input.Thumbnail),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -51,6 +53,7 @@ func (s *Store) GetExamPrepCatalogCourseByID(ctx context.Context, id int64) (dom
|
||||||
ID: c.ID,
|
ID: c.ID,
|
||||||
Name: c.Name,
|
Name: c.Name,
|
||||||
Description: c.Description,
|
Description: c.Description,
|
||||||
|
Category: c.Category,
|
||||||
Thumbnail: c.Thumbnail,
|
Thumbnail: c.Thumbnail,
|
||||||
SortOrder: c.SortOrder,
|
SortOrder: c.SortOrder,
|
||||||
CreatedAt: c.CreatedAt,
|
CreatedAt: c.CreatedAt,
|
||||||
|
|
@ -81,6 +84,7 @@ func (s *Store) ListExamPrepCatalogCourses(ctx context.Context, limit, offset in
|
||||||
ID: r.ID,
|
ID: r.ID,
|
||||||
Name: r.Name,
|
Name: r.Name,
|
||||||
Description: r.Description,
|
Description: r.Description,
|
||||||
|
Category: r.Category,
|
||||||
Thumbnail: r.Thumbnail,
|
Thumbnail: r.Thumbnail,
|
||||||
SortOrder: r.SortOrder,
|
SortOrder: r.SortOrder,
|
||||||
CreatedAt: r.CreatedAt,
|
CreatedAt: r.CreatedAt,
|
||||||
|
|
@ -110,6 +114,7 @@ func (s *Store) UpdateExamPrepCatalogCourse(ctx context.Context, id int64, input
|
||||||
ID: id,
|
ID: id,
|
||||||
Name: nameText,
|
Name: nameText,
|
||||||
Description: optionalTextUpdate(input.Description),
|
Description: optionalTextUpdate(input.Description),
|
||||||
|
Category: optionalTextUpdate(input.Category),
|
||||||
Thumbnail: optionalTextUpdate(input.Thumbnail),
|
Thumbnail: optionalTextUpdate(input.Thumbnail),
|
||||||
SortOrder: optionalInt4Update(input.SortOrder),
|
SortOrder: optionalInt4Update(input.SortOrder),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,9 @@ import (
|
||||||
|
|
||||||
func programToDomain(p dbgen.Program) domain.Program {
|
func programToDomain(p dbgen.Program) domain.Program {
|
||||||
out := domain.Program{
|
out := domain.Program{
|
||||||
ID: p.ID,
|
ID: p.ID,
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
|
Category: p.Category,
|
||||||
}
|
}
|
||||||
out.Description = fromPgText(p.Description)
|
out.Description = fromPgText(p.Description)
|
||||||
out.Thumbnail = fromPgText(p.Thumbnail)
|
out.Thumbnail = fromPgText(p.Thumbnail)
|
||||||
|
|
@ -42,6 +43,7 @@ func (s *Store) CreateProgram(ctx context.Context, input domain.CreateProgramInp
|
||||||
p, err := q.CreateProgram(ctx, dbgen.CreateProgramParams{
|
p, err := q.CreateProgram(ctx, dbgen.CreateProgramParams{
|
||||||
Name: input.Name,
|
Name: input.Name,
|
||||||
Description: toPgText(input.Description),
|
Description: toPgText(input.Description),
|
||||||
|
Category: input.Category,
|
||||||
Thumbnail: toPgText(input.Thumbnail),
|
Thumbnail: toPgText(input.Thumbnail),
|
||||||
SortOrder: pgtype.Int4{Int32: target, Valid: true},
|
SortOrder: pgtype.Int4{Int32: target, Valid: true},
|
||||||
})
|
})
|
||||||
|
|
@ -57,6 +59,7 @@ func (s *Store) CreateProgram(ctx context.Context, input domain.CreateProgramInp
|
||||||
p, err := s.queries.CreateProgram(ctx, dbgen.CreateProgramParams{
|
p, err := s.queries.CreateProgram(ctx, dbgen.CreateProgramParams{
|
||||||
Name: input.Name,
|
Name: input.Name,
|
||||||
Description: toPgText(input.Description),
|
Description: toPgText(input.Description),
|
||||||
|
Category: input.Category,
|
||||||
Thumbnail: toPgText(input.Thumbnail),
|
Thumbnail: toPgText(input.Thumbnail),
|
||||||
SortOrder: pgtype.Int4{Valid: false},
|
SortOrder: pgtype.Int4{Valid: false},
|
||||||
})
|
})
|
||||||
|
|
@ -102,6 +105,7 @@ func (s *Store) ListPrograms(ctx context.Context, limit, offset int32) ([]domain
|
||||||
ID: r.ID,
|
ID: r.ID,
|
||||||
Name: r.Name,
|
Name: r.Name,
|
||||||
Description: r.Description,
|
Description: r.Description,
|
||||||
|
Category: r.Category,
|
||||||
Thumbnail: r.Thumbnail,
|
Thumbnail: r.Thumbnail,
|
||||||
CreatedAt: r.CreatedAt,
|
CreatedAt: r.CreatedAt,
|
||||||
UpdatedAt: r.UpdatedAt,
|
UpdatedAt: r.UpdatedAt,
|
||||||
|
|
@ -166,6 +170,7 @@ func (s *Store) UpdateProgram(ctx context.Context, id int64, input domain.Update
|
||||||
ID: id,
|
ID: id,
|
||||||
Name: nameText,
|
Name: nameText,
|
||||||
Description: optionalTextUpdate(input.Description),
|
Description: optionalTextUpdate(input.Description),
|
||||||
|
Category: optionalTextUpdate(input.Category),
|
||||||
Thumbnail: optionalTextUpdate(input.Thumbnail),
|
Thumbnail: optionalTextUpdate(input.Thumbnail),
|
||||||
SortOrder: pgtype.Int4{Valid: false},
|
SortOrder: pgtype.Int4{Valid: false},
|
||||||
})
|
})
|
||||||
|
|
@ -190,6 +195,7 @@ func (s *Store) UpdateProgram(ctx context.Context, id int64, input domain.Update
|
||||||
ID: id,
|
ID: id,
|
||||||
Name: nameText,
|
Name: nameText,
|
||||||
Description: optionalTextUpdate(input.Description),
|
Description: optionalTextUpdate(input.Description),
|
||||||
|
Category: optionalTextUpdate(input.Category),
|
||||||
Thumbnail: optionalTextUpdate(input.Thumbnail),
|
Thumbnail: optionalTextUpdate(input.Thumbnail),
|
||||||
SortOrder: sortParam,
|
SortOrder: sortParam,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -45,11 +45,12 @@ func (s *Store) CreateSubscriptionPlan(ctx context.Context, input domain.CreateS
|
||||||
plan, err := s.queries.CreateSubscriptionPlan(ctx, dbgen.CreateSubscriptionPlanParams{
|
plan, err := s.queries.CreateSubscriptionPlan(ctx, dbgen.CreateSubscriptionPlanParams{
|
||||||
Name: input.Name,
|
Name: input.Name,
|
||||||
Description: toPgText(input.Description),
|
Description: toPgText(input.Description),
|
||||||
|
Category: input.Category,
|
||||||
DurationValue: input.DurationValue,
|
DurationValue: input.DurationValue,
|
||||||
DurationUnit: input.DurationUnit,
|
DurationUnit: input.DurationUnit,
|
||||||
Price: toPgNumeric(input.Price),
|
Price: toPgNumeric(input.Price),
|
||||||
Currency: input.Currency,
|
Currency: input.Currency,
|
||||||
Column7: input.IsActive,
|
Column8: input.IsActive,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -87,13 +88,14 @@ func (s *Store) ListSubscriptionPlans(ctx context.Context, activeOnly bool) ([]d
|
||||||
|
|
||||||
func (s *Store) UpdateSubscriptionPlan(ctx context.Context, id int64, input domain.UpdateSubscriptionPlanInput) error {
|
func (s *Store) UpdateSubscriptionPlan(ctx context.Context, id int64, input domain.UpdateSubscriptionPlanInput) error {
|
||||||
return s.queries.UpdateSubscriptionPlan(ctx, dbgen.UpdateSubscriptionPlanParams{
|
return s.queries.UpdateSubscriptionPlan(ctx, dbgen.UpdateSubscriptionPlanParams{
|
||||||
Name: stringVal(input.Name),
|
Name: optionalTextUpdate(input.Name),
|
||||||
Description: toPgText(input.Description),
|
Description: optionalTextUpdate(input.Description),
|
||||||
DurationValue: int32Val(input.DurationValue),
|
Category: optionalTextUpdate(input.Category),
|
||||||
DurationUnit: stringVal(input.DurationUnit),
|
DurationValue: optionalInt4(input.DurationValue),
|
||||||
|
DurationUnit: optionalTextUpdate(input.DurationUnit),
|
||||||
Price: numericPtrToNumeric(input.Price),
|
Price: numericPtrToNumeric(input.Price),
|
||||||
Currency: stringVal(input.Currency),
|
Currency: optionalTextUpdate(input.Currency),
|
||||||
IsActive: boolPtrToBool(input.IsActive),
|
IsActive: optionalBool(input.IsActive),
|
||||||
ID: id,
|
ID: id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -215,6 +217,13 @@ func (s *Store) HasActiveSubscription(ctx context.Context, userID int64) (bool,
|
||||||
return s.queries.HasActiveSubscription(ctx, userID)
|
return s.queries.HasActiveSubscription(ctx, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) HasActiveSubscriptionByCategory(ctx context.Context, userID int64, category string) (bool, error) {
|
||||||
|
return s.queries.HasActiveSubscriptionByCategory(ctx, dbgen.HasActiveSubscriptionByCategoryParams{
|
||||||
|
UserID: userID,
|
||||||
|
Category: category,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) CancelUserSubscription(ctx context.Context, id int64) error {
|
func (s *Store) CancelUserSubscription(ctx context.Context, id int64) error {
|
||||||
return s.queries.CancelUserSubscription(ctx, id)
|
return s.queries.CancelUserSubscription(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
@ -247,6 +256,7 @@ func subscriptionPlanToDomain(p dbgen.SubscriptionPlan) *domain.SubscriptionPlan
|
||||||
ID: p.ID,
|
ID: p.ID,
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
Description: fromPgText(p.Description),
|
Description: fromPgText(p.Description),
|
||||||
|
Category: p.Category,
|
||||||
DurationValue: p.DurationValue,
|
DurationValue: p.DurationValue,
|
||||||
DurationUnit: p.DurationUnit,
|
DurationUnit: p.DurationUnit,
|
||||||
Price: fromPgNumeric(p.Price),
|
Price: fromPgNumeric(p.Price),
|
||||||
|
|
@ -296,18 +306,11 @@ func userSubscriptionWithPlanToDomain(s dbgen.GetUserSubscriptionByIDRow) *domai
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stringVal(s *string) string {
|
func optionalInt4(v *int32) pgtype.Int4 {
|
||||||
if s == nil {
|
if v == nil {
|
||||||
return ""
|
return pgtype.Int4{Valid: false}
|
||||||
}
|
}
|
||||||
return *s
|
return pgtype.Int4{Int32: *v, Valid: true}
|
||||||
}
|
|
||||||
|
|
||||||
func int32Val(i *int32) int32 {
|
|
||||||
if i == nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return *i
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func numericPtrToNumeric(val *float64) pgtype.Numeric {
|
func numericPtrToNumeric(val *float64) pgtype.Numeric {
|
||||||
|
|
@ -317,11 +320,11 @@ func numericPtrToNumeric(val *float64) pgtype.Numeric {
|
||||||
return toPgNumeric(*val)
|
return toPgNumeric(*val)
|
||||||
}
|
}
|
||||||
|
|
||||||
func boolPtrToBool(b *bool) bool {
|
func optionalBool(b *bool) pgtype.Bool {
|
||||||
if b == nil {
|
if b == nil {
|
||||||
return false
|
return pgtype.Bool{Valid: false}
|
||||||
}
|
}
|
||||||
return *b
|
return pgtype.Bool{Bool: *b, Valid: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
func float64Ptr(f float64) *float64 {
|
func float64Ptr(f float64) *float64 {
|
||||||
|
|
|
||||||
|
|
@ -59,12 +59,12 @@ func (s *ArifpayService) InitiateSubscriptionPayment(ctx context.Context, userID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user already has an active subscription
|
// Check if user already has an active subscription
|
||||||
hasActive, err := s.subscriptionStore.HasActiveSubscription(ctx, userID)
|
hasActive, err := s.subscriptionStore.HasActiveSubscriptionByCategory(ctx, userID, plan.Category)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to check active subscription: %w", err)
|
return nil, fmt.Errorf("failed to check active subscription: %w", err)
|
||||||
}
|
}
|
||||||
if hasActive {
|
if hasActive {
|
||||||
return nil, errors.New("user already has an active subscription")
|
return nil, errors.New("user already has an active subscription for this category")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate unique nonce
|
// Generate unique nonce
|
||||||
|
|
@ -573,12 +573,12 @@ func (s *ArifpayService) InitiateDirectPayment(ctx context.Context, userID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user already has an active subscription
|
// Check if user already has an active subscription
|
||||||
hasActive, err := s.subscriptionStore.HasActiveSubscription(ctx, userID)
|
hasActive, err := s.subscriptionStore.HasActiveSubscriptionByCategory(ctx, userID, plan.Category)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to check active subscription: %w", err)
|
return nil, fmt.Errorf("failed to check active subscription: %w", err)
|
||||||
}
|
}
|
||||||
if hasActive {
|
if hasActive {
|
||||||
return nil, errors.New("user already has an active subscription")
|
return nil, errors.New("user already has an active subscription for this category")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate unique nonce
|
// Generate unique nonce
|
||||||
|
|
|
||||||
|
|
@ -76,12 +76,12 @@ func (s *Service) InitiateSubscriptionPayment(ctx context.Context, userID int64,
|
||||||
return nil, errors.New("subscription plan is not active")
|
return nil, errors.New("subscription plan is not active")
|
||||||
}
|
}
|
||||||
|
|
||||||
hasActive, err := s.subscriptionStore.HasActiveSubscription(ctx, userID)
|
hasActive, err := s.subscriptionStore.HasActiveSubscriptionByCategory(ctx, userID, plan.Category)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to check active subscription: %w", err)
|
return nil, fmt.Errorf("failed to check active subscription: %w", err)
|
||||||
}
|
}
|
||||||
if hasActive {
|
if hasActive {
|
||||||
return nil, errors.New("user already has an active subscription")
|
return nil, errors.New("user already has an active subscription for this category")
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := s.userStore.GetUserByID(ctx, userID)
|
user, err := s.userStore.GetUserByID(ctx, userID)
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ var (
|
||||||
ErrPlanNotFound = errors.New("subscription plan not found")
|
ErrPlanNotFound = errors.New("subscription plan not found")
|
||||||
ErrSubscriptionNotFound = errors.New("subscription not found")
|
ErrSubscriptionNotFound = errors.New("subscription not found")
|
||||||
ErrSubscriptionNotOwned = errors.New("subscription does not belong to this user")
|
ErrSubscriptionNotOwned = errors.New("subscription does not belong to this user")
|
||||||
ErrAlreadySubscribed = errors.New("user already has an active subscription")
|
ErrAlreadySubscribed = errors.New("user already has an active subscription for this category")
|
||||||
ErrInvalidPlan = errors.New("invalid subscription plan")
|
ErrInvalidPlan = errors.New("invalid subscription plan")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -56,15 +56,6 @@ func (s *Service) DeletePlan(ctx context.Context, id int64) error {
|
||||||
|
|
||||||
// Subscribe creates a new subscription for a user
|
// Subscribe creates a new subscription for a user
|
||||||
func (s *Service) Subscribe(ctx context.Context, userID, planID int64, paymentRef, paymentMethod *string) (*domain.UserSubscription, error) {
|
func (s *Service) Subscribe(ctx context.Context, userID, planID int64, paymentRef, paymentMethod *string) (*domain.UserSubscription, error) {
|
||||||
// Check if user already has an active subscription
|
|
||||||
hasActive, err := s.store.HasActiveSubscription(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if hasActive {
|
|
||||||
return nil, ErrAlreadySubscribed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the plan to calculate expiry
|
// Get the plan to calculate expiry
|
||||||
plan, err := s.store.GetSubscriptionPlanByID(ctx, planID)
|
plan, err := s.store.GetSubscriptionPlanByID(ctx, planID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -74,6 +65,14 @@ func (s *Service) Subscribe(ctx context.Context, userID, planID int64, paymentRe
|
||||||
return nil, ErrInvalidPlan
|
return nil, ErrInvalidPlan
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasActive, err := s.store.HasActiveSubscriptionByCategory(ctx, userID, plan.Category)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if hasActive {
|
||||||
|
return nil, ErrAlreadySubscribed
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate expiry date
|
// Calculate expiry date
|
||||||
startsAt := time.Now()
|
startsAt := time.Now()
|
||||||
expiresAt := domain.CalculateExpiryDate(startsAt, plan.DurationValue, plan.DurationUnit)
|
expiresAt := domain.CalculateExpiryDate(startsAt, plan.DurationValue, plan.DurationUnit)
|
||||||
|
|
@ -126,6 +125,10 @@ func (s *Service) HasActiveSubscription(ctx context.Context, userID int64) (bool
|
||||||
return s.store.HasActiveSubscription(ctx, userID)
|
return s.store.HasActiveSubscription(ctx, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) HasActiveSubscriptionByCategory(ctx context.Context, userID int64, category domain.SubscriptionCategory) (bool, error) {
|
||||||
|
return s.store.HasActiveSubscriptionByCategory(ctx, userID, string(category))
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) subscriptionOwnedBy(ctx context.Context, subscriptionID, userID int64) error {
|
func (s *Service) subscriptionOwnedBy(ctx context.Context, subscriptionID, userID int64) error {
|
||||||
sub, err := s.store.GetUserSubscriptionByID(ctx, subscriptionID)
|
sub, err := s.store.GetUserSubscriptionByID(ctx, subscriptionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
|
||||||
"Yimaru-Backend/internal/domain"
|
"Yimaru-Backend/internal/domain"
|
||||||
"Yimaru-Backend/internal/services/chapa"
|
"Yimaru-Backend/internal/services/chapa"
|
||||||
|
|
@ -312,6 +314,64 @@ func paymentInitiationStatus(err error) int {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleArifpaySuccessPage godoc
|
||||||
|
// @Summary ArifPay payment success page
|
||||||
|
// @Description Displays the Yimaru Academy success page after ArifPay redirects the learner back to the backend.
|
||||||
|
// @Tags payments
|
||||||
|
// @Produce html
|
||||||
|
// @Param session_id query string false "ArifPay session identifier"
|
||||||
|
// @Param sessionId query string false "ArifPay session identifier"
|
||||||
|
// @Param nonce query string false "Fallback payment nonce"
|
||||||
|
// @Success 200 {string} string "HTML success page"
|
||||||
|
// @Router /api/v1/payments/arifpay/success [get]
|
||||||
|
func (h *Handler) HandleArifpaySuccessPage(c *fiber.Ctx) error {
|
||||||
|
ref := firstNonEmpty(
|
||||||
|
c.Query("session_id"),
|
||||||
|
c.Query("sessionId"),
|
||||||
|
c.Query("sessionID"),
|
||||||
|
c.Query("nonce"),
|
||||||
|
)
|
||||||
|
|
||||||
|
page := arifpaySuccessPageData{
|
||||||
|
Title: "Subscription Payment Successful",
|
||||||
|
Headline: "Your Yimaru Academy payment was received",
|
||||||
|
Body: "Thank you for your payment. Your subscription is being activated and you can return to Yimaru Academy shortly.",
|
||||||
|
BadgeLabel: "Payment successful",
|
||||||
|
StatusLabel: "Activation in progress",
|
||||||
|
ActionLabel: "Continue learning",
|
||||||
|
ActionHref: "/",
|
||||||
|
}
|
||||||
|
|
||||||
|
if ref != "" {
|
||||||
|
payment, err := h.arifpaySvc.VerifyPayment(c.Context(), ref)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("Failed to verify ArifPay success redirect", "error", err, "ref", ref)
|
||||||
|
page.Body = "Thank you for your payment. We are confirming it with ArifPay and will activate your subscription shortly."
|
||||||
|
page.Helper = "You can safely return to Yimaru Academy. If activation takes longer than expected, refresh the app in a moment."
|
||||||
|
page.Reference = ref
|
||||||
|
} else {
|
||||||
|
page.Reference = ref
|
||||||
|
page.PlanName = derefString(payment.PlanName)
|
||||||
|
if payment.Status == string(domain.PaymentStatusSuccess) {
|
||||||
|
page.StatusLabel = "Subscription active"
|
||||||
|
page.Body = "Your Yimaru Academy subscription is active. You now have access to your learning content."
|
||||||
|
} else {
|
||||||
|
page.Body = "Thank you for your payment. We received your success redirect and are finalizing subscription activation."
|
||||||
|
page.StatusLabel = "Processing confirmation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
page.Helper = "Return to Yimaru Academy and refresh your subscription status if you do not see access immediately."
|
||||||
|
}
|
||||||
|
|
||||||
|
html, err := renderArifpaySuccessPage(page)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).SendString("Failed to render success page")
|
||||||
|
}
|
||||||
|
c.Type("html", "utf-8")
|
||||||
|
return c.SendString(html)
|
||||||
|
}
|
||||||
|
|
||||||
// HandleArifpayWebhook godoc
|
// HandleArifpayWebhook godoc
|
||||||
// @Summary Handle ArifPay webhook
|
// @Summary Handle ArifPay webhook
|
||||||
// @Description Processes payment notifications from ArifPay
|
// @Description Processes payment notifications from ArifPay
|
||||||
|
|
@ -525,3 +585,95 @@ func paymentToRes(p *domain.Payment) *paymentRes {
|
||||||
|
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type arifpaySuccessPageData struct {
|
||||||
|
Title string
|
||||||
|
Headline string
|
||||||
|
Body string
|
||||||
|
Helper string
|
||||||
|
BadgeLabel string
|
||||||
|
StatusLabel string
|
||||||
|
Reference string
|
||||||
|
PlanName string
|
||||||
|
ActionLabel string
|
||||||
|
ActionHref string
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderArifpaySuccessPage(data arifpaySuccessPageData) (string, error) {
|
||||||
|
const tpl = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;background:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;color:#333;">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="min-height:100vh;background:#f4f6fb;padding:24px 16px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="max-width:640px;width:100%;background:#ffffff;border-radius:18px;overflow:hidden;box-shadow:0 14px 40px rgba(157,42,131,0.12);">
|
||||||
|
<tr>
|
||||||
|
<td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 55%,#c43a9a 100%);padding:32px 28px;text-align:center;">
|
||||||
|
<div style="display:inline-block;padding:8px 14px;border-radius:999px;background:rgba(255,255,255,0.15);color:#fff;font-size:12px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;">{{.BadgeLabel}}</div>
|
||||||
|
<h1 style="margin:18px 0 8px;color:#fff;font-size:30px;line-height:1.2;">Yimaru Academy</h1>
|
||||||
|
<p style="margin:0;color:rgba(255,255,255,0.88);font-size:16px;">{{.Headline}}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:32px 28px;">
|
||||||
|
<div style="margin:0 auto 24px;width:76px;height:76px;border-radius:50%;background:#eef9f2;border:1px solid #cfead9;text-align:center;line-height:76px;font-size:40px;color:#1f9d55;">✓</div>
|
||||||
|
<p style="margin:0 0 18px;font-size:16px;line-height:1.7;color:#555;">{{.Body}}</p>
|
||||||
|
{{if .Helper}}<p style="margin:0 0 22px;font-size:14px;line-height:1.7;color:#777;">{{.Helper}}</p>{{end}}
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="margin:0 0 26px;background:#f8f3f8;border:1px solid #eddced;border-radius:12px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:18px 20px;">
|
||||||
|
<p style="margin:0 0 8px;font-size:12px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#9d2a83;">Status</p>
|
||||||
|
<p style="margin:0;font-size:18px;font-weight:700;color:#333;">{{.StatusLabel}}</p>
|
||||||
|
{{if .PlanName}}<p style="margin:14px 0 0;font-size:14px;color:#555;"><strong>Plan:</strong> {{.PlanName}}</p>{{end}}
|
||||||
|
{{if .Reference}}<p style="margin:8px 0 0;font-size:14px;color:#555;word-break:break-word;"><strong>Reference:</strong> {{.Reference}}</p>{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<div style="text-align:center;">
|
||||||
|
<a href="{{.ActionHref}}" style="display:inline-block;padding:14px 24px;border-radius:10px;background:#9d2a83;color:#fff;text-decoration:none;font-size:15px;font-weight:700;">{{.ActionLabel}}</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px 28px;background:#fafafa;border-top:1px solid #eee;text-align:center;">
|
||||||
|
<p style="margin:0;font-size:12px;line-height:1.6;color:#8a8a8a;">Yimaru Academy subscription payments are verified securely before access is granted.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
t, err := template.New("arifpay-success").Parse(tpl)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := t.Execute(&buf, data); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func derefString(value *string) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,76 @@ func (h *Handler) CreateExamPrepCatalogCourse(c *fiber.Ctx) error {
|
||||||
func (h *Handler) ListExamPrepCatalogCourses(c *fiber.Ctx) error {
|
func (h *Handler) ListExamPrepCatalogCourses(c *fiber.Ctx) error {
|
||||||
limit, _ := strconv.Atoi(c.Query("limit", "20"))
|
limit, _ := strconv.Atoi(c.Query("limit", "20"))
|
||||||
offset, _ := strconv.Atoi(c.Query("offset", "0"))
|
offset, _ := strconv.Atoi(c.Query("offset", "0"))
|
||||||
|
|
||||||
|
role, _ := c.Locals("role").(domain.Role)
|
||||||
|
if role == domain.RoleStudent || role == domain.RoleOpenLearner {
|
||||||
|
userID, ok := c.Locals("user_id").(int64)
|
||||||
|
if !ok || userID == 0 {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Unauthorized",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
hasIELTS, err := h.subscriptionsSvc.HasActiveSubscriptionByCategory(c.Context(), userID, domain.SubscriptionCategoryIELTS)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Failed to verify IELTS subscription",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
hasDuolingo, err := h.subscriptionsSvc.HasActiveSubscriptionByCategory(c.Context(), userID, domain.SubscriptionCategoryDuolingo)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Failed to verify Duolingo subscription",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
allItems, _, err := h.examPrepSvc.ListCatalogCourses(c.Context(), 200, 0)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Failed to list catalog courses",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := make([]domain.ExamPrepCatalogCourse, 0, len(allItems))
|
||||||
|
for _, item := range allItems {
|
||||||
|
switch domain.SubscriptionCategory(item.Category) {
|
||||||
|
case domain.SubscriptionCategoryIELTS:
|
||||||
|
if hasIELTS {
|
||||||
|
filtered = append(filtered, item)
|
||||||
|
}
|
||||||
|
case domain.SubscriptionCategoryDuolingo:
|
||||||
|
if hasDuolingo {
|
||||||
|
filtered = append(filtered, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
total := len(filtered)
|
||||||
|
start := offset
|
||||||
|
if start > total {
|
||||||
|
start = total
|
||||||
|
}
|
||||||
|
end := start + limit
|
||||||
|
if end > total {
|
||||||
|
end = total
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(domain.Response{
|
||||||
|
Message: "Catalog courses retrieved successfully",
|
||||||
|
Data: fiber.Map{
|
||||||
|
"catalog_courses": filtered[start:end],
|
||||||
|
"total_count": total,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
},
|
||||||
|
Success: true,
|
||||||
|
StatusCode: fiber.StatusOK,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
items, total, err := h.examPrepSvc.ListCatalogCourses(c.Context(), int32(limit), int32(offset))
|
items, total, err := h.examPrepSvc.ListCatalogCourses(c.Context(), int32(limit), int32(offset))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,12 @@ func (h *Handler) UpdateProgram(c *fiber.Ctx) error {
|
||||||
Error: err.Error(),
|
Error: err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if valErrs, ok := h.validator.Validate(c, req); !ok {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Validation failed",
|
||||||
|
Error: firstValidationError(valErrs),
|
||||||
|
})
|
||||||
|
}
|
||||||
p, err := h.programSvc.Update(c.Context(), id, req)
|
p, err := h.programSvc.Update(c.Context(), id, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, programs.ErrProgramNotFound) {
|
if errors.Is(err, programs.ErrProgramNotFound) {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import (
|
||||||
type createPlanReq struct {
|
type createPlanReq struct {
|
||||||
Name string `json:"name" validate:"required"`
|
Name string `json:"name" validate:"required"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
|
Category string `json:"category" validate:"required,oneof=LEARN_ENGLISH IELTS DUOLINGO"`
|
||||||
DurationValue int32 `json:"duration_value" validate:"required,min=1"`
|
DurationValue int32 `json:"duration_value" validate:"required,min=1"`
|
||||||
DurationUnit string `json:"duration_unit" validate:"required,oneof=DAY WEEK MONTH YEAR"`
|
DurationUnit string `json:"duration_unit" validate:"required,oneof=DAY WEEK MONTH YEAR"`
|
||||||
Price float64 `json:"price" validate:"required,min=0"`
|
Price float64 `json:"price" validate:"required,min=0"`
|
||||||
|
|
@ -29,6 +30,7 @@ type createPlanReq struct {
|
||||||
type updatePlanReq struct {
|
type updatePlanReq struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
|
Category *string `json:"category" validate:"omitempty,oneof=LEARN_ENGLISH IELTS DUOLINGO"`
|
||||||
DurationValue *int32 `json:"duration_value"`
|
DurationValue *int32 `json:"duration_value"`
|
||||||
DurationUnit *string `json:"duration_unit"`
|
DurationUnit *string `json:"duration_unit"`
|
||||||
Price *float64 `json:"price"`
|
Price *float64 `json:"price"`
|
||||||
|
|
@ -40,6 +42,7 @@ type planRes struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
|
Category string `json:"category"`
|
||||||
DurationValue int32 `json:"duration_value"`
|
DurationValue int32 `json:"duration_value"`
|
||||||
DurationUnit string `json:"duration_unit"`
|
DurationUnit string `json:"duration_unit"`
|
||||||
Price float64 `json:"price"`
|
Price float64 `json:"price"`
|
||||||
|
|
@ -110,10 +113,17 @@ func (h *Handler) CreateSubscriptionPlan(c *fiber.Ctx) error {
|
||||||
Error: err.Error(),
|
Error: err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if valErrs, ok := h.validator.Validate(c, req); !ok {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Validation failed",
|
||||||
|
Error: firstValidationError(valErrs),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
plan, err := h.subscriptionsSvc.CreatePlan(c.Context(), domain.CreateSubscriptionPlanInput{
|
plan, err := h.subscriptionsSvc.CreatePlan(c.Context(), domain.CreateSubscriptionPlanInput{
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
|
Category: req.Category,
|
||||||
DurationValue: req.DurationValue,
|
DurationValue: req.DurationValue,
|
||||||
DurationUnit: req.DurationUnit,
|
DurationUnit: req.DurationUnit,
|
||||||
Price: req.Price,
|
Price: req.Price,
|
||||||
|
|
@ -228,10 +238,17 @@ func (h *Handler) UpdateSubscriptionPlan(c *fiber.Ctx) error {
|
||||||
Error: err.Error(),
|
Error: err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if valErrs, ok := h.validator.Validate(c, req); !ok {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Validation failed",
|
||||||
|
Error: firstValidationError(valErrs),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
err = h.subscriptionsSvc.UpdatePlan(c.Context(), id, domain.UpdateSubscriptionPlanInput{
|
err = h.subscriptionsSvc.UpdatePlan(c.Context(), id, domain.UpdateSubscriptionPlanInput{
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
|
Category: req.Category,
|
||||||
DurationValue: req.DurationValue,
|
DurationValue: req.DurationValue,
|
||||||
DurationUnit: req.DurationUnit,
|
DurationUnit: req.DurationUnit,
|
||||||
Price: req.Price,
|
Price: req.Price,
|
||||||
|
|
@ -623,6 +640,7 @@ func planToRes(p *domain.SubscriptionPlan) *planRes {
|
||||||
ID: p.ID,
|
ID: p.ID,
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
Description: p.Description,
|
Description: p.Description,
|
||||||
|
Category: p.Category,
|
||||||
DurationValue: p.DurationValue,
|
DurationValue: p.DurationValue,
|
||||||
DurationUnit: p.DurationUnit,
|
DurationUnit: p.DurationUnit,
|
||||||
Price: p.Price,
|
Price: p.Price,
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,12 @@ package httpserver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"Yimaru-Backend/internal/domain"
|
"Yimaru-Backend/internal/domain"
|
||||||
|
examprepsvc "Yimaru-Backend/internal/services/examprep"
|
||||||
jwtutil "Yimaru-Backend/internal/web_server/jwt"
|
jwtutil "Yimaru-Backend/internal/web_server/jwt"
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -12,6 +15,8 @@ import (
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var categorySubscriptionGateDisabled = true
|
||||||
|
|
||||||
func (a *App) authMiddleware(c *fiber.Ctx) error {
|
func (a *App) authMiddleware(c *fiber.Ctx) error {
|
||||||
ip := c.IP()
|
ip := c.IP()
|
||||||
userAgent := c.Get("User-Agent")
|
userAgent := c.Get("User-Agent")
|
||||||
|
|
@ -210,6 +215,245 @@ func (a *App) RequireActiveSubscription() fiber.Handler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) RequireSubscriptionCategory(category domain.SubscriptionCategory) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
role, userID, err := subscriptionScopedUser(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if bypassSubscriptionForRole(role) {
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
if role != domain.RoleStudent && role != domain.RoleOpenLearner {
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
if categorySubscriptionGateDisabled {
|
||||||
|
// Temporary bypass to disable category-aware learner access checks without changing route wiring.
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
active, err := a.subscriptionsSvc.HasActiveSubscriptionByCategory(c.Context(), userID, category)
|
||||||
|
if err != nil {
|
||||||
|
a.mongoLoggerSvc.Error("category subscription check failed",
|
||||||
|
zap.Int64("userID", userID),
|
||||||
|
zap.String("category", string(category)),
|
||||||
|
zap.String("path", c.Path()),
|
||||||
|
zap.Error(err),
|
||||||
|
zap.Time("timestamp", time.Now()),
|
||||||
|
)
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to verify subscription")
|
||||||
|
}
|
||||||
|
if !active {
|
||||||
|
return fiber.NewError(fiber.StatusForbidden, fmt.Sprintf("An active %s subscription is required", humanizeSubscriptionCategory(category)))
|
||||||
|
}
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) RequireExamPrepSubscription() fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
role, userID, err := subscriptionScopedUser(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if bypassSubscriptionForRole(role) {
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
if role != domain.RoleStudent && role != domain.RoleOpenLearner {
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
if categorySubscriptionGateDisabled {
|
||||||
|
// Temporary bypass to disable category-aware learner access checks without changing route wiring.
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
category, scoped, err := a.resolveExamPrepSubscriptionCategory(c)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, examprepsvc.ErrCatalogCourseNotFound),
|
||||||
|
errors.Is(err, examprepsvc.ErrUnitNotFound),
|
||||||
|
errors.Is(err, examprepsvc.ErrModuleNotFound),
|
||||||
|
errors.Is(err, examprepsvc.ErrLessonNotFound),
|
||||||
|
errors.Is(err, examprepsvc.ErrPracticeNotFound):
|
||||||
|
return fiber.NewError(fiber.StatusNotFound, err.Error())
|
||||||
|
default:
|
||||||
|
a.mongoLoggerSvc.Error("exam prep category resolution failed",
|
||||||
|
zap.Int64("userID", userID),
|
||||||
|
zap.String("path", c.Path()),
|
||||||
|
zap.Error(err),
|
||||||
|
zap.Time("timestamp", time.Now()),
|
||||||
|
)
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to verify subscription")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !scoped {
|
||||||
|
hasIELTS, err := a.subscriptionsSvc.HasActiveSubscriptionByCategory(c.Context(), userID, domain.SubscriptionCategoryIELTS)
|
||||||
|
if err != nil {
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to verify subscription")
|
||||||
|
}
|
||||||
|
hasDuolingo, err := a.subscriptionsSvc.HasActiveSubscriptionByCategory(c.Context(), userID, domain.SubscriptionCategoryDuolingo)
|
||||||
|
if err != nil {
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to verify subscription")
|
||||||
|
}
|
||||||
|
if !hasIELTS && !hasDuolingo {
|
||||||
|
return fiber.NewError(fiber.StatusForbidden, "An active IELTS or Duolingo subscription is required")
|
||||||
|
}
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
active, err := a.subscriptionsSvc.HasActiveSubscriptionByCategory(c.Context(), userID, category)
|
||||||
|
if err != nil {
|
||||||
|
a.mongoLoggerSvc.Error("exam prep subscription check failed",
|
||||||
|
zap.Int64("userID", userID),
|
||||||
|
zap.String("category", string(category)),
|
||||||
|
zap.String("path", c.Path()),
|
||||||
|
zap.Error(err),
|
||||||
|
zap.Time("timestamp", time.Now()),
|
||||||
|
)
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to verify subscription")
|
||||||
|
}
|
||||||
|
if !active {
|
||||||
|
return fiber.NewError(fiber.StatusForbidden, fmt.Sprintf("An active %s subscription is required", humanizeSubscriptionCategory(category)))
|
||||||
|
}
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func subscriptionScopedUser(c *fiber.Ctx) (domain.Role, int64, error) {
|
||||||
|
role, ok := c.Locals("role").(domain.Role)
|
||||||
|
if !ok {
|
||||||
|
return "", 0, fiber.NewError(fiber.StatusForbidden, "Role not found in context")
|
||||||
|
}
|
||||||
|
userID, ok := c.Locals("user_id").(int64)
|
||||||
|
if !ok || userID == 0 {
|
||||||
|
return role, 0, fiber.NewError(fiber.StatusUnauthorized, "Unauthorized")
|
||||||
|
}
|
||||||
|
return role, userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bypassSubscriptionForRole(role domain.Role) bool {
|
||||||
|
switch role {
|
||||||
|
case domain.RoleSuperAdmin, domain.RoleAdmin, domain.RoleInstructor, domain.RoleSupport:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func humanizeSubscriptionCategory(category domain.SubscriptionCategory) string {
|
||||||
|
return strings.ToLower(strings.ReplaceAll(string(category), "_", " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRouteInt64(c *fiber.Ctx, name string) (int64, bool, error) {
|
||||||
|
raw := strings.TrimSpace(c.Params(name))
|
||||||
|
if raw == "" {
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid %s", name))
|
||||||
|
}
|
||||||
|
return id, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) resolveExamPrepSubscriptionCategory(c *fiber.Ctx) (domain.SubscriptionCategory, bool, error) {
|
||||||
|
if catalogCourseID, ok, err := parseRouteInt64(c, "catalogCourseId"); err != nil {
|
||||||
|
return "", false, err
|
||||||
|
} else if ok {
|
||||||
|
return a.examPrepCategoryByCatalogCourseID(c.Context(), catalogCourseID)
|
||||||
|
}
|
||||||
|
if unitID, ok, err := parseRouteInt64(c, "unitId"); err != nil {
|
||||||
|
return "", false, err
|
||||||
|
} else if ok {
|
||||||
|
return a.examPrepCategoryByUnitID(c.Context(), unitID)
|
||||||
|
}
|
||||||
|
if moduleID, ok, err := parseRouteInt64(c, "moduleId"); err != nil {
|
||||||
|
return "", false, err
|
||||||
|
} else if ok {
|
||||||
|
return a.examPrepCategoryByModuleID(c.Context(), moduleID)
|
||||||
|
}
|
||||||
|
if lessonID, ok, err := parseRouteInt64(c, "lessonId"); err != nil {
|
||||||
|
return "", false, err
|
||||||
|
} else if ok {
|
||||||
|
return a.examPrepCategoryByLessonID(c.Context(), lessonID)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch routePath := c.Route().Path; {
|
||||||
|
case strings.Contains(routePath, "/catalog-courses/:id"):
|
||||||
|
if id, ok, err := parseRouteInt64(c, "id"); err != nil {
|
||||||
|
return "", false, err
|
||||||
|
} else if ok {
|
||||||
|
return a.examPrepCategoryByCatalogCourseID(c.Context(), id)
|
||||||
|
}
|
||||||
|
case strings.Contains(routePath, "/units/:id"):
|
||||||
|
if id, ok, err := parseRouteInt64(c, "id"); err != nil {
|
||||||
|
return "", false, err
|
||||||
|
} else if ok {
|
||||||
|
return a.examPrepCategoryByUnitID(c.Context(), id)
|
||||||
|
}
|
||||||
|
case strings.Contains(routePath, "/modules/:id"):
|
||||||
|
if id, ok, err := parseRouteInt64(c, "id"); err != nil {
|
||||||
|
return "", false, err
|
||||||
|
} else if ok {
|
||||||
|
return a.examPrepCategoryByModuleID(c.Context(), id)
|
||||||
|
}
|
||||||
|
case strings.Contains(routePath, "/lessons/:id"):
|
||||||
|
if id, ok, err := parseRouteInt64(c, "id"); err != nil {
|
||||||
|
return "", false, err
|
||||||
|
} else if ok {
|
||||||
|
return a.examPrepCategoryByLessonID(c.Context(), id)
|
||||||
|
}
|
||||||
|
case strings.Contains(routePath, "/practices/:id"):
|
||||||
|
if id, ok, err := parseRouteInt64(c, "id"); err != nil {
|
||||||
|
return "", false, err
|
||||||
|
} else if ok {
|
||||||
|
return a.examPrepCategoryByPracticeID(c.Context(), id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) examPrepCategoryByCatalogCourseID(ctx context.Context, catalogCourseID int64) (domain.SubscriptionCategory, bool, error) {
|
||||||
|
catalogCourse, err := a.examPrepSvc.GetCatalogCourseByID(ctx, catalogCourseID)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
return domain.SubscriptionCategory(catalogCourse.Category), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) examPrepCategoryByUnitID(ctx context.Context, unitID int64) (domain.SubscriptionCategory, bool, error) {
|
||||||
|
unit, err := a.examPrepSvc.GetUnitByID(ctx, unitID)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
return a.examPrepCategoryByCatalogCourseID(ctx, unit.CatalogCourseID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) examPrepCategoryByModuleID(ctx context.Context, moduleID int64) (domain.SubscriptionCategory, bool, error) {
|
||||||
|
module, err := a.examPrepSvc.GetModuleByID(ctx, moduleID)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
return a.examPrepCategoryByUnitID(ctx, module.UnitID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) examPrepCategoryByLessonID(ctx context.Context, lessonID int64) (domain.SubscriptionCategory, bool, error) {
|
||||||
|
lesson, err := a.examPrepSvc.GetLessonByID(ctx, lessonID)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
return a.examPrepCategoryByModuleID(ctx, lesson.UnitModuleID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) examPrepCategoryByPracticeID(ctx context.Context, practiceID int64) (domain.SubscriptionCategory, bool, error) {
|
||||||
|
practice, err := a.examPrepSvc.GetExamPrepPracticeByID(ctx, practiceID)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
return a.examPrepCategoryByLessonID(ctx, practice.LessonID)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) RequirePermission(permKey string) fiber.Handler {
|
func (a *App) RequirePermission(permKey string) fiber.Handler {
|
||||||
return func(c *fiber.Ctx) error {
|
return func(c *fiber.Ctx) error {
|
||||||
userRole, ok := c.Locals("role").(domain.Role)
|
userRole, ok := c.Locals("role").(domain.Role)
|
||||||
|
|
|
||||||
|
|
@ -82,16 +82,16 @@ func (a *App) initAppRoutes() {
|
||||||
|
|
||||||
// Programs (LMS top-level)
|
// Programs (LMS top-level)
|
||||||
groupV1.Post("/programs", a.authMiddleware, a.RequirePermission("programs.create"), h.CreateProgram)
|
groupV1.Post("/programs", a.authMiddleware, a.RequirePermission("programs.create"), h.CreateProgram)
|
||||||
groupV1.Get("/programs", a.authMiddleware, a.RequirePermission("programs.list"), h.ListPrograms)
|
groupV1.Get("/programs", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("programs.list"), h.ListPrograms)
|
||||||
groupV1.Put("/programs/reorder", a.authMiddleware, a.RequirePermission("programs.reorder"), h.ReorderPrograms)
|
groupV1.Put("/programs/reorder", a.authMiddleware, a.RequirePermission("programs.reorder"), h.ReorderPrograms)
|
||||||
groupV1.Get("/lms/progress", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lms.get_my_progress"), h.GetMyLMSProgress)
|
groupV1.Get("/lms/progress", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("lms.get_my_progress"), h.GetMyLMSProgress)
|
||||||
groupV1.Get("/lms/progress-summary", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lms.get_my_progress"), h.GetMyLMSProgressSummary)
|
groupV1.Get("/lms/progress-summary", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("lms.get_my_progress"), h.GetMyLMSProgressSummary)
|
||||||
groupV1.Get("/programs/:id", a.authMiddleware, a.RequirePermission("programs.get"), h.GetProgram)
|
groupV1.Get("/programs/:id", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("programs.get"), h.GetProgram)
|
||||||
groupV1.Put("/programs/:id", a.authMiddleware, a.RequirePermission("programs.update"), h.UpdateProgram)
|
groupV1.Put("/programs/:id", a.authMiddleware, a.RequirePermission("programs.update"), h.UpdateProgram)
|
||||||
groupV1.Delete("/programs/:id", a.authMiddleware, a.RequirePermission("programs.delete"), h.DeleteProgram)
|
groupV1.Delete("/programs/:id", a.authMiddleware, a.RequirePermission("programs.delete"), h.DeleteProgram)
|
||||||
|
|
||||||
// Exam prep (schema exam_prep — separate from LMS Learn English). Students need an active subscription.
|
// Exam prep (schema exam_prep — separate from LMS Learn English). Students need an active subscription.
|
||||||
examPrep := groupV1.Group("/exam-prep", a.authMiddleware, a.RequireActiveSubscription())
|
examPrep := groupV1.Group("/exam-prep", a.authMiddleware, a.RequireExamPrepSubscription())
|
||||||
examPrep.Post("/catalog-courses", a.RequirePermission("exam_prep.catalog_courses.create"), h.CreateExamPrepCatalogCourse)
|
examPrep.Post("/catalog-courses", a.RequirePermission("exam_prep.catalog_courses.create"), h.CreateExamPrepCatalogCourse)
|
||||||
examPrep.Get("/catalog-courses", a.RequirePermission("exam_prep.catalog_courses.list"), h.ListExamPrepCatalogCourses)
|
examPrep.Get("/catalog-courses", a.RequirePermission("exam_prep.catalog_courses.list"), h.ListExamPrepCatalogCourses)
|
||||||
examPrep.Put("/catalog-courses/reorder", a.RequirePermission("exam_prep.catalog_courses.reorder"), h.ReorderExamPrepCatalogCourses)
|
examPrep.Put("/catalog-courses/reorder", a.RequirePermission("exam_prep.catalog_courses.reorder"), h.ReorderExamPrepCatalogCourses)
|
||||||
|
|
@ -130,32 +130,32 @@ func (a *App) initAppRoutes() {
|
||||||
// Courses
|
// Courses
|
||||||
groupV1.Post("/programs/:id/courses", a.authMiddleware, a.RequirePermission("courses.create"), h.CreateCourse)
|
groupV1.Post("/programs/:id/courses", a.authMiddleware, a.RequirePermission("courses.create"), h.CreateCourse)
|
||||||
groupV1.Put("/programs/:id/courses/reorder", a.authMiddleware, a.RequirePermission("courses.reorder"), h.ReorderCoursesInProgram)
|
groupV1.Put("/programs/:id/courses/reorder", a.authMiddleware, a.RequirePermission("courses.reorder"), h.ReorderCoursesInProgram)
|
||||||
groupV1.Get("/programs/:id/courses", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("courses.list_by_program"), h.ListCoursesByProgram)
|
groupV1.Get("/programs/:id/courses", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("courses.list_by_program"), h.ListCoursesByProgram)
|
||||||
groupV1.Get("/courses/:id/practices", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("practices.list"), h.ListPracticesByCourse)
|
groupV1.Get("/courses/:id/practices", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("practices.list"), h.ListPracticesByCourse)
|
||||||
groupV1.Get("/courses/:id", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("courses.get"), h.GetCourse)
|
groupV1.Get("/courses/:id", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("courses.get"), h.GetCourse)
|
||||||
groupV1.Put("/courses/:id", a.authMiddleware, a.RequirePermission("courses.update"), h.UpdateCourse)
|
groupV1.Put("/courses/:id", a.authMiddleware, a.RequirePermission("courses.update"), h.UpdateCourse)
|
||||||
groupV1.Delete("/courses/:id", a.authMiddleware, a.RequirePermission("courses.delete"), h.DeleteCourse)
|
groupV1.Delete("/courses/:id", a.authMiddleware, a.RequirePermission("courses.delete"), h.DeleteCourse)
|
||||||
groupV1.Post("/courses/:courseId/modules", a.authMiddleware, a.RequirePermission("modules.create"), h.CreateModule)
|
groupV1.Post("/courses/:courseId/modules", a.authMiddleware, a.RequirePermission("modules.create"), h.CreateModule)
|
||||||
groupV1.Put("/courses/:courseId/modules/reorder", a.authMiddleware, a.RequirePermission("modules.reorder"), h.ReorderModulesInCourse)
|
groupV1.Put("/courses/:courseId/modules/reorder", a.authMiddleware, a.RequirePermission("modules.reorder"), h.ReorderModulesInCourse)
|
||||||
groupV1.Get("/courses/:courseId/modules", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("modules.list_by_course"), h.ListModulesByCourse)
|
groupV1.Get("/courses/:courseId/modules", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("modules.list_by_course"), h.ListModulesByCourse)
|
||||||
|
|
||||||
// /modules/:moduleId/lessons before /modules/:id; /modules/:id/practices before /modules/:id
|
// /modules/:moduleId/lessons before /modules/:id; /modules/:id/practices before /modules/:id
|
||||||
groupV1.Post("/modules/:moduleId/lessons", a.authMiddleware, a.RequirePermission("lessons.create"), h.CreateLesson)
|
groupV1.Post("/modules/:moduleId/lessons", a.authMiddleware, a.RequirePermission("lessons.create"), h.CreateLesson)
|
||||||
groupV1.Get("/modules/:moduleId/lessons", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lessons.list_by_module"), h.ListLessonsByModule)
|
groupV1.Get("/modules/:moduleId/lessons", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("lessons.list_by_module"), h.ListLessonsByModule)
|
||||||
groupV1.Get("/modules/:id/practices", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("practices.list"), h.ListPracticesByModule)
|
groupV1.Get("/modules/:id/practices", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("practices.list"), h.ListPracticesByModule)
|
||||||
groupV1.Get("/modules/:id", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("modules.get"), h.GetModule)
|
groupV1.Get("/modules/:id", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("modules.get"), h.GetModule)
|
||||||
groupV1.Put("/modules/:id", a.authMiddleware, a.RequirePermission("modules.update"), h.UpdateModule)
|
groupV1.Put("/modules/:id", a.authMiddleware, a.RequirePermission("modules.update"), h.UpdateModule)
|
||||||
groupV1.Delete("/modules/:id", a.authMiddleware, a.RequirePermission("modules.delete"), h.DeleteModule)
|
groupV1.Delete("/modules/:id", a.authMiddleware, a.RequirePermission("modules.delete"), h.DeleteModule)
|
||||||
groupV1.Get("/lessons/:id/practices", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("practices.list"), h.ListPracticesByLesson)
|
groupV1.Get("/lessons/:id/practices", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("practices.list"), h.ListPracticesByLesson)
|
||||||
groupV1.Post("/lessons/:id/complete", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lessons.complete"), h.CompleteLesson)
|
groupV1.Post("/lessons/:id/complete", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("lessons.complete"), h.CompleteLesson)
|
||||||
groupV1.Post("/videos/engagement/heartbeat", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("videos.track_engagement"), h.RecordVideoEngagementHeartbeat)
|
groupV1.Post("/videos/engagement/heartbeat", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("videos.track_engagement"), h.RecordVideoEngagementHeartbeat)
|
||||||
groupV1.Post("/progress/practices/:id/complete", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("progress.complete"), h.CompletePractice)
|
groupV1.Post("/progress/practices/:id/complete", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("progress.complete"), h.CompletePractice)
|
||||||
groupV1.Get("/lessons/:id", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lessons.get"), h.GetLesson)
|
groupV1.Get("/lessons/:id", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("lessons.get"), h.GetLesson)
|
||||||
groupV1.Put("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.update"), h.UpdateLesson)
|
groupV1.Put("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.update"), h.UpdateLesson)
|
||||||
groupV1.Delete("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.delete"), h.DeleteLesson)
|
groupV1.Delete("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.delete"), h.DeleteLesson)
|
||||||
|
|
||||||
groupV1.Post("/practices", a.authMiddleware, a.RequirePermission("practices.create"), h.CreatePractice)
|
groupV1.Post("/practices", a.authMiddleware, a.RequirePermission("practices.create"), h.CreatePractice)
|
||||||
groupV1.Get("/practices/:id", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("practices.get"), h.GetPractice)
|
groupV1.Get("/practices/:id", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("practices.get"), h.GetPractice)
|
||||||
groupV1.Put("/practices/:id", a.authMiddleware, a.RequirePermission("practices.update"), h.UpdatePractice)
|
groupV1.Put("/practices/:id", a.authMiddleware, a.RequirePermission("practices.update"), h.UpdatePractice)
|
||||||
groupV1.Delete("/practices/:id", a.authMiddleware, a.RequirePermission("practices.delete"), h.DeletePractice)
|
groupV1.Delete("/practices/:id", a.authMiddleware, a.RequirePermission("practices.delete"), h.DeletePractice)
|
||||||
|
|
||||||
|
|
@ -240,7 +240,7 @@ func (a *App) initAppRoutes() {
|
||||||
// Question Set Items
|
// Question Set Items
|
||||||
groupV1.Post("/question-sets/:setId/questions", a.authMiddleware, a.RequirePermission("question_set_items.add"), h.AddQuestionToSet)
|
groupV1.Post("/question-sets/:setId/questions", a.authMiddleware, a.RequirePermission("question_set_items.add"), h.AddQuestionToSet)
|
||||||
groupV1.Get("/question-sets/:setId/questions", a.authMiddleware, a.RequirePermission("question_set_items.list"), h.GetQuestionsInSet)
|
groupV1.Get("/question-sets/:setId/questions", a.authMiddleware, a.RequirePermission("question_set_items.list"), h.GetQuestionsInSet)
|
||||||
groupV1.Get("/practices/:practiceId/questions", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("question_set_items.list"), h.GetQuestionsByPractice)
|
groupV1.Get("/practices/:practiceId/questions", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("question_set_items.list"), h.GetQuestionsByPractice)
|
||||||
groupV1.Delete("/question-sets/:setId/questions/:questionId", a.authMiddleware, a.RequirePermission("question_set_items.remove"), h.RemoveQuestionFromSet)
|
groupV1.Delete("/question-sets/:setId/questions/:questionId", a.authMiddleware, a.RequirePermission("question_set_items.remove"), h.RemoveQuestionFromSet)
|
||||||
groupV1.Put("/question-sets/:setId/questions/:questionId/order", a.authMiddleware, a.RequirePermission("question_set_items.update_order"), h.UpdateQuestionOrderInSet)
|
groupV1.Put("/question-sets/:setId/questions/:questionId/order", a.authMiddleware, a.RequirePermission("question_set_items.update_order"), h.UpdateQuestionOrderInSet)
|
||||||
|
|
||||||
|
|
@ -273,6 +273,7 @@ func (a *App) initAppRoutes() {
|
||||||
groupV1.Get("/payments/:id", a.authMiddleware, a.RequirePermission("payments.get"), h.GetPaymentByID)
|
groupV1.Get("/payments/:id", a.authMiddleware, a.RequirePermission("payments.get"), h.GetPaymentByID)
|
||||||
groupV1.Post("/payments/:id/cancel", a.authMiddleware, a.RequirePermission("payments.cancel"), h.CancelPayment)
|
groupV1.Post("/payments/:id/cancel", a.authMiddleware, a.RequirePermission("payments.cancel"), h.CancelPayment)
|
||||||
groupV1.Post("/payments/webhook", h.HandleChapaWebhook)
|
groupV1.Post("/payments/webhook", h.HandleChapaWebhook)
|
||||||
|
groupV1.Get("/payments/arifpay/success", h.HandleArifpaySuccessPage)
|
||||||
groupV1.Get("/payments/chapa/callback", h.HandleChapaCallback)
|
groupV1.Get("/payments/chapa/callback", h.HandleChapaCallback)
|
||||||
|
|
||||||
// Direct Payments
|
// Direct Payments
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user