cloud convert integration + more advanced activity log + issue reporting + video file management fixes

This commit is contained in:
Yared Yemane 2026-02-11 06:54:05 -08:00
parent 97c4f3d28f
commit 0f44e63692
41 changed files with 2347 additions and 251 deletions

View File

@ -22,6 +22,8 @@ import (
"Yimaru-Backend/internal/services/settings"
"Yimaru-Backend/internal/services/subscriptions"
"Yimaru-Backend/internal/services/team"
activitylogservice "Yimaru-Backend/internal/services/activity_log"
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
vimeoservice "Yimaru-Backend/internal/services/vimeo"
"context"
@ -364,6 +366,15 @@ func main() {
courseSvc.SetVimeoService(vimeoSvc)
}
// CloudConvert service for video compression
if cfg.CloudConvert.Enabled && cfg.CloudConvert.APIKey != "" {
ccSvc := cloudconvertservice.NewService(cfg.CloudConvert.APIKey, domain.MongoDBLogger)
courseSvc.SetCloudConvertService(ccSvc)
logger.Info("CloudConvert service initialized")
} else {
logger.Info("CloudConvert service disabled (CLOUDCONVERT_ENABLED not set or missing API key)")
}
// Questions service (unified questions system)
questionsSvc := questions.NewService(store)
@ -386,6 +397,9 @@ func main() {
// santimpaySvc := santimpay.NewSantimPayService(santimpayClient, cfg, transferStore)
// telebirrSvc := telebirr.NewTelebirrService(cfg, transferStore)
// Activity Log service
activityLogSvc := activitylogservice.NewService(store, domain.MongoDBLogger)
// Initialize and start HTTP server
app := httpserver.NewApp(
assessmentSvc,
@ -396,6 +410,7 @@ func main() {
issueReportingSvc,
vimeoSvc,
teamSvc,
activityLogSvc,
cfg.Port,
v,
settingSvc,

View File

@ -0,0 +1,31 @@
INSERT INTO activity_logs (actor_id, actor_role, action, resource_type, resource_id, message, metadata, ip_address, user_agent, created_at) VALUES
(1, 'SUPER_ADMIN', 'CATEGORY_CREATED', 'CATEGORY', 1, 'Created course category: Mathematics', '{"name": "Mathematics"}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '30 days'),
(1, 'SUPER_ADMIN', 'CATEGORY_CREATED', 'CATEGORY', 2, 'Created course category: Science', '{"name": "Science"}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '29 days'),
(1, 'SUPER_ADMIN', 'CATEGORY_CREATED', 'CATEGORY', 3, 'Created course category: Language Arts', '{"name": "Language Arts"}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '28 days'),
(1, 'SUPER_ADMIN', 'COURSE_CREATED', 'COURSE', 1, 'Created course: Algebra Fundamentals', '{"title": "Algebra Fundamentals", "category_id": 1}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '27 days'),
(1, 'SUPER_ADMIN', 'COURSE_CREATED', 'COURSE', 2, 'Created course: Biology 101', '{"title": "Biology 101", "category_id": 2}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '26 days'),
(2, 'ADMIN', 'COURSE_CREATED', 'COURSE', 3, 'Created course: English Grammar', '{"title": "English Grammar", "category_id": 3}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '25 days'),
(1, 'SUPER_ADMIN', 'SUB_COURSE_CREATED', 'SUB_COURSE', 1, 'Created sub-course: Linear Equations', '{"title": "Linear Equations", "course_id": 1, "level": "BEGINNER"}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '24 days'),
(1, 'SUPER_ADMIN', 'SUB_COURSE_CREATED', 'SUB_COURSE', 2, 'Created sub-course: Quadratic Equations', '{"title": "Quadratic Equations", "course_id": 1, "level": "INTERMEDIATE"}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '23 days'),
(2, 'ADMIN', 'SUB_COURSE_CREATED', 'SUB_COURSE', 3, 'Created sub-course: Cell Biology', '{"title": "Cell Biology", "course_id": 2, "level": "BEGINNER"}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '22 days'),
(1, 'SUPER_ADMIN', 'VIDEO_CREATED', 'VIDEO', 1, 'Created video: Introduction to Algebra', '{"title": "Introduction to Algebra", "sub_course_id": 1}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '21 days'),
(1, 'SUPER_ADMIN', 'VIDEO_UPLOADED', 'VIDEO', 1, 'Uploaded video to Vimeo: Introduction to Algebra', '{"title": "Introduction to Algebra", "vimeo_id": "987654321", "file_size": 52428800}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '21 days'),
(1, 'SUPER_ADMIN', 'VIDEO_PUBLISHED', 'VIDEO', 1, 'Published video: Introduction to Algebra', '{"title": "Introduction to Algebra"}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '20 days'),
(2, 'ADMIN', 'VIDEO_CREATED', 'VIDEO', 2, 'Created video: Solving for X', '{"title": "Solving for X", "sub_course_id": 1}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '19 days'),
(2, 'ADMIN', 'VIDEO_UPLOADED', 'VIDEO', 2, 'Uploaded video to Vimeo: Solving for X', '{"title": "Solving for X", "vimeo_id": "987654322", "file_size": 41943040}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '19 days'),
(1, 'SUPER_ADMIN', 'COURSE_UPDATED', 'COURSE', 1, 'Updated course: Algebra Fundamentals', '{"title": "Algebra Fundamentals", "changed_fields": ["description", "thumbnail"]}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '18 days'),
(1, 'SUPER_ADMIN', 'CATEGORY_UPDATED', 'CATEGORY', 1, 'Updated course category: Mathematics & Statistics', '{"name": "Mathematics & Statistics"}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '17 days'),
(2, 'ADMIN', 'VIDEO_CREATED', 'VIDEO', 3, 'Created video: Cell Structure Overview', '{"title": "Cell Structure Overview", "sub_course_id": 3}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '15 days'),
(2, 'ADMIN', 'VIDEO_UPLOADED', 'VIDEO', 3, 'Uploaded video to Vimeo: Cell Structure Overview', '{"title": "Cell Structure Overview", "vimeo_id": "987654323", "file_size": 73400320}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '15 days'),
(2, 'ADMIN', 'VIDEO_PUBLISHED', 'VIDEO', 2, 'Published video: Solving for X', '{"title": "Solving for X"}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '14 days'),
(1, 'SUPER_ADMIN', 'SUB_COURSE_UPDATED', 'SUB_COURSE', 2, 'Updated sub-course: Quadratic Equations', '{"title": "Quadratic Equations", "changed_fields": ["description"]}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '12 days'),
(2, 'ADMIN', 'VIDEO_UPDATED', 'VIDEO', 3, 'Updated video: Cell Structure Overview', '{"title": "Cell Structure Overview", "changed_fields": ["thumbnail", "resolution"]}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '10 days'),
(2, 'ADMIN', 'VIDEO_PUBLISHED', 'VIDEO', 3, 'Published video: Cell Structure Overview', '{"title": "Cell Structure Overview"}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '9 days'),
(1, 'SUPER_ADMIN', 'VIDEO_ARCHIVED', 'VIDEO', 4, 'Archived video ID: 4', '{"id": 4}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '7 days'),
(1, 'SUPER_ADMIN', 'SETTINGS_UPDATED', 'SETTINGS', NULL, 'Updated global settings', '{"keys": ["site_name", "maintenance_mode"]}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '5 days'),
(1, 'SUPER_ADMIN', 'TEAM_MEMBER_CREATED', 'TEAM_MEMBER', 3, 'Created team member: John Doe', '{"name": "John Doe", "role": "instructor"}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '4 days'),
(1, 'SUPER_ADMIN', 'COURSE_CREATED', 'COURSE', 4, 'Created course: Advanced Physics', '{"title": "Advanced Physics", "category_id": 2}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '3 days'),
(2, 'ADMIN', 'CATEGORY_DELETED', 'CATEGORY', 5, 'Deleted category ID: 5', '{"id": 5}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '2 days'),
(1, 'SUPER_ADMIN', 'SUB_COURSE_DELETED', 'SUB_COURSE', 6, 'Deleted sub-course ID: 6', '{"id": 6}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '1 day'),
(2, 'ADMIN', 'VIDEO_DELETED', 'VIDEO', 5, 'Deleted video ID: 5', '{"id": 5}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '6 hours'),
(1, 'SUPER_ADMIN', 'TEAM_MEMBER_UPDATED', 'TEAM_MEMBER', 3, 'Updated team member: John Doe', '{"name": "John Doe", "changed_fields": ["role"]}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '2 hours');

View File

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

View File

@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS activity_logs (
id BIGSERIAL PRIMARY KEY,
actor_id BIGINT NULL,
actor_role TEXT NULL,
action TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id BIGINT NULL,
message TEXT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
ip_address TEXT NULL,
user_agent TEXT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_activity_logs_created_at ON activity_logs (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_activity_logs_actor ON activity_logs (actor_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_activity_logs_resource ON activity_logs (resource_type, resource_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_activity_logs_action ON activity_logs (action, created_at DESC);

View File

@ -0,0 +1,39 @@
-- name: CreateActivityLog :one
INSERT INTO activity_logs (
actor_id, actor_role, action, resource_type, resource_id,
message, metadata, ip_address, user_agent
) VALUES (
sqlc.narg('actor_id'), sqlc.narg('actor_role'),
@action, @resource_type, sqlc.narg('resource_id'),
sqlc.narg('message'), COALESCE(sqlc.narg('metadata'), '{}'::jsonb),
sqlc.narg('ip_address'), sqlc.narg('user_agent')
)
RETURNING *;
-- name: ListActivityLogs :many
SELECT
COUNT(*) OVER() AS total_count,
al.id,
al.actor_id,
al.actor_role,
al.action,
al.resource_type,
al.resource_id,
al.message,
al.metadata,
al.ip_address,
al.user_agent,
al.created_at
FROM activity_logs al
WHERE
(sqlc.narg('filter_actor_id')::bigint IS NULL OR al.actor_id = sqlc.narg('filter_actor_id'))
AND (sqlc.narg('filter_action')::text IS NULL OR al.action = sqlc.narg('filter_action'))
AND (sqlc.narg('filter_resource_type')::text IS NULL OR al.resource_type = sqlc.narg('filter_resource_type'))
AND (sqlc.narg('filter_resource_id')::bigint IS NULL OR al.resource_id = sqlc.narg('filter_resource_id'))
AND (sqlc.narg('filter_after')::timestamptz IS NULL OR al.created_at >= sqlc.narg('filter_after'))
AND (sqlc.narg('filter_before')::timestamptz IS NULL OR al.created_at <= sqlc.narg('filter_before'))
ORDER BY al.created_at DESC
LIMIT @log_limit OFFSET @log_offset;
-- name: GetActivityLogByID :one
SELECT * FROM activity_logs WHERE id = $1;

View File

@ -35,3 +35,6 @@ WHERE id = $1;
-- name: DeleteReportedIssue :exec
DELETE FROM reported_issues
WHERE id = $1;
-- name: GetReportedIssueByID :one
SELECT * FROM reported_issues WHERE id = $1;

183
gen/db/activity_logs.sql.go Normal file
View File

@ -0,0 +1,183 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: activity_logs.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CreateActivityLog = `-- name: CreateActivityLog :one
INSERT INTO activity_logs (
actor_id, actor_role, action, resource_type, resource_id,
message, metadata, ip_address, user_agent
) VALUES (
$1, $2,
$3, $4, $5,
$6, COALESCE($7, '{}'::jsonb),
$8, $9
)
RETURNING id, actor_id, actor_role, action, resource_type, resource_id, message, metadata, ip_address, user_agent, created_at
`
type CreateActivityLogParams struct {
ActorID pgtype.Int8 `json:"actor_id"`
ActorRole pgtype.Text `json:"actor_role"`
Action string `json:"action"`
ResourceType string `json:"resource_type"`
ResourceID pgtype.Int8 `json:"resource_id"`
Message pgtype.Text `json:"message"`
Metadata interface{} `json:"metadata"`
IpAddress pgtype.Text `json:"ip_address"`
UserAgent pgtype.Text `json:"user_agent"`
}
func (q *Queries) CreateActivityLog(ctx context.Context, arg CreateActivityLogParams) (ActivityLog, error) {
row := q.db.QueryRow(ctx, CreateActivityLog,
arg.ActorID,
arg.ActorRole,
arg.Action,
arg.ResourceType,
arg.ResourceID,
arg.Message,
arg.Metadata,
arg.IpAddress,
arg.UserAgent,
)
var i ActivityLog
err := row.Scan(
&i.ID,
&i.ActorID,
&i.ActorRole,
&i.Action,
&i.ResourceType,
&i.ResourceID,
&i.Message,
&i.Metadata,
&i.IpAddress,
&i.UserAgent,
&i.CreatedAt,
)
return i, err
}
const GetActivityLogByID = `-- name: GetActivityLogByID :one
SELECT id, actor_id, actor_role, action, resource_type, resource_id, message, metadata, ip_address, user_agent, created_at FROM activity_logs WHERE id = $1
`
func (q *Queries) GetActivityLogByID(ctx context.Context, id int64) (ActivityLog, error) {
row := q.db.QueryRow(ctx, GetActivityLogByID, id)
var i ActivityLog
err := row.Scan(
&i.ID,
&i.ActorID,
&i.ActorRole,
&i.Action,
&i.ResourceType,
&i.ResourceID,
&i.Message,
&i.Metadata,
&i.IpAddress,
&i.UserAgent,
&i.CreatedAt,
)
return i, err
}
const ListActivityLogs = `-- name: ListActivityLogs :many
SELECT
COUNT(*) OVER() AS total_count,
al.id,
al.actor_id,
al.actor_role,
al.action,
al.resource_type,
al.resource_id,
al.message,
al.metadata,
al.ip_address,
al.user_agent,
al.created_at
FROM activity_logs al
WHERE
($1::bigint IS NULL OR al.actor_id = $1)
AND ($2::text IS NULL OR al.action = $2)
AND ($3::text IS NULL OR al.resource_type = $3)
AND ($4::bigint IS NULL OR al.resource_id = $4)
AND ($5::timestamptz IS NULL OR al.created_at >= $5)
AND ($6::timestamptz IS NULL OR al.created_at <= $6)
ORDER BY al.created_at DESC
LIMIT $8 OFFSET $7
`
type ListActivityLogsParams struct {
FilterActorID pgtype.Int8 `json:"filter_actor_id"`
FilterAction pgtype.Text `json:"filter_action"`
FilterResourceType pgtype.Text `json:"filter_resource_type"`
FilterResourceID pgtype.Int8 `json:"filter_resource_id"`
FilterAfter pgtype.Timestamptz `json:"filter_after"`
FilterBefore pgtype.Timestamptz `json:"filter_before"`
LogOffset int32 `json:"log_offset"`
LogLimit int32 `json:"log_limit"`
}
type ListActivityLogsRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
ActorID pgtype.Int8 `json:"actor_id"`
ActorRole pgtype.Text `json:"actor_role"`
Action string `json:"action"`
ResourceType string `json:"resource_type"`
ResourceID pgtype.Int8 `json:"resource_id"`
Message pgtype.Text `json:"message"`
Metadata []byte `json:"metadata"`
IpAddress pgtype.Text `json:"ip_address"`
UserAgent pgtype.Text `json:"user_agent"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
func (q *Queries) ListActivityLogs(ctx context.Context, arg ListActivityLogsParams) ([]ListActivityLogsRow, error) {
rows, err := q.db.Query(ctx, ListActivityLogs,
arg.FilterActorID,
arg.FilterAction,
arg.FilterResourceType,
arg.FilterResourceID,
arg.FilterAfter,
arg.FilterBefore,
arg.LogOffset,
arg.LogLimit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListActivityLogsRow
for rows.Next() {
var i ListActivityLogsRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.ActorID,
&i.ActorRole,
&i.Action,
&i.ResourceType,
&i.ResourceID,
&i.Message,
&i.Metadata,
&i.IpAddress,
&i.UserAgent,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View File

@ -91,6 +91,28 @@ func (q *Queries) DeleteReportedIssue(ctx context.Context, id int64) error {
return err
}
const GetReportedIssueByID = `-- name: GetReportedIssueByID :one
SELECT id, user_id, user_role, subject, description, issue_type, status, metadata, created_at, updated_at FROM reported_issues WHERE id = $1
`
func (q *Queries) GetReportedIssueByID(ctx context.Context, id int64) (ReportedIssue, error) {
row := q.db.QueryRow(ctx, GetReportedIssueByID, id)
var i ReportedIssue
err := row.Scan(
&i.ID,
&i.UserID,
&i.UserRole,
&i.Subject,
&i.Description,
&i.IssueType,
&i.Status,
&i.Metadata,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const ListReportedIssues = `-- name: ListReportedIssues :many
SELECT id, user_id, user_role, subject, description, issue_type, status, metadata, created_at, updated_at
FROM reported_issues

View File

@ -8,6 +8,20 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
type ActivityLog struct {
ID int64 `json:"id"`
ActorID pgtype.Int8 `json:"actor_id"`
ActorRole pgtype.Text `json:"actor_role"`
Action string `json:"action"`
ResourceType string `json:"resource_type"`
ResourceID pgtype.Int8 `json:"resource_id"`
Message pgtype.Text `json:"message"`
Metadata []byte `json:"metadata"`
IpAddress pgtype.Text `json:"ip_address"`
UserAgent pgtype.Text `json:"user_agent"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Course struct {
ID int64 `json:"id"`
CategoryID int64 `json:"category_id"`

3
go.mod
View File

@ -46,11 +46,8 @@ require (
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/zeebo/errs v1.4.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect

28
go.sum
View File

@ -12,12 +12,16 @@ cloud.google.com/go/firestore v1.18.0 h1:cuydCaLS7Vl2SatAeivXyhbhDEIR8BDmtn4egDh
cloud.google.com/go/firestore v1.18.0/go.mod h1:5ye0v48PhseZBdcl0qbl3uttu7FIEwEYVaWm0UIEOEU=
cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8=
cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE=
cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc=
cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA=
cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE=
cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY=
cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM=
cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U=
cloud.google.com/go/storage v1.53.0 h1:gg0ERZwL17pJ+Cz3cD2qS60w1WMDnwcm5YPAIQBHUAw=
cloud.google.com/go/storage v1.53.0/go.mod h1:7/eO2a/srr9ImZW9k5uufcNahT2+fPb8w5it1i5boaA=
cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4=
cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI=
firebase.google.com/go/v4 v4.19.0 h1:f5NMlC2YHFsncz00c2+ecBr+ZYlRMhKIhj1z8Iz0lD8=
firebase.google.com/go/v4 v4.19.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
@ -25,6 +29,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0 h1:OqVGm6Ei3x5+yZmSJG1Mh2NwHvpVmZ08CB5qJhT9Nuk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0/go.mod h1:SZiPHWGOOk3bl8tkevxkoiwPgsIl6CwrWcbwjfHZpdM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 h1:6/0iUd0xrnX7qt+mLNRwg5c0PGv8wpE8K90ryANQwMI=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
@ -41,7 +47,6 @@ github.com/amanuelabay/afrosms-go v1.0.6/go.mod h1:5mzzZtWSCDdvQsA0OyYf5CtbdGpl9
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
@ -57,11 +62,15 @@ github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1Ig
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA=
github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@ -100,7 +109,6 @@ github.com/gofiber/fiber/v2 v2.52.10/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAre
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
@ -113,6 +121,8 @@ github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@ -172,18 +182,17 @@ github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJ
github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
github.com/otiai10/mint v1.3.3/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/resend/resend-go/v2 v2.28.0 h1:ttM1/VZR4fApBv3xI1TneSKi1pbfFsVrq7fXFlHKtj4=
github.com/resend/resend-go/v2 v2.28.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@ -214,8 +223,6 @@ github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe/go.mod h1:lKJPbtWzJ9J
github.com/swaggo/swag v1.8.1/go.mod h1:ugemnJsPZm/kRwFUnzBlbHRd0JY9zE1M4F+uy2pAaPQ=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/twilio/twilio-go v1.28.8 h1:wbFz7Wt4S5mCEaes6FcM/ddcJGIhdjwp/9CHb9e+4fk=
github.com/twilio/twilio-go v1.28.8/go.mod h1:FpgNWMoD8CFnmukpKq9RNpUSGXC0BwnbeKZj2YHlIkw=
github.com/twilio/twilio-go v1.30.0 h1:86FBso7jFqpSZ0XC0GKJcEY2KOeUNOFh6zLhTbUMlnc=
github.com/twilio/twilio-go v1.30.0/go.mod h1:QbitvbvtkV77Jn4BABAKVmxabYSjMyQG4tHey9gfPqg=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
@ -254,6 +261,8 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6h
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 h1:PB3Zrjs1sG1GBX51SXyTSoOTqcDglmsk7nT6tkKPb/k=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0/go.mod h1:U2R3XyVPzn0WX7wOIypPuptulsMcPDPs/oiSVOMVnHY=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
@ -339,9 +348,10 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.239.0 h1:2hZKUnFZEy81eugPs4e2XzIJ5SOwQg0G82bpXD65Puo=
google.golang.org/api v0.239.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=

View File

@ -83,12 +83,18 @@ type VimeoConfig struct {
Enabled bool `mapstructure:"vimeo_enabled"`
}
type CloudConvertConfig struct {
APIKey string `mapstructure:"cloudconvert_api_key"`
Enabled bool `mapstructure:"cloudconvert_enabled"`
}
type Config struct {
GoogleOAuthClientID string
GoogleOAuthClientSecret string
GoogleOAuthRedirectURL string
AFROSMSConfig AFROSMSConfig `mapstructure:"afro_sms_config"`
Vimeo VimeoConfig `mapstructure:"vimeo_config"`
Vimeo VimeoConfig `mapstructure:"vimeo_config"`
CloudConvert CloudConvertConfig `mapstructure:"cloudconvert_config"`
APP_VERSION string
FIXER_API_KEY string
FIXER_BASE_URL string
@ -483,6 +489,13 @@ func (c *Config) loadEnv() error {
}
c.Vimeo.AccessToken = os.Getenv("VIMEO_ACCESS_TOKEN")
// CloudConvert configuration
cloudConvertEnabled := os.Getenv("CLOUDCONVERT_ENABLED")
if cloudConvertEnabled == "true" || cloudConvertEnabled == "1" {
c.CloudConvert.Enabled = true
}
c.CloudConvert.APIKey = os.Getenv("CLOUDCONVERT_API_KEY")
return nil
}

View File

@ -0,0 +1,90 @@
package domain
import (
"encoding/json"
"time"
)
type ActivityAction string
const (
ActionCourseCreated ActivityAction = "COURSE_CREATED"
ActionCourseUpdated ActivityAction = "COURSE_UPDATED"
ActionCourseDeleted ActivityAction = "COURSE_DELETED"
ActionSubCourseCreated ActivityAction = "SUB_COURSE_CREATED"
ActionSubCourseUpdated ActivityAction = "SUB_COURSE_UPDATED"
ActionSubCourseDeleted ActivityAction = "SUB_COURSE_DELETED"
ActionVideoCreated ActivityAction = "VIDEO_CREATED"
ActionVideoUploaded ActivityAction = "VIDEO_UPLOADED"
ActionVideoPublished ActivityAction = "VIDEO_PUBLISHED"
ActionVideoUpdated ActivityAction = "VIDEO_UPDATED"
ActionVideoArchived ActivityAction = "VIDEO_ARCHIVED"
ActionVideoDeleted ActivityAction = "VIDEO_DELETED"
ActionUserCreated ActivityAction = "USER_CREATED"
ActionUserUpdated ActivityAction = "USER_UPDATED"
ActionUserDeleted ActivityAction = "USER_DELETED"
ActionSettingsUpdated ActivityAction = "SETTINGS_UPDATED"
ActionTeamMemberCreated ActivityAction = "TEAM_MEMBER_CREATED"
ActionTeamMemberUpdated ActivityAction = "TEAM_MEMBER_UPDATED"
ActionTeamMemberDeleted ActivityAction = "TEAM_MEMBER_DELETED"
ActionCategoryCreated ActivityAction = "CATEGORY_CREATED"
ActionCategoryUpdated ActivityAction = "CATEGORY_UPDATED"
ActionCategoryDeleted ActivityAction = "CATEGORY_DELETED"
ActionAdminCreated ActivityAction = "ADMIN_CREATED"
ActionAdminUpdated ActivityAction = "ADMIN_UPDATED"
ActionSubscriptionPlanCreated ActivityAction = "SUBSCRIPTION_PLAN_CREATED"
ActionSubscriptionPlanUpdated ActivityAction = "SUBSCRIPTION_PLAN_UPDATED"
ActionSubscriptionPlanDeleted ActivityAction = "SUBSCRIPTION_PLAN_DELETED"
ActionQuestionCreated ActivityAction = "QUESTION_CREATED"
ActionQuestionUpdated ActivityAction = "QUESTION_UPDATED"
ActionQuestionDeleted ActivityAction = "QUESTION_DELETED"
ActionQuestionSetCreated ActivityAction = "QUESTION_SET_CREATED"
ActionQuestionSetUpdated ActivityAction = "QUESTION_SET_UPDATED"
ActionQuestionSetDeleted ActivityAction = "QUESTION_SET_DELETED"
ActionSubCourseDeactivated ActivityAction = "SUB_COURSE_DEACTIVATED"
ActionIssueCreated ActivityAction = "ISSUE_CREATED"
ActionIssueStatusUpdated ActivityAction = "ISSUE_STATUS_UPDATED"
ActionIssueDeleted ActivityAction = "ISSUE_DELETED"
)
type ResourceType string
const (
ResourceCourse ResourceType = "COURSE"
ResourceSubCourse ResourceType = "SUB_COURSE"
ResourceVideo ResourceType = "VIDEO"
ResourceUser ResourceType = "USER"
ResourceSettings ResourceType = "SETTINGS"
ResourceTeamMember ResourceType = "TEAM_MEMBER"
ResourceCategory ResourceType = "CATEGORY"
ResourceAdmin ResourceType = "ADMIN"
ResourceSubscriptionPlan ResourceType = "SUBSCRIPTION_PLAN"
ResourceQuestion ResourceType = "QUESTION"
ResourceQuestionSet ResourceType = "QUESTION_SET"
ResourceIssue ResourceType = "ISSUE"
)
type ActivityLog struct {
ID int64
ActorID *int64
ActorRole *string
Action string
ResourceType string
ResourceID *int64
Message *string
Metadata json.RawMessage
IPAddress *string
UserAgent *string
CreatedAt time.Time
}
type ActivityLogFilter struct {
ActorID *int64
Action *string
ResourceType *string
ResourceID *int64
After *time.Time
Before *time.Time
Limit int32
Offset int32
}

View File

@ -5,20 +5,18 @@ import "time"
type ReportedIssueType string
var (
ISSUE_TYPE_DEPOSIT ReportedIssueType = "deposit"
ISSUE_TYPE_WITHDRAWAL ReportedIssueType = "withdrawal"
ISSUE_TYPE_BET ReportedIssueType = "bet"
ISSUE_TYPE_CASHOUT ReportedIssueType = "cashout"
ISSUE_TYPE_ODDS ReportedIssueType = "odds"
ISSUE_TYPE_EVENTS ReportedIssueType = "events"
ISSUE_TYPE_BRANCH ReportedIssueType = "branch"
ISSUE_TYPE_USER ReportedIssueType = "user"
ISSUE_TYPE_LOGIN ReportedIssueType = "login"
ISSUE_TYPE_REGISTER ReportedIssueType = "register"
ISSUE_TYPE_RESET_PASSWORD ReportedIssueType = "reset_password"
ISSUE_TYPE_WALLET ReportedIssueType = "wallet"
ISSUE_TYPE_VIRTUAL ReportedIssueType = "virtual games"
ISSUE_TYPE_OTHER ReportedIssueType = "other"
ISSUE_TYPE_COURSE ReportedIssueType = "course"
ISSUE_TYPE_VIDEO ReportedIssueType = "video"
ISSUE_TYPE_SUBSCRIPTION ReportedIssueType = "subscription"
ISSUE_TYPE_PAYMENT ReportedIssueType = "payment"
ISSUE_TYPE_ACCOUNT ReportedIssueType = "account"
ISSUE_TYPE_LOGIN ReportedIssueType = "login"
ISSUE_TYPE_CONTENT ReportedIssueType = "content"
ISSUE_TYPE_PERFORMANCE ReportedIssueType = "performance"
ISSUE_TYPE_ACCESSIBILITY ReportedIssueType = "accessibility"
ISSUE_TYPE_FEATURE_REQUEST ReportedIssueType = "feature_request"
ISSUE_TYPE_BUG ReportedIssueType = "bug"
ISSUE_TYPE_OTHER ReportedIssueType = "other"
)
type ReportedIssueStatus string

View File

@ -0,0 +1,273 @@
package cloudconvert
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"time"
)
const BaseURL = "https://api.cloudconvert.com/v2"
type Client struct {
httpClient *http.Client
apiKey string
}
func NewClient(apiKey string) *Client {
return &Client{
httpClient: &http.Client{
Timeout: 60 * time.Second,
},
apiKey: apiKey,
}
}
type JobRequest struct {
Tasks map[string]interface{} `json:"tasks"`
}
type JobResponse struct {
Data Job `json:"data"`
}
type Job struct {
ID string `json:"id"`
Status string `json:"status"`
Tasks []Task `json:"tasks"`
}
type Task struct {
ID string `json:"id"`
Name string `json:"name"`
Operation string `json:"operation"`
Status string `json:"status"`
Message string `json:"message"`
Result *TaskResult `json:"result"`
}
type TaskResult struct {
Form *UploadForm `json:"form,omitempty"`
Files []ExportFile `json:"files,omitempty"`
}
type UploadForm struct {
URL string `json:"url"`
Parameters map[string]interface{} `json:"parameters"`
}
type ExportFile struct {
Filename string `json:"filename"`
URL string `json:"url"`
Size int64 `json:"size"`
}
func (c *Client) doRequest(ctx context.Context, method, url string, body interface{}) (*http.Response, error) {
var reqBody io.Reader
if body != nil {
jsonBytes, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
reqBody = bytes.NewReader(jsonBytes)
}
req, err := http.NewRequestWithContext(ctx, method, url, reqBody)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
return c.httpClient.Do(req)
}
func (c *Client) CreateJob(ctx context.Context, jobReq *JobRequest) (*Job, error) {
resp, err := c.doRequest(ctx, http.MethodPost, BaseURL+"/jobs", jobReq)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("failed to create job: status %d, body: %s", resp.StatusCode, string(bodyBytes))
}
var jobResp JobResponse
if err := json.NewDecoder(resp.Body).Decode(&jobResp); err != nil {
return nil, fmt.Errorf("failed to decode job response: %w", err)
}
return &jobResp.Data, nil
}
func (c *Client) GetJob(ctx context.Context, jobID string) (*Job, error) {
resp, err := c.doRequest(ctx, http.MethodGet, BaseURL+"/jobs/"+jobID, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("failed to get job: status %d, body: %s", resp.StatusCode, string(bodyBytes))
}
var jobResp JobResponse
if err := json.NewDecoder(resp.Body).Decode(&jobResp); err != nil {
return nil, fmt.Errorf("failed to decode job response: %w", err)
}
return &jobResp.Data, nil
}
func (c *Client) UploadFile(ctx context.Context, form *UploadForm, filename string, fileData io.Reader) error {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
for key, val := range form.Parameters {
var strVal string
switch v := val.(type) {
case string:
strVal = v
case float64:
strVal = fmt.Sprintf("%.0f", v)
default:
strVal = fmt.Sprintf("%v", v)
}
if err := writer.WriteField(key, strVal); err != nil {
return fmt.Errorf("failed to write form field %s: %w", key, err)
}
}
part, err := writer.CreateFormFile("file", filename)
if err != nil {
return fmt.Errorf("failed to create form file: %w", err)
}
if _, err := io.Copy(part, fileData); err != nil {
return fmt.Errorf("failed to copy file data: %w", err)
}
if err := writer.Close(); err != nil {
return fmt.Errorf("failed to close multipart writer: %w", err)
}
uploadClient := &http.Client{
Timeout: 30 * time.Minute,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return nil
},
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, form.URL, body)
if err != nil {
return fmt.Errorf("failed to create upload request: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := uploadClient.Do(req)
if err != nil {
return fmt.Errorf("failed to upload file: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("upload failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
}
return nil
}
func (c *Client) WaitForJob(ctx context.Context, jobID string, pollInterval time.Duration, maxWait time.Duration) (*Job, error) {
deadline := time.Now().Add(maxWait)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
job, err := c.GetJob(ctx, jobID)
if err != nil {
return nil, err
}
switch job.Status {
case "finished":
return job, nil
case "error":
for _, task := range job.Tasks {
if task.Status == "error" {
return nil, fmt.Errorf("job failed: task '%s' error: %s", task.Name, task.Message)
}
}
return nil, fmt.Errorf("job failed with unknown error")
}
time.Sleep(pollInterval)
}
return nil, fmt.Errorf("job timed out after %v", maxWait)
}
func (c *Client) DownloadFile(ctx context.Context, url string) (io.ReadCloser, int64, error) {
downloadClient := &http.Client{
Timeout: 30 * time.Minute,
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, 0, fmt.Errorf("failed to create download request: %w", err)
}
resp, err := downloadClient.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("failed to download file: %w", err)
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, 0, fmt.Errorf("download failed: status %d", resp.StatusCode)
}
return resp.Body, resp.ContentLength, nil
}
func (c *Client) CreateVideoCompressionJob(ctx context.Context) (*Job, error) {
jobReq := &JobRequest{
Tasks: map[string]interface{}{
"import-video": map[string]interface{}{
"operation": "import/upload",
},
"convert-video": map[string]interface{}{
"operation": "convert",
"input": "import-video",
"output_format": "mp4",
"video_codec": "x264",
"crf": 28,
"preset": "medium",
"height": 720,
"fit": "max",
"audio_codec": "aac",
"audio_bitrate": 128,
},
"export-video": map[string]interface{}{
"operation": "export/url",
"input": "convert-video",
},
},
}
return c.CreateJob(ctx, jobReq)
}

View File

@ -238,6 +238,43 @@ func (c *Client) CreateTusUpload(ctx context.Context, name, description string,
return c.CreateUpload(ctx, req)
}
func (c *Client) UploadTusVideoFile(ctx context.Context, uploadLink string, fileData io.Reader, fileSize int64) error {
uploadClient := &http.Client{
Timeout: 30 * time.Minute,
}
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, uploadLink, fileData)
if err != nil {
return fmt.Errorf("failed to create TUS upload request: %w", err)
}
req.Header.Set("Tus-Resumable", "1.0.0")
req.Header.Set("Upload-Offset", "0")
req.Header.Set("Content-Type", "application/offset+octet-stream")
req.ContentLength = fileSize
resp, err := uploadClient.Do(req)
if err != nil {
return fmt.Errorf("failed to upload video file to Vimeo: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("TUS upload failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
}
offsetStr := resp.Header.Get("Upload-Offset")
if offsetStr != "" {
offset, err := strconv.ParseInt(offsetStr, 10, 64)
if err == nil && offset < fileSize {
return fmt.Errorf("incomplete upload: uploaded %d of %d bytes", offset, fileSize)
}
}
return nil
}
func (c *Client) UpdateVideo(ctx context.Context, videoID string, req *UpdateVideoRequest) (*Video, error) {
resp, err := c.doRequest(ctx, http.MethodPatch, "/videos/"+videoID, req)
if err != nil {

View File

@ -0,0 +1,12 @@
package ports
import (
"Yimaru-Backend/internal/domain"
"context"
)
type ActivityLogStore interface {
CreateActivityLog(ctx context.Context, log domain.ActivityLog) (domain.ActivityLog, error)
ListActivityLogs(ctx context.Context, filter domain.ActivityLogFilter) ([]domain.ActivityLog, int64, error)
GetActivityLogByID(ctx context.Context, id int64) (domain.ActivityLog, error)
}

View File

@ -0,0 +1,147 @@
package repository
import (
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"context"
"encoding/json"
"github.com/jackc/pgx/v5/pgtype"
)
func (s *Store) CreateActivityLog(ctx context.Context, log domain.ActivityLog) (domain.ActivityLog, error) {
var actorID, resourceID pgtype.Int8
var actorRole, message, ipAddress, userAgent pgtype.Text
if log.ActorID != nil {
actorID = pgtype.Int8{Int64: *log.ActorID, Valid: true}
}
if log.ResourceID != nil {
resourceID = pgtype.Int8{Int64: *log.ResourceID, Valid: true}
}
if log.ActorRole != nil {
actorRole = pgtype.Text{String: *log.ActorRole, Valid: true}
}
if log.Message != nil {
message = pgtype.Text{String: *log.Message, Valid: true}
}
if log.IPAddress != nil {
ipAddress = pgtype.Text{String: *log.IPAddress, Valid: true}
}
if log.UserAgent != nil {
userAgent = pgtype.Text{String: *log.UserAgent, Valid: true}
}
var metadata interface{}
if log.Metadata != nil {
metadata = log.Metadata
}
row, err := s.queries.CreateActivityLog(ctx, dbgen.CreateActivityLogParams{
ActorID: actorID,
ActorRole: actorRole,
Action: log.Action,
ResourceType: log.ResourceType,
ResourceID: resourceID,
Message: message,
Metadata: metadata,
IpAddress: ipAddress,
UserAgent: userAgent,
})
if err != nil {
return domain.ActivityLog{}, err
}
return mapActivityLogRow(row), nil
}
func (s *Store) ListActivityLogs(ctx context.Context, filter domain.ActivityLogFilter) ([]domain.ActivityLog, int64, error) {
var filterActorID, filterResourceID pgtype.Int8
var filterAction, filterResourceType pgtype.Text
var filterAfter, filterBefore pgtype.Timestamptz
if filter.ActorID != nil {
filterActorID = pgtype.Int8{Int64: *filter.ActorID, Valid: true}
}
if filter.ResourceID != nil {
filterResourceID = pgtype.Int8{Int64: *filter.ResourceID, Valid: true}
}
if filter.Action != nil {
filterAction = pgtype.Text{String: *filter.Action, Valid: true}
}
if filter.ResourceType != nil {
filterResourceType = pgtype.Text{String: *filter.ResourceType, Valid: true}
}
if filter.After != nil {
filterAfter = pgtype.Timestamptz{Time: *filter.After, Valid: true}
}
if filter.Before != nil {
filterBefore = pgtype.Timestamptz{Time: *filter.Before, Valid: true}
}
rows, err := s.queries.ListActivityLogs(ctx, dbgen.ListActivityLogsParams{
FilterActorID: filterActorID,
FilterAction: filterAction,
FilterResourceType: filterResourceType,
FilterResourceID: filterResourceID,
FilterAfter: filterAfter,
FilterBefore: filterBefore,
LogOffset: filter.Offset,
LogLimit: filter.Limit,
})
if err != nil {
return nil, 0, err
}
var (
logs []domain.ActivityLog
totalCount int64
)
for i, row := range rows {
if i == 0 {
totalCount = row.TotalCount
}
logs = append(logs, domain.ActivityLog{
ID: row.ID,
ActorID: ptrInt64(row.ActorID),
ActorRole: ptrString(row.ActorRole),
Action: row.Action,
ResourceType: row.ResourceType,
ResourceID: ptrInt64(row.ResourceID),
Message: ptrString(row.Message),
Metadata: json.RawMessage(row.Metadata),
IPAddress: ptrString(row.IpAddress),
UserAgent: ptrString(row.UserAgent),
CreatedAt: row.CreatedAt.Time,
})
}
return logs, totalCount, nil
}
func (s *Store) GetActivityLogByID(ctx context.Context, id int64) (domain.ActivityLog, error) {
row, err := s.queries.GetActivityLogByID(ctx, id)
if err != nil {
return domain.ActivityLog{}, err
}
return mapActivityLogRow(row), nil
}
func mapActivityLogRow(row dbgen.ActivityLog) domain.ActivityLog {
return domain.ActivityLog{
ID: row.ID,
ActorID: ptrInt64(row.ActorID),
ActorRole: ptrString(row.ActorRole),
Action: row.Action,
ResourceType: row.ResourceType,
ResourceID: ptrInt64(row.ResourceID),
Message: ptrString(row.Message),
Metadata: json.RawMessage(row.Metadata),
IPAddress: ptrString(row.IpAddress),
UserAgent: ptrString(row.UserAgent),
CreatedAt: row.CreatedAt.Time,
}
}

View File

@ -19,3 +19,17 @@ func ptrTimestamptz(t pgtype.Timestamptz) *time.Time {
}
return &t.Time
}
func ptrString(t pgtype.Text) *string {
if !t.Valid {
return nil
}
return &t.String
}
func ptrInt64(v pgtype.Int8) *int64 {
if !v.Valid {
return nil
}
return &v.Int64
}

View File

@ -13,6 +13,7 @@ type ReportedIssueRepository interface {
CountReportedIssuesByUser(ctx context.Context, userID int64) (int64, error)
UpdateReportedIssueStatus(ctx context.Context, id int64, status string) error
DeleteReportedIssue(ctx context.Context, id int64) error
GetReportedIssueByID(ctx context.Context, id int64) (dbgen.ReportedIssue, error)
}
type ReportedIssueRepo struct {
@ -62,3 +63,7 @@ func (s *ReportedIssueRepo) UpdateReportedIssueStatus(ctx context.Context, id in
func (s *ReportedIssueRepo) DeleteReportedIssue(ctx context.Context, id int64) error {
return s.store.queries.DeleteReportedIssue(ctx, id)
}
func (s *ReportedIssueRepo) GetReportedIssueByID(ctx context.Context, id int64) (dbgen.ReportedIssue, error) {
return s.store.queries.GetReportedIssueByID(ctx, id)
}

View File

@ -233,9 +233,4 @@ func (s *Store) DeleteSubCourse(
}, nil
}
func ptrString(t pgtype.Text) *string {
if !t.Valid {
return nil
}
return &t.String
}

View File

@ -0,0 +1,54 @@
package activity_log
import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/ports"
"context"
"encoding/json"
"go.uber.org/zap"
)
type Service struct {
store ports.ActivityLogStore
logger *zap.Logger
}
func NewService(store ports.ActivityLogStore, logger *zap.Logger) *Service {
return &Service{
store: store,
logger: logger,
}
}
func (s *Service) Record(ctx context.Context, log domain.ActivityLog) {
if _, err := s.store.CreateActivityLog(ctx, log); err != nil {
s.logger.Warn("failed to record activity log",
zap.String("action", log.Action),
zap.String("resource_type", log.ResourceType),
zap.Error(err),
)
}
}
func (s *Service) List(ctx context.Context, filter domain.ActivityLogFilter) ([]domain.ActivityLog, int64, error) {
return s.store.ListActivityLogs(ctx, filter)
}
func (s *Service) GetByID(ctx context.Context, id int64) (domain.ActivityLog, error) {
return s.store.GetActivityLogByID(ctx, id)
}
func (s *Service) RecordAction(ctx context.Context, actorID *int64, actorRole *string, action domain.ActivityAction, resourceType domain.ResourceType, resourceID *int64, message string, metadata json.RawMessage, ip *string, userAgent *string) {
s.Record(ctx, domain.ActivityLog{
ActorID: actorID,
ActorRole: actorRole,
Action: string(action),
ResourceType: string(resourceType),
ResourceID: resourceID,
Message: &message,
Metadata: metadata,
IPAddress: ip,
UserAgent: userAgent,
})
}

View File

@ -0,0 +1,110 @@
package cloudconvert
import (
cc "Yimaru-Backend/internal/pkgs/cloudconvert"
"bytes"
"context"
"fmt"
"io"
"time"
"go.uber.org/zap"
)
type Service struct {
client *cc.Client
logger *zap.Logger
}
func NewService(apiKey string, logger *zap.Logger) *Service {
return &Service{
client: cc.NewClient(apiKey),
logger: logger,
}
}
type CompressResult struct {
Data io.ReadCloser
FileSize int64
Filename string
}
func (s *Service) CompressVideo(ctx context.Context, filename string, fileData io.Reader, fileSize int64) (*CompressResult, error) {
s.logger.Info("Creating CloudConvert compression job", zap.String("filename", filename), zap.Int64("original_size", fileSize))
job, err := s.client.CreateVideoCompressionJob(ctx)
if err != nil {
s.logger.Error("Failed to create CloudConvert job", zap.Error(err))
return nil, fmt.Errorf("failed to create compression job: %w", err)
}
var uploadForm *cc.UploadForm
for _, task := range job.Tasks {
if task.Name == "import-video" && task.Result != nil && task.Result.Form != nil {
uploadForm = task.Result.Form
break
}
}
if uploadForm == nil {
return nil, fmt.Errorf("no upload form found in job response")
}
s.logger.Info("Uploading video to CloudConvert", zap.String("job_id", job.ID))
if err := s.client.UploadFile(ctx, uploadForm, filename, fileData); err != nil {
s.logger.Error("Failed to upload file to CloudConvert", zap.Error(err))
return nil, fmt.Errorf("failed to upload file: %w", err)
}
s.logger.Info("Waiting for CloudConvert job to complete", zap.String("job_id", job.ID))
completedJob, err := s.client.WaitForJob(ctx, job.ID, 5*time.Second, 30*time.Minute)
if err != nil {
s.logger.Error("CloudConvert job failed", zap.String("job_id", job.ID), zap.Error(err))
return nil, fmt.Errorf("compression job failed: %w", err)
}
var exportURL string
var exportFilename string
for _, task := range completedJob.Tasks {
if task.Name == "export-video" && task.Result != nil && len(task.Result.Files) > 0 {
exportURL = task.Result.Files[0].URL
exportFilename = task.Result.Files[0].Filename
break
}
}
if exportURL == "" {
return nil, fmt.Errorf("no export URL found in completed job")
}
s.logger.Info("Downloading compressed video from CloudConvert", zap.String("job_id", job.ID), zap.String("filename", exportFilename))
body, contentLength, err := s.client.DownloadFile(ctx, exportURL)
if err != nil {
return nil, fmt.Errorf("failed to download compressed file: %w", err)
}
if contentLength <= 0 {
data, err := io.ReadAll(body)
body.Close()
if err != nil {
return nil, fmt.Errorf("failed to read compressed file: %w", err)
}
contentLength = int64(len(data))
body = io.NopCloser(bytes.NewReader(data))
}
s.logger.Info("Video compression complete",
zap.Int64("original_size", fileSize),
zap.Int64("compressed_size", contentLength),
zap.String("filename", exportFilename),
)
return &CompressResult{
Data: body,
FileSize: contentLength,
Filename: exportFilename,
}, nil
}

View File

@ -3,16 +3,18 @@ package course_management
import (
"Yimaru-Backend/internal/config"
"Yimaru-Backend/internal/ports"
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
notificationservice "Yimaru-Backend/internal/services/notification"
vimeoservice "Yimaru-Backend/internal/services/vimeo"
)
type Service struct {
userStore ports.UserStore
courseStore ports.CourseStore
notificationSvc *notificationservice.Service
vimeoSvc *vimeoservice.Service
config *config.Config
userStore ports.UserStore
courseStore ports.CourseStore
notificationSvc *notificationservice.Service
vimeoSvc *vimeoservice.Service
cloudConvertSvc *cloudconvertservice.Service
config *config.Config
}
func NewService(
@ -36,3 +38,11 @@ func (s *Service) SetVimeoService(vimeoSvc *vimeoservice.Service) {
func (s *Service) HasVimeoService() bool {
return s.vimeoSvc != nil
}
func (s *Service) SetCloudConvertService(ccSvc *cloudconvertservice.Service) {
s.cloudConvertSvc = ccSvc
}
func (s *Service) HasCloudConvertService() bool {
return s.cloudConvertSvc != nil
}

View File

@ -3,8 +3,13 @@ package course_management
import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/pkgs/vimeo"
vimeoservice "Yimaru-Backend/internal/services/vimeo"
"context"
"fmt"
"io"
"net/http"
"path"
"time"
)
func (s *Service) CreateSubCourseVideo(
@ -49,36 +54,132 @@ func (s *Service) CreateSubCourseVideoWithVimeo(
return domain.SubCourseVideo{}, fmt.Errorf("vimeo service is not configured")
}
// Create pull upload to Vimeo
descStr := ""
if description != nil {
descStr = *description
}
uploadResult, err := s.vimeoSvc.CreatePullUpload(ctx, title, descStr, sourceURL, fileSize)
var uploadResult *vimeoservice.UploadResult
var err error
if s.cloudConvertSvc != nil {
httpClient := &http.Client{Timeout: 30 * time.Minute}
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
if reqErr != nil {
return domain.SubCourseVideo{}, fmt.Errorf("failed to create download request: %w", reqErr)
}
resp, dlErr := httpClient.Do(req)
if dlErr != nil {
return domain.SubCourseVideo{}, fmt.Errorf("failed to download source video: %w", dlErr)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return domain.SubCourseVideo{}, fmt.Errorf("failed to download source video: status %d", resp.StatusCode)
}
dlSize := resp.ContentLength
if dlSize <= 0 {
dlSize = fileSize
}
filename := path.Base(sourceURL)
if filename == "" || filename == "." || filename == "/" {
filename = "video.mp4"
}
result, compErr := s.cloudConvertSvc.CompressVideo(ctx, filename, resp.Body, dlSize)
if compErr != nil {
return domain.SubCourseVideo{}, fmt.Errorf("failed to compress video: %w", compErr)
}
defer result.Data.Close()
uploadResult, err = s.vimeoSvc.UploadVideoFile(ctx, title, descStr, result.Data, result.FileSize)
} else {
uploadResult, err = s.vimeoSvc.CreatePullUpload(ctx, title, descStr, sourceURL, fileSize)
}
if err != nil {
return domain.SubCourseVideo{}, fmt.Errorf("failed to upload to Vimeo: %w", err)
}
// Generate embed URL
embedURL := vimeo.GenerateEmbedURL(uploadResult.VimeoID, &vimeo.EmbedOptions{
Title: true,
Byline: true,
Portrait: true,
})
// Generate embed HTML
embedHTML := vimeo.GenerateIframeEmbed(uploadResult.VimeoID, 640, 360, nil)
// Set values for Vimeo fields
provider := string(domain.VideoHostProviderVimeo)
vimeoStatus := "uploading"
status := "DRAFT"
// Create the video record with Vimeo info
return s.courseStore.CreateSubCourseVideo(
ctx, subCourseID, title, description,
uploadResult.Link, // Use Vimeo link as video URL
uploadResult.Link,
duration, resolution, instructorID, thumbnail, visibility, displayOrder, &status,
&uploadResult.VimeoID, &embedURL, &embedHTML, &vimeoStatus, &provider,
)
}
func (s *Service) CreateSubCourseVideoWithFileUpload(
ctx context.Context,
subCourseID int64,
title string,
description *string,
filename string,
fileData io.Reader,
fileSize int64,
duration int32,
resolution *string,
instructorID *string,
thumbnail *string,
visibility *string,
displayOrder *int32,
) (domain.SubCourseVideo, error) {
if s.vimeoSvc == nil {
return domain.SubCourseVideo{}, fmt.Errorf("vimeo service is not configured")
}
descStr := ""
if description != nil {
descStr = *description
}
videoReader := fileData
videoSize := fileSize
if s.cloudConvertSvc != nil {
result, err := s.cloudConvertSvc.CompressVideo(ctx, filename, fileData, fileSize)
if err != nil {
return domain.SubCourseVideo{}, fmt.Errorf("failed to compress video: %w", err)
}
defer result.Data.Close()
videoReader = result.Data
videoSize = result.FileSize
}
uploadResult, err := s.vimeoSvc.UploadVideoFile(ctx, title, descStr, videoReader, videoSize)
if err != nil {
return domain.SubCourseVideo{}, fmt.Errorf("failed to upload video file to Vimeo: %w", err)
}
embedURL := vimeo.GenerateEmbedURL(uploadResult.VimeoID, &vimeo.EmbedOptions{
Title: true,
Byline: true,
Portrait: true,
})
embedHTML := vimeo.GenerateIframeEmbed(uploadResult.VimeoID, 640, 360, nil)
provider := string(domain.VideoHostProviderVimeo)
vimeoStatus := "uploading"
status := "DRAFT"
return s.courseStore.CreateSubCourseVideo(
ctx, subCourseID, title, description,
uploadResult.Link,
duration, resolution, instructorID, thumbnail, visibility, displayOrder, &status,
&uploadResult.VimeoID, &embedURL, &embedHTML, &vimeoStatus, &provider,
)

View File

@ -2,6 +2,7 @@ package issuereporting
import (
"context"
"encoding/json"
"errors"
dbgen "Yimaru-Backend/gen/db"
@ -18,38 +19,27 @@ func New(repo repository.ReportedIssueRepository) *Service {
}
func (s *Service) CreateReportedIssue(ctx context.Context, issue domain.ReportedIssueReq, userID int64, role domain.Role) (domain.ReportedIssue, error) {
// metadata, err := json.Marshal(issue.Metadata)
// if err != nil {
// return domain.ReportedIssue{}, err
// }
var metadata []byte
if issue.Metadata != nil {
var err error
metadata, err = json.Marshal(issue.Metadata)
if err != nil {
return domain.ReportedIssue{}, err
}
}
params := dbgen.CreateReportedIssueParams{
UserID: userID,
UserRole: string(role),
Subject: issue.Subject,
Description: issue.Description,
IssueType: string(issue.IssueType),
// Metadata: metadata,
Metadata: metadata,
}
dbIssue, err := s.repo.CreateReportedIssue(ctx, params)
if err != nil {
return domain.ReportedIssue{}, err
}
// Map dbgen.ReportedIssue to domain.ReportedIssue
reportedIssue := domain.ReportedIssue{
ID: dbIssue.ID,
Subject: dbIssue.Subject,
Description: dbIssue.Description,
UserID: dbIssue.UserID,
UserRole: domain.Role(dbIssue.UserRole),
Status: domain.ReportedIssueStatus(dbIssue.Status),
IssueType: domain.ReportedIssueType(dbIssue.IssueType),
CreatedAt: dbIssue.CreatedAt.Time,
UpdatedAt: dbIssue.UpdatedAt.Time,
// Add other fields as necessary
}
return reportedIssue, nil
return mapDBIssueToDomain(dbIssue), nil
}
func (s *Service) GetIssuesForUser(ctx context.Context, userID int64, limit, offset int) ([]domain.ReportedIssue, error) {
@ -57,26 +47,23 @@ func (s *Service) GetIssuesForUser(ctx context.Context, userID int64, limit, off
if err != nil {
return nil, err
}
reportedIssues := make([]domain.ReportedIssue, len(dbIssues))
for i, dbIssue := range dbIssues {
reportedIssues[i] = domain.ReportedIssue{
ID: dbIssue.ID,
Subject: dbIssue.Subject,
Description: dbIssue.Description,
UserID: dbIssue.UserID,
UserRole: domain.Role(dbIssue.UserRole),
Status: domain.ReportedIssueStatus(dbIssue.Status),
IssueType: domain.ReportedIssueType(dbIssue.IssueType),
CreatedAt: dbIssue.CreatedAt.Time,
UpdatedAt: dbIssue.UpdatedAt.Time,
// Add other fields as necessary
}
}
return reportedIssues, nil
return mapDBIssuesToDomain(dbIssues), nil
}
func (s *Service) GetAllIssues(ctx context.Context, limit, offset int) ([]dbgen.ReportedIssue, error) {
return s.repo.ListReportedIssues(ctx, int32(limit), int32(offset))
func (s *Service) GetAllIssues(ctx context.Context, limit, offset int) ([]domain.ReportedIssue, error) {
dbIssues, err := s.repo.ListReportedIssues(ctx, int32(limit), int32(offset))
if err != nil {
return nil, err
}
return mapDBIssuesToDomain(dbIssues), nil
}
func (s *Service) GetIssueByID(ctx context.Context, issueID int64) (domain.ReportedIssue, error) {
dbIssue, err := s.repo.GetReportedIssueByID(ctx, issueID)
if err != nil {
return domain.ReportedIssue{}, err
}
return mapDBIssueToDomain(dbIssue), nil
}
func (s *Service) UpdateIssueStatus(ctx context.Context, issueID int64, status string) error {
@ -87,6 +74,40 @@ func (s *Service) UpdateIssueStatus(ctx context.Context, issueID int64, status s
return s.repo.UpdateReportedIssueStatus(ctx, issueID, status)
}
func (s *Service) CountAllIssues(ctx context.Context) (int64, error) {
return s.repo.CountReportedIssues(ctx)
}
func (s *Service) CountIssuesByUser(ctx context.Context, userID int64) (int64, error) {
return s.repo.CountReportedIssuesByUser(ctx, userID)
}
func (s *Service) DeleteIssue(ctx context.Context, issueID int64) error {
return s.repo.DeleteReportedIssue(ctx, issueID)
}
func mapDBIssueToDomain(dbIssue dbgen.ReportedIssue) domain.ReportedIssue {
issue := domain.ReportedIssue{
ID: dbIssue.ID,
Subject: dbIssue.Subject,
Description: dbIssue.Description,
UserID: dbIssue.UserID,
UserRole: domain.Role(dbIssue.UserRole),
Status: domain.ReportedIssueStatus(dbIssue.Status),
IssueType: domain.ReportedIssueType(dbIssue.IssueType),
CreatedAt: dbIssue.CreatedAt.Time,
UpdatedAt: dbIssue.UpdatedAt.Time,
}
if len(dbIssue.Metadata) > 0 {
_ = json.Unmarshal(dbIssue.Metadata, &issue.Metadata)
}
return issue
}
func mapDBIssuesToDomain(dbIssues []dbgen.ReportedIssue) []domain.ReportedIssue {
issues := make([]domain.ReportedIssue, len(dbIssues))
for i, dbIssue := range dbIssues {
issues[i] = mapDBIssueToDomain(dbIssue)
}
return issues
}

View File

@ -4,6 +4,7 @@ import (
"Yimaru-Backend/internal/pkgs/vimeo"
"context"
"fmt"
"io"
"go.uber.org/zap"
)
@ -120,6 +121,29 @@ func (s *Service) CreateTusUpload(ctx context.Context, name, description string,
}, nil
}
func (s *Service) UploadVideoFile(ctx context.Context, name, description string, fileData io.Reader, fileSize int64) (*UploadResult, error) {
resp, err := s.client.CreateTusUpload(ctx, name, description, fileSize)
if err != nil {
s.logger.Error("Failed to create TUS upload slot", zap.Error(err))
return nil, fmt.Errorf("failed to create TUS upload slot: %w", err)
}
if err := s.client.UploadTusVideoFile(ctx, resp.Upload.UploadLink, fileData, fileSize); err != nil {
s.logger.Error("Failed to upload video file to Vimeo", zap.Error(err))
return nil, fmt.Errorf("failed to upload video file: %w", err)
}
videoID := vimeo.ExtractVideoID(resp.URI)
return &UploadResult{
VimeoID: videoID,
URI: resp.URI,
Link: resp.Link,
UploadLink: resp.Upload.UploadLink,
Status: "uploading",
}, nil
}
func (s *Service) UpdateVideoMetadata(ctx context.Context, videoID string, name, description *string) (*VideoInfo, error) {
req := &vimeo.UpdateVideoRequest{
Name: name,

View File

@ -2,6 +2,7 @@ package httpserver
import (
"Yimaru-Backend/internal/config"
activitylogservice "Yimaru-Backend/internal/services/activity_log"
"Yimaru-Backend/internal/services/arifpay"
"Yimaru-Backend/internal/services/assessment"
"Yimaru-Backend/internal/services/authentication"
@ -38,6 +39,7 @@ type App struct {
issueReportingSvc *issuereporting.Service
vimeoSvc *vimeoservice.Service
teamSvc *team.Service
activityLogSvc *activitylogservice.Service
fiber *fiber.App
recommendationSvc recommendation.RecommendationService
cfg *config.Config
@ -63,6 +65,7 @@ func NewApp(
issueReportingSvc *issuereporting.Service,
vimeoSvc *vimeoservice.Service,
teamSvc *team.Service,
activityLogSvc *activitylogservice.Service,
port int, validator *customvalidator.CustomValidator,
settingSvc *settings.Service,
authSvc *authentication.Service,
@ -80,6 +83,7 @@ func NewApp(
DisableHeaderNormalizing: true,
JSONEncoder: sonic.Marshal,
JSONDecoder: sonic.Unmarshal,
BodyLimit: 500 * 1024 * 1024, // 500 MB
})
app.Use(cors.New(cors.Config{
@ -99,7 +103,8 @@ func NewApp(
arifpaySvc: arifpaySvc,
vimeoSvc: vimeoSvc,
teamSvc: teamSvc,
// issueReportingSvc: issueReportingSvc,
activityLogSvc: activityLogSvc,
issueReportingSvc: issueReportingSvc,
fiber: app,
port: port,
settingSvc: settingSvc,

View File

@ -0,0 +1,208 @@
package handlers
import (
"Yimaru-Backend/internal/domain"
"encoding/json"
"strconv"
"time"
"github.com/gofiber/fiber/v2"
)
type activityLogRes struct {
ID int64 `json:"id"`
ActorID *int64 `json:"actor_id,omitempty"`
ActorRole *string `json:"actor_role,omitempty"`
Action string `json:"action"`
ResourceType string `json:"resource_type"`
ResourceID *int64 `json:"resource_id,omitempty"`
Message *string `json:"message,omitempty"`
Metadata json.RawMessage `json:"metadata"`
IPAddress *string `json:"ip_address,omitempty"`
UserAgent *string `json:"user_agent,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type activityLogListRes struct {
Logs []activityLogRes `json:"logs"`
TotalCount int64 `json:"total_count"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
// GetActivityLogs godoc
// @Summary Get activity logs
// @Description Returns a filtered, paginated list of activity logs
// @Tags activity-logs
// @Produce json
// @Param actor_id query int false "Filter by actor ID"
// @Param action query string false "Filter by action"
// @Param resource_type query string false "Filter by resource type"
// @Param resource_id query int false "Filter by resource ID"
// @Param after query string false "Filter logs after this RFC3339 timestamp"
// @Param before query string false "Filter logs before this RFC3339 timestamp"
// @Param limit query int false "Limit" default(20)
// @Param offset query int false "Offset" default(0)
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/activity-logs [get]
func (h *Handler) GetActivityLogs(c *fiber.Ctx) error {
var filter domain.ActivityLogFilter
if v := c.Query("actor_id"); v != "" {
id, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid actor_id parameter",
Error: err.Error(),
})
}
filter.ActorID = &id
}
if v := c.Query("action"); v != "" {
filter.Action = &v
}
if v := c.Query("resource_type"); v != "" {
filter.ResourceType = &v
}
if v := c.Query("resource_id"); v != "" {
id, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid resource_id parameter",
Error: err.Error(),
})
}
filter.ResourceID = &id
}
if v := c.Query("after"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid after parameter, expected RFC3339 format",
Error: err.Error(),
})
}
filter.After = &t
}
if v := c.Query("before"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid before parameter, expected RFC3339 format",
Error: err.Error(),
})
}
filter.Before = &t
}
limitStr := c.Query("limit", "20")
limit, err := strconv.Atoi(limitStr)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid limit parameter",
Error: err.Error(),
})
}
if limit > 100 {
limit = 100
}
filter.Limit = int32(limit)
offsetStr := c.Query("offset", "0")
offset, err := strconv.Atoi(offsetStr)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid offset parameter",
Error: err.Error(),
})
}
filter.Offset = int32(offset)
logs, totalCount, err := h.activityLogSvc.List(c.Context(), filter)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to retrieve activity logs",
Error: err.Error(),
})
}
var logResponses []activityLogRes
for _, l := range logs {
logResponses = append(logResponses, activityLogRes{
ID: l.ID,
ActorID: l.ActorID,
ActorRole: l.ActorRole,
Action: l.Action,
ResourceType: l.ResourceType,
ResourceID: l.ResourceID,
Message: l.Message,
Metadata: l.Metadata,
IPAddress: l.IPAddress,
UserAgent: l.UserAgent,
CreatedAt: l.CreatedAt,
})
}
return c.JSON(domain.Response{
Message: "Activity logs retrieved successfully",
Data: activityLogListRes{
Logs: logResponses,
TotalCount: totalCount,
Limit: int32(limit),
Offset: int32(offset),
},
})
}
// GetActivityLogByID godoc
// @Summary Get activity log by ID
// @Description Returns a single activity log entry by its ID
// @Tags activity-logs
// @Produce json
// @Param id path int true "Activity Log ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Router /api/v1/activity-logs/{id} [get]
func (h *Handler) GetActivityLogByID(c *fiber.Ctx) error {
idStr := c.Params("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid activity log ID",
Error: err.Error(),
})
}
l, err := h.activityLogSvc.GetByID(c.Context(), id)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Activity log not found",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Activity log retrieved successfully",
Data: activityLogRes{
ID: l.ID,
ActorID: l.ActorID,
ActorRole: l.ActorRole,
Action: l.Action,
ResourceType: l.ResourceType,
ResourceID: l.ResourceID,
Message: l.Message,
Metadata: l.Metadata,
IPAddress: l.IPAddress,
UserAgent: l.UserAgent,
CreatedAt: l.CreatedAt,
},
})
}

View File

@ -4,6 +4,8 @@ import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/services/authentication"
"Yimaru-Backend/internal/web_server/response"
"context"
"encoding/json"
"fmt"
"strconv"
"time"
@ -127,6 +129,13 @@ func (h *Handler) CreateAdmin(c *fiber.Ctx) error {
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{}{"email": req.Email, "admin_id": newUser.ID})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionAdminCreated, domain.ResourceAdmin, &newUser.ID, "Created admin: "+req.Email, meta, &ip, &ua)
return response.WriteJSON(c, fiber.StatusOK, "Admin created successfully", nil, nil)
}
@ -359,5 +368,12 @@ func (h *Handler) UpdateAdmin(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update admin: "+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{}{"admin_id": adminID, "first_name": req.FirstName, "last_name": req.LastName})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionAdminUpdated, domain.ResourceAdmin, &adminID, fmt.Sprintf("Updated admin ID: %d", adminID), meta, &ip, &ua)
return response.WriteJSON(c, fiber.StatusOK, "Admin updated successfully", nil, nil)
}

View File

@ -2,6 +2,8 @@ package handlers
import (
"Yimaru-Backend/internal/domain"
"context"
"encoding/json"
"fmt"
"strconv"
@ -49,6 +51,13 @@ func (h *Handler) CreateCourseCategory(c *fiber.Ctx) 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{}{"name": category.Name})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCategoryCreated, domain.ResourceCategory, &category.ID, "Created course category: "+category.Name, meta, &ip, &ua)
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Course category created successfully",
Data: courseCategoryRes{
@ -206,6 +215,13 @@ func (h *Handler) UpdateCourseCategory(c *fiber.Ctx) 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{}{"id": id, "name": req.Name, "is_active": req.IsActive})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCategoryUpdated, domain.ResourceCategory, &id, fmt.Sprintf("Updated course category ID: %d", id), meta, &ip, &ua)
return c.JSON(domain.Response{
Message: "Course category updated successfully",
})
@ -239,6 +255,13 @@ func (h *Handler) DeleteCourseCategory(c *fiber.Ctx) 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{}{"id": id})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCategoryDeleted, domain.ResourceCategory, &id, fmt.Sprintf("Deleted category ID: %d", id), meta, &ip, &ua)
return c.JSON(domain.Response{
Message: "Course category deleted successfully",
})
@ -290,6 +313,13 @@ func (h *Handler) CreateCourse(c *fiber.Ctx) 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{}{"title": course.Title, "category_id": course.CategoryID})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCourseCreated, domain.ResourceCourse, &course.ID, "Created course: "+course.Title, meta, &ip, &ua)
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Course created successfully",
Data: courseRes{
@ -465,6 +495,13 @@ func (h *Handler) UpdateCourse(c *fiber.Ctx) 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{}{"id": id, "title": req.Title, "description": req.Description, "thumbnail": req.Thumbnail, "is_active": req.IsActive})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCourseUpdated, domain.ResourceCourse, &id, fmt.Sprintf("Updated course ID: %d", id), meta, &ip, &ua)
return c.JSON(domain.Response{
Message: "Course updated successfully",
})
@ -498,6 +535,13 @@ func (h *Handler) DeleteCourse(c *fiber.Ctx) 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{}{"id": id})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCourseDeleted, domain.ResourceCourse, &id, fmt.Sprintf("Deleted course ID: %d", id), meta, &ip, &ua)
return c.JSON(domain.Response{
Message: "Course deleted successfully",
})
@ -553,6 +597,13 @@ func (h *Handler) CreateSubCourse(c *fiber.Ctx) 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{}{"title": subCourse.Title, "course_id": subCourse.CourseID, "level": subCourse.Level})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionSubCourseCreated, domain.ResourceSubCourse, &subCourse.ID, "Created sub-course: "+subCourse.Title, meta, &ip, &ua)
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Sub-course created successfully",
Data: subCourseRes{
@ -800,6 +851,13 @@ func (h *Handler) UpdateSubCourse(c *fiber.Ctx) 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{}{"id": id, "title": req.Title, "description": req.Description, "level": req.Level, "is_active": req.IsActive})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionSubCourseUpdated, domain.ResourceSubCourse, &id, fmt.Sprintf("Updated sub-course ID: %d", id), meta, &ip, &ua)
return c.JSON(domain.Response{
Message: "Sub-course updated successfully",
})
@ -833,6 +891,13 @@ func (h *Handler) DeactivateSubCourse(c *fiber.Ctx) 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{}{"id": id})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionSubCourseDeactivated, domain.ResourceSubCourse, &id, fmt.Sprintf("Deactivated sub-course ID: %d", id), meta, &ip, &ua)
return c.JSON(domain.Response{
Message: "Sub-course deactivated successfully",
})
@ -866,6 +931,13 @@ func (h *Handler) DeleteSubCourse(c *fiber.Ctx) 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{}{"id": id})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionSubCourseDeleted, domain.ResourceSubCourse, &id, fmt.Sprintf("Deleted sub-course ID: %d", id), meta, &ip, &ua)
return c.JSON(domain.Response{
Message: "Sub-course deleted successfully",
})
@ -959,6 +1031,13 @@ func (h *Handler) CreateSubCourseVideo(c *fiber.Ctx) 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{}{"title": video.Title, "sub_course_id": video.SubCourseID})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionVideoCreated, domain.ResourceVideo, &video.ID, "Created video: "+video.Title, meta, &ip, &ua)
var publishDate *string
if video.PublishDate != nil {
pd := video.PublishDate.String()
@ -1197,6 +1276,13 @@ func (h *Handler) PublishSubCourseVideo(c *fiber.Ctx) 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{}{"id": id})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionVideoPublished, domain.ResourceVideo, &id, fmt.Sprintf("Published video ID: %d", id), meta, &ip, &ua)
return c.JSON(domain.Response{
Message: "Video published successfully",
})
@ -1252,6 +1338,13 @@ func (h *Handler) UpdateSubCourseVideo(c *fiber.Ctx) 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{}{"id": id, "title": req.Title})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionVideoUpdated, domain.ResourceVideo, &id, fmt.Sprintf("Updated video ID: %d", id), meta, &ip, &ua)
return c.JSON(domain.Response{
Message: "Video updated successfully",
})
@ -1285,6 +1378,13 @@ func (h *Handler) DeleteSubCourseVideo(c *fiber.Ctx) 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{}{"id": id})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionVideoArchived, domain.ResourceVideo, &id, fmt.Sprintf("Archived video ID: %d", id), meta, &ip, &ua)
return c.JSON(domain.Response{
Message: "Video deleted successfully",
})
@ -1319,6 +1419,143 @@ func (h *Handler) GetFullLearningTree(c *fiber.Ctx) error {
})
}
// UploadSubCourseVideo godoc
// @Summary Upload a video file and create sub-course video
// @Description Accepts a video file upload, uploads it to Vimeo via TUS, and creates a sub-course video record
// @Tags sub-course-videos
// @Accept multipart/form-data
// @Produce json
// @Param file formData file true "Video file"
// @Param sub_course_id formData int true "Sub-course ID"
// @Param title formData string true "Video title"
// @Param description formData string false "Video description"
// @Param duration formData int false "Duration in seconds"
// @Param resolution formData string false "Video resolution"
// @Param instructor_id formData string false "Instructor ID"
// @Param thumbnail formData string false "Thumbnail URL"
// @Param visibility formData string false "Visibility"
// @Param display_order formData int false "Display order"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/videos/upload [post]
func (h *Handler) UploadSubCourseVideo(c *fiber.Ctx) error {
subCourseIDStr := c.FormValue("sub_course_id")
if subCourseIDStr == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "sub_course_id is required",
Error: "sub_course_id form field is empty",
})
}
subCourseID, err := strconv.ParseInt(subCourseIDStr, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid sub_course_id",
Error: err.Error(),
})
}
title := c.FormValue("title")
if title == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "title is required",
Error: "title form field is empty",
})
}
fileHeader, err := c.FormFile("file")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Video file is required",
Error: err.Error(),
})
}
const maxSize = 500 * 1024 * 1024 // 500 MB
if fileHeader.Size > maxSize {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "File too large",
Error: "Video file must be <= 500MB",
})
}
file, err := fileHeader.Open()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to read uploaded file",
Error: err.Error(),
})
}
defer file.Close()
var description *string
if desc := c.FormValue("description"); desc != "" {
description = &desc
}
var duration int32
if durStr := c.FormValue("duration"); durStr != "" {
durVal, err := strconv.ParseInt(durStr, 10, 32)
if err == nil {
duration = int32(durVal)
}
}
var resolution *string
if res := c.FormValue("resolution"); res != "" {
resolution = &res
}
var instructorID *string
if iid := c.FormValue("instructor_id"); iid != "" {
instructorID = &iid
}
var thumbnail *string
if thumb := c.FormValue("thumbnail"); thumb != "" {
thumbnail = &thumb
}
var visibility *string
if vis := c.FormValue("visibility"); vis != "" {
visibility = &vis
}
var displayOrder *int32
if doStr := c.FormValue("display_order"); doStr != "" {
doVal, err := strconv.ParseInt(doStr, 10, 32)
if err == nil {
do := int32(doVal)
displayOrder = &do
}
}
video, err := h.courseMgmtSvc.CreateSubCourseVideoWithFileUpload(
c.Context(), subCourseID, title, description,
fileHeader.Filename, file, fileHeader.Size, duration, resolution,
instructorID, thumbnail, visibility, displayOrder,
)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to upload video",
Error: 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{}{"title": video.Title, "sub_course_id": subCourseID})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionVideoUploaded, domain.ResourceVideo, &video.ID, "Uploaded video: "+video.Title, meta, &ip, &ua)
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Video uploaded and created successfully",
Data: mapVideoToResponse(video),
Success: true,
})
}
// CreateSubCourseVideoWithVimeo godoc
// @Summary Create a new sub-course video with Vimeo upload
// @Description Creates a video by uploading to Vimeo from a source URL
@ -1358,6 +1595,13 @@ func (h *Handler) CreateSubCourseVideoWithVimeo(c *fiber.Ctx) 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{}{"title": video.Title, "sub_course_id": video.SubCourseID})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionVideoCreated, domain.ResourceVideo, &video.ID, "Created video with Vimeo: "+video.Title, meta, &ip, &ua)
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Video created and uploaded to Vimeo successfully",
Data: mapVideoToResponse(video),
@ -1403,6 +1647,13 @@ func (h *Handler) CreateSubCourseVideoFromVimeoID(c *fiber.Ctx) 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{}{"title": video.Title, "sub_course_id": video.SubCourseID, "vimeo_video_id": req.VimeoVideoID})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionVideoCreated, domain.ResourceVideo, &video.ID, "Imported video from Vimeo: "+video.Title, meta, &ip, &ua)
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Video imported from Vimeo successfully",
Data: mapVideoToResponse(video),

View File

@ -1,52 +0,0 @@
package handlers
// @Summary Get supported currencies
// @Description Returns list of supported currencies
// @Tags Multi-Currency
// @Produce json
// @Success 200 {object} domain.Response{data=[]domain.Currency}
// @Router /api/v1/currencies [get]
// func (h *Handler) GetSupportedCurrencies(c *fiber.Ctx) error {
// currencies, err := h.currSvc.GetSupportedCurrencies(c.Context())
// if err != nil {
// return domain.UnExpectedErrorResponse(c)
// }
// return c.Status(fiber.StatusOK).JSON(domain.Response{
// Success: true,
// Message: "Supported currencies retrieved successfully",
// Data: currencies,
// StatusCode: fiber.StatusOK,
// })
// }
// @Summary Convert currency
// @Description Converts amount from one currency to another
// @Tags Multi-Currency
// @Produce json
// @Param from query string true "Source currency code (e.g., USD)"
// @Param to query string true "Target currency code (e.g., ETB)"
// @Param amount query number true "Amount to convert"
// @Success 200 {object} domain.Response{data=float64}
// @Failure 400 {object} domain.ErrorResponse
// @Router /api/v1/currencies/convert [get]
// func (h *Handler) ConvertCurrency(c *fiber.Ctx) error {
// from := domain.IntCurrency(c.Query("from"))
// to := domain.IntCurrency(c.Query("to"))
// amount := c.QueryFloat("amount", 0)
// // if err != nil {
// // return domain.BadRequestResponse(c)
// // }
// converted, err := h.currSvc.Convert(c.Context(), amount, from, to)
// if err != nil {
// return domain.UnExpectedErrorResponse(c)
// }
// return c.Status(fiber.StatusOK).JSON(domain.Response{
// Success: true,
// Message: "Currency converted successfully",
// Data: converted,
// StatusCode: fiber.StatusOK,
// })
// }

View File

@ -2,7 +2,9 @@ package handlers
import (
"Yimaru-Backend/internal/config"
activitylogservice "Yimaru-Backend/internal/services/activity_log"
"Yimaru-Backend/internal/services/arifpay"
issuereporting "Yimaru-Backend/internal/services/issue_reporting"
"Yimaru-Backend/internal/services/assessment"
"Yimaru-Backend/internal/services/authentication"
course_management "Yimaru-Backend/internal/services/course_management"
@ -41,6 +43,8 @@ type Handler struct {
authSvc *authentication.Service
vimeoSvc *vimeoservice.Service
teamSvc *team.Service
activityLogSvc *activitylogservice.Service
issueReportingSvc *issuereporting.Service
jwtConfig jwtutil.JwtConfig
validator *customvalidator.CustomValidator
Cfg *config.Config
@ -63,6 +67,8 @@ func New(
authSvc *authentication.Service,
vimeoSvc *vimeoservice.Service,
teamSvc *team.Service,
activityLogSvc *activitylogservice.Service,
issueReportingSvc *issuereporting.Service,
jwtConfig jwtutil.JwtConfig,
cfg *config.Config,
mongoLoggerSvc *zap.Logger,
@ -83,6 +89,8 @@ func New(
authSvc: authSvc,
vimeoSvc: vimeoSvc,
teamSvc: teamSvc,
activityLogSvc: activityLogSvc,
issueReportingSvc: issueReportingSvc,
jwtConfig: jwtConfig,
Cfg: cfg,
mongoLoggerSvc: mongoLoggerSvc,

View File

@ -0,0 +1,400 @@
package handlers
import (
"Yimaru-Backend/internal/domain"
"context"
"encoding/json"
"fmt"
"strconv"
"github.com/gofiber/fiber/v2"
)
type createIssueReq struct {
Subject string `json:"subject" validate:"required"`
Description string `json:"description" validate:"required"`
IssueType string `json:"issue_type" validate:"required"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
type issueRes struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
UserRole string `json:"user_role"`
Subject string `json:"subject"`
Description string `json:"description"`
IssueType string `json:"issue_type"`
Status string `json:"status"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type updateIssueStatusReq struct {
Status string `json:"status" validate:"required,oneof=pending in_progress resolved rejected"`
}
type issueListRes struct {
Issues []issueRes `json:"issues"`
TotalCount int64 `json:"total_count"`
}
func mapIssueToRes(issue domain.ReportedIssue) issueRes {
return issueRes{
ID: issue.ID,
UserID: issue.UserID,
UserRole: string(issue.UserRole),
Subject: issue.Subject,
Description: issue.Description,
IssueType: string(issue.IssueType),
Status: string(issue.Status),
Metadata: issue.Metadata,
CreatedAt: issue.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: issue.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
}
// CreateIssue godoc
// @Summary Report an issue
// @Description Allows any authenticated user to report an issue they encountered
// @Tags issues
// @Accept json
// @Produce json
// @Security Bearer
// @Param body body createIssueReq true "Issue report payload"
// @Success 201 {object} domain.Response{data=issueRes}
// @Failure 400 {object} domain.ErrorResponse
// @Failure 401 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/issues [post]
func (h *Handler) CreateIssue(c *fiber.Ctx) error {
var req createIssueReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: 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: "Validation failed",
Error: errMsg,
})
}
userID := c.Locals("user_id").(int64)
role := c.Locals("role").(domain.Role)
issueReq := domain.ReportedIssueReq{
Subject: req.Subject,
Description: req.Description,
IssueType: domain.ReportedIssueType(req.IssueType),
Metadata: req.Metadata,
}
issue, err := h.issueReportingSvc.CreateReportedIssue(c.Context(), issueReq, userID, role)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to create issue report",
Error: err.Error(),
})
}
actorRole := string(role)
ip := c.IP()
ua := c.Get("User-Agent")
meta, _ := json.Marshal(map[string]interface{}{"subject": issue.Subject, "issue_type": string(issue.IssueType)})
go h.activityLogSvc.RecordAction(context.Background(), &userID, &actorRole, domain.ActionIssueCreated, domain.ResourceIssue, &issue.ID, "Reported issue: "+issue.Subject, meta, &ip, &ua)
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Issue reported successfully",
Data: mapIssueToRes(issue),
Success: true,
})
}
// GetIssueByID godoc
// @Summary Get issue by ID
// @Description Returns a single issue report by its ID (admin only)
// @Tags issues
// @Produce json
// @Security Bearer
// @Param id path int true "Issue ID"
// @Success 200 {object} domain.Response{data=issueRes}
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Router /api/v1/issues/{id} [get]
func (h *Handler) GetIssueByID(c *fiber.Ctx) error {
idStr := c.Params("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid issue ID",
Error: err.Error(),
})
}
issue, err := h.issueReportingSvc.GetIssueByID(c.Context(), id)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Issue not found",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Issue retrieved successfully",
Data: mapIssueToRes(issue),
Success: true,
})
}
// GetUserIssues godoc
// @Summary Get issues for a specific user
// @Description Returns paginated issues reported by a specific user (admin only)
// @Tags issues
// @Produce json
// @Security Bearer
// @Param user_id path int true "User ID"
// @Param limit query int false "Limit" default(20)
// @Param offset query int false "Offset" default(0)
// @Success 200 {object} domain.Response{data=issueListRes}
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/issues/user/{user_id} [get]
func (h *Handler) GetUserIssues(c *fiber.Ctx) error {
userIDStr := c.Params("user_id")
userID, err := strconv.ParseInt(userIDStr, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid user ID",
Error: err.Error(),
})
}
limit, _ := strconv.Atoi(c.Query("limit", "20"))
offset, _ := strconv.Atoi(c.Query("offset", "0"))
if limit > 100 {
limit = 100
}
issues, err := h.issueReportingSvc.GetIssuesForUser(c.Context(), userID, limit, offset)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to retrieve user issues",
Error: err.Error(),
})
}
totalCount, _ := h.issueReportingSvc.CountIssuesByUser(c.Context(), userID)
var issueResponses []issueRes
for _, issue := range issues {
issueResponses = append(issueResponses, mapIssueToRes(issue))
}
return c.JSON(domain.Response{
Message: "User issues retrieved successfully",
Data: issueListRes{
Issues: issueResponses,
TotalCount: totalCount,
},
Success: true,
})
}
// GetMyIssues godoc
// @Summary Get my reported issues
// @Description Returns paginated issues reported by the authenticated user
// @Tags issues
// @Produce json
// @Security Bearer
// @Param limit query int false "Limit" default(20)
// @Param offset query int false "Offset" default(0)
// @Success 200 {object} domain.Response{data=issueListRes}
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/issues/me [get]
func (h *Handler) GetMyIssues(c *fiber.Ctx) error {
userID := c.Locals("user_id").(int64)
limit, _ := strconv.Atoi(c.Query("limit", "20"))
offset, _ := strconv.Atoi(c.Query("offset", "0"))
if limit > 100 {
limit = 100
}
issues, err := h.issueReportingSvc.GetIssuesForUser(c.Context(), userID, limit, offset)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to retrieve issues",
Error: err.Error(),
})
}
totalCount, _ := h.issueReportingSvc.CountIssuesByUser(c.Context(), userID)
var issueResponses []issueRes
for _, issue := range issues {
issueResponses = append(issueResponses, mapIssueToRes(issue))
}
return c.JSON(domain.Response{
Message: "Issues retrieved successfully",
Data: issueListRes{
Issues: issueResponses,
TotalCount: totalCount,
},
Success: true,
})
}
// GetAllIssues godoc
// @Summary Get all issues
// @Description Returns all reported issues with pagination (admin only)
// @Tags issues
// @Produce json
// @Security Bearer
// @Param limit query int false "Limit" default(20)
// @Param offset query int false "Offset" default(0)
// @Success 200 {object} domain.Response{data=issueListRes}
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/issues [get]
func (h *Handler) GetAllIssues(c *fiber.Ctx) error {
limit, _ := strconv.Atoi(c.Query("limit", "20"))
offset, _ := strconv.Atoi(c.Query("offset", "0"))
if limit > 100 {
limit = 100
}
issues, err := h.issueReportingSvc.GetAllIssues(c.Context(), limit, offset)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to retrieve issues",
Error: err.Error(),
})
}
totalCount, _ := h.issueReportingSvc.CountAllIssues(c.Context())
var issueResponses []issueRes
for _, issue := range issues {
issueResponses = append(issueResponses, mapIssueToRes(issue))
}
return c.JSON(domain.Response{
Message: "Issues retrieved successfully",
Data: issueListRes{
Issues: issueResponses,
TotalCount: totalCount,
},
Success: true,
})
}
// UpdateIssueStatus godoc
// @Summary Update issue status
// @Description Updates the status of an issue (admin only)
// @Tags issues
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path int true "Issue ID"
// @Param body body updateIssueStatusReq true "Status update payload"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/issues/{id}/status [patch]
func (h *Handler) UpdateIssueStatus(c *fiber.Ctx) error {
idStr := c.Params("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid issue ID",
Error: err.Error(),
})
}
var req updateIssueStatusReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: 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: "Validation failed",
Error: errMsg,
})
}
if err := h.issueReportingSvc.UpdateIssueStatus(c.Context(), id, req.Status); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to update issue status",
Error: 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{}{"issue_id": id, "new_status": req.Status})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionIssueStatusUpdated, domain.ResourceIssue, &id, fmt.Sprintf("Updated issue %d status to %s", id, req.Status), meta, &ip, &ua)
return c.JSON(domain.Response{
Message: "Issue status updated successfully",
Success: true,
})
}
// DeleteIssue godoc
// @Summary Delete an issue
// @Description Deletes an issue report (admin only)
// @Tags issues
// @Produce json
// @Security Bearer
// @Param id path int true "Issue ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/issues/{id} [delete]
func (h *Handler) DeleteIssue(c *fiber.Ctx) error {
idStr := c.Params("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid issue ID",
Error: err.Error(),
})
}
if err := h.issueReportingSvc.DeleteIssue(c.Context(), id); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to delete issue",
Error: 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{}{"issue_id": id})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionIssueDeleted, domain.ResourceIssue, &id, fmt.Sprintf("Deleted issue ID: %d", id), meta, &ip, &ua)
return c.JSON(domain.Response{
Message: "Issue deleted successfully",
Success: true,
})
}

View File

@ -1,107 +0,0 @@
package handlers
import "time"
var (
ChapaSecret string
ChapaBaseURL string
)
type InitPaymentRequest struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
TxRef string `json:"tx_ref"`
CallbackURL string `json:"callback_url"`
ReturnURL string `json:"return_url"`
}
type TransferRequest struct {
AccountNumber string `json:"account_number"`
BankCode string `json:"bank_code"`
Amount string `json:"amount"`
Currency string `json:"currency"`
Reference string `json:"reference"`
Reason string `json:"reason"`
RecipientName string `json:"recipient_name"`
}
type ChapaSupportedBank struct {
Id int64 `json:"id"`
Slug string `json:"slug"`
Swift string `json:"swift"`
Name string `json:"name"`
AcctLength int `json:"acct_length"`
AcctNumberRegex string `json:"acct_number_regex"`
ExampleValue string `json:"example_value"`
CountryId int `json:"country_id"`
IsMobilemoney *int `json:"is_mobilemoney"`
IsActive int `json:"is_active"`
IsRtgs *int `json:"is_rtgs"`
Active int `json:"active"`
Is24Hrs *int `json:"is_24hrs"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Currency string `json:"currency"`
}
type ChapaSupportedBanksResponse struct {
Message string `json:"message"`
Data []ChapaSupportedBank `json:"data"`
}
type InitPaymentData struct {
TxRef string `json:"tx_ref"`
CheckoutURL string `json:"checkout_url"`
}
type InitPaymentResponse struct {
Status string `json:"status"` // "success"
Message string `json:"message"` // e.g., "Payment initialized"
Data InitPaymentData `json:"data"`
}
type WebhookPayload map[string]interface{}
type TransactionData struct {
TxRef string `json:"tx_ref"`
Status string `json:"status"`
Amount string `json:"amount"`
Currency string `json:"currency"`
CustomerEmail string `json:"email"`
}
type VerifyTransactionResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Data TransactionData `json:"data"`
}
type TransferData struct {
Reference string `json:"reference"`
Status string `json:"status"`
Amount string `json:"amount"`
Currency string `json:"currency"`
}
type CreateTransferResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Data TransferData `json:"data"`
}
type TransferVerificationData struct {
Reference string `json:"reference"`
Status string `json:"status"`
BankCode string `json:"bank_code"`
AccountName string `json:"account_name"`
}
type VerifyTransferResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Data TransferVerificationData `json:"data"`
}

View File

@ -2,6 +2,9 @@ package handlers
import (
"Yimaru-Backend/internal/domain"
"context"
"encoding/json"
"fmt"
"strconv"
"github.com/gofiber/fiber/v2"
@ -129,6 +132,13 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) 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{}{"question_type": question.QuestionType})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionCreated, domain.ResourceQuestion, &question.ID, "Created question: "+question.QuestionText, meta, &ip, &ua)
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Question created successfully",
Data: questionRes{
@ -429,6 +439,13 @@ func (h *Handler) UpdateQuestion(c *fiber.Ctx) 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{}{"question_id": id})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionUpdated, domain.ResourceQuestion, &id, fmt.Sprintf("Updated question ID: %d", id), meta, &ip, &ua)
return c.JSON(domain.Response{
Message: "Question updated successfully",
})
@ -462,6 +479,13 @@ func (h *Handler) DeleteQuestion(c *fiber.Ctx) 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{}{"question_id": id})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionDeleted, domain.ResourceQuestion, &id, fmt.Sprintf("Deleted question ID: %d", id), meta, &ip, &ua)
return c.JSON(domain.Response{
Message: "Question deleted successfully",
})
@ -550,6 +574,13 @@ func (h *Handler) CreateQuestionSet(c *fiber.Ctx) error {
})
}
actorID := c.Locals("user_id").(int64)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
qsMeta, _ := json.Marshal(map[string]interface{}{"title": set.Title, "set_type": set.SetType})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionSetCreated, domain.ResourceQuestionSet, &set.ID, "Created question set: "+set.Title, qsMeta, &ip, &ua)
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Question set created successfully",
Data: questionSetRes{
@ -815,6 +846,13 @@ func (h *Handler) UpdateQuestionSet(c *fiber.Ctx) 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{}{"question_set_id": id})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionSetUpdated, domain.ResourceQuestionSet, &id, fmt.Sprintf("Updated question set ID: %d", id), meta, &ip, &ua)
return c.JSON(domain.Response{
Message: "Question set updated successfully",
})
@ -848,6 +886,13 @@ func (h *Handler) DeleteQuestionSet(c *fiber.Ctx) 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{}{"question_set_id": id})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionSetDeleted, domain.ResourceQuestionSet, &id, fmt.Sprintf("Deleted question set ID: %d", id), meta, &ip, &ua)
return c.JSON(domain.Response{
Message: "Question set deleted successfully",
})

View File

@ -3,6 +3,8 @@ package handlers
import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/web_server/response"
"context"
"encoding/json"
"fmt"
"time"
@ -105,6 +107,14 @@ func (h *Handler) UpdateGlobalSettingList(c *fiber.Ctx) error {
res := domain.ConvertSettingListRes(settingsList)
actorID := c.Locals("user_id").(int64)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
settingID := int64(0)
meta, _ := json.Marshal(map[string]interface{}{"updated": true})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionSettingsUpdated, domain.ResourceSettings, &settingID, "Updated global settings", meta, &ip, &ua)
return response.WriteJSON(c, fiber.StatusOK, "setting updated", res, nil)
}

View File

@ -2,6 +2,9 @@ package handlers
import (
"Yimaru-Backend/internal/domain"
"context"
"encoding/json"
"fmt"
"strconv"
"github.com/gofiber/fiber/v2"
@ -121,6 +124,13 @@ func (h *Handler) CreateSubscriptionPlan(c *fiber.Ctx) 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{}{"name": plan.Name, "price": plan.Price})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionSubscriptionPlanCreated, domain.ResourceSubscriptionPlan, &plan.ID, "Created subscription plan: "+plan.Name, meta, &ip, &ua)
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Subscription plan created successfully",
Data: planToRes(plan),
@ -232,6 +242,13 @@ func (h *Handler) UpdateSubscriptionPlan(c *fiber.Ctx) 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{}{"plan_id": id})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionSubscriptionPlanUpdated, domain.ResourceSubscriptionPlan, &id, fmt.Sprintf("Updated subscription plan ID: %d", id), meta, &ip, &ua)
return c.JSON(domain.Response{
Message: "Subscription plan updated successfully",
})
@ -262,6 +279,13 @@ func (h *Handler) DeleteSubscriptionPlan(c *fiber.Ctx) 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{}{"plan_id": id})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionSubscriptionPlanDeleted, domain.ResourceSubscriptionPlan, &id, fmt.Sprintf("Deleted subscription plan ID: %d", id), meta, &ip, &ua)
return c.JSON(domain.Response{
Message: "Subscription plan deleted successfully",
})

View File

@ -3,6 +3,8 @@ package handlers
import (
"Yimaru-Backend/internal/domain"
jwtutil "Yimaru-Backend/internal/web_server/jwt"
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
@ -237,6 +239,12 @@ func (h *Handler) CreateTeamMember(c *fiber.Ctx) error {
zap.Time("timestamp", time.Now()),
)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
meta, _ := json.Marshal(map[string]interface{}{"email": member.Email, "team_role": string(member.TeamRole)})
go h.activityLogSvc.RecordAction(context.Background(), &creatorID, &actorRole, domain.ActionTeamMemberCreated, domain.ResourceTeamMember, &member.ID, "Created team member: "+member.Email, meta, &ip, &ua)
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Team member created successfully",
Data: toTeamMemberResponse(&member),
@ -464,6 +472,12 @@ func (h *Handler) UpdateTeamMember(c *fiber.Ctx) error {
zap.Time("timestamp", time.Now()),
)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
meta, _ := json.Marshal(map[string]interface{}{"member_id": memberID})
go h.activityLogSvc.RecordAction(context.Background(), &updaterID, &actorRole, domain.ActionTeamMemberUpdated, domain.ResourceTeamMember, &memberID, fmt.Sprintf("Updated team member ID: %d", memberID), meta, &ip, &ua)
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Team member updated successfully",
Success: true,
@ -560,6 +574,12 @@ func (h *Handler) UpdateTeamMemberStatus(c *fiber.Ctx) error {
zap.Time("timestamp", time.Now()),
)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
meta, _ := json.Marshal(map[string]interface{}{"member_id": memberID, "new_status": req.Status})
go h.activityLogSvc.RecordAction(context.Background(), &updaterID, &actorRole, domain.ActionTeamMemberUpdated, domain.ResourceTeamMember, &memberID, fmt.Sprintf("Updated team member status ID: %d to %s", memberID, req.Status), meta, &ip, &ua)
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Team member status updated successfully",
Success: true,
@ -616,6 +636,13 @@ func (h *Handler) DeleteTeamMember(c *fiber.Ctx) error {
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{}{"member_id": memberID})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionTeamMemberDeleted, domain.ResourceTeamMember, &memberID, fmt.Sprintf("Deleted team member ID: %d", memberID), meta, &ip, &ua)
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Team member deleted successfully",
Success: true,

View File

@ -5,6 +5,8 @@ import (
"Yimaru-Backend/internal/services/authentication"
jwtutil "Yimaru-Backend/internal/web_server/jwt"
"Yimaru-Backend/internal/web_server/response"
"context"
"encoding/json"
"errors"
"fmt"
"io"
@ -140,6 +142,12 @@ func (h *Handler) UpdateUser(c *fiber.Ctx) error {
})
}
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
meta, _ := json.Marshal(map[string]interface{}{"user_id": userID})
go h.activityLogSvc.RecordAction(context.Background(), &userID, &actorRole, domain.ActionUserUpdated, domain.ResourceUser, &userID, fmt.Sprintf("Updated user ID: %d", userID), meta, &ip, &ua)
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "User updated successfully",
})
@ -1594,6 +1602,13 @@ func (h *Handler) DeleteUser(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete user:"+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{}{"deleted_user_id": userID})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionUserDeleted, domain.ResourceUser, &userID, fmt.Sprintf("Deleted user ID: %d", userID), meta, &ip, &ua)
return response.WriteJSON(c, fiber.StatusOK, "User deleted successfully", nil, nil)
}

View File

@ -28,6 +28,8 @@ func (a *App) initAppRoutes() {
a.authSvc,
a.vimeoSvc,
a.teamSvc,
a.activityLogSvc,
a.issueReportingSvc,
a.JwtConfig,
a.cfg,
a.mongoLoggerSvc,
@ -160,6 +162,7 @@ func (a *App) initAppRoutes() {
// Sub-course Videos
groupV1.Post("/course-management/videos", a.authMiddleware, h.CreateSubCourseVideo)
groupV1.Post("/course-management/videos/vimeo", a.authMiddleware, h.CreateSubCourseVideoWithVimeo)
groupV1.Post("/course-management/videos/upload", a.authMiddleware, h.UploadSubCourseVideo)
groupV1.Post("/course-management/videos/vimeo/import", a.authMiddleware, h.CreateSubCourseVideoFromVimeoID)
groupV1.Get("/course-management/videos/:id", a.authMiddleware, h.GetSubCourseVideoByID)
groupV1.Get("/course-management/sub-courses/:subCourseId/videos", a.authMiddleware, h.GetVideosBySubCourse)
@ -313,6 +316,10 @@ func (a *App) initAppRoutes() {
//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)
// groupV1.Get("/shop/transaction", a.authMiddleware, a.CompanyOnly, h.GetAllTransactions)
// groupV1.Get("/shop/transaction/:id", a.authMiddleware, a.CompanyOnly, h.GetTransactionByID)
// groupV1.Get("/shop/transaction/:id/bet", a.authMiddleware, a.CompanyOnly, h.GetShopBetByTransactionID)
@ -327,12 +334,14 @@ func (a *App) initAppRoutes() {
groupV1.Get("/notifications/unread", a.authMiddleware, h.CountUnreadNotifications)
groupV1.Post("/notifications/create", a.authMiddleware, h.CreateAndSendNotification)
//Issue Reporting Routes
// groupV1.Post("/issues", a.authMiddleware, h.CreateIssue) //anyone who has logged can report a
// groupV1.Get("/issues/customer/:customer_id", a.authMiddleware, a.OnlyAdminAndAbove, h.GetUserIssues)
// groupV1.Get("/issues", a.authMiddleware, a.OnlyAdminAndAbove, h.GetAllIssues)
// groupV1.Patch("/issues/:issue_id/status", a.authMiddleware, a.OnlyAdminAndAbove, h.UpdateIssueStatus)
// groupV1.Delete("/issues/:issue_id", a.authMiddleware, a.OnlyAdminAndAbove, h.DeleteIssue)
// Issue Reporting Routes
groupV1.Post("/issues", a.authMiddleware, h.CreateIssue)
groupV1.Get("/issues/me", a.authMiddleware, h.GetMyIssues)
groupV1.Get("/issues/user/:user_id", a.authMiddleware, a.OnlyAdminAndAbove, h.GetUserIssues)
groupV1.Get("/issues", a.authMiddleware, a.OnlyAdminAndAbove, h.GetAllIssues)
groupV1.Get("/issues/:id", a.authMiddleware, a.OnlyAdminAndAbove, h.GetIssueByID)
groupV1.Patch("/issues/:id/status", a.authMiddleware, a.OnlyAdminAndAbove, h.UpdateIssueStatus)
groupV1.Delete("/issues/:id", a.authMiddleware, a.OnlyAdminAndAbove, h.DeleteIssue)
// Device Token Registration
groupV1.Post("/devices/register", a.authMiddleware, h.RegisterDeviceToken)