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

View File

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

View File

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

View File

@ -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": {
"get": {
"description": "Verifies payment after Chapa redirects to callback_url",
@ -11925,9 +11965,17 @@ const docTemplate = `{
"domain.CreateExamPrepCatalogCourseInput": {
"type": "object",
"required": [
"category",
"name"
],
"properties": {
"category": {
"type": "string",
"enum": [
"IELTS",
"DUOLINGO"
]
},
"description": {
"type": "string"
},
@ -12172,9 +12220,18 @@ const docTemplate = `{
"domain.CreateProgramInput": {
"type": "object",
"required": [
"category",
"name"
],
"properties": {
"category": {
"type": "string",
"enum": [
"LEARN_ENGLISH",
"IELTS",
"DUOLINGO"
]
},
"description": {
"type": "string"
},
@ -12928,6 +12985,13 @@ const docTemplate = `{
"domain.UpdateExamPrepCatalogCourseInput": {
"type": "object",
"properties": {
"category": {
"type": "string",
"enum": [
"IELTS",
"DUOLINGO"
]
},
"description": {
"type": "string"
},
@ -13104,6 +13168,14 @@ const docTemplate = `{
"domain.UpdateProgramInput": {
"type": "object",
"properties": {
"category": {
"type": "string",
"enum": [
"LEARN_ENGLISH",
"IELTS",
"DUOLINGO"
]
},
"description": {
"type": "string"
},
@ -14061,6 +14133,7 @@ const docTemplate = `{
"handlers.createPlanReq": {
"type": "object",
"required": [
"category",
"currency",
"duration_unit",
"duration_value",
@ -14068,6 +14141,14 @@ const docTemplate = `{
"price"
],
"properties": {
"category": {
"type": "string",
"enum": [
"LEARN_ENGLISH",
"IELTS",
"DUOLINGO"
]
},
"currency": {
"type": "string"
},
@ -14668,6 +14749,14 @@ const docTemplate = `{
"handlers.updatePlanReq": {
"type": "object",
"properties": {
"category": {
"type": "string",
"enum": [
"LEARN_ENGLISH",
"IELTS",
"DUOLINGO"
]
},
"currency": {
"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": {
"get": {
"description": "Verifies payment after Chapa redirects to callback_url",
@ -11917,9 +11957,17 @@
"domain.CreateExamPrepCatalogCourseInput": {
"type": "object",
"required": [
"category",
"name"
],
"properties": {
"category": {
"type": "string",
"enum": [
"IELTS",
"DUOLINGO"
]
},
"description": {
"type": "string"
},
@ -12164,9 +12212,18 @@
"domain.CreateProgramInput": {
"type": "object",
"required": [
"category",
"name"
],
"properties": {
"category": {
"type": "string",
"enum": [
"LEARN_ENGLISH",
"IELTS",
"DUOLINGO"
]
},
"description": {
"type": "string"
},
@ -12920,6 +12977,13 @@
"domain.UpdateExamPrepCatalogCourseInput": {
"type": "object",
"properties": {
"category": {
"type": "string",
"enum": [
"IELTS",
"DUOLINGO"
]
},
"description": {
"type": "string"
},
@ -13096,6 +13160,14 @@
"domain.UpdateProgramInput": {
"type": "object",
"properties": {
"category": {
"type": "string",
"enum": [
"LEARN_ENGLISH",
"IELTS",
"DUOLINGO"
]
},
"description": {
"type": "string"
},
@ -14053,6 +14125,7 @@
"handlers.createPlanReq": {
"type": "object",
"required": [
"category",
"currency",
"duration_unit",
"duration_value",
@ -14060,6 +14133,14 @@
"price"
],
"properties": {
"category": {
"type": "string",
"enum": [
"LEARN_ENGLISH",
"IELTS",
"DUOLINGO"
]
},
"currency": {
"type": "string"
},
@ -14660,6 +14741,14 @@
"handlers.updatePlanReq": {
"type": "object",
"properties": {
"category": {
"type": "string",
"enum": [
"LEARN_ENGLISH",
"IELTS",
"DUOLINGO"
]
},
"currency": {
"type": "string"
},

View File

@ -440,6 +440,11 @@ definitions:
type: object
domain.CreateExamPrepCatalogCourseInput:
properties:
category:
enum:
- IELTS
- DUOLINGO
type: string
description:
type: string
name:
@ -447,6 +452,7 @@ definitions:
thumbnail:
type: string
required:
- category
- name
type: object
domain.CreateExamPrepLessonInput:
@ -612,6 +618,12 @@ definitions:
type: object
domain.CreateProgramInput:
properties:
category:
enum:
- LEARN_ENGLISH
- IELTS
- DUOLINGO
type: string
description:
type: string
name:
@ -624,6 +636,7 @@ definitions:
thumbnail:
type: string
required:
- category
- name
type: object
domain.CreateRoleReq:
@ -1131,6 +1144,11 @@ definitions:
type: object
domain.UpdateExamPrepCatalogCourseInput:
properties:
category:
enum:
- IELTS
- DUOLINGO
type: string
description:
type: string
name:
@ -1249,6 +1267,12 @@ definitions:
type: object
domain.UpdateProgramInput:
properties:
category:
enum:
- LEARN_ENGLISH
- IELTS
- DUOLINGO
type: string
description:
type: string
name:
@ -1893,6 +1917,12 @@ definitions:
type: object
handlers.createPlanReq:
properties:
category:
enum:
- LEARN_ENGLISH
- IELTS
- DUOLINGO
type: string
currency:
type: string
description:
@ -1915,6 +1945,7 @@ definitions:
minimum: 0
type: number
required:
- category
- currency
- duration_unit
- duration_value
@ -2304,6 +2335,12 @@ definitions:
type: object
handlers.updatePlanReq:
properties:
category:
enum:
- LEARN_ENGLISH
- IELTS
- DUOLINGO
type: string
currency:
type: string
description:
@ -5932,6 +5969,33 @@ paths:
summary: Cancel a pending payment
tags:
- 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:
get:
description: Verifies payment after Chapa redirects to callback_url

View File

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

View File

@ -546,7 +546,7 @@ func (q *Queries) GetPreviousModuleInCourse(ctx context.Context, id int64) (Modu
const GetPreviousProgram = `-- name: GetPreviousProgram :one
SELECT
p2.id, p2.name, p2.description, p2.thumbnail, p2.created_at, p2.updated_at, p2.sort_order
p2.id, p2.name, p2.description, p2.thumbnail, p2.created_at, p2.updated_at, p2.sort_order, p2.category
FROM
programs AS p1
INNER JOIN programs AS p2 ON p2.sort_order = p1.sort_order - 1
@ -565,6 +565,7 @@ func (q *Queries) GetPreviousProgram(ctx context.Context, id int64) (Program, er
&i.CreatedAt,
&i.UpdatedAt,
&i.SortOrder,
&i.Category,
)
return i, err
}

View File

@ -65,6 +65,7 @@ type ExamPrepCatalogCourse struct {
SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Category string `json:"category"`
}
type ExamPrepLessonPractice struct {
@ -309,6 +310,7 @@ type Program struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SortOrder int32 `json:"sort_order"`
Category string `json:"category"`
}
type Question struct {
@ -482,6 +484,7 @@ type SubscriptionPlan struct {
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Category string `json:"category"`
}
type TeamInvitation struct {

View File

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

View File

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

View File

@ -7,6 +7,7 @@ type ExamPrepCatalogCourse struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Category string `json:"category"`
Thumbnail *string `json:"thumbnail,omitempty"`
SortOrder int `json:"sort_order"`
UnitsCount *int64 `json:"units_count,omitempty"`
@ -20,12 +21,14 @@ type ExamPrepCatalogCourse struct {
type CreateExamPrepCatalogCourseInput struct {
Name string `json:"name" validate:"required"`
Description *string `json:"description,omitempty"`
Category string `json:"category" validate:"required,oneof=IELTS DUOLINGO"`
Thumbnail *string `json:"thumbnail,omitempty"`
}
type UpdateExamPrepCatalogCourseInput struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Category *string `json:"category,omitempty" validate:"omitempty,oneof=IELTS DUOLINGO"`
Thumbnail *string `json:"thumbnail,omitempty"`
SortOrder *int `json:"sort_order,omitempty"`
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -59,12 +59,12 @@ func (s *ArifpayService) InitiateSubscriptionPayment(ctx context.Context, userID
}
// Check if user already has an active subscription
hasActive, err := s.subscriptionStore.HasActiveSubscription(ctx, userID)
hasActive, err := s.subscriptionStore.HasActiveSubscriptionByCategory(ctx, userID, plan.Category)
if err != nil {
return nil, fmt.Errorf("failed to check active subscription: %w", err)
}
if hasActive {
return nil, errors.New("user already has an active subscription")
return nil, errors.New("user already has an active subscription for this category")
}
// Generate unique nonce
@ -573,12 +573,12 @@ func (s *ArifpayService) InitiateDirectPayment(ctx context.Context, userID int64
}
// Check if user already has an active subscription
hasActive, err := s.subscriptionStore.HasActiveSubscription(ctx, userID)
hasActive, err := s.subscriptionStore.HasActiveSubscriptionByCategory(ctx, userID, plan.Category)
if err != nil {
return nil, fmt.Errorf("failed to check active subscription: %w", err)
}
if hasActive {
return nil, errors.New("user already has an active subscription")
return nil, errors.New("user already has an active subscription for this category")
}
// Generate unique nonce

View File

@ -76,12 +76,12 @@ func (s *Service) InitiateSubscriptionPayment(ctx context.Context, userID int64,
return nil, errors.New("subscription plan is not active")
}
hasActive, err := s.subscriptionStore.HasActiveSubscription(ctx, userID)
hasActive, err := s.subscriptionStore.HasActiveSubscriptionByCategory(ctx, userID, plan.Category)
if err != nil {
return nil, fmt.Errorf("failed to check active subscription: %w", err)
}
if hasActive {
return nil, errors.New("user already has an active subscription")
return nil, errors.New("user already has an active subscription for this category")
}
user, err := s.userStore.GetUserByID(ctx, userID)

View File

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

View File

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

View File

@ -59,6 +59,76 @@ func (h *Handler) CreateExamPrepCatalogCourse(c *fiber.Ctx) error {
func (h *Handler) ListExamPrepCatalogCourses(c *fiber.Ctx) error {
limit, _ := strconv.Atoi(c.Query("limit", "20"))
offset, _ := strconv.Atoi(c.Query("offset", "0"))
role, _ := c.Locals("role").(domain.Role)
if role == domain.RoleStudent || role == domain.RoleOpenLearner {
userID, ok := c.Locals("user_id").(int64)
if !ok || userID == 0 {
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
Message: "Unauthorized",
})
}
hasIELTS, err := h.subscriptionsSvc.HasActiveSubscriptionByCategory(c.Context(), userID, domain.SubscriptionCategoryIELTS)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to verify IELTS subscription",
Error: err.Error(),
})
}
hasDuolingo, err := h.subscriptionsSvc.HasActiveSubscriptionByCategory(c.Context(), userID, domain.SubscriptionCategoryDuolingo)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to verify Duolingo subscription",
Error: err.Error(),
})
}
allItems, _, err := h.examPrepSvc.ListCatalogCourses(c.Context(), 200, 0)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to list catalog courses",
Error: err.Error(),
})
}
filtered := make([]domain.ExamPrepCatalogCourse, 0, len(allItems))
for _, item := range allItems {
switch domain.SubscriptionCategory(item.Category) {
case domain.SubscriptionCategoryIELTS:
if hasIELTS {
filtered = append(filtered, item)
}
case domain.SubscriptionCategoryDuolingo:
if hasDuolingo {
filtered = append(filtered, item)
}
}
}
total := len(filtered)
start := offset
if start > total {
start = total
}
end := start + limit
if end > total {
end = total
}
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))
if err != nil {
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(),
})
}
if valErrs, ok := h.validator.Validate(c, req); !ok {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Validation failed",
Error: firstValidationError(valErrs),
})
}
p, err := h.programSvc.Update(c.Context(), id, req)
if err != nil {
if errors.Is(err, programs.ErrProgramNotFound) {

View File

@ -19,6 +19,7 @@ import (
type createPlanReq struct {
Name string `json:"name" validate:"required"`
Description *string `json:"description"`
Category string `json:"category" validate:"required,oneof=LEARN_ENGLISH IELTS DUOLINGO"`
DurationValue int32 `json:"duration_value" validate:"required,min=1"`
DurationUnit string `json:"duration_unit" validate:"required,oneof=DAY WEEK MONTH YEAR"`
Price float64 `json:"price" validate:"required,min=0"`
@ -29,6 +30,7 @@ type createPlanReq struct {
type updatePlanReq struct {
Name *string `json:"name"`
Description *string `json:"description"`
Category *string `json:"category" validate:"omitempty,oneof=LEARN_ENGLISH IELTS DUOLINGO"`
DurationValue *int32 `json:"duration_value"`
DurationUnit *string `json:"duration_unit"`
Price *float64 `json:"price"`
@ -40,6 +42,7 @@ type planRes struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Category string `json:"category"`
DurationValue int32 `json:"duration_value"`
DurationUnit string `json:"duration_unit"`
Price float64 `json:"price"`
@ -110,10 +113,17 @@ func (h *Handler) CreateSubscriptionPlan(c *fiber.Ctx) error {
Error: err.Error(),
})
}
if valErrs, ok := h.validator.Validate(c, req); !ok {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Validation failed",
Error: firstValidationError(valErrs),
})
}
plan, err := h.subscriptionsSvc.CreatePlan(c.Context(), domain.CreateSubscriptionPlanInput{
Name: req.Name,
Description: req.Description,
Category: req.Category,
DurationValue: req.DurationValue,
DurationUnit: req.DurationUnit,
Price: req.Price,
@ -228,10 +238,17 @@ func (h *Handler) UpdateSubscriptionPlan(c *fiber.Ctx) error {
Error: err.Error(),
})
}
if valErrs, ok := h.validator.Validate(c, req); !ok {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Validation failed",
Error: firstValidationError(valErrs),
})
}
err = h.subscriptionsSvc.UpdatePlan(c.Context(), id, domain.UpdateSubscriptionPlanInput{
Name: req.Name,
Description: req.Description,
Category: req.Category,
DurationValue: req.DurationValue,
DurationUnit: req.DurationUnit,
Price: req.Price,
@ -623,6 +640,7 @@ func planToRes(p *domain.SubscriptionPlan) *planRes {
ID: p.ID,
Name: p.Name,
Description: p.Description,
Category: p.Category,
DurationValue: p.DurationValue,
DurationUnit: p.DurationUnit,
Price: p.Price,

View File

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

View File

@ -82,16 +82,16 @@ func (a *App) initAppRoutes() {
// Programs (LMS top-level)
groupV1.Post("/programs", a.authMiddleware, a.RequirePermission("programs.create"), h.CreateProgram)
groupV1.Get("/programs", a.authMiddleware, a.RequirePermission("programs.list"), h.ListPrograms)
groupV1.Get("/programs", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("programs.list"), h.ListPrograms)
groupV1.Put("/programs/reorder", a.authMiddleware, a.RequirePermission("programs.reorder"), h.ReorderPrograms)
groupV1.Get("/lms/progress", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lms.get_my_progress"), h.GetMyLMSProgress)
groupV1.Get("/lms/progress-summary", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lms.get_my_progress"), h.GetMyLMSProgressSummary)
groupV1.Get("/programs/:id", a.authMiddleware, a.RequirePermission("programs.get"), h.GetProgram)
groupV1.Get("/lms/progress", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("lms.get_my_progress"), h.GetMyLMSProgress)
groupV1.Get("/lms/progress-summary", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("lms.get_my_progress"), h.GetMyLMSProgressSummary)
groupV1.Get("/programs/:id", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("programs.get"), h.GetProgram)
groupV1.Put("/programs/:id", a.authMiddleware, a.RequirePermission("programs.update"), h.UpdateProgram)
groupV1.Delete("/programs/:id", a.authMiddleware, a.RequirePermission("programs.delete"), h.DeleteProgram)
// Exam prep (schema exam_prep — separate from LMS Learn English). Students need an active subscription.
examPrep := groupV1.Group("/exam-prep", a.authMiddleware, a.RequireActiveSubscription())
examPrep := groupV1.Group("/exam-prep", a.authMiddleware, a.RequireExamPrepSubscription())
examPrep.Post("/catalog-courses", a.RequirePermission("exam_prep.catalog_courses.create"), h.CreateExamPrepCatalogCourse)
examPrep.Get("/catalog-courses", a.RequirePermission("exam_prep.catalog_courses.list"), h.ListExamPrepCatalogCourses)
examPrep.Put("/catalog-courses/reorder", a.RequirePermission("exam_prep.catalog_courses.reorder"), h.ReorderExamPrepCatalogCourses)
@ -130,32 +130,32 @@ func (a *App) initAppRoutes() {
// Courses
groupV1.Post("/programs/:id/courses", a.authMiddleware, a.RequirePermission("courses.create"), h.CreateCourse)
groupV1.Put("/programs/:id/courses/reorder", a.authMiddleware, a.RequirePermission("courses.reorder"), h.ReorderCoursesInProgram)
groupV1.Get("/programs/:id/courses", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("courses.list_by_program"), h.ListCoursesByProgram)
groupV1.Get("/courses/:id/practices", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("practices.list"), h.ListPracticesByCourse)
groupV1.Get("/courses/:id", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("courses.get"), h.GetCourse)
groupV1.Get("/programs/:id/courses", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("courses.list_by_program"), h.ListCoursesByProgram)
groupV1.Get("/courses/:id/practices", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("practices.list"), h.ListPracticesByCourse)
groupV1.Get("/courses/:id", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("courses.get"), h.GetCourse)
groupV1.Put("/courses/:id", a.authMiddleware, a.RequirePermission("courses.update"), h.UpdateCourse)
groupV1.Delete("/courses/:id", a.authMiddleware, a.RequirePermission("courses.delete"), h.DeleteCourse)
groupV1.Post("/courses/:courseId/modules", a.authMiddleware, a.RequirePermission("modules.create"), h.CreateModule)
groupV1.Put("/courses/:courseId/modules/reorder", a.authMiddleware, a.RequirePermission("modules.reorder"), h.ReorderModulesInCourse)
groupV1.Get("/courses/:courseId/modules", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("modules.list_by_course"), h.ListModulesByCourse)
groupV1.Get("/courses/:courseId/modules", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("modules.list_by_course"), h.ListModulesByCourse)
// /modules/:moduleId/lessons before /modules/:id; /modules/:id/practices before /modules/:id
groupV1.Post("/modules/:moduleId/lessons", a.authMiddleware, a.RequirePermission("lessons.create"), h.CreateLesson)
groupV1.Get("/modules/:moduleId/lessons", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lessons.list_by_module"), h.ListLessonsByModule)
groupV1.Get("/modules/:id/practices", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("practices.list"), h.ListPracticesByModule)
groupV1.Get("/modules/:id", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("modules.get"), h.GetModule)
groupV1.Get("/modules/:moduleId/lessons", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("lessons.list_by_module"), h.ListLessonsByModule)
groupV1.Get("/modules/:id/practices", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("practices.list"), h.ListPracticesByModule)
groupV1.Get("/modules/:id", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("modules.get"), h.GetModule)
groupV1.Put("/modules/:id", a.authMiddleware, a.RequirePermission("modules.update"), h.UpdateModule)
groupV1.Delete("/modules/:id", a.authMiddleware, a.RequirePermission("modules.delete"), h.DeleteModule)
groupV1.Get("/lessons/:id/practices", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("practices.list"), h.ListPracticesByLesson)
groupV1.Post("/lessons/:id/complete", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lessons.complete"), h.CompleteLesson)
groupV1.Post("/videos/engagement/heartbeat", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("videos.track_engagement"), h.RecordVideoEngagementHeartbeat)
groupV1.Post("/progress/practices/:id/complete", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("progress.complete"), h.CompletePractice)
groupV1.Get("/lessons/:id", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lessons.get"), h.GetLesson)
groupV1.Get("/lessons/:id/practices", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("practices.list"), h.ListPracticesByLesson)
groupV1.Post("/lessons/:id/complete", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("lessons.complete"), h.CompleteLesson)
groupV1.Post("/videos/engagement/heartbeat", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("videos.track_engagement"), h.RecordVideoEngagementHeartbeat)
groupV1.Post("/progress/practices/:id/complete", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("progress.complete"), h.CompletePractice)
groupV1.Get("/lessons/:id", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("lessons.get"), h.GetLesson)
groupV1.Put("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.update"), h.UpdateLesson)
groupV1.Delete("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.delete"), h.DeleteLesson)
groupV1.Post("/practices", a.authMiddleware, a.RequirePermission("practices.create"), h.CreatePractice)
groupV1.Get("/practices/:id", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("practices.get"), h.GetPractice)
groupV1.Get("/practices/:id", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("practices.get"), h.GetPractice)
groupV1.Put("/practices/:id", a.authMiddleware, a.RequirePermission("practices.update"), h.UpdatePractice)
groupV1.Delete("/practices/:id", a.authMiddleware, a.RequirePermission("practices.delete"), h.DeletePractice)
@ -240,7 +240,7 @@ func (a *App) initAppRoutes() {
// Question Set Items
groupV1.Post("/question-sets/:setId/questions", a.authMiddleware, a.RequirePermission("question_set_items.add"), h.AddQuestionToSet)
groupV1.Get("/question-sets/:setId/questions", a.authMiddleware, a.RequirePermission("question_set_items.list"), h.GetQuestionsInSet)
groupV1.Get("/practices/:practiceId/questions", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("question_set_items.list"), h.GetQuestionsByPractice)
groupV1.Get("/practices/:practiceId/questions", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("question_set_items.list"), h.GetQuestionsByPractice)
groupV1.Delete("/question-sets/:setId/questions/:questionId", a.authMiddleware, a.RequirePermission("question_set_items.remove"), h.RemoveQuestionFromSet)
groupV1.Put("/question-sets/:setId/questions/:questionId/order", a.authMiddleware, a.RequirePermission("question_set_items.update_order"), h.UpdateQuestionOrderInSet)
@ -273,6 +273,7 @@ func (a *App) initAppRoutes() {
groupV1.Get("/payments/:id", a.authMiddleware, a.RequirePermission("payments.get"), h.GetPaymentByID)
groupV1.Post("/payments/:id/cancel", a.authMiddleware, a.RequirePermission("payments.cancel"), h.CancelPayment)
groupV1.Post("/payments/webhook", h.HandleChapaWebhook)
groupV1.Get("/payments/arifpay/success", h.HandleArifpaySuccessPage)
groupV1.Get("/payments/chapa/callback", h.HandleChapaCallback)
// Direct Payments