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

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

View File

@ -22,6 +22,8 @@ import (
"Yimaru-Backend/internal/services/settings" "Yimaru-Backend/internal/services/settings"
"Yimaru-Backend/internal/services/subscriptions" "Yimaru-Backend/internal/services/subscriptions"
"Yimaru-Backend/internal/services/team" "Yimaru-Backend/internal/services/team"
activitylogservice "Yimaru-Backend/internal/services/activity_log"
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
vimeoservice "Yimaru-Backend/internal/services/vimeo" vimeoservice "Yimaru-Backend/internal/services/vimeo"
"context" "context"
@ -364,6 +366,15 @@ func main() {
courseSvc.SetVimeoService(vimeoSvc) 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) // Questions service (unified questions system)
questionsSvc := questions.NewService(store) questionsSvc := questions.NewService(store)
@ -386,6 +397,9 @@ func main() {
// santimpaySvc := santimpay.NewSantimPayService(santimpayClient, cfg, transferStore) // santimpaySvc := santimpay.NewSantimPayService(santimpayClient, cfg, transferStore)
// telebirrSvc := telebirr.NewTelebirrService(cfg, transferStore) // telebirrSvc := telebirr.NewTelebirrService(cfg, transferStore)
// Activity Log service
activityLogSvc := activitylogservice.NewService(store, domain.MongoDBLogger)
// Initialize and start HTTP server // Initialize and start HTTP server
app := httpserver.NewApp( app := httpserver.NewApp(
assessmentSvc, assessmentSvc,
@ -396,6 +410,7 @@ func main() {
issueReportingSvc, issueReportingSvc,
vimeoSvc, vimeoSvc,
teamSvc, teamSvc,
activityLogSvc,
cfg.Port, cfg.Port,
v, v,
settingSvc, settingSvc,

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@ -91,6 +91,28 @@ func (q *Queries) DeleteReportedIssue(ctx context.Context, id int64) error {
return err 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 const ListReportedIssues = `-- name: ListReportedIssues :many
SELECT id, user_id, user_role, subject, description, issue_type, status, metadata, created_at, updated_at SELECT id, user_id, user_role, subject, description, issue_type, status, metadata, created_at, updated_at
FROM reported_issues FROM reported_issues

View File

@ -8,6 +8,20 @@ import (
"github.com/jackc/pgx/v5/pgtype" "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 { type Course struct {
ID int64 `json:"id"` ID int64 `json:"id"`
CategoryID int64 `json:"category_id"` CategoryID int64 `json:"category_id"`

3
go.mod
View File

@ -46,11 +46,8 @@ require (
github.com/google/s2a-go v0.1.9 // indirect github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // 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/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/spiffe/go-spiffe/v2 v2.5.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/zeebo/errs v1.4.0 // indirect github.com/zeebo/errs v1.4.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect

28
go.sum
View File

@ -12,12 +12,16 @@ cloud.google.com/go/firestore v1.18.0 h1:cuydCaLS7Vl2SatAeivXyhbhDEIR8BDmtn4egDh
cloud.google.com/go/firestore v1.18.0/go.mod h1:5ye0v48PhseZBdcl0qbl3uttu7FIEwEYVaWm0UIEOEU= cloud.google.com/go/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 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8=
cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= 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 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE=
cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY= 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 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM=
cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U= 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 h1:gg0ERZwL17pJ+Cz3cD2qS60w1WMDnwcm5YPAIQBHUAw=
cloud.google.com/go/storage v1.53.0/go.mod h1:7/eO2a/srr9ImZW9k5uufcNahT2+fPb8w5it1i5boaA= 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 h1:f5NMlC2YHFsncz00c2+ecBr+ZYlRMhKIhj1z8Iz0lD8=
firebase.google.com/go/v4 v4.19.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs= 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= 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/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 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/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 h1:6/0iUd0xrnX7qt+mLNRwg5c0PGv8wpE8K90ryANQwMI=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0= 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= 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.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= 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 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE= 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/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/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.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.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 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 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= 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 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= 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= 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.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 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 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 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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= 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.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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 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/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.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
github.com/otiai10/mint v1.3.3/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= 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 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= 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.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 h1:ttM1/VZR4fApBv3xI1TneSKi1pbfFsVrq7fXFlHKtj4=
github.com/resend/resend-go/v2 v2.28.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= 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 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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= 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.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 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= 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 h1:86FBso7jFqpSZ0XC0GKJcEY2KOeUNOFh6zLhTbUMlnc=
github.com/twilio/twilio-go v1.30.0/go.mod h1:QbitvbvtkV77Jn4BABAKVmxabYSjMyQG4tHey9gfPqg= github.com/twilio/twilio-go v1.30.0/go.mod h1:QbitvbvtkV77Jn4BABAKVmxabYSjMyQG4tHey9gfPqg=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 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/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 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= 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 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= 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-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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/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 h1:2hZKUnFZEy81eugPs4e2XzIJ5SOwQg0G82bpXD65Puo=
google.golang.org/api v0.239.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50= 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 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI= google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=

View File

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

View File

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

View File

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

View File

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

View File

@ -238,6 +238,43 @@ func (c *Client) CreateTusUpload(ctx context.Context, name, description string,
return c.CreateUpload(ctx, req) 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) { func (c *Client) UpdateVideo(ctx context.Context, videoID string, req *UpdateVideoRequest) (*Video, error) {
resp, err := c.doRequest(ctx, http.MethodPatch, "/videos/"+videoID, req) resp, err := c.doRequest(ctx, http.MethodPatch, "/videos/"+videoID, req)
if err != nil { if err != nil {

View File

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

View File

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

View File

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

View File

@ -13,6 +13,7 @@ type ReportedIssueRepository interface {
CountReportedIssuesByUser(ctx context.Context, userID int64) (int64, error) CountReportedIssuesByUser(ctx context.Context, userID int64) (int64, error)
UpdateReportedIssueStatus(ctx context.Context, id int64, status string) error UpdateReportedIssueStatus(ctx context.Context, id int64, status string) error
DeleteReportedIssue(ctx context.Context, id int64) error DeleteReportedIssue(ctx context.Context, id int64) error
GetReportedIssueByID(ctx context.Context, id int64) (dbgen.ReportedIssue, error)
} }
type ReportedIssueRepo struct { 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 { func (s *ReportedIssueRepo) DeleteReportedIssue(ctx context.Context, id int64) error {
return s.store.queries.DeleteReportedIssue(ctx, id) return s.store.queries.DeleteReportedIssue(ctx, id)
} }
func (s *ReportedIssueRepo) GetReportedIssueByID(ctx context.Context, id int64) (dbgen.ReportedIssue, error) {
return s.store.queries.GetReportedIssueByID(ctx, id)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -3,8 +3,13 @@ package course_management
import ( import (
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/pkgs/vimeo" "Yimaru-Backend/internal/pkgs/vimeo"
vimeoservice "Yimaru-Backend/internal/services/vimeo"
"context" "context"
"fmt" "fmt"
"io"
"net/http"
"path"
"time"
) )
func (s *Service) CreateSubCourseVideo( func (s *Service) CreateSubCourseVideo(
@ -49,36 +54,132 @@ func (s *Service) CreateSubCourseVideoWithVimeo(
return domain.SubCourseVideo{}, fmt.Errorf("vimeo service is not configured") return domain.SubCourseVideo{}, fmt.Errorf("vimeo service is not configured")
} }
// Create pull upload to Vimeo
descStr := "" descStr := ""
if description != nil { if description != nil {
descStr = *description 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 { if err != nil {
return domain.SubCourseVideo{}, fmt.Errorf("failed to upload to Vimeo: %w", err) return domain.SubCourseVideo{}, fmt.Errorf("failed to upload to Vimeo: %w", err)
} }
// Generate embed URL
embedURL := vimeo.GenerateEmbedURL(uploadResult.VimeoID, &vimeo.EmbedOptions{ embedURL := vimeo.GenerateEmbedURL(uploadResult.VimeoID, &vimeo.EmbedOptions{
Title: true, Title: true,
Byline: true, Byline: true,
Portrait: true, Portrait: true,
}) })
// Generate embed HTML
embedHTML := vimeo.GenerateIframeEmbed(uploadResult.VimeoID, 640, 360, nil) embedHTML := vimeo.GenerateIframeEmbed(uploadResult.VimeoID, 640, 360, nil)
// Set values for Vimeo fields
provider := string(domain.VideoHostProviderVimeo) provider := string(domain.VideoHostProviderVimeo)
vimeoStatus := "uploading" vimeoStatus := "uploading"
status := "DRAFT" status := "DRAFT"
// Create the video record with Vimeo info
return s.courseStore.CreateSubCourseVideo( return s.courseStore.CreateSubCourseVideo(
ctx, subCourseID, title, description, 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, duration, resolution, instructorID, thumbnail, visibility, displayOrder, &status,
&uploadResult.VimeoID, &embedURL, &embedHTML, &vimeoStatus, &provider, &uploadResult.VimeoID, &embedURL, &embedHTML, &vimeoStatus, &provider,
) )

View File

@ -2,6 +2,7 @@ package issuereporting
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
dbgen "Yimaru-Backend/gen/db" 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) { func (s *Service) CreateReportedIssue(ctx context.Context, issue domain.ReportedIssueReq, userID int64, role domain.Role) (domain.ReportedIssue, error) {
var metadata []byte
// metadata, err := json.Marshal(issue.Metadata) if issue.Metadata != nil {
// if err != nil { var err error
// return domain.ReportedIssue{}, err metadata, err = json.Marshal(issue.Metadata)
// } if err != nil {
return domain.ReportedIssue{}, err
}
}
params := dbgen.CreateReportedIssueParams{ params := dbgen.CreateReportedIssueParams{
UserID: userID, UserID: userID,
UserRole: string(role), UserRole: string(role),
Subject: issue.Subject, Subject: issue.Subject,
Description: issue.Description, Description: issue.Description,
IssueType: string(issue.IssueType), IssueType: string(issue.IssueType),
// Metadata: metadata, Metadata: metadata,
} }
dbIssue, err := s.repo.CreateReportedIssue(ctx, params) dbIssue, err := s.repo.CreateReportedIssue(ctx, params)
if err != nil { if err != nil {
return domain.ReportedIssue{}, err return domain.ReportedIssue{}, err
} }
// Map dbgen.ReportedIssue to domain.ReportedIssue return mapDBIssueToDomain(dbIssue), nil
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
} }
func (s *Service) GetIssuesForUser(ctx context.Context, userID int64, limit, offset int) ([]domain.ReportedIssue, error) { 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 { if err != nil {
return nil, err return nil, err
} }
reportedIssues := make([]domain.ReportedIssue, len(dbIssues)) return mapDBIssuesToDomain(dbIssues), nil
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
} }
func (s *Service) GetAllIssues(ctx context.Context, limit, offset int) ([]dbgen.ReportedIssue, error) { func (s *Service) GetAllIssues(ctx context.Context, limit, offset int) ([]domain.ReportedIssue, error) {
return s.repo.ListReportedIssues(ctx, int32(limit), int32(offset)) 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 { 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) 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 { func (s *Service) DeleteIssue(ctx context.Context, issueID int64) error {
return s.repo.DeleteReportedIssue(ctx, issueID) return s.repo.DeleteReportedIssue(ctx, issueID)
} }
func mapDBIssueToDomain(dbIssue dbgen.ReportedIssue) domain.ReportedIssue {
issue := domain.ReportedIssue{
ID: dbIssue.ID,
Subject: dbIssue.Subject,
Description: dbIssue.Description,
UserID: dbIssue.UserID,
UserRole: domain.Role(dbIssue.UserRole),
Status: domain.ReportedIssueStatus(dbIssue.Status),
IssueType: domain.ReportedIssueType(dbIssue.IssueType),
CreatedAt: dbIssue.CreatedAt.Time,
UpdatedAt: dbIssue.UpdatedAt.Time,
}
if len(dbIssue.Metadata) > 0 {
_ = json.Unmarshal(dbIssue.Metadata, &issue.Metadata)
}
return issue
}
func mapDBIssuesToDomain(dbIssues []dbgen.ReportedIssue) []domain.ReportedIssue {
issues := make([]domain.ReportedIssue, len(dbIssues))
for i, dbIssue := range dbIssues {
issues[i] = mapDBIssueToDomain(dbIssue)
}
return issues
}

View File

@ -4,6 +4,7 @@ import (
"Yimaru-Backend/internal/pkgs/vimeo" "Yimaru-Backend/internal/pkgs/vimeo"
"context" "context"
"fmt" "fmt"
"io"
"go.uber.org/zap" "go.uber.org/zap"
) )
@ -120,6 +121,29 @@ func (s *Service) CreateTusUpload(ctx context.Context, name, description string,
}, nil }, 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) { func (s *Service) UpdateVideoMetadata(ctx context.Context, videoID string, name, description *string) (*VideoInfo, error) {
req := &vimeo.UpdateVideoRequest{ req := &vimeo.UpdateVideoRequest{
Name: name, Name: name,

View File

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

View File

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

View File

@ -4,6 +4,8 @@ import (
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/services/authentication" "Yimaru-Backend/internal/services/authentication"
"Yimaru-Backend/internal/web_server/response" "Yimaru-Backend/internal/web_server/response"
"context"
"encoding/json"
"fmt" "fmt"
"strconv" "strconv"
"time" "time"
@ -127,6 +129,13 @@ func (h *Handler) CreateAdmin(c *fiber.Ctx) error {
zap.Time("timestamp", time.Now()), 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) 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()) 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) return response.WriteJSON(c, fiber.StatusOK, "Admin updated successfully", nil, nil)
} }

View File

@ -2,6 +2,8 @@ package handlers
import ( import (
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
"context"
"encoding/json"
"fmt" "fmt"
"strconv" "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{ return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Course category created successfully", Message: "Course category created successfully",
Data: courseCategoryRes{ 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{ return c.JSON(domain.Response{
Message: "Course category updated successfully", 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{ return c.JSON(domain.Response{
Message: "Course category deleted successfully", 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{ return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Course created successfully", Message: "Course created successfully",
Data: courseRes{ 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{ return c.JSON(domain.Response{
Message: "Course updated successfully", 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{ return c.JSON(domain.Response{
Message: "Course deleted successfully", 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{ return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Sub-course created successfully", Message: "Sub-course created successfully",
Data: subCourseRes{ 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{ return c.JSON(domain.Response{
Message: "Sub-course updated successfully", 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{ return c.JSON(domain.Response{
Message: "Sub-course deactivated successfully", 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{ return c.JSON(domain.Response{
Message: "Sub-course deleted successfully", 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 var publishDate *string
if video.PublishDate != nil { if video.PublishDate != nil {
pd := video.PublishDate.String() 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{ return c.JSON(domain.Response{
Message: "Video published successfully", 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{ return c.JSON(domain.Response{
Message: "Video updated successfully", 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{ return c.JSON(domain.Response{
Message: "Video deleted successfully", 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 // CreateSubCourseVideoWithVimeo godoc
// @Summary Create a new sub-course video with Vimeo upload // @Summary Create a new sub-course video with Vimeo upload
// @Description Creates a video by uploading to Vimeo from a source URL // @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{ return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Video created and uploaded to Vimeo successfully", Message: "Video created and uploaded to Vimeo successfully",
Data: mapVideoToResponse(video), 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{ return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Video imported from Vimeo successfully", Message: "Video imported from Vimeo successfully",
Data: mapVideoToResponse(video), Data: mapVideoToResponse(video),

View File

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

View File

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

View File

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

View File

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

View File

@ -2,6 +2,9 @@ package handlers
import ( import (
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
"context"
"encoding/json"
"fmt"
"strconv" "strconv"
"github.com/gofiber/fiber/v2" "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{ return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Question created successfully", Message: "Question created successfully",
Data: questionRes{ 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{ return c.JSON(domain.Response{
Message: "Question updated successfully", 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{ return c.JSON(domain.Response{
Message: "Question deleted successfully", 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{ return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Question set created successfully", Message: "Question set created successfully",
Data: questionSetRes{ 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{ return c.JSON(domain.Response{
Message: "Question set updated successfully", 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{ return c.JSON(domain.Response{
Message: "Question set deleted successfully", Message: "Question set deleted successfully",
}) })

View File

@ -3,6 +3,8 @@ package handlers
import ( import (
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/web_server/response" "Yimaru-Backend/internal/web_server/response"
"context"
"encoding/json"
"fmt" "fmt"
"time" "time"
@ -105,6 +107,14 @@ func (h *Handler) UpdateGlobalSettingList(c *fiber.Ctx) error {
res := domain.ConvertSettingListRes(settingsList) 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) return response.WriteJSON(c, fiber.StatusOK, "setting updated", res, nil)
} }

View File

@ -2,6 +2,9 @@ package handlers
import ( import (
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
"context"
"encoding/json"
"fmt"
"strconv" "strconv"
"github.com/gofiber/fiber/v2" "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{ return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Subscription plan created successfully", Message: "Subscription plan created successfully",
Data: planToRes(plan), 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{ return c.JSON(domain.Response{
Message: "Subscription plan updated successfully", 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{ return c.JSON(domain.Response{
Message: "Subscription plan deleted successfully", Message: "Subscription plan deleted successfully",
}) })

View File

@ -3,6 +3,8 @@ package handlers
import ( import (
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
jwtutil "Yimaru-Backend/internal/web_server/jwt" jwtutil "Yimaru-Backend/internal/web_server/jwt"
"context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"strconv" "strconv"
@ -237,6 +239,12 @@ func (h *Handler) CreateTeamMember(c *fiber.Ctx) error {
zap.Time("timestamp", time.Now()), 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{ return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Team member created successfully", Message: "Team member created successfully",
Data: toTeamMemberResponse(&member), Data: toTeamMemberResponse(&member),
@ -464,6 +472,12 @@ func (h *Handler) UpdateTeamMember(c *fiber.Ctx) error {
zap.Time("timestamp", time.Now()), 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{ return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Team member updated successfully", Message: "Team member updated successfully",
Success: true, Success: true,
@ -560,6 +574,12 @@ func (h *Handler) UpdateTeamMemberStatus(c *fiber.Ctx) error {
zap.Time("timestamp", time.Now()), 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{ return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Team member status updated successfully", Message: "Team member status updated successfully",
Success: true, Success: true,
@ -616,6 +636,13 @@ func (h *Handler) DeleteTeamMember(c *fiber.Ctx) error {
zap.Time("timestamp", time.Now()), 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{ return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Team member deleted successfully", Message: "Team member deleted successfully",
Success: true, Success: true,

View File

@ -5,6 +5,8 @@ import (
"Yimaru-Backend/internal/services/authentication" "Yimaru-Backend/internal/services/authentication"
jwtutil "Yimaru-Backend/internal/web_server/jwt" jwtutil "Yimaru-Backend/internal/web_server/jwt"
"Yimaru-Backend/internal/web_server/response" "Yimaru-Backend/internal/web_server/response"
"context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "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{ return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "User updated successfully", 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()) 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) return response.WriteJSON(c, fiber.StatusOK, "User deleted successfully", nil, nil)
} }

View File

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