Add OPEN_LEARNER role without LMS sequential gating.

Migration 000061 inserts the RBAC role and demo user (openlearner@yimaru.com). STUDENT keeps sequential ApplyAccess and practice ordering; OPEN_LEARNER shares learner permissions and customer flows. Document the role in Swagger and point initial seed SQL at the migration for the demo account.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-05-19 10:26:25 -07:00
parent 83db13bed0
commit 7e61e34292
15 changed files with 243 additions and 132 deletions

View File

@ -4,6 +4,7 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ======================================================
-- Customer/Learner Users (login via /api/v1/auth/customer-login)
-- Credentials: email + password@123
-- OPEN_LEARNER demo user is seeded by migration 000061_open_learner_role (not here).
-- ======================================================
INSERT INTO users (
id,

View File

@ -0,0 +1,5 @@
DELETE FROM users WHERE id = 13 AND email = 'openlearner@yimaru.com';
DELETE FROM role_permissions WHERE role_id = (SELECT id FROM roles WHERE name = 'OPEN_LEARNER');
DELETE FROM roles WHERE name = 'OPEN_LEARNER';

View File

@ -0,0 +1,79 @@
-- OPEN_LEARNER: learner role with STUDENT-like RBAC but without LMS sequential prerequisite locks (handled in app code).
CREATE EXTENSION IF NOT EXISTS pgcrypto;
INSERT INTO roles (name, description, is_system) VALUES
(
'OPEN_LEARNER',
'Learner with full LMS catalog access without sequential prerequisite locking',
TRUE
)
ON CONFLICT (name) DO NOTHING;
-- Demo OPEN_LEARNER (customer-login): openlearner@yimaru.com / password@123
INSERT INTO users (
id,
first_name,
last_name,
gender,
birth_day,
email,
phone_number,
role,
password,
age_group,
education_level,
country,
region,
knowledge_level,
nick_name,
occupation,
learning_goal,
language_goal,
language_challange,
favourite_topic,
initial_assessment_completed,
email_verified,
phone_verified,
status,
last_login,
profile_completed,
profile_picture_url,
preferred_language,
created_at,
updated_at
)
VALUES
(
13,
'Demo',
'OpenLearner',
'Female',
'1999-06-01',
'openlearner@yimaru.com',
NULL,
'OPEN_LEARNER',
crypt('password@123', gen_salt('bf'))::bytea,
'25_34',
'Bachelor',
'Ethiopia',
'Addis Ababa',
'BEGINNER',
'OpenLearner',
'Tester',
'Preview LMS content without sequential locks',
'English',
'Grammar',
'Technology',
FALSE,
TRUE,
FALSE,
'ACTIVE',
NULL,
FALSE,
NULL,
'en',
CURRENT_TIMESTAMP,
NULL
)
ON CONFLICT (id) DO NOTHING;

View File

@ -11063,6 +11063,7 @@ const docTemplate = `{
"SUPER_ADMIN",
"ADMIN",
"STUDENT",
"OPEN_LEARNER",
"INSTRUCTOR",
"SUPPORT"
],
@ -11070,6 +11071,7 @@ const docTemplate = `{
"RoleSuperAdmin",
"RoleAdmin",
"RoleStudent",
"RoleOpenLearner",
"RoleInstructor",
"RoleSupport"
]

View File

@ -11055,6 +11055,7 @@
"SUPER_ADMIN",
"ADMIN",
"STUDENT",
"OPEN_LEARNER",
"INSTRUCTOR",
"SUPPORT"
],
@ -11062,6 +11063,7 @@
"RoleSuperAdmin",
"RoleAdmin",
"RoleStudent",
"RoleOpenLearner",
"RoleInstructor",
"RoleSupport"
]

View File

@ -813,6 +813,7 @@ definitions:
- SUPER_ADMIN
- ADMIN
- STUDENT
- OPEN_LEARNER
- INSTRUCTOR
- SUPPORT
type: string
@ -820,6 +821,7 @@ definitions:
- RoleSuperAdmin
- RoleAdmin
- RoleStudent
- RoleOpenLearner
- RoleInstructor
- RoleSupport
domain.RoleRecord:

View File

@ -31,7 +31,7 @@ const (
NOTIFICATION_TYPE_ADMIN_CREATED NotificationType = "admin_created"
NOTIFICATION_TYPE_TEAM_MEMBER_CREATED NotificationType = "team_member_created"
NOTIFICATION_TYPE_USER_DELETED NotificationType = "user_deleted"
NOTIFICATION_TYPE_SYSTEM_ALERT NotificationType = "system_alert"
NOTIFICATION_TYPE_SYSTEM_ALERT NotificationType = "system_alert"
NotificationRecieverSideAdmin NotificationRecieverSide = "admin"
NotificationRecieverSideCustomer NotificationRecieverSide = "customer"
@ -137,6 +137,8 @@ func ReceiverFromRole(role Role) NotificationRecieverSide {
return NotificationRecieverSideAdmin
case RoleStudent:
return NotificationRecieverSideCustomer
case RoleOpenLearner:
return NotificationRecieverSideCustomer
case RoleInstructor:
return NotificationRecieverSideCustomer
default:

View File

@ -6,19 +6,31 @@ const (
RoleSuperAdmin Role = "SUPER_ADMIN"
RoleAdmin Role = "ADMIN"
RoleStudent Role = "STUDENT"
RoleInstructor Role = "INSTRUCTOR"
RoleSupport Role = "SUPPORT"
// RoleOpenLearner can consume LMS content like a learner but without sequential prerequisite locking (step-by-step gates).
RoleOpenLearner Role = "OPEN_LEARNER"
RoleInstructor Role = "INSTRUCTOR"
RoleSupport Role = "SUPPORT"
)
func (r Role) IsValid() bool {
switch r {
case RoleSuperAdmin, RoleAdmin, RoleStudent, RoleInstructor, RoleSupport:
case RoleSuperAdmin, RoleAdmin, RoleStudent, RoleOpenLearner, RoleInstructor, RoleSupport:
return true
default:
return false
}
}
// UsesLMSSequentialGating is true when LMS APIs apply sequential prerequisite locks (403 when blocked).
func (r Role) UsesLMSSequentialGating() bool {
return r == RoleStudent
}
// IsCustomerLearnerRole is true for platform roles that sign in as customers and consume learner-facing LMS APIs.
func (r Role) IsCustomerLearnerRole() bool {
return r == RoleStudent || r == RoleOpenLearner
}
func (r Role) Value() string {
return string(r)
}

View File

@ -146,7 +146,7 @@ func (s *Service) CanAccessLesson(ctx context.Context, userID, lessonID int64) (
// ApplyAccessProgram sets p.Access for a learner. Non-learners: clears Access to omit from JSON.
func (s *Service) ApplyAccessProgram(ctx context.Context, role domain.Role, userID int64, p *domain.Program) error {
if role != domain.RoleStudent {
if !role.UsesLMSSequentialGating() {
p.Access = nil
return nil
}
@ -172,7 +172,7 @@ func (s *Service) ApplyAccessProgram(ctx context.Context, role domain.Role, user
// ApplyAccessCourse sets c.Access for a learner.
func (s *Service) ApplyAccessCourse(ctx context.Context, role domain.Role, userID int64, c *domain.Course) error {
if role != domain.RoleStudent {
if !role.UsesLMSSequentialGating() {
c.Access = nil
return nil
}
@ -198,7 +198,7 @@ func (s *Service) ApplyAccessCourse(ctx context.Context, role domain.Role, userI
// ApplyAccessModule sets m.Access for a learner.
func (s *Service) ApplyAccessModule(ctx context.Context, role domain.Role, userID int64, m *domain.Module) error {
if role != domain.RoleStudent {
if !role.UsesLMSSequentialGating() {
m.Access = nil
return nil
}
@ -224,7 +224,7 @@ func (s *Service) ApplyAccessModule(ctx context.Context, role domain.Role, userI
// ApplyAccessLesson sets l.Access for a learner.
func (s *Service) ApplyAccessLesson(ctx context.Context, role domain.Role, userID int64, les *domain.Lesson) error {
if role != domain.RoleStudent {
if !role.UsesLMSSequentialGating() {
les.Access = nil
return nil
}

View File

@ -302,10 +302,71 @@ var AllPermissions = []domain.PermissionSeed{
{Key: "internal.db.reset_reseed", Name: "Reset And Reseed Database", Description: "Dangerous operation: clears all data and re-seeds from SQL files", GroupName: "Internal Operations"},
}
// defaultStudentLearnerPermissions is the learner consumption permission set shared by STUDENT and OPEN_LEARNER.
// LMS sequential prerequisite locking applies only to STUDENT in application handlers.
var defaultStudentLearnerPermissions = []string{
// Course browsing
"course_categories.list", "course_categories.get",
"courses.get", "courses.list_by_program",
"modules.get", "modules.list_by_course",
"lessons.get", "lessons.list_by_module", "lessons.complete",
"practices.get", "practices.list",
"subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active",
"videos.get", "videos.list_by_subcourse", "videos.list_published",
"learning_tree.get",
"programs.list", "programs.get",
"exam_prep.catalog_courses.list", "exam_prep.catalog_courses.get",
"exam_prep.units.list", "exam_prep.units.get",
"exam_prep.modules.list", "exam_prep.modules.get",
"exam_prep.lessons.list_by_module", "exam_prep.lessons.get",
"exam_prep.practices.list_by_lesson", "exam_prep.practices.get",
"lms.get_my_progress",
// Questions (read + attempt)
"questions.list", "questions.search", "questions.get",
"question_sets.list", "question_sets.list_by_owner", "question_sets.get",
"question_set_items.list",
"question_set_personas.list",
// Subscriptions & Payments (own)
"subscriptions.checkout", "subscriptions.get_mine", "subscriptions.history",
"subscriptions.status", "subscriptions.cancel", "subscriptions.set_auto_renew",
"payments.initiate", "payments.verify", "payments.list_mine", "payments.get", "payments.cancel",
"payments.direct_initiate", "payments.direct_verify_otp",
// User (self-service)
"users.update_self", "users.delete_self", "users.cancel_delete_self", "users.profile_completed", "users.upload_profile_picture", "users.user_profile",
// Notifications (own)
"notifications.ws_connect", "notifications.list_mine", "notifications.list_all",
"notifications.mark_read", "notifications.mark_all_read", "notifications.mark_unread", "notifications.mark_all_unread",
"notifications.delete_mine", "notifications.count_unread",
"notifications.test_push",
// Issues (own)
"issues.create", "issues.list_mine",
// Devices
"devices.register", "devices.unregister",
// Progress
"progress.start", "progress.update", "progress.complete", "progress.check_access", "progress.get_course",
// Sub-course Prerequisites (read)
"subcourse_prerequisites.list",
// Ratings
"ratings.submit", "ratings.list_by_target", "ratings.summary", "ratings.get_mine", "ratings.list_mine", "ratings.delete",
// Auth
"auth.logout",
}
// DefaultRolePermissions maps each system role to the permission keys it should
// have by default. This preserves the previous middleware behavior:
// - ADMIN: everything that was previously OnlyAdminAndAbove + SuperAdminOnly + all authenticated routes
// - STUDENT/INSTRUCTOR/SUPPORT: only self-service endpoints (profile, courses, progress, etc.)
// - STUDENT/OPEN_LEARNER/INSTRUCTOR/SUPPORT: only self-service endpoints (profile, courses, progress, etc.)
var DefaultRolePermissions = map[string][]string{
"ADMIN": {
// Course Management (full access)
@ -409,64 +470,9 @@ var DefaultRolePermissions = map[string][]string{
"internal.db.reset_reseed",
},
"STUDENT": {
// Course browsing
"course_categories.list", "course_categories.get",
"courses.get", "courses.list_by_program",
"modules.get", "modules.list_by_course",
"lessons.get", "lessons.list_by_module", "lessons.complete",
"practices.get", "practices.list",
"subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active",
"videos.get", "videos.list_by_subcourse", "videos.list_published",
"learning_tree.get",
"STUDENT": defaultStudentLearnerPermissions,
"programs.list", "programs.get",
"exam_prep.catalog_courses.list", "exam_prep.catalog_courses.get",
"exam_prep.units.list", "exam_prep.units.get",
"exam_prep.modules.list", "exam_prep.modules.get",
"exam_prep.lessons.list_by_module", "exam_prep.lessons.get",
"exam_prep.practices.list_by_lesson", "exam_prep.practices.get",
"lms.get_my_progress",
// Questions (read + attempt)
"questions.list", "questions.search", "questions.get",
"question_sets.list", "question_sets.list_by_owner", "question_sets.get",
"question_set_items.list",
"question_set_personas.list",
// Subscriptions & Payments (own)
"subscriptions.checkout", "subscriptions.get_mine", "subscriptions.history",
"subscriptions.status", "subscriptions.cancel", "subscriptions.set_auto_renew",
"payments.initiate", "payments.verify", "payments.list_mine", "payments.get", "payments.cancel",
"payments.direct_initiate", "payments.direct_verify_otp",
// User (self-service)
"users.update_self", "users.delete_self", "users.cancel_delete_self", "users.profile_completed", "users.upload_profile_picture", "users.user_profile",
// Notifications (own)
"notifications.ws_connect", "notifications.list_mine", "notifications.list_all",
"notifications.mark_read", "notifications.mark_all_read", "notifications.mark_unread", "notifications.mark_all_unread",
"notifications.delete_mine", "notifications.count_unread",
"notifications.test_push",
// Issues (own)
"issues.create", "issues.list_mine",
// Devices
"devices.register", "devices.unregister",
// Progress
"progress.start", "progress.update", "progress.complete", "progress.check_access", "progress.get_course",
// Sub-course Prerequisites (read)
"subcourse_prerequisites.list",
// Ratings
"ratings.submit", "ratings.list_by_target", "ratings.summary", "ratings.get_mine", "ratings.list_mine", "ratings.delete",
// Auth
"auth.logout",
},
"OPEN_LEARNER": defaultStudentLearnerPermissions,
"INSTRUCTOR": {
// Course browsing + management

View File

@ -357,7 +357,7 @@ func (h *Handler) LoginAdmin(c *fiber.Ctx) error {
}
}
if successRes.Role == domain.RoleStudent || successRes.Role == domain.RoleInstructor {
if successRes.Role == domain.RoleStudent || successRes.Role == domain.RoleOpenLearner || successRes.Role == domain.RoleInstructor {
h.mongoLoggerSvc.Warn("Login attempt: admin login of user",
zap.Int("status_code", fiber.StatusForbidden),
zap.String("role", string(successRes.Role)),

View File

@ -279,7 +279,7 @@ func (h *Handler) CompleteLesson(c *fiber.Ctx) error {
}
uid := c.Locals("user_id").(int64)
role := c.Locals("role").(domain.Role)
if role == domain.RoleStudent {
if role.UsesLMSSequentialGating() {
ok, reason, err := h.lmsProgressSvc.CanAccessLesson(c.Context(), uid, id)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{

View File

@ -737,7 +737,7 @@ func isSequenceGatedPractice(set domain.QuestionSet) bool {
func (h *Handler) enforcePracticeSequenceForStudent(c *fiber.Ctx, set domain.QuestionSet) error {
role := c.Locals("role").(domain.Role)
if role != domain.RoleStudent || !isSequenceGatedPractice(set) {
if !role.UsesLMSSequentialGating() || !isSequenceGatedPractice(set) {
return nil
}
if !strings.EqualFold(set.Status, "PUBLISHED") {
@ -1547,7 +1547,7 @@ func (h *Handler) GetQuestionsByPractice(c *fiber.Ctx) error {
// @Router /api/v1/progress/practices/{id}/complete [post]
func (h *Handler) CompletePractice(c *fiber.Ctx) error {
role := c.Locals("role").(domain.Role)
if role != domain.RoleStudent {
if !role.IsCustomerLearnerRole() {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "Only learners can complete practices",
})

View File

@ -1470,18 +1470,18 @@ func (h *Handler) GetUserProfile(c *fiber.Ctx) error {
}
return ""
}(),
EducationLevel: user.EducationLevel,
Country: user.Country,
Region: user.Region,
EmailVerified: user.EmailVerified,
PhoneVerified: user.PhoneVerified,
Status: user.Status,
LastLogin: lastLogin,
ProfileCompleted: user.ProfileCompleted,
ProfilePictureURL: user.ProfilePictureURL,
PreferredLanguage: user.PreferredLanguage,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
EducationLevel: user.EducationLevel,
Country: user.Country,
Region: user.Region,
EmailVerified: user.EmailVerified,
PhoneVerified: user.PhoneVerified,
Status: user.Status,
LastLogin: lastLogin,
ProfileCompleted: user.ProfileCompleted,
ProfilePictureURL: user.ProfilePictureURL,
PreferredLanguage: user.PreferredLanguage,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
SubscriptionStatus: subscriptionStatus,
}
return response.WriteJSON(c, fiber.StatusOK, "User profile retrieved successfully", res, nil)
@ -1567,22 +1567,22 @@ func (h *Handler) AdminProfile(c *fiber.Ctx) error {
FirstName: user.FirstName,
LastName: user.LastName,
// UserName: user.UserName,
Email: user.Email,
PhoneNumber: user.PhoneNumber,
Role: user.Role,
AgeGroup: user.AgeGroup,
EducationLevel: user.EducationLevel,
Country: user.Country,
Region: user.Region,
EmailVerified: user.EmailVerified,
PhoneVerified: user.PhoneVerified,
Status: user.Status,
LastLogin: lastLogin,
ProfileCompleted: user.ProfileCompleted,
ProfilePictureURL: user.ProfilePictureURL,
PreferredLanguage: user.PreferredLanguage,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
Email: user.Email,
PhoneNumber: user.PhoneNumber,
Role: user.Role,
AgeGroup: user.AgeGroup,
EducationLevel: user.EducationLevel,
Country: user.Country,
Region: user.Region,
EmailVerified: user.EmailVerified,
PhoneVerified: user.PhoneVerified,
Status: user.Status,
LastLogin: lastLogin,
ProfileCompleted: user.ProfileCompleted,
ProfilePictureURL: user.ProfilePictureURL,
PreferredLanguage: user.PreferredLanguage,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
SubscriptionStatus: subscriptionStatus,
}
// Ensure birthday is included and formatted
@ -1721,21 +1721,21 @@ func (h *Handler) SearchUserByNameOrPhone(c *fiber.Ctx) error {
}
return ""
}(),
PhoneNumber: user.PhoneNumber,
Role: user.Role,
AgeGroup: user.AgeGroup,
EducationLevel: user.EducationLevel,
Country: user.Country,
Region: user.Region,
EmailVerified: user.EmailVerified,
PhoneVerified: user.PhoneVerified,
Status: user.Status,
LastLogin: lastLogin,
ProfileCompleted: user.ProfileCompleted,
ProfilePictureURL: user.ProfilePictureURL,
PreferredLanguage: user.PreferredLanguage,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
PhoneNumber: user.PhoneNumber,
Role: user.Role,
AgeGroup: user.AgeGroup,
EducationLevel: user.EducationLevel,
Country: user.Country,
Region: user.Region,
EmailVerified: user.EmailVerified,
PhoneVerified: user.PhoneVerified,
Status: user.Status,
LastLogin: lastLogin,
ProfileCompleted: user.ProfileCompleted,
ProfilePictureURL: user.ProfilePictureURL,
PreferredLanguage: user.PreferredLanguage,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
SubscriptionStatus: subStatus,
})
}
@ -1828,22 +1828,22 @@ func (h *Handler) GetUserByID(c *fiber.Ctx) error {
Occupation: user.Occupation,
FavouriteTopic: user.FavouriteTopic,
// UserName: user.UserName,
Email: user.Email,
PhoneNumber: user.PhoneNumber,
Role: user.Role,
AgeGroup: user.AgeGroup,
EducationLevel: user.EducationLevel,
Country: user.Country,
Region: user.Region,
EmailVerified: user.EmailVerified,
PhoneVerified: user.PhoneVerified,
Status: user.Status,
LastLogin: lastLogin,
ProfileCompleted: user.ProfileCompleted,
ProfilePictureURL: user.ProfilePictureURL,
PreferredLanguage: user.PreferredLanguage,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
Email: user.Email,
PhoneNumber: user.PhoneNumber,
Role: user.Role,
AgeGroup: user.AgeGroup,
EducationLevel: user.EducationLevel,
Country: user.Country,
Region: user.Region,
EmailVerified: user.EmailVerified,
PhoneVerified: user.PhoneVerified,
Status: user.Status,
LastLogin: lastLogin,
ProfileCompleted: user.ProfileCompleted,
ProfilePictureURL: user.ProfilePictureURL,
PreferredLanguage: user.PreferredLanguage,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
SubscriptionStatus: subscriptionStatus,
}
@ -1933,7 +1933,7 @@ func (h *Handler) DeleteMyUserAccount(c *fiber.Ctx) error {
if !ok {
return fiber.NewError(fiber.StatusUnauthorized, "Invalid authenticated role")
}
if role != domain.RoleStudent {
if !role.IsCustomerLearnerRole() {
return fiber.NewError(fiber.StatusForbidden, "Only learners can delete their own account using this endpoint")
}
@ -1986,7 +1986,7 @@ func (h *Handler) CancelMyUserAccountDeletion(c *fiber.Ctx) error {
if !ok {
return fiber.NewError(fiber.StatusUnauthorized, "Invalid authenticated role")
}
if role != domain.RoleStudent {
if !role.IsCustomerLearnerRole() {
return fiber.NewError(fiber.StatusForbidden, "Only learners can cancel their own account deletion using this endpoint")
}

View File

@ -183,7 +183,7 @@ func (a *App) RequireActiveSubscription() fiber.Handler {
switch role {
case domain.RoleSuperAdmin, domain.RoleAdmin, domain.RoleInstructor, domain.RoleSupport:
return c.Next()
case domain.RoleStudent:
case domain.RoleStudent, domain.RoleOpenLearner:
userID, ok := c.Locals("user_id").(int64)
if !ok || userID == 0 {
return fiber.NewError(fiber.StatusUnauthorized, "Unauthorized")