diff --git a/cmd/main.go b/cmd/main.go index c3150ed..d315b08 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -26,6 +26,7 @@ import ( activitylogservice "Yimaru-Backend/internal/services/activity_log" cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" ratingsservice "Yimaru-Backend/internal/services/ratings" + rbacservice "Yimaru-Backend/internal/services/rbac" vimeoservice "Yimaru-Backend/internal/services/vimeo" "context" @@ -361,6 +362,7 @@ func main() { courseSvc := course_management.NewService( repository.NewUserStore(store), repository.NewCourseStore(store), + repository.NewProgressionStore(store), notificationSvc, cfg, ) @@ -407,6 +409,18 @@ func main() { // Ratings service ratingSvc := ratingsservice.NewService(repository.NewRatingStore(store)) + // RBAC service + rbacSvc := rbacservice.NewService(repository.NewRBACStore(store), logger) + if err := rbacSvc.SeedPermissions(context.Background()); err != nil { + log.Fatalf("failed to seed RBAC permissions: %v", err) + } + if err := rbacSvc.SeedDefaultRolePermissions(context.Background()); err != nil { + log.Fatalf("failed to seed default role permissions: %v", err) + } + if err := rbacSvc.Reload(context.Background()); err != nil { + log.Fatalf("failed to load RBAC cache: %v", err) + } + // Initialize and start HTTP server app := httpserver.NewApp( assessmentSvc, @@ -436,6 +450,7 @@ func main() { cfg, domain.MongoDBLogger, analyticsDB, + rbacSvc, ) logger.Info("Starting server", "port", cfg.Port) diff --git a/db/migrations/000012_profile_completion.up.sql b/db/migrations/000012_profile_completion.up.sql index 9da668a..65a0257 100644 --- a/db/migrations/000012_profile_completion.up.sql +++ b/db/migrations/000012_profile_completion.up.sql @@ -37,11 +37,6 @@ BEGIN filled_count := filled_count + 1; END IF; - -- Check knowledge_level - IF NULLIF(TRIM(NEW.knowledge_level), '') IS NOT NULL THEN - filled_count := filled_count + 1; - END IF; - -- Check learning_goal IF NULLIF(TRIM(NEW.learning_goal), '') IS NOT NULL THEN filled_count := filled_count + 1; @@ -52,8 +47,8 @@ BEGIN filled_count := filled_count + 1; END IF; - -- Calculate percentage (9 total required fields) - NEW.profile_completion_percentage := (filled_count * 100 / 9)::SMALLINT; + -- Calculate percentage (8 total required fields) + NEW.profile_completion_percentage := (filled_count * 100 / 8)::SMALLINT; -- Set profile_completed if 100% IF NEW.profile_completion_percentage = 100 THEN diff --git a/db/migrations/000018_course_intro_video.down.sql b/db/migrations/000018_course_intro_video.down.sql new file mode 100644 index 0000000..e57c0ac --- /dev/null +++ b/db/migrations/000018_course_intro_video.down.sql @@ -0,0 +1 @@ +ALTER TABLE courses DROP COLUMN IF EXISTS intro_video_url; diff --git a/db/migrations/000018_course_intro_video.up.sql b/db/migrations/000018_course_intro_video.up.sql new file mode 100644 index 0000000..7c34eba --- /dev/null +++ b/db/migrations/000018_course_intro_video.up.sql @@ -0,0 +1 @@ +ALTER TABLE courses ADD COLUMN IF NOT EXISTS intro_video_url TEXT; diff --git a/db/migrations/000019_sub_course_progression.down.sql b/db/migrations/000019_sub_course_progression.down.sql new file mode 100644 index 0000000..2f302ab --- /dev/null +++ b/db/migrations/000019_sub_course_progression.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS user_sub_course_progress; +DROP TABLE IF EXISTS sub_course_prerequisites; diff --git a/db/migrations/000019_sub_course_progression.up.sql b/db/migrations/000019_sub_course_progression.up.sql new file mode 100644 index 0000000..59dff04 --- /dev/null +++ b/db/migrations/000019_sub_course_progression.up.sql @@ -0,0 +1,41 @@ +-- Sub-course prerequisite links (admin-defined course flow) +CREATE TABLE IF NOT EXISTS sub_course_prerequisites ( + id BIGSERIAL PRIMARY KEY, + sub_course_id BIGINT NOT NULL REFERENCES sub_courses(id) ON DELETE CASCADE, + prerequisite_sub_course_id BIGINT NOT NULL REFERENCES sub_courses(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(sub_course_id, prerequisite_sub_course_id), + CHECK(sub_course_id != prerequisite_sub_course_id) +); + +CREATE INDEX IF NOT EXISTS idx_sub_course_prerequisites_sub_course_id + ON sub_course_prerequisites(sub_course_id); +CREATE INDEX IF NOT EXISTS idx_sub_course_prerequisites_prerequisite_id + ON sub_course_prerequisites(prerequisite_sub_course_id); + +-- User progress per sub-course +CREATE TABLE IF NOT EXISTS user_sub_course_progress ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + sub_course_id BIGINT NOT NULL REFERENCES sub_courses(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL DEFAULT 'IN_PROGRESS' CHECK ( + status IN ('IN_PROGRESS', 'COMPLETED') + ), + progress_percentage SMALLINT NOT NULL DEFAULT 0 CHECK ( + progress_percentage BETWEEN 0 AND 100 + ), + started_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ, + + UNIQUE(user_id, sub_course_id) +); + +CREATE INDEX IF NOT EXISTS idx_user_sub_course_progress_user_id + ON user_sub_course_progress(user_id); +CREATE INDEX IF NOT EXISTS idx_user_sub_course_progress_sub_course_id + ON user_sub_course_progress(sub_course_id); +CREATE INDEX IF NOT EXISTS idx_user_sub_course_progress_status + ON user_sub_course_progress(user_id, status); diff --git a/db/migrations/000020_scheduled_notifications.down.sql b/db/migrations/000020_scheduled_notifications.down.sql new file mode 100644 index 0000000..a711716 --- /dev/null +++ b/db/migrations/000020_scheduled_notifications.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS scheduled_notifications; diff --git a/db/migrations/000020_scheduled_notifications.up.sql b/db/migrations/000020_scheduled_notifications.up.sql new file mode 100644 index 0000000..03d0875 --- /dev/null +++ b/db/migrations/000020_scheduled_notifications.up.sql @@ -0,0 +1,40 @@ +CREATE TABLE IF NOT EXISTS scheduled_notifications ( + id BIGSERIAL PRIMARY KEY, + + channel TEXT NOT NULL CHECK (channel IN ('email', 'sms', 'push')), + title TEXT, + message TEXT NOT NULL, + html TEXT, + + scheduled_at TIMESTAMPTZ NOT NULL, + + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'processing', 'sent', 'failed', 'cancelled')), + + target_user_ids BIGINT[], + target_role TEXT, + target_raw JSONB, + + attempt_count INT NOT NULL DEFAULT 0, + last_error TEXT, + processing_started_at TIMESTAMPTZ, + sent_at TIMESTAMPTZ, + cancelled_at TIMESTAMPTZ, + + created_by BIGINT NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT scheduled_notifications_target_required CHECK ( + (target_user_ids IS NOT NULL AND array_length(target_user_ids, 1) > 0) + OR target_role IS NOT NULL + OR target_raw IS NOT NULL + ) +); + +CREATE INDEX IF NOT EXISTS idx_scheduled_notifications_due +ON scheduled_notifications (scheduled_at) +WHERE status = 'pending'; + +CREATE INDEX IF NOT EXISTS idx_scheduled_notifications_status +ON scheduled_notifications (status, scheduled_at); diff --git a/db/migrations/000021_rbac.down.sql b/db/migrations/000021_rbac.down.sql new file mode 100644 index 0000000..adf0e49 --- /dev/null +++ b/db/migrations/000021_rbac.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS role_permissions; +DROP TABLE IF EXISTS permissions; +DROP TABLE IF EXISTS roles; diff --git a/db/migrations/000021_rbac.up.sql b/db/migrations/000021_rbac.up.sql new file mode 100644 index 0000000..97c3948 --- /dev/null +++ b/db/migrations/000021_rbac.up.sql @@ -0,0 +1,37 @@ +-- RBAC: Roles, Permissions, Role-Permissions + +CREATE TABLE IF NOT EXISTS roles ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT NOT NULL DEFAULT '', + is_system BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS permissions ( + id BIGSERIAL PRIMARY KEY, + key TEXT NOT NULL UNIQUE, + name TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + group_name TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS role_permissions ( + role_id BIGINT NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + permission_id BIGINT NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, + PRIMARY KEY (role_id, permission_id) +); + +CREATE INDEX IF NOT EXISTS idx_role_permissions_role_id ON role_permissions(role_id); +CREATE INDEX IF NOT EXISTS idx_role_permissions_permission_id ON role_permissions(permission_id); + +-- Seed system roles +INSERT INTO roles (name, description, is_system) VALUES + ('SUPER_ADMIN', 'System super administrator with full access', true), + ('ADMIN', 'System administrator', true), + ('STUDENT', 'Student user', true), + ('INSTRUCTOR', 'Instructor user', true), + ('SUPPORT', 'Support staff', true) +ON CONFLICT (name) DO NOTHING; diff --git a/db/query/courses.sql b/db/query/courses.sql index 75a8152..87fafad 100644 --- a/db/query/courses.sql +++ b/db/query/courses.sql @@ -4,9 +4,10 @@ INSERT INTO courses ( title, description, thumbnail, + intro_video_url, is_active ) -VALUES ($1, $2, $3, $4, COALESCE($5, true)) +VALUES ($1, $2, $3, $4, $5, COALESCE($6, true)) RETURNING *; @@ -24,6 +25,7 @@ SELECT title, description, thumbnail, + intro_video_url, is_active FROM courses WHERE category_id = $1 @@ -38,8 +40,9 @@ SET title = COALESCE($1, title), description = COALESCE($2, description), thumbnail = COALESCE($3, thumbnail), - is_active = COALESCE($4, is_active) -WHERE id = $5; + intro_video_url = COALESCE($4, intro_video_url), + is_active = COALESCE($5, is_active) +WHERE id = $6; -- name: DeleteCourse :exec diff --git a/db/query/notification.sql b/db/query/notification.sql index e0ec869..a7dd470 100644 --- a/db/query/notification.sql +++ b/db/query/notification.sql @@ -86,3 +86,27 @@ WHERE user_id = $1 -- name: DeleteUserNotifications :exec DELETE FROM notifications WHERE user_id = $1; + +-- name: GetFilteredNotifications :many +SELECT * +FROM notifications +WHERE + (sqlc.narg('filter_channel')::text IS NULL OR channel = sqlc.narg('filter_channel')) + AND (sqlc.narg('filter_type')::text IS NULL OR type = sqlc.narg('filter_type')) + AND (sqlc.narg('filter_user_id')::bigint IS NULL OR user_id = sqlc.narg('filter_user_id')) + AND (sqlc.narg('filter_is_read')::boolean IS NULL OR is_read = sqlc.narg('filter_is_read')) + AND (sqlc.narg('filter_after')::timestamptz IS NULL OR created_at >= sqlc.narg('filter_after')) + AND (sqlc.narg('filter_before')::timestamptz IS NULL OR created_at <= sqlc.narg('filter_before')) +ORDER BY created_at DESC +LIMIT @page_limit OFFSET @page_offset; + +-- name: GetFilteredNotificationCount :one +SELECT COUNT(*) +FROM notifications +WHERE + (sqlc.narg('filter_channel')::text IS NULL OR channel = sqlc.narg('filter_channel')) + AND (sqlc.narg('filter_type')::text IS NULL OR type = sqlc.narg('filter_type')) + AND (sqlc.narg('filter_user_id')::bigint IS NULL OR user_id = sqlc.narg('filter_user_id')) + AND (sqlc.narg('filter_is_read')::boolean IS NULL OR is_read = sqlc.narg('filter_is_read')) + AND (sqlc.narg('filter_after')::timestamptz IS NULL OR created_at >= sqlc.narg('filter_after')) + AND (sqlc.narg('filter_before')::timestamptz IS NULL OR created_at <= sqlc.narg('filter_before')); diff --git a/db/query/rbac.sql b/db/query/rbac.sql new file mode 100644 index 0000000..e7fda91 --- /dev/null +++ b/db/query/rbac.sql @@ -0,0 +1,79 @@ +-- name: CreateRole :one +INSERT INTO roles (name, description, is_system) +VALUES ($1, $2, $3) +RETURNING *; + +-- name: GetRoleByID :one +SELECT * FROM roles WHERE id = $1; + +-- name: GetRoleByName :one +SELECT * FROM roles WHERE name = $1; + +-- name: ListRoles :many +SELECT + COUNT(*) OVER () AS total_count, + id, name, description, is_system, created_at, updated_at +FROM roles +WHERE + (sqlc.narg('query')::TEXT IS NULL OR name ILIKE '%' || sqlc.narg('query')::TEXT || '%') + AND (sqlc.narg('is_system')::BOOLEAN IS NULL OR is_system = sqlc.narg('is_system')::BOOLEAN) +ORDER BY name +LIMIT sqlc.narg('limit')::INT +OFFSET sqlc.narg('offset')::INT; + +-- name: UpdateRole :exec +UPDATE roles +SET name = $2, description = $3, updated_at = CURRENT_TIMESTAMP +WHERE id = $1 AND is_system = false; + +-- name: DeleteRole :exec +DELETE FROM roles WHERE id = $1 AND is_system = false; + +-- name: UpsertPermission :one +INSERT INTO permissions (key, name, description, group_name) +VALUES ($1, $2, $3, $4) +ON CONFLICT (key) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + group_name = EXCLUDED.group_name +RETURNING *; + +-- name: ListPermissions :many +SELECT * FROM permissions ORDER BY group_name, key; + +-- name: ListPermissionGroups :many +SELECT DISTINCT group_name FROM permissions ORDER BY group_name; + +-- name: AssignPermissionToRole :exec +INSERT INTO role_permissions (role_id, permission_id) +VALUES ($1, $2) +ON CONFLICT DO NOTHING; + +-- name: RemovePermissionFromRole :exec +DELETE FROM role_permissions +WHERE role_id = $1 AND permission_id = $2; + +-- name: SetRolePermissions :exec +DELETE FROM role_permissions WHERE role_id = $1; + +-- name: GetRolePermissions :many +SELECT p.* +FROM permissions p +INNER JOIN role_permissions rp ON rp.permission_id = p.id +WHERE rp.role_id = $1 +ORDER BY p.group_name, p.key; + +-- name: GetAllRolesWithPermissions :many +SELECT r.id AS role_id, r.name AS role_name, p.key AS permission_key +FROM roles r +INNER JOIN role_permissions rp ON rp.role_id = r.id +INNER JOIN permissions p ON p.id = rp.permission_id +ORDER BY r.name, p.key; + +-- name: GetPermissionByKey :one +SELECT * FROM permissions WHERE key = $1; + +-- name: BulkAssignPermissionsToRole :exec +INSERT INTO role_permissions (role_id, permission_id) +SELECT $1, p.id FROM permissions p WHERE p.id = ANY($2::BIGINT[]) +ON CONFLICT DO NOTHING; diff --git a/db/query/scheduled_notification.sql b/db/query/scheduled_notification.sql new file mode 100644 index 0000000..cb73189 --- /dev/null +++ b/db/query/scheduled_notification.sql @@ -0,0 +1,81 @@ +-- name: CreateScheduledNotification :one +INSERT INTO scheduled_notifications ( + channel, title, message, html, + scheduled_at, + target_user_ids, target_role, target_raw, + created_by +) VALUES ( + $1, $2, $3, $4, + $5, + $6, $7, $8, + $9 +) +RETURNING *; + +-- name: GetScheduledNotification :one +SELECT * FROM scheduled_notifications +WHERE id = $1; + +-- name: ListScheduledNotifications :many +SELECT * +FROM scheduled_notifications +WHERE + (sqlc.narg('filter_status')::text IS NULL OR status = sqlc.narg('filter_status')) + AND (sqlc.narg('filter_channel')::text IS NULL OR channel = sqlc.narg('filter_channel')) + AND (sqlc.narg('filter_after')::timestamptz IS NULL OR scheduled_at >= sqlc.narg('filter_after')) + AND (sqlc.narg('filter_before')::timestamptz IS NULL OR scheduled_at <= sqlc.narg('filter_before')) +ORDER BY scheduled_at DESC +LIMIT @page_limit OFFSET @page_offset; + +-- name: CountScheduledNotifications :one +SELECT COUNT(*) +FROM scheduled_notifications +WHERE + (sqlc.narg('filter_status')::text IS NULL OR status = sqlc.narg('filter_status')) + AND (sqlc.narg('filter_channel')::text IS NULL OR channel = sqlc.narg('filter_channel')) + AND (sqlc.narg('filter_after')::timestamptz IS NULL OR scheduled_at >= sqlc.narg('filter_after')) + AND (sqlc.narg('filter_before')::timestamptz IS NULL OR scheduled_at <= sqlc.narg('filter_before')); + +-- name: CancelScheduledNotification :one +UPDATE scheduled_notifications +SET + status = 'cancelled', + cancelled_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 + AND status IN ('pending', 'processing') +RETURNING *; + +-- name: ClaimDueScheduledNotifications :many +UPDATE scheduled_notifications sn +SET + status = 'processing', + processing_started_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +WHERE sn.id IN ( + SELECT id + FROM scheduled_notifications + WHERE status = 'pending' + AND scheduled_at <= CURRENT_TIMESTAMP + ORDER BY scheduled_at ASC + FOR UPDATE SKIP LOCKED + LIMIT $1 +) +RETURNING *; + +-- name: MarkScheduledNotificationSent :exec +UPDATE scheduled_notifications +SET + status = 'sent', + sent_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1; + +-- name: MarkScheduledNotificationFailed :exec +UPDATE scheduled_notifications +SET + status = 'failed', + last_error = $2, + attempt_count = attempt_count + 1, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1; diff --git a/db/query/sub_course_prerequisites.sql b/db/query/sub_course_prerequisites.sql new file mode 100644 index 0000000..4c1fecd --- /dev/null +++ b/db/query/sub_course_prerequisites.sql @@ -0,0 +1,50 @@ +-- name: AddSubCoursePrerequisite :one +INSERT INTO sub_course_prerequisites (sub_course_id, prerequisite_sub_course_id) +VALUES ($1, $2) +RETURNING *; + +-- name: RemoveSubCoursePrerequisite :exec +DELETE FROM sub_course_prerequisites +WHERE sub_course_id = $1 AND prerequisite_sub_course_id = $2; + +-- name: GetSubCoursePrerequisites :many +SELECT + p.id, + p.sub_course_id, + p.prerequisite_sub_course_id, + p.created_at, + sc.title AS prerequisite_title, + sc.level AS prerequisite_level, + sc.display_order AS prerequisite_display_order +FROM sub_course_prerequisites p +JOIN sub_courses sc ON sc.id = p.prerequisite_sub_course_id +WHERE p.sub_course_id = $1 +ORDER BY sc.display_order; + +-- name: GetSubCourseDependents :many +SELECT + p.id, + p.sub_course_id, + p.prerequisite_sub_course_id, + p.created_at, + sc.title AS dependent_title, + sc.level AS dependent_level +FROM sub_course_prerequisites p +JOIN sub_courses sc ON sc.id = p.sub_course_id +WHERE p.prerequisite_sub_course_id = $1 +ORDER BY sc.display_order; + +-- name: CountUnmetPrerequisites :one +SELECT COUNT(*)::bigint AS unmet_count +FROM sub_course_prerequisites p +WHERE p.sub_course_id = $1 + AND p.prerequisite_sub_course_id NOT IN ( + SELECT usp.sub_course_id + FROM user_sub_course_progress usp + WHERE usp.user_id = $2 + AND usp.status = 'COMPLETED' + ); + +-- name: DeleteAllPrerequisitesForSubCourse :exec +DELETE FROM sub_course_prerequisites +WHERE sub_course_id = $1; diff --git a/db/query/user.sql b/db/query/user.sql index 91f93ae..88b0c1d 100644 --- a/db/query/user.sql +++ b/db/query/user.sql @@ -185,6 +185,18 @@ SELECT created_at, updated_at FROM users +WHERE + (sqlc.narg('role')::TEXT IS NULL OR role = sqlc.narg('role')::TEXT) + AND (sqlc.narg('status')::TEXT IS NULL OR status = sqlc.narg('status')::TEXT) + AND (sqlc.narg('query')::TEXT IS NULL OR ( + first_name ILIKE '%' || sqlc.narg('query')::TEXT || '%' + OR last_name ILIKE '%' || sqlc.narg('query')::TEXT || '%' + OR email ILIKE '%' || sqlc.narg('query')::TEXT || '%' + OR phone_number ILIKE '%' || sqlc.narg('query')::TEXT || '%' + )) + AND (sqlc.narg('created_after')::TIMESTAMPTZ IS NULL OR created_at >= sqlc.narg('created_after')::TIMESTAMPTZ) + AND (sqlc.narg('created_before')::TIMESTAMPTZ IS NULL OR created_at <= sqlc.narg('created_before')::TIMESTAMPTZ) +ORDER BY created_at DESC LIMIT sqlc.narg('limit')::INT OFFSET sqlc.narg('offset')::INT; diff --git a/db/query/user_sub_course_progress.sql b/db/query/user_sub_course_progress.sql new file mode 100644 index 0000000..35fcabe --- /dev/null +++ b/db/query/user_sub_course_progress.sql @@ -0,0 +1,78 @@ +-- name: StartSubCourseProgress :one +INSERT INTO user_sub_course_progress (user_id, sub_course_id) +VALUES ($1, $2) +ON CONFLICT (user_id, sub_course_id) DO NOTHING +RETURNING *; + +-- name: UpdateSubCourseProgress :exec +UPDATE user_sub_course_progress +SET + progress_percentage = $1, + updated_at = CURRENT_TIMESTAMP +WHERE user_id = $2 AND sub_course_id = $3; + +-- name: CompleteSubCourse :exec +UPDATE user_sub_course_progress +SET + status = 'COMPLETED', + progress_percentage = 100, + completed_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +WHERE user_id = $1 AND sub_course_id = $2; + +-- name: GetUserSubCourseProgress :one +SELECT * FROM user_sub_course_progress +WHERE user_id = $1 AND sub_course_id = $2; + +-- name: GetUserCourseProgress :many +SELECT + usp.id, + usp.user_id, + usp.sub_course_id, + usp.status, + usp.progress_percentage, + usp.started_at, + usp.completed_at, + usp.created_at, + usp.updated_at, + sc.title AS sub_course_title, + sc.level AS sub_course_level, + sc.display_order AS sub_course_display_order +FROM user_sub_course_progress usp +JOIN sub_courses sc ON sc.id = usp.sub_course_id +WHERE usp.user_id = $1 AND sc.course_id = $2 +ORDER BY sc.display_order; + +-- name: GetSubCoursesWithProgressByCourse :many +SELECT + sc.id AS sub_course_id, + sc.title, + sc.description, + sc.thumbnail, + sc.display_order, + sc.level, + sc.is_active, + COALESCE(usp.status, 'NOT_STARTED') AS progress_status, + COALESCE(usp.progress_percentage, 0)::smallint AS progress_percentage, + usp.started_at, + usp.completed_at, + (SELECT COUNT(*)::bigint + FROM sub_course_prerequisites p + WHERE p.sub_course_id = sc.id + AND p.prerequisite_sub_course_id NOT IN ( + SELECT usp2.sub_course_id + FROM user_sub_course_progress usp2 + WHERE usp2.user_id = $1 + AND usp2.status = 'COMPLETED' + ) + ) AS unmet_prerequisites_count +FROM sub_courses sc +LEFT JOIN user_sub_course_progress usp + ON usp.sub_course_id = sc.id AND usp.user_id = $1 +WHERE sc.course_id = $2 + AND sc.is_active = true +ORDER BY sc.display_order; + +-- name: DeleteUserSubCourseProgress :exec +DELETE FROM user_sub_course_progress +WHERE user_id = $1 AND sub_course_id = $2; diff --git a/gen/db/courses.sql.go b/gen/db/courses.sql.go index 030db60..7ffe791 100644 --- a/gen/db/courses.sql.go +++ b/gen/db/courses.sql.go @@ -17,18 +17,20 @@ INSERT INTO courses ( title, description, thumbnail, + intro_video_url, is_active ) -VALUES ($1, $2, $3, $4, COALESCE($5, true)) -RETURNING id, category_id, title, description, is_active, thumbnail +VALUES ($1, $2, $3, $4, $5, COALESCE($6, true)) +RETURNING id, category_id, title, description, is_active, thumbnail, intro_video_url ` type CreateCourseParams struct { - CategoryID int64 `json:"category_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - Column5 interface{} `json:"column_5"` + CategoryID int64 `json:"category_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` + IntroVideoUrl pgtype.Text `json:"intro_video_url"` + Column6 interface{} `json:"column_6"` } func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Course, error) { @@ -37,7 +39,8 @@ func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Cou arg.Title, arg.Description, arg.Thumbnail, - arg.Column5, + arg.IntroVideoUrl, + arg.Column6, ) var i Course err := row.Scan( @@ -47,6 +50,7 @@ func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Cou &i.Description, &i.IsActive, &i.Thumbnail, + &i.IntroVideoUrl, ) return i, err } @@ -62,7 +66,7 @@ func (q *Queries) DeleteCourse(ctx context.Context, id int64) error { } const GetCourseByID = `-- name: GetCourseByID :one -SELECT id, category_id, title, description, is_active, thumbnail +SELECT id, category_id, title, description, is_active, thumbnail, intro_video_url FROM courses WHERE id = $1 ` @@ -77,6 +81,7 @@ func (q *Queries) GetCourseByID(ctx context.Context, id int64) (Course, error) { &i.Description, &i.IsActive, &i.Thumbnail, + &i.IntroVideoUrl, ) return i, err } @@ -89,6 +94,7 @@ SELECT title, description, thumbnail, + intro_video_url, is_active FROM courses WHERE category_id = $1 @@ -104,13 +110,14 @@ type GetCoursesByCategoryParams struct { } type GetCoursesByCategoryRow struct { - TotalCount int64 `json:"total_count"` - ID int64 `json:"id"` - CategoryID int64 `json:"category_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - IsActive bool `json:"is_active"` + TotalCount int64 `json:"total_count"` + ID int64 `json:"id"` + CategoryID int64 `json:"category_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` + IntroVideoUrl pgtype.Text `json:"intro_video_url"` + IsActive bool `json:"is_active"` } func (q *Queries) GetCoursesByCategory(ctx context.Context, arg GetCoursesByCategoryParams) ([]GetCoursesByCategoryRow, error) { @@ -129,6 +136,7 @@ func (q *Queries) GetCoursesByCategory(ctx context.Context, arg GetCoursesByCate &i.Title, &i.Description, &i.Thumbnail, + &i.IntroVideoUrl, &i.IsActive, ); err != nil { return nil, err @@ -147,16 +155,18 @@ SET title = COALESCE($1, title), description = COALESCE($2, description), thumbnail = COALESCE($3, thumbnail), - is_active = COALESCE($4, is_active) -WHERE id = $5 + intro_video_url = COALESCE($4, intro_video_url), + is_active = COALESCE($5, is_active) +WHERE id = $6 ` type UpdateCourseParams struct { - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - IsActive bool `json:"is_active"` - ID int64 `json:"id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` + IntroVideoUrl pgtype.Text `json:"intro_video_url"` + IsActive bool `json:"is_active"` + ID int64 `json:"id"` } func (q *Queries) UpdateCourse(ctx context.Context, arg UpdateCourseParams) error { @@ -164,6 +174,7 @@ func (q *Queries) UpdateCourse(ctx context.Context, arg UpdateCourseParams) erro arg.Title, arg.Description, arg.Thumbnail, + arg.IntroVideoUrl, arg.IsActive, arg.ID, ) diff --git a/gen/db/models.go b/gen/db/models.go index 637b0b0..299b678 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -23,12 +23,13 @@ type ActivityLog struct { } type Course struct { - ID int64 `json:"id"` - CategoryID int64 `json:"category_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - IsActive bool `json:"is_active"` - Thumbnail pgtype.Text `json:"thumbnail"` + ID int64 `json:"id"` + CategoryID int64 `json:"category_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + IsActive bool `json:"is_active"` + Thumbnail pgtype.Text `json:"thumbnail"` + IntroVideoUrl pgtype.Text `json:"intro_video_url"` } type CourseCategory struct { @@ -112,6 +113,15 @@ type Payment struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type Permission struct { + ID int64 `json:"id"` + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description"` + GroupName string `json:"group_name"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type Question struct { ID int64 `json:"id"` QuestionText string `json:"question_text"` @@ -211,6 +221,41 @@ type ReportedIssue struct { UpdatedAt pgtype.Timestamp `json:"updated_at"` } +type Role struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + IsSystem bool `json:"is_system"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type RolePermission struct { + RoleID int64 `json:"role_id"` + PermissionID int64 `json:"permission_id"` +} + +type ScheduledNotification struct { + ID int64 `json:"id"` + Channel string `json:"channel"` + Title pgtype.Text `json:"title"` + Message string `json:"message"` + Html pgtype.Text `json:"html"` + ScheduledAt pgtype.Timestamptz `json:"scheduled_at"` + Status string `json:"status"` + TargetUserIds []int64 `json:"target_user_ids"` + TargetRole pgtype.Text `json:"target_role"` + TargetRaw []byte `json:"target_raw"` + AttemptCount int32 `json:"attempt_count"` + LastError pgtype.Text `json:"last_error"` + ProcessingStartedAt pgtype.Timestamptz `json:"processing_started_at"` + SentAt pgtype.Timestamptz `json:"sent_at"` + CancelledAt pgtype.Timestamptz `json:"cancelled_at"` + CreatedBy int64 `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type SubCourse struct { ID int64 `json:"id"` CourseID int64 `json:"course_id"` @@ -222,6 +267,13 @@ type SubCourse struct { IsActive bool `json:"is_active"` } +type SubCoursePrerequisite struct { + ID int64 `json:"id"` + SubCourseID int64 `json:"sub_course_id"` + PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type SubCourseVideo struct { ID int64 `json:"id"` SubCourseID int64 `json:"sub_course_id"` @@ -324,6 +376,18 @@ type User struct { ProfileCompletionPercentage int16 `json:"profile_completion_percentage"` } +type UserSubCourseProgress struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + SubCourseID int64 `json:"sub_course_id"` + Status string `json:"status"` + ProgressPercentage int16 `json:"progress_percentage"` + StartedAt pgtype.Timestamptz `json:"started_at"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type UserSubscription struct { ID int64 `json:"id"` UserID int64 `json:"user_id"` diff --git a/gen/db/notification.sql.go b/gen/db/notification.sql.go index 4e90591..c21af0e 100644 --- a/gen/db/notification.sql.go +++ b/gen/db/notification.sql.go @@ -144,6 +144,108 @@ func (q *Queries) GetAllNotifications(ctx context.Context, arg GetAllNotificatio return items, nil } +const GetFilteredNotificationCount = `-- name: GetFilteredNotificationCount :one +SELECT COUNT(*) +FROM notifications +WHERE + ($1::text IS NULL OR channel = $1) + AND ($2::text IS NULL OR type = $2) + AND ($3::bigint IS NULL OR user_id = $3) + AND ($4::boolean IS NULL OR is_read = $4) + AND ($5::timestamptz IS NULL OR created_at >= $5) + AND ($6::timestamptz IS NULL OR created_at <= $6) +` + +type GetFilteredNotificationCountParams struct { + FilterChannel pgtype.Text `json:"filter_channel"` + FilterType pgtype.Text `json:"filter_type"` + FilterUserID pgtype.Int8 `json:"filter_user_id"` + FilterIsRead pgtype.Bool `json:"filter_is_read"` + FilterAfter pgtype.Timestamptz `json:"filter_after"` + FilterBefore pgtype.Timestamptz `json:"filter_before"` +} + +func (q *Queries) GetFilteredNotificationCount(ctx context.Context, arg GetFilteredNotificationCountParams) (int64, error) { + row := q.db.QueryRow(ctx, GetFilteredNotificationCount, + arg.FilterChannel, + arg.FilterType, + arg.FilterUserID, + arg.FilterIsRead, + arg.FilterAfter, + arg.FilterBefore, + ) + var count int64 + err := row.Scan(&count) + return count, err +} + +const GetFilteredNotifications = `-- name: GetFilteredNotifications :many +SELECT id, user_id, type, level, channel, title, message, payload, is_read, created_at, read_at, receiver_type +FROM notifications +WHERE + ($1::text IS NULL OR channel = $1) + AND ($2::text IS NULL OR type = $2) + AND ($3::bigint IS NULL OR user_id = $3) + AND ($4::boolean IS NULL OR is_read = $4) + AND ($5::timestamptz IS NULL OR created_at >= $5) + AND ($6::timestamptz IS NULL OR created_at <= $6) +ORDER BY created_at DESC +LIMIT $8 OFFSET $7 +` + +type GetFilteredNotificationsParams struct { + FilterChannel pgtype.Text `json:"filter_channel"` + FilterType pgtype.Text `json:"filter_type"` + FilterUserID pgtype.Int8 `json:"filter_user_id"` + FilterIsRead pgtype.Bool `json:"filter_is_read"` + FilterAfter pgtype.Timestamptz `json:"filter_after"` + FilterBefore pgtype.Timestamptz `json:"filter_before"` + PageOffset int32 `json:"page_offset"` + PageLimit int32 `json:"page_limit"` +} + +func (q *Queries) GetFilteredNotifications(ctx context.Context, arg GetFilteredNotificationsParams) ([]Notification, error) { + rows, err := q.db.Query(ctx, GetFilteredNotifications, + arg.FilterChannel, + arg.FilterType, + arg.FilterUserID, + arg.FilterIsRead, + arg.FilterAfter, + arg.FilterBefore, + arg.PageOffset, + arg.PageLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Notification + for rows.Next() { + var i Notification + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Type, + &i.Level, + &i.Channel, + &i.Title, + &i.Message, + &i.Payload, + &i.IsRead, + &i.CreatedAt, + &i.ReadAt, + &i.ReceiverType, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetNotification = `-- name: GetNotification :one SELECT id, user_id, type, level, channel, title, message, payload, is_read, created_at, read_at, receiver_type FROM notifications diff --git a/gen/db/rbac.sql.go b/gen/db/rbac.sql.go new file mode 100644 index 0000000..67d935e --- /dev/null +++ b/gen/db/rbac.sql.go @@ -0,0 +1,397 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: rbac.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const AssignPermissionToRole = `-- name: AssignPermissionToRole :exec +INSERT INTO role_permissions (role_id, permission_id) +VALUES ($1, $2) +ON CONFLICT DO NOTHING +` + +type AssignPermissionToRoleParams struct { + RoleID int64 `json:"role_id"` + PermissionID int64 `json:"permission_id"` +} + +func (q *Queries) AssignPermissionToRole(ctx context.Context, arg AssignPermissionToRoleParams) error { + _, err := q.db.Exec(ctx, AssignPermissionToRole, arg.RoleID, arg.PermissionID) + return err +} + +const BulkAssignPermissionsToRole = `-- name: BulkAssignPermissionsToRole :exec +INSERT INTO role_permissions (role_id, permission_id) +SELECT $1, p.id FROM permissions p WHERE p.id = ANY($2::BIGINT[]) +ON CONFLICT DO NOTHING +` + +type BulkAssignPermissionsToRoleParams struct { + RoleID int64 `json:"role_id"` + Column2 []int64 `json:"column_2"` +} + +func (q *Queries) BulkAssignPermissionsToRole(ctx context.Context, arg BulkAssignPermissionsToRoleParams) error { + _, err := q.db.Exec(ctx, BulkAssignPermissionsToRole, arg.RoleID, arg.Column2) + return err +} + +const CreateRole = `-- name: CreateRole :one +INSERT INTO roles (name, description, is_system) +VALUES ($1, $2, $3) +RETURNING id, name, description, is_system, created_at, updated_at +` + +type CreateRoleParams struct { + Name string `json:"name"` + Description string `json:"description"` + IsSystem bool `json:"is_system"` +} + +func (q *Queries) CreateRole(ctx context.Context, arg CreateRoleParams) (Role, error) { + row := q.db.QueryRow(ctx, CreateRole, arg.Name, arg.Description, arg.IsSystem) + var i Role + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.IsSystem, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const DeleteRole = `-- name: DeleteRole :exec +DELETE FROM roles WHERE id = $1 AND is_system = false +` + +func (q *Queries) DeleteRole(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, DeleteRole, id) + return err +} + +const GetAllRolesWithPermissions = `-- name: GetAllRolesWithPermissions :many +SELECT r.id AS role_id, r.name AS role_name, p.key AS permission_key +FROM roles r +INNER JOIN role_permissions rp ON rp.role_id = r.id +INNER JOIN permissions p ON p.id = rp.permission_id +ORDER BY r.name, p.key +` + +type GetAllRolesWithPermissionsRow struct { + RoleID int64 `json:"role_id"` + RoleName string `json:"role_name"` + PermissionKey string `json:"permission_key"` +} + +func (q *Queries) GetAllRolesWithPermissions(ctx context.Context) ([]GetAllRolesWithPermissionsRow, error) { + rows, err := q.db.Query(ctx, GetAllRolesWithPermissions) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAllRolesWithPermissionsRow + for rows.Next() { + var i GetAllRolesWithPermissionsRow + if err := rows.Scan(&i.RoleID, &i.RoleName, &i.PermissionKey); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetPermissionByKey = `-- name: GetPermissionByKey :one +SELECT id, key, name, description, group_name, created_at FROM permissions WHERE key = $1 +` + +func (q *Queries) GetPermissionByKey(ctx context.Context, key string) (Permission, error) { + row := q.db.QueryRow(ctx, GetPermissionByKey, key) + var i Permission + err := row.Scan( + &i.ID, + &i.Key, + &i.Name, + &i.Description, + &i.GroupName, + &i.CreatedAt, + ) + return i, err +} + +const GetRoleByID = `-- name: GetRoleByID :one +SELECT id, name, description, is_system, created_at, updated_at FROM roles WHERE id = $1 +` + +func (q *Queries) GetRoleByID(ctx context.Context, id int64) (Role, error) { + row := q.db.QueryRow(ctx, GetRoleByID, id) + var i Role + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.IsSystem, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const GetRoleByName = `-- name: GetRoleByName :one +SELECT id, name, description, is_system, created_at, updated_at FROM roles WHERE name = $1 +` + +func (q *Queries) GetRoleByName(ctx context.Context, name string) (Role, error) { + row := q.db.QueryRow(ctx, GetRoleByName, name) + var i Role + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.IsSystem, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const GetRolePermissions = `-- name: GetRolePermissions :many +SELECT p.id, p.key, p.name, p.description, p.group_name, p.created_at +FROM permissions p +INNER JOIN role_permissions rp ON rp.permission_id = p.id +WHERE rp.role_id = $1 +ORDER BY p.group_name, p.key +` + +func (q *Queries) GetRolePermissions(ctx context.Context, roleID int64) ([]Permission, error) { + rows, err := q.db.Query(ctx, GetRolePermissions, roleID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Permission + for rows.Next() { + var i Permission + if err := rows.Scan( + &i.ID, + &i.Key, + &i.Name, + &i.Description, + &i.GroupName, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListPermissionGroups = `-- name: ListPermissionGroups :many +SELECT DISTINCT group_name FROM permissions ORDER BY group_name +` + +func (q *Queries) ListPermissionGroups(ctx context.Context) ([]string, error) { + rows, err := q.db.Query(ctx, ListPermissionGroups) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var group_name string + if err := rows.Scan(&group_name); err != nil { + return nil, err + } + items = append(items, group_name) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListPermissions = `-- name: ListPermissions :many +SELECT id, key, name, description, group_name, created_at FROM permissions ORDER BY group_name, key +` + +func (q *Queries) ListPermissions(ctx context.Context) ([]Permission, error) { + rows, err := q.db.Query(ctx, ListPermissions) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Permission + for rows.Next() { + var i Permission + if err := rows.Scan( + &i.ID, + &i.Key, + &i.Name, + &i.Description, + &i.GroupName, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListRoles = `-- name: ListRoles :many +SELECT + COUNT(*) OVER () AS total_count, + id, name, description, is_system, created_at, updated_at +FROM roles +WHERE + ($1::TEXT IS NULL OR name ILIKE '%' || $1::TEXT || '%') + AND ($2::BOOLEAN IS NULL OR is_system = $2::BOOLEAN) +ORDER BY name +LIMIT $4::INT +OFFSET $3::INT +` + +type ListRolesParams struct { + Query pgtype.Text `json:"query"` + IsSystem pgtype.Bool `json:"is_system"` + Offset pgtype.Int4 `json:"offset"` + Limit pgtype.Int4 `json:"limit"` +} + +type ListRolesRow struct { + TotalCount int64 `json:"total_count"` + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + IsSystem bool `json:"is_system"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) ListRoles(ctx context.Context, arg ListRolesParams) ([]ListRolesRow, error) { + rows, err := q.db.Query(ctx, ListRoles, + arg.Query, + arg.IsSystem, + arg.Offset, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListRolesRow + for rows.Next() { + var i ListRolesRow + if err := rows.Scan( + &i.TotalCount, + &i.ID, + &i.Name, + &i.Description, + &i.IsSystem, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const RemovePermissionFromRole = `-- name: RemovePermissionFromRole :exec +DELETE FROM role_permissions +WHERE role_id = $1 AND permission_id = $2 +` + +type RemovePermissionFromRoleParams struct { + RoleID int64 `json:"role_id"` + PermissionID int64 `json:"permission_id"` +} + +func (q *Queries) RemovePermissionFromRole(ctx context.Context, arg RemovePermissionFromRoleParams) error { + _, err := q.db.Exec(ctx, RemovePermissionFromRole, arg.RoleID, arg.PermissionID) + return err +} + +const SetRolePermissions = `-- name: SetRolePermissions :exec +DELETE FROM role_permissions WHERE role_id = $1 +` + +func (q *Queries) SetRolePermissions(ctx context.Context, roleID int64) error { + _, err := q.db.Exec(ctx, SetRolePermissions, roleID) + return err +} + +const UpdateRole = `-- name: UpdateRole :exec +UPDATE roles +SET name = $2, description = $3, updated_at = CURRENT_TIMESTAMP +WHERE id = $1 AND is_system = false +` + +type UpdateRoleParams struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +func (q *Queries) UpdateRole(ctx context.Context, arg UpdateRoleParams) error { + _, err := q.db.Exec(ctx, UpdateRole, arg.ID, arg.Name, arg.Description) + return err +} + +const UpsertPermission = `-- name: UpsertPermission :one +INSERT INTO permissions (key, name, description, group_name) +VALUES ($1, $2, $3, $4) +ON CONFLICT (key) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + group_name = EXCLUDED.group_name +RETURNING id, key, name, description, group_name, created_at +` + +type UpsertPermissionParams struct { + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description"` + GroupName string `json:"group_name"` +} + +func (q *Queries) UpsertPermission(ctx context.Context, arg UpsertPermissionParams) (Permission, error) { + row := q.db.QueryRow(ctx, UpsertPermission, + arg.Key, + arg.Name, + arg.Description, + arg.GroupName, + ) + var i Permission + err := row.Scan( + &i.ID, + &i.Key, + &i.Name, + &i.Description, + &i.GroupName, + &i.CreatedAt, + ) + return i, err +} diff --git a/gen/db/scheduled_notification.sql.go b/gen/db/scheduled_notification.sql.go new file mode 100644 index 0000000..3562e27 --- /dev/null +++ b/gen/db/scheduled_notification.sql.go @@ -0,0 +1,330 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: scheduled_notification.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const CancelScheduledNotification = `-- name: CancelScheduledNotification :one +UPDATE scheduled_notifications +SET + status = 'cancelled', + cancelled_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 + AND status IN ('pending', 'processing') +RETURNING id, channel, title, message, html, scheduled_at, status, target_user_ids, target_role, target_raw, attempt_count, last_error, processing_started_at, sent_at, cancelled_at, created_by, created_at, updated_at +` + +func (q *Queries) CancelScheduledNotification(ctx context.Context, id int64) (ScheduledNotification, error) { + row := q.db.QueryRow(ctx, CancelScheduledNotification, id) + var i ScheduledNotification + err := row.Scan( + &i.ID, + &i.Channel, + &i.Title, + &i.Message, + &i.Html, + &i.ScheduledAt, + &i.Status, + &i.TargetUserIds, + &i.TargetRole, + &i.TargetRaw, + &i.AttemptCount, + &i.LastError, + &i.ProcessingStartedAt, + &i.SentAt, + &i.CancelledAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const ClaimDueScheduledNotifications = `-- name: ClaimDueScheduledNotifications :many +UPDATE scheduled_notifications sn +SET + status = 'processing', + processing_started_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +WHERE sn.id IN ( + SELECT id + FROM scheduled_notifications + WHERE status = 'pending' + AND scheduled_at <= CURRENT_TIMESTAMP + ORDER BY scheduled_at ASC + FOR UPDATE SKIP LOCKED + LIMIT $1 +) +RETURNING id, channel, title, message, html, scheduled_at, status, target_user_ids, target_role, target_raw, attempt_count, last_error, processing_started_at, sent_at, cancelled_at, created_by, created_at, updated_at +` + +func (q *Queries) ClaimDueScheduledNotifications(ctx context.Context, limit int32) ([]ScheduledNotification, error) { + rows, err := q.db.Query(ctx, ClaimDueScheduledNotifications, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ScheduledNotification + for rows.Next() { + var i ScheduledNotification + if err := rows.Scan( + &i.ID, + &i.Channel, + &i.Title, + &i.Message, + &i.Html, + &i.ScheduledAt, + &i.Status, + &i.TargetUserIds, + &i.TargetRole, + &i.TargetRaw, + &i.AttemptCount, + &i.LastError, + &i.ProcessingStartedAt, + &i.SentAt, + &i.CancelledAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const CountScheduledNotifications = `-- name: CountScheduledNotifications :one +SELECT COUNT(*) +FROM scheduled_notifications +WHERE + ($1::text IS NULL OR status = $1) + AND ($2::text IS NULL OR channel = $2) + AND ($3::timestamptz IS NULL OR scheduled_at >= $3) + AND ($4::timestamptz IS NULL OR scheduled_at <= $4) +` + +type CountScheduledNotificationsParams struct { + FilterStatus pgtype.Text `json:"filter_status"` + FilterChannel pgtype.Text `json:"filter_channel"` + FilterAfter pgtype.Timestamptz `json:"filter_after"` + FilterBefore pgtype.Timestamptz `json:"filter_before"` +} + +func (q *Queries) CountScheduledNotifications(ctx context.Context, arg CountScheduledNotificationsParams) (int64, error) { + row := q.db.QueryRow(ctx, CountScheduledNotifications, + arg.FilterStatus, + arg.FilterChannel, + arg.FilterAfter, + arg.FilterBefore, + ) + var count int64 + err := row.Scan(&count) + return count, err +} + +const CreateScheduledNotification = `-- name: CreateScheduledNotification :one +INSERT INTO scheduled_notifications ( + channel, title, message, html, + scheduled_at, + target_user_ids, target_role, target_raw, + created_by +) VALUES ( + $1, $2, $3, $4, + $5, + $6, $7, $8, + $9 +) +RETURNING id, channel, title, message, html, scheduled_at, status, target_user_ids, target_role, target_raw, attempt_count, last_error, processing_started_at, sent_at, cancelled_at, created_by, created_at, updated_at +` + +type CreateScheduledNotificationParams struct { + Channel string `json:"channel"` + Title pgtype.Text `json:"title"` + Message string `json:"message"` + Html pgtype.Text `json:"html"` + ScheduledAt pgtype.Timestamptz `json:"scheduled_at"` + TargetUserIds []int64 `json:"target_user_ids"` + TargetRole pgtype.Text `json:"target_role"` + TargetRaw []byte `json:"target_raw"` + CreatedBy int64 `json:"created_by"` +} + +func (q *Queries) CreateScheduledNotification(ctx context.Context, arg CreateScheduledNotificationParams) (ScheduledNotification, error) { + row := q.db.QueryRow(ctx, CreateScheduledNotification, + arg.Channel, + arg.Title, + arg.Message, + arg.Html, + arg.ScheduledAt, + arg.TargetUserIds, + arg.TargetRole, + arg.TargetRaw, + arg.CreatedBy, + ) + var i ScheduledNotification + err := row.Scan( + &i.ID, + &i.Channel, + &i.Title, + &i.Message, + &i.Html, + &i.ScheduledAt, + &i.Status, + &i.TargetUserIds, + &i.TargetRole, + &i.TargetRaw, + &i.AttemptCount, + &i.LastError, + &i.ProcessingStartedAt, + &i.SentAt, + &i.CancelledAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const GetScheduledNotification = `-- name: GetScheduledNotification :one +SELECT id, channel, title, message, html, scheduled_at, status, target_user_ids, target_role, target_raw, attempt_count, last_error, processing_started_at, sent_at, cancelled_at, created_by, created_at, updated_at FROM scheduled_notifications +WHERE id = $1 +` + +func (q *Queries) GetScheduledNotification(ctx context.Context, id int64) (ScheduledNotification, error) { + row := q.db.QueryRow(ctx, GetScheduledNotification, id) + var i ScheduledNotification + err := row.Scan( + &i.ID, + &i.Channel, + &i.Title, + &i.Message, + &i.Html, + &i.ScheduledAt, + &i.Status, + &i.TargetUserIds, + &i.TargetRole, + &i.TargetRaw, + &i.AttemptCount, + &i.LastError, + &i.ProcessingStartedAt, + &i.SentAt, + &i.CancelledAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const ListScheduledNotifications = `-- name: ListScheduledNotifications :many +SELECT id, channel, title, message, html, scheduled_at, status, target_user_ids, target_role, target_raw, attempt_count, last_error, processing_started_at, sent_at, cancelled_at, created_by, created_at, updated_at +FROM scheduled_notifications +WHERE + ($1::text IS NULL OR status = $1) + AND ($2::text IS NULL OR channel = $2) + AND ($3::timestamptz IS NULL OR scheduled_at >= $3) + AND ($4::timestamptz IS NULL OR scheduled_at <= $4) +ORDER BY scheduled_at DESC +LIMIT $6 OFFSET $5 +` + +type ListScheduledNotificationsParams struct { + FilterStatus pgtype.Text `json:"filter_status"` + FilterChannel pgtype.Text `json:"filter_channel"` + FilterAfter pgtype.Timestamptz `json:"filter_after"` + FilterBefore pgtype.Timestamptz `json:"filter_before"` + PageOffset int32 `json:"page_offset"` + PageLimit int32 `json:"page_limit"` +} + +func (q *Queries) ListScheduledNotifications(ctx context.Context, arg ListScheduledNotificationsParams) ([]ScheduledNotification, error) { + rows, err := q.db.Query(ctx, ListScheduledNotifications, + arg.FilterStatus, + arg.FilterChannel, + arg.FilterAfter, + arg.FilterBefore, + arg.PageOffset, + arg.PageLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ScheduledNotification + for rows.Next() { + var i ScheduledNotification + if err := rows.Scan( + &i.ID, + &i.Channel, + &i.Title, + &i.Message, + &i.Html, + &i.ScheduledAt, + &i.Status, + &i.TargetUserIds, + &i.TargetRole, + &i.TargetRaw, + &i.AttemptCount, + &i.LastError, + &i.ProcessingStartedAt, + &i.SentAt, + &i.CancelledAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const MarkScheduledNotificationFailed = `-- name: MarkScheduledNotificationFailed :exec +UPDATE scheduled_notifications +SET + status = 'failed', + last_error = $2, + attempt_count = attempt_count + 1, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 +` + +type MarkScheduledNotificationFailedParams struct { + ID int64 `json:"id"` + LastError pgtype.Text `json:"last_error"` +} + +func (q *Queries) MarkScheduledNotificationFailed(ctx context.Context, arg MarkScheduledNotificationFailedParams) error { + _, err := q.db.Exec(ctx, MarkScheduledNotificationFailed, arg.ID, arg.LastError) + return err +} + +const MarkScheduledNotificationSent = `-- name: MarkScheduledNotificationSent :exec +UPDATE scheduled_notifications +SET + status = 'sent', + sent_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 +` + +func (q *Queries) MarkScheduledNotificationSent(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, MarkScheduledNotificationSent, id) + return err +} diff --git a/gen/db/sub_course_prerequisites.sql.go b/gen/db/sub_course_prerequisites.sql.go new file mode 100644 index 0000000..98ce4b2 --- /dev/null +++ b/gen/db/sub_course_prerequisites.sql.go @@ -0,0 +1,187 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: sub_course_prerequisites.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const AddSubCoursePrerequisite = `-- name: AddSubCoursePrerequisite :one +INSERT INTO sub_course_prerequisites (sub_course_id, prerequisite_sub_course_id) +VALUES ($1, $2) +RETURNING id, sub_course_id, prerequisite_sub_course_id, created_at +` + +type AddSubCoursePrerequisiteParams struct { + SubCourseID int64 `json:"sub_course_id"` + PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"` +} + +func (q *Queries) AddSubCoursePrerequisite(ctx context.Context, arg AddSubCoursePrerequisiteParams) (SubCoursePrerequisite, error) { + row := q.db.QueryRow(ctx, AddSubCoursePrerequisite, arg.SubCourseID, arg.PrerequisiteSubCourseID) + var i SubCoursePrerequisite + err := row.Scan( + &i.ID, + &i.SubCourseID, + &i.PrerequisiteSubCourseID, + &i.CreatedAt, + ) + return i, err +} + +const CountUnmetPrerequisites = `-- name: CountUnmetPrerequisites :one +SELECT COUNT(*)::bigint AS unmet_count +FROM sub_course_prerequisites p +WHERE p.sub_course_id = $1 + AND p.prerequisite_sub_course_id NOT IN ( + SELECT usp.sub_course_id + FROM user_sub_course_progress usp + WHERE usp.user_id = $2 + AND usp.status = 'COMPLETED' + ) +` + +type CountUnmetPrerequisitesParams struct { + SubCourseID int64 `json:"sub_course_id"` + UserID int64 `json:"user_id"` +} + +func (q *Queries) CountUnmetPrerequisites(ctx context.Context, arg CountUnmetPrerequisitesParams) (int64, error) { + row := q.db.QueryRow(ctx, CountUnmetPrerequisites, arg.SubCourseID, arg.UserID) + var unmet_count int64 + err := row.Scan(&unmet_count) + return unmet_count, err +} + +const DeleteAllPrerequisitesForSubCourse = `-- name: DeleteAllPrerequisitesForSubCourse :exec +DELETE FROM sub_course_prerequisites +WHERE sub_course_id = $1 +` + +func (q *Queries) DeleteAllPrerequisitesForSubCourse(ctx context.Context, subCourseID int64) error { + _, err := q.db.Exec(ctx, DeleteAllPrerequisitesForSubCourse, subCourseID) + return err +} + +const GetSubCourseDependents = `-- name: GetSubCourseDependents :many +SELECT + p.id, + p.sub_course_id, + p.prerequisite_sub_course_id, + p.created_at, + sc.title AS dependent_title, + sc.level AS dependent_level +FROM sub_course_prerequisites p +JOIN sub_courses sc ON sc.id = p.sub_course_id +WHERE p.prerequisite_sub_course_id = $1 +ORDER BY sc.display_order +` + +type GetSubCourseDependentsRow struct { + ID int64 `json:"id"` + SubCourseID int64 `json:"sub_course_id"` + PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + DependentTitle string `json:"dependent_title"` + DependentLevel string `json:"dependent_level"` +} + +func (q *Queries) GetSubCourseDependents(ctx context.Context, prerequisiteSubCourseID int64) ([]GetSubCourseDependentsRow, error) { + rows, err := q.db.Query(ctx, GetSubCourseDependents, prerequisiteSubCourseID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetSubCourseDependentsRow + for rows.Next() { + var i GetSubCourseDependentsRow + if err := rows.Scan( + &i.ID, + &i.SubCourseID, + &i.PrerequisiteSubCourseID, + &i.CreatedAt, + &i.DependentTitle, + &i.DependentLevel, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetSubCoursePrerequisites = `-- name: GetSubCoursePrerequisites :many +SELECT + p.id, + p.sub_course_id, + p.prerequisite_sub_course_id, + p.created_at, + sc.title AS prerequisite_title, + sc.level AS prerequisite_level, + sc.display_order AS prerequisite_display_order +FROM sub_course_prerequisites p +JOIN sub_courses sc ON sc.id = p.prerequisite_sub_course_id +WHERE p.sub_course_id = $1 +ORDER BY sc.display_order +` + +type GetSubCoursePrerequisitesRow struct { + ID int64 `json:"id"` + SubCourseID int64 `json:"sub_course_id"` + PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + PrerequisiteTitle string `json:"prerequisite_title"` + PrerequisiteLevel string `json:"prerequisite_level"` + PrerequisiteDisplayOrder int32 `json:"prerequisite_display_order"` +} + +func (q *Queries) GetSubCoursePrerequisites(ctx context.Context, subCourseID int64) ([]GetSubCoursePrerequisitesRow, error) { + rows, err := q.db.Query(ctx, GetSubCoursePrerequisites, subCourseID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetSubCoursePrerequisitesRow + for rows.Next() { + var i GetSubCoursePrerequisitesRow + if err := rows.Scan( + &i.ID, + &i.SubCourseID, + &i.PrerequisiteSubCourseID, + &i.CreatedAt, + &i.PrerequisiteTitle, + &i.PrerequisiteLevel, + &i.PrerequisiteDisplayOrder, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const RemoveSubCoursePrerequisite = `-- name: RemoveSubCoursePrerequisite :exec +DELETE FROM sub_course_prerequisites +WHERE sub_course_id = $1 AND prerequisite_sub_course_id = $2 +` + +type RemoveSubCoursePrerequisiteParams struct { + SubCourseID int64 `json:"sub_course_id"` + PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"` +} + +func (q *Queries) RemoveSubCoursePrerequisite(ctx context.Context, arg RemoveSubCoursePrerequisiteParams) error { + _, err := q.db.Exec(ctx, RemoveSubCoursePrerequisite, arg.SubCourseID, arg.PrerequisiteSubCourseID) + return err +} diff --git a/gen/db/user.sql.go b/gen/db/user.sql.go index 69d7398..4dac35c 100644 --- a/gen/db/user.sql.go +++ b/gen/db/user.sql.go @@ -372,13 +372,30 @@ SELECT created_at, updated_at FROM users -LIMIT $2::INT -OFFSET $1::INT +WHERE + ($1::TEXT IS NULL OR role = $1::TEXT) + AND ($2::TEXT IS NULL OR status = $2::TEXT) + AND ($3::TEXT IS NULL OR ( + first_name ILIKE '%' || $3::TEXT || '%' + OR last_name ILIKE '%' || $3::TEXT || '%' + OR email ILIKE '%' || $3::TEXT || '%' + OR phone_number ILIKE '%' || $3::TEXT || '%' + )) + AND ($4::TIMESTAMPTZ IS NULL OR created_at >= $4::TIMESTAMPTZ) + AND ($5::TIMESTAMPTZ IS NULL OR created_at <= $5::TIMESTAMPTZ) +ORDER BY created_at DESC +LIMIT $7::INT +OFFSET $6::INT ` type GetAllUsersParams struct { - Offset pgtype.Int4 `json:"offset"` - Limit pgtype.Int4 `json:"limit"` + Role pgtype.Text `json:"role"` + Status pgtype.Text `json:"status"` + Query pgtype.Text `json:"query"` + CreatedAfter pgtype.Timestamptz `json:"created_after"` + CreatedBefore pgtype.Timestamptz `json:"created_before"` + Offset pgtype.Int4 `json:"offset"` + Limit pgtype.Int4 `json:"limit"` } type GetAllUsersRow struct { @@ -415,7 +432,15 @@ type GetAllUsersRow struct { } func (q *Queries) GetAllUsers(ctx context.Context, arg GetAllUsersParams) ([]GetAllUsersRow, error) { - rows, err := q.db.Query(ctx, GetAllUsers, arg.Offset, arg.Limit) + rows, err := q.db.Query(ctx, GetAllUsers, + arg.Role, + arg.Status, + arg.Query, + arg.CreatedAfter, + arg.CreatedBefore, + arg.Offset, + arg.Limit, + ) if err != nil { return nil, err } diff --git a/gen/db/user_sub_course_progress.sql.go b/gen/db/user_sub_course_progress.sql.go new file mode 100644 index 0000000..f72c76e --- /dev/null +++ b/gen/db/user_sub_course_progress.sql.go @@ -0,0 +1,279 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: user_sub_course_progress.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const CompleteSubCourse = `-- name: CompleteSubCourse :exec +UPDATE user_sub_course_progress +SET + status = 'COMPLETED', + progress_percentage = 100, + completed_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +WHERE user_id = $1 AND sub_course_id = $2 +` + +type CompleteSubCourseParams struct { + UserID int64 `json:"user_id"` + SubCourseID int64 `json:"sub_course_id"` +} + +func (q *Queries) CompleteSubCourse(ctx context.Context, arg CompleteSubCourseParams) error { + _, err := q.db.Exec(ctx, CompleteSubCourse, arg.UserID, arg.SubCourseID) + return err +} + +const DeleteUserSubCourseProgress = `-- name: DeleteUserSubCourseProgress :exec +DELETE FROM user_sub_course_progress +WHERE user_id = $1 AND sub_course_id = $2 +` + +type DeleteUserSubCourseProgressParams struct { + UserID int64 `json:"user_id"` + SubCourseID int64 `json:"sub_course_id"` +} + +func (q *Queries) DeleteUserSubCourseProgress(ctx context.Context, arg DeleteUserSubCourseProgressParams) error { + _, err := q.db.Exec(ctx, DeleteUserSubCourseProgress, arg.UserID, arg.SubCourseID) + return err +} + +const GetSubCoursesWithProgressByCourse = `-- name: GetSubCoursesWithProgressByCourse :many +SELECT + sc.id AS sub_course_id, + sc.title, + sc.description, + sc.thumbnail, + sc.display_order, + sc.level, + sc.is_active, + COALESCE(usp.status, 'NOT_STARTED') AS progress_status, + COALESCE(usp.progress_percentage, 0)::smallint AS progress_percentage, + usp.started_at, + usp.completed_at, + (SELECT COUNT(*)::bigint + FROM sub_course_prerequisites p + WHERE p.sub_course_id = sc.id + AND p.prerequisite_sub_course_id NOT IN ( + SELECT usp2.sub_course_id + FROM user_sub_course_progress usp2 + WHERE usp2.user_id = $1 + AND usp2.status = 'COMPLETED' + ) + ) AS unmet_prerequisites_count +FROM sub_courses sc +LEFT JOIN user_sub_course_progress usp + ON usp.sub_course_id = sc.id AND usp.user_id = $1 +WHERE sc.course_id = $2 + AND sc.is_active = true +ORDER BY sc.display_order +` + +type GetSubCoursesWithProgressByCourseParams struct { + UserID int64 `json:"user_id"` + CourseID int64 `json:"course_id"` +} + +type GetSubCoursesWithProgressByCourseRow struct { + SubCourseID int64 `json:"sub_course_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` + DisplayOrder int32 `json:"display_order"` + Level string `json:"level"` + IsActive bool `json:"is_active"` + ProgressStatus string `json:"progress_status"` + ProgressPercentage int16 `json:"progress_percentage"` + StartedAt pgtype.Timestamptz `json:"started_at"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` + UnmetPrerequisitesCount int64 `json:"unmet_prerequisites_count"` +} + +func (q *Queries) GetSubCoursesWithProgressByCourse(ctx context.Context, arg GetSubCoursesWithProgressByCourseParams) ([]GetSubCoursesWithProgressByCourseRow, error) { + rows, err := q.db.Query(ctx, GetSubCoursesWithProgressByCourse, arg.UserID, arg.CourseID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetSubCoursesWithProgressByCourseRow + for rows.Next() { + var i GetSubCoursesWithProgressByCourseRow + if err := rows.Scan( + &i.SubCourseID, + &i.Title, + &i.Description, + &i.Thumbnail, + &i.DisplayOrder, + &i.Level, + &i.IsActive, + &i.ProgressStatus, + &i.ProgressPercentage, + &i.StartedAt, + &i.CompletedAt, + &i.UnmetPrerequisitesCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetUserCourseProgress = `-- name: GetUserCourseProgress :many +SELECT + usp.id, + usp.user_id, + usp.sub_course_id, + usp.status, + usp.progress_percentage, + usp.started_at, + usp.completed_at, + usp.created_at, + usp.updated_at, + sc.title AS sub_course_title, + sc.level AS sub_course_level, + sc.display_order AS sub_course_display_order +FROM user_sub_course_progress usp +JOIN sub_courses sc ON sc.id = usp.sub_course_id +WHERE usp.user_id = $1 AND sc.course_id = $2 +ORDER BY sc.display_order +` + +type GetUserCourseProgressParams struct { + UserID int64 `json:"user_id"` + CourseID int64 `json:"course_id"` +} + +type GetUserCourseProgressRow struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + SubCourseID int64 `json:"sub_course_id"` + Status string `json:"status"` + ProgressPercentage int16 `json:"progress_percentage"` + StartedAt pgtype.Timestamptz `json:"started_at"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + SubCourseTitle string `json:"sub_course_title"` + SubCourseLevel string `json:"sub_course_level"` + SubCourseDisplayOrder int32 `json:"sub_course_display_order"` +} + +func (q *Queries) GetUserCourseProgress(ctx context.Context, arg GetUserCourseProgressParams) ([]GetUserCourseProgressRow, error) { + rows, err := q.db.Query(ctx, GetUserCourseProgress, arg.UserID, arg.CourseID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetUserCourseProgressRow + for rows.Next() { + var i GetUserCourseProgressRow + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.SubCourseID, + &i.Status, + &i.ProgressPercentage, + &i.StartedAt, + &i.CompletedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.SubCourseTitle, + &i.SubCourseLevel, + &i.SubCourseDisplayOrder, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetUserSubCourseProgress = `-- name: GetUserSubCourseProgress :one +SELECT id, user_id, sub_course_id, status, progress_percentage, started_at, completed_at, created_at, updated_at FROM user_sub_course_progress +WHERE user_id = $1 AND sub_course_id = $2 +` + +type GetUserSubCourseProgressParams struct { + UserID int64 `json:"user_id"` + SubCourseID int64 `json:"sub_course_id"` +} + +func (q *Queries) GetUserSubCourseProgress(ctx context.Context, arg GetUserSubCourseProgressParams) (UserSubCourseProgress, error) { + row := q.db.QueryRow(ctx, GetUserSubCourseProgress, arg.UserID, arg.SubCourseID) + var i UserSubCourseProgress + err := row.Scan( + &i.ID, + &i.UserID, + &i.SubCourseID, + &i.Status, + &i.ProgressPercentage, + &i.StartedAt, + &i.CompletedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const StartSubCourseProgress = `-- name: StartSubCourseProgress :one +INSERT INTO user_sub_course_progress (user_id, sub_course_id) +VALUES ($1, $2) +ON CONFLICT (user_id, sub_course_id) DO NOTHING +RETURNING id, user_id, sub_course_id, status, progress_percentage, started_at, completed_at, created_at, updated_at +` + +type StartSubCourseProgressParams struct { + UserID int64 `json:"user_id"` + SubCourseID int64 `json:"sub_course_id"` +} + +func (q *Queries) StartSubCourseProgress(ctx context.Context, arg StartSubCourseProgressParams) (UserSubCourseProgress, error) { + row := q.db.QueryRow(ctx, StartSubCourseProgress, arg.UserID, arg.SubCourseID) + var i UserSubCourseProgress + err := row.Scan( + &i.ID, + &i.UserID, + &i.SubCourseID, + &i.Status, + &i.ProgressPercentage, + &i.StartedAt, + &i.CompletedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const UpdateSubCourseProgress = `-- name: UpdateSubCourseProgress :exec +UPDATE user_sub_course_progress +SET + progress_percentage = $1, + updated_at = CURRENT_TIMESTAMP +WHERE user_id = $2 AND sub_course_id = $3 +` + +type UpdateSubCourseProgressParams struct { + ProgressPercentage int16 `json:"progress_percentage"` + UserID int64 `json:"user_id"` + SubCourseID int64 `json:"sub_course_id"` +} + +func (q *Queries) UpdateSubCourseProgress(ctx context.Context, arg UpdateSubCourseProgressParams) error { + _, err := q.db.Exec(ctx, UpdateSubCourseProgress, arg.ProgressPercentage, arg.UserID, arg.SubCourseID) + return err +} diff --git a/internal/domain/course_management.go b/internal/domain/course_management.go index b80aae1..74e6965 100644 --- a/internal/domain/course_management.go +++ b/internal/domain/course_management.go @@ -39,12 +39,13 @@ type CourseCategory struct { } type Course struct { - ID int64 - CategoryID int64 - Title string - Description *string - Thumbnail *string - IsActive bool + ID int64 + CategoryID int64 + Title string + Description *string + Thumbnail *string + IntroVideoURL *string + IsActive bool } type SubCourse struct { diff --git a/internal/domain/notification.go b/internal/domain/notification.go index 1240441..bbd1b22 100644 --- a/internal/domain/notification.go +++ b/internal/domain/notification.go @@ -31,6 +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" NotificationRecieverSideAdmin NotificationRecieverSide = "admin" NotificationRecieverSideCustomer NotificationRecieverSide = "customer" @@ -104,6 +105,17 @@ type CreateNotification struct { Metadata json.RawMessage `json:"metadata,omitempty"` } +type NotificationFilter struct { + Channel string + Type string + UserID *int64 + IsRead *bool + After *time.Time + Before *time.Time + Limit int + Offset int +} + func (n *Notification) ToJSON() ([]byte, error) { return json.Marshal(n) } diff --git a/internal/domain/progression.go b/internal/domain/progression.go new file mode 100644 index 0000000..692770d --- /dev/null +++ b/internal/domain/progression.go @@ -0,0 +1,84 @@ +package domain + +import ( + "errors" + "time" +) + +var ( + ErrPrerequisiteNotMet = errors.New("prerequisites not completed") + ErrProgressNotFound = errors.New("progress record not found") + ErrPrerequisiteExists = errors.New("prerequisite already exists") + ErrSelfPrerequisite = errors.New("sub-course cannot be its own prerequisite") + ErrSubCourseAlreadyStarted = errors.New("sub-course already started") +) + +type SubCoursePrerequisite struct { + ID int64 + SubCourseID int64 + PrerequisiteSubCourseID int64 + PrerequisiteTitle string + PrerequisiteLevel string + PrerequisiteDisplayOrder int32 + CreatedAt time.Time +} + +type SubCourseDependent struct { + ID int64 + SubCourseID int64 + PrerequisiteSubCourseID int64 + DependentTitle string + DependentLevel string + CreatedAt time.Time +} + +type ProgressStatus string + +const ( + ProgressStatusNotStarted ProgressStatus = "NOT_STARTED" + ProgressStatusInProgress ProgressStatus = "IN_PROGRESS" + ProgressStatusCompleted ProgressStatus = "COMPLETED" +) + +type UserSubCourseProgress struct { + ID int64 + UserID int64 + SubCourseID int64 + Status ProgressStatus + ProgressPercentage int16 + StartedAt *time.Time + CompletedAt *time.Time + CreatedAt time.Time + UpdatedAt *time.Time +} + +type SubCourseWithProgress struct { + SubCourseID int64 + Title string + Description *string + Thumbnail *string + DisplayOrder int32 + Level string + IsActive bool + ProgressStatus ProgressStatus + ProgressPercentage int16 + StartedAt *time.Time + CompletedAt *time.Time + UnmetPrerequisitesCount int64 + IsLocked bool +} + +type UserCourseProgressItem struct { + ID int64 + UserID int64 + SubCourseID int64 + Status ProgressStatus + ProgressPercentage int16 + StartedAt *time.Time + CompletedAt *time.Time + CreatedAt time.Time + UpdatedAt *time.Time + SubCourseTitle string + SubCourseLevel string + SubCourseDisplayOrder int32 +} diff --git a/internal/domain/rbac.go b/internal/domain/rbac.go new file mode 100644 index 0000000..549bbf3 --- /dev/null +++ b/internal/domain/rbac.go @@ -0,0 +1,63 @@ +package domain + +import "time" + +type Permission struct { + ID int64 `json:"id"` + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description"` + GroupName string `json:"group_name"` + CreatedAt time.Time `json:"created_at"` +} + +type RoleRecord struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + IsSystem bool `json:"is_system"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +type RoleWithPermissions struct { + RoleRecord + Permissions []Permission `json:"permissions"` +} + +type RoleListFilter struct { + Query string + IsSystem *bool + Page int64 + PageSize int64 +} + +type CreateRoleReq struct { + Name string `json:"name" validate:"required,min=2,max=100"` + Description string `json:"description"` +} + +type UpdateRoleReq struct { + Name string `json:"name" validate:"required,min=2,max=100"` + Description string `json:"description"` +} + +type SetRolePermissionsReq struct { + PermissionIDs []int64 `json:"permission_ids" validate:"required"` +} + +type PermissionSeed struct { + Key string + Name string + Description string + GroupName string +} + +// Activity log constants for RBAC +const ( + ActionRoleCreated ActivityAction = "ROLE_CREATED" + ActionRoleUpdated ActivityAction = "ROLE_UPDATED" + ActionRoleDeleted ActivityAction = "ROLE_DELETED" + ActionRolePermissionsSet ActivityAction = "ROLE_PERMISSIONS_SET" + ResourceRole ResourceType = "ROLE" +) diff --git a/internal/domain/scheduled_notification.go b/internal/domain/scheduled_notification.go new file mode 100644 index 0000000..6834a2b --- /dev/null +++ b/internal/domain/scheduled_notification.go @@ -0,0 +1,56 @@ +package domain + +import ( + "encoding/json" + "time" +) + +type ScheduledNotificationStatus string + +const ( + ScheduledStatusPending ScheduledNotificationStatus = "pending" + ScheduledStatusProcessing ScheduledNotificationStatus = "processing" + ScheduledStatusSent ScheduledNotificationStatus = "sent" + ScheduledStatusFailed ScheduledNotificationStatus = "failed" + ScheduledStatusCancelled ScheduledNotificationStatus = "cancelled" +) + +type ScheduledNotificationTargetRaw struct { + Phones []string `json:"phones,omitempty"` + Emails []string `json:"emails,omitempty"` +} + +type ScheduledNotification struct { + ID int64 `json:"id"` + Channel DeliveryChannel `json:"channel"` + Title string `json:"title,omitempty"` + Message string `json:"message"` + HTML string `json:"html,omitempty"` + + ScheduledAt time.Time `json:"scheduled_at"` + Status ScheduledNotificationStatus `json:"status"` + + TargetUserIDs []int64 `json:"target_user_ids,omitempty"` + TargetRole string `json:"target_role,omitempty"` + TargetRaw json.RawMessage `json:"target_raw,omitempty"` + + AttemptCount int32 `json:"attempt_count"` + LastError string `json:"last_error,omitempty"` + + ProcessingStartedAt *time.Time `json:"processing_started_at,omitempty"` + SentAt *time.Time `json:"sent_at,omitempty"` + CancelledAt *time.Time `json:"cancelled_at,omitempty"` + + CreatedBy int64 `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ScheduledNotificationFilter struct { + Status string + Channel string + After *time.Time + Before *time.Time + Limit int + Offset int +} diff --git a/internal/domain/user.go b/internal/domain/user.go index 6f45aa2..e4e1dd2 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -123,7 +123,8 @@ type UserProfileResponse struct { } type UserFilter struct { - Role string + Role string + Status string Page int64 PageSize int64 diff --git a/internal/ports/course_management.go b/internal/ports/course_management.go index a3ec53b..b6505ec 100644 --- a/internal/ports/course_management.go +++ b/internal/ports/course_management.go @@ -38,6 +38,7 @@ type CourseStore interface { title string, description *string, thumbnail *string, + introVideoURL *string, ) (domain.Course, error) GetCourseByID( ctx context.Context, @@ -55,6 +56,7 @@ type CourseStore interface { title *string, description *string, thumbnail *string, + introVideoURL *string, isActive *bool, ) error DeleteCourse( @@ -171,3 +173,21 @@ type CourseStore interface { // Learning Tree GetFullLearningTree(ctx context.Context) ([]domain.TreeCourse, error) } + +type ProgressionStore interface { + // Prerequisites (admin) + AddSubCoursePrerequisite(ctx context.Context, subCourseID, prerequisiteSubCourseID int64) error + RemoveSubCoursePrerequisite(ctx context.Context, subCourseID, prerequisiteSubCourseID int64) error + GetSubCoursePrerequisites(ctx context.Context, subCourseID int64) ([]domain.SubCoursePrerequisite, error) + GetSubCourseDependents(ctx context.Context, prerequisiteSubCourseID int64) ([]domain.SubCourseDependent, error) + CountUnmetPrerequisites(ctx context.Context, subCourseID, userID int64) (int64, error) + DeleteAllPrerequisitesForSubCourse(ctx context.Context, subCourseID int64) error + + // User progress + StartSubCourseProgress(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error) + UpdateSubCourseProgress(ctx context.Context, userID, subCourseID int64, percentage int16) error + CompleteSubCourse(ctx context.Context, userID, subCourseID int64) error + GetUserSubCourseProgress(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error) + GetUserCourseProgress(ctx context.Context, userID, courseID int64) ([]domain.UserCourseProgressItem, error) + GetSubCoursesWithProgressByCourse(ctx context.Context, userID, courseID int64) ([]domain.SubCourseWithProgress, error) +} diff --git a/internal/ports/notification.go b/internal/ports/notification.go index 90c3b0d..229fb1f 100644 --- a/internal/ports/notification.go +++ b/internal/ports/notification.go @@ -10,6 +10,7 @@ type NotificationStore interface { GetUserNotifications(ctx context.Context, recipientID int64, limit, offset int) ([]domain.Notification, int64, error) CountUnreadNotifications(ctx context.Context, recipient_id int64) (int64, error) GetAllNotifications(ctx context.Context, limit, offset int) ([]domain.Notification, error) + GetFilteredNotifications(ctx context.Context, filter domain.NotificationFilter) ([]domain.Notification, int64, error) CreateNotification(ctx context.Context, notification *domain.Notification) (*domain.Notification, error) MarkNotificationAsRead(ctx context.Context, id int64) (*domain.Notification, error) @@ -17,4 +18,13 @@ type NotificationStore interface { MarkNotificationAsUnread(ctx context.Context, id int64) (*domain.Notification, error) MarkAllUserNotificationsAsUnread(ctx context.Context, userID int64) error DeleteUserNotifications(ctx context.Context, userID int64) error + + // Scheduled Notifications + CreateScheduledNotification(ctx context.Context, sn *domain.ScheduledNotification) (*domain.ScheduledNotification, error) + GetScheduledNotification(ctx context.Context, id int64) (*domain.ScheduledNotification, error) + ListScheduledNotifications(ctx context.Context, filter domain.ScheduledNotificationFilter) ([]domain.ScheduledNotification, int64, error) + CancelScheduledNotification(ctx context.Context, id int64) (*domain.ScheduledNotification, error) + ClaimDueScheduledNotifications(ctx context.Context, limit int32) ([]domain.ScheduledNotification, error) + MarkScheduledNotificationSent(ctx context.Context, id int64) error + MarkScheduledNotificationFailed(ctx context.Context, id int64, lastError string) error } diff --git a/internal/ports/rbac.go b/internal/ports/rbac.go new file mode 100644 index 0000000..8b6f945 --- /dev/null +++ b/internal/ports/rbac.go @@ -0,0 +1,25 @@ +package ports + +import ( + "Yimaru-Backend/internal/domain" + "context" +) + +type RBACStore interface { + CreateRole(ctx context.Context, name, description string, isSystem bool) (domain.RoleRecord, error) + GetRoleByID(ctx context.Context, id int64) (domain.RoleRecord, error) + GetRoleByName(ctx context.Context, name string) (domain.RoleRecord, error) + ListRoles(ctx context.Context, filter domain.RoleListFilter) ([]domain.RoleRecord, int64, error) + UpdateRole(ctx context.Context, id int64, name, description string) error + DeleteRole(ctx context.Context, id int64) error + + UpsertPermission(ctx context.Context, seed domain.PermissionSeed) (domain.Permission, error) + ListPermissions(ctx context.Context) ([]domain.Permission, error) + ListPermissionGroups(ctx context.Context) ([]string, error) + GetPermissionByKey(ctx context.Context, key string) (domain.Permission, error) + + SetRolePermissions(ctx context.Context, roleID int64, permissionIDs []int64) error + GetRolePermissions(ctx context.Context, roleID int64) ([]domain.Permission, error) + + GetAllRolesWithPermissions(ctx context.Context) (map[string]map[string]struct{}, error) +} diff --git a/internal/ports/user.go b/internal/ports/user.go index c362572..3e8c372 100644 --- a/internal/ports/user.go +++ b/internal/ports/user.go @@ -52,6 +52,7 @@ type UserStore interface { GetAllUsers( ctx context.Context, role *string, + status *string, query *string, createdBefore, createdAfter *time.Time, limit, offset int32, diff --git a/internal/repository/courses.go b/internal/repository/courses.go index 1c63c7b..32dcfaf 100644 --- a/internal/repository/courses.go +++ b/internal/repository/courses.go @@ -14,34 +14,32 @@ func (s *Store) CreateCourse( title string, description *string, thumbnail *string, + introVideoURL *string, ) (domain.Course, error) { - var descVal, thumbVal string + var descVal, thumbVal, introVideoVal string if description != nil { descVal = *description } if thumbnail != nil { thumbVal = *thumbnail } + if introVideoURL != nil { + introVideoVal = *introVideoURL + } row, err := s.queries.CreateCourse(ctx, dbgen.CreateCourseParams{ - CategoryID: categoryID, - Title: title, - Description: pgtype.Text{String: descVal, Valid: description != nil}, - Thumbnail: pgtype.Text{String: thumbVal, Valid: thumbnail != nil}, - Column5: true, + CategoryID: categoryID, + Title: title, + Description: pgtype.Text{String: descVal, Valid: description != nil}, + Thumbnail: pgtype.Text{String: thumbVal, Valid: thumbnail != nil}, + IntroVideoUrl: pgtype.Text{String: introVideoVal, Valid: introVideoURL != nil}, + Column6: true, }) if err != nil { return domain.Course{}, err } - return domain.Course{ - ID: row.ID, - CategoryID: row.CategoryID, - Title: row.Title, - Description: &row.Description.String, - Thumbnail: &row.Thumbnail.String, - IsActive: row.IsActive, - }, nil + return mapCourse(row), nil } func (s *Store) GetCourseByID( @@ -54,14 +52,7 @@ func (s *Store) GetCourseByID( return domain.Course{}, err } - return domain.Course{ - ID: row.ID, - CategoryID: row.CategoryID, - Title: row.Title, - Description: &row.Description.String, - Thumbnail: &row.Thumbnail.String, - IsActive: row.IsActive, - }, nil + return mapCourse(row), nil } func (s *Store) GetCoursesByCategory( @@ -91,12 +82,13 @@ func (s *Store) GetCoursesByCategory( } courses = append(courses, domain.Course{ - ID: row.ID, - CategoryID: row.CategoryID, - Title: row.Title, - Description: &row.Description.String, - Thumbnail: &row.Thumbnail.String, - IsActive: row.IsActive, + ID: row.ID, + CategoryID: row.CategoryID, + Title: row.Title, + Description: ptrText(row.Description), + Thumbnail: ptrText(row.Thumbnail), + IntroVideoURL: ptrText(row.IntroVideoUrl), + IsActive: row.IsActive, }) } @@ -109,13 +101,15 @@ func (s *Store) UpdateCourse( title *string, description *string, thumbnail *string, + introVideoURL *string, isActive *bool, ) error { var ( - titleVal string - descriptionVal string - thumbnailVal string - isActiveVal bool + titleVal string + descriptionVal string + thumbnailVal string + introVideoVal string + isActiveVal bool ) if title != nil { @@ -127,16 +121,20 @@ func (s *Store) UpdateCourse( if thumbnail != nil { thumbnailVal = *thumbnail } + if introVideoURL != nil { + introVideoVal = *introVideoURL + } if isActive != nil { isActiveVal = *isActive } return s.queries.UpdateCourse(ctx, dbgen.UpdateCourseParams{ - Title: titleVal, - Description: pgtype.Text{String: descriptionVal, Valid: description != nil}, - Thumbnail: pgtype.Text{String: thumbnailVal, Valid: thumbnail != nil}, - IsActive: isActiveVal, - ID: id, + Title: titleVal, + Description: pgtype.Text{String: descriptionVal, Valid: description != nil}, + Thumbnail: pgtype.Text{String: thumbnailVal, Valid: thumbnail != nil}, + IntroVideoUrl: pgtype.Text{String: introVideoVal, Valid: introVideoURL != nil}, + IsActive: isActiveVal, + ID: id, }) } @@ -147,3 +145,22 @@ func (s *Store) DeleteCourse( return s.queries.DeleteCourse(ctx, id) } + +func mapCourse(row dbgen.Course) domain.Course { + return domain.Course{ + ID: row.ID, + CategoryID: row.CategoryID, + Title: row.Title, + Description: ptrText(row.Description), + Thumbnail: ptrText(row.Thumbnail), + IntroVideoURL: ptrText(row.IntroVideoUrl), + IsActive: row.IsActive, + } +} + +func ptrText(t pgtype.Text) *string { + if t.Valid { + return &t.String + } + return nil +} diff --git a/internal/repository/notification.go b/internal/repository/notification.go index b78dff2..8298493 100644 --- a/internal/repository/notification.go +++ b/internal/repository/notification.go @@ -104,6 +104,61 @@ func (r *Store) GetAllNotifications( return result, nil } +func (r *Store) GetFilteredNotifications( + ctx context.Context, + filter domain.NotificationFilter, +) ([]domain.Notification, int64, error) { + + filterParams := dbgen.GetFilteredNotificationsParams{ + FilterChannel: pgtype.Text{String: filter.Channel, Valid: filter.Channel != ""}, + FilterType: pgtype.Text{String: filter.Type, Valid: filter.Type != ""}, + PageLimit: int32(filter.Limit), + PageOffset: int32(filter.Offset), + } + countParams := dbgen.GetFilteredNotificationCountParams{ + FilterChannel: filterParams.FilterChannel, + FilterType: filterParams.FilterType, + } + + if filter.UserID != nil { + v := pgtype.Int8{Int64: *filter.UserID, Valid: true} + filterParams.FilterUserID = v + countParams.FilterUserID = v + } + if filter.IsRead != nil { + v := pgtype.Bool{Bool: *filter.IsRead, Valid: true} + filterParams.FilterIsRead = v + countParams.FilterIsRead = v + } + if filter.After != nil { + v := pgtype.Timestamptz{Time: *filter.After, Valid: true} + filterParams.FilterAfter = v + countParams.FilterAfter = v + } + if filter.Before != nil { + v := pgtype.Timestamptz{Time: *filter.Before, Valid: true} + filterParams.FilterBefore = v + countParams.FilterBefore = v + } + + rows, err := r.queries.GetFilteredNotifications(ctx, filterParams) + if err != nil { + return nil, 0, err + } + + total, err := r.queries.GetFilteredNotificationCount(ctx, countParams) + if err != nil { + return nil, 0, err + } + + result := make([]domain.Notification, 0, len(rows)) + for _, row := range rows { + result = append(result, *mapDBToDomain(&row)) + } + + return result, total, nil +} + func (r *Store) CountUnreadNotifications( ctx context.Context, userID int64, diff --git a/internal/repository/progression.go b/internal/repository/progression.go new file mode 100644 index 0000000..67b215a --- /dev/null +++ b/internal/repository/progression.go @@ -0,0 +1,226 @@ +package repository + +import ( + dbgen "Yimaru-Backend/gen/db" + "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/ports" + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" +) + +func NewProgressionStore(s *Store) ports.ProgressionStore { return s } + +func (s *Store) AddSubCoursePrerequisite(ctx context.Context, subCourseID, prerequisiteSubCourseID int64) error { + _, err := s.queries.AddSubCoursePrerequisite(ctx, dbgen.AddSubCoursePrerequisiteParams{ + SubCourseID: subCourseID, + PrerequisiteSubCourseID: prerequisiteSubCourseID, + }) + return err +} + +func (s *Store) RemoveSubCoursePrerequisite(ctx context.Context, subCourseID, prerequisiteSubCourseID int64) error { + return s.queries.RemoveSubCoursePrerequisite(ctx, dbgen.RemoveSubCoursePrerequisiteParams{ + SubCourseID: subCourseID, + PrerequisiteSubCourseID: prerequisiteSubCourseID, + }) +} + +func (s *Store) GetSubCoursePrerequisites(ctx context.Context, subCourseID int64) ([]domain.SubCoursePrerequisite, error) { + rows, err := s.queries.GetSubCoursePrerequisites(ctx, subCourseID) + if err != nil { + return nil, err + } + + prereqs := make([]domain.SubCoursePrerequisite, len(rows)) + for i, row := range rows { + prereqs[i] = domain.SubCoursePrerequisite{ + ID: row.ID, + SubCourseID: row.SubCourseID, + PrerequisiteSubCourseID: row.PrerequisiteSubCourseID, + CreatedAt: row.CreatedAt.Time, + PrerequisiteTitle: row.PrerequisiteTitle, + PrerequisiteLevel: row.PrerequisiteLevel, + PrerequisiteDisplayOrder: row.PrerequisiteDisplayOrder, + } + } + return prereqs, nil +} + +func (s *Store) GetSubCourseDependents(ctx context.Context, prerequisiteSubCourseID int64) ([]domain.SubCourseDependent, error) { + rows, err := s.queries.GetSubCourseDependents(ctx, prerequisiteSubCourseID) + if err != nil { + return nil, err + } + + deps := make([]domain.SubCourseDependent, len(rows)) + for i, row := range rows { + deps[i] = domain.SubCourseDependent{ + ID: row.ID, + SubCourseID: row.SubCourseID, + PrerequisiteSubCourseID: row.PrerequisiteSubCourseID, + CreatedAt: row.CreatedAt.Time, + DependentTitle: row.DependentTitle, + DependentLevel: row.DependentLevel, + } + } + return deps, nil +} + +func (s *Store) CountUnmetPrerequisites(ctx context.Context, subCourseID, userID int64) (int64, error) { + return s.queries.CountUnmetPrerequisites(ctx, dbgen.CountUnmetPrerequisitesParams{ + SubCourseID: subCourseID, + UserID: userID, + }) +} + +func (s *Store) DeleteAllPrerequisitesForSubCourse(ctx context.Context, subCourseID int64) error { + return s.queries.DeleteAllPrerequisitesForSubCourse(ctx, subCourseID) +} + +func (s *Store) StartSubCourseProgress(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error) { + row, err := s.queries.StartSubCourseProgress(ctx, dbgen.StartSubCourseProgressParams{ + UserID: userID, + SubCourseID: subCourseID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return s.GetUserSubCourseProgress(ctx, userID, subCourseID) + } + return domain.UserSubCourseProgress{}, err + } + return mapUserSubCourseProgress(row), nil +} + +func (s *Store) UpdateSubCourseProgress(ctx context.Context, userID, subCourseID int64, percentage int16) error { + return s.queries.UpdateSubCourseProgress(ctx, dbgen.UpdateSubCourseProgressParams{ + ProgressPercentage: percentage, + UserID: userID, + SubCourseID: subCourseID, + }) +} + +func (s *Store) CompleteSubCourse(ctx context.Context, userID, subCourseID int64) error { + return s.queries.CompleteSubCourse(ctx, dbgen.CompleteSubCourseParams{ + UserID: userID, + SubCourseID: subCourseID, + }) +} + +func (s *Store) GetUserSubCourseProgress(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error) { + row, err := s.queries.GetUserSubCourseProgress(ctx, dbgen.GetUserSubCourseProgressParams{ + UserID: userID, + SubCourseID: subCourseID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.UserSubCourseProgress{}, domain.ErrProgressNotFound + } + return domain.UserSubCourseProgress{}, err + } + return mapUserSubCourseProgress(row), nil +} + +func (s *Store) GetUserCourseProgress(ctx context.Context, userID, courseID int64) ([]domain.UserCourseProgressItem, error) { + rows, err := s.queries.GetUserCourseProgress(ctx, dbgen.GetUserCourseProgressParams{ + UserID: userID, + CourseID: courseID, + }) + if err != nil { + return nil, err + } + + items := make([]domain.UserCourseProgressItem, len(rows)) + for i, row := range rows { + var startedAt, completedAt *time.Time + if row.StartedAt.Valid { + startedAt = &row.StartedAt.Time + } + if row.CompletedAt.Valid { + completedAt = &row.CompletedAt.Time + } + var updatedAt *time.Time + if row.UpdatedAt.Valid { + updatedAt = &row.UpdatedAt.Time + } + items[i] = domain.UserCourseProgressItem{ + ID: row.ID, + UserID: row.UserID, + SubCourseID: row.SubCourseID, + Status: domain.ProgressStatus(row.Status), + ProgressPercentage: row.ProgressPercentage, + StartedAt: startedAt, + CompletedAt: completedAt, + CreatedAt: row.CreatedAt.Time, + UpdatedAt: updatedAt, + SubCourseTitle: row.SubCourseTitle, + SubCourseLevel: row.SubCourseLevel, + SubCourseDisplayOrder: row.SubCourseDisplayOrder, + } + } + return items, nil +} + +func (s *Store) GetSubCoursesWithProgressByCourse(ctx context.Context, userID, courseID int64) ([]domain.SubCourseWithProgress, error) { + rows, err := s.queries.GetSubCoursesWithProgressByCourse(ctx, dbgen.GetSubCoursesWithProgressByCourseParams{ + UserID: userID, + CourseID: courseID, + }) + if err != nil { + return nil, err + } + + items := make([]domain.SubCourseWithProgress, len(rows)) + for i, row := range rows { + var startedAt, completedAt *time.Time + if row.StartedAt.Valid { + startedAt = &row.StartedAt.Time + } + if row.CompletedAt.Valid { + completedAt = &row.CompletedAt.Time + } + items[i] = domain.SubCourseWithProgress{ + SubCourseID: row.SubCourseID, + Title: row.Title, + Description: ptrText(row.Description), + Thumbnail: ptrText(row.Thumbnail), + DisplayOrder: row.DisplayOrder, + Level: row.Level, + IsActive: row.IsActive, + ProgressStatus: domain.ProgressStatus(row.ProgressStatus), + ProgressPercentage: row.ProgressPercentage, + StartedAt: startedAt, + CompletedAt: completedAt, + UnmetPrerequisitesCount: row.UnmetPrerequisitesCount, + IsLocked: row.UnmetPrerequisitesCount > 0, + } + } + return items, nil +} + +func mapUserSubCourseProgress(row dbgen.UserSubCourseProgress) domain.UserSubCourseProgress { + var startedAt, completedAt *time.Time + if row.StartedAt.Valid { + startedAt = &row.StartedAt.Time + } + if row.CompletedAt.Valid { + completedAt = &row.CompletedAt.Time + } + var updatedAt *time.Time + if row.UpdatedAt.Valid { + updatedAt = &row.UpdatedAt.Time + } + return domain.UserSubCourseProgress{ + ID: row.ID, + UserID: row.UserID, + SubCourseID: row.SubCourseID, + Status: domain.ProgressStatus(row.Status), + ProgressPercentage: row.ProgressPercentage, + StartedAt: startedAt, + CompletedAt: completedAt, + CreatedAt: row.CreatedAt.Time, + UpdatedAt: updatedAt, + } +} diff --git a/internal/repository/rbac.go b/internal/repository/rbac.go new file mode 100644 index 0000000..3705080 --- /dev/null +++ b/internal/repository/rbac.go @@ -0,0 +1,203 @@ +package repository + +import ( + dbgen "Yimaru-Backend/gen/db" + "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/ports" + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +func NewRBACStore(s *Store) ports.RBACStore { return s } + +func (s *Store) CreateRole(ctx context.Context, name, description string, isSystem bool) (domain.RoleRecord, error) { + r, err := s.queries.CreateRole(ctx, dbgen.CreateRoleParams{ + Name: name, + Description: description, + IsSystem: isSystem, + }) + if err != nil { + return domain.RoleRecord{}, err + } + return mapRole(r), nil +} + +func (s *Store) GetRoleByID(ctx context.Context, id int64) (domain.RoleRecord, error) { + r, err := s.queries.GetRoleByID(ctx, id) + if err != nil { + return domain.RoleRecord{}, err + } + return mapRole(r), nil +} + +func (s *Store) GetRoleByName(ctx context.Context, name string) (domain.RoleRecord, error) { + r, err := s.queries.GetRoleByName(ctx, name) + if err != nil { + return domain.RoleRecord{}, err + } + return mapRole(r), nil +} + +func (s *Store) ListRoles(ctx context.Context, filter domain.RoleListFilter) ([]domain.RoleRecord, int64, error) { + var queryParam pgtype.Text + if filter.Query != "" { + queryParam = pgtype.Text{String: filter.Query, Valid: true} + } + + var isSystemParam pgtype.Bool + if filter.IsSystem != nil { + isSystemParam = pgtype.Bool{Bool: *filter.IsSystem, Valid: true} + } + + limit := int32(filter.PageSize) + if limit <= 0 { + limit = 20 + } + offset := int32(filter.Page * filter.PageSize) + + rows, err := s.queries.ListRoles(ctx, dbgen.ListRolesParams{ + Query: queryParam, + IsSystem: isSystemParam, + Limit: pgtype.Int4{Int32: limit, Valid: true}, + Offset: pgtype.Int4{Int32: offset, Valid: true}, + }) + if err != nil { + return nil, 0, err + } + + if len(rows) == 0 { + return []domain.RoleRecord{}, 0, nil + } + + totalCount := rows[0].TotalCount + roles := make([]domain.RoleRecord, len(rows)) + for i, r := range rows { + rec := domain.RoleRecord{ + ID: r.ID, + Name: r.Name, + Description: r.Description, + IsSystem: r.IsSystem, + CreatedAt: r.CreatedAt.Time, + } + if r.UpdatedAt.Valid { + rec.UpdatedAt = &r.UpdatedAt.Time + } + roles[i] = rec + } + return roles, totalCount, nil +} + +func (s *Store) UpdateRole(ctx context.Context, id int64, name, description string) error { + return s.queries.UpdateRole(ctx, dbgen.UpdateRoleParams{ + ID: id, + Name: name, + Description: description, + }) +} + +func (s *Store) DeleteRole(ctx context.Context, id int64) error { + return s.queries.DeleteRole(ctx, id) +} + +func (s *Store) UpsertPermission(ctx context.Context, seed domain.PermissionSeed) (domain.Permission, error) { + p, err := s.queries.UpsertPermission(ctx, dbgen.UpsertPermissionParams{ + Key: seed.Key, + Name: seed.Name, + Description: seed.Description, + GroupName: seed.GroupName, + }) + if err != nil { + return domain.Permission{}, err + } + return mapPermission(p), nil +} + +func (s *Store) ListPermissions(ctx context.Context) ([]domain.Permission, error) { + rows, err := s.queries.ListPermissions(ctx) + if err != nil { + return nil, err + } + perms := make([]domain.Permission, len(rows)) + for i, p := range rows { + perms[i] = mapPermission(p) + } + return perms, nil +} + +func (s *Store) ListPermissionGroups(ctx context.Context) ([]string, error) { + return s.queries.ListPermissionGroups(ctx) +} + +func (s *Store) GetPermissionByKey(ctx context.Context, key string) (domain.Permission, error) { + p, err := s.queries.GetPermissionByKey(ctx, key) + if err != nil { + return domain.Permission{}, err + } + return mapPermission(p), nil +} + +func (s *Store) SetRolePermissions(ctx context.Context, roleID int64, permissionIDs []int64) error { + if err := s.queries.SetRolePermissions(ctx, roleID); err != nil { + return err + } + if len(permissionIDs) > 0 { + return s.queries.BulkAssignPermissionsToRole(ctx, dbgen.BulkAssignPermissionsToRoleParams{ + RoleID: roleID, + Column2: permissionIDs, + }) + } + return nil +} + +func (s *Store) GetRolePermissions(ctx context.Context, roleID int64) ([]domain.Permission, error) { + rows, err := s.queries.GetRolePermissions(ctx, roleID) + if err != nil { + return nil, err + } + perms := make([]domain.Permission, len(rows)) + for i, p := range rows { + perms[i] = mapPermission(p) + } + return perms, nil +} + +func (s *Store) GetAllRolesWithPermissions(ctx context.Context) (map[string]map[string]struct{}, error) { + rows, err := s.queries.GetAllRolesWithPermissions(ctx) + if err != nil { + return nil, err + } + result := make(map[string]map[string]struct{}) + for _, row := range rows { + if _, ok := result[row.RoleName]; !ok { + result[row.RoleName] = make(map[string]struct{}) + } + result[row.RoleName][row.PermissionKey] = struct{}{} + } + return result, nil +} + +func mapRole(r dbgen.Role) domain.RoleRecord { + rec := domain.RoleRecord{ + ID: r.ID, + Name: r.Name, + Description: r.Description, + IsSystem: r.IsSystem, + CreatedAt: r.CreatedAt.Time, + } + if r.UpdatedAt.Valid { + rec.UpdatedAt = &r.UpdatedAt.Time + } + return rec +} + +func mapPermission(p dbgen.Permission) domain.Permission { + return domain.Permission{ + ID: p.ID, + Key: p.Key, + Name: p.Name, + Description: p.Description, + GroupName: p.GroupName, + CreatedAt: p.CreatedAt.Time, + } +} diff --git a/internal/repository/scheduled_notification.go b/internal/repository/scheduled_notification.go new file mode 100644 index 0000000..796bbcd --- /dev/null +++ b/internal/repository/scheduled_notification.go @@ -0,0 +1,202 @@ +package repository + +import ( + "context" + "encoding/json" + + dbgen "Yimaru-Backend/gen/db" + "Yimaru-Backend/internal/domain" + + "github.com/jackc/pgx/v5/pgtype" +) + +/* ========================= + Create + ========================= */ + +func (r *Store) CreateScheduledNotification( + ctx context.Context, + sn *domain.ScheduledNotification, +) (*domain.ScheduledNotification, error) { + + params := dbgen.CreateScheduledNotificationParams{ + Channel: string(sn.Channel), + Title: pgtype.Text{String: sn.Title, Valid: sn.Title != ""}, + Message: sn.Message, + Html: pgtype.Text{String: sn.HTML, Valid: sn.HTML != ""}, + ScheduledAt: pgtype.Timestamptz{Time: sn.ScheduledAt, Valid: true}, + TargetUserIds: sn.TargetUserIDs, + TargetRole: pgtype.Text{String: sn.TargetRole, Valid: sn.TargetRole != ""}, + TargetRaw: json.RawMessage(sn.TargetRaw), + CreatedBy: sn.CreatedBy, + } + + dbRow, err := r.queries.CreateScheduledNotification(ctx, params) + if err != nil { + return nil, err + } + + return mapScheduledDBToDomain(&dbRow), nil +} + +/* ========================= + Read + ========================= */ + +func (r *Store) GetScheduledNotification( + ctx context.Context, + id int64, +) (*domain.ScheduledNotification, error) { + + dbRow, err := r.queries.GetScheduledNotification(ctx, id) + if err != nil { + return nil, err + } + + return mapScheduledDBToDomain(&dbRow), nil +} + +func (r *Store) ListScheduledNotifications( + ctx context.Context, + filter domain.ScheduledNotificationFilter, +) ([]domain.ScheduledNotification, int64, error) { + + filterParams := dbgen.ListScheduledNotificationsParams{ + FilterStatus: pgtype.Text{String: filter.Status, Valid: filter.Status != ""}, + FilterChannel: pgtype.Text{String: filter.Channel, Valid: filter.Channel != ""}, + PageLimit: int32(filter.Limit), + PageOffset: int32(filter.Offset), + } + countParams := dbgen.CountScheduledNotificationsParams{ + FilterStatus: filterParams.FilterStatus, + FilterChannel: filterParams.FilterChannel, + } + + if filter.After != nil { + v := pgtype.Timestamptz{Time: *filter.After, Valid: true} + filterParams.FilterAfter = v + countParams.FilterAfter = v + } + if filter.Before != nil { + v := pgtype.Timestamptz{Time: *filter.Before, Valid: true} + filterParams.FilterBefore = v + countParams.FilterBefore = v + } + + rows, err := r.queries.ListScheduledNotifications(ctx, filterParams) + if err != nil { + return nil, 0, err + } + + total, err := r.queries.CountScheduledNotifications(ctx, countParams) + if err != nil { + return nil, 0, err + } + + result := make([]domain.ScheduledNotification, 0, len(rows)) + for _, row := range rows { + result = append(result, *mapScheduledDBToDomain(&row)) + } + + return result, total, nil +} + +/* ========================= + Update + ========================= */ + +func (r *Store) CancelScheduledNotification( + ctx context.Context, + id int64, +) (*domain.ScheduledNotification, error) { + + dbRow, err := r.queries.CancelScheduledNotification(ctx, id) + if err != nil { + return nil, err + } + + return mapScheduledDBToDomain(&dbRow), nil +} + +func (r *Store) ClaimDueScheduledNotifications( + ctx context.Context, + limit int32, +) ([]domain.ScheduledNotification, error) { + + rows, err := r.queries.ClaimDueScheduledNotifications(ctx, limit) + if err != nil { + return nil, err + } + + result := make([]domain.ScheduledNotification, 0, len(rows)) + for _, row := range rows { + result = append(result, *mapScheduledDBToDomain(&row)) + } + + return result, nil +} + +func (r *Store) MarkScheduledNotificationSent( + ctx context.Context, + id int64, +) error { + return r.queries.MarkScheduledNotificationSent(ctx, id) +} + +func (r *Store) MarkScheduledNotificationFailed( + ctx context.Context, + id int64, + lastError string, +) error { + return r.queries.MarkScheduledNotificationFailed(ctx, dbgen.MarkScheduledNotificationFailedParams{ + ID: id, + LastError: pgtype.Text{String: lastError, Valid: lastError != ""}, + }) +} + +/* ========================= + Mapping + ========================= */ + +func mapScheduledDBToDomain(db *dbgen.ScheduledNotification) *domain.ScheduledNotification { + sn := &domain.ScheduledNotification{ + ID: db.ID, + Channel: domain.DeliveryChannel(db.Channel), + Message: db.Message, + ScheduledAt: db.ScheduledAt.Time, + Status: domain.ScheduledNotificationStatus(db.Status), + TargetUserIDs: db.TargetUserIds, + TargetRaw: json.RawMessage(db.TargetRaw), + AttemptCount: db.AttemptCount, + CreatedBy: db.CreatedBy, + CreatedAt: db.CreatedAt.Time, + UpdatedAt: db.UpdatedAt.Time, + } + + if db.Title.Valid { + sn.Title = db.Title.String + } + if db.Html.Valid { + sn.HTML = db.Html.String + } + if db.TargetRole.Valid { + sn.TargetRole = db.TargetRole.String + } + if db.LastError.Valid { + sn.LastError = db.LastError.String + } + if db.ProcessingStartedAt.Valid { + t := db.ProcessingStartedAt.Time + sn.ProcessingStartedAt = &t + } + if db.SentAt.Valid { + t := db.SentAt.Time + sn.SentAt = &t + } + if db.CancelledAt.Valid { + t := db.CancelledAt.Time + sn.CancelledAt = &t + } + + return sn +} diff --git a/internal/repository/user.go b/internal/repository/user.go index bfa0d81..8d80475 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -409,53 +409,43 @@ func (s *Store) GetUserByGoogleID( func (s *Store) GetAllUsers( ctx context.Context, role *string, + status *string, query *string, createdBefore, createdAfter *time.Time, limit, offset int32, ) ([]domain.User, int64, error) { - // var roleParam sql.NullString - // if role != nil && *role != "" { - // roleParam = sql.NullString{String: *role, Valid: true} - // } else { - // roleParam = sql.NullString{Valid: false} // This will make $1 IS NULL work - // } + var roleParam pgtype.Text + if role != nil && *role != "" { + roleParam = pgtype.Text{String: *role, Valid: true} + } - // var queryParam sql.NullString - // if query != nil && *query != "" { - // queryParam = sql.NullString{String: *query, Valid: true} - // } else { - // queryParam = sql.NullString{Valid: false} - // } + var statusParam pgtype.Text + if status != nil && *status != "" { + statusParam = pgtype.Text{String: *status, Valid: true} + } - // var createdAfterParam sql.NullTime - // if createdAfter != nil { - // createdAfterParam = sql.NullTime{Time: *createdAfter, Valid: true} - // } else { - // createdAfterParam = sql.NullTime{Valid: false} - // } + var queryParam pgtype.Text + if query != nil && *query != "" { + queryParam = pgtype.Text{String: *query, Valid: true} + } - // var createdBeforeParam sql.NullTime - // if createdBefore != nil { - // createdBeforeParam = sql.NullTime{Time: *createdBefore, Valid: true} - // } else { - // createdBeforeParam = sql.NullTime{Valid: false} - // } + var createdAfterParam pgtype.Timestamptz + if createdAfter != nil { + createdAfterParam = pgtype.Timestamptz{Time: *createdAfter, Valid: true} + } + + var createdBeforeParam pgtype.Timestamptz + if createdBefore != nil { + createdBeforeParam = pgtype.Timestamptz{Time: *createdBefore, Valid: true} + } params := dbgen.GetAllUsersParams{ - // Role: pgtype.Text{ - // String: roleParam.String, - // Valid: roleParam.String != "", - // }, - // Query: queryParam.String, - // CreatedAfter: pgtype.Timestamptz{ - // Time: createdAfterParam.Time, - // Valid: createdAfterParam.Valid, - // }, - // CreatedBefore: pgtype.Timestamptz{ - // Time: createdBeforeParam.Time, - // Valid: createdBeforeParam.Valid, - // }, + Role: roleParam, + Status: statusParam, + Query: queryParam, + CreatedAfter: createdAfterParam, + CreatedBefore: createdBeforeParam, Limit: pgtype.Int4{ Int32: limit, Valid: true, diff --git a/internal/services/course_management/courses.go b/internal/services/course_management/courses.go index 2dda984..ad18f18 100644 --- a/internal/services/course_management/courses.go +++ b/internal/services/course_management/courses.go @@ -11,8 +11,9 @@ func (s *Service) CreateCourse( title string, description *string, thumbnail *string, + introVideoURL *string, ) (domain.Course, error) { - return s.courseStore.CreateCourse(ctx, categoryID, title, description, thumbnail) + return s.courseStore.CreateCourse(ctx, categoryID, title, description, thumbnail, introVideoURL) } func (s *Service) GetCourseByID( @@ -37,9 +38,10 @@ func (s *Service) UpdateCourse( title *string, description *string, thumbnail *string, + introVideoURL *string, isActive *bool, ) error { - return s.courseStore.UpdateCourse(ctx, id, title, description, thumbnail, isActive) + return s.courseStore.UpdateCourse(ctx, id, title, description, thumbnail, introVideoURL, isActive) } func (s *Service) DeleteCourse( diff --git a/internal/services/course_management/progression.go b/internal/services/course_management/progression.go new file mode 100644 index 0000000..95cc81d --- /dev/null +++ b/internal/services/course_management/progression.go @@ -0,0 +1,69 @@ +package course_management + +import ( + "Yimaru-Backend/internal/domain" + "context" +) + +// --- Prerequisites (admin) --- + +func (s *Service) AddSubCoursePrerequisite(ctx context.Context, subCourseID, prerequisiteSubCourseID int64) error { + if subCourseID == prerequisiteSubCourseID { + return domain.ErrSelfPrerequisite + } + return s.progressionStore.AddSubCoursePrerequisite(ctx, subCourseID, prerequisiteSubCourseID) +} + +func (s *Service) RemoveSubCoursePrerequisite(ctx context.Context, subCourseID, prerequisiteSubCourseID int64) error { + return s.progressionStore.RemoveSubCoursePrerequisite(ctx, subCourseID, prerequisiteSubCourseID) +} + +func (s *Service) GetSubCoursePrerequisites(ctx context.Context, subCourseID int64) ([]domain.SubCoursePrerequisite, error) { + return s.progressionStore.GetSubCoursePrerequisites(ctx, subCourseID) +} + +func (s *Service) GetSubCourseDependents(ctx context.Context, prerequisiteSubCourseID int64) ([]domain.SubCourseDependent, error) { + return s.progressionStore.GetSubCourseDependents(ctx, prerequisiteSubCourseID) +} + +// --- User progress --- + +func (s *Service) CheckSubCourseAccess(ctx context.Context, userID, subCourseID int64) (bool, error) { + unmet, err := s.progressionStore.CountUnmetPrerequisites(ctx, subCourseID, userID) + if err != nil { + return false, err + } + return unmet == 0, nil +} + +func (s *Service) StartSubCourse(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error) { + accessible, err := s.CheckSubCourseAccess(ctx, userID, subCourseID) + if err != nil { + return domain.UserSubCourseProgress{}, err + } + if !accessible { + return domain.UserSubCourseProgress{}, domain.ErrPrerequisiteNotMet + } + + return s.progressionStore.StartSubCourseProgress(ctx, userID, subCourseID) +} + +func (s *Service) UpdateSubCourseProgress(ctx context.Context, userID, subCourseID int64, percentage int16) error { + return s.progressionStore.UpdateSubCourseProgress(ctx, userID, subCourseID, percentage) +} + +func (s *Service) CompleteSubCourse(ctx context.Context, userID, subCourseID int64) error { + return s.progressionStore.CompleteSubCourse(ctx, userID, subCourseID) +} + +func (s *Service) GetUserSubCourseProgress(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error) { + return s.progressionStore.GetUserSubCourseProgress(ctx, userID, subCourseID) +} + +func (s *Service) GetUserCourseProgress(ctx context.Context, userID, courseID int64) ([]domain.UserCourseProgressItem, error) { + return s.progressionStore.GetUserCourseProgress(ctx, userID, courseID) +} + +func (s *Service) GetSubCoursesWithProgress(ctx context.Context, userID, courseID int64) ([]domain.SubCourseWithProgress, error) { + return s.progressionStore.GetSubCoursesWithProgressByCourse(ctx, userID, courseID) +} diff --git a/internal/services/course_management/service.go b/internal/services/course_management/service.go index cfaa551..63422ca 100644 --- a/internal/services/course_management/service.go +++ b/internal/services/course_management/service.go @@ -11,6 +11,7 @@ import ( type Service struct { userStore ports.UserStore courseStore ports.CourseStore + progressionStore ports.ProgressionStore notificationSvc *notificationservice.Service vimeoSvc *vimeoservice.Service cloudConvertSvc *cloudconvertservice.Service @@ -20,12 +21,14 @@ type Service struct { func NewService( userStore ports.UserStore, courseStore ports.CourseStore, + progressionStore ports.ProgressionStore, notificationSvc *notificationservice.Service, cfg *config.Config, ) *Service { return &Service{ - userStore: userStore, - courseStore: courseStore, + userStore: userStore, + courseStore: courseStore, + progressionStore: progressionStore, notificationSvc: notificationSvc, config: cfg, } diff --git a/internal/services/messenger/email.go b/internal/services/messenger/email.go index 4dc4eda..c6df6a2 100644 --- a/internal/services/messenger/email.go +++ b/internal/services/messenger/email.go @@ -6,15 +6,20 @@ import ( ) func (s *Service) SendEmail(ctx context.Context, receiverEmail, message string, messageHTML string, subject string) error { + return s.SendEmailWithAttachments(ctx, receiverEmail, message, messageHTML, subject, nil) +} + +func (s *Service) SendEmailWithAttachments(ctx context.Context, receiverEmail, message string, messageHTML string, subject string, attachments []*resend.Attachment) error { apiKey := s.config.ResendApiKey client := resend.NewClient(apiKey) formattedSenderEmail := "Y <" + s.config.ResendSenderEmail + ">" params := &resend.SendEmailRequest{ - From: formattedSenderEmail, - To: []string{receiverEmail}, - Subject: subject, - Text: message, - Html: messageHTML, + From: formattedSenderEmail, + To: []string{receiverEmail}, + Subject: subject, + Text: message, + Html: messageHTML, + Attachments: attachments, } _, err := client.Emails.Send(params) diff --git a/internal/services/notification/service.go b/internal/services/notification/service.go index d870798..2ff4f20 100644 --- a/internal/services/notification/service.go +++ b/internal/services/notification/service.go @@ -28,6 +28,7 @@ import ( "firebase.google.com/go/v4/messaging" afro "github.com/amanuelabay/afrosms-go" "github.com/gorilla/websocket" + "github.com/resend/resend-go/v2" "google.golang.org/api/option" // "github.com/redis/go-redis/v9" ) @@ -78,6 +79,7 @@ func New( go hub.Run() go svc.startWorker() + go svc.startSchedulerWorker() // go svc.startRetryWorker() // go svc.RunRedisSubscriber(context.Background()) // go svc.StartKafkaConsumer(context.Background()) @@ -233,6 +235,35 @@ func (s *Service) addConnection(recipientID int64, c *websocket.Conn) error { return nil } +func (s *Service) GetFilteredNotifications(ctx context.Context, filter domain.NotificationFilter) ([]domain.Notification, int64, error) { + return s.store.GetFilteredNotifications(ctx, filter) +} + +// RecordNotification saves a notification to the database for history/audit without dispatching to the worker. +func (s *Service) RecordNotification(ctx context.Context, recipientID int64, notifType domain.NotificationType, channel domain.DeliveryChannel, level domain.NotificationLevel, headline, message string) { + notification := &domain.Notification{ + ID: helpers.GenerateID(), + RecipientID: recipientID, + Type: notifType, + Level: level, + DeliveryChannel: channel, + DeliveryStatus: domain.DeliveryStatusSent, + Payload: domain.NotificationPayload{ + Headline: headline, + Message: message, + }, + Timestamp: time.Now(), + } + + if _, err := s.store.CreateNotification(ctx, notification); err != nil { + s.mongoLogger.Error("[NotificationSvc.RecordNotification] Failed to record notification", + zap.Int64("recipientID", recipientID), + zap.String("channel", string(channel)), + zap.Error(err), + ) + } +} + func (s *Service) SendNotification(ctx context.Context, notification *domain.Notification) error { notification.ID = helpers.GenerateID() @@ -559,8 +590,9 @@ func (s *Service) SendPushNotification(ctx context.Context, notification *domain // Create FCM message message := &messaging.Message{ Notification: &messaging.Notification{ - Title: notification.Payload.Headline, - Body: notification.Payload.Message, + Title: notification.Payload.Headline, + Body: notification.Payload.Message, + ImageURL: notification.Image, }, Data: map[string]string{ "type": string(notification.Type), @@ -613,6 +645,148 @@ func (s *Service) SendPushNotification(ctx context.Context, notification *domain return nil } +// SendBulkPushNotification sends a push notification to multiple users using FCM multicast. +// It collects all device tokens for the given user IDs and sends in batches of 500 (FCM limit). +func (s *Service) MessengerSvc() *messenger.Service { + return s.messengerSvc +} + +func (s *Service) SendBulkPushNotification(ctx context.Context, userIDs []int64, notification *domain.Notification) (sent int, failed int, err error) { + if s.fcmClient == nil { + return 0, 0, fmt.Errorf("FCM client not initialized") + } + + // Collect all device tokens for the given users + var allTokens []string + tokenUserMap := make(map[string]int64) // token -> userID for cleanup + for _, uid := range userIDs { + tokens, err := s.userSvc.GetUserDeviceTokens(ctx, uid) + if err != nil { + s.mongoLogger.Warn("[NotificationSvc.SendBulkPushNotification] Failed to get tokens for user", + zap.Int64("userID", uid), + zap.Error(err), + ) + continue + } + for _, t := range tokens { + tokenUserMap[t] = uid + } + allTokens = append(allTokens, tokens...) + } + + if len(allTokens) == 0 { + s.mongoLogger.Info("[NotificationSvc.SendBulkPushNotification] No device tokens found for any user", + zap.Int("userCount", len(userIDs)), + ) + return 0, 0, nil + } + + fcmNotification := &messaging.Notification{ + Title: notification.Payload.Headline, + Body: notification.Payload.Message, + ImageURL: notification.Image, + } + data := map[string]string{ + "type": string(notification.Type), + "notification_id": notification.ID, + } + + // FCM multicast supports max 500 tokens per batch + const batchSize = 500 + for i := 0; i < len(allTokens); i += batchSize { + end := i + batchSize + if end > len(allTokens) { + end = len(allTokens) + } + batch := allTokens[i:end] + + msg := &messaging.MulticastMessage{ + Notification: fcmNotification, + Data: data, + Tokens: batch, + } + + resp, err := s.fcmClient.SendEachForMulticast(ctx, msg) + if err != nil { + s.mongoLogger.Error("[NotificationSvc.SendBulkPushNotification] Multicast send failed", + zap.Error(err), + zap.Int("batchSize", len(batch)), + ) + failed += len(batch) + continue + } + + sent += resp.SuccessCount + failed += resp.FailureCount + + // Deactivate invalid tokens + for j, sendResp := range resp.Responses { + if sendResp.Error != nil && (messaging.IsUnregistered(sendResp.Error) || messaging.IsInvalidArgument(sendResp.Error)) { + token := batch[j] + if uid, ok := tokenUserMap[token]; ok { + _ = s.userSvc.DeactivateDevice(ctx, uid, token) + } + } + } + } + + s.mongoLogger.Info("[NotificationSvc.SendBulkPushNotification] Bulk push completed", + zap.Int("totalTokens", len(allTokens)), + zap.Int("sent", sent), + zap.Int("failed", failed), + ) + + return sent, failed, nil +} + +// SendBulkSMS sends an SMS to multiple phone numbers using AfroMessage. +// It sends sequentially and returns the count of successful and failed deliveries. +func (s *Service) SendBulkSMS(ctx context.Context, recipients []string, message string) (sent int, failed int) { + for _, phone := range recipients { + if err := s.SendAfroMessageSMS(ctx, phone, message); err != nil { + s.mongoLogger.Error("[NotificationSvc.SendBulkSMS] Failed to send SMS", + zap.String("phone", phone), + zap.Error(err), + ) + failed++ + continue + } + sent++ + } + + s.mongoLogger.Info("[NotificationSvc.SendBulkSMS] Bulk SMS completed", + zap.Int("totalRecipients", len(recipients)), + zap.Int("sent", sent), + zap.Int("failed", failed), + ) + + return sent, failed +} + +// SendBulkEmail sends an email to multiple recipients using the messenger service. +// It sends sequentially and returns the count of successful and failed deliveries. +func (s *Service) SendBulkEmail(ctx context.Context, recipients []string, subject, message, messageHTML string, attachments []*resend.Attachment) (sent int, failed int) { + for _, email := range recipients { + if err := s.messengerSvc.SendEmailWithAttachments(ctx, email, message, messageHTML, subject, attachments); err != nil { + s.mongoLogger.Error("[NotificationSvc.SendBulkEmail] Failed to send email", + zap.String("email", email), + zap.Error(err), + ) + failed++ + continue + } + sent++ + } + + s.mongoLogger.Info("[NotificationSvc.SendBulkEmail] Bulk email completed", + zap.Int("totalRecipients", len(recipients)), + zap.Int("sent", sent), + zap.Int("failed", failed), + ) + + return sent, failed +} + // func (s *Service) startRetryWorker() { // ticker := time.NewTicker(1 * time.Minute) // defer ticker.Stop() @@ -763,6 +937,255 @@ func (s *Service) DeleteUserNotifications(ctx context.Context, userID int64) err return nil } +// Scheduled Notification Methods + +func (s *Service) CreateScheduledNotification(ctx context.Context, sn *domain.ScheduledNotification) (*domain.ScheduledNotification, error) { + created, err := s.store.CreateScheduledNotification(ctx, sn) + if err != nil { + s.mongoLogger.Error("[NotificationSvc.CreateScheduledNotification] Failed to create", + zap.String("channel", string(sn.Channel)), + zap.Error(err), + ) + return nil, err + } + + s.mongoLogger.Info("[NotificationSvc.CreateScheduledNotification] Created", + zap.Int64("id", created.ID), + zap.String("channel", string(created.Channel)), + zap.Time("scheduledAt", created.ScheduledAt), + ) + + return created, nil +} + +func (s *Service) GetScheduledNotification(ctx context.Context, id int64) (*domain.ScheduledNotification, error) { + return s.store.GetScheduledNotification(ctx, id) +} + +func (s *Service) ListScheduledNotifications(ctx context.Context, filter domain.ScheduledNotificationFilter) ([]domain.ScheduledNotification, int64, error) { + return s.store.ListScheduledNotifications(ctx, filter) +} + +func (s *Service) CancelScheduledNotification(ctx context.Context, id int64) (*domain.ScheduledNotification, error) { + cancelled, err := s.store.CancelScheduledNotification(ctx, id) + if err != nil { + s.mongoLogger.Error("[NotificationSvc.CancelScheduledNotification] Failed to cancel", + zap.Int64("id", id), + zap.Error(err), + ) + return nil, err + } + + s.mongoLogger.Info("[NotificationSvc.CancelScheduledNotification] Cancelled", + zap.Int64("id", cancelled.ID), + ) + + return cancelled, nil +} + +func (s *Service) startSchedulerWorker() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + s.processDueScheduledNotifications() + case <-s.stopCh: + s.mongoLogger.Info("[NotificationSvc.SchedulerWorker] Stopped") + return + } + } +} + +func (s *Service) processDueScheduledNotifications() { + ctx := context.Background() + + claimed, err := s.store.ClaimDueScheduledNotifications(ctx, 20) + if err != nil { + s.mongoLogger.Error("[NotificationSvc.SchedulerWorker] Failed to claim due notifications", + zap.Error(err), + ) + return + } + + for i := range claimed { + sn := &claimed[i] + go s.dispatchScheduledNotification(ctx, sn) + } +} + +func (s *Service) dispatchScheduledNotification(ctx context.Context, sn *domain.ScheduledNotification) { + var dispatchErr error + + switch sn.Channel { + case domain.DeliveryChannelSMS: + dispatchErr = s.dispatchScheduledSMS(ctx, sn) + case domain.DeliveryChannelEmail: + dispatchErr = s.dispatchScheduledEmail(ctx, sn) + case domain.DeliveryChannelPush: + dispatchErr = s.dispatchScheduledPush(ctx, sn) + default: + dispatchErr = fmt.Errorf("unsupported channel: %s", sn.Channel) + } + + if dispatchErr != nil { + s.mongoLogger.Error("[NotificationSvc.SchedulerWorker] Failed to dispatch", + zap.Int64("id", sn.ID), + zap.String("channel", string(sn.Channel)), + zap.Error(dispatchErr), + ) + _ = s.store.MarkScheduledNotificationFailed(ctx, sn.ID, dispatchErr.Error()) + return + } + + _ = s.store.MarkScheduledNotificationSent(ctx, sn.ID) + s.mongoLogger.Info("[NotificationSvc.SchedulerWorker] Dispatched", + zap.Int64("id", sn.ID), + zap.String("channel", string(sn.Channel)), + ) +} + +func (s *Service) resolvePhoneNumbers(ctx context.Context, sn *domain.ScheduledNotification) []string { + phoneSet := make(map[string]struct{}) + + userIDs := sn.TargetUserIDs + if len(userIDs) == 0 && sn.TargetRole != "" { + users, _, err := s.userSvc.GetAllUsers(ctx, domain.UserFilter{Role: sn.TargetRole}) + if err == nil { + for _, u := range users { + userIDs = append(userIDs, u.ID) + } + } + } + + for _, uid := range userIDs { + user, err := s.userSvc.GetUserByID(ctx, uid) + if err == nil && user.PhoneNumber != "" { + phoneSet[user.PhoneNumber] = struct{}{} + } + } + + if len(sn.TargetRaw) > 0 { + var raw domain.ScheduledNotificationTargetRaw + if err := json.Unmarshal(sn.TargetRaw, &raw); err == nil { + for _, p := range raw.Phones { + phoneSet[p] = struct{}{} + } + } + } + + phones := make([]string, 0, len(phoneSet)) + for p := range phoneSet { + phones = append(phones, p) + } + return phones +} + +func (s *Service) resolveEmails(ctx context.Context, sn *domain.ScheduledNotification) []string { + emailSet := make(map[string]struct{}) + + userIDs := sn.TargetUserIDs + if len(userIDs) == 0 && sn.TargetRole != "" { + users, _, err := s.userSvc.GetAllUsers(ctx, domain.UserFilter{Role: sn.TargetRole}) + if err == nil { + for _, u := range users { + userIDs = append(userIDs, u.ID) + } + } + } + + for _, uid := range userIDs { + user, err := s.userSvc.GetUserByID(ctx, uid) + if err == nil && user.Email != "" { + emailSet[user.Email] = struct{}{} + } + } + + if len(sn.TargetRaw) > 0 { + var raw domain.ScheduledNotificationTargetRaw + if err := json.Unmarshal(sn.TargetRaw, &raw); err == nil { + for _, e := range raw.Emails { + emailSet[e] = struct{}{} + } + } + } + + emails := make([]string, 0, len(emailSet)) + for e := range emailSet { + emails = append(emails, e) + } + return emails +} + +func (s *Service) resolveUserIDs(ctx context.Context, sn *domain.ScheduledNotification) []int64 { + if len(sn.TargetUserIDs) > 0 { + return sn.TargetUserIDs + } + if sn.TargetRole != "" { + users, _, err := s.userSvc.GetAllUsers(ctx, domain.UserFilter{Role: sn.TargetRole}) + if err == nil { + ids := make([]int64, 0, len(users)) + for _, u := range users { + ids = append(ids, u.ID) + } + return ids + } + } + return nil +} + +func (s *Service) dispatchScheduledSMS(ctx context.Context, sn *domain.ScheduledNotification) error { + phones := s.resolvePhoneNumbers(ctx, sn) + if len(phones) == 0 { + return fmt.Errorf("no SMS recipients resolved") + } + + sent, failed := s.SendBulkSMS(ctx, phones, sn.Message) + if sent == 0 && failed > 0 { + return fmt.Errorf("all %d SMS deliveries failed", failed) + } + return nil +} + +func (s *Service) dispatchScheduledEmail(ctx context.Context, sn *domain.ScheduledNotification) error { + emails := s.resolveEmails(ctx, sn) + if len(emails) == 0 { + return fmt.Errorf("no email recipients resolved") + } + + sent, failed := s.SendBulkEmail(ctx, emails, sn.Title, sn.Message, sn.HTML, nil) + if sent == 0 && failed > 0 { + return fmt.Errorf("all %d email deliveries failed", failed) + } + return nil +} + +func (s *Service) dispatchScheduledPush(ctx context.Context, sn *domain.ScheduledNotification) error { + userIDs := s.resolveUserIDs(ctx, sn) + if len(userIDs) == 0 { + return fmt.Errorf("no push recipients resolved") + } + + notification := &domain.Notification{ + Type: domain.NOTIFICATION_TYPE_SYSTEM_ALERT, + DeliveryChannel: domain.DeliveryChannelPush, + Payload: domain.NotificationPayload{ + Headline: sn.Title, + Message: sn.Message, + }, + } + + sent, failed, err := s.SendBulkPushNotification(ctx, userIDs, notification) + if err != nil { + return err + } + if sent == 0 && failed > 0 { + return fmt.Errorf("all %d push deliveries failed", failed) + } + return nil +} + // func (s *Service) DeleteOldNotifications(ctx context.Context) error { // return s.store.DeleteOldNotifications(ctx) // } diff --git a/internal/services/rbac/seeds.go b/internal/services/rbac/seeds.go new file mode 100644 index 0000000..67e813d --- /dev/null +++ b/internal/services/rbac/seeds.go @@ -0,0 +1,439 @@ +package rbac + +import "Yimaru-Backend/internal/domain" + +var AllPermissions = []domain.PermissionSeed{ + // Course Management - Categories + {Key: "course_categories.create", Name: "Create Course Category", Description: "Create a new course category", GroupName: "Course Categories"}, + {Key: "course_categories.list", Name: "List Course Categories", Description: "List all course categories", GroupName: "Course Categories"}, + {Key: "course_categories.get", Name: "Get Course Category", Description: "Get a course category by ID", GroupName: "Course Categories"}, + {Key: "course_categories.update", Name: "Update Course Category", Description: "Update a course category", GroupName: "Course Categories"}, + {Key: "course_categories.delete", Name: "Delete Course Category", Description: "Delete a course category", GroupName: "Course Categories"}, + + // Course Management - Courses + {Key: "courses.create", Name: "Create Course", Description: "Create a new course", GroupName: "Courses"}, + {Key: "courses.get", Name: "Get Course", Description: "Get a course by ID", GroupName: "Courses"}, + {Key: "courses.list_by_category", Name: "List Courses by Category", Description: "List courses by category", GroupName: "Courses"}, + {Key: "courses.update", Name: "Update Course", Description: "Update a course", GroupName: "Courses"}, + {Key: "courses.upload_thumbnail", Name: "Upload Course Thumbnail", Description: "Upload course thumbnail image", GroupName: "Courses"}, + {Key: "courses.delete", Name: "Delete Course", Description: "Delete a course", GroupName: "Courses"}, + + // Course Management - Sub-courses + {Key: "subcourses.create", Name: "Create Sub-course", Description: "Create a new sub-course", GroupName: "Sub-courses"}, + {Key: "subcourses.get", Name: "Get Sub-course", Description: "Get a sub-course by ID", GroupName: "Sub-courses"}, + {Key: "subcourses.list_by_course", Name: "List Sub-courses by Course", Description: "List sub-courses by course", GroupName: "Sub-courses"}, + {Key: "subcourses.list_by_course_list", Name: "List Sub-courses by Course (list)", Description: "List sub-courses by course (list view)", GroupName: "Sub-courses"}, + {Key: "subcourses.list_active", Name: "List Active Sub-courses", Description: "List active sub-courses", GroupName: "Sub-courses"}, + {Key: "subcourses.update", Name: "Update Sub-course", Description: "Update a sub-course", GroupName: "Sub-courses"}, + {Key: "subcourses.upload_thumbnail", Name: "Upload Sub-course Thumbnail", Description: "Upload sub-course thumbnail", GroupName: "Sub-courses"}, + {Key: "subcourses.deactivate", Name: "Deactivate Sub-course", Description: "Deactivate a sub-course", GroupName: "Sub-courses"}, + {Key: "subcourses.delete", Name: "Delete Sub-course", Description: "Delete a sub-course", GroupName: "Sub-courses"}, + + // Course Management - Videos + {Key: "videos.create", Name: "Create Video", Description: "Create a sub-course video", GroupName: "Videos"}, + {Key: "videos.create_vimeo", Name: "Create Vimeo Video", Description: "Create a video with Vimeo", GroupName: "Videos"}, + {Key: "videos.upload", Name: "Upload Video", Description: "Upload a video file", GroupName: "Videos"}, + {Key: "videos.import_vimeo", Name: "Import Vimeo Video", Description: "Import video from Vimeo ID", GroupName: "Videos"}, + {Key: "videos.get", Name: "Get Video", Description: "Get video by ID", GroupName: "Videos"}, + {Key: "videos.list_by_subcourse", Name: "List Videos by Sub-course", Description: "List videos by sub-course", GroupName: "Videos"}, + {Key: "videos.list_published", Name: "List Published Videos", Description: "List published videos by sub-course", GroupName: "Videos"}, + {Key: "videos.publish", Name: "Publish Video", Description: "Publish a video", GroupName: "Videos"}, + {Key: "videos.update", Name: "Update Video", Description: "Update a video", GroupName: "Videos"}, + {Key: "videos.delete", Name: "Delete Video", Description: "Delete a video", GroupName: "Videos"}, + + // Learning Tree + {Key: "learning_tree.get", Name: "Get Learning Tree", Description: "Get full learning tree", GroupName: "Learning Tree"}, + + // Questions + {Key: "questions.create", Name: "Create Question", Description: "Create a new question", GroupName: "Questions"}, + {Key: "questions.list", Name: "List Questions", Description: "List all questions", GroupName: "Questions"}, + {Key: "questions.search", Name: "Search Questions", Description: "Search questions", GroupName: "Questions"}, + {Key: "questions.get", Name: "Get Question", Description: "Get question by ID", GroupName: "Questions"}, + {Key: "questions.update", Name: "Update Question", Description: "Update a question", GroupName: "Questions"}, + {Key: "questions.delete", Name: "Delete Question", Description: "Delete a question", GroupName: "Questions"}, + + // Question Sets + {Key: "question_sets.create", Name: "Create Question Set", Description: "Create a question set", GroupName: "Question Sets"}, + {Key: "question_sets.list", Name: "List Question Sets", Description: "List question sets by type", GroupName: "Question Sets"}, + {Key: "question_sets.list_by_owner", Name: "List Question Sets by Owner", Description: "List question sets by owner", GroupName: "Question Sets"}, + {Key: "question_sets.get", Name: "Get Question Set", Description: "Get question set by ID", GroupName: "Question Sets"}, + {Key: "question_sets.update", Name: "Update Question Set", Description: "Update a question set", GroupName: "Question Sets"}, + {Key: "question_sets.delete", Name: "Delete Question Set", Description: "Delete a question set", GroupName: "Question Sets"}, + + // Question Set Items + {Key: "question_set_items.add", Name: "Add Question to Set", Description: "Add a question to a set", GroupName: "Question Set Items"}, + {Key: "question_set_items.list", Name: "List Questions in Set", Description: "List questions in a set", GroupName: "Question Set Items"}, + {Key: "question_set_items.remove", Name: "Remove Question from Set", Description: "Remove a question from a set", GroupName: "Question Set Items"}, + {Key: "question_set_items.update_order", Name: "Update Question Order", Description: "Update question order in set", GroupName: "Question Set Items"}, + + // Question Set Personas + {Key: "question_set_personas.list", Name: "List Personas", Description: "List personas in a question set", GroupName: "Question Set Personas"}, + {Key: "question_set_personas.add", Name: "Add Persona", Description: "Add persona to question set", GroupName: "Question Set Personas"}, + {Key: "question_set_personas.remove", Name: "Remove Persona", Description: "Remove persona from question set", GroupName: "Question Set Personas"}, + + // Subscription Plans + {Key: "subscription_plans.create", Name: "Create Subscription Plan", Description: "Create a subscription plan", GroupName: "Subscription Plans"}, + {Key: "subscription_plans.update", Name: "Update Subscription Plan", Description: "Update a subscription plan", GroupName: "Subscription Plans"}, + {Key: "subscription_plans.delete", Name: "Delete Subscription Plan", Description: "Delete a subscription plan", GroupName: "Subscription Plans"}, + + // Subscriptions + {Key: "subscriptions.create", Name: "Create Subscription", Description: "Create subscription (admin)", GroupName: "Subscriptions"}, + {Key: "subscriptions.checkout", Name: "Checkout Subscription", Description: "Initiate subscription payment", GroupName: "Subscriptions"}, + {Key: "subscriptions.get_mine", Name: "Get My Subscription", Description: "Get own subscription", GroupName: "Subscriptions"}, + {Key: "subscriptions.history", Name: "Subscription History", Description: "Get subscription history", GroupName: "Subscriptions"}, + {Key: "subscriptions.status", Name: "Check Subscription Status", Description: "Check subscription status", GroupName: "Subscriptions"}, + {Key: "subscriptions.cancel", Name: "Cancel Subscription", Description: "Cancel a subscription", GroupName: "Subscriptions"}, + {Key: "subscriptions.set_auto_renew", Name: "Set Auto Renew", Description: "Set auto-renew on subscription", GroupName: "Subscriptions"}, + + // Payments + {Key: "payments.initiate", Name: "Initiate Payment", Description: "Initiate subscription payment", GroupName: "Payments"}, + {Key: "payments.verify", Name: "Verify Payment", Description: "Verify a payment", GroupName: "Payments"}, + {Key: "payments.list_mine", Name: "List My Payments", Description: "List own payments", GroupName: "Payments"}, + {Key: "payments.get", Name: "Get Payment", Description: "Get payment by ID", GroupName: "Payments"}, + {Key: "payments.cancel", Name: "Cancel Payment", Description: "Cancel a payment", GroupName: "Payments"}, + {Key: "payments.direct_initiate", Name: "Initiate Direct Payment", Description: "Initiate direct payment", GroupName: "Payments"}, + {Key: "payments.direct_verify_otp", Name: "Verify Direct Payment OTP", Description: "Verify OTP for direct payment", GroupName: "Payments"}, + + // Users + {Key: "users.list", Name: "List Users", Description: "List all users", GroupName: "Users"}, + {Key: "users.get", Name: "Get User", Description: "Get user by ID", GroupName: "Users"}, + {Key: "users.update_self", Name: "Update Own Profile", Description: "Update own user profile", GroupName: "Users"}, + {Key: "users.update_status", Name: "Update User Status", Description: "Activate/deactivate users", GroupName: "Users"}, + {Key: "users.delete", Name: "Delete User", Description: "Delete a user", GroupName: "Users"}, + {Key: "users.search", Name: "Search Users", Description: "Search users by name or phone", GroupName: "Users"}, + {Key: "users.profile_completed", Name: "Check Profile Completed", Description: "Check if user profile is completed", GroupName: "Users"}, + {Key: "users.upload_profile_picture", Name: "Upload Profile Picture", Description: "Upload user profile picture", GroupName: "Users"}, + {Key: "users.admin_profile", Name: "View Admin Profile", Description: "View admin profile", GroupName: "Users"}, + {Key: "users.user_profile", Name: "View User Profile", Description: "View user profile", GroupName: "Users"}, + + // Admins + {Key: "admins.list", Name: "List Admins", Description: "List all admin users", GroupName: "Admins"}, + {Key: "admins.get", Name: "Get Admin", Description: "Get admin by ID", GroupName: "Admins"}, + {Key: "admins.create", Name: "Create Admin", Description: "Create a new admin", GroupName: "Admins"}, + {Key: "admins.update", Name: "Update Admin", Description: "Update an admin", GroupName: "Admins"}, + + // Logs + {Key: "logs.list", Name: "List Logs", Description: "List system logs", GroupName: "Logs"}, + {Key: "activity_logs.list", Name: "List Activity Logs", Description: "List activity logs", GroupName: "Logs"}, + {Key: "activity_logs.get", Name: "Get Activity Log", Description: "Get activity log by ID", GroupName: "Logs"}, + + // Notifications + {Key: "notifications.ws_connect", Name: "WebSocket Connect", Description: "Connect to WebSocket notifications", GroupName: "Notifications"}, + {Key: "notifications.list_mine", Name: "List My Notifications", Description: "List own notifications", GroupName: "Notifications"}, + {Key: "notifications.list_all", Name: "List All Notifications", Description: "List all notifications", GroupName: "Notifications"}, + {Key: "notifications.mark_read", Name: "Mark Notification Read", Description: "Mark a notification as read", GroupName: "Notifications"}, + {Key: "notifications.mark_all_read", Name: "Mark All Read", Description: "Mark all notifications as read", GroupName: "Notifications"}, + {Key: "notifications.mark_unread", Name: "Mark Notification Unread", Description: "Mark a notification as unread", GroupName: "Notifications"}, + {Key: "notifications.mark_all_unread", Name: "Mark All Unread", Description: "Mark all notifications as unread", GroupName: "Notifications"}, + {Key: "notifications.delete_mine", Name: "Delete My Notifications", Description: "Delete own notifications", GroupName: "Notifications"}, + {Key: "notifications.count_unread", Name: "Count Unread", Description: "Count unread notifications", GroupName: "Notifications"}, + {Key: "notifications.create", Name: "Create Notification", Description: "Create and send a notification", GroupName: "Notifications"}, + {Key: "notifications.test_push", Name: "Test Push Notification", Description: "Send a test push notification", GroupName: "Notifications"}, + {Key: "notifications.bulk_push", Name: "Bulk Push Notification", Description: "Send bulk push notifications", GroupName: "Notifications"}, + {Key: "notifications.bulk_sms", Name: "Bulk SMS", Description: "Send bulk SMS notifications", GroupName: "Notifications"}, + {Key: "notifications.send_email", Name: "Send Email", Description: "Send a single email", GroupName: "Notifications"}, + {Key: "notifications.bulk_email", Name: "Bulk Email", Description: "Send bulk emails", GroupName: "Notifications"}, + + // Scheduled Notifications + {Key: "notifications_scheduled.list", Name: "List Scheduled Notifications", Description: "List scheduled notifications", GroupName: "Scheduled Notifications"}, + {Key: "notifications_scheduled.get", Name: "Get Scheduled Notification", Description: "Get scheduled notification by ID", GroupName: "Scheduled Notifications"}, + {Key: "notifications_scheduled.cancel", Name: "Cancel Scheduled Notification", Description: "Cancel a scheduled notification", GroupName: "Scheduled Notifications"}, + + // Issues + {Key: "issues.create", Name: "Create Issue", Description: "Report a new issue", GroupName: "Issues"}, + {Key: "issues.list_mine", Name: "List My Issues", Description: "List own issues", GroupName: "Issues"}, + {Key: "issues.list_by_user", Name: "List User Issues", Description: "List issues by user", GroupName: "Issues"}, + {Key: "issues.list_all", Name: "List All Issues", Description: "List all issues", GroupName: "Issues"}, + {Key: "issues.get", Name: "Get Issue", Description: "Get issue by ID", GroupName: "Issues"}, + {Key: "issues.update_status", Name: "Update Issue Status", Description: "Update issue status", GroupName: "Issues"}, + {Key: "issues.delete", Name: "Delete Issue", Description: "Delete an issue", GroupName: "Issues"}, + + // Devices + {Key: "devices.register", Name: "Register Device", Description: "Register a device token", GroupName: "Devices"}, + {Key: "devices.unregister", Name: "Unregister Device", Description: "Unregister a device token", GroupName: "Devices"}, + + // Settings + {Key: "settings.list", Name: "List Settings", Description: "List all settings", GroupName: "Settings"}, + {Key: "settings.get", Name: "Get Setting", Description: "Get setting by key", GroupName: "Settings"}, + {Key: "settings.update", Name: "Update Settings", Description: "Update settings", GroupName: "Settings"}, + + // Analytics + {Key: "analytics.dashboard", Name: "View Dashboard", Description: "View analytics dashboard", GroupName: "Analytics"}, + + // Vimeo + {Key: "vimeo.videos.get", Name: "Get Vimeo Video", Description: "Get Vimeo video details", GroupName: "Vimeo"}, + {Key: "vimeo.videos.embed", Name: "Get Embed Code", Description: "Get Vimeo embed code", GroupName: "Vimeo"}, + {Key: "vimeo.videos.status", Name: "Get Transcode Status", Description: "Get Vimeo transcode status", GroupName: "Vimeo"}, + {Key: "vimeo.videos.delete", Name: "Delete Vimeo Video", Description: "Delete a Vimeo video", GroupName: "Vimeo"}, + {Key: "vimeo.uploads.pull", Name: "Create Pull Upload", Description: "Create Vimeo pull upload", GroupName: "Vimeo"}, + {Key: "vimeo.uploads.tus", Name: "Create TUS Upload", Description: "Create Vimeo TUS upload", GroupName: "Vimeo"}, + + // Team + {Key: "team.profile.get_mine", Name: "Get My Team Profile", Description: "Get own team profile", GroupName: "Team"}, + {Key: "team.stats", Name: "Get Team Stats", Description: "Get team member statistics", GroupName: "Team"}, + {Key: "team.members.list", Name: "List Team Members", Description: "List all team members", GroupName: "Team"}, + {Key: "team.members.create", Name: "Create Team Member", Description: "Create a team member", GroupName: "Team"}, + {Key: "team.members.get", Name: "Get Team Member", Description: "Get team member by ID", GroupName: "Team"}, + {Key: "team.members.update", Name: "Update Team Member", Description: "Update a team member", GroupName: "Team"}, + {Key: "team.members.update_status", Name: "Update Team Member Status", Description: "Update team member status", GroupName: "Team"}, + {Key: "team.members.delete", Name: "Delete Team Member", Description: "Delete a team member", GroupName: "Team"}, + {Key: "team.members.change_password", Name: "Change Team Password", Description: "Change team member password", GroupName: "Team"}, + + // Sub-course Prerequisites + {Key: "subcourse_prerequisites.add", Name: "Add Prerequisite", Description: "Add sub-course prerequisite", GroupName: "Sub-course Prerequisites"}, + {Key: "subcourse_prerequisites.list", Name: "List Prerequisites", Description: "List sub-course prerequisites", GroupName: "Sub-course Prerequisites"}, + {Key: "subcourse_prerequisites.remove", Name: "Remove Prerequisite", Description: "Remove sub-course prerequisite", GroupName: "Sub-course Prerequisites"}, + + // Progress + {Key: "progress.start", Name: "Start Sub-course", Description: "Start a sub-course", GroupName: "Progress"}, + {Key: "progress.update", Name: "Update Progress", Description: "Update sub-course progress", GroupName: "Progress"}, + {Key: "progress.complete", Name: "Complete Sub-course", Description: "Complete a sub-course", GroupName: "Progress"}, + {Key: "progress.check_access", Name: "Check Access", Description: "Check sub-course access", GroupName: "Progress"}, + {Key: "progress.get_course", Name: "Get Course Progress", Description: "Get user course progress", GroupName: "Progress"}, + + // Ratings + {Key: "ratings.submit", Name: "Submit Rating", Description: "Submit a rating", GroupName: "Ratings"}, + {Key: "ratings.list_by_target", Name: "List Ratings", Description: "List ratings by target", GroupName: "Ratings"}, + {Key: "ratings.summary", Name: "Rating Summary", Description: "Get rating summary", GroupName: "Ratings"}, + {Key: "ratings.get_mine", Name: "Get My Rating", Description: "Get own rating", GroupName: "Ratings"}, + {Key: "ratings.list_mine", Name: "List My Ratings", Description: "List all own ratings", GroupName: "Ratings"}, + {Key: "ratings.delete", Name: "Delete Rating", Description: "Delete a rating", GroupName: "Ratings"}, + + // Auth (protected endpoints only) + {Key: "auth.logout", Name: "Logout", Description: "Log out user", GroupName: "Auth"}, + + // RBAC Management + {Key: "rbac.roles.list", Name: "List Roles", Description: "List all roles", GroupName: "RBAC"}, + {Key: "rbac.roles.get", Name: "Get Role", Description: "Get role by ID", GroupName: "RBAC"}, + {Key: "rbac.roles.create", Name: "Create Role", Description: "Create a new role", GroupName: "RBAC"}, + {Key: "rbac.roles.update", Name: "Update Role", Description: "Update a role", GroupName: "RBAC"}, + {Key: "rbac.roles.delete", Name: "Delete Role", Description: "Delete a role", GroupName: "RBAC"}, + {Key: "rbac.roles.set_permissions", Name: "Set Role Permissions", Description: "Set permissions for a role", GroupName: "RBAC"}, + {Key: "rbac.roles.get_permissions", Name: "Get Role Permissions", Description: "Get permissions for a role", GroupName: "RBAC"}, + {Key: "rbac.permissions.list", Name: "List Permissions", Description: "List all permissions", GroupName: "RBAC"}, + {Key: "rbac.permissions.groups", Name: "List Permission Groups", Description: "List permission groups", GroupName: "RBAC"}, + {Key: "rbac.permissions.sync", Name: "Sync Permissions", Description: "Sync permissions from code", GroupName: "RBAC"}, +} + +// 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.) +var DefaultRolePermissions = map[string][]string{ + "ADMIN": { + // Course Management (full access) + "course_categories.create", "course_categories.list", "course_categories.get", "course_categories.update", "course_categories.delete", + "courses.create", "courses.get", "courses.list_by_category", "courses.update", "courses.upload_thumbnail", "courses.delete", + "subcourses.create", "subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active", + "subcourses.update", "subcourses.upload_thumbnail", "subcourses.deactivate", "subcourses.delete", + "videos.create", "videos.create_vimeo", "videos.upload", "videos.import_vimeo", "videos.get", + "videos.list_by_subcourse", "videos.list_published", "videos.publish", "videos.update", "videos.delete", + "learning_tree.get", + + // Questions (full access) + "questions.create", "questions.list", "questions.search", "questions.get", "questions.update", "questions.delete", + "question_sets.create", "question_sets.list", "question_sets.list_by_owner", "question_sets.get", "question_sets.update", "question_sets.delete", + "question_set_items.add", "question_set_items.list", "question_set_items.remove", "question_set_items.update_order", + "question_set_personas.list", "question_set_personas.add", "question_set_personas.remove", + + // Subscriptions & Payments (full access) + "subscription_plans.create", "subscription_plans.update", "subscription_plans.delete", + "subscriptions.create", "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", + + // Users (full access) + "users.list", "users.get", "users.update_self", "users.update_status", "users.delete", "users.search", + "users.profile_completed", "users.upload_profile_picture", "users.admin_profile", "users.user_profile", + + // Admin management + "admins.list", "admins.get", "admins.create", "admins.update", + + // Logs (previously OnlyAdminAndAbove) + "logs.list", "activity_logs.list", "activity_logs.get", + + // Notifications (full access including bulk) + "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.create", + "notifications.test_push", "notifications.bulk_push", "notifications.bulk_sms", "notifications.send_email", "notifications.bulk_email", + "notifications_scheduled.list", "notifications_scheduled.get", "notifications_scheduled.cancel", + + // Issues (full access including admin views) + "issues.create", "issues.list_mine", "issues.list_by_user", "issues.list_all", "issues.get", "issues.update_status", "issues.delete", + + // Devices + "devices.register", "devices.unregister", + + // Settings (previously SuperAdminOnly, now accessible to ADMIN too) + "settings.list", "settings.get", "settings.update", + + // Analytics (previously OnlyAdminAndAbove) + "analytics.dashboard", + + // Vimeo + "vimeo.videos.get", "vimeo.videos.embed", "vimeo.videos.status", "vimeo.videos.delete", + "vimeo.uploads.pull", "vimeo.uploads.tus", + + // Team (full access) + "team.profile.get_mine", "team.stats", "team.members.list", "team.members.create", + "team.members.get", "team.members.update", "team.members.update_status", "team.members.delete", "team.members.change_password", + + // Sub-course Prerequisites + "subcourse_prerequisites.add", "subcourse_prerequisites.list", "subcourse_prerequisites.remove", + + // Progress + "progress.start", "progress.update", "progress.complete", "progress.check_access", "progress.get_course", + + // Ratings + "ratings.submit", "ratings.list_by_target", "ratings.summary", "ratings.get_mine", "ratings.list_mine", "ratings.delete", + + // Auth + "auth.logout", + + // RBAC management + "rbac.roles.list", "rbac.roles.get", "rbac.roles.create", "rbac.roles.update", "rbac.roles.delete", + "rbac.roles.set_permissions", "rbac.roles.get_permissions", + "rbac.permissions.list", "rbac.permissions.groups", "rbac.permissions.sync", + }, + + "STUDENT": { + // Course browsing + "course_categories.list", "course_categories.get", + "courses.get", "courses.list_by_category", + "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", + + // 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.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", + }, + + "INSTRUCTOR": { + // Course browsing + management + "course_categories.list", "course_categories.get", + "courses.get", "courses.list_by_category", + "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", + + // Questions (full — instructors create content) + "questions.create", "questions.list", "questions.search", "questions.get", "questions.update", "questions.delete", + "question_sets.create", "question_sets.list", "question_sets.list_by_owner", "question_sets.get", "question_sets.update", "question_sets.delete", + "question_set_items.add", "question_set_items.list", "question_set_items.remove", "question_set_items.update_order", + "question_set_personas.list", "question_set_personas.add", "question_set_personas.remove", + + // 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.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 + "subcourse_prerequisites.list", + + // Ratings + "ratings.submit", "ratings.list_by_target", "ratings.summary", "ratings.get_mine", "ratings.list_mine", "ratings.delete", + + // Auth + "auth.logout", + }, + + "SUPPORT": { + // Course browsing (read-only) + "course_categories.list", "course_categories.get", + "courses.get", "courses.list_by_category", + "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", + + // Questions (read) + "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", + + // Users (view + search for support) + "users.list", "users.get", "users.search", "users.update_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 + view all for support) + "issues.create", "issues.list_mine", "issues.list_by_user", "issues.list_all", "issues.get", "issues.update_status", + + // Devices + "devices.register", "devices.unregister", + + // Progress (read) + "progress.check_access", "progress.get_course", + + // Sub-course Prerequisites (read) + "subcourse_prerequisites.list", + + // Ratings (read) + "ratings.list_by_target", "ratings.summary", "ratings.get_mine", "ratings.list_mine", + + // Auth + "auth.logout", + }, +} diff --git a/internal/services/rbac/service.go b/internal/services/rbac/service.go new file mode 100644 index 0000000..b64e3d7 --- /dev/null +++ b/internal/services/rbac/service.go @@ -0,0 +1,165 @@ +package rbac + +import ( + "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/ports" + "context" + "fmt" + "log/slog" + "sync/atomic" +) + +type snapshot struct { + rolePerms map[string]map[string]struct{} // roleName -> set of permissionKeys +} + +type Service struct { + store ports.RBACStore + cache atomic.Value // holds *snapshot + logger *slog.Logger +} + +func NewService(store ports.RBACStore, logger *slog.Logger) *Service { + s := &Service{ + store: store, + logger: logger, + } + // Initialize with empty snapshot + s.cache.Store(&snapshot{rolePerms: make(map[string]map[string]struct{})}) + return s +} + +// HasPermission checks if a role has a specific permission key. +// SUPER_ADMIN always returns true. +func (s *Service) HasPermission(roleName, permKey string) bool { + if roleName == string(domain.RoleSuperAdmin) { + return true + } + snap := s.cache.Load().(*snapshot) + perms, ok := snap.rolePerms[roleName] + if !ok { + return false + } + _, has := perms[permKey] + return has +} + +// Reload rebuilds the in-memory cache from the database. +func (s *Service) Reload(ctx context.Context) error { + rolePerms, err := s.store.GetAllRolesWithPermissions(ctx) + if err != nil { + return fmt.Errorf("rbac reload: %w", err) + } + s.cache.Store(&snapshot{rolePerms: rolePerms}) + s.logger.Info("RBAC cache reloaded", "roles", len(rolePerms)) + return nil +} + +// SeedPermissions upserts all permission definitions into the database. +func (s *Service) SeedPermissions(ctx context.Context) error { + for _, p := range AllPermissions { + if _, err := s.store.UpsertPermission(ctx, p); err != nil { + return fmt.Errorf("seed permission %s: %w", p.Key, err) + } + } + s.logger.Info("RBAC permissions seeded", "count", len(AllPermissions)) + return nil +} + +// SeedDefaultRolePermissions assigns the default permission sets to each system role. +// It only assigns permissions if the role currently has zero permissions (first-time setup). +func (s *Service) SeedDefaultRolePermissions(ctx context.Context) error { + for roleName, permKeys := range DefaultRolePermissions { + role, err := s.store.GetRoleByName(ctx, roleName) + if err != nil { + s.logger.Warn("skipping role permission seed: role not found", "role", roleName) + continue + } + + existing, err := s.store.GetRolePermissions(ctx, role.ID) + if err != nil { + return fmt.Errorf("check existing permissions for %s: %w", roleName, err) + } + if len(existing) > 0 { + s.logger.Info("role already has permissions, skipping seed", "role", roleName, "count", len(existing)) + continue + } + + var permIDs []int64 + for _, key := range permKeys { + perm, err := s.store.GetPermissionByKey(ctx, key) + if err != nil { + s.logger.Warn("permission key not found during seed", "key", key, "role", roleName) + continue + } + permIDs = append(permIDs, perm.ID) + } + + if len(permIDs) > 0 { + if err := s.store.SetRolePermissions(ctx, role.ID, permIDs); err != nil { + return fmt.Errorf("seed permissions for role %s: %w", roleName, err) + } + s.logger.Info("seeded default permissions for role", "role", roleName, "count", len(permIDs)) + } + } + return nil +} + +// --- Role CRUD (pass-through to store + reload cache) --- + +func (s *Service) CreateRole(ctx context.Context, name, description string) (domain.RoleRecord, error) { + role, err := s.store.CreateRole(ctx, name, description, false) + if err != nil { + return domain.RoleRecord{}, err + } + _ = s.Reload(ctx) + return role, nil +} + +func (s *Service) GetRoleByID(ctx context.Context, id int64) (domain.RoleRecord, error) { + return s.store.GetRoleByID(ctx, id) +} + +func (s *Service) ListRoles(ctx context.Context, filter domain.RoleListFilter) ([]domain.RoleRecord, int64, error) { + return s.store.ListRoles(ctx, filter) +} + +func (s *Service) UpdateRole(ctx context.Context, id int64, name, description string) error { + if err := s.store.UpdateRole(ctx, id, name, description); err != nil { + return err + } + _ = s.Reload(ctx) + return nil +} + +func (s *Service) DeleteRole(ctx context.Context, id int64) error { + if err := s.store.DeleteRole(ctx, id); err != nil { + return err + } + _ = s.Reload(ctx) + return nil +} + +// --- Permission queries --- + +func (s *Service) ListPermissions(ctx context.Context) ([]domain.Permission, error) { + return s.store.ListPermissions(ctx) +} + +func (s *Service) ListPermissionGroups(ctx context.Context) ([]string, error) { + return s.store.ListPermissionGroups(ctx) +} + +// --- Role-Permission management --- + +func (s *Service) SetRolePermissions(ctx context.Context, roleID int64, permissionIDs []int64) error { + if err := s.store.SetRolePermissions(ctx, roleID, permissionIDs); err != nil { + return err + } + _ = s.Reload(ctx) + return nil +} + +func (s *Service) GetRolePermissions(ctx context.Context, roleID int64) ([]domain.Permission, error) { + return s.store.GetRolePermissions(ctx, roleID) +} diff --git a/internal/services/user/direct.go b/internal/services/user/direct.go index 7e38761..4c8895a 100644 --- a/internal/services/user/direct.go +++ b/internal/services/user/direct.go @@ -67,6 +67,11 @@ func (s *Service) GetAllUsers( role = &filter.Role } + var status *string + if filter.Status != "" { + status = &filter.Status + } + var query *string if filter.Query != "" { query = &filter.Query @@ -77,6 +82,7 @@ func (s *Service) GetAllUsers( return s.userStore.GetAllUsers( ctx, role, + status, query, before, after, @@ -85,6 +91,10 @@ func (s *Service) GetAllUsers( ) } +func (s *Service) UpdateUserStatus(ctx context.Context, req domain.UpdateUserStatusReq) error { + return s.userStore.UpdateUserStatus(ctx, req) +} + func (s *Service) GetUserById(ctx context.Context, id int64) (domain.User, error) { return s.userStore.GetUserByID(ctx, id) diff --git a/internal/services/user/interface.go b/internal/services/user/interface.go index 22873f2..675d64d 100644 --- a/internal/services/user/interface.go +++ b/internal/services/user/interface.go @@ -21,6 +21,7 @@ type UserStore interface { GetAllUsers(ctx context.Context, filter domain.UserFilter) ([]domain.User, int64, error) GetAdminByCompanyID(ctx context.Context, companyID int64) (domain.User, error) UpdateUser(ctx context.Context, user domain.UpdateUserReq) error + UpdateUserStatus(ctx context.Context, req domain.UpdateUserStatusReq) error UpdateUserCompany(ctx context.Context, id int64, companyID int64) error UpdateUserSuspend(ctx context.Context, id int64, status bool) error DeleteUser(ctx context.Context, id int64) error diff --git a/internal/web_server/app.go b/internal/web_server/app.go index 4e410a5..6b6ebff 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -6,6 +6,7 @@ import ( activitylogservice "Yimaru-Backend/internal/services/activity_log" cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" ratingsservice "Yimaru-Backend/internal/services/ratings" + rbacservice "Yimaru-Backend/internal/services/rbac" "Yimaru-Backend/internal/services/arifpay" "Yimaru-Backend/internal/services/assessment" "Yimaru-Backend/internal/services/authentication" @@ -60,6 +61,7 @@ type App struct { Logger *slog.Logger mongoLoggerSvc *zap.Logger analyticsDB *dbgen.Queries + rbacSvc *rbacservice.Service } func NewApp( @@ -86,6 +88,7 @@ func NewApp( cfg *config.Config, mongoLoggerSvc *zap.Logger, analyticsDB *dbgen.Queries, + rbacSvc *rbacservice.Service, ) *App { app := fiber.New(fiber.Config{ CaseSensitive: true, @@ -131,6 +134,7 @@ func NewApp( cfg: cfg, mongoLoggerSvc: mongoLoggerSvc, analyticsDB: analyticsDB, + rbacSvc: rbacSvc, } s.initAppRoutes() diff --git a/internal/web_server/handlers/course_management.go b/internal/web_server/handlers/course_management.go index 33c9f89..684ab20 100644 --- a/internal/web_server/handlers/course_management.go +++ b/internal/web_server/handlers/course_management.go @@ -277,19 +277,21 @@ func (h *Handler) DeleteCourseCategory(c *fiber.Ctx) error { // Course Handlers type createCourseReq struct { - CategoryID int64 `json:"category_id" validate:"required"` - Title string `json:"title" validate:"required"` - Description *string `json:"description"` - Thumbnail *string `json:"thumbnail"` + CategoryID int64 `json:"category_id" validate:"required"` + Title string `json:"title" validate:"required"` + Description *string `json:"description"` + Thumbnail *string `json:"thumbnail"` + IntroVideoURL *string `json:"intro_video_url"` } type courseRes struct { - ID int64 `json:"id"` - CategoryID int64 `json:"category_id"` - Title string `json:"title"` - Description *string `json:"description"` - Thumbnail *string `json:"thumbnail"` - IsActive bool `json:"is_active"` + ID int64 `json:"id"` + CategoryID int64 `json:"category_id"` + Title string `json:"title"` + Description *string `json:"description"` + Thumbnail *string `json:"thumbnail"` + IntroVideoURL *string `json:"intro_video_url,omitempty"` + IsActive bool `json:"is_active"` } // CreateCourse godoc @@ -312,7 +314,7 @@ func (h *Handler) CreateCourse(c *fiber.Ctx) error { }) } - course, err := h.courseMgmtSvc.CreateCourse(c.Context(), req.CategoryID, req.Title, req.Description, req.Thumbnail) + course, err := h.courseMgmtSvc.CreateCourse(c.Context(), req.CategoryID, req.Title, req.Description, req.Thumbnail, req.IntroVideoURL) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to create course", @@ -340,12 +342,13 @@ func (h *Handler) CreateCourse(c *fiber.Ctx) error { return c.Status(fiber.StatusCreated).JSON(domain.Response{ Message: "Course created successfully", Data: courseRes{ - ID: course.ID, - CategoryID: course.CategoryID, - Title: course.Title, - Description: course.Description, - Thumbnail: course.Thumbnail, - IsActive: course.IsActive, + ID: course.ID, + CategoryID: course.CategoryID, + Title: course.Title, + Description: course.Description, + Thumbnail: course.Thumbnail, + IntroVideoURL: course.IntroVideoURL, + IsActive: course.IsActive, }, }) } @@ -382,12 +385,13 @@ func (h *Handler) GetCourseByID(c *fiber.Ctx) error { return c.JSON(domain.Response{ Message: "Course retrieved successfully", Data: courseRes{ - ID: course.ID, - CategoryID: course.CategoryID, - Title: course.Title, - Description: course.Description, - Thumbnail: course.Thumbnail, - IsActive: course.IsActive, + ID: course.ID, + CategoryID: course.CategoryID, + Title: course.Title, + Description: course.Description, + Thumbnail: course.Thumbnail, + IntroVideoURL: course.IntroVideoURL, + IsActive: course.IsActive, }, }) } @@ -449,12 +453,13 @@ func (h *Handler) GetCoursesByCategory(c *fiber.Ctx) error { var courseResponses []courseRes for _, course := range courses { courseResponses = append(courseResponses, courseRes{ - ID: course.ID, - CategoryID: course.CategoryID, - Title: course.Title, - Description: course.Description, - Thumbnail: course.Thumbnail, - IsActive: course.IsActive, + ID: course.ID, + CategoryID: course.CategoryID, + Title: course.Title, + Description: course.Description, + Thumbnail: course.Thumbnail, + IntroVideoURL: course.IntroVideoURL, + IsActive: course.IsActive, }) } @@ -468,10 +473,11 @@ func (h *Handler) GetCoursesByCategory(c *fiber.Ctx) error { } type updateCourseReq struct { - Title *string `json:"title"` - Description *string `json:"description"` - Thumbnail *string `json:"thumbnail"` - IsActive *bool `json:"is_active"` + Title *string `json:"title"` + Description *string `json:"description"` + Thumbnail *string `json:"thumbnail"` + IntroVideoURL *string `json:"intro_video_url"` + IsActive *bool `json:"is_active"` } // UpdateCourse godoc @@ -504,7 +510,7 @@ func (h *Handler) UpdateCourse(c *fiber.Ctx) error { }) } - err = h.courseMgmtSvc.UpdateCourse(c.Context(), id, req.Title, req.Description, req.Thumbnail, req.IsActive) + err = h.courseMgmtSvc.UpdateCourse(c.Context(), id, req.Title, req.Description, req.Thumbnail, req.IntroVideoURL, req.IsActive) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to update course", @@ -1190,30 +1196,9 @@ func (h *Handler) GetVideosBySubCourse(c *fiber.Ctx) error { }) } - var videoResponses []subCourseVideoRes + videoResponses := make([]subCourseVideoRes, 0, len(videos)) for _, v := range videos { - var publishDate *string - if v.PublishDate != nil { - pd := v.PublishDate.String() - publishDate = &pd - } - - videoResponses = append(videoResponses, subCourseVideoRes{ - ID: v.ID, - SubCourseID: v.SubCourseID, - Title: v.Title, - Description: v.Description, - VideoURL: v.VideoURL, - Duration: v.Duration, - Resolution: v.Resolution, - InstructorID: v.InstructorID, - Thumbnail: v.Thumbnail, - Visibility: v.Visibility, - DisplayOrder: v.DisplayOrder, - IsPublished: v.IsPublished, - PublishDate: publishDate, - Status: v.Status, - }) + videoResponses = append(videoResponses, mapVideoToResponse(v)) } return c.JSON(domain.Response{ @@ -1253,30 +1238,9 @@ func (h *Handler) GetPublishedVideosBySubCourse(c *fiber.Ctx) error { }) } - var videoResponses []subCourseVideoRes + videoResponses := make([]subCourseVideoRes, 0, len(videos)) for _, v := range videos { - var publishDate *string - if v.PublishDate != nil { - pd := v.PublishDate.String() - publishDate = &pd - } - - videoResponses = append(videoResponses, subCourseVideoRes{ - ID: v.ID, - SubCourseID: v.SubCourseID, - Title: v.Title, - Description: v.Description, - VideoURL: v.VideoURL, - Duration: v.Duration, - Resolution: v.Resolution, - InstructorID: v.InstructorID, - Thumbnail: v.Thumbnail, - Visibility: v.Visibility, - DisplayOrder: v.DisplayOrder, - IsPublished: v.IsPublished, - PublishDate: publishDate, - Status: v.Status, - }) + videoResponses = append(videoResponses, mapVideoToResponse(v)) } return c.JSON(domain.Response{ @@ -1725,7 +1689,7 @@ func (h *Handler) UploadCourseThumbnail(c *fiber.Ctx) error { return err } - if err := h.courseMgmtSvc.UpdateCourse(c.Context(), id, nil, nil, &publicPath, nil); err != nil { + if err := h.courseMgmtSvc.UpdateCourse(c.Context(), id, nil, nil, &publicPath, nil, nil); err != nil { _ = os.Remove(filepath.Join(".", publicPath)) return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to update course thumbnail", diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index 65e54c0..03c5bed 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -14,6 +14,7 @@ import ( cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" issuereporting "Yimaru-Backend/internal/services/issue_reporting" ratingsservice "Yimaru-Backend/internal/services/ratings" + rbacservice "Yimaru-Backend/internal/services/rbac" notificationservice "Yimaru-Backend/internal/services/notification" "Yimaru-Backend/internal/services/questions" "Yimaru-Backend/internal/services/recommendation" @@ -53,6 +54,7 @@ type Handler struct { issueReportingSvc *issuereporting.Service cloudConvertSvc *cloudconvertservice.Service ratingSvc *ratingsservice.Service + rbacSvc *rbacservice.Service jwtConfig jwtutil.JwtConfig validator *customvalidator.CustomValidator Cfg *config.Config @@ -80,6 +82,7 @@ func New( issueReportingSvc *issuereporting.Service, cloudConvertSvc *cloudconvertservice.Service, ratingSvc *ratingsservice.Service, + rbacSvc *rbacservice.Service, jwtConfig jwtutil.JwtConfig, cfg *config.Config, mongoLoggerSvc *zap.Logger, @@ -105,6 +108,7 @@ func New( issueReportingSvc: issueReportingSvc, cloudConvertSvc: cloudConvertSvc, ratingSvc: ratingSvc, + rbacSvc: rbacSvc, jwtConfig: jwtConfig, Cfg: cfg, mongoLoggerSvc: mongoLoggerSvc, diff --git a/internal/web_server/handlers/notification_handler.go b/internal/web_server/handlers/notification_handler.go index 6ab614d..0425785 100644 --- a/internal/web_server/handlers/notification_handler.go +++ b/internal/web_server/handlers/notification_handler.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net" "net/http" "strconv" @@ -14,6 +15,7 @@ import ( "github.com/gofiber/fiber/v2" "github.com/gorilla/websocket" + "github.com/resend/resend-go/v2" "go.uber.org/zap" ) @@ -507,32 +509,54 @@ func (h *Handler) GetAllNotifications(c *fiber.Ctx) error { limitStr := c.Query("limit", "10") pageStr := c.Query("page", "1") - // Convert limit and offset to integers limit, err := strconv.Atoi(limitStr) if err != nil || limit <= 0 { - h.mongoLoggerSvc.Info("[NotificationSvc.GetNotifications] Invalid limit value", - zap.String("limit", limitStr), - zap.Int("status_code", fiber.StatusBadRequest), - zap.Error(err), - zap.Time("timestamp", time.Now()), - ) return fiber.NewError(fiber.StatusBadRequest, "Invalid limit value") } page, err := strconv.Atoi(pageStr) if err != nil || page <= 0 { - h.mongoLoggerSvc.Info("[NotificationSvc.GetNotifications] Invalid page value", - zap.String("page", pageStr), - zap.Int("status_code", fiber.StatusBadRequest), - zap.Error(err), - zap.Time("timestamp", time.Now()), - ) return fiber.NewError(fiber.StatusBadRequest, "Invalid page value") } - notifications, err := h.notificationSvc.GetAllNotifications(context.Background(), limit, ((page - 1) * limit)) + filter := domain.NotificationFilter{ + Channel: c.Query("channel"), + Type: c.Query("type"), + Limit: limit, + Offset: (page - 1) * limit, + } + + if uid := c.Query("user_id"); uid != "" { + id, err := strconv.ParseInt(uid, 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid user_id value") + } + filter.UserID = &id + } + + if isRead := c.Query("is_read"); isRead != "" { + val := isRead == "true" + filter.IsRead = &val + } + + if after := c.Query("after"); after != "" { + t, err := time.Parse(time.RFC3339, after) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid after date, use RFC3339 format") + } + filter.After = &t + } + + if before := c.Query("before"); before != "" { + t, err := time.Parse(time.RFC3339, before) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid before date, use RFC3339 format") + } + filter.Before = &t + } + + notifications, total, err := h.notificationSvc.GetFilteredNotifications(context.Background(), filter) if err != nil { h.mongoLoggerSvc.Error("[NotificationSvc.GetNotifications] Failed to fetch notifications", - zap.Int64("limit", int64(limit)), zap.Int("status_code", fiber.StatusInternalServerError), zap.Error(err), zap.Time("timestamp", time.Now()), @@ -542,11 +566,10 @@ func (h *Handler) GetAllNotifications(c *fiber.Ctx) error { return c.Status(fiber.StatusOK).JSON(fiber.Map{ "notifications": notifications, - "total_count": len(notifications), + "total_count": total, "limit": limit, "page": page, }) - } type SendSingleAfroSMSReq struct { @@ -684,25 +707,8 @@ func (h *Handler) RegisterDeviceToken(c *fiber.Ctx) error { // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/notifications/test-push [post] func (h *Handler) SendTestPushNotification(c *fiber.Ctx) error { - type Request struct { - Title string `json:"title"` - Message string `json:"message"` - } - - var req Request - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - } - - if req.Title == "" { - req.Title = "Test Push Notification" - } - if req.Message == "" { - req.Message = "This is a test push notification from Yimaru Backend" - } + title := c.FormValue("title", "Test Push Notification") + message := c.FormValue("message", "This is a test push notification from Yimaru Backend") userID, ok := c.Locals("user_id").(int64) if !ok || userID == 0 { @@ -728,14 +734,25 @@ func (h *Handler) SendTestPushNotification(c *fiber.Ctx) error { }) } + // Handle optional image upload + var imageURL string + if _, err := c.FormFile("file"); err == nil { + savedPath, saveErr := h.processAndSaveThumbnail(c, "notification_images") + if saveErr != nil { + return saveErr + } + imageURL = c.BaseURL() + savedPath + } + // Create test notification notification := &domain.Notification{ RecipientID: userID, Type: "system_alert", DeliveryChannel: domain.DeliveryChannelPush, + Image: imageURL, Payload: domain.NotificationPayload{ - Headline: req.Title, - Message: req.Message, + Headline: title, + Message: message, }, } @@ -748,6 +765,9 @@ func (h *Handler) SendTestPushNotification(c *fiber.Ctx) error { }) } + // Record in DB for history + h.notificationSvc.RecordNotification(c.Context(), userID, domain.NOTIFICATION_TYPE_SYSTEM_ALERT, domain.DeliveryChannelPush, domain.NotificationLevelInfo, title, message) + h.mongoLoggerSvc.Info("[NotificationHandler.SendTestPushNotification] Test push sent", zap.Int64("userID", userID), zap.Int("deviceCount", len(tokens)), @@ -760,8 +780,746 @@ func (h *Handler) SendTestPushNotification(c *fiber.Ctx) error { StatusCode: fiber.StatusOK, Data: map[string]interface{}{ "devices_count": len(tokens), - "title": req.Title, - "message": req.Message, + "title": title, + "message": message, + "image": imageURL, + }, + }) +} + +// SendBulkPushNotification sends a push notification to multiple users or all users of a given role. +// @Summary Send bulk push notification +// @Description Sends a push notification to specified user IDs or all users matching a role. Optionally schedule for later with scheduled_at (RFC3339). +// @Tags notifications +// @Accept json +// @Produce json +// @Param body body object{title=string,message=string,image=string,user_ids=[]int64,role=string,scheduled_at=string} true "Bulk push content" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/notifications/bulk-push [post] +func (h *Handler) SendBulkPushNotification(c *fiber.Ctx) error { + title := c.FormValue("title") + message := c.FormValue("message") + role := c.FormValue("role") + userIDsRaw := c.FormValue("user_ids") + scheduledAtRaw := c.FormValue("scheduled_at") + + if title == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Title is required", + }) + } + if message == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Message is required", + }) + } + + // Parse user_ids from JSON array string e.g. "[1,2,3]" + var userIDs []int64 + if userIDsRaw != "" { + if err := json.Unmarshal([]byte(userIDsRaw), &userIDs); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid user_ids format", + Error: "user_ids must be a JSON array of integers, e.g. [1,2,3]", + }) + } + } + + if len(userIDs) == 0 && role == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "No target users specified", + Error: "Provide either user_ids or role", + }) + } + + // Schedule for later if scheduled_at is provided + if scheduledAtRaw != "" { + scheduledAt, err := time.Parse(time.RFC3339, scheduledAtRaw) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid scheduled_at format, use RFC3339 (e.g. 2025-06-01T10:00:00Z)", + Error: err.Error(), + }) + } + if scheduledAt.Before(time.Now()) { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "scheduled_at must be in the future", + }) + } + + creatorID, _ := c.Locals("user_id").(int64) + sn := &domain.ScheduledNotification{ + Channel: domain.DeliveryChannelPush, + Title: title, + Message: message, + ScheduledAt: scheduledAt, + TargetUserIDs: userIDs, + TargetRole: role, + CreatedBy: creatorID, + } + + created, err := h.notificationSvc.CreateScheduledNotification(c.Context(), sn) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to schedule push notification", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusCreated).JSON(domain.Response{ + Message: "Push notification scheduled", + Success: true, + StatusCode: fiber.StatusCreated, + Data: created, + }) + } + + // Determine target user IDs by role if no specific IDs given + if len(userIDs) == 0 && role != "" { + users, _, err := h.userSvc.GetAllUsers(context.Background(), domain.UserFilter{Role: role}) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to fetch users for role", + Error: err.Error(), + }) + } + for _, u := range users { + userIDs = append(userIDs, u.ID) + } + } + + if len(userIDs) == 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "No target users found", + }) + } + + // Handle optional image upload + var imageURL string + if _, err := c.FormFile("file"); err == nil { + savedPath, saveErr := h.processAndSaveThumbnail(c, "notification_images") + if saveErr != nil { + return saveErr + } + imageURL = c.BaseURL() + savedPath + } + + notification := &domain.Notification{ + Type: "system_alert", + DeliveryChannel: domain.DeliveryChannelPush, + Image: imageURL, + Payload: domain.NotificationPayload{ + Headline: title, + Message: message, + }, + } + + sent, failed, err := h.notificationSvc.SendBulkPushNotification(c.Context(), userIDs, notification) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to send bulk push notification", + Error: err.Error(), + }) + } + + // Record in DB for history + for _, uid := range userIDs { + h.notificationSvc.RecordNotification(c.Context(), uid, domain.NOTIFICATION_TYPE_SYSTEM_ALERT, domain.DeliveryChannelPush, domain.NotificationLevelInfo, title, message) + } + + h.mongoLoggerSvc.Info("[NotificationHandler.SendBulkPushNotification] Bulk push sent", + zap.Int("targetUsers", len(userIDs)), + zap.Int("sent", sent), + zap.Int("failed", failed), + zap.Time("timestamp", time.Now()), + ) + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Bulk push notification sent", + Success: true, + StatusCode: fiber.StatusOK, + Data: map[string]interface{}{ + "target_users": len(userIDs), + "sent": sent, + "failed": failed, + "image": imageURL, + }, + }) +} + +// SendBulkSMS sends an SMS to multiple users by user IDs, role, or direct phone numbers. +// @Summary Send bulk SMS +// @Description Sends an SMS to specified user IDs, all users of a role, or direct phone numbers. Optionally schedule for later with scheduled_at (RFC3339). +// @Tags notifications +// @Accept json +// @Produce json +// @Param body body object{message=string,user_ids=[]int64,role=string,phone_numbers=[]string,scheduled_at=string} true "Bulk SMS content" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/notifications/bulk-sms [post] +func (h *Handler) SendBulkSMS(c *fiber.Ctx) error { + type Request struct { + Message string `json:"message" validate:"required"` + UserIDs []int64 `json:"user_ids"` + Role string `json:"role"` + PhoneNumbers []string `json:"phone_numbers"` + ScheduledAt string `json:"scheduled_at"` + } + + var req Request + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + + if req.Message == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Message is required", + }) + } + + if len(req.UserIDs) == 0 && req.Role == "" && len(req.PhoneNumbers) == 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "No recipients specified", + Error: "Provide user_ids, role, or phone_numbers", + }) + } + + // Schedule for later if scheduled_at is provided + if req.ScheduledAt != "" { + scheduledAt, err := time.Parse(time.RFC3339, req.ScheduledAt) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid scheduled_at format, use RFC3339 (e.g. 2025-06-01T10:00:00Z)", + Error: err.Error(), + }) + } + if scheduledAt.Before(time.Now()) { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "scheduled_at must be in the future", + }) + } + + creatorID, _ := c.Locals("user_id").(int64) + + var targetRaw json.RawMessage + if len(req.PhoneNumbers) > 0 { + raw := domain.ScheduledNotificationTargetRaw{Phones: req.PhoneNumbers} + targetRaw, _ = json.Marshal(raw) + } + + sn := &domain.ScheduledNotification{ + Channel: domain.DeliveryChannelSMS, + Message: req.Message, + ScheduledAt: scheduledAt, + TargetUserIDs: req.UserIDs, + TargetRole: req.Role, + TargetRaw: targetRaw, + CreatedBy: creatorID, + } + + created, err := h.notificationSvc.CreateScheduledNotification(c.Context(), sn) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to schedule SMS", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusCreated).JSON(domain.Response{ + Message: "SMS scheduled", + Success: true, + StatusCode: fiber.StatusCreated, + Data: created, + }) + } + + // Collect phone numbers from all sources + phoneNumbers := make(map[string]struct{}) + + // Add directly provided phone numbers + for _, p := range req.PhoneNumbers { + phoneNumbers[p] = struct{}{} + } + + // Collect user IDs from role if needed + userIDs := req.UserIDs + if len(userIDs) == 0 && req.Role != "" { + users, _, err := h.userSvc.GetAllUsers(context.Background(), domain.UserFilter{Role: req.Role}) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to fetch users for role", + Error: err.Error(), + }) + } + for _, u := range users { + userIDs = append(userIDs, u.ID) + } + } + + // Resolve user IDs to phone numbers + for _, uid := range userIDs { + user, err := h.userSvc.GetUserByID(context.Background(), uid) + if err != nil { + h.mongoLoggerSvc.Warn("[NotificationHandler.SendBulkSMS] Failed to get user", + zap.Int64("userID", uid), + zap.Error(err), + ) + continue + } + if user.PhoneNumber != "" { + phoneNumbers[user.PhoneNumber] = struct{}{} + } + } + + if len(phoneNumbers) == 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "No recipients found", + Error: "Provide user_ids, role, or phone_numbers", + }) + } + + // Flatten to slice + recipients := make([]string, 0, len(phoneNumbers)) + for p := range phoneNumbers { + recipients = append(recipients, p) + } + + sent, failed := h.notificationSvc.SendBulkSMS(c.Context(), recipients, req.Message) + + // Record in DB for history (only for known users) + for _, uid := range userIDs { + h.notificationSvc.RecordNotification(c.Context(), uid, domain.NOTIFICATION_TYPE_SYSTEM_ALERT, domain.DeliveryChannelSMS, domain.NotificationLevelInfo, "SMS Notification", req.Message) + } + + h.mongoLoggerSvc.Info("[NotificationHandler.SendBulkSMS] Bulk SMS sent", + zap.Int("totalRecipients", len(recipients)), + zap.Int("sent", sent), + zap.Int("failed", failed), + zap.Time("timestamp", time.Now()), + ) + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Bulk SMS sent", + Success: true, + StatusCode: fiber.StatusOK, + Data: map[string]interface{}{ + "total_recipients": len(recipients), + "sent": sent, + "failed": failed, + }, + }) +} + +// GetScheduledNotification retrieves a single scheduled notification by ID. +// @Summary Get scheduled notification +// @Description Returns a single scheduled notification by its ID +// @Tags notifications +// @Produce json +// @Param id path int true "Scheduled Notification ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/notifications/scheduled/{id} [get] +func (h *Handler) GetScheduledNotification(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid ID", + Error: err.Error(), + }) + } + + sn, err := h.notificationSvc.GetScheduledNotification(c.Context(), id) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to get scheduled notification", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Scheduled notification retrieved", + Success: true, + StatusCode: fiber.StatusOK, + Data: sn, + }) +} + +// ListScheduledNotifications lists scheduled notifications with optional filters. +// @Summary List scheduled notifications +// @Description Returns paginated scheduled notifications with optional status, channel, and date filters +// @Tags notifications +// @Produce json +// @Param status query string false "Filter by status" +// @Param channel query string false "Filter by channel" +// @Param after query string false "Filter after date (RFC3339)" +// @Param before query string false "Filter before date (RFC3339)" +// @Param limit query int false "Page size" default(20) +// @Param page query int false "Page number" default(1) +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/notifications/scheduled [get] +func (h *Handler) ListScheduledNotifications(c *fiber.Ctx) error { + limit, err := strconv.Atoi(c.Query("limit", "20")) + if err != nil || limit <= 0 { + limit = 20 + } + page, err := strconv.Atoi(c.Query("page", "1")) + if err != nil || page <= 0 { + page = 1 + } + + filter := domain.ScheduledNotificationFilter{ + Status: c.Query("status"), + Channel: c.Query("channel"), + Limit: limit, + Offset: (page - 1) * limit, + } + + if after := c.Query("after"); after != "" { + t, err := time.Parse(time.RFC3339, after) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid after date, use RFC3339 format", + Error: err.Error(), + }) + } + filter.After = &t + } + + if before := c.Query("before"); before != "" { + t, err := time.Parse(time.RFC3339, before) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid before date, use RFC3339 format", + Error: err.Error(), + }) + } + filter.Before = &t + } + + notifications, total, err := h.notificationSvc.ListScheduledNotifications(c.Context(), filter) + if err != nil { + h.mongoLoggerSvc.Error("[NotificationHandler.ListScheduledNotifications] Failed", + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to list scheduled notifications", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "scheduled_notifications": notifications, + "total_count": total, + "limit": limit, + "page": page, + }) +} + +// CancelScheduledNotification cancels a pending or processing scheduled notification. +// @Summary Cancel scheduled notification +// @Description Cancels a scheduled notification if it is still pending or processing +// @Tags notifications +// @Produce json +// @Param id path int true "Scheduled Notification ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/notifications/scheduled/{id}/cancel [post] +func (h *Handler) CancelScheduledNotification(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid ID", + Error: err.Error(), + }) + } + + cancelled, err := h.notificationSvc.CancelScheduledNotification(c.Context(), id) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to cancel scheduled notification", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Scheduled notification cancelled", + Success: true, + StatusCode: fiber.StatusOK, + Data: cancelled, + }) +} + +// parseEmailAttachment reads an optional "file" from the multipart form and returns a Resend attachment. +func (h *Handler) parseEmailAttachment(c *fiber.Ctx) []*resend.Attachment { + fileHeader, err := c.FormFile("file") + if err != nil { + return nil + } + + fh, err := fileHeader.Open() + if err != nil { + return nil + } + defer fh.Close() + + data, err := io.ReadAll(fh) + if err != nil { + return nil + } + + return []*resend.Attachment{ + { + Content: data, + Filename: fileHeader.Filename, + }, + } +} + +// SendSingleEmail sends an email to a single recipient with an optional image attachment. +// @Summary Send single email +// @Description Sends an email to a single email address with optional image attachment +// @Tags notifications +// @Accept multipart/form-data +// @Produce json +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/notifications/send-email [post] +func (h *Handler) SendSingleEmail(c *fiber.Ctx) error { + recipient := c.FormValue("recipient") + subject := c.FormValue("subject") + message := c.FormValue("message") + html := c.FormValue("html") + + if recipient == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Recipient is required", + }) + } + if subject == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Subject is required", + }) + } + if message == "" && html == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Either message or html is required", + }) + } + + attachments := h.parseEmailAttachment(c) + + if err := h.notificationSvc.MessengerSvc().SendEmailWithAttachments( + c.Context(), recipient, message, html, subject, attachments, + ); err != nil { + h.mongoLoggerSvc.Error("[NotificationHandler.SendSingleEmail] Failed to send email", + zap.String("recipient", recipient), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to send email", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Email sent successfully", + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// SendBulkEmail sends an email to multiple users by user IDs, role, or direct email addresses. +// @Summary Send bulk email +// @Description Sends an email to specified user IDs, all users of a role, or direct email addresses with optional image attachment. Optionally schedule for later with scheduled_at (RFC3339). +// @Tags notifications +// @Accept multipart/form-data +// @Produce json +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/notifications/bulk-email [post] +func (h *Handler) SendBulkEmail(c *fiber.Ctx) error { + subject := c.FormValue("subject") + message := c.FormValue("message") + html := c.FormValue("html") + role := c.FormValue("role") + userIDsRaw := c.FormValue("user_ids") + emailsRaw := c.FormValue("emails") + scheduledAtRaw := c.FormValue("scheduled_at") + + if subject == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Subject is required", + }) + } + if message == "" && html == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Either message or html is required", + }) + } + + // Parse direct emails + var directEmails []string + if emailsRaw != "" { + if err := json.Unmarshal([]byte(emailsRaw), &directEmails); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid emails format", + Error: "emails must be a JSON array of strings", + }) + } + } + + // Parse user_ids + var userIDs []int64 + if userIDsRaw != "" { + if err := json.Unmarshal([]byte(userIDsRaw), &userIDs); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid user_ids format", + Error: "user_ids must be a JSON array of integers", + }) + } + } + + if len(userIDs) == 0 && role == "" && len(directEmails) == 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "No recipients specified", + Error: "Provide user_ids, role, or emails", + }) + } + + // Schedule for later if scheduled_at is provided + if scheduledAtRaw != "" { + scheduledAt, err := time.Parse(time.RFC3339, scheduledAtRaw) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid scheduled_at format, use RFC3339 (e.g. 2025-06-01T10:00:00Z)", + Error: err.Error(), + }) + } + if scheduledAt.Before(time.Now()) { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "scheduled_at must be in the future", + }) + } + + creatorID, _ := c.Locals("user_id").(int64) + + var targetRaw json.RawMessage + if len(directEmails) > 0 { + raw := domain.ScheduledNotificationTargetRaw{Emails: directEmails} + targetRaw, _ = json.Marshal(raw) + } + + sn := &domain.ScheduledNotification{ + Channel: domain.DeliveryChannelEmail, + Title: subject, + Message: message, + HTML: html, + ScheduledAt: scheduledAt, + TargetUserIDs: userIDs, + TargetRole: role, + TargetRaw: targetRaw, + CreatedBy: creatorID, + } + + created, err := h.notificationSvc.CreateScheduledNotification(c.Context(), sn) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to schedule email", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusCreated).JSON(domain.Response{ + Message: "Email scheduled", + Success: true, + StatusCode: fiber.StatusCreated, + Data: created, + }) + } + + // Immediate send: collect emails from all sources + emailSet := make(map[string]struct{}) + for _, e := range directEmails { + emailSet[e] = struct{}{} + } + + if len(userIDs) == 0 && role != "" { + users, _, err := h.userSvc.GetAllUsers(context.Background(), domain.UserFilter{Role: role}) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to fetch users for role", + Error: err.Error(), + }) + } + for _, u := range users { + userIDs = append(userIDs, u.ID) + } + } + + for _, uid := range userIDs { + user, err := h.userSvc.GetUserByID(context.Background(), uid) + if err != nil { + h.mongoLoggerSvc.Warn("[NotificationHandler.SendBulkEmail] Failed to get user", + zap.Int64("userID", uid), + zap.Error(err), + ) + continue + } + if user.Email != "" { + emailSet[user.Email] = struct{}{} + } + } + + if len(emailSet) == 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "No recipients found", + Error: "Provide user_ids, role, or emails", + }) + } + + recipients := make([]string, 0, len(emailSet)) + for e := range emailSet { + recipients = append(recipients, e) + } + + attachments := h.parseEmailAttachment(c) + + sent, failed := h.notificationSvc.SendBulkEmail(c.Context(), recipients, subject, message, html, attachments) + + // Record in DB for history (only for known users) + for _, uid := range userIDs { + h.notificationSvc.RecordNotification(c.Context(), uid, domain.NOTIFICATION_TYPE_SYSTEM_ALERT, domain.DeliveryChannelEmail, domain.NotificationLevelInfo, subject, message) + } + + h.mongoLoggerSvc.Info("[NotificationHandler.SendBulkEmail] Bulk email sent", + zap.Int("totalRecipients", len(recipients)), + zap.Int("sent", sent), + zap.Int("failed", failed), + zap.Time("timestamp", time.Now()), + ) + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Bulk email sent", + Success: true, + StatusCode: fiber.StatusOK, + Data: map[string]interface{}{ + "total_recipients": len(recipients), + "sent": sent, + "failed": failed, }, }) } diff --git a/internal/web_server/handlers/progression_handler.go b/internal/web_server/handlers/progression_handler.go new file mode 100644 index 0000000..c5ebb8e --- /dev/null +++ b/internal/web_server/handlers/progression_handler.go @@ -0,0 +1,404 @@ +package handlers + +import ( + "Yimaru-Backend/internal/domain" + "errors" + "strconv" + "time" + + "github.com/gofiber/fiber/v2" +) + +// --- Request / Response types --- + +type addPrerequisiteReq struct { + PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id" validate:"required"` +} + +type prerequisiteRes struct { + ID int64 `json:"id"` + SubCourseID int64 `json:"sub_course_id"` + PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"` + PrerequisiteTitle string `json:"prerequisite_title"` + PrerequisiteLevel string `json:"prerequisite_level"` + PrerequisiteDisplayOrder int32 `json:"prerequisite_display_order"` +} + +type dependentRes struct { + ID int64 `json:"id"` + SubCourseID int64 `json:"sub_course_id"` + PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"` + DependentTitle string `json:"dependent_title"` + DependentLevel string `json:"dependent_level"` +} + +type updateProgressReq struct { + ProgressPercentage int16 `json:"progress_percentage" validate:"required,min=0,max=100"` +} + +type subCourseProgressRes struct { + SubCourseID int64 `json:"sub_course_id"` + Title string `json:"title"` + Description *string `json:"description,omitempty"` + Thumbnail *string `json:"thumbnail,omitempty"` + DisplayOrder int32 `json:"display_order"` + Level string `json:"level"` + ProgressStatus string `json:"progress_status"` + ProgressPercentage int16 `json:"progress_percentage"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + IsLocked bool `json:"is_locked"` +} + +type userProgressRes struct { + SubCourseID int64 `json:"sub_course_id"` + SubCourseTitle string `json:"sub_course_title"` + SubCourseLevel string `json:"sub_course_level"` + Status string `json:"status"` + ProgressPercentage int16 `json:"progress_percentage"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` +} + +// --- Prerequisite Handlers (admin) --- + +// AddSubCoursePrerequisite godoc +// @Summary Add prerequisite to sub-course +// @Description Link a prerequisite sub-course that must be completed before accessing this sub-course +// @Tags progression +// @Accept json +// @Produce json +// @Param id path int true "Sub-course ID" +// @Param body body addPrerequisiteReq true "Prerequisite sub-course ID" +// @Success 201 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/sub-courses/{id}/prerequisites [post] +func (h *Handler) AddSubCoursePrerequisite(c *fiber.Ctx) error { + subCourseID, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid sub-course ID", + Error: err.Error(), + }) + } + + var req addPrerequisiteReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + + if err := h.courseMgmtSvc.AddSubCoursePrerequisite(c.Context(), subCourseID, req.PrerequisiteSubCourseID); err != nil { + if errors.Is(err, domain.ErrSelfPrerequisite) { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid prerequisite", + Error: err.Error(), + }) + } + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to add prerequisite", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusCreated).JSON(domain.Response{ + Message: "Prerequisite added successfully", + }) +} + +// GetSubCoursePrerequisites godoc +// @Summary Get sub-course prerequisites +// @Description Returns all prerequisites for a sub-course +// @Tags progression +// @Produce json +// @Param id path int true "Sub-course ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/sub-courses/{id}/prerequisites [get] +func (h *Handler) GetSubCoursePrerequisites(c *fiber.Ctx) error { + subCourseID, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid sub-course ID", + Error: err.Error(), + }) + } + + prerequisites, err := h.courseMgmtSvc.GetSubCoursePrerequisites(c.Context(), subCourseID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to get prerequisites", + Error: err.Error(), + }) + } + + var res []prerequisiteRes + for _, p := range prerequisites { + res = append(res, prerequisiteRes{ + ID: p.ID, + SubCourseID: p.SubCourseID, + PrerequisiteSubCourseID: p.PrerequisiteSubCourseID, + PrerequisiteTitle: p.PrerequisiteTitle, + PrerequisiteLevel: p.PrerequisiteLevel, + PrerequisiteDisplayOrder: p.PrerequisiteDisplayOrder, + }) + } + + return c.JSON(domain.Response{ + Message: "Prerequisites retrieved successfully", + Data: res, + }) +} + +// RemoveSubCoursePrerequisite godoc +// @Summary Remove prerequisite from sub-course +// @Description Unlink a prerequisite from a sub-course +// @Tags progression +// @Produce json +// @Param id path int true "Sub-course ID" +// @Param prerequisiteId path int true "Prerequisite sub-course ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/sub-courses/{id}/prerequisites/{prerequisiteId} [delete] +func (h *Handler) RemoveSubCoursePrerequisite(c *fiber.Ctx) error { + subCourseID, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid sub-course ID", + Error: err.Error(), + }) + } + + prerequisiteID, err := strconv.ParseInt(c.Params("prerequisiteId"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid prerequisite ID", + Error: err.Error(), + }) + } + + if err := h.courseMgmtSvc.RemoveSubCoursePrerequisite(c.Context(), subCourseID, prerequisiteID); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to remove prerequisite", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Prerequisite removed successfully", + }) +} + +// --- User Progress Handlers --- + +// StartSubCourse godoc +// @Summary Start a sub-course +// @Description Mark a sub-course as started for the authenticated user (checks prerequisites) +// @Tags progression +// @Produce json +// @Param id path int true "Sub-course ID" +// @Success 201 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 403 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/progress/sub-courses/{id}/start [post] +func (h *Handler) StartSubCourse(c *fiber.Ctx) error { + userID := c.Locals("user_id").(int64) + subCourseID, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid sub-course ID", + Error: err.Error(), + }) + } + + progress, err := h.courseMgmtSvc.StartSubCourse(c.Context(), userID, subCourseID) + if err != nil { + if errors.Is(err, domain.ErrPrerequisiteNotMet) { + return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ + Message: "Cannot start sub-course", + Error: "Prerequisites not completed", + }) + } + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to start sub-course", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusCreated).JSON(domain.Response{ + Message: "Sub-course started", + Data: userProgressRes{ + SubCourseID: progress.SubCourseID, + Status: string(progress.Status), + ProgressPercentage: progress.ProgressPercentage, + StartedAt: progress.StartedAt, + }, + }) +} + +// UpdateSubCourseProgress godoc +// @Summary Update sub-course progress +// @Description Update the progress percentage for a sub-course +// @Tags progression +// @Accept json +// @Produce json +// @Param id path int true "Sub-course ID" +// @Param body body updateProgressReq true "Progress update" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/progress/sub-courses/{id} [put] +func (h *Handler) UpdateSubCourseProgress(c *fiber.Ctx) error { + userID := c.Locals("user_id").(int64) + subCourseID, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid sub-course ID", + Error: err.Error(), + }) + } + + var req updateProgressReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + + if err := h.courseMgmtSvc.UpdateSubCourseProgress(c.Context(), userID, subCourseID, req.ProgressPercentage); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to update progress", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Progress updated successfully", + }) +} + +// CompleteSubCourse godoc +// @Summary Complete a sub-course +// @Description Mark a sub-course as completed for the authenticated user +// @Tags progression +// @Produce json +// @Param id path int true "Sub-course ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/progress/sub-courses/{id}/complete [post] +func (h *Handler) CompleteSubCourse(c *fiber.Ctx) error { + userID := c.Locals("user_id").(int64) + subCourseID, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid sub-course ID", + Error: err.Error(), + }) + } + + if err := h.courseMgmtSvc.CompleteSubCourse(c.Context(), userID, subCourseID); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to complete sub-course", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Sub-course completed", + }) +} + +// CheckSubCourseAccess godoc +// @Summary Check sub-course access +// @Description Check if the authenticated user has completed all prerequisites for a sub-course +// @Tags progression +// @Produce json +// @Param id path int true "Sub-course ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/progress/sub-courses/{id}/access [get] +func (h *Handler) CheckSubCourseAccess(c *fiber.Ctx) error { + userID := c.Locals("user_id").(int64) + subCourseID, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid sub-course ID", + Error: err.Error(), + }) + } + + accessible, err := h.courseMgmtSvc.CheckSubCourseAccess(c.Context(), userID, subCourseID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to check access", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Access check completed", + Data: fiber.Map{ + "accessible": accessible, + }, + }) +} + +// GetUserCourseProgress godoc +// @Summary Get user's course progress +// @Description Returns the authenticated user's progress for all sub-courses in a course, including lock status +// @Tags progression +// @Produce json +// @Param courseId path int true "Course ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/progress/courses/{courseId} [get] +func (h *Handler) GetUserCourseProgress(c *fiber.Ctx) error { + userID := c.Locals("user_id").(int64) + courseID, err := strconv.ParseInt(c.Params("courseId"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid course ID", + Error: err.Error(), + }) + } + + items, err := h.courseMgmtSvc.GetSubCoursesWithProgress(c.Context(), userID, courseID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to get course progress", + Error: err.Error(), + }) + } + + var res []subCourseProgressRes + for _, item := range items { + res = append(res, subCourseProgressRes{ + SubCourseID: item.SubCourseID, + Title: item.Title, + Description: item.Description, + Thumbnail: item.Thumbnail, + DisplayOrder: item.DisplayOrder, + Level: item.Level, + ProgressStatus: string(item.ProgressStatus), + ProgressPercentage: item.ProgressPercentage, + StartedAt: item.StartedAt, + CompletedAt: item.CompletedAt, + IsLocked: item.IsLocked, + }) + } + + return c.JSON(domain.Response{ + Message: "Course progress retrieved successfully", + Data: res, + }) +} diff --git a/internal/web_server/handlers/rbac_handler.go b/internal/web_server/handlers/rbac_handler.go new file mode 100644 index 0000000..b3eff86 --- /dev/null +++ b/internal/web_server/handlers/rbac_handler.go @@ -0,0 +1,565 @@ +package handlers + +import ( + "Yimaru-Backend/internal/domain" + "context" + "encoding/json" + "fmt" + "strconv" + "time" + + "github.com/gofiber/fiber/v2" + "go.uber.org/zap" +) + +// ListRoles godoc +// @Summary List all roles +// @Description Get all roles with optional filters +// @Tags rbac +// @Accept json +// @Produce json +// @Security Bearer +// @Param query query string false "Search by role name" +// @Param is_system query bool false "Filter by system role (true/false)" +// @Param page query int false "Page number (default: 1)" +// @Param page_size query int false "Page size (default: 20)" +// @Success 200 {object} domain.Response +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/rbac/roles [get] +func (h *Handler) ListRoles(c *fiber.Ctx) error { + filter := domain.RoleListFilter{ + Query: c.Query("query"), + Page: int64(c.QueryInt("page", 1) - 1), + PageSize: int64(c.QueryInt("page_size", 20)), + } + + if isSystemStr := c.Query("is_system"); isSystemStr != "" { + isSystem := isSystemStr == "true" + filter.IsSystem = &isSystem + } + + roles, total, err := h.rbacSvc.ListRoles(c.Context(), filter) + if err != nil { + h.mongoLoggerSvc.Error("Failed to list roles", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to list roles", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Roles retrieved successfully", + Data: map[string]interface{}{ + "roles": roles, + "total": total, + "page": filter.Page + 1, + "page_size": filter.PageSize, + }, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// GetRoleByID godoc +// @Summary Get a role by ID +// @Description Get a role and its permissions by ID +// @Tags rbac +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "Role ID" +// @Success 200 {object} domain.Response{data=domain.RoleWithPermissions} +// @Failure 400 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/rbac/roles/{id} [get] +func (h *Handler) GetRoleByID(c *fiber.Ctx) error { + roleID, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil || roleID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid role ID", + Error: "Role ID must be a valid positive integer", + }) + } + + role, err := h.rbacSvc.GetRoleByID(c.Context(), roleID) + if err != nil { + h.mongoLoggerSvc.Error("Failed to get role", + zap.Int64("role_id", roleID), + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to get role", + Error: err.Error(), + }) + } + + permissions, err := h.rbacSvc.GetRolePermissions(c.Context(), roleID) + if err != nil { + h.mongoLoggerSvc.Error("Failed to get role permissions", + zap.Int64("role_id", roleID), + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to get role permissions", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Role retrieved successfully", + Data: domain.RoleWithPermissions{ + RoleRecord: role, + Permissions: permissions, + }, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// CreateRole godoc +// @Summary Create a new role +// @Description Create a new role with a name and description +// @Tags rbac +// @Accept json +// @Produce json +// @Security Bearer +// @Param body body domain.CreateRoleReq true "Role creation payload" +// @Success 201 {object} domain.Response{data=domain.RoleRecord} +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/rbac/roles [post] +func (h *Handler) CreateRole(c *fiber.Ctx) error { + var req domain.CreateRoleReq + if err := c.BodyParser(&req); err != nil { + h.mongoLoggerSvc.Info("Failed to parse CreateRole request", + zap.Int("status_code", fiber.StatusBadRequest), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Failed to create role", + Error: "Invalid request body: " + err.Error(), + }) + } + + if valErrs, ok := h.validator.Validate(c, req); !ok { + var errMsg string + for field, msg := range valErrs { + errMsg += fmt.Sprintf("%s: %s; ", field, msg) + } + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Failed to create role", + Error: errMsg, + }) + } + + role, err := h.rbacSvc.CreateRole(c.Context(), req.Name, req.Description) + if err != nil { + h.mongoLoggerSvc.Error("Failed to create role", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to create role", + Error: err.Error(), + }) + } + + h.mongoLoggerSvc.Info("Role created successfully", + zap.Int("status_code", fiber.StatusCreated), + zap.Int64("role_id", role.ID), + zap.Time("timestamp", time.Now()), + ) + + actorID := c.Locals("user_id").(int64) + actorRole := string(c.Locals("role").(domain.Role)) + ip := c.IP() + ua := c.Get("User-Agent") + meta, _ := json.Marshal(map[string]interface{}{"name": req.Name, "description": req.Description}) + go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionRoleCreated, domain.ResourceRole, &role.ID, "Created role: "+req.Name, meta, &ip, &ua) + + return c.Status(fiber.StatusCreated).JSON(domain.Response{ + Message: "Role created successfully", + Data: role, + Success: true, + StatusCode: fiber.StatusCreated, + }) +} + +// UpdateRole godoc +// @Summary Update a role +// @Description Update an existing role's name and description +// @Tags rbac +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "Role ID" +// @Param body body domain.UpdateRoleReq true "Role update payload" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/rbac/roles/{id} [put] +func (h *Handler) UpdateRole(c *fiber.Ctx) error { + roleID, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil || roleID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid role ID", + Error: "Role ID must be a valid positive integer", + }) + } + + var req domain.UpdateRoleReq + if err := c.BodyParser(&req); err != nil { + h.mongoLoggerSvc.Info("Failed to parse UpdateRole request", + zap.Int("status_code", fiber.StatusBadRequest), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Failed to update role", + Error: "Invalid request body: " + err.Error(), + }) + } + + if valErrs, ok := h.validator.Validate(c, req); !ok { + var errMsg string + for field, msg := range valErrs { + errMsg += fmt.Sprintf("%s: %s; ", field, msg) + } + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Failed to update role", + Error: errMsg, + }) + } + + if err := h.rbacSvc.UpdateRole(c.Context(), roleID, req.Name, req.Description); err != nil { + h.mongoLoggerSvc.Error("Failed to update role", + zap.Int64("role_id", roleID), + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to update role", + Error: err.Error(), + }) + } + + h.mongoLoggerSvc.Info("Role updated successfully", + zap.Int64("role_id", roleID), + zap.Time("timestamp", time.Now()), + ) + + actorID := c.Locals("user_id").(int64) + actorRole := string(c.Locals("role").(domain.Role)) + ip := c.IP() + ua := c.Get("User-Agent") + meta, _ := json.Marshal(map[string]interface{}{"role_id": roleID, "name": req.Name, "description": req.Description}) + go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionRoleUpdated, domain.ResourceRole, &roleID, fmt.Sprintf("Updated role ID: %d", roleID), meta, &ip, &ua) + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Role updated successfully", + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// DeleteRole godoc +// @Summary Delete a role +// @Description Delete a non-system role by ID +// @Tags rbac +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "Role ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/rbac/roles/{id} [delete] +func (h *Handler) DeleteRole(c *fiber.Ctx) error { + roleID, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil || roleID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid role ID", + Error: "Role ID must be a valid positive integer", + }) + } + + if err := h.rbacSvc.DeleteRole(c.Context(), roleID); err != nil { + h.mongoLoggerSvc.Error("Failed to delete role", + zap.Int64("role_id", roleID), + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to delete role", + Error: err.Error(), + }) + } + + h.mongoLoggerSvc.Info("Role deleted successfully", + zap.Int64("role_id", roleID), + zap.Time("timestamp", time.Now()), + ) + + actorID := c.Locals("user_id").(int64) + actorRole := string(c.Locals("role").(domain.Role)) + ip := c.IP() + ua := c.Get("User-Agent") + meta, _ := json.Marshal(map[string]interface{}{"role_id": roleID}) + go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionRoleDeleted, domain.ResourceRole, &roleID, fmt.Sprintf("Deleted role ID: %d", roleID), meta, &ip, &ua) + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Role deleted successfully", + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// SetRolePermissions godoc +// @Summary Set permissions for a role +// @Description Replace all permissions for a role with the given permission IDs +// @Tags rbac +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "Role ID" +// @Param body body domain.SetRolePermissionsReq true "Permission IDs payload" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/rbac/roles/{id}/permissions [put] +func (h *Handler) SetRolePermissions(c *fiber.Ctx) error { + roleID, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil || roleID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid role ID", + Error: "Role ID must be a valid positive integer", + }) + } + + var req domain.SetRolePermissionsReq + if err := c.BodyParser(&req); err != nil { + h.mongoLoggerSvc.Info("Failed to parse SetRolePermissions request", + zap.Int("status_code", fiber.StatusBadRequest), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Failed to set role permissions", + Error: "Invalid request body: " + err.Error(), + }) + } + + if valErrs, ok := h.validator.Validate(c, req); !ok { + var errMsg string + for field, msg := range valErrs { + errMsg += fmt.Sprintf("%s: %s; ", field, msg) + } + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Failed to set role permissions", + Error: errMsg, + }) + } + + if err := h.rbacSvc.SetRolePermissions(c.Context(), roleID, req.PermissionIDs); err != nil { + h.mongoLoggerSvc.Error("Failed to set role permissions", + zap.Int64("role_id", roleID), + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to set role permissions", + Error: err.Error(), + }) + } + + h.mongoLoggerSvc.Info("Role permissions set successfully", + zap.Int64("role_id", roleID), + zap.Time("timestamp", time.Now()), + ) + + actorID := c.Locals("user_id").(int64) + actorRole := string(c.Locals("role").(domain.Role)) + ip := c.IP() + ua := c.Get("User-Agent") + meta, _ := json.Marshal(map[string]interface{}{"role_id": roleID, "permission_ids": req.PermissionIDs}) + go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionRolePermissionsSet, domain.ResourceRole, &roleID, fmt.Sprintf("Set permissions for role ID: %d", roleID), meta, &ip, &ua) + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Role permissions set successfully", + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// GetRolePermissions godoc +// @Summary Get permissions for a role +// @Description Get all permissions assigned to a role +// @Tags rbac +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "Role ID" +// @Success 200 {object} domain.Response{data=[]domain.Permission} +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/rbac/roles/{id}/permissions [get] +func (h *Handler) GetRolePermissions(c *fiber.Ctx) error { + roleID, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil || roleID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid role ID", + Error: "Role ID must be a valid positive integer", + }) + } + + permissions, err := h.rbacSvc.GetRolePermissions(c.Context(), roleID) + if err != nil { + h.mongoLoggerSvc.Error("Failed to get role permissions", + zap.Int64("role_id", roleID), + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to get role permissions", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Role permissions retrieved successfully", + Data: permissions, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// ListPermissions godoc +// @Summary List all permissions +// @Description Get all permissions in the system grouped by group name +// @Tags rbac +// @Accept json +// @Produce json +// @Security Bearer +// @Success 200 {object} domain.Response{data=[]domain.Permission} +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/rbac/permissions [get] +func (h *Handler) ListPermissions(c *fiber.Ctx) error { + permissions, err := h.rbacSvc.ListPermissions(c.Context()) + if err != nil { + h.mongoLoggerSvc.Error("Failed to list permissions", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to list permissions", + Error: err.Error(), + }) + } + + grouped := make(map[string][]domain.Permission) + for _, p := range permissions { + grouped[p.GroupName] = append(grouped[p.GroupName], p) + } + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Permissions retrieved successfully", + Data: grouped, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// ListPermissionGroups godoc +// @Summary List permission groups +// @Description Get all distinct permission group names +// @Tags rbac +// @Accept json +// @Produce json +// @Security Bearer +// @Success 200 {object} domain.Response{data=[]string} +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/rbac/permissions/groups [get] +func (h *Handler) ListPermissionGroups(c *fiber.Ctx) error { + groups, err := h.rbacSvc.ListPermissionGroups(c.Context()) + if err != nil { + h.mongoLoggerSvc.Error("Failed to list permission groups", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to list permission groups", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Permission groups retrieved successfully", + Data: groups, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// SyncPermissions godoc +// @Summary Sync permissions +// @Description Re-seed permissions from code and reload the RBAC cache +// @Tags rbac +// @Accept json +// @Produce json +// @Security Bearer +// @Success 200 {object} domain.Response +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/rbac/permissions/sync [post] +func (h *Handler) SyncPermissions(c *fiber.Ctx) error { + if err := h.rbacSvc.SeedPermissions(c.Context()); err != nil { + h.mongoLoggerSvc.Error("Failed to seed permissions", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to sync permissions", + Error: err.Error(), + }) + } + + if err := h.rbacSvc.Reload(c.Context()); err != nil { + h.mongoLoggerSvc.Error("Failed to reload RBAC cache", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to reload RBAC cache", + Error: err.Error(), + }) + } + + h.mongoLoggerSvc.Info("Permissions synced and cache reloaded", + zap.Int("status_code", fiber.StatusOK), + zap.Time("timestamp", time.Now()), + ) + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Permissions synced and cache reloaded successfully", + Success: true, + StatusCode: fiber.StatusOK, + }) +} diff --git a/internal/web_server/handlers/user.go b/internal/web_server/handlers/user.go index 5ee6a91..2f80c57 100644 --- a/internal/web_server/handlers/user.go +++ b/internal/web_server/handlers/user.go @@ -405,6 +405,7 @@ func (h *Handler) CheckUserPending(c *fiber.Ctx) error { // @Param page_size query int false "Page size" // @Param created_before query string false "Created before (RFC3339)" // @Param created_after query string false "Created after (RFC3339)" +// @Param status query string false "Status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)" // @Success 200 {object} response.APIResponse // @Failure 400 {object} response.APIResponse // @Failure 500 {object} response.APIResponse @@ -440,6 +441,7 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error { filter := domain.UserFilter{ Role: c.Query("role"), + Status: c.Query("status"), Page: int64(c.QueryInt("page", 1) - 1), PageSize: int64(c.QueryInt("page_size", 10)), Query: searchString.Value, @@ -543,6 +545,64 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error { return response.WriteJSON(c, fiber.StatusOK, "Users fetched successfully", map[string]interface{}{"users": mapped, "total": total}, nil) } +// UpdateUserStatus godoc +// @Summary Update user status +// @Description Activates, deactivates, or suspends a user account +// @Tags user +// @Accept json +// @Produce json +// @Param body body object true "Status update payload" example({"user_id": 1, "status": "ACTIVE"}) +// @Success 200 {object} response.APIResponse +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /api/v1/user/status [patch] +func (h *Handler) UpdateUserStatus(c *fiber.Ctx) error { + var req struct { + UserID int64 `json:"user_id"` + Status string `json:"status"` + } + if err := c.BodyParser(&req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + if req.UserID <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid user ID") + } + + // Validate status + switch domain.UserStatus(req.Status) { + case domain.UserStatusActive, domain.UserStatusDeactivated, domain.UserStatusSuspended, domain.UserStatusPending: + // valid + default: + return fiber.NewError(fiber.StatusBadRequest, "Invalid status. Must be one of: ACTIVE, DEACTIVATED, SUSPENDED, PENDING") + } + + err := h.userSvc.UpdateUserStatus(c.Context(), domain.UpdateUserStatusReq{ + UserID: req.UserID, + Status: req.Status, + }) + if err != nil { + h.mongoLoggerSvc.Error("failed to update user status", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Int64("target_user_id", req.UserID), + zap.String("new_status", req.Status), + zap.Error(err), + zap.Time("timestamp", time.Now())) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update user status: "+err.Error()) + } + + actorID, _ := c.Locals("user_id").(int64) + actorRole := string(c.Locals("role").(domain.Role)) + ip := c.IP() + ua := c.Get("User-Agent") + meta, _ := json.Marshal(map[string]interface{}{"target_user_id": req.UserID, "new_status": req.Status}) + go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionUserUpdated, domain.ResourceUser, &req.UserID, fmt.Sprintf("Updated user %d status to %s", req.UserID, req.Status), meta, &ip, &ua) + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: fmt.Sprintf("User status updated to %s successfully", req.Status), + }) +} + // VerifyOtp godoc // @Summary Verify OTP // @Description Verify OTP for registration or other actions diff --git a/internal/web_server/middleware.go b/internal/web_server/middleware.go index 6a563a7..0e9dc59 100644 --- a/internal/web_server/middleware.go +++ b/internal/web_server/middleware.go @@ -171,6 +171,29 @@ func (a *App) OnlyAdminAndAbove(c *fiber.Ctx) error { return c.Next() } +func (a *App) RequirePermission(permKey string) fiber.Handler { + return func(c *fiber.Ctx) error { + userRole, ok := c.Locals("role").(domain.Role) + if !ok { + return fiber.NewError(fiber.StatusForbidden, "Role not found in context") + } + + if !a.rbacSvc.HasPermission(string(userRole), permKey) { + userID, _ := c.Locals("user_id").(int64) + a.mongoLoggerSvc.Warn("Permission denied", + zap.Int64("userID", userID), + zap.String("role", string(userRole)), + zap.String("permission", permKey), + zap.Int("status_code", fiber.StatusForbidden), + zap.Time("timestamp", time.Now()), + ) + return fiber.NewError(fiber.StatusForbidden, "You don't have permission to access this resource") + } + + return c.Next() + } +} + func (a *App) OnlyBranchManagerAndAbove(c *fiber.Ctx) error { userID := c.Locals("user_id").(int64) userRole := c.Locals("role").(domain.Role) diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index d68f51f..fd9d066 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -32,6 +32,7 @@ func (a *App) initAppRoutes() { a.issueReportingSvc, a.cloudConvertSvc, a.ratingSvc, + a.rbacSvc, a.JwtConfig, a.cfg, a.mongoLoggerSvc, @@ -65,116 +66,110 @@ func (a *App) initAppRoutes() { }) }) - // Assessment questions + // Assessment questions (public) groupV1.Post("/assessment/questions", h.CreateAssessmentQuestion) groupV1.Get("/assessment/questions", h.ListAssessmentQuestions) groupV1.Get("/assessment/questions/:id", h.GetAssessmentQuestionByID) - // groupV1.Put("/assessment/questions/:id", h.UpdateAssessmentQuestion) - // groupV1.Delete("/assessment/questions/:id", h.DeleteAssessmentQuestion) - - // Course Management Routes // Course Categories - groupV1.Post("/course-management/categories", a.authMiddleware, h.CreateCourseCategory) - groupV1.Get("/course-management/categories", a.authMiddleware, h.GetAllCourseCategories) - groupV1.Get("/course-management/categories/:id", a.authMiddleware, h.GetCourseCategoryByID) - groupV1.Put("/course-management/categories/:id", a.authMiddleware, h.UpdateCourseCategory) - groupV1.Delete("/course-management/categories/:id", a.authMiddleware, h.DeleteCourseCategory) + groupV1.Post("/course-management/categories", a.authMiddleware, a.RequirePermission("course_categories.create"), h.CreateCourseCategory) + groupV1.Get("/course-management/categories", a.authMiddleware, a.RequirePermission("course_categories.list"), h.GetAllCourseCategories) + groupV1.Get("/course-management/categories/:id", a.authMiddleware, a.RequirePermission("course_categories.get"), h.GetCourseCategoryByID) + groupV1.Put("/course-management/categories/:id", a.authMiddleware, a.RequirePermission("course_categories.update"), h.UpdateCourseCategory) + groupV1.Delete("/course-management/categories/:id", a.authMiddleware, a.RequirePermission("course_categories.delete"), h.DeleteCourseCategory) // Courses - groupV1.Post("/course-management/courses", a.authMiddleware, h.CreateCourse) - groupV1.Get("/course-management/courses/:id", a.authMiddleware, h.GetCourseByID) - groupV1.Get("/course-management/categories/:categoryId/courses", a.authMiddleware, h.GetCoursesByCategory) - groupV1.Put("/course-management/courses/:id", a.authMiddleware, h.UpdateCourse) - groupV1.Post("/course-management/courses/:id/thumbnail", a.authMiddleware, h.UploadCourseThumbnail) - groupV1.Delete("/course-management/courses/:id", a.authMiddleware, h.DeleteCourse) + groupV1.Post("/course-management/courses", a.authMiddleware, a.RequirePermission("courses.create"), h.CreateCourse) + groupV1.Get("/course-management/courses/:id", a.authMiddleware, a.RequirePermission("courses.get"), h.GetCourseByID) + groupV1.Get("/course-management/categories/:categoryId/courses", a.authMiddleware, a.RequirePermission("courses.list_by_category"), h.GetCoursesByCategory) + groupV1.Put("/course-management/courses/:id", a.authMiddleware, a.RequirePermission("courses.update"), h.UpdateCourse) + groupV1.Post("/course-management/courses/:id/thumbnail", a.authMiddleware, a.RequirePermission("courses.upload_thumbnail"), h.UploadCourseThumbnail) + groupV1.Delete("/course-management/courses/:id", a.authMiddleware, a.RequirePermission("courses.delete"), h.DeleteCourse) // Sub-courses - groupV1.Post("/course-management/sub-courses", a.authMiddleware, h.CreateSubCourse) - groupV1.Get("/course-management/sub-courses/:id", a.authMiddleware, h.GetSubCourseByID) - groupV1.Get("/course-management/courses/:courseId/sub-courses", a.authMiddleware, h.GetSubCoursesByCourse) - groupV1.Get("/course-management/courses/:courseId/sub-courses/list", a.authMiddleware, h.ListSubCoursesByCourse) - groupV1.Get("/course-management/sub-courses/active", a.authMiddleware, h.ListActiveSubCourses) - groupV1.Patch("/course-management/sub-courses/:id", a.authMiddleware, h.UpdateSubCourse) - groupV1.Post("/course-management/sub-courses/:id/thumbnail", a.authMiddleware, h.UploadSubCourseThumbnail) - groupV1.Put("/course-management/sub-courses/:id/deactivate", a.authMiddleware, h.DeactivateSubCourse) - groupV1.Delete("/course-management/sub-courses/:id", a.authMiddleware, h.DeleteSubCourse) + groupV1.Post("/course-management/sub-courses", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateSubCourse) + groupV1.Get("/course-management/sub-courses/:id", a.authMiddleware, a.RequirePermission("subcourses.get"), h.GetSubCourseByID) + groupV1.Get("/course-management/courses/:courseId/sub-courses", a.authMiddleware, a.RequirePermission("subcourses.list_by_course"), h.GetSubCoursesByCourse) + groupV1.Get("/course-management/courses/:courseId/sub-courses/list", a.authMiddleware, a.RequirePermission("subcourses.list_by_course_list"), h.ListSubCoursesByCourse) + groupV1.Get("/course-management/sub-courses/active", a.authMiddleware, a.RequirePermission("subcourses.list_active"), h.ListActiveSubCourses) + groupV1.Patch("/course-management/sub-courses/:id", a.authMiddleware, a.RequirePermission("subcourses.update"), h.UpdateSubCourse) + groupV1.Post("/course-management/sub-courses/:id/thumbnail", a.authMiddleware, a.RequirePermission("subcourses.upload_thumbnail"), h.UploadSubCourseThumbnail) + groupV1.Put("/course-management/sub-courses/:id/deactivate", a.authMiddleware, a.RequirePermission("subcourses.deactivate"), h.DeactivateSubCourse) + groupV1.Delete("/course-management/sub-courses/:id", a.authMiddleware, a.RequirePermission("subcourses.delete"), h.DeleteSubCourse) // Sub-course Videos - groupV1.Post("/course-management/videos", a.authMiddleware, h.CreateSubCourseVideo) - groupV1.Post("/course-management/videos/vimeo", a.authMiddleware, h.CreateSubCourseVideoWithVimeo) - groupV1.Post("/course-management/videos/upload", a.authMiddleware, h.UploadSubCourseVideo) - groupV1.Post("/course-management/videos/vimeo/import", a.authMiddleware, h.CreateSubCourseVideoFromVimeoID) - groupV1.Get("/course-management/videos/:id", a.authMiddleware, h.GetSubCourseVideoByID) - groupV1.Get("/course-management/sub-courses/:subCourseId/videos", a.authMiddleware, h.GetVideosBySubCourse) - groupV1.Get("/course-management/sub-courses/:subCourseId/videos/published", a.authMiddleware, h.GetPublishedVideosBySubCourse) - groupV1.Put("/course-management/videos/:id/publish", a.authMiddleware, h.PublishSubCourseVideo) - groupV1.Put("/course-management/videos/:id", a.authMiddleware, h.UpdateSubCourseVideo) - groupV1.Delete("/course-management/videos/:id", a.authMiddleware, h.DeleteSubCourseVideo) + groupV1.Post("/course-management/videos", a.authMiddleware, a.RequirePermission("videos.create"), h.CreateSubCourseVideo) + groupV1.Post("/course-management/videos/vimeo", a.authMiddleware, a.RequirePermission("videos.create_vimeo"), h.CreateSubCourseVideoWithVimeo) + groupV1.Post("/course-management/videos/upload", a.authMiddleware, a.RequirePermission("videos.upload"), h.UploadSubCourseVideo) + groupV1.Post("/course-management/videos/vimeo/import", a.authMiddleware, a.RequirePermission("videos.import_vimeo"), h.CreateSubCourseVideoFromVimeoID) + groupV1.Get("/course-management/videos/:id", a.authMiddleware, a.RequirePermission("videos.get"), h.GetSubCourseVideoByID) + groupV1.Get("/course-management/sub-courses/:subCourseId/videos", a.authMiddleware, a.RequirePermission("videos.list_by_subcourse"), h.GetVideosBySubCourse) + groupV1.Get("/course-management/sub-courses/:subCourseId/videos/published", a.authMiddleware, a.RequirePermission("videos.list_published"), h.GetPublishedVideosBySubCourse) + groupV1.Put("/course-management/videos/:id/publish", a.authMiddleware, a.RequirePermission("videos.publish"), h.PublishSubCourseVideo) + groupV1.Put("/course-management/videos/:id", a.authMiddleware, a.RequirePermission("videos.update"), h.UpdateSubCourseVideo) + groupV1.Delete("/course-management/videos/:id", a.authMiddleware, a.RequirePermission("videos.delete"), h.DeleteSubCourseVideo) // Learning Tree - groupV1.Get("/course-management/learning-tree", a.authMiddleware, h.GetFullLearningTree) + groupV1.Get("/course-management/learning-tree", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetFullLearningTree) - // Unified Questions System // Questions - groupV1.Post("/questions", a.authMiddleware, h.CreateQuestion) - groupV1.Get("/questions", a.authMiddleware, h.ListQuestions) - groupV1.Get("/questions/search", a.authMiddleware, h.SearchQuestions) - groupV1.Get("/questions/:id", a.authMiddleware, h.GetQuestionByID) - groupV1.Put("/questions/:id", a.authMiddleware, h.UpdateQuestion) - groupV1.Delete("/questions/:id", a.authMiddleware, h.DeleteQuestion) + groupV1.Post("/questions", a.authMiddleware, a.RequirePermission("questions.create"), h.CreateQuestion) + groupV1.Get("/questions", a.authMiddleware, a.RequirePermission("questions.list"), h.ListQuestions) + groupV1.Get("/questions/search", a.authMiddleware, a.RequirePermission("questions.search"), h.SearchQuestions) + groupV1.Get("/questions/:id", a.authMiddleware, a.RequirePermission("questions.get"), h.GetQuestionByID) + groupV1.Put("/questions/:id", a.authMiddleware, a.RequirePermission("questions.update"), h.UpdateQuestion) + groupV1.Delete("/questions/:id", a.authMiddleware, a.RequirePermission("questions.delete"), h.DeleteQuestion) - // Question Sets (replaces Practices for question grouping) - groupV1.Post("/question-sets", a.authMiddleware, h.CreateQuestionSet) - groupV1.Get("/question-sets", a.authMiddleware, h.GetQuestionSetsByType) - groupV1.Get("/question-sets/by-owner", a.authMiddleware, h.GetQuestionSetsByOwner) - groupV1.Get("/question-sets/:id", a.authMiddleware, h.GetQuestionSetByID) - groupV1.Put("/question-sets/:id", a.authMiddleware, h.UpdateQuestionSet) - groupV1.Delete("/question-sets/:id", a.authMiddleware, h.DeleteQuestionSet) + // Question Sets + groupV1.Post("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.create"), h.CreateQuestionSet) + groupV1.Get("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetQuestionSetsByType) + groupV1.Get("/question-sets/by-owner", a.authMiddleware, a.RequirePermission("question_sets.list_by_owner"), h.GetQuestionSetsByOwner) + groupV1.Get("/question-sets/:id", a.authMiddleware, a.RequirePermission("question_sets.get"), h.GetQuestionSetByID) + groupV1.Put("/question-sets/:id", a.authMiddleware, a.RequirePermission("question_sets.update"), h.UpdateQuestionSet) + groupV1.Delete("/question-sets/:id", a.authMiddleware, a.RequirePermission("question_sets.delete"), h.DeleteQuestionSet) - // Question Set Items (questions within sets) - groupV1.Post("/question-sets/:setId/questions", a.authMiddleware, h.AddQuestionToSet) - groupV1.Get("/question-sets/:setId/questions", a.authMiddleware, h.GetQuestionsInSet) - groupV1.Delete("/question-sets/:setId/questions/:questionId", a.authMiddleware, h.RemoveQuestionFromSet) - groupV1.Put("/question-sets/:setId/questions/:questionId/order", a.authMiddleware, h.UpdateQuestionOrderInSet) + // 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.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) - // Question Set User Personas (users as personas within practices) - groupV1.Get("/question-sets/:setId/personas", a.authMiddleware, h.GetUserPersonasByQuestionSet) - groupV1.Post("/question-sets/:setId/personas", a.authMiddleware, h.AddUserPersonaToQuestionSet) - groupV1.Delete("/question-sets/:setId/personas/:userId", a.authMiddleware, h.RemoveUserPersonaFromQuestionSet) + // Question Set Personas + groupV1.Get("/question-sets/:setId/personas", a.authMiddleware, a.RequirePermission("question_set_personas.list"), h.GetUserPersonasByQuestionSet) + groupV1.Post("/question-sets/:setId/personas", a.authMiddleware, a.RequirePermission("question_set_personas.add"), h.AddUserPersonaToQuestionSet) + groupV1.Delete("/question-sets/:setId/personas/:userId", a.authMiddleware, a.RequirePermission("question_set_personas.remove"), h.RemoveUserPersonaFromQuestionSet) - // Subscription Plans (admin) - groupV1.Post("/subscription-plans", a.authMiddleware, h.CreateSubscriptionPlan) + // Subscription Plans + groupV1.Post("/subscription-plans", a.authMiddleware, a.RequirePermission("subscription_plans.create"), h.CreateSubscriptionPlan) groupV1.Get("/subscription-plans", h.ListSubscriptionPlans) groupV1.Get("/subscription-plans/:id", h.GetSubscriptionPlan) - groupV1.Put("/subscription-plans/:id", a.authMiddleware, h.UpdateSubscriptionPlan) - groupV1.Delete("/subscription-plans/:id", a.authMiddleware, h.DeleteSubscriptionPlan) + groupV1.Put("/subscription-plans/:id", a.authMiddleware, a.RequirePermission("subscription_plans.update"), h.UpdateSubscriptionPlan) + groupV1.Delete("/subscription-plans/:id", a.authMiddleware, a.RequirePermission("subscription_plans.delete"), h.DeleteSubscriptionPlan) // User Subscriptions - groupV1.Post("/subscriptions", a.authMiddleware, h.Subscribe) // Admin only - creates subscription without payment - groupV1.Post("/subscriptions/checkout", a.authMiddleware, h.SubscribeWithPayment) // User - initiates payment for subscription - groupV1.Get("/subscriptions/me", a.authMiddleware, h.GetMySubscription) - groupV1.Get("/subscriptions/history", a.authMiddleware, h.GetMySubscriptionHistory) - groupV1.Get("/subscriptions/status", a.authMiddleware, h.CheckSubscriptionStatus) - groupV1.Post("/subscriptions/:id/cancel", a.authMiddleware, h.CancelSubscription) - groupV1.Put("/subscriptions/:id/auto-renew", a.authMiddleware, h.SetAutoRenew) + groupV1.Post("/subscriptions", a.authMiddleware, a.RequirePermission("subscriptions.create"), h.Subscribe) + groupV1.Post("/subscriptions/checkout", a.authMiddleware, a.RequirePermission("subscriptions.checkout"), h.SubscribeWithPayment) + groupV1.Get("/subscriptions/me", a.authMiddleware, a.RequirePermission("subscriptions.get_mine"), h.GetMySubscription) + groupV1.Get("/subscriptions/history", a.authMiddleware, a.RequirePermission("subscriptions.history"), h.GetMySubscriptionHistory) + groupV1.Get("/subscriptions/status", a.authMiddleware, a.RequirePermission("subscriptions.status"), h.CheckSubscriptionStatus) + groupV1.Post("/subscriptions/:id/cancel", a.authMiddleware, a.RequirePermission("subscriptions.cancel"), h.CancelSubscription) + groupV1.Put("/subscriptions/:id/auto-renew", a.authMiddleware, a.RequirePermission("subscriptions.set_auto_renew"), h.SetAutoRenew) - // Payments (ArifPay Integration) - groupV1.Post("/payments/subscribe", a.authMiddleware, h.InitiateSubscriptionPayment) - groupV1.Get("/payments/verify/:session_id", a.authMiddleware, h.VerifyPayment) - groupV1.Get("/payments", a.authMiddleware, h.GetMyPayments) - groupV1.Get("/payments/:id", a.authMiddleware, h.GetPaymentByID) - groupV1.Post("/payments/:id/cancel", a.authMiddleware, h.CancelPayment) + // Payments (ArifPay) + groupV1.Post("/payments/subscribe", a.authMiddleware, a.RequirePermission("payments.initiate"), h.InitiateSubscriptionPayment) + groupV1.Get("/payments/verify/:session_id", a.authMiddleware, a.RequirePermission("payments.verify"), h.VerifyPayment) + groupV1.Get("/payments", a.authMiddleware, a.RequirePermission("payments.list_mine"), h.GetMyPayments) + 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.Get("/payments/methods", h.GetArifpayPaymentMethods) - // Webhook endpoint (no auth - called by ArifPay) groupV1.Post("/payments/webhook", h.HandleArifpayWebhook) - // Direct Payments (OTP-based - Telebirr, CBE, Amole, HelloCash, etc.) - groupV1.Post("/payments/direct", a.authMiddleware, h.InitiateDirectPayment) - groupV1.Post("/payments/direct/verify-otp", a.authMiddleware, h.VerifyDirectPaymentOTP) + // Direct Payments + groupV1.Post("/payments/direct", a.authMiddleware, a.RequirePermission("payments.direct_initiate"), h.InitiateDirectPayment) + groupV1.Post("/payments/direct/verify-otp", a.authMiddleware, a.RequirePermission("payments.direct_verify_otp"), h.VerifyDirectPaymentOTP) groupV1.Get("/payments/direct/methods", h.GetDirectPaymentMethods) - // Auth Routes + // Auth Routes (public) groupV1.Post("/auth/google/android", h.GoogleAndroidLogin) groupV1.Get("/auth/google/login", h.GoogleLogin) groupV1.Get("/auth/google/callback", h.GoogleCallback) @@ -182,9 +177,8 @@ func (a *App) initAppRoutes() { groupV1.Post("/auth/admin-login", h.LoginAdmin) groupV1.Post("/auth/super-login", h.LoginSuper) groupV1.Post("/auth/refresh", h.RefreshToken) - // Upload profile picture - groupV1.Post("/user/:id/profile-picture", a.authMiddleware, h.UploadProfilePicture) - groupV1.Post("/auth/logout", a.authMiddleware, h.LogOutuser) + groupV1.Post("/user/:id/profile-picture", a.authMiddleware, a.RequirePermission("users.upload_profile_picture"), h.UploadProfilePicture) + groupV1.Post("/auth/logout", a.authMiddleware, a.RequirePermission("auth.logout"), h.LogOutuser) groupV1.Get("/auth/test", a.authMiddleware, func(c *fiber.Ctx) error { userID, ok := c.Locals("user_id").(int64) if !ok { @@ -211,22 +205,12 @@ func (a *App) initAppRoutes() { return c.SendString("Test endpoint") }) - //Arifpay - // groupV1.Post("/arifpay/checkout", a.authMiddleware, h.CreateCheckoutSessionHandler) - // groupV1.Post("/arifpay/checkout/cancel/:sessionId", a.authMiddleware, h.CancelCheckoutSessionHandler) - // groupV1.Post("/api/v1/arifpay/c2b-webhook", h.HandleArifpayC2BWebhook) - // groupV1.Post("/api/v1/arifpay/b2c-webhook", h.HandleArifpayB2CWebhook) - // groupV1.Post("/arifpay/b2c/transfer", a.authMiddleware, h.ExecuteArifpayB2CTransfer) - // groupV1.Post("/arifpay/transaction-id/verify-transaction", a.authMiddleware, h.ArifpayVerifyByTransactionIDHandler) - // groupV1.Get("/arifpay/session-id/verify-transaction/:session_id", a.authMiddleware, h.ArifpayVerifyBySessionIDHandler) - // groupV1.Get("/arifpay/payment-methods", a.authMiddleware, h.GetArifpayPaymentMethodsHandler - // User Routes - groupV1.Get("/user/:user_id/is-profile-completed", a.authMiddleware, h.CheckProfileCompleted) - groupV1.Get("/users", a.authMiddleware, h.GetAllUsers) - groupV1.Put("/user", a.authMiddleware, h.UpdateUser) + groupV1.Get("/user/:user_id/is-profile-completed", a.authMiddleware, a.RequirePermission("users.profile_completed"), h.CheckProfileCompleted) + groupV1.Get("/users", a.authMiddleware, a.RequirePermission("users.list"), h.GetAllUsers) + groupV1.Put("/user", a.authMiddleware, a.RequirePermission("users.update_self"), h.UpdateUser) + groupV1.Patch("/user/status", a.authMiddleware, a.RequirePermission("users.update_status"), h.UpdateUserStatus) groupV1.Put("/user/knowledge-level", h.UpdateUserKnowledgeLevel) - // groupV1.Get("/user/:user_name/is-unique", h.CheckUserNameUnique) groupV1.Get("/user/:user_name/is-pending", h.CheckUserPending) groupV1.Post("/user/resetPassword", h.ResetPassword) groupV1.Post("/user/sendResetCode", h.SendResetCode) @@ -235,93 +219,123 @@ func (a *App) initAppRoutes() { groupV1.Post("/user/register", h.RegisterUser) groupV1.Post("/user/sendRegisterCode", h.SendRegisterCode) groupV1.Post("/user/checkPhoneEmailExist", h.CheckPhoneEmailExist) + groupV1.Get("/user/admin-profile", a.authMiddleware, a.RequirePermission("users.admin_profile"), h.AdminProfile) + groupV1.Get("/user/user-profile", a.authMiddleware, a.RequirePermission("users.user_profile"), h.GetUserProfile) + groupV1.Get("/user/single/:id", a.authMiddleware, a.RequirePermission("users.get"), h.GetUserByID) + groupV1.Delete("/user/delete/:id", a.authMiddleware, a.RequirePermission("users.delete"), h.DeleteUser) + groupV1.Post("/user/search", a.authMiddleware, a.RequirePermission("users.search"), h.SearchUserByNameOrPhone) - groupV1.Get("/user/admin-profile", a.authMiddleware, h.AdminProfile) + // Admin management + groupV1.Get("/admin", a.authMiddleware, a.RequirePermission("admins.list"), h.GetAllAdmins) + groupV1.Get("/admin/:id", a.authMiddleware, a.RequirePermission("admins.get"), h.GetAdminByID) + groupV1.Post("/admin", a.authMiddleware, a.RequirePermission("admins.create"), h.CreateAdmin) + groupV1.Put("/admin/:id", a.authMiddleware, a.RequirePermission("admins.update"), h.UpdateAdmin) - groupV1.Get("/user/user-profile", a.authMiddleware, h.GetUserProfile) + // Logs + groupV1.Get("/logs", a.authMiddleware, a.RequirePermission("logs.list"), handlers.GetLogsHandler(context.Background())) + groupV1.Get("/activity-logs", a.authMiddleware, a.RequirePermission("activity_logs.list"), h.GetActivityLogs) + groupV1.Get("/activity-logs/:id", a.authMiddleware, a.RequirePermission("activity_logs.get"), h.GetActivityLogByID) - groupV1.Get("/user/single/:id", a.authMiddleware, h.GetUserByID) - groupV1.Delete("/user/delete/:id", a.authMiddleware, h.DeleteUser) - groupV1.Post("/user/search", a.authMiddleware, h.SearchUserByNameOrPhone) - - groupV1.Get("/admin", a.authMiddleware, a.SuperAdminOnly, h.GetAllAdmins) - groupV1.Get("/admin/:id", a.authMiddleware, a.SuperAdminOnly, h.GetAdminByID) - groupV1.Post("/admin", a.authMiddleware, a.SuperAdminOnly, h.CreateAdmin) - groupV1.Put("/admin/:id", a.authMiddleware, a.SuperAdminOnly, h.UpdateAdmin) - - //mongoDB logs - groupV1.Get("/logs", a.authMiddleware, a.OnlyAdminAndAbove, handlers.GetLogsHandler(context.Background())) - - // Activity Logs - groupV1.Get("/activity-logs", a.authMiddleware, a.OnlyAdminAndAbove, h.GetActivityLogs) - groupV1.Get("/activity-logs/:id", a.authMiddleware, a.OnlyAdminAndAbove, h.GetActivityLogByID) - - // Notification Routes + // Notifications groupV1.Post("/sendSMS", h.SendSingleAfroSMS) groupV1.Get("/ws/connect", a.WebsocketAuthMiddleware, h.ConnectSocket) - groupV1.Get("/notifications", a.authMiddleware, h.GetUserNotification) - groupV1.Get("/notifications/all", a.authMiddleware, h.GetAllNotifications) - groupV1.Patch("/notifications/:id/read", a.authMiddleware, h.MarkNotificationAsRead) - groupV1.Post("/notifications/mark-all-read", a.authMiddleware, h.MarkAllNotificationsAsRead) - groupV1.Patch("/notifications/:id/unread", a.authMiddleware, h.MarkNotificationAsUnread) - groupV1.Post("/notifications/mark-all-unread", a.authMiddleware, h.MarkAllNotificationsAsUnread) - groupV1.Delete("/notifications", a.authMiddleware, h.DeleteUserNotifications) - groupV1.Get("/notifications/unread", a.authMiddleware, h.CountUnreadNotifications) - groupV1.Post("/notifications/create", a.authMiddleware, h.CreateAndSendNotification) + groupV1.Get("/notifications", a.authMiddleware, a.RequirePermission("notifications.list_mine"), h.GetUserNotification) + groupV1.Get("/notifications/all", a.authMiddleware, a.RequirePermission("notifications.list_all"), h.GetAllNotifications) + groupV1.Patch("/notifications/:id/read", a.authMiddleware, a.RequirePermission("notifications.mark_read"), h.MarkNotificationAsRead) + groupV1.Post("/notifications/mark-all-read", a.authMiddleware, a.RequirePermission("notifications.mark_all_read"), h.MarkAllNotificationsAsRead) + groupV1.Patch("/notifications/:id/unread", a.authMiddleware, a.RequirePermission("notifications.mark_unread"), h.MarkNotificationAsUnread) + groupV1.Post("/notifications/mark-all-unread", a.authMiddleware, a.RequirePermission("notifications.mark_all_unread"), h.MarkAllNotificationsAsUnread) + groupV1.Delete("/notifications", a.authMiddleware, a.RequirePermission("notifications.delete_mine"), h.DeleteUserNotifications) + groupV1.Get("/notifications/unread", a.authMiddleware, a.RequirePermission("notifications.count_unread"), h.CountUnreadNotifications) + groupV1.Post("/notifications/create", a.authMiddleware, a.RequirePermission("notifications.create"), h.CreateAndSendNotification) - // Issue Reporting Routes - groupV1.Post("/issues", a.authMiddleware, h.CreateIssue) - groupV1.Get("/issues/me", a.authMiddleware, h.GetMyIssues) - groupV1.Get("/issues/user/:user_id", a.authMiddleware, a.OnlyAdminAndAbove, h.GetUserIssues) - groupV1.Get("/issues", a.authMiddleware, a.OnlyAdminAndAbove, h.GetAllIssues) - groupV1.Get("/issues/:id", a.authMiddleware, a.OnlyAdminAndAbove, h.GetIssueByID) - groupV1.Patch("/issues/:id/status", a.authMiddleware, a.OnlyAdminAndAbove, h.UpdateIssueStatus) - groupV1.Delete("/issues/:id", a.authMiddleware, a.OnlyAdminAndAbove, h.DeleteIssue) + // Issues + groupV1.Post("/issues", a.authMiddleware, a.RequirePermission("issues.create"), h.CreateIssue) + groupV1.Get("/issues/me", a.authMiddleware, a.RequirePermission("issues.list_mine"), h.GetMyIssues) + groupV1.Get("/issues/user/:user_id", a.authMiddleware, a.RequirePermission("issues.list_by_user"), h.GetUserIssues) + groupV1.Get("/issues", a.authMiddleware, a.RequirePermission("issues.list_all"), h.GetAllIssues) + groupV1.Get("/issues/:id", a.authMiddleware, a.RequirePermission("issues.get"), h.GetIssueByID) + groupV1.Patch("/issues/:id/status", a.authMiddleware, a.RequirePermission("issues.update_status"), h.UpdateIssueStatus) + groupV1.Delete("/issues/:id", a.authMiddleware, a.RequirePermission("issues.delete"), h.DeleteIssue) - // Device Token Registration - groupV1.Post("/devices/register", a.authMiddleware, h.RegisterDeviceToken) - groupV1.Post("/devices/unregister", a.authMiddleware, h.UnregisterDeviceToken) + // Devices + groupV1.Post("/devices/register", a.authMiddleware, a.RequirePermission("devices.register"), h.RegisterDeviceToken) + groupV1.Post("/devices/unregister", a.authMiddleware, a.RequirePermission("devices.unregister"), h.UnregisterDeviceToken) - // Test Push Notification (for development/testing) - groupV1.Post("/notifications/test-push", a.authMiddleware, h.SendTestPushNotification) + // Push Notifications + groupV1.Post("/notifications/test-push", a.authMiddleware, a.RequirePermission("notifications.test_push"), h.SendTestPushNotification) + groupV1.Post("/notifications/bulk-push", a.authMiddleware, a.RequirePermission("notifications.bulk_push"), h.SendBulkPushNotification) + groupV1.Post("/notifications/bulk-sms", a.authMiddleware, a.RequirePermission("notifications.bulk_sms"), h.SendBulkSMS) + groupV1.Post("/notifications/send-email", a.authMiddleware, a.RequirePermission("notifications.send_email"), h.SendSingleEmail) + groupV1.Post("/notifications/bulk-email", a.authMiddleware, a.RequirePermission("notifications.bulk_email"), h.SendBulkEmail) + + // Scheduled Notifications + groupV1.Get("/notifications/scheduled", a.authMiddleware, a.RequirePermission("notifications_scheduled.list"), h.ListScheduledNotifications) + groupV1.Get("/notifications/scheduled/:id", a.authMiddleware, a.RequirePermission("notifications_scheduled.get"), h.GetScheduledNotification) + groupV1.Post("/notifications/scheduled/:id/cancel", a.authMiddleware, a.RequirePermission("notifications_scheduled.cancel"), h.CancelScheduledNotification) // Settings - groupV1.Get("/settings", a.authMiddleware, a.SuperAdminOnly, h.GetGlobalSettingList) - groupV1.Get("/settings/:key", a.authMiddleware, a.SuperAdminOnly, h.GetGlobalSettingByKey) - groupV1.Put("/settings", a.authMiddleware, a.SuperAdminOnly, h.UpdateGlobalSettingList) + groupV1.Get("/settings", a.authMiddleware, a.RequirePermission("settings.list"), h.GetGlobalSettingList) + groupV1.Get("/settings/:key", a.authMiddleware, a.RequirePermission("settings.get"), h.GetGlobalSettingByKey) + groupV1.Put("/settings", a.authMiddleware, a.RequirePermission("settings.update"), h.UpdateGlobalSettingList) - // Analytics Routes - groupV1.Get("/analytics/dashboard", a.authMiddleware, a.OnlyAdminAndAbove, h.GetAnalyticsDashboard) + // Analytics + groupV1.Get("/analytics/dashboard", a.authMiddleware, a.RequirePermission("analytics.dashboard"), h.GetAnalyticsDashboard) - // Vimeo Video Hosting Routes + // Vimeo vimeoGroup := groupV1.Group("/vimeo") - vimeoGroup.Get("/videos/:video_id", a.authMiddleware, h.GetVimeoVideo) - vimeoGroup.Get("/videos/:video_id/embed", a.authMiddleware, h.GetEmbedCode) - vimeoGroup.Get("/videos/:video_id/status", a.authMiddleware, h.GetTranscodeStatus) - vimeoGroup.Delete("/videos/:video_id", a.authMiddleware, h.DeleteVimeoVideo) - vimeoGroup.Post("/uploads/pull", a.authMiddleware, h.CreatePullUpload) - vimeoGroup.Post("/uploads/tus", a.authMiddleware, h.CreateTusUpload) + vimeoGroup.Get("/videos/:video_id", a.authMiddleware, a.RequirePermission("vimeo.videos.get"), h.GetVimeoVideo) + vimeoGroup.Get("/videos/:video_id/embed", a.authMiddleware, a.RequirePermission("vimeo.videos.embed"), h.GetEmbedCode) + vimeoGroup.Get("/videos/:video_id/status", a.authMiddleware, a.RequirePermission("vimeo.videos.status"), h.GetTranscodeStatus) + vimeoGroup.Delete("/videos/:video_id", a.authMiddleware, a.RequirePermission("vimeo.videos.delete"), h.DeleteVimeoVideo) + vimeoGroup.Post("/uploads/pull", a.authMiddleware, a.RequirePermission("vimeo.uploads.pull"), h.CreatePullUpload) + vimeoGroup.Post("/uploads/tus", a.authMiddleware, a.RequirePermission("vimeo.uploads.tus"), h.CreateTusUpload) vimeoGroup.Get("/oembed", h.GetOEmbed) - // Team Management Routes (Internal HR/Team) + // Team Management teamGroup := groupV1.Group("/team") - teamGroup.Post("/login", h.TeamMemberLogin) // Team member authentication - teamGroup.Get("/me", a.authMiddleware, h.GetMyTeamProfile) // Get own profile - teamGroup.Get("/stats", a.authMiddleware, a.OnlyAdminAndAbove, h.GetTeamMemberStats) // Team statistics - teamGroup.Get("/members", a.authMiddleware, a.OnlyAdminAndAbove, h.GetAllTeamMembers) // List all team members - teamGroup.Post("/members", a.authMiddleware, a.OnlyAdminAndAbove, h.CreateTeamMember) // Create team member - teamGroup.Get("/members/:id", a.authMiddleware, h.GetTeamMember) // Get team member by ID - teamGroup.Put("/members/:id", a.authMiddleware, a.OnlyAdminAndAbove, h.UpdateTeamMember) // Update team member - teamGroup.Patch("/members/:id/status", a.authMiddleware, a.OnlyAdminAndAbove, h.UpdateTeamMemberStatus) // Update status - teamGroup.Delete("/members/:id", a.authMiddleware, a.SuperAdminOnly, h.DeleteTeamMember) // Delete team member - teamGroup.Post("/members/:id/change-password", a.authMiddleware, h.ChangeTeamMemberPassword) // Change password + teamGroup.Post("/login", h.TeamMemberLogin) + teamGroup.Get("/me", a.authMiddleware, a.RequirePermission("team.profile.get_mine"), h.GetMyTeamProfile) + teamGroup.Get("/stats", a.authMiddleware, a.RequirePermission("team.stats"), h.GetTeamMemberStats) + teamGroup.Get("/members", a.authMiddleware, a.RequirePermission("team.members.list"), h.GetAllTeamMembers) + teamGroup.Post("/members", a.authMiddleware, a.RequirePermission("team.members.create"), h.CreateTeamMember) + teamGroup.Get("/members/:id", a.authMiddleware, a.RequirePermission("team.members.get"), h.GetTeamMember) + teamGroup.Put("/members/:id", a.authMiddleware, a.RequirePermission("team.members.update"), h.UpdateTeamMember) + teamGroup.Patch("/members/:id/status", a.authMiddleware, a.RequirePermission("team.members.update_status"), h.UpdateTeamMemberStatus) + teamGroup.Delete("/members/:id", a.authMiddleware, a.RequirePermission("team.members.delete"), h.DeleteTeamMember) + teamGroup.Post("/members/:id/change-password", a.authMiddleware, a.RequirePermission("team.members.change_password"), h.ChangeTeamMemberPassword) + + // Sub-course Prerequisites + groupV1.Post("/course-management/sub-courses/:id/prerequisites", a.authMiddleware, a.RequirePermission("subcourse_prerequisites.add"), h.AddSubCoursePrerequisite) + groupV1.Get("/course-management/sub-courses/:id/prerequisites", a.authMiddleware, a.RequirePermission("subcourse_prerequisites.list"), h.GetSubCoursePrerequisites) + groupV1.Delete("/course-management/sub-courses/:id/prerequisites/:prerequisiteId", a.authMiddleware, a.RequirePermission("subcourse_prerequisites.remove"), h.RemoveSubCoursePrerequisite) + + // User Progression + groupV1.Post("/progress/sub-courses/:id/start", a.authMiddleware, a.RequirePermission("progress.start"), h.StartSubCourse) + groupV1.Put("/progress/sub-courses/:id", a.authMiddleware, a.RequirePermission("progress.update"), h.UpdateSubCourseProgress) + groupV1.Post("/progress/sub-courses/:id/complete", a.authMiddleware, a.RequirePermission("progress.complete"), h.CompleteSubCourse) + groupV1.Get("/progress/sub-courses/:id/access", a.authMiddleware, a.RequirePermission("progress.check_access"), h.CheckSubCourseAccess) + groupV1.Get("/progress/courses/:courseId", a.authMiddleware, a.RequirePermission("progress.get_course"), h.GetUserCourseProgress) // Ratings - groupV1.Post("/ratings", a.authMiddleware, h.SubmitRating) - groupV1.Get("/ratings", a.authMiddleware, h.GetRatingsByTarget) - groupV1.Get("/ratings/summary", a.authMiddleware, h.GetRatingSummary) - groupV1.Get("/ratings/me", a.authMiddleware, h.GetMyRating) - groupV1.Get("/ratings/me/all", a.authMiddleware, h.GetMyRatings) - groupV1.Delete("/ratings/:id", a.authMiddleware, h.DeleteRating) + groupV1.Post("/ratings", a.authMiddleware, a.RequirePermission("ratings.submit"), h.SubmitRating) + groupV1.Get("/ratings", a.authMiddleware, a.RequirePermission("ratings.list_by_target"), h.GetRatingsByTarget) + groupV1.Get("/ratings/summary", a.authMiddleware, a.RequirePermission("ratings.summary"), h.GetRatingSummary) + groupV1.Get("/ratings/me", a.authMiddleware, a.RequirePermission("ratings.get_mine"), h.GetMyRating) + groupV1.Get("/ratings/me/all", a.authMiddleware, a.RequirePermission("ratings.list_mine"), h.GetMyRatings) + groupV1.Delete("/ratings/:id", a.authMiddleware, a.RequirePermission("ratings.delete"), h.DeleteRating) + + // RBAC Management + rbacGroup := groupV1.Group("/rbac", a.authMiddleware) + rbacGroup.Get("/roles", a.RequirePermission("rbac.roles.list"), h.ListRoles) + rbacGroup.Get("/roles/:id", a.RequirePermission("rbac.roles.get"), h.GetRoleByID) + rbacGroup.Post("/roles", a.RequirePermission("rbac.roles.create"), h.CreateRole) + rbacGroup.Put("/roles/:id", a.RequirePermission("rbac.roles.update"), h.UpdateRole) + rbacGroup.Delete("/roles/:id", a.RequirePermission("rbac.roles.delete"), h.DeleteRole) + rbacGroup.Put("/roles/:id/permissions", a.RequirePermission("rbac.roles.set_permissions"), h.SetRolePermissions) + rbacGroup.Get("/roles/:id/permissions", a.RequirePermission("rbac.roles.get_permissions"), h.GetRolePermissions) + rbacGroup.Get("/permissions", a.RequirePermission("rbac.permissions.list"), h.ListPermissions) + rbacGroup.Get("/permissions/groups", a.RequirePermission("rbac.permissions.groups"), h.ListPermissionGroups) + rbacGroup.Post("/permissions/sync", a.RequirePermission("rbac.permissions.sync"), h.SyncPermissions) }