Compare commits

...

7 Commits

61 changed files with 6173 additions and 435 deletions

View File

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

View File

@ -27,7 +27,7 @@ BEGIN
filled_count := filled_count + 1;
END IF;
-- Check country
--- Check country
IF NULLIF(TRIM(NEW.country), '') IS NOT NULL THEN
filled_count := filled_count + 1;
END IF;
@ -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

View File

@ -0,0 +1 @@
ALTER TABLE courses DROP COLUMN IF EXISTS intro_video_url;

View File

@ -0,0 +1 @@
ALTER TABLE courses ADD COLUMN IF NOT EXISTS intro_video_url TEXT;

View File

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS user_sub_course_progress;
DROP TABLE IF EXISTS sub_course_prerequisites;

View File

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

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS scheduled_notifications;

View File

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

View File

@ -0,0 +1,3 @@
DROP TABLE IF EXISTS role_permissions;
DROP TABLE IF EXISTS permissions;
DROP TABLE IF EXISTS roles;

View File

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

View File

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

View File

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

79
db/query/rbac.sql Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

397
gen/db/rbac.sql.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -111,7 +111,7 @@ type Config struct {
AFRO_SMS_API_KEY string
AFRO_SMS_SENDER_NAME string
AFRO_SMS_RECEIVER_PHONE_NUMBER string
ADRO_SMS_HOST_URL string
AFRO_SMS_HOST_URL string
CHAPA_WEBHOOK_SECRET string
CHAPA_TRANSFER_TYPE string
CHAPA_PAYMENT_TYPE string
@ -367,14 +367,14 @@ func (c *Config) loadEnv() error {
c.AFRO_SMS_SENDER_NAME = os.Getenv("AFRO_SMS_SENDER_NAME")
if c.AFRO_SMS_SENDER_NAME == "" {
c.AFRO_SMS_SENDER_NAME = "Yimaru"
c.AFRO_SMS_SENDER_NAME = "Yimaru Acad"
}
c.AFRO_SMS_RECEIVER_PHONE_NUMBER = os.Getenv("AFRO_SMS_RECEIVER_PHONE_NUMBER")
c.ADRO_SMS_HOST_URL = os.Getenv("ADRO_SMS_HOST_URL")
if c.ADRO_SMS_HOST_URL == "" {
c.ADRO_SMS_HOST_URL = "https://api.afromessage.com"
c.AFRO_SMS_HOST_URL = os.Getenv("AFRO_SMS_HOST_URL")
if c.AFRO_SMS_HOST_URL == "" {
c.AFRO_SMS_HOST_URL = "https://api.afromessage.com"
}
//Atlas

View File

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

View File

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

View File

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

63
internal/domain/rbac.go Normal file
View File

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

View File

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

View File

@ -123,7 +123,8 @@ type UserProfileResponse struct {
}
type UserFilter struct {
Role string
Role string
Status string
Page int64
PageSize int64

View File

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

View File

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

25
internal/ports/rbac.go Normal file
View File

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

View File

@ -52,6 +52,7 @@ type UserStore interface {
GetAllUsers(
ctx context.Context,
role *string,
status *string,
query *string,
createdBefore, createdAfter *time.Time,
limit, offset int32,

View File

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

View File

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

View File

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

203
internal/repository/rbac.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -38,7 +38,7 @@ func (s *Service) SendSMS(ctx context.Context, receiverPhone, message string) er
func (s *Service) SendAfroMessageSMS(ctx context.Context, receiverPhone, message string) error {
apiKey := s.config.AFRO_SMS_API_KEY
senderName := s.config.AFRO_SMS_SENDER_NAME
hostURL := s.config.ADRO_SMS_HOST_URL
hostURL := s.config.AFRO_SMS_HOST_URL
endpoint := "/api/send"
// API endpoint has been updated

View File

@ -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())
@ -167,7 +169,7 @@ func (s *Service) SendAfroMessageSMSTemp(
}
// Construct full URL
reqURL := fmt.Sprintf("%s?%s", baseURL, params.Encode())
reqURL := fmt.Sprintf("%s/api/send?%s", baseURL, params.Encode())
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
@ -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()
@ -382,7 +413,7 @@ func (s *Service) DisconnectWebSocket(recipientID int64) {
// apiKey := s.config.AFRO_SMS_API_KEY
// senderName := s.config.AFRO_SMS_SENDER_NAME
// receiverPhone := s.config.AFRO_SMS_RECEIVER_PHONE_NUMBER
// hostURL := s.config.ADRO_SMS_HOST_URL
// hostURL := s.config.AFRO_SMS_HOST_URL
// endpoint := "/api/send"
// request := afro.GetRequest(apiKey, endpoint, hostURL)
@ -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)
// }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 {
@ -595,10 +618,11 @@ func (h *Handler) SendSingleAfroSMS(c *fiber.Ctx) error {
}
// Send SMS via service
if err := h.notificationSvc.SendAfroMessageSMS(
if err := h.notificationSvc.SendAfroMessageSMSTemp(
c.Context(),
req.Recipient,
req.Message,
nil,
); err != nil {
h.mongoLoggerSvc.Error("Failed to send AfroMessage SMS",
@ -684,25 +708,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 +735,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 +766,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 +781,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,
},
})
}

View File

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

View File

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

View File

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

View File

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

View File

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