diff --git a/db/migrations/000070_subscription_content_categories.down.sql b/db/migrations/000070_subscription_content_categories.down.sql new file mode 100644 index 0000000..5baf11d --- /dev/null +++ b/db/migrations/000070_subscription_content_categories.down.sql @@ -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; diff --git a/db/migrations/000070_subscription_content_categories.up.sql b/db/migrations/000070_subscription_content_categories.up.sql new file mode 100644 index 0000000..28d0730 --- /dev/null +++ b/db/migrations/000070_subscription_content_categories.up.sql @@ -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); diff --git a/db/query/exam_prep_catalog_courses.sql b/db/query/exam_prep_catalog_courses.sql index f2789f0..a55d3f1 100644 --- a/db/query/exam_prep_catalog_courses.sql +++ b/db/query/exam_prep_catalog_courses.sql @@ -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 diff --git a/db/query/programs.sql b/db/query/programs.sql index dd3ca74..8c5ce07 100644 --- a/db/query/programs.sql +++ b/db/query/programs.sql @@ -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 diff --git a/db/query/subscriptions.sql b/db/query/subscriptions.sql index dcdd32a..ed961c5 100644 --- a/db/query/subscriptions.sql +++ b/db/query/subscriptions.sql @@ -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 diff --git a/docs/docs.go b/docs/docs.go index d6c01f7..e6c8f32 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -4619,6 +4619,46 @@ const docTemplate = `{ } } }, + "/api/v1/payments/arifpay/success": { + "get": { + "description": "Displays the Yimaru Academy success page after ArifPay redirects the learner back to the backend.", + "produces": [ + "text/html" + ], + "tags": [ + "payments" + ], + "summary": "ArifPay payment success page", + "parameters": [ + { + "type": "string", + "description": "ArifPay session identifier", + "name": "session_id", + "in": "query" + }, + { + "type": "string", + "description": "ArifPay session identifier", + "name": "sessionId", + "in": "query" + }, + { + "type": "string", + "description": "Fallback payment nonce", + "name": "nonce", + "in": "query" + } + ], + "responses": { + "200": { + "description": "HTML success page", + "schema": { + "type": "string" + } + } + } + } + }, "/api/v1/payments/chapa/callback": { "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" }, diff --git a/docs/swagger.json b/docs/swagger.json index fb398c7..4c24a0e 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index b7d9056..a1dd30e 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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 diff --git a/gen/db/exam_prep_catalog_courses.sql.go b/gen/db/exam_prep_catalog_courses.sql.go index 0bc2705..355c7e5 100644 --- a/gen/db/exam_prep_catalog_courses.sql.go +++ b/gen/db/exam_prep_catalog_courses.sql.go @@ -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 } diff --git a/gen/db/lms_progress.sql.go b/gen/db/lms_progress.sql.go index 52e420d..b033024 100644 --- a/gen/db/lms_progress.sql.go +++ b/gen/db/lms_progress.sql.go @@ -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 } diff --git a/gen/db/models.go b/gen/db/models.go index d897574..8651f3b 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -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 { diff --git a/gen/db/programs.sql.go b/gen/db/programs.sql.go index 907e4e6..885d515 100644 --- a/gen/db/programs.sql.go +++ b/gen/db/programs.sql.go @@ -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 } diff --git a/gen/db/subscriptions.sql.go b/gen/db/subscriptions.sql.go index 05255e6..b6f65b8 100644 --- a/gen/db/subscriptions.sql.go +++ b/gen/db/subscriptions.sql.go @@ -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, diff --git a/internal/domain/exam_prep_catalog_course.go b/internal/domain/exam_prep_catalog_course.go index 4384c49..cfc7f3b 100644 --- a/internal/domain/exam_prep_catalog_course.go +++ b/internal/domain/exam_prep_catalog_course.go @@ -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"` } diff --git a/internal/domain/program.go b/internal/domain/program.go index 4bddd45..b0944ab 100644 --- a/internal/domain/program.go +++ b/internal/domain/program.go @@ -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"` } diff --git a/internal/domain/subscriptions.go b/internal/domain/subscriptions.go index d5e7228..680a101 100644 --- a/internal/domain/subscriptions.go +++ b/internal/domain/subscriptions.go @@ -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 diff --git a/internal/ports/subscriptions.go b/internal/ports/subscriptions.go index 2f93d3b..d0e5ef8 100644 --- a/internal/ports/subscriptions.go +++ b/internal/ports/subscriptions.go @@ -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 diff --git a/internal/repository/exam_prep_catalog_courses.go b/internal/repository/exam_prep_catalog_courses.go index 903d98a..035a836 100644 --- a/internal/repository/exam_prep_catalog_courses.go +++ b/internal/repository/exam_prep_catalog_courses.go @@ -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), }) diff --git a/internal/repository/programs.go b/internal/repository/programs.go index 8243d23..7b51cc0 100644 --- a/internal/repository/programs.go +++ b/internal/repository/programs.go @@ -14,8 +14,9 @@ import ( func programToDomain(p dbgen.Program) domain.Program { out := domain.Program{ - ID: p.ID, - Name: p.Name, + 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, }) diff --git a/internal/repository/subscriptions.go b/internal/repository/subscriptions.go index 9c30299..21fef62 100644 --- a/internal/repository/subscriptions.go +++ b/internal/repository/subscriptions.go @@ -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 { diff --git a/internal/services/arifpay/service.go b/internal/services/arifpay/service.go index a4c308b..65b0365 100644 --- a/internal/services/arifpay/service.go +++ b/internal/services/arifpay/service.go @@ -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 diff --git a/internal/services/chapa/service.go b/internal/services/chapa/service.go index 1674a4d..cdddf3f 100644 --- a/internal/services/chapa/service.go +++ b/internal/services/chapa/service.go @@ -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) diff --git a/internal/services/subscriptions/service.go b/internal/services/subscriptions/service.go index 57d4b4d..b2549df 100644 --- a/internal/services/subscriptions/service.go +++ b/internal/services/subscriptions/service.go @@ -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 { diff --git a/internal/web_server/handlers/arifpay.go b/internal/web_server/handlers/arifpay.go index b5ec974..b335b74 100644 --- a/internal/web_server/handlers/arifpay.go +++ b/internal/web_server/handlers/arifpay.go @@ -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 = ` + + + + + {{.Title}} + + + + + + +
+ + + + + + + + + + +
+
{{.BadgeLabel}}
+

Yimaru Academy

+

{{.Headline}}

+
+
+

{{.Body}}

+ {{if .Helper}}

{{.Helper}}

{{end}} + + + + +
+

Status

+

{{.StatusLabel}}

+ {{if .PlanName}}

Plan: {{.PlanName}}

{{end}} + {{if .Reference}}

Reference: {{.Reference}}

{{end}} +
+ +
+

Yimaru Academy subscription payments are verified securely before access is granted.

+
+
+ +` + + 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 +} diff --git a/internal/web_server/handlers/exam_prep_catalog_course_handler.go b/internal/web_server/handlers/exam_prep_catalog_course_handler.go index 01a8d5e..467282b 100644 --- a/internal/web_server/handlers/exam_prep_catalog_course_handler.go +++ b/internal/web_server/handlers/exam_prep_catalog_course_handler.go @@ -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{ diff --git a/internal/web_server/handlers/program_handler.go b/internal/web_server/handlers/program_handler.go index 1296ae1..cd34ae4 100644 --- a/internal/web_server/handlers/program_handler.go +++ b/internal/web_server/handlers/program_handler.go @@ -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) { diff --git a/internal/web_server/handlers/subscriptions.go b/internal/web_server/handlers/subscriptions.go index c7d14ce..4d7dd86 100644 --- a/internal/web_server/handlers/subscriptions.go +++ b/internal/web_server/handlers/subscriptions.go @@ -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, diff --git a/internal/web_server/middleware.go b/internal/web_server/middleware.go index 3071016..0e2d303 100644 --- a/internal/web_server/middleware.go +++ b/internal/web_server/middleware.go @@ -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) diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 1d3133a..e17fd61 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -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