diff --git a/cmd/main.go b/cmd/main.go index ba6ec75..ff67b86 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -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, diff --git a/db/data/004_activity_logs_seed.sql b/db/data/004_activity_logs_seed.sql new file mode 100644 index 0000000..6405acf --- /dev/null +++ b/db/data/004_activity_logs_seed.sql @@ -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'); diff --git a/db/migrations/000014_activity_logs.down.sql b/db/migrations/000014_activity_logs.down.sql new file mode 100644 index 0000000..b6a2017 --- /dev/null +++ b/db/migrations/000014_activity_logs.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS activity_logs; diff --git a/db/migrations/000014_activity_logs.up.sql b/db/migrations/000014_activity_logs.up.sql new file mode 100644 index 0000000..74c4e1b --- /dev/null +++ b/db/migrations/000014_activity_logs.up.sql @@ -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); diff --git a/db/query/activity_logs.sql b/db/query/activity_logs.sql new file mode 100644 index 0000000..84adc82 --- /dev/null +++ b/db/query/activity_logs.sql @@ -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; diff --git a/db/query/issue_reporting.sql b/db/query/issue_reporting.sql index 1906ffe..3efc5c9 100644 --- a/db/query/issue_reporting.sql +++ b/db/query/issue_reporting.sql @@ -34,4 +34,7 @@ SET status = $2, WHERE id = $1; -- name: DeleteReportedIssue :exec DELETE FROM reported_issues -WHERE id = $1; \ No newline at end of file +WHERE id = $1; + +-- name: GetReportedIssueByID :one +SELECT * FROM reported_issues WHERE id = $1; \ No newline at end of file diff --git a/gen/db/activity_logs.sql.go b/gen/db/activity_logs.sql.go new file mode 100644 index 0000000..e3ab729 --- /dev/null +++ b/gen/db/activity_logs.sql.go @@ -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 +} diff --git a/gen/db/issue_reporting.sql.go b/gen/db/issue_reporting.sql.go index e35fba1..1a41124 100644 --- a/gen/db/issue_reporting.sql.go +++ b/gen/db/issue_reporting.sql.go @@ -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 diff --git a/gen/db/models.go b/gen/db/models.go index a442246..17c6127 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -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"` diff --git a/go.mod b/go.mod index 1a5513d..db278e0 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index b8b9d30..cd871f1 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go index 5015beb..5977ff6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } diff --git a/internal/domain/activity_log.go b/internal/domain/activity_log.go new file mode 100644 index 0000000..5579361 --- /dev/null +++ b/internal/domain/activity_log.go @@ -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 +} diff --git a/internal/domain/issue_reporting.go b/internal/domain/issue_reporting.go index 38ffe76..e0e9b84 100644 --- a/internal/domain/issue_reporting.go +++ b/internal/domain/issue_reporting.go @@ -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 diff --git a/internal/pkgs/cloudconvert/client.go b/internal/pkgs/cloudconvert/client.go new file mode 100644 index 0000000..1353245 --- /dev/null +++ b/internal/pkgs/cloudconvert/client.go @@ -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) +} diff --git a/internal/pkgs/vimeo/client.go b/internal/pkgs/vimeo/client.go index 3c08f20..8926a16 100644 --- a/internal/pkgs/vimeo/client.go +++ b/internal/pkgs/vimeo/client.go @@ -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 { diff --git a/internal/ports/activity_log.go b/internal/ports/activity_log.go new file mode 100644 index 0000000..ca69455 --- /dev/null +++ b/internal/ports/activity_log.go @@ -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) +} diff --git a/internal/repository/activity_logs.go b/internal/repository/activity_logs.go new file mode 100644 index 0000000..5afadb7 --- /dev/null +++ b/internal/repository/activity_logs.go @@ -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, + } +} diff --git a/internal/repository/common.go b/internal/repository/common.go index 0b8b249..0908ed5 100644 --- a/internal/repository/common.go +++ b/internal/repository/common.go @@ -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 +} diff --git a/internal/repository/issue_reporting.go b/internal/repository/issue_reporting.go index a6e8c79..6e781e7 100644 --- a/internal/repository/issue_reporting.go +++ b/internal/repository/issue_reporting.go @@ -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) +} diff --git a/internal/repository/sub_courses.go b/internal/repository/sub_courses.go index 038b444..43f0de6 100644 --- a/internal/repository/sub_courses.go +++ b/internal/repository/sub_courses.go @@ -233,9 +233,4 @@ func (s *Store) DeleteSubCourse( }, nil } -func ptrString(t pgtype.Text) *string { - if !t.Valid { - return nil - } - return &t.String -} + diff --git a/internal/services/activity_log/service.go b/internal/services/activity_log/service.go new file mode 100644 index 0000000..d71d44d --- /dev/null +++ b/internal/services/activity_log/service.go @@ -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, + }) +} diff --git a/internal/services/cloudconvert/service.go b/internal/services/cloudconvert/service.go new file mode 100644 index 0000000..6c1cd0a --- /dev/null +++ b/internal/services/cloudconvert/service.go @@ -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 +} diff --git a/internal/services/course_management/service.go b/internal/services/course_management/service.go index 828582c..cfaa551 100644 --- a/internal/services/course_management/service.go +++ b/internal/services/course_management/service.go @@ -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 +} diff --git a/internal/services/course_management/sub_course_videos.go b/internal/services/course_management/sub_course_videos.go index 0f257aa..4cbdf7e 100644 --- a/internal/services/course_management/sub_course_videos.go +++ b/internal/services/course_management/sub_course_videos.go @@ -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, ) diff --git a/internal/services/issue_reporting/service.go b/internal/services/issue_reporting/service.go index 308f467..48c21eb 100644 --- a/internal/services/issue_reporting/service.go +++ b/internal/services/issue_reporting/service.go @@ -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 +} diff --git a/internal/services/vimeo/service.go b/internal/services/vimeo/service.go index 78ee649..25750ef 100644 --- a/internal/services/vimeo/service.go +++ b/internal/services/vimeo/service.go @@ -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, diff --git a/internal/web_server/app.go b/internal/web_server/app.go index cb4a801..47994e6 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -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, diff --git a/internal/web_server/handlers/activity_logs.go b/internal/web_server/handlers/activity_logs.go new file mode 100644 index 0000000..8ff2a4e --- /dev/null +++ b/internal/web_server/handlers/activity_logs.go @@ -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, + }, + }) +} diff --git a/internal/web_server/handlers/admin.go b/internal/web_server/handlers/admin.go index 9133974..e74d39b 100644 --- a/internal/web_server/handlers/admin.go +++ b/internal/web_server/handlers/admin.go @@ -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) } diff --git a/internal/web_server/handlers/course_management.go b/internal/web_server/handlers/course_management.go index e2aa3c8..82ed0a3 100644 --- a/internal/web_server/handlers/course_management.go +++ b/internal/web_server/handlers/course_management.go @@ -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), diff --git a/internal/web_server/handlers/currency.go b/internal/web_server/handlers/currency.go deleted file mode 100644 index 92a5ff4..0000000 --- a/internal/web_server/handlers/currency.go +++ /dev/null @@ -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, -// }) -// } diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index 8b97d29..aff4357 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -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, diff --git a/internal/web_server/handlers/issue_reporting.go b/internal/web_server/handlers/issue_reporting.go new file mode 100644 index 0000000..ea3924b --- /dev/null +++ b/internal/web_server/handlers/issue_reporting.go @@ -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, + }) +} diff --git a/internal/web_server/handlers/models.chapa.go b/internal/web_server/handlers/models.chapa.go deleted file mode 100644 index f829b53..0000000 --- a/internal/web_server/handlers/models.chapa.go +++ /dev/null @@ -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"` -} diff --git a/internal/web_server/handlers/questions.go b/internal/web_server/handlers/questions.go index 446e03f..861a1b8 100644 --- a/internal/web_server/handlers/questions.go +++ b/internal/web_server/handlers/questions.go @@ -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", }) diff --git a/internal/web_server/handlers/settings_handler.go b/internal/web_server/handlers/settings_handler.go index 2effaac..a324f34 100644 --- a/internal/web_server/handlers/settings_handler.go +++ b/internal/web_server/handlers/settings_handler.go @@ -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) } diff --git a/internal/web_server/handlers/subscriptions.go b/internal/web_server/handlers/subscriptions.go index 6d7255b..fff5ddd 100644 --- a/internal/web_server/handlers/subscriptions.go +++ b/internal/web_server/handlers/subscriptions.go @@ -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", }) diff --git a/internal/web_server/handlers/team_handler.go b/internal/web_server/handlers/team_handler.go index dbaccbd..bbaa021 100644 --- a/internal/web_server/handlers/team_handler.go +++ b/internal/web_server/handlers/team_handler.go @@ -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, diff --git a/internal/web_server/handlers/user.go b/internal/web_server/handlers/user.go index 7eaf73a..b479020 100644 --- a/internal/web_server/handlers/user.go +++ b/internal/web_server/handlers/user.go @@ -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) } diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 2dd62ca..1dc2dd4 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -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)