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"
|
activitylogservice "Yimaru-Backend/internal/services/activity_log"
|
||||||
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
|
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
|
||||||
ratingsservice "Yimaru-Backend/internal/services/ratings"
|
ratingsservice "Yimaru-Backend/internal/services/ratings"
|
||||||
|
rbacservice "Yimaru-Backend/internal/services/rbac"
|
||||||
vimeoservice "Yimaru-Backend/internal/services/vimeo"
|
vimeoservice "Yimaru-Backend/internal/services/vimeo"
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
|
@ -361,6 +362,7 @@ func main() {
|
||||||
courseSvc := course_management.NewService(
|
courseSvc := course_management.NewService(
|
||||||
repository.NewUserStore(store),
|
repository.NewUserStore(store),
|
||||||
repository.NewCourseStore(store),
|
repository.NewCourseStore(store),
|
||||||
|
repository.NewProgressionStore(store),
|
||||||
notificationSvc,
|
notificationSvc,
|
||||||
cfg,
|
cfg,
|
||||||
)
|
)
|
||||||
|
|
@ -407,6 +409,18 @@ func main() {
|
||||||
// Ratings service
|
// Ratings service
|
||||||
ratingSvc := ratingsservice.NewService(repository.NewRatingStore(store))
|
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
|
// Initialize and start HTTP server
|
||||||
app := httpserver.NewApp(
|
app := httpserver.NewApp(
|
||||||
assessmentSvc,
|
assessmentSvc,
|
||||||
|
|
@ -436,6 +450,7 @@ func main() {
|
||||||
cfg,
|
cfg,
|
||||||
domain.MongoDBLogger,
|
domain.MongoDBLogger,
|
||||||
analyticsDB,
|
analyticsDB,
|
||||||
|
rbacSvc,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.Info("Starting server", "port", cfg.Port)
|
logger.Info("Starting server", "port", cfg.Port)
|
||||||
|
|
|
||||||
|
|
@ -37,11 +37,6 @@ BEGIN
|
||||||
filled_count := filled_count + 1;
|
filled_count := filled_count + 1;
|
||||||
END IF;
|
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
|
-- Check learning_goal
|
||||||
IF NULLIF(TRIM(NEW.learning_goal), '') IS NOT NULL THEN
|
IF NULLIF(TRIM(NEW.learning_goal), '') IS NOT NULL THEN
|
||||||
filled_count := filled_count + 1;
|
filled_count := filled_count + 1;
|
||||||
|
|
@ -52,8 +47,8 @@ BEGIN
|
||||||
filled_count := filled_count + 1;
|
filled_count := filled_count + 1;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
-- Calculate percentage (9 total required fields)
|
-- Calculate percentage (8 total required fields)
|
||||||
NEW.profile_completion_percentage := (filled_count * 100 / 9)::SMALLINT;
|
NEW.profile_completion_percentage := (filled_count * 100 / 8)::SMALLINT;
|
||||||
|
|
||||||
-- Set profile_completed if 100%
|
-- Set profile_completed if 100%
|
||||||
IF NEW.profile_completion_percentage = 100 THEN
|
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,
|
title,
|
||||||
description,
|
description,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
|
intro_video_url,
|
||||||
is_active
|
is_active
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, COALESCE($5, true))
|
VALUES ($1, $2, $3, $4, $5, COALESCE($6, true))
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -24,6 +25,7 @@ SELECT
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
|
intro_video_url,
|
||||||
is_active
|
is_active
|
||||||
FROM courses
|
FROM courses
|
||||||
WHERE category_id = $1
|
WHERE category_id = $1
|
||||||
|
|
@ -38,8 +40,9 @@ SET
|
||||||
title = COALESCE($1, title),
|
title = COALESCE($1, title),
|
||||||
description = COALESCE($2, description),
|
description = COALESCE($2, description),
|
||||||
thumbnail = COALESCE($3, thumbnail),
|
thumbnail = COALESCE($3, thumbnail),
|
||||||
is_active = COALESCE($4, is_active)
|
intro_video_url = COALESCE($4, intro_video_url),
|
||||||
WHERE id = $5;
|
is_active = COALESCE($5, is_active)
|
||||||
|
WHERE id = $6;
|
||||||
|
|
||||||
|
|
||||||
-- name: DeleteCourse :exec
|
-- name: DeleteCourse :exec
|
||||||
|
|
|
||||||
|
|
@ -86,3 +86,27 @@ WHERE user_id = $1
|
||||||
-- name: DeleteUserNotifications :exec
|
-- name: DeleteUserNotifications :exec
|
||||||
DELETE FROM notifications
|
DELETE FROM notifications
|
||||||
WHERE user_id = $1;
|
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,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM users
|
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
|
LIMIT sqlc.narg('limit')::INT
|
||||||
OFFSET sqlc.narg('offset')::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,18 +17,20 @@ INSERT INTO courses (
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
|
intro_video_url,
|
||||||
is_active
|
is_active
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, COALESCE($5, true))
|
VALUES ($1, $2, $3, $4, $5, COALESCE($6, true))
|
||||||
RETURNING id, category_id, title, description, is_active, thumbnail
|
RETURNING id, category_id, title, description, is_active, thumbnail, intro_video_url
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateCourseParams struct {
|
type CreateCourseParams struct {
|
||||||
CategoryID int64 `json:"category_id"`
|
CategoryID int64 `json:"category_id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description pgtype.Text `json:"description"`
|
Description pgtype.Text `json:"description"`
|
||||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
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) {
|
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.Title,
|
||||||
arg.Description,
|
arg.Description,
|
||||||
arg.Thumbnail,
|
arg.Thumbnail,
|
||||||
arg.Column5,
|
arg.IntroVideoUrl,
|
||||||
|
arg.Column6,
|
||||||
)
|
)
|
||||||
var i Course
|
var i Course
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
|
|
@ -47,6 +50,7 @@ func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Cou
|
||||||
&i.Description,
|
&i.Description,
|
||||||
&i.IsActive,
|
&i.IsActive,
|
||||||
&i.Thumbnail,
|
&i.Thumbnail,
|
||||||
|
&i.IntroVideoUrl,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -62,7 +66,7 @@ func (q *Queries) DeleteCourse(ctx context.Context, id int64) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
const GetCourseByID = `-- name: GetCourseByID :one
|
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
|
FROM courses
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
@ -77,6 +81,7 @@ func (q *Queries) GetCourseByID(ctx context.Context, id int64) (Course, error) {
|
||||||
&i.Description,
|
&i.Description,
|
||||||
&i.IsActive,
|
&i.IsActive,
|
||||||
&i.Thumbnail,
|
&i.Thumbnail,
|
||||||
|
&i.IntroVideoUrl,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -89,6 +94,7 @@ SELECT
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
|
intro_video_url,
|
||||||
is_active
|
is_active
|
||||||
FROM courses
|
FROM courses
|
||||||
WHERE category_id = $1
|
WHERE category_id = $1
|
||||||
|
|
@ -104,13 +110,14 @@ type GetCoursesByCategoryParams struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetCoursesByCategoryRow struct {
|
type GetCoursesByCategoryRow struct {
|
||||||
TotalCount int64 `json:"total_count"`
|
TotalCount int64 `json:"total_count"`
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
CategoryID int64 `json:"category_id"`
|
CategoryID int64 `json:"category_id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description pgtype.Text `json:"description"`
|
Description pgtype.Text `json:"description"`
|
||||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||||
IsActive bool `json:"is_active"`
|
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetCoursesByCategory(ctx context.Context, arg GetCoursesByCategoryParams) ([]GetCoursesByCategoryRow, error) {
|
func (q *Queries) GetCoursesByCategory(ctx context.Context, arg GetCoursesByCategoryParams) ([]GetCoursesByCategoryRow, error) {
|
||||||
|
|
@ -129,6 +136,7 @@ func (q *Queries) GetCoursesByCategory(ctx context.Context, arg GetCoursesByCate
|
||||||
&i.Title,
|
&i.Title,
|
||||||
&i.Description,
|
&i.Description,
|
||||||
&i.Thumbnail,
|
&i.Thumbnail,
|
||||||
|
&i.IntroVideoUrl,
|
||||||
&i.IsActive,
|
&i.IsActive,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -147,16 +155,18 @@ SET
|
||||||
title = COALESCE($1, title),
|
title = COALESCE($1, title),
|
||||||
description = COALESCE($2, description),
|
description = COALESCE($2, description),
|
||||||
thumbnail = COALESCE($3, thumbnail),
|
thumbnail = COALESCE($3, thumbnail),
|
||||||
is_active = COALESCE($4, is_active)
|
intro_video_url = COALESCE($4, intro_video_url),
|
||||||
WHERE id = $5
|
is_active = COALESCE($5, is_active)
|
||||||
|
WHERE id = $6
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateCourseParams struct {
|
type UpdateCourseParams struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description pgtype.Text `json:"description"`
|
Description pgtype.Text `json:"description"`
|
||||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||||
IsActive bool `json:"is_active"`
|
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
|
||||||
ID int64 `json:"id"`
|
IsActive bool `json:"is_active"`
|
||||||
|
ID int64 `json:"id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpdateCourse(ctx context.Context, arg UpdateCourseParams) error {
|
func (q *Queries) UpdateCourse(ctx context.Context, arg UpdateCourseParams) error {
|
||||||
|
|
@ -164,6 +174,7 @@ func (q *Queries) UpdateCourse(ctx context.Context, arg UpdateCourseParams) erro
|
||||||
arg.Title,
|
arg.Title,
|
||||||
arg.Description,
|
arg.Description,
|
||||||
arg.Thumbnail,
|
arg.Thumbnail,
|
||||||
|
arg.IntroVideoUrl,
|
||||||
arg.IsActive,
|
arg.IsActive,
|
||||||
arg.ID,
|
arg.ID,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -23,12 +23,13 @@ type ActivityLog struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Course struct {
|
type Course struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
CategoryID int64 `json:"category_id"`
|
CategoryID int64 `json:"category_id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description pgtype.Text `json:"description"`
|
Description pgtype.Text `json:"description"`
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||||
|
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CourseCategory struct {
|
type CourseCategory struct {
|
||||||
|
|
@ -112,6 +113,15 @@ type Payment struct {
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
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 {
|
type Question struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
QuestionText string `json:"question_text"`
|
QuestionText string `json:"question_text"`
|
||||||
|
|
@ -211,6 +221,41 @@ type ReportedIssue struct {
|
||||||
UpdatedAt pgtype.Timestamp `json:"updated_at"`
|
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 {
|
type SubCourse struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
CourseID int64 `json:"course_id"`
|
CourseID int64 `json:"course_id"`
|
||||||
|
|
@ -222,6 +267,13 @@ type SubCourse struct {
|
||||||
IsActive bool `json:"is_active"`
|
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 {
|
type SubCourseVideo struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
SubCourseID int64 `json:"sub_course_id"`
|
SubCourseID int64 `json:"sub_course_id"`
|
||||||
|
|
@ -324,6 +376,18 @@ type User struct {
|
||||||
ProfileCompletionPercentage int16 `json:"profile_completion_percentage"`
|
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 {
|
type UserSubscription struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
UserID int64 `json:"user_id"`
|
UserID int64 `json:"user_id"`
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,108 @@ func (q *Queries) GetAllNotifications(ctx context.Context, arg GetAllNotificatio
|
||||||
return items, nil
|
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
|
const GetNotification = `-- name: GetNotification :one
|
||||||
SELECT id, user_id, type, level, channel, title, message, payload, is_read, created_at, read_at, receiver_type
|
SELECT id, user_id, type, level, channel, title, message, payload, is_read, created_at, read_at, receiver_type
|
||||||
FROM notifications
|
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,13 +372,30 @@ SELECT
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM users
|
FROM users
|
||||||
LIMIT $2::INT
|
WHERE
|
||||||
OFFSET $1::INT
|
($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 {
|
type GetAllUsersParams struct {
|
||||||
Offset pgtype.Int4 `json:"offset"`
|
Role pgtype.Text `json:"role"`
|
||||||
Limit pgtype.Int4 `json:"limit"`
|
Status pgtype.Text `json:"status"`
|
||||||
|
Query pgtype.Text `json:"query"`
|
||||||
|
CreatedAfter pgtype.Timestamptz `json:"created_after"`
|
||||||
|
CreatedBefore pgtype.Timestamptz `json:"created_before"`
|
||||||
|
Offset pgtype.Int4 `json:"offset"`
|
||||||
|
Limit pgtype.Int4 `json:"limit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetAllUsersRow struct {
|
type GetAllUsersRow struct {
|
||||||
|
|
@ -415,7 +432,15 @@ type GetAllUsersRow struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetAllUsers(ctx context.Context, arg GetAllUsersParams) ([]GetAllUsersRow, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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
|
||||||
|
}
|
||||||
|
|
@ -39,12 +39,13 @@ type CourseCategory struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Course struct {
|
type Course struct {
|
||||||
ID int64
|
ID int64
|
||||||
CategoryID int64
|
CategoryID int64
|
||||||
Title string
|
Title string
|
||||||
Description *string
|
Description *string
|
||||||
Thumbnail *string
|
Thumbnail *string
|
||||||
IsActive bool
|
IntroVideoURL *string
|
||||||
|
IsActive bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubCourse struct {
|
type SubCourse struct {
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ const (
|
||||||
NOTIFICATION_TYPE_ADMIN_CREATED NotificationType = "admin_created"
|
NOTIFICATION_TYPE_ADMIN_CREATED NotificationType = "admin_created"
|
||||||
NOTIFICATION_TYPE_TEAM_MEMBER_CREATED NotificationType = "team_member_created"
|
NOTIFICATION_TYPE_TEAM_MEMBER_CREATED NotificationType = "team_member_created"
|
||||||
NOTIFICATION_TYPE_USER_DELETED NotificationType = "user_deleted"
|
NOTIFICATION_TYPE_USER_DELETED NotificationType = "user_deleted"
|
||||||
|
NOTIFICATION_TYPE_SYSTEM_ALERT NotificationType = "system_alert"
|
||||||
|
|
||||||
NotificationRecieverSideAdmin NotificationRecieverSide = "admin"
|
NotificationRecieverSideAdmin NotificationRecieverSide = "admin"
|
||||||
NotificationRecieverSideCustomer NotificationRecieverSide = "customer"
|
NotificationRecieverSideCustomer NotificationRecieverSide = "customer"
|
||||||
|
|
@ -104,6 +105,17 @@ type CreateNotification struct {
|
||||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
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) {
|
func (n *Notification) ToJSON() ([]byte, error) {
|
||||||
return json.Marshal(n)
|
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
|
||||||
|
}
|
||||||
|
|
@ -123,7 +123,8 @@ type UserProfileResponse struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserFilter struct {
|
type UserFilter struct {
|
||||||
Role string
|
Role string
|
||||||
|
Status string
|
||||||
|
|
||||||
Page int64
|
Page int64
|
||||||
PageSize int64
|
PageSize int64
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ type CourseStore interface {
|
||||||
title string,
|
title string,
|
||||||
description *string,
|
description *string,
|
||||||
thumbnail *string,
|
thumbnail *string,
|
||||||
|
introVideoURL *string,
|
||||||
) (domain.Course, error)
|
) (domain.Course, error)
|
||||||
GetCourseByID(
|
GetCourseByID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
|
@ -55,6 +56,7 @@ type CourseStore interface {
|
||||||
title *string,
|
title *string,
|
||||||
description *string,
|
description *string,
|
||||||
thumbnail *string,
|
thumbnail *string,
|
||||||
|
introVideoURL *string,
|
||||||
isActive *bool,
|
isActive *bool,
|
||||||
) error
|
) error
|
||||||
DeleteCourse(
|
DeleteCourse(
|
||||||
|
|
@ -171,3 +173,21 @@ type CourseStore interface {
|
||||||
// Learning Tree
|
// Learning Tree
|
||||||
GetFullLearningTree(ctx context.Context) ([]domain.TreeCourse, error)
|
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)
|
GetUserNotifications(ctx context.Context, recipientID int64, limit, offset int) ([]domain.Notification, int64, error)
|
||||||
CountUnreadNotifications(ctx context.Context, recipient_id int64) (int64, error)
|
CountUnreadNotifications(ctx context.Context, recipient_id int64) (int64, error)
|
||||||
GetAllNotifications(ctx context.Context, limit, offset int) ([]domain.Notification, 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)
|
CreateNotification(ctx context.Context, notification *domain.Notification) (*domain.Notification, error)
|
||||||
MarkNotificationAsRead(ctx context.Context, id int64) (*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)
|
MarkNotificationAsUnread(ctx context.Context, id int64) (*domain.Notification, error)
|
||||||
MarkAllUserNotificationsAsUnread(ctx context.Context, userID int64) error
|
MarkAllUserNotificationsAsUnread(ctx context.Context, userID int64) error
|
||||||
DeleteUserNotifications(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(
|
GetAllUsers(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
role *string,
|
role *string,
|
||||||
|
status *string,
|
||||||
query *string,
|
query *string,
|
||||||
createdBefore, createdAfter *time.Time,
|
createdBefore, createdAfter *time.Time,
|
||||||
limit, offset int32,
|
limit, offset int32,
|
||||||
|
|
|
||||||
|
|
@ -14,34 +14,32 @@ func (s *Store) CreateCourse(
|
||||||
title string,
|
title string,
|
||||||
description *string,
|
description *string,
|
||||||
thumbnail *string,
|
thumbnail *string,
|
||||||
|
introVideoURL *string,
|
||||||
) (domain.Course, error) {
|
) (domain.Course, error) {
|
||||||
var descVal, thumbVal string
|
var descVal, thumbVal, introVideoVal string
|
||||||
if description != nil {
|
if description != nil {
|
||||||
descVal = *description
|
descVal = *description
|
||||||
}
|
}
|
||||||
if thumbnail != nil {
|
if thumbnail != nil {
|
||||||
thumbVal = *thumbnail
|
thumbVal = *thumbnail
|
||||||
}
|
}
|
||||||
|
if introVideoURL != nil {
|
||||||
|
introVideoVal = *introVideoURL
|
||||||
|
}
|
||||||
|
|
||||||
row, err := s.queries.CreateCourse(ctx, dbgen.CreateCourseParams{
|
row, err := s.queries.CreateCourse(ctx, dbgen.CreateCourseParams{
|
||||||
CategoryID: categoryID,
|
CategoryID: categoryID,
|
||||||
Title: title,
|
Title: title,
|
||||||
Description: pgtype.Text{String: descVal, Valid: description != nil},
|
Description: pgtype.Text{String: descVal, Valid: description != nil},
|
||||||
Thumbnail: pgtype.Text{String: thumbVal, Valid: thumbnail != nil},
|
Thumbnail: pgtype.Text{String: thumbVal, Valid: thumbnail != nil},
|
||||||
Column5: true,
|
IntroVideoUrl: pgtype.Text{String: introVideoVal, Valid: introVideoURL != nil},
|
||||||
|
Column6: true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.Course{}, err
|
return domain.Course{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return domain.Course{
|
return mapCourse(row), nil
|
||||||
ID: row.ID,
|
|
||||||
CategoryID: row.CategoryID,
|
|
||||||
Title: row.Title,
|
|
||||||
Description: &row.Description.String,
|
|
||||||
Thumbnail: &row.Thumbnail.String,
|
|
||||||
IsActive: row.IsActive,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) GetCourseByID(
|
func (s *Store) GetCourseByID(
|
||||||
|
|
@ -54,14 +52,7 @@ func (s *Store) GetCourseByID(
|
||||||
return domain.Course{}, err
|
return domain.Course{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return domain.Course{
|
return mapCourse(row), nil
|
||||||
ID: row.ID,
|
|
||||||
CategoryID: row.CategoryID,
|
|
||||||
Title: row.Title,
|
|
||||||
Description: &row.Description.String,
|
|
||||||
Thumbnail: &row.Thumbnail.String,
|
|
||||||
IsActive: row.IsActive,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) GetCoursesByCategory(
|
func (s *Store) GetCoursesByCategory(
|
||||||
|
|
@ -91,12 +82,13 @@ func (s *Store) GetCoursesByCategory(
|
||||||
}
|
}
|
||||||
|
|
||||||
courses = append(courses, domain.Course{
|
courses = append(courses, domain.Course{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
CategoryID: row.CategoryID,
|
CategoryID: row.CategoryID,
|
||||||
Title: row.Title,
|
Title: row.Title,
|
||||||
Description: &row.Description.String,
|
Description: ptrText(row.Description),
|
||||||
Thumbnail: &row.Thumbnail.String,
|
Thumbnail: ptrText(row.Thumbnail),
|
||||||
IsActive: row.IsActive,
|
IntroVideoURL: ptrText(row.IntroVideoUrl),
|
||||||
|
IsActive: row.IsActive,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -109,13 +101,15 @@ func (s *Store) UpdateCourse(
|
||||||
title *string,
|
title *string,
|
||||||
description *string,
|
description *string,
|
||||||
thumbnail *string,
|
thumbnail *string,
|
||||||
|
introVideoURL *string,
|
||||||
isActive *bool,
|
isActive *bool,
|
||||||
) error {
|
) error {
|
||||||
var (
|
var (
|
||||||
titleVal string
|
titleVal string
|
||||||
descriptionVal string
|
descriptionVal string
|
||||||
thumbnailVal string
|
thumbnailVal string
|
||||||
isActiveVal bool
|
introVideoVal string
|
||||||
|
isActiveVal bool
|
||||||
)
|
)
|
||||||
|
|
||||||
if title != nil {
|
if title != nil {
|
||||||
|
|
@ -127,16 +121,20 @@ func (s *Store) UpdateCourse(
|
||||||
if thumbnail != nil {
|
if thumbnail != nil {
|
||||||
thumbnailVal = *thumbnail
|
thumbnailVal = *thumbnail
|
||||||
}
|
}
|
||||||
|
if introVideoURL != nil {
|
||||||
|
introVideoVal = *introVideoURL
|
||||||
|
}
|
||||||
if isActive != nil {
|
if isActive != nil {
|
||||||
isActiveVal = *isActive
|
isActiveVal = *isActive
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.queries.UpdateCourse(ctx, dbgen.UpdateCourseParams{
|
return s.queries.UpdateCourse(ctx, dbgen.UpdateCourseParams{
|
||||||
Title: titleVal,
|
Title: titleVal,
|
||||||
Description: pgtype.Text{String: descriptionVal, Valid: description != nil},
|
Description: pgtype.Text{String: descriptionVal, Valid: description != nil},
|
||||||
Thumbnail: pgtype.Text{String: thumbnailVal, Valid: thumbnail != nil},
|
Thumbnail: pgtype.Text{String: thumbnailVal, Valid: thumbnail != nil},
|
||||||
IsActive: isActiveVal,
|
IntroVideoUrl: pgtype.Text{String: introVideoVal, Valid: introVideoURL != nil},
|
||||||
ID: id,
|
IsActive: isActiveVal,
|
||||||
|
ID: id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,3 +145,22 @@ func (s *Store) DeleteCourse(
|
||||||
|
|
||||||
return s.queries.DeleteCourse(ctx, id)
|
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
|
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(
|
func (r *Store) CountUnreadNotifications(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
userID int64,
|
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(
|
func (s *Store) GetAllUsers(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
role *string,
|
role *string,
|
||||||
|
status *string,
|
||||||
query *string,
|
query *string,
|
||||||
createdBefore, createdAfter *time.Time,
|
createdBefore, createdAfter *time.Time,
|
||||||
limit, offset int32,
|
limit, offset int32,
|
||||||
) ([]domain.User, int64, error) {
|
) ([]domain.User, int64, error) {
|
||||||
|
|
||||||
// var roleParam sql.NullString
|
var roleParam pgtype.Text
|
||||||
// if role != nil && *role != "" {
|
if role != nil && *role != "" {
|
||||||
// roleParam = sql.NullString{String: *role, Valid: true}
|
roleParam = pgtype.Text{String: *role, Valid: true}
|
||||||
// } else {
|
}
|
||||||
// roleParam = sql.NullString{Valid: false} // This will make $1 IS NULL work
|
|
||||||
// }
|
|
||||||
|
|
||||||
// var queryParam sql.NullString
|
var statusParam pgtype.Text
|
||||||
// if query != nil && *query != "" {
|
if status != nil && *status != "" {
|
||||||
// queryParam = sql.NullString{String: *query, Valid: true}
|
statusParam = pgtype.Text{String: *status, Valid: true}
|
||||||
// } else {
|
}
|
||||||
// queryParam = sql.NullString{Valid: false}
|
|
||||||
// }
|
|
||||||
|
|
||||||
// var createdAfterParam sql.NullTime
|
var queryParam pgtype.Text
|
||||||
// if createdAfter != nil {
|
if query != nil && *query != "" {
|
||||||
// createdAfterParam = sql.NullTime{Time: *createdAfter, Valid: true}
|
queryParam = pgtype.Text{String: *query, Valid: true}
|
||||||
// } else {
|
}
|
||||||
// createdAfterParam = sql.NullTime{Valid: false}
|
|
||||||
// }
|
|
||||||
|
|
||||||
// var createdBeforeParam sql.NullTime
|
var createdAfterParam pgtype.Timestamptz
|
||||||
// if createdBefore != nil {
|
if createdAfter != nil {
|
||||||
// createdBeforeParam = sql.NullTime{Time: *createdBefore, Valid: true}
|
createdAfterParam = pgtype.Timestamptz{Time: *createdAfter, Valid: true}
|
||||||
// } else {
|
}
|
||||||
// createdBeforeParam = sql.NullTime{Valid: false}
|
|
||||||
// }
|
var createdBeforeParam pgtype.Timestamptz
|
||||||
|
if createdBefore != nil {
|
||||||
|
createdBeforeParam = pgtype.Timestamptz{Time: *createdBefore, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
params := dbgen.GetAllUsersParams{
|
params := dbgen.GetAllUsersParams{
|
||||||
// Role: pgtype.Text{
|
Role: roleParam,
|
||||||
// String: roleParam.String,
|
Status: statusParam,
|
||||||
// Valid: roleParam.String != "",
|
Query: queryParam,
|
||||||
// },
|
CreatedAfter: createdAfterParam,
|
||||||
// Query: queryParam.String,
|
CreatedBefore: createdBeforeParam,
|
||||||
// CreatedAfter: pgtype.Timestamptz{
|
|
||||||
// Time: createdAfterParam.Time,
|
|
||||||
// Valid: createdAfterParam.Valid,
|
|
||||||
// },
|
|
||||||
// CreatedBefore: pgtype.Timestamptz{
|
|
||||||
// Time: createdBeforeParam.Time,
|
|
||||||
// Valid: createdBeforeParam.Valid,
|
|
||||||
// },
|
|
||||||
Limit: pgtype.Int4{
|
Limit: pgtype.Int4{
|
||||||
Int32: limit,
|
Int32: limit,
|
||||||
Valid: true,
|
Valid: true,
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,9 @@ func (s *Service) CreateCourse(
|
||||||
title string,
|
title string,
|
||||||
description *string,
|
description *string,
|
||||||
thumbnail *string,
|
thumbnail *string,
|
||||||
|
introVideoURL *string,
|
||||||
) (domain.Course, error) {
|
) (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(
|
func (s *Service) GetCourseByID(
|
||||||
|
|
@ -37,9 +38,10 @@ func (s *Service) UpdateCourse(
|
||||||
title *string,
|
title *string,
|
||||||
description *string,
|
description *string,
|
||||||
thumbnail *string,
|
thumbnail *string,
|
||||||
|
introVideoURL *string,
|
||||||
isActive *bool,
|
isActive *bool,
|
||||||
) error {
|
) 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(
|
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 {
|
type Service struct {
|
||||||
userStore ports.UserStore
|
userStore ports.UserStore
|
||||||
courseStore ports.CourseStore
|
courseStore ports.CourseStore
|
||||||
|
progressionStore ports.ProgressionStore
|
||||||
notificationSvc *notificationservice.Service
|
notificationSvc *notificationservice.Service
|
||||||
vimeoSvc *vimeoservice.Service
|
vimeoSvc *vimeoservice.Service
|
||||||
cloudConvertSvc *cloudconvertservice.Service
|
cloudConvertSvc *cloudconvertservice.Service
|
||||||
|
|
@ -20,12 +21,14 @@ type Service struct {
|
||||||
func NewService(
|
func NewService(
|
||||||
userStore ports.UserStore,
|
userStore ports.UserStore,
|
||||||
courseStore ports.CourseStore,
|
courseStore ports.CourseStore,
|
||||||
|
progressionStore ports.ProgressionStore,
|
||||||
notificationSvc *notificationservice.Service,
|
notificationSvc *notificationservice.Service,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
) *Service {
|
) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
userStore: userStore,
|
userStore: userStore,
|
||||||
courseStore: courseStore,
|
courseStore: courseStore,
|
||||||
|
progressionStore: progressionStore,
|
||||||
notificationSvc: notificationSvc,
|
notificationSvc: notificationSvc,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,20 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Service) SendEmail(ctx context.Context, receiverEmail, message string, messageHTML string, subject string) error {
|
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
|
apiKey := s.config.ResendApiKey
|
||||||
client := resend.NewClient(apiKey)
|
client := resend.NewClient(apiKey)
|
||||||
formattedSenderEmail := "Y <" + s.config.ResendSenderEmail + ">"
|
formattedSenderEmail := "Y <" + s.config.ResendSenderEmail + ">"
|
||||||
params := &resend.SendEmailRequest{
|
params := &resend.SendEmailRequest{
|
||||||
From: formattedSenderEmail,
|
From: formattedSenderEmail,
|
||||||
To: []string{receiverEmail},
|
To: []string{receiverEmail},
|
||||||
Subject: subject,
|
Subject: subject,
|
||||||
Text: message,
|
Text: message,
|
||||||
Html: messageHTML,
|
Html: messageHTML,
|
||||||
|
Attachments: attachments,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := client.Emails.Send(params)
|
_, err := client.Emails.Send(params)
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import (
|
||||||
"firebase.google.com/go/v4/messaging"
|
"firebase.google.com/go/v4/messaging"
|
||||||
afro "github.com/amanuelabay/afrosms-go"
|
afro "github.com/amanuelabay/afrosms-go"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/resend/resend-go/v2"
|
||||||
"google.golang.org/api/option"
|
"google.golang.org/api/option"
|
||||||
// "github.com/redis/go-redis/v9"
|
// "github.com/redis/go-redis/v9"
|
||||||
)
|
)
|
||||||
|
|
@ -78,6 +79,7 @@ func New(
|
||||||
|
|
||||||
go hub.Run()
|
go hub.Run()
|
||||||
go svc.startWorker()
|
go svc.startWorker()
|
||||||
|
go svc.startSchedulerWorker()
|
||||||
// go svc.startRetryWorker()
|
// go svc.startRetryWorker()
|
||||||
// go svc.RunRedisSubscriber(context.Background())
|
// go svc.RunRedisSubscriber(context.Background())
|
||||||
// go svc.StartKafkaConsumer(context.Background())
|
// go svc.StartKafkaConsumer(context.Background())
|
||||||
|
|
@ -233,6 +235,35 @@ func (s *Service) addConnection(recipientID int64, c *websocket.Conn) error {
|
||||||
return nil
|
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 {
|
func (s *Service) SendNotification(ctx context.Context, notification *domain.Notification) error {
|
||||||
|
|
||||||
notification.ID = helpers.GenerateID()
|
notification.ID = helpers.GenerateID()
|
||||||
|
|
@ -559,8 +590,9 @@ func (s *Service) SendPushNotification(ctx context.Context, notification *domain
|
||||||
// Create FCM message
|
// Create FCM message
|
||||||
message := &messaging.Message{
|
message := &messaging.Message{
|
||||||
Notification: &messaging.Notification{
|
Notification: &messaging.Notification{
|
||||||
Title: notification.Payload.Headline,
|
Title: notification.Payload.Headline,
|
||||||
Body: notification.Payload.Message,
|
Body: notification.Payload.Message,
|
||||||
|
ImageURL: notification.Image,
|
||||||
},
|
},
|
||||||
Data: map[string]string{
|
Data: map[string]string{
|
||||||
"type": string(notification.Type),
|
"type": string(notification.Type),
|
||||||
|
|
@ -613,6 +645,148 @@ func (s *Service) SendPushNotification(ctx context.Context, notification *domain
|
||||||
return nil
|
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() {
|
// func (s *Service) startRetryWorker() {
|
||||||
// ticker := time.NewTicker(1 * time.Minute)
|
// ticker := time.NewTicker(1 * time.Minute)
|
||||||
// defer ticker.Stop()
|
// defer ticker.Stop()
|
||||||
|
|
@ -763,6 +937,255 @@ func (s *Service) DeleteUserNotifications(ctx context.Context, userID int64) err
|
||||||
return nil
|
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 {
|
// func (s *Service) DeleteOldNotifications(ctx context.Context) error {
|
||||||
// return s.store.DeleteOldNotifications(ctx)
|
// 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
|
role = &filter.Role
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var status *string
|
||||||
|
if filter.Status != "" {
|
||||||
|
status = &filter.Status
|
||||||
|
}
|
||||||
|
|
||||||
var query *string
|
var query *string
|
||||||
if filter.Query != "" {
|
if filter.Query != "" {
|
||||||
query = &filter.Query
|
query = &filter.Query
|
||||||
|
|
@ -77,6 +82,7 @@ func (s *Service) GetAllUsers(
|
||||||
return s.userStore.GetAllUsers(
|
return s.userStore.GetAllUsers(
|
||||||
ctx,
|
ctx,
|
||||||
role,
|
role,
|
||||||
|
status,
|
||||||
query,
|
query,
|
||||||
before,
|
before,
|
||||||
after,
|
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) {
|
func (s *Service) GetUserById(ctx context.Context, id int64) (domain.User, error) {
|
||||||
|
|
||||||
return s.userStore.GetUserByID(ctx, id)
|
return s.userStore.GetUserByID(ctx, id)
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ type UserStore interface {
|
||||||
GetAllUsers(ctx context.Context, filter domain.UserFilter) ([]domain.User, int64, error)
|
GetAllUsers(ctx context.Context, filter domain.UserFilter) ([]domain.User, int64, error)
|
||||||
GetAdminByCompanyID(ctx context.Context, companyID int64) (domain.User, error)
|
GetAdminByCompanyID(ctx context.Context, companyID int64) (domain.User, error)
|
||||||
UpdateUser(ctx context.Context, user domain.UpdateUserReq) 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
|
UpdateUserCompany(ctx context.Context, id int64, companyID int64) error
|
||||||
UpdateUserSuspend(ctx context.Context, id int64, status bool) error
|
UpdateUserSuspend(ctx context.Context, id int64, status bool) error
|
||||||
DeleteUser(ctx context.Context, id int64) error
|
DeleteUser(ctx context.Context, id int64) error
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
activitylogservice "Yimaru-Backend/internal/services/activity_log"
|
activitylogservice "Yimaru-Backend/internal/services/activity_log"
|
||||||
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
|
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
|
||||||
ratingsservice "Yimaru-Backend/internal/services/ratings"
|
ratingsservice "Yimaru-Backend/internal/services/ratings"
|
||||||
|
rbacservice "Yimaru-Backend/internal/services/rbac"
|
||||||
"Yimaru-Backend/internal/services/arifpay"
|
"Yimaru-Backend/internal/services/arifpay"
|
||||||
"Yimaru-Backend/internal/services/assessment"
|
"Yimaru-Backend/internal/services/assessment"
|
||||||
"Yimaru-Backend/internal/services/authentication"
|
"Yimaru-Backend/internal/services/authentication"
|
||||||
|
|
@ -60,6 +61,7 @@ type App struct {
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
mongoLoggerSvc *zap.Logger
|
mongoLoggerSvc *zap.Logger
|
||||||
analyticsDB *dbgen.Queries
|
analyticsDB *dbgen.Queries
|
||||||
|
rbacSvc *rbacservice.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewApp(
|
func NewApp(
|
||||||
|
|
@ -86,6 +88,7 @@ func NewApp(
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
mongoLoggerSvc *zap.Logger,
|
mongoLoggerSvc *zap.Logger,
|
||||||
analyticsDB *dbgen.Queries,
|
analyticsDB *dbgen.Queries,
|
||||||
|
rbacSvc *rbacservice.Service,
|
||||||
) *App {
|
) *App {
|
||||||
app := fiber.New(fiber.Config{
|
app := fiber.New(fiber.Config{
|
||||||
CaseSensitive: true,
|
CaseSensitive: true,
|
||||||
|
|
@ -131,6 +134,7 @@ func NewApp(
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
mongoLoggerSvc: mongoLoggerSvc,
|
mongoLoggerSvc: mongoLoggerSvc,
|
||||||
analyticsDB: analyticsDB,
|
analyticsDB: analyticsDB,
|
||||||
|
rbacSvc: rbacSvc,
|
||||||
}
|
}
|
||||||
|
|
||||||
s.initAppRoutes()
|
s.initAppRoutes()
|
||||||
|
|
|
||||||
|
|
@ -277,19 +277,21 @@ func (h *Handler) DeleteCourseCategory(c *fiber.Ctx) error {
|
||||||
// Course Handlers
|
// Course Handlers
|
||||||
|
|
||||||
type createCourseReq struct {
|
type createCourseReq struct {
|
||||||
CategoryID int64 `json:"category_id" validate:"required"`
|
CategoryID int64 `json:"category_id" validate:"required"`
|
||||||
Title string `json:"title" validate:"required"`
|
Title string `json:"title" validate:"required"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Thumbnail *string `json:"thumbnail"`
|
Thumbnail *string `json:"thumbnail"`
|
||||||
|
IntroVideoURL *string `json:"intro_video_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type courseRes struct {
|
type courseRes struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
CategoryID int64 `json:"category_id"`
|
CategoryID int64 `json:"category_id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Thumbnail *string `json:"thumbnail"`
|
Thumbnail *string `json:"thumbnail"`
|
||||||
IsActive bool `json:"is_active"`
|
IntroVideoURL *string `json:"intro_video_url,omitempty"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateCourse godoc
|
// CreateCourse godoc
|
||||||
|
|
@ -312,7 +314,7 @@ func (h *Handler) CreateCourse(c *fiber.Ctx) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
course, err := h.courseMgmtSvc.CreateCourse(c.Context(), req.CategoryID, req.Title, req.Description, req.Thumbnail)
|
course, err := h.courseMgmtSvc.CreateCourse(c.Context(), req.CategoryID, req.Title, req.Description, req.Thumbnail, req.IntroVideoURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
Message: "Failed to create course",
|
Message: "Failed to create course",
|
||||||
|
|
@ -340,12 +342,13 @@ func (h *Handler) CreateCourse(c *fiber.Ctx) error {
|
||||||
return c.Status(fiber.StatusCreated).JSON(domain.Response{
|
return c.Status(fiber.StatusCreated).JSON(domain.Response{
|
||||||
Message: "Course created successfully",
|
Message: "Course created successfully",
|
||||||
Data: courseRes{
|
Data: courseRes{
|
||||||
ID: course.ID,
|
ID: course.ID,
|
||||||
CategoryID: course.CategoryID,
|
CategoryID: course.CategoryID,
|
||||||
Title: course.Title,
|
Title: course.Title,
|
||||||
Description: course.Description,
|
Description: course.Description,
|
||||||
Thumbnail: course.Thumbnail,
|
Thumbnail: course.Thumbnail,
|
||||||
IsActive: course.IsActive,
|
IntroVideoURL: course.IntroVideoURL,
|
||||||
|
IsActive: course.IsActive,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -382,12 +385,13 @@ func (h *Handler) GetCourseByID(c *fiber.Ctx) error {
|
||||||
return c.JSON(domain.Response{
|
return c.JSON(domain.Response{
|
||||||
Message: "Course retrieved successfully",
|
Message: "Course retrieved successfully",
|
||||||
Data: courseRes{
|
Data: courseRes{
|
||||||
ID: course.ID,
|
ID: course.ID,
|
||||||
CategoryID: course.CategoryID,
|
CategoryID: course.CategoryID,
|
||||||
Title: course.Title,
|
Title: course.Title,
|
||||||
Description: course.Description,
|
Description: course.Description,
|
||||||
Thumbnail: course.Thumbnail,
|
Thumbnail: course.Thumbnail,
|
||||||
IsActive: course.IsActive,
|
IntroVideoURL: course.IntroVideoURL,
|
||||||
|
IsActive: course.IsActive,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -449,12 +453,13 @@ func (h *Handler) GetCoursesByCategory(c *fiber.Ctx) error {
|
||||||
var courseResponses []courseRes
|
var courseResponses []courseRes
|
||||||
for _, course := range courses {
|
for _, course := range courses {
|
||||||
courseResponses = append(courseResponses, courseRes{
|
courseResponses = append(courseResponses, courseRes{
|
||||||
ID: course.ID,
|
ID: course.ID,
|
||||||
CategoryID: course.CategoryID,
|
CategoryID: course.CategoryID,
|
||||||
Title: course.Title,
|
Title: course.Title,
|
||||||
Description: course.Description,
|
Description: course.Description,
|
||||||
Thumbnail: course.Thumbnail,
|
Thumbnail: course.Thumbnail,
|
||||||
IsActive: course.IsActive,
|
IntroVideoURL: course.IntroVideoURL,
|
||||||
|
IsActive: course.IsActive,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -468,10 +473,11 @@ func (h *Handler) GetCoursesByCategory(c *fiber.Ctx) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
type updateCourseReq struct {
|
type updateCourseReq struct {
|
||||||
Title *string `json:"title"`
|
Title *string `json:"title"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Thumbnail *string `json:"thumbnail"`
|
Thumbnail *string `json:"thumbnail"`
|
||||||
IsActive *bool `json:"is_active"`
|
IntroVideoURL *string `json:"intro_video_url"`
|
||||||
|
IsActive *bool `json:"is_active"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateCourse godoc
|
// UpdateCourse godoc
|
||||||
|
|
@ -504,7 +510,7 @@ func (h *Handler) UpdateCourse(c *fiber.Ctx) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
err = h.courseMgmtSvc.UpdateCourse(c.Context(), id, req.Title, req.Description, req.Thumbnail, req.IsActive)
|
err = h.courseMgmtSvc.UpdateCourse(c.Context(), id, req.Title, req.Description, req.Thumbnail, req.IntroVideoURL, req.IsActive)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
Message: "Failed to update course",
|
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 {
|
for _, v := range videos {
|
||||||
var publishDate *string
|
videoResponses = append(videoResponses, mapVideoToResponse(v))
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(domain.Response{
|
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 {
|
for _, v := range videos {
|
||||||
var publishDate *string
|
videoResponses = append(videoResponses, mapVideoToResponse(v))
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(domain.Response{
|
return c.JSON(domain.Response{
|
||||||
|
|
@ -1725,7 +1689,7 @@ func (h *Handler) UploadCourseThumbnail(c *fiber.Ctx) error {
|
||||||
return err
|
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))
|
_ = os.Remove(filepath.Join(".", publicPath))
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
Message: "Failed to update course thumbnail",
|
Message: "Failed to update course thumbnail",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import (
|
||||||
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
|
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
|
||||||
issuereporting "Yimaru-Backend/internal/services/issue_reporting"
|
issuereporting "Yimaru-Backend/internal/services/issue_reporting"
|
||||||
ratingsservice "Yimaru-Backend/internal/services/ratings"
|
ratingsservice "Yimaru-Backend/internal/services/ratings"
|
||||||
|
rbacservice "Yimaru-Backend/internal/services/rbac"
|
||||||
notificationservice "Yimaru-Backend/internal/services/notification"
|
notificationservice "Yimaru-Backend/internal/services/notification"
|
||||||
"Yimaru-Backend/internal/services/questions"
|
"Yimaru-Backend/internal/services/questions"
|
||||||
"Yimaru-Backend/internal/services/recommendation"
|
"Yimaru-Backend/internal/services/recommendation"
|
||||||
|
|
@ -53,6 +54,7 @@ type Handler struct {
|
||||||
issueReportingSvc *issuereporting.Service
|
issueReportingSvc *issuereporting.Service
|
||||||
cloudConvertSvc *cloudconvertservice.Service
|
cloudConvertSvc *cloudconvertservice.Service
|
||||||
ratingSvc *ratingsservice.Service
|
ratingSvc *ratingsservice.Service
|
||||||
|
rbacSvc *rbacservice.Service
|
||||||
jwtConfig jwtutil.JwtConfig
|
jwtConfig jwtutil.JwtConfig
|
||||||
validator *customvalidator.CustomValidator
|
validator *customvalidator.CustomValidator
|
||||||
Cfg *config.Config
|
Cfg *config.Config
|
||||||
|
|
@ -80,6 +82,7 @@ func New(
|
||||||
issueReportingSvc *issuereporting.Service,
|
issueReportingSvc *issuereporting.Service,
|
||||||
cloudConvertSvc *cloudconvertservice.Service,
|
cloudConvertSvc *cloudconvertservice.Service,
|
||||||
ratingSvc *ratingsservice.Service,
|
ratingSvc *ratingsservice.Service,
|
||||||
|
rbacSvc *rbacservice.Service,
|
||||||
jwtConfig jwtutil.JwtConfig,
|
jwtConfig jwtutil.JwtConfig,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
mongoLoggerSvc *zap.Logger,
|
mongoLoggerSvc *zap.Logger,
|
||||||
|
|
@ -105,6 +108,7 @@ func New(
|
||||||
issueReportingSvc: issueReportingSvc,
|
issueReportingSvc: issueReportingSvc,
|
||||||
cloudConvertSvc: cloudConvertSvc,
|
cloudConvertSvc: cloudConvertSvc,
|
||||||
ratingSvc: ratingSvc,
|
ratingSvc: ratingSvc,
|
||||||
|
rbacSvc: rbacSvc,
|
||||||
jwtConfig: jwtConfig,
|
jwtConfig: jwtConfig,
|
||||||
Cfg: cfg,
|
Cfg: cfg,
|
||||||
mongoLoggerSvc: mongoLoggerSvc,
|
mongoLoggerSvc: mongoLoggerSvc,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
@ -14,6 +15,7 @@ import (
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/resend/resend-go/v2"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -507,32 +509,54 @@ func (h *Handler) GetAllNotifications(c *fiber.Ctx) error {
|
||||||
limitStr := c.Query("limit", "10")
|
limitStr := c.Query("limit", "10")
|
||||||
pageStr := c.Query("page", "1")
|
pageStr := c.Query("page", "1")
|
||||||
|
|
||||||
// Convert limit and offset to integers
|
|
||||||
limit, err := strconv.Atoi(limitStr)
|
limit, err := strconv.Atoi(limitStr)
|
||||||
if err != nil || limit <= 0 {
|
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")
|
return fiber.NewError(fiber.StatusBadRequest, "Invalid limit value")
|
||||||
}
|
}
|
||||||
page, err := strconv.Atoi(pageStr)
|
page, err := strconv.Atoi(pageStr)
|
||||||
if err != nil || page <= 0 {
|
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")
|
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 {
|
if err != nil {
|
||||||
h.mongoLoggerSvc.Error("[NotificationSvc.GetNotifications] Failed to fetch notifications",
|
h.mongoLoggerSvc.Error("[NotificationSvc.GetNotifications] Failed to fetch notifications",
|
||||||
zap.Int64("limit", int64(limit)),
|
|
||||||
zap.Int("status_code", fiber.StatusInternalServerError),
|
zap.Int("status_code", fiber.StatusInternalServerError),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
zap.Time("timestamp", time.Now()),
|
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{
|
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||||
"notifications": notifications,
|
"notifications": notifications,
|
||||||
"total_count": len(notifications),
|
"total_count": total,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"page": page,
|
"page": page,
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SendSingleAfroSMSReq struct {
|
type SendSingleAfroSMSReq struct {
|
||||||
|
|
@ -684,25 +707,8 @@ func (h *Handler) RegisterDeviceToken(c *fiber.Ctx) error {
|
||||||
// @Failure 500 {object} domain.ErrorResponse
|
// @Failure 500 {object} domain.ErrorResponse
|
||||||
// @Router /api/v1/notifications/test-push [post]
|
// @Router /api/v1/notifications/test-push [post]
|
||||||
func (h *Handler) SendTestPushNotification(c *fiber.Ctx) error {
|
func (h *Handler) SendTestPushNotification(c *fiber.Ctx) error {
|
||||||
type Request struct {
|
title := c.FormValue("title", "Test Push Notification")
|
||||||
Title string `json:"title"`
|
message := c.FormValue("message", "This is a test push notification from Yimaru Backend")
|
||||||
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"
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, ok := c.Locals("user_id").(int64)
|
userID, ok := c.Locals("user_id").(int64)
|
||||||
if !ok || userID == 0 {
|
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
|
// Create test notification
|
||||||
notification := &domain.Notification{
|
notification := &domain.Notification{
|
||||||
RecipientID: userID,
|
RecipientID: userID,
|
||||||
Type: "system_alert",
|
Type: "system_alert",
|
||||||
DeliveryChannel: domain.DeliveryChannelPush,
|
DeliveryChannel: domain.DeliveryChannelPush,
|
||||||
|
Image: imageURL,
|
||||||
Payload: domain.NotificationPayload{
|
Payload: domain.NotificationPayload{
|
||||||
Headline: req.Title,
|
Headline: title,
|
||||||
Message: req.Message,
|
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",
|
h.mongoLoggerSvc.Info("[NotificationHandler.SendTestPushNotification] Test push sent",
|
||||||
zap.Int64("userID", userID),
|
zap.Int64("userID", userID),
|
||||||
zap.Int("deviceCount", len(tokens)),
|
zap.Int("deviceCount", len(tokens)),
|
||||||
|
|
@ -760,8 +780,746 @@ func (h *Handler) SendTestPushNotification(c *fiber.Ctx) error {
|
||||||
StatusCode: fiber.StatusOK,
|
StatusCode: fiber.StatusOK,
|
||||||
Data: map[string]interface{}{
|
Data: map[string]interface{}{
|
||||||
"devices_count": len(tokens),
|
"devices_count": len(tokens),
|
||||||
"title": req.Title,
|
"title": title,
|
||||||
"message": req.Message,
|
"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 page_size query int false "Page size"
|
||||||
// @Param created_before query string false "Created before (RFC3339)"
|
// @Param created_before query string false "Created before (RFC3339)"
|
||||||
// @Param created_after query string false "Created after (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
|
// @Success 200 {object} response.APIResponse
|
||||||
// @Failure 400 {object} response.APIResponse
|
// @Failure 400 {object} response.APIResponse
|
||||||
// @Failure 500 {object} response.APIResponse
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
|
@ -440,6 +441,7 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
|
||||||
|
|
||||||
filter := domain.UserFilter{
|
filter := domain.UserFilter{
|
||||||
Role: c.Query("role"),
|
Role: c.Query("role"),
|
||||||
|
Status: c.Query("status"),
|
||||||
Page: int64(c.QueryInt("page", 1) - 1),
|
Page: int64(c.QueryInt("page", 1) - 1),
|
||||||
PageSize: int64(c.QueryInt("page_size", 10)),
|
PageSize: int64(c.QueryInt("page_size", 10)),
|
||||||
Query: searchString.Value,
|
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)
|
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
|
// VerifyOtp godoc
|
||||||
// @Summary Verify OTP
|
// @Summary Verify OTP
|
||||||
// @Description Verify OTP for registration or other actions
|
// @Description Verify OTP for registration or other actions
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,29 @@ func (a *App) OnlyAdminAndAbove(c *fiber.Ctx) error {
|
||||||
return c.Next()
|
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 {
|
func (a *App) OnlyBranchManagerAndAbove(c *fiber.Ctx) error {
|
||||||
userID := c.Locals("user_id").(int64)
|
userID := c.Locals("user_id").(int64)
|
||||||
userRole := c.Locals("role").(domain.Role)
|
userRole := c.Locals("role").(domain.Role)
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ func (a *App) initAppRoutes() {
|
||||||
a.issueReportingSvc,
|
a.issueReportingSvc,
|
||||||
a.cloudConvertSvc,
|
a.cloudConvertSvc,
|
||||||
a.ratingSvc,
|
a.ratingSvc,
|
||||||
|
a.rbacSvc,
|
||||||
a.JwtConfig,
|
a.JwtConfig,
|
||||||
a.cfg,
|
a.cfg,
|
||||||
a.mongoLoggerSvc,
|
a.mongoLoggerSvc,
|
||||||
|
|
@ -65,116 +66,110 @@ func (a *App) initAppRoutes() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Assessment questions
|
// Assessment questions (public)
|
||||||
groupV1.Post("/assessment/questions", h.CreateAssessmentQuestion)
|
groupV1.Post("/assessment/questions", h.CreateAssessmentQuestion)
|
||||||
groupV1.Get("/assessment/questions", h.ListAssessmentQuestions)
|
groupV1.Get("/assessment/questions", h.ListAssessmentQuestions)
|
||||||
groupV1.Get("/assessment/questions/:id", h.GetAssessmentQuestionByID)
|
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
|
// Course Categories
|
||||||
groupV1.Post("/course-management/categories", a.authMiddleware, h.CreateCourseCategory)
|
groupV1.Post("/course-management/categories", a.authMiddleware, a.RequirePermission("course_categories.create"), h.CreateCourseCategory)
|
||||||
groupV1.Get("/course-management/categories", a.authMiddleware, h.GetAllCourseCategories)
|
groupV1.Get("/course-management/categories", a.authMiddleware, a.RequirePermission("course_categories.list"), h.GetAllCourseCategories)
|
||||||
groupV1.Get("/course-management/categories/:id", a.authMiddleware, h.GetCourseCategoryByID)
|
groupV1.Get("/course-management/categories/:id", a.authMiddleware, a.RequirePermission("course_categories.get"), h.GetCourseCategoryByID)
|
||||||
groupV1.Put("/course-management/categories/:id", a.authMiddleware, h.UpdateCourseCategory)
|
groupV1.Put("/course-management/categories/:id", a.authMiddleware, a.RequirePermission("course_categories.update"), h.UpdateCourseCategory)
|
||||||
groupV1.Delete("/course-management/categories/:id", a.authMiddleware, h.DeleteCourseCategory)
|
groupV1.Delete("/course-management/categories/:id", a.authMiddleware, a.RequirePermission("course_categories.delete"), h.DeleteCourseCategory)
|
||||||
|
|
||||||
// Courses
|
// Courses
|
||||||
groupV1.Post("/course-management/courses", a.authMiddleware, h.CreateCourse)
|
groupV1.Post("/course-management/courses", a.authMiddleware, a.RequirePermission("courses.create"), h.CreateCourse)
|
||||||
groupV1.Get("/course-management/courses/:id", a.authMiddleware, h.GetCourseByID)
|
groupV1.Get("/course-management/courses/:id", a.authMiddleware, a.RequirePermission("courses.get"), h.GetCourseByID)
|
||||||
groupV1.Get("/course-management/categories/:categoryId/courses", a.authMiddleware, h.GetCoursesByCategory)
|
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, h.UpdateCourse)
|
groupV1.Put("/course-management/courses/:id", a.authMiddleware, a.RequirePermission("courses.update"), h.UpdateCourse)
|
||||||
groupV1.Post("/course-management/courses/:id/thumbnail", a.authMiddleware, h.UploadCourseThumbnail)
|
groupV1.Post("/course-management/courses/:id/thumbnail", a.authMiddleware, a.RequirePermission("courses.upload_thumbnail"), h.UploadCourseThumbnail)
|
||||||
groupV1.Delete("/course-management/courses/:id", a.authMiddleware, h.DeleteCourse)
|
groupV1.Delete("/course-management/courses/:id", a.authMiddleware, a.RequirePermission("courses.delete"), h.DeleteCourse)
|
||||||
|
|
||||||
// Sub-courses
|
// Sub-courses
|
||||||
groupV1.Post("/course-management/sub-courses", a.authMiddleware, h.CreateSubCourse)
|
groupV1.Post("/course-management/sub-courses", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateSubCourse)
|
||||||
groupV1.Get("/course-management/sub-courses/:id", a.authMiddleware, h.GetSubCourseByID)
|
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, h.GetSubCoursesByCourse)
|
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, h.ListSubCoursesByCourse)
|
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, h.ListActiveSubCourses)
|
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, h.UpdateSubCourse)
|
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, h.UploadSubCourseThumbnail)
|
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, h.DeactivateSubCourse)
|
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, h.DeleteSubCourse)
|
groupV1.Delete("/course-management/sub-courses/:id", a.authMiddleware, a.RequirePermission("subcourses.delete"), h.DeleteSubCourse)
|
||||||
|
|
||||||
// Sub-course Videos
|
// Sub-course Videos
|
||||||
groupV1.Post("/course-management/videos", a.authMiddleware, h.CreateSubCourseVideo)
|
groupV1.Post("/course-management/videos", a.authMiddleware, a.RequirePermission("videos.create"), h.CreateSubCourseVideo)
|
||||||
groupV1.Post("/course-management/videos/vimeo", a.authMiddleware, h.CreateSubCourseVideoWithVimeo)
|
groupV1.Post("/course-management/videos/vimeo", a.authMiddleware, a.RequirePermission("videos.create_vimeo"), h.CreateSubCourseVideoWithVimeo)
|
||||||
groupV1.Post("/course-management/videos/upload", a.authMiddleware, h.UploadSubCourseVideo)
|
groupV1.Post("/course-management/videos/upload", a.authMiddleware, a.RequirePermission("videos.upload"), h.UploadSubCourseVideo)
|
||||||
groupV1.Post("/course-management/videos/vimeo/import", a.authMiddleware, h.CreateSubCourseVideoFromVimeoID)
|
groupV1.Post("/course-management/videos/vimeo/import", a.authMiddleware, a.RequirePermission("videos.import_vimeo"), h.CreateSubCourseVideoFromVimeoID)
|
||||||
groupV1.Get("/course-management/videos/:id", a.authMiddleware, h.GetSubCourseVideoByID)
|
groupV1.Get("/course-management/videos/:id", a.authMiddleware, a.RequirePermission("videos.get"), h.GetSubCourseVideoByID)
|
||||||
groupV1.Get("/course-management/sub-courses/:subCourseId/videos", a.authMiddleware, h.GetVideosBySubCourse)
|
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, h.GetPublishedVideosBySubCourse)
|
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, h.PublishSubCourseVideo)
|
groupV1.Put("/course-management/videos/:id/publish", a.authMiddleware, a.RequirePermission("videos.publish"), h.PublishSubCourseVideo)
|
||||||
groupV1.Put("/course-management/videos/:id", a.authMiddleware, h.UpdateSubCourseVideo)
|
groupV1.Put("/course-management/videos/:id", a.authMiddleware, a.RequirePermission("videos.update"), h.UpdateSubCourseVideo)
|
||||||
groupV1.Delete("/course-management/videos/:id", a.authMiddleware, h.DeleteSubCourseVideo)
|
groupV1.Delete("/course-management/videos/:id", a.authMiddleware, a.RequirePermission("videos.delete"), h.DeleteSubCourseVideo)
|
||||||
|
|
||||||
// Learning Tree
|
// 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
|
// Questions
|
||||||
groupV1.Post("/questions", a.authMiddleware, h.CreateQuestion)
|
groupV1.Post("/questions", a.authMiddleware, a.RequirePermission("questions.create"), h.CreateQuestion)
|
||||||
groupV1.Get("/questions", a.authMiddleware, h.ListQuestions)
|
groupV1.Get("/questions", a.authMiddleware, a.RequirePermission("questions.list"), h.ListQuestions)
|
||||||
groupV1.Get("/questions/search", a.authMiddleware, h.SearchQuestions)
|
groupV1.Get("/questions/search", a.authMiddleware, a.RequirePermission("questions.search"), h.SearchQuestions)
|
||||||
groupV1.Get("/questions/:id", a.authMiddleware, h.GetQuestionByID)
|
groupV1.Get("/questions/:id", a.authMiddleware, a.RequirePermission("questions.get"), h.GetQuestionByID)
|
||||||
groupV1.Put("/questions/:id", a.authMiddleware, h.UpdateQuestion)
|
groupV1.Put("/questions/:id", a.authMiddleware, a.RequirePermission("questions.update"), h.UpdateQuestion)
|
||||||
groupV1.Delete("/questions/:id", a.authMiddleware, h.DeleteQuestion)
|
groupV1.Delete("/questions/:id", a.authMiddleware, a.RequirePermission("questions.delete"), h.DeleteQuestion)
|
||||||
|
|
||||||
// Question Sets (replaces Practices for question grouping)
|
// Question Sets
|
||||||
groupV1.Post("/question-sets", a.authMiddleware, h.CreateQuestionSet)
|
groupV1.Post("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.create"), h.CreateQuestionSet)
|
||||||
groupV1.Get("/question-sets", a.authMiddleware, h.GetQuestionSetsByType)
|
groupV1.Get("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetQuestionSetsByType)
|
||||||
groupV1.Get("/question-sets/by-owner", a.authMiddleware, h.GetQuestionSetsByOwner)
|
groupV1.Get("/question-sets/by-owner", a.authMiddleware, a.RequirePermission("question_sets.list_by_owner"), h.GetQuestionSetsByOwner)
|
||||||
groupV1.Get("/question-sets/:id", a.authMiddleware, h.GetQuestionSetByID)
|
groupV1.Get("/question-sets/:id", a.authMiddleware, a.RequirePermission("question_sets.get"), h.GetQuestionSetByID)
|
||||||
groupV1.Put("/question-sets/:id", a.authMiddleware, h.UpdateQuestionSet)
|
groupV1.Put("/question-sets/:id", a.authMiddleware, a.RequirePermission("question_sets.update"), h.UpdateQuestionSet)
|
||||||
groupV1.Delete("/question-sets/:id", a.authMiddleware, h.DeleteQuestionSet)
|
groupV1.Delete("/question-sets/:id", a.authMiddleware, a.RequirePermission("question_sets.delete"), h.DeleteQuestionSet)
|
||||||
|
|
||||||
// Question Set Items (questions within sets)
|
// Question Set Items
|
||||||
groupV1.Post("/question-sets/:setId/questions", a.authMiddleware, h.AddQuestionToSet)
|
groupV1.Post("/question-sets/:setId/questions", a.authMiddleware, a.RequirePermission("question_set_items.add"), h.AddQuestionToSet)
|
||||||
groupV1.Get("/question-sets/:setId/questions", a.authMiddleware, h.GetQuestionsInSet)
|
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, h.RemoveQuestionFromSet)
|
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, h.UpdateQuestionOrderInSet)
|
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)
|
// Question Set Personas
|
||||||
groupV1.Get("/question-sets/:setId/personas", a.authMiddleware, h.GetUserPersonasByQuestionSet)
|
groupV1.Get("/question-sets/:setId/personas", a.authMiddleware, a.RequirePermission("question_set_personas.list"), h.GetUserPersonasByQuestionSet)
|
||||||
groupV1.Post("/question-sets/:setId/personas", a.authMiddleware, h.AddUserPersonaToQuestionSet)
|
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, h.RemoveUserPersonaFromQuestionSet)
|
groupV1.Delete("/question-sets/:setId/personas/:userId", a.authMiddleware, a.RequirePermission("question_set_personas.remove"), h.RemoveUserPersonaFromQuestionSet)
|
||||||
|
|
||||||
// Subscription Plans (admin)
|
// Subscription Plans
|
||||||
groupV1.Post("/subscription-plans", a.authMiddleware, h.CreateSubscriptionPlan)
|
groupV1.Post("/subscription-plans", a.authMiddleware, a.RequirePermission("subscription_plans.create"), h.CreateSubscriptionPlan)
|
||||||
groupV1.Get("/subscription-plans", h.ListSubscriptionPlans)
|
groupV1.Get("/subscription-plans", h.ListSubscriptionPlans)
|
||||||
groupV1.Get("/subscription-plans/:id", h.GetSubscriptionPlan)
|
groupV1.Get("/subscription-plans/:id", h.GetSubscriptionPlan)
|
||||||
groupV1.Put("/subscription-plans/:id", a.authMiddleware, h.UpdateSubscriptionPlan)
|
groupV1.Put("/subscription-plans/:id", a.authMiddleware, a.RequirePermission("subscription_plans.update"), h.UpdateSubscriptionPlan)
|
||||||
groupV1.Delete("/subscription-plans/:id", a.authMiddleware, h.DeleteSubscriptionPlan)
|
groupV1.Delete("/subscription-plans/:id", a.authMiddleware, a.RequirePermission("subscription_plans.delete"), h.DeleteSubscriptionPlan)
|
||||||
|
|
||||||
// User Subscriptions
|
// User Subscriptions
|
||||||
groupV1.Post("/subscriptions", a.authMiddleware, h.Subscribe) // Admin only - creates subscription without payment
|
groupV1.Post("/subscriptions", a.authMiddleware, a.RequirePermission("subscriptions.create"), h.Subscribe)
|
||||||
groupV1.Post("/subscriptions/checkout", a.authMiddleware, h.SubscribeWithPayment) // User - initiates payment for subscription
|
groupV1.Post("/subscriptions/checkout", a.authMiddleware, a.RequirePermission("subscriptions.checkout"), h.SubscribeWithPayment)
|
||||||
groupV1.Get("/subscriptions/me", a.authMiddleware, h.GetMySubscription)
|
groupV1.Get("/subscriptions/me", a.authMiddleware, a.RequirePermission("subscriptions.get_mine"), h.GetMySubscription)
|
||||||
groupV1.Get("/subscriptions/history", a.authMiddleware, h.GetMySubscriptionHistory)
|
groupV1.Get("/subscriptions/history", a.authMiddleware, a.RequirePermission("subscriptions.history"), h.GetMySubscriptionHistory)
|
||||||
groupV1.Get("/subscriptions/status", a.authMiddleware, h.CheckSubscriptionStatus)
|
groupV1.Get("/subscriptions/status", a.authMiddleware, a.RequirePermission("subscriptions.status"), h.CheckSubscriptionStatus)
|
||||||
groupV1.Post("/subscriptions/:id/cancel", a.authMiddleware, h.CancelSubscription)
|
groupV1.Post("/subscriptions/:id/cancel", a.authMiddleware, a.RequirePermission("subscriptions.cancel"), h.CancelSubscription)
|
||||||
groupV1.Put("/subscriptions/:id/auto-renew", a.authMiddleware, h.SetAutoRenew)
|
groupV1.Put("/subscriptions/:id/auto-renew", a.authMiddleware, a.RequirePermission("subscriptions.set_auto_renew"), h.SetAutoRenew)
|
||||||
|
|
||||||
// Payments (ArifPay Integration)
|
// Payments (ArifPay)
|
||||||
groupV1.Post("/payments/subscribe", a.authMiddleware, h.InitiateSubscriptionPayment)
|
groupV1.Post("/payments/subscribe", a.authMiddleware, a.RequirePermission("payments.initiate"), h.InitiateSubscriptionPayment)
|
||||||
groupV1.Get("/payments/verify/:session_id", a.authMiddleware, h.VerifyPayment)
|
groupV1.Get("/payments/verify/:session_id", a.authMiddleware, a.RequirePermission("payments.verify"), h.VerifyPayment)
|
||||||
groupV1.Get("/payments", a.authMiddleware, h.GetMyPayments)
|
groupV1.Get("/payments", a.authMiddleware, a.RequirePermission("payments.list_mine"), h.GetMyPayments)
|
||||||
groupV1.Get("/payments/:id", a.authMiddleware, h.GetPaymentByID)
|
groupV1.Get("/payments/:id", a.authMiddleware, a.RequirePermission("payments.get"), h.GetPaymentByID)
|
||||||
groupV1.Post("/payments/:id/cancel", a.authMiddleware, h.CancelPayment)
|
groupV1.Post("/payments/:id/cancel", a.authMiddleware, a.RequirePermission("payments.cancel"), h.CancelPayment)
|
||||||
groupV1.Get("/payments/methods", h.GetArifpayPaymentMethods)
|
groupV1.Get("/payments/methods", h.GetArifpayPaymentMethods)
|
||||||
// Webhook endpoint (no auth - called by ArifPay)
|
|
||||||
groupV1.Post("/payments/webhook", h.HandleArifpayWebhook)
|
groupV1.Post("/payments/webhook", h.HandleArifpayWebhook)
|
||||||
|
|
||||||
// Direct Payments (OTP-based - Telebirr, CBE, Amole, HelloCash, etc.)
|
// Direct Payments
|
||||||
groupV1.Post("/payments/direct", a.authMiddleware, h.InitiateDirectPayment)
|
groupV1.Post("/payments/direct", a.authMiddleware, a.RequirePermission("payments.direct_initiate"), h.InitiateDirectPayment)
|
||||||
groupV1.Post("/payments/direct/verify-otp", a.authMiddleware, h.VerifyDirectPaymentOTP)
|
groupV1.Post("/payments/direct/verify-otp", a.authMiddleware, a.RequirePermission("payments.direct_verify_otp"), h.VerifyDirectPaymentOTP)
|
||||||
groupV1.Get("/payments/direct/methods", h.GetDirectPaymentMethods)
|
groupV1.Get("/payments/direct/methods", h.GetDirectPaymentMethods)
|
||||||
|
|
||||||
// Auth Routes
|
// Auth Routes (public)
|
||||||
groupV1.Post("/auth/google/android", h.GoogleAndroidLogin)
|
groupV1.Post("/auth/google/android", h.GoogleAndroidLogin)
|
||||||
groupV1.Get("/auth/google/login", h.GoogleLogin)
|
groupV1.Get("/auth/google/login", h.GoogleLogin)
|
||||||
groupV1.Get("/auth/google/callback", h.GoogleCallback)
|
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/admin-login", h.LoginAdmin)
|
||||||
groupV1.Post("/auth/super-login", h.LoginSuper)
|
groupV1.Post("/auth/super-login", h.LoginSuper)
|
||||||
groupV1.Post("/auth/refresh", h.RefreshToken)
|
groupV1.Post("/auth/refresh", h.RefreshToken)
|
||||||
// Upload profile picture
|
groupV1.Post("/user/:id/profile-picture", a.authMiddleware, a.RequirePermission("users.upload_profile_picture"), h.UploadProfilePicture)
|
||||||
groupV1.Post("/user/:id/profile-picture", a.authMiddleware, h.UploadProfilePicture)
|
groupV1.Post("/auth/logout", a.authMiddleware, a.RequirePermission("auth.logout"), h.LogOutuser)
|
||||||
groupV1.Post("/auth/logout", a.authMiddleware, h.LogOutuser)
|
|
||||||
groupV1.Get("/auth/test", a.authMiddleware, func(c *fiber.Ctx) error {
|
groupV1.Get("/auth/test", a.authMiddleware, func(c *fiber.Ctx) error {
|
||||||
userID, ok := c.Locals("user_id").(int64)
|
userID, ok := c.Locals("user_id").(int64)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -211,22 +205,12 @@ func (a *App) initAppRoutes() {
|
||||||
return c.SendString("Test endpoint")
|
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
|
// User Routes
|
||||||
groupV1.Get("/user/:user_id/is-profile-completed", a.authMiddleware, h.CheckProfileCompleted)
|
groupV1.Get("/user/:user_id/is-profile-completed", a.authMiddleware, a.RequirePermission("users.profile_completed"), h.CheckProfileCompleted)
|
||||||
groupV1.Get("/users", a.authMiddleware, h.GetAllUsers)
|
groupV1.Get("/users", a.authMiddleware, a.RequirePermission("users.list"), h.GetAllUsers)
|
||||||
groupV1.Put("/user", a.authMiddleware, h.UpdateUser)
|
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.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.Get("/user/:user_name/is-pending", h.CheckUserPending)
|
||||||
groupV1.Post("/user/resetPassword", h.ResetPassword)
|
groupV1.Post("/user/resetPassword", h.ResetPassword)
|
||||||
groupV1.Post("/user/sendResetCode", h.SendResetCode)
|
groupV1.Post("/user/sendResetCode", h.SendResetCode)
|
||||||
|
|
@ -235,93 +219,123 @@ func (a *App) initAppRoutes() {
|
||||||
groupV1.Post("/user/register", h.RegisterUser)
|
groupV1.Post("/user/register", h.RegisterUser)
|
||||||
groupV1.Post("/user/sendRegisterCode", h.SendRegisterCode)
|
groupV1.Post("/user/sendRegisterCode", h.SendRegisterCode)
|
||||||
groupV1.Post("/user/checkPhoneEmailExist", h.CheckPhoneEmailExist)
|
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)
|
// Notifications
|
||||||
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
|
|
||||||
groupV1.Post("/sendSMS", h.SendSingleAfroSMS)
|
groupV1.Post("/sendSMS", h.SendSingleAfroSMS)
|
||||||
groupV1.Get("/ws/connect", a.WebsocketAuthMiddleware, h.ConnectSocket)
|
groupV1.Get("/ws/connect", a.WebsocketAuthMiddleware, h.ConnectSocket)
|
||||||
groupV1.Get("/notifications", a.authMiddleware, h.GetUserNotification)
|
groupV1.Get("/notifications", a.authMiddleware, a.RequirePermission("notifications.list_mine"), h.GetUserNotification)
|
||||||
groupV1.Get("/notifications/all", a.authMiddleware, h.GetAllNotifications)
|
groupV1.Get("/notifications/all", a.authMiddleware, a.RequirePermission("notifications.list_all"), h.GetAllNotifications)
|
||||||
groupV1.Patch("/notifications/:id/read", a.authMiddleware, h.MarkNotificationAsRead)
|
groupV1.Patch("/notifications/:id/read", a.authMiddleware, a.RequirePermission("notifications.mark_read"), h.MarkNotificationAsRead)
|
||||||
groupV1.Post("/notifications/mark-all-read", a.authMiddleware, h.MarkAllNotificationsAsRead)
|
groupV1.Post("/notifications/mark-all-read", a.authMiddleware, a.RequirePermission("notifications.mark_all_read"), h.MarkAllNotificationsAsRead)
|
||||||
groupV1.Patch("/notifications/:id/unread", a.authMiddleware, h.MarkNotificationAsUnread)
|
groupV1.Patch("/notifications/:id/unread", a.authMiddleware, a.RequirePermission("notifications.mark_unread"), h.MarkNotificationAsUnread)
|
||||||
groupV1.Post("/notifications/mark-all-unread", a.authMiddleware, h.MarkAllNotificationsAsUnread)
|
groupV1.Post("/notifications/mark-all-unread", a.authMiddleware, a.RequirePermission("notifications.mark_all_unread"), h.MarkAllNotificationsAsUnread)
|
||||||
groupV1.Delete("/notifications", a.authMiddleware, h.DeleteUserNotifications)
|
groupV1.Delete("/notifications", a.authMiddleware, a.RequirePermission("notifications.delete_mine"), h.DeleteUserNotifications)
|
||||||
groupV1.Get("/notifications/unread", a.authMiddleware, h.CountUnreadNotifications)
|
groupV1.Get("/notifications/unread", a.authMiddleware, a.RequirePermission("notifications.count_unread"), h.CountUnreadNotifications)
|
||||||
groupV1.Post("/notifications/create", a.authMiddleware, h.CreateAndSendNotification)
|
groupV1.Post("/notifications/create", a.authMiddleware, a.RequirePermission("notifications.create"), h.CreateAndSendNotification)
|
||||||
|
|
||||||
// Issue Reporting Routes
|
// Issues
|
||||||
groupV1.Post("/issues", a.authMiddleware, h.CreateIssue)
|
groupV1.Post("/issues", a.authMiddleware, a.RequirePermission("issues.create"), h.CreateIssue)
|
||||||
groupV1.Get("/issues/me", a.authMiddleware, h.GetMyIssues)
|
groupV1.Get("/issues/me", a.authMiddleware, a.RequirePermission("issues.list_mine"), h.GetMyIssues)
|
||||||
groupV1.Get("/issues/user/:user_id", a.authMiddleware, a.OnlyAdminAndAbove, h.GetUserIssues)
|
groupV1.Get("/issues/user/:user_id", a.authMiddleware, a.RequirePermission("issues.list_by_user"), h.GetUserIssues)
|
||||||
groupV1.Get("/issues", a.authMiddleware, a.OnlyAdminAndAbove, h.GetAllIssues)
|
groupV1.Get("/issues", a.authMiddleware, a.RequirePermission("issues.list_all"), h.GetAllIssues)
|
||||||
groupV1.Get("/issues/:id", a.authMiddleware, a.OnlyAdminAndAbove, h.GetIssueByID)
|
groupV1.Get("/issues/:id", a.authMiddleware, a.RequirePermission("issues.get"), h.GetIssueByID)
|
||||||
groupV1.Patch("/issues/:id/status", a.authMiddleware, a.OnlyAdminAndAbove, h.UpdateIssueStatus)
|
groupV1.Patch("/issues/:id/status", a.authMiddleware, a.RequirePermission("issues.update_status"), h.UpdateIssueStatus)
|
||||||
groupV1.Delete("/issues/:id", a.authMiddleware, a.OnlyAdminAndAbove, h.DeleteIssue)
|
groupV1.Delete("/issues/:id", a.authMiddleware, a.RequirePermission("issues.delete"), h.DeleteIssue)
|
||||||
|
|
||||||
// Device Token Registration
|
// Devices
|
||||||
groupV1.Post("/devices/register", a.authMiddleware, h.RegisterDeviceToken)
|
groupV1.Post("/devices/register", a.authMiddleware, a.RequirePermission("devices.register"), h.RegisterDeviceToken)
|
||||||
groupV1.Post("/devices/unregister", a.authMiddleware, h.UnregisterDeviceToken)
|
groupV1.Post("/devices/unregister", a.authMiddleware, a.RequirePermission("devices.unregister"), h.UnregisterDeviceToken)
|
||||||
|
|
||||||
// Test Push Notification (for development/testing)
|
// Push Notifications
|
||||||
groupV1.Post("/notifications/test-push", a.authMiddleware, h.SendTestPushNotification)
|
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
|
// Settings
|
||||||
groupV1.Get("/settings", a.authMiddleware, a.SuperAdminOnly, h.GetGlobalSettingList)
|
groupV1.Get("/settings", a.authMiddleware, a.RequirePermission("settings.list"), h.GetGlobalSettingList)
|
||||||
groupV1.Get("/settings/:key", a.authMiddleware, a.SuperAdminOnly, h.GetGlobalSettingByKey)
|
groupV1.Get("/settings/:key", a.authMiddleware, a.RequirePermission("settings.get"), h.GetGlobalSettingByKey)
|
||||||
groupV1.Put("/settings", a.authMiddleware, a.SuperAdminOnly, h.UpdateGlobalSettingList)
|
groupV1.Put("/settings", a.authMiddleware, a.RequirePermission("settings.update"), h.UpdateGlobalSettingList)
|
||||||
|
|
||||||
// Analytics Routes
|
// Analytics
|
||||||
groupV1.Get("/analytics/dashboard", a.authMiddleware, a.OnlyAdminAndAbove, h.GetAnalyticsDashboard)
|
groupV1.Get("/analytics/dashboard", a.authMiddleware, a.RequirePermission("analytics.dashboard"), h.GetAnalyticsDashboard)
|
||||||
|
|
||||||
// Vimeo Video Hosting Routes
|
// Vimeo
|
||||||
vimeoGroup := groupV1.Group("/vimeo")
|
vimeoGroup := groupV1.Group("/vimeo")
|
||||||
vimeoGroup.Get("/videos/:video_id", a.authMiddleware, h.GetVimeoVideo)
|
vimeoGroup.Get("/videos/:video_id", a.authMiddleware, a.RequirePermission("vimeo.videos.get"), h.GetVimeoVideo)
|
||||||
vimeoGroup.Get("/videos/:video_id/embed", a.authMiddleware, h.GetEmbedCode)
|
vimeoGroup.Get("/videos/:video_id/embed", a.authMiddleware, a.RequirePermission("vimeo.videos.embed"), h.GetEmbedCode)
|
||||||
vimeoGroup.Get("/videos/:video_id/status", a.authMiddleware, h.GetTranscodeStatus)
|
vimeoGroup.Get("/videos/:video_id/status", a.authMiddleware, a.RequirePermission("vimeo.videos.status"), h.GetTranscodeStatus)
|
||||||
vimeoGroup.Delete("/videos/:video_id", a.authMiddleware, h.DeleteVimeoVideo)
|
vimeoGroup.Delete("/videos/:video_id", a.authMiddleware, a.RequirePermission("vimeo.videos.delete"), h.DeleteVimeoVideo)
|
||||||
vimeoGroup.Post("/uploads/pull", a.authMiddleware, h.CreatePullUpload)
|
vimeoGroup.Post("/uploads/pull", a.authMiddleware, a.RequirePermission("vimeo.uploads.pull"), h.CreatePullUpload)
|
||||||
vimeoGroup.Post("/uploads/tus", a.authMiddleware, h.CreateTusUpload)
|
vimeoGroup.Post("/uploads/tus", a.authMiddleware, a.RequirePermission("vimeo.uploads.tus"), h.CreateTusUpload)
|
||||||
vimeoGroup.Get("/oembed", h.GetOEmbed)
|
vimeoGroup.Get("/oembed", h.GetOEmbed)
|
||||||
|
|
||||||
// Team Management Routes (Internal HR/Team)
|
// Team Management
|
||||||
teamGroup := groupV1.Group("/team")
|
teamGroup := groupV1.Group("/team")
|
||||||
teamGroup.Post("/login", h.TeamMemberLogin) // Team member authentication
|
teamGroup.Post("/login", h.TeamMemberLogin)
|
||||||
teamGroup.Get("/me", a.authMiddleware, h.GetMyTeamProfile) // Get own profile
|
teamGroup.Get("/me", a.authMiddleware, a.RequirePermission("team.profile.get_mine"), h.GetMyTeamProfile)
|
||||||
teamGroup.Get("/stats", a.authMiddleware, a.OnlyAdminAndAbove, h.GetTeamMemberStats) // Team statistics
|
teamGroup.Get("/stats", a.authMiddleware, a.RequirePermission("team.stats"), h.GetTeamMemberStats)
|
||||||
teamGroup.Get("/members", a.authMiddleware, a.OnlyAdminAndAbove, h.GetAllTeamMembers) // List all team members
|
teamGroup.Get("/members", a.authMiddleware, a.RequirePermission("team.members.list"), h.GetAllTeamMembers)
|
||||||
teamGroup.Post("/members", a.authMiddleware, a.OnlyAdminAndAbove, h.CreateTeamMember) // Create team member
|
teamGroup.Post("/members", a.authMiddleware, a.RequirePermission("team.members.create"), h.CreateTeamMember)
|
||||||
teamGroup.Get("/members/:id", a.authMiddleware, h.GetTeamMember) // Get team member by ID
|
teamGroup.Get("/members/:id", a.authMiddleware, a.RequirePermission("team.members.get"), h.GetTeamMember)
|
||||||
teamGroup.Put("/members/:id", a.authMiddleware, a.OnlyAdminAndAbove, h.UpdateTeamMember) // Update team member
|
teamGroup.Put("/members/:id", a.authMiddleware, a.RequirePermission("team.members.update"), h.UpdateTeamMember)
|
||||||
teamGroup.Patch("/members/:id/status", a.authMiddleware, a.OnlyAdminAndAbove, h.UpdateTeamMemberStatus) // Update status
|
teamGroup.Patch("/members/:id/status", a.authMiddleware, a.RequirePermission("team.members.update_status"), h.UpdateTeamMemberStatus)
|
||||||
teamGroup.Delete("/members/:id", a.authMiddleware, a.SuperAdminOnly, h.DeleteTeamMember) // Delete team member
|
teamGroup.Delete("/members/:id", a.authMiddleware, a.RequirePermission("team.members.delete"), h.DeleteTeamMember)
|
||||||
teamGroup.Post("/members/:id/change-password", a.authMiddleware, h.ChangeTeamMemberPassword) // Change password
|
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
|
// Ratings
|
||||||
groupV1.Post("/ratings", a.authMiddleware, h.SubmitRating)
|
groupV1.Post("/ratings", a.authMiddleware, a.RequirePermission("ratings.submit"), h.SubmitRating)
|
||||||
groupV1.Get("/ratings", a.authMiddleware, h.GetRatingsByTarget)
|
groupV1.Get("/ratings", a.authMiddleware, a.RequirePermission("ratings.list_by_target"), h.GetRatingsByTarget)
|
||||||
groupV1.Get("/ratings/summary", a.authMiddleware, h.GetRatingSummary)
|
groupV1.Get("/ratings/summary", a.authMiddleware, a.RequirePermission("ratings.summary"), h.GetRatingSummary)
|
||||||
groupV1.Get("/ratings/me", a.authMiddleware, h.GetMyRating)
|
groupV1.Get("/ratings/me", a.authMiddleware, a.RequirePermission("ratings.get_mine"), h.GetMyRating)
|
||||||
groupV1.Get("/ratings/me/all", a.authMiddleware, h.GetMyRatings)
|
groupV1.Get("/ratings/me/all", a.authMiddleware, a.RequirePermission("ratings.list_mine"), h.GetMyRatings)
|
||||||
groupV1.Delete("/ratings/:id", a.authMiddleware, h.DeleteRating)
|
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