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:
Yared Yemane 2026-05-26 06:20:49 -07:00
parent 7a4253edf4
commit 79fb95ce36
29 changed files with 1006 additions and 108 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"
}, },

View File

@ -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"
}, },

View File

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

View File

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

View File

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

View File

@ -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 {

View File

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

View File

@ -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,

View File

@ -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"`
} }

View File

@ -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"`
} }

View File

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

View File

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

View File

@ -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),
}) })

View File

@ -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,
}) })

View File

@ -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 {

View File

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

View File

@ -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)

View File

@ -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 {

View File

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

View File

@ -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{

View File

@ -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) {

View File

@ -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,

View File

@ -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)

View File

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