bulk notification fix + custom role and permission feature implementation + activity log and user list filters
This commit is contained in:
parent
b5f5d80057
commit
809ab90d30
15
cmd/main.go
15
cmd/main.go
|
|
@ -26,6 +26,7 @@ import (
|
|||
activitylogservice "Yimaru-Backend/internal/services/activity_log"
|
||||
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
|
||||
ratingsservice "Yimaru-Backend/internal/services/ratings"
|
||||
rbacservice "Yimaru-Backend/internal/services/rbac"
|
||||
vimeoservice "Yimaru-Backend/internal/services/vimeo"
|
||||
"context"
|
||||
|
||||
|
|
@ -361,6 +362,7 @@ func main() {
|
|||
courseSvc := course_management.NewService(
|
||||
repository.NewUserStore(store),
|
||||
repository.NewCourseStore(store),
|
||||
repository.NewProgressionStore(store),
|
||||
notificationSvc,
|
||||
cfg,
|
||||
)
|
||||
|
|
@ -407,6 +409,18 @@ func main() {
|
|||
// Ratings service
|
||||
ratingSvc := ratingsservice.NewService(repository.NewRatingStore(store))
|
||||
|
||||
// RBAC service
|
||||
rbacSvc := rbacservice.NewService(repository.NewRBACStore(store), logger)
|
||||
if err := rbacSvc.SeedPermissions(context.Background()); err != nil {
|
||||
log.Fatalf("failed to seed RBAC permissions: %v", err)
|
||||
}
|
||||
if err := rbacSvc.SeedDefaultRolePermissions(context.Background()); err != nil {
|
||||
log.Fatalf("failed to seed default role permissions: %v", err)
|
||||
}
|
||||
if err := rbacSvc.Reload(context.Background()); err != nil {
|
||||
log.Fatalf("failed to load RBAC cache: %v", err)
|
||||
}
|
||||
|
||||
// Initialize and start HTTP server
|
||||
app := httpserver.NewApp(
|
||||
assessmentSvc,
|
||||
|
|
@ -436,6 +450,7 @@ func main() {
|
|||
cfg,
|
||||
domain.MongoDBLogger,
|
||||
analyticsDB,
|
||||
rbacSvc,
|
||||
)
|
||||
|
||||
logger.Info("Starting server", "port", cfg.Port)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1
db/migrations/000018_course_intro_video.down.sql
Normal file
1
db/migrations/000018_course_intro_video.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE courses DROP COLUMN IF EXISTS intro_video_url;
|
||||
1
db/migrations/000018_course_intro_video.up.sql
Normal file
1
db/migrations/000018_course_intro_video.up.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE courses ADD COLUMN IF NOT EXISTS intro_video_url TEXT;
|
||||
2
db/migrations/000019_sub_course_progression.down.sql
Normal file
2
db/migrations/000019_sub_course_progression.down.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
DROP TABLE IF EXISTS user_sub_course_progress;
|
||||
DROP TABLE IF EXISTS sub_course_prerequisites;
|
||||
41
db/migrations/000019_sub_course_progression.up.sql
Normal file
41
db/migrations/000019_sub_course_progression.up.sql
Normal 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);
|
||||
1
db/migrations/000020_scheduled_notifications.down.sql
Normal file
1
db/migrations/000020_scheduled_notifications.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS scheduled_notifications;
|
||||
40
db/migrations/000020_scheduled_notifications.up.sql
Normal file
40
db/migrations/000020_scheduled_notifications.up.sql
Normal 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);
|
||||
3
db/migrations/000021_rbac.down.sql
Normal file
3
db/migrations/000021_rbac.down.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
DROP TABLE IF EXISTS role_permissions;
|
||||
DROP TABLE IF EXISTS permissions;
|
||||
DROP TABLE IF EXISTS roles;
|
||||
37
db/migrations/000021_rbac.up.sql
Normal file
37
db/migrations/000021_rbac.up.sql
Normal 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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
79
db/query/rbac.sql
Normal 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;
|
||||
81
db/query/scheduled_notification.sql
Normal file
81
db/query/scheduled_notification.sql
Normal 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;
|
||||
50
db/query/sub_course_prerequisites.sql
Normal file
50
db/query/sub_course_prerequisites.sql
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
78
db/query/user_sub_course_progress.sql
Normal file
78
db/query/user_sub_course_progress.sql
Normal 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;
|
||||
|
|
@ -17,10 +17,11 @@ 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 {
|
||||
|
|
@ -28,7 +29,8 @@ type CreateCourseParams struct {
|
|||
Title string `json:"title"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
Column5 interface{} `json:"column_5"`
|
||||
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
|
||||
|
|
@ -110,6 +116,7 @@ type GetCoursesByCategoryRow struct {
|
|||
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"`
|
||||
}
|
||||
|
||||
|
|
@ -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,14 +155,16 @@ 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"`
|
||||
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
|
||||
IsActive bool `json:"is_active"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ type Course struct {
|
|||
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"`
|
||||
|
|
|
|||
|
|
@ -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
397
gen/db/rbac.sql.go
Normal 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
|
||||
}
|
||||
330
gen/db/scheduled_notification.sql.go
Normal file
330
gen/db/scheduled_notification.sql.go
Normal 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
|
||||
}
|
||||
187
gen/db/sub_course_prerequisites.sql.go
Normal file
187
gen/db/sub_course_prerequisites.sql.go
Normal 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
|
||||
}
|
||||
|
|
@ -372,11 +372,28 @@ 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 {
|
||||
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"`
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
279
gen/db/user_sub_course_progress.sql.go
Normal file
279
gen/db/user_sub_course_progress.sql.go
Normal 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
|
||||
}
|
||||
|
|
@ -44,6 +44,7 @@ type Course struct {
|
|||
Title string
|
||||
Description *string
|
||||
Thumbnail *string
|
||||
IntroVideoURL *string
|
||||
IsActive bool
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
84
internal/domain/progression.go
Normal file
84
internal/domain/progression.go
Normal 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
63
internal/domain/rbac.go
Normal 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"
|
||||
)
|
||||
56
internal/domain/scheduled_notification.go
Normal file
56
internal/domain/scheduled_notification.go
Normal 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
|
||||
}
|
||||
|
|
@ -124,6 +124,7 @@ type UserProfileResponse struct {
|
|||
|
||||
type UserFilter struct {
|
||||
Role string
|
||||
Status string
|
||||
|
||||
Page int64
|
||||
PageSize int64
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
25
internal/ports/rbac.go
Normal 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)
|
||||
}
|
||||
|
|
@ -52,6 +52,7 @@ type UserStore interface {
|
|||
GetAllUsers(
|
||||
ctx context.Context,
|
||||
role *string,
|
||||
status *string,
|
||||
query *string,
|
||||
createdBefore, createdAfter *time.Time,
|
||||
limit, offset int32,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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(
|
||||
|
|
@ -94,8 +85,9 @@ func (s *Store) GetCoursesByCategory(
|
|||
ID: row.ID,
|
||||
CategoryID: row.CategoryID,
|
||||
Title: row.Title,
|
||||
Description: &row.Description.String,
|
||||
Thumbnail: &row.Thumbnail.String,
|
||||
Description: ptrText(row.Description),
|
||||
Thumbnail: ptrText(row.Thumbnail),
|
||||
IntroVideoURL: ptrText(row.IntroVideoUrl),
|
||||
IsActive: row.IsActive,
|
||||
})
|
||||
}
|
||||
|
|
@ -109,12 +101,14 @@ func (s *Store) UpdateCourse(
|
|||
title *string,
|
||||
description *string,
|
||||
thumbnail *string,
|
||||
introVideoURL *string,
|
||||
isActive *bool,
|
||||
) error {
|
||||
var (
|
||||
titleVal string
|
||||
descriptionVal string
|
||||
thumbnailVal string
|
||||
introVideoVal string
|
||||
isActiveVal bool
|
||||
)
|
||||
|
||||
|
|
@ -127,6 +121,9 @@ func (s *Store) UpdateCourse(
|
|||
if thumbnail != nil {
|
||||
thumbnailVal = *thumbnail
|
||||
}
|
||||
if introVideoURL != nil {
|
||||
introVideoVal = *introVideoURL
|
||||
}
|
||||
if isActive != nil {
|
||||
isActiveVal = *isActive
|
||||
}
|
||||
|
|
@ -135,6 +132,7 @@ func (s *Store) UpdateCourse(
|
|||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
226
internal/repository/progression.go
Normal file
226
internal/repository/progression.go
Normal 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
203
internal/repository/rbac.go
Normal 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,
|
||||
}
|
||||
}
|
||||
202
internal/repository/scheduled_notification.go
Normal file
202
internal/repository/scheduled_notification.go
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
69
internal/services/course_management/progression.go
Normal file
69
internal/services/course_management/progression.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
progressionStore: progressionStore,
|
||||
notificationSvc: notificationSvc,
|
||||
config: cfg,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ 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 + ">"
|
||||
|
|
@ -15,6 +19,7 @@ func (s *Service) SendEmail(ctx context.Context, receiverEmail, message string,
|
|||
Subject: subject,
|
||||
Text: message,
|
||||
Html: messageHTML,
|
||||
Attachments: attachments,
|
||||
}
|
||||
|
||||
_, err := client.Emails.Send(params)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import (
|
|||
"firebase.google.com/go/v4/messaging"
|
||||
afro "github.com/amanuelabay/afrosms-go"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/resend/resend-go/v2"
|
||||
"google.golang.org/api/option"
|
||||
// "github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
|
@ -78,6 +79,7 @@ func New(
|
|||
|
||||
go hub.Run()
|
||||
go svc.startWorker()
|
||||
go svc.startSchedulerWorker()
|
||||
// go svc.startRetryWorker()
|
||||
// go svc.RunRedisSubscriber(context.Background())
|
||||
// go svc.StartKafkaConsumer(context.Background())
|
||||
|
|
@ -233,6 +235,35 @@ func (s *Service) addConnection(recipientID int64, c *websocket.Conn) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) GetFilteredNotifications(ctx context.Context, filter domain.NotificationFilter) ([]domain.Notification, int64, error) {
|
||||
return s.store.GetFilteredNotifications(ctx, filter)
|
||||
}
|
||||
|
||||
// RecordNotification saves a notification to the database for history/audit without dispatching to the worker.
|
||||
func (s *Service) RecordNotification(ctx context.Context, recipientID int64, notifType domain.NotificationType, channel domain.DeliveryChannel, level domain.NotificationLevel, headline, message string) {
|
||||
notification := &domain.Notification{
|
||||
ID: helpers.GenerateID(),
|
||||
RecipientID: recipientID,
|
||||
Type: notifType,
|
||||
Level: level,
|
||||
DeliveryChannel: channel,
|
||||
DeliveryStatus: domain.DeliveryStatusSent,
|
||||
Payload: domain.NotificationPayload{
|
||||
Headline: headline,
|
||||
Message: message,
|
||||
},
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
if _, err := s.store.CreateNotification(ctx, notification); err != nil {
|
||||
s.mongoLogger.Error("[NotificationSvc.RecordNotification] Failed to record notification",
|
||||
zap.Int64("recipientID", recipientID),
|
||||
zap.String("channel", string(channel)),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) SendNotification(ctx context.Context, notification *domain.Notification) error {
|
||||
|
||||
notification.ID = helpers.GenerateID()
|
||||
|
|
@ -561,6 +592,7 @@ func (s *Service) SendPushNotification(ctx context.Context, notification *domain
|
|||
Notification: &messaging.Notification{
|
||||
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)
|
||||
// }
|
||||
|
|
|
|||
439
internal/services/rbac/seeds.go
Normal file
439
internal/services/rbac/seeds.go
Normal 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",
|
||||
},
|
||||
}
|
||||
165
internal/services/rbac/service.go
Normal file
165
internal/services/rbac/service.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -281,6 +281,7 @@ type createCourseReq struct {
|
|||
Title string `json:"title" validate:"required"`
|
||||
Description *string `json:"description"`
|
||||
Thumbnail *string `json:"thumbnail"`
|
||||
IntroVideoURL *string `json:"intro_video_url"`
|
||||
}
|
||||
|
||||
type courseRes struct {
|
||||
|
|
@ -289,6 +290,7 @@ type courseRes struct {
|
|||
Title string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Thumbnail *string `json:"thumbnail"`
|
||||
IntroVideoURL *string `json:"intro_video_url,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
|
|
@ -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",
|
||||
|
|
@ -345,6 +347,7 @@ func (h *Handler) CreateCourse(c *fiber.Ctx) error {
|
|||
Title: course.Title,
|
||||
Description: course.Description,
|
||||
Thumbnail: course.Thumbnail,
|
||||
IntroVideoURL: course.IntroVideoURL,
|
||||
IsActive: course.IsActive,
|
||||
},
|
||||
})
|
||||
|
|
@ -387,6 +390,7 @@ func (h *Handler) GetCourseByID(c *fiber.Ctx) error {
|
|||
Title: course.Title,
|
||||
Description: course.Description,
|
||||
Thumbnail: course.Thumbnail,
|
||||
IntroVideoURL: course.IntroVideoURL,
|
||||
IsActive: course.IsActive,
|
||||
},
|
||||
})
|
||||
|
|
@ -454,6 +458,7 @@ func (h *Handler) GetCoursesByCategory(c *fiber.Ctx) error {
|
|||
Title: course.Title,
|
||||
Description: course.Description,
|
||||
Thumbnail: course.Thumbnail,
|
||||
IntroVideoURL: course.IntroVideoURL,
|
||||
IsActive: course.IsActive,
|
||||
})
|
||||
}
|
||||
|
|
@ -471,6 +476,7 @@ type updateCourseReq struct {
|
|||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Thumbnail *string `json:"thumbnail"`
|
||||
IntroVideoURL *string `json:"intro_video_url"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
}
|
||||
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
|
@ -14,6 +15,7 @@ import (
|
|||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/resend/resend-go/v2"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
|
|
@ -507,32 +509,54 @@ func (h *Handler) GetAllNotifications(c *fiber.Ctx) error {
|
|||
limitStr := c.Query("limit", "10")
|
||||
pageStr := c.Query("page", "1")
|
||||
|
||||
// Convert limit and offset to integers
|
||||
limit, err := strconv.Atoi(limitStr)
|
||||
if err != nil || limit <= 0 {
|
||||
h.mongoLoggerSvc.Info("[NotificationSvc.GetNotifications] Invalid limit value",
|
||||
zap.String("limit", limitStr),
|
||||
zap.Int("status_code", fiber.StatusBadRequest),
|
||||
zap.Error(err),
|
||||
zap.Time("timestamp", time.Now()),
|
||||
)
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid limit value")
|
||||
}
|
||||
page, err := strconv.Atoi(pageStr)
|
||||
if err != nil || page <= 0 {
|
||||
h.mongoLoggerSvc.Info("[NotificationSvc.GetNotifications] Invalid page value",
|
||||
zap.String("page", pageStr),
|
||||
zap.Int("status_code", fiber.StatusBadRequest),
|
||||
zap.Error(err),
|
||||
zap.Time("timestamp", time.Now()),
|
||||
)
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid page value")
|
||||
}
|
||||
|
||||
notifications, err := h.notificationSvc.GetAllNotifications(context.Background(), limit, ((page - 1) * limit))
|
||||
filter := domain.NotificationFilter{
|
||||
Channel: c.Query("channel"),
|
||||
Type: c.Query("type"),
|
||||
Limit: limit,
|
||||
Offset: (page - 1) * limit,
|
||||
}
|
||||
|
||||
if uid := c.Query("user_id"); uid != "" {
|
||||
id, err := strconv.ParseInt(uid, 10, 64)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid user_id value")
|
||||
}
|
||||
filter.UserID = &id
|
||||
}
|
||||
|
||||
if isRead := c.Query("is_read"); isRead != "" {
|
||||
val := isRead == "true"
|
||||
filter.IsRead = &val
|
||||
}
|
||||
|
||||
if after := c.Query("after"); after != "" {
|
||||
t, err := time.Parse(time.RFC3339, after)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid after date, use RFC3339 format")
|
||||
}
|
||||
filter.After = &t
|
||||
}
|
||||
|
||||
if before := c.Query("before"); before != "" {
|
||||
t, err := time.Parse(time.RFC3339, before)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid before date, use RFC3339 format")
|
||||
}
|
||||
filter.Before = &t
|
||||
}
|
||||
|
||||
notifications, total, err := h.notificationSvc.GetFilteredNotifications(context.Background(), filter)
|
||||
if err != nil {
|
||||
h.mongoLoggerSvc.Error("[NotificationSvc.GetNotifications] Failed to fetch notifications",
|
||||
zap.Int64("limit", int64(limit)),
|
||||
zap.Int("status_code", fiber.StatusInternalServerError),
|
||||
zap.Error(err),
|
||||
zap.Time("timestamp", time.Now()),
|
||||
|
|
@ -542,11 +566,10 @@ func (h *Handler) GetAllNotifications(c *fiber.Ctx) error {
|
|||
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||
"notifications": notifications,
|
||||
"total_count": len(notifications),
|
||||
"total_count": total,
|
||||
"limit": limit,
|
||||
"page": page,
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
type SendSingleAfroSMSReq struct {
|
||||
|
|
@ -684,25 +707,8 @@ func (h *Handler) RegisterDeviceToken(c *fiber.Ctx) error {
|
|||
// @Failure 500 {object} domain.ErrorResponse
|
||||
// @Router /api/v1/notifications/test-push [post]
|
||||
func (h *Handler) SendTestPushNotification(c *fiber.Ctx) error {
|
||||
type Request struct {
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
var req Request
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid request body",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
if req.Title == "" {
|
||||
req.Title = "Test Push Notification"
|
||||
}
|
||||
if req.Message == "" {
|
||||
req.Message = "This is a test push notification from Yimaru Backend"
|
||||
}
|
||||
title := c.FormValue("title", "Test Push Notification")
|
||||
message := c.FormValue("message", "This is a test push notification from Yimaru Backend")
|
||||
|
||||
userID, ok := c.Locals("user_id").(int64)
|
||||
if !ok || userID == 0 {
|
||||
|
|
@ -728,14 +734,25 @@ func (h *Handler) SendTestPushNotification(c *fiber.Ctx) error {
|
|||
})
|
||||
}
|
||||
|
||||
// Handle optional image upload
|
||||
var imageURL string
|
||||
if _, err := c.FormFile("file"); err == nil {
|
||||
savedPath, saveErr := h.processAndSaveThumbnail(c, "notification_images")
|
||||
if saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
imageURL = c.BaseURL() + savedPath
|
||||
}
|
||||
|
||||
// Create test notification
|
||||
notification := &domain.Notification{
|
||||
RecipientID: userID,
|
||||
Type: "system_alert",
|
||||
DeliveryChannel: domain.DeliveryChannelPush,
|
||||
Image: imageURL,
|
||||
Payload: domain.NotificationPayload{
|
||||
Headline: req.Title,
|
||||
Message: req.Message,
|
||||
Headline: title,
|
||||
Message: message,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -748,6 +765,9 @@ func (h *Handler) SendTestPushNotification(c *fiber.Ctx) error {
|
|||
})
|
||||
}
|
||||
|
||||
// Record in DB for history
|
||||
h.notificationSvc.RecordNotification(c.Context(), userID, domain.NOTIFICATION_TYPE_SYSTEM_ALERT, domain.DeliveryChannelPush, domain.NotificationLevelInfo, title, message)
|
||||
|
||||
h.mongoLoggerSvc.Info("[NotificationHandler.SendTestPushNotification] Test push sent",
|
||||
zap.Int64("userID", userID),
|
||||
zap.Int("deviceCount", len(tokens)),
|
||||
|
|
@ -760,8 +780,746 @@ func (h *Handler) SendTestPushNotification(c *fiber.Ctx) error {
|
|||
StatusCode: fiber.StatusOK,
|
||||
Data: map[string]interface{}{
|
||||
"devices_count": len(tokens),
|
||||
"title": req.Title,
|
||||
"message": req.Message,
|
||||
"title": title,
|
||||
"message": message,
|
||||
"image": imageURL,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// SendBulkPushNotification sends a push notification to multiple users or all users of a given role.
|
||||
// @Summary Send bulk push notification
|
||||
// @Description Sends a push notification to specified user IDs or all users matching a role. Optionally schedule for later with scheduled_at (RFC3339).
|
||||
// @Tags notifications
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{title=string,message=string,image=string,user_ids=[]int64,role=string,scheduled_at=string} true "Bulk push content"
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Failure 400 {object} domain.ErrorResponse
|
||||
// @Failure 500 {object} domain.ErrorResponse
|
||||
// @Router /api/v1/notifications/bulk-push [post]
|
||||
func (h *Handler) SendBulkPushNotification(c *fiber.Ctx) error {
|
||||
title := c.FormValue("title")
|
||||
message := c.FormValue("message")
|
||||
role := c.FormValue("role")
|
||||
userIDsRaw := c.FormValue("user_ids")
|
||||
scheduledAtRaw := c.FormValue("scheduled_at")
|
||||
|
||||
if title == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Title is required",
|
||||
})
|
||||
}
|
||||
if message == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Message is required",
|
||||
})
|
||||
}
|
||||
|
||||
// Parse user_ids from JSON array string e.g. "[1,2,3]"
|
||||
var userIDs []int64
|
||||
if userIDsRaw != "" {
|
||||
if err := json.Unmarshal([]byte(userIDsRaw), &userIDs); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid user_ids format",
|
||||
Error: "user_ids must be a JSON array of integers, e.g. [1,2,3]",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(userIDs) == 0 && role == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "No target users specified",
|
||||
Error: "Provide either user_ids or role",
|
||||
})
|
||||
}
|
||||
|
||||
// Schedule for later if scheduled_at is provided
|
||||
if scheduledAtRaw != "" {
|
||||
scheduledAt, err := time.Parse(time.RFC3339, scheduledAtRaw)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid scheduled_at format, use RFC3339 (e.g. 2025-06-01T10:00:00Z)",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if scheduledAt.Before(time.Now()) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "scheduled_at must be in the future",
|
||||
})
|
||||
}
|
||||
|
||||
creatorID, _ := c.Locals("user_id").(int64)
|
||||
sn := &domain.ScheduledNotification{
|
||||
Channel: domain.DeliveryChannelPush,
|
||||
Title: title,
|
||||
Message: message,
|
||||
ScheduledAt: scheduledAt,
|
||||
TargetUserIDs: userIDs,
|
||||
TargetRole: role,
|
||||
CreatedBy: creatorID,
|
||||
}
|
||||
|
||||
created, err := h.notificationSvc.CreateScheduledNotification(c.Context(), sn)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to schedule push notification",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(domain.Response{
|
||||
Message: "Push notification scheduled",
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusCreated,
|
||||
Data: created,
|
||||
})
|
||||
}
|
||||
|
||||
// Determine target user IDs by role if no specific IDs given
|
||||
if len(userIDs) == 0 && role != "" {
|
||||
users, _, err := h.userSvc.GetAllUsers(context.Background(), domain.UserFilter{Role: role})
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to fetch users for role",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
for _, u := range users {
|
||||
userIDs = append(userIDs, u.ID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(userIDs) == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "No target users found",
|
||||
})
|
||||
}
|
||||
|
||||
// Handle optional image upload
|
||||
var imageURL string
|
||||
if _, err := c.FormFile("file"); err == nil {
|
||||
savedPath, saveErr := h.processAndSaveThumbnail(c, "notification_images")
|
||||
if saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
imageURL = c.BaseURL() + savedPath
|
||||
}
|
||||
|
||||
notification := &domain.Notification{
|
||||
Type: "system_alert",
|
||||
DeliveryChannel: domain.DeliveryChannelPush,
|
||||
Image: imageURL,
|
||||
Payload: domain.NotificationPayload{
|
||||
Headline: title,
|
||||
Message: message,
|
||||
},
|
||||
}
|
||||
|
||||
sent, failed, err := h.notificationSvc.SendBulkPushNotification(c.Context(), userIDs, notification)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to send bulk push notification",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Record in DB for history
|
||||
for _, uid := range userIDs {
|
||||
h.notificationSvc.RecordNotification(c.Context(), uid, domain.NOTIFICATION_TYPE_SYSTEM_ALERT, domain.DeliveryChannelPush, domain.NotificationLevelInfo, title, message)
|
||||
}
|
||||
|
||||
h.mongoLoggerSvc.Info("[NotificationHandler.SendBulkPushNotification] Bulk push sent",
|
||||
zap.Int("targetUsers", len(userIDs)),
|
||||
zap.Int("sent", sent),
|
||||
zap.Int("failed", failed),
|
||||
zap.Time("timestamp", time.Now()),
|
||||
)
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
||||
Message: "Bulk push notification sent",
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
Data: map[string]interface{}{
|
||||
"target_users": len(userIDs),
|
||||
"sent": sent,
|
||||
"failed": failed,
|
||||
"image": imageURL,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// SendBulkSMS sends an SMS to multiple users by user IDs, role, or direct phone numbers.
|
||||
// @Summary Send bulk SMS
|
||||
// @Description Sends an SMS to specified user IDs, all users of a role, or direct phone numbers. Optionally schedule for later with scheduled_at (RFC3339).
|
||||
// @Tags notifications
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{message=string,user_ids=[]int64,role=string,phone_numbers=[]string,scheduled_at=string} true "Bulk SMS content"
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Failure 400 {object} domain.ErrorResponse
|
||||
// @Failure 500 {object} domain.ErrorResponse
|
||||
// @Router /api/v1/notifications/bulk-sms [post]
|
||||
func (h *Handler) SendBulkSMS(c *fiber.Ctx) error {
|
||||
type Request struct {
|
||||
Message string `json:"message" validate:"required"`
|
||||
UserIDs []int64 `json:"user_ids"`
|
||||
Role string `json:"role"`
|
||||
PhoneNumbers []string `json:"phone_numbers"`
|
||||
ScheduledAt string `json:"scheduled_at"`
|
||||
}
|
||||
|
||||
var req Request
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid request body",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
if req.Message == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Message is required",
|
||||
})
|
||||
}
|
||||
|
||||
if len(req.UserIDs) == 0 && req.Role == "" && len(req.PhoneNumbers) == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "No recipients specified",
|
||||
Error: "Provide user_ids, role, or phone_numbers",
|
||||
})
|
||||
}
|
||||
|
||||
// Schedule for later if scheduled_at is provided
|
||||
if req.ScheduledAt != "" {
|
||||
scheduledAt, err := time.Parse(time.RFC3339, req.ScheduledAt)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid scheduled_at format, use RFC3339 (e.g. 2025-06-01T10:00:00Z)",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if scheduledAt.Before(time.Now()) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "scheduled_at must be in the future",
|
||||
})
|
||||
}
|
||||
|
||||
creatorID, _ := c.Locals("user_id").(int64)
|
||||
|
||||
var targetRaw json.RawMessage
|
||||
if len(req.PhoneNumbers) > 0 {
|
||||
raw := domain.ScheduledNotificationTargetRaw{Phones: req.PhoneNumbers}
|
||||
targetRaw, _ = json.Marshal(raw)
|
||||
}
|
||||
|
||||
sn := &domain.ScheduledNotification{
|
||||
Channel: domain.DeliveryChannelSMS,
|
||||
Message: req.Message,
|
||||
ScheduledAt: scheduledAt,
|
||||
TargetUserIDs: req.UserIDs,
|
||||
TargetRole: req.Role,
|
||||
TargetRaw: targetRaw,
|
||||
CreatedBy: creatorID,
|
||||
}
|
||||
|
||||
created, err := h.notificationSvc.CreateScheduledNotification(c.Context(), sn)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to schedule SMS",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(domain.Response{
|
||||
Message: "SMS scheduled",
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusCreated,
|
||||
Data: created,
|
||||
})
|
||||
}
|
||||
|
||||
// Collect phone numbers from all sources
|
||||
phoneNumbers := make(map[string]struct{})
|
||||
|
||||
// Add directly provided phone numbers
|
||||
for _, p := range req.PhoneNumbers {
|
||||
phoneNumbers[p] = struct{}{}
|
||||
}
|
||||
|
||||
// Collect user IDs from role if needed
|
||||
userIDs := req.UserIDs
|
||||
if len(userIDs) == 0 && req.Role != "" {
|
||||
users, _, err := h.userSvc.GetAllUsers(context.Background(), domain.UserFilter{Role: req.Role})
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to fetch users for role",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
for _, u := range users {
|
||||
userIDs = append(userIDs, u.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve user IDs to phone numbers
|
||||
for _, uid := range userIDs {
|
||||
user, err := h.userSvc.GetUserByID(context.Background(), uid)
|
||||
if err != nil {
|
||||
h.mongoLoggerSvc.Warn("[NotificationHandler.SendBulkSMS] Failed to get user",
|
||||
zap.Int64("userID", uid),
|
||||
zap.Error(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
if user.PhoneNumber != "" {
|
||||
phoneNumbers[user.PhoneNumber] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
if len(phoneNumbers) == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "No recipients found",
|
||||
Error: "Provide user_ids, role, or phone_numbers",
|
||||
})
|
||||
}
|
||||
|
||||
// Flatten to slice
|
||||
recipients := make([]string, 0, len(phoneNumbers))
|
||||
for p := range phoneNumbers {
|
||||
recipients = append(recipients, p)
|
||||
}
|
||||
|
||||
sent, failed := h.notificationSvc.SendBulkSMS(c.Context(), recipients, req.Message)
|
||||
|
||||
// Record in DB for history (only for known users)
|
||||
for _, uid := range userIDs {
|
||||
h.notificationSvc.RecordNotification(c.Context(), uid, domain.NOTIFICATION_TYPE_SYSTEM_ALERT, domain.DeliveryChannelSMS, domain.NotificationLevelInfo, "SMS Notification", req.Message)
|
||||
}
|
||||
|
||||
h.mongoLoggerSvc.Info("[NotificationHandler.SendBulkSMS] Bulk SMS sent",
|
||||
zap.Int("totalRecipients", len(recipients)),
|
||||
zap.Int("sent", sent),
|
||||
zap.Int("failed", failed),
|
||||
zap.Time("timestamp", time.Now()),
|
||||
)
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
||||
Message: "Bulk SMS sent",
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
Data: map[string]interface{}{
|
||||
"total_recipients": len(recipients),
|
||||
"sent": sent,
|
||||
"failed": failed,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetScheduledNotification retrieves a single scheduled notification by ID.
|
||||
// @Summary Get scheduled notification
|
||||
// @Description Returns a single scheduled notification by its ID
|
||||
// @Tags notifications
|
||||
// @Produce json
|
||||
// @Param id path int true "Scheduled Notification ID"
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Failure 400 {object} domain.ErrorResponse
|
||||
// @Failure 500 {object} domain.ErrorResponse
|
||||
// @Router /api/v1/notifications/scheduled/{id} [get]
|
||||
func (h *Handler) GetScheduledNotification(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid ID",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
sn, err := h.notificationSvc.GetScheduledNotification(c.Context(), id)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to get scheduled notification",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
||||
Message: "Scheduled notification retrieved",
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
Data: sn,
|
||||
})
|
||||
}
|
||||
|
||||
// ListScheduledNotifications lists scheduled notifications with optional filters.
|
||||
// @Summary List scheduled notifications
|
||||
// @Description Returns paginated scheduled notifications with optional status, channel, and date filters
|
||||
// @Tags notifications
|
||||
// @Produce json
|
||||
// @Param status query string false "Filter by status"
|
||||
// @Param channel query string false "Filter by channel"
|
||||
// @Param after query string false "Filter after date (RFC3339)"
|
||||
// @Param before query string false "Filter before date (RFC3339)"
|
||||
// @Param limit query int false "Page size" default(20)
|
||||
// @Param page query int false "Page number" default(1)
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Failure 400 {object} domain.ErrorResponse
|
||||
// @Failure 500 {object} domain.ErrorResponse
|
||||
// @Router /api/v1/notifications/scheduled [get]
|
||||
func (h *Handler) ListScheduledNotifications(c *fiber.Ctx) error {
|
||||
limit, err := strconv.Atoi(c.Query("limit", "20"))
|
||||
if err != nil || limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
page, err := strconv.Atoi(c.Query("page", "1"))
|
||||
if err != nil || page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
filter := domain.ScheduledNotificationFilter{
|
||||
Status: c.Query("status"),
|
||||
Channel: c.Query("channel"),
|
||||
Limit: limit,
|
||||
Offset: (page - 1) * limit,
|
||||
}
|
||||
|
||||
if after := c.Query("after"); after != "" {
|
||||
t, err := time.Parse(time.RFC3339, after)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid after date, use RFC3339 format",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
filter.After = &t
|
||||
}
|
||||
|
||||
if before := c.Query("before"); before != "" {
|
||||
t, err := time.Parse(time.RFC3339, before)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid before date, use RFC3339 format",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
filter.Before = &t
|
||||
}
|
||||
|
||||
notifications, total, err := h.notificationSvc.ListScheduledNotifications(c.Context(), filter)
|
||||
if err != nil {
|
||||
h.mongoLoggerSvc.Error("[NotificationHandler.ListScheduledNotifications] Failed",
|
||||
zap.Error(err),
|
||||
zap.Time("timestamp", time.Now()),
|
||||
)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to list scheduled notifications",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||
"scheduled_notifications": notifications,
|
||||
"total_count": total,
|
||||
"limit": limit,
|
||||
"page": page,
|
||||
})
|
||||
}
|
||||
|
||||
// CancelScheduledNotification cancels a pending or processing scheduled notification.
|
||||
// @Summary Cancel scheduled notification
|
||||
// @Description Cancels a scheduled notification if it is still pending or processing
|
||||
// @Tags notifications
|
||||
// @Produce json
|
||||
// @Param id path int true "Scheduled Notification ID"
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Failure 400 {object} domain.ErrorResponse
|
||||
// @Failure 500 {object} domain.ErrorResponse
|
||||
// @Router /api/v1/notifications/scheduled/{id}/cancel [post]
|
||||
func (h *Handler) CancelScheduledNotification(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid ID",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
cancelled, err := h.notificationSvc.CancelScheduledNotification(c.Context(), id)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to cancel scheduled notification",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
||||
Message: "Scheduled notification cancelled",
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
Data: cancelled,
|
||||
})
|
||||
}
|
||||
|
||||
// parseEmailAttachment reads an optional "file" from the multipart form and returns a Resend attachment.
|
||||
func (h *Handler) parseEmailAttachment(c *fiber.Ctx) []*resend.Attachment {
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
fh, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer fh.Close()
|
||||
|
||||
data, err := io.ReadAll(fh)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []*resend.Attachment{
|
||||
{
|
||||
Content: data,
|
||||
Filename: fileHeader.Filename,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SendSingleEmail sends an email to a single recipient with an optional image attachment.
|
||||
// @Summary Send single email
|
||||
// @Description Sends an email to a single email address with optional image attachment
|
||||
// @Tags notifications
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Failure 400 {object} domain.ErrorResponse
|
||||
// @Failure 500 {object} domain.ErrorResponse
|
||||
// @Router /api/v1/notifications/send-email [post]
|
||||
func (h *Handler) SendSingleEmail(c *fiber.Ctx) error {
|
||||
recipient := c.FormValue("recipient")
|
||||
subject := c.FormValue("subject")
|
||||
message := c.FormValue("message")
|
||||
html := c.FormValue("html")
|
||||
|
||||
if recipient == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Recipient is required",
|
||||
})
|
||||
}
|
||||
if subject == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Subject is required",
|
||||
})
|
||||
}
|
||||
if message == "" && html == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Either message or html is required",
|
||||
})
|
||||
}
|
||||
|
||||
attachments := h.parseEmailAttachment(c)
|
||||
|
||||
if err := h.notificationSvc.MessengerSvc().SendEmailWithAttachments(
|
||||
c.Context(), recipient, message, html, subject, attachments,
|
||||
); err != nil {
|
||||
h.mongoLoggerSvc.Error("[NotificationHandler.SendSingleEmail] Failed to send email",
|
||||
zap.String("recipient", recipient),
|
||||
zap.Error(err),
|
||||
zap.Time("timestamp", time.Now()),
|
||||
)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to send email",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
||||
Message: "Email sent successfully",
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
||||
// SendBulkEmail sends an email to multiple users by user IDs, role, or direct email addresses.
|
||||
// @Summary Send bulk email
|
||||
// @Description Sends an email to specified user IDs, all users of a role, or direct email addresses with optional image attachment. Optionally schedule for later with scheduled_at (RFC3339).
|
||||
// @Tags notifications
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Failure 400 {object} domain.ErrorResponse
|
||||
// @Failure 500 {object} domain.ErrorResponse
|
||||
// @Router /api/v1/notifications/bulk-email [post]
|
||||
func (h *Handler) SendBulkEmail(c *fiber.Ctx) error {
|
||||
subject := c.FormValue("subject")
|
||||
message := c.FormValue("message")
|
||||
html := c.FormValue("html")
|
||||
role := c.FormValue("role")
|
||||
userIDsRaw := c.FormValue("user_ids")
|
||||
emailsRaw := c.FormValue("emails")
|
||||
scheduledAtRaw := c.FormValue("scheduled_at")
|
||||
|
||||
if subject == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Subject is required",
|
||||
})
|
||||
}
|
||||
if message == "" && html == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Either message or html is required",
|
||||
})
|
||||
}
|
||||
|
||||
// Parse direct emails
|
||||
var directEmails []string
|
||||
if emailsRaw != "" {
|
||||
if err := json.Unmarshal([]byte(emailsRaw), &directEmails); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid emails format",
|
||||
Error: "emails must be a JSON array of strings",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Parse user_ids
|
||||
var userIDs []int64
|
||||
if userIDsRaw != "" {
|
||||
if err := json.Unmarshal([]byte(userIDsRaw), &userIDs); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid user_ids format",
|
||||
Error: "user_ids must be a JSON array of integers",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(userIDs) == 0 && role == "" && len(directEmails) == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "No recipients specified",
|
||||
Error: "Provide user_ids, role, or emails",
|
||||
})
|
||||
}
|
||||
|
||||
// Schedule for later if scheduled_at is provided
|
||||
if scheduledAtRaw != "" {
|
||||
scheduledAt, err := time.Parse(time.RFC3339, scheduledAtRaw)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid scheduled_at format, use RFC3339 (e.g. 2025-06-01T10:00:00Z)",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if scheduledAt.Before(time.Now()) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "scheduled_at must be in the future",
|
||||
})
|
||||
}
|
||||
|
||||
creatorID, _ := c.Locals("user_id").(int64)
|
||||
|
||||
var targetRaw json.RawMessage
|
||||
if len(directEmails) > 0 {
|
||||
raw := domain.ScheduledNotificationTargetRaw{Emails: directEmails}
|
||||
targetRaw, _ = json.Marshal(raw)
|
||||
}
|
||||
|
||||
sn := &domain.ScheduledNotification{
|
||||
Channel: domain.DeliveryChannelEmail,
|
||||
Title: subject,
|
||||
Message: message,
|
||||
HTML: html,
|
||||
ScheduledAt: scheduledAt,
|
||||
TargetUserIDs: userIDs,
|
||||
TargetRole: role,
|
||||
TargetRaw: targetRaw,
|
||||
CreatedBy: creatorID,
|
||||
}
|
||||
|
||||
created, err := h.notificationSvc.CreateScheduledNotification(c.Context(), sn)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to schedule email",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(domain.Response{
|
||||
Message: "Email scheduled",
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusCreated,
|
||||
Data: created,
|
||||
})
|
||||
}
|
||||
|
||||
// Immediate send: collect emails from all sources
|
||||
emailSet := make(map[string]struct{})
|
||||
for _, e := range directEmails {
|
||||
emailSet[e] = struct{}{}
|
||||
}
|
||||
|
||||
if len(userIDs) == 0 && role != "" {
|
||||
users, _, err := h.userSvc.GetAllUsers(context.Background(), domain.UserFilter{Role: role})
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to fetch users for role",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
for _, u := range users {
|
||||
userIDs = append(userIDs, u.ID)
|
||||
}
|
||||
}
|
||||
|
||||
for _, uid := range userIDs {
|
||||
user, err := h.userSvc.GetUserByID(context.Background(), uid)
|
||||
if err != nil {
|
||||
h.mongoLoggerSvc.Warn("[NotificationHandler.SendBulkEmail] Failed to get user",
|
||||
zap.Int64("userID", uid),
|
||||
zap.Error(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
if user.Email != "" {
|
||||
emailSet[user.Email] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
if len(emailSet) == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "No recipients found",
|
||||
Error: "Provide user_ids, role, or emails",
|
||||
})
|
||||
}
|
||||
|
||||
recipients := make([]string, 0, len(emailSet))
|
||||
for e := range emailSet {
|
||||
recipients = append(recipients, e)
|
||||
}
|
||||
|
||||
attachments := h.parseEmailAttachment(c)
|
||||
|
||||
sent, failed := h.notificationSvc.SendBulkEmail(c.Context(), recipients, subject, message, html, attachments)
|
||||
|
||||
// Record in DB for history (only for known users)
|
||||
for _, uid := range userIDs {
|
||||
h.notificationSvc.RecordNotification(c.Context(), uid, domain.NOTIFICATION_TYPE_SYSTEM_ALERT, domain.DeliveryChannelEmail, domain.NotificationLevelInfo, subject, message)
|
||||
}
|
||||
|
||||
h.mongoLoggerSvc.Info("[NotificationHandler.SendBulkEmail] Bulk email sent",
|
||||
zap.Int("totalRecipients", len(recipients)),
|
||||
zap.Int("sent", sent),
|
||||
zap.Int("failed", failed),
|
||||
zap.Time("timestamp", time.Now()),
|
||||
)
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
||||
Message: "Bulk email sent",
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
Data: map[string]interface{}{
|
||||
"total_recipients": len(recipients),
|
||||
"sent": sent,
|
||||
"failed": failed,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
404
internal/web_server/handlers/progression_handler.go
Normal file
404
internal/web_server/handlers/progression_handler.go
Normal 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,
|
||||
})
|
||||
}
|
||||
565
internal/web_server/handlers/rbac_handler.go
Normal file
565
internal/web_server/handlers/rbac_handler.go
Normal 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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user