diff --git a/cmd/main.go b/cmd/main.go index ed558a4..c3150ed 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -25,6 +25,7 @@ import ( "Yimaru-Backend/internal/services/team" activitylogservice "Yimaru-Backend/internal/services/activity_log" cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" + ratingsservice "Yimaru-Backend/internal/services/ratings" vimeoservice "Yimaru-Backend/internal/services/vimeo" "context" @@ -369,8 +370,9 @@ func main() { } // CloudConvert service for video compression + var ccSvc *cloudconvertservice.Service if cfg.CloudConvert.Enabled && cfg.CloudConvert.APIKey != "" { - ccSvc := cloudconvertservice.NewService(cfg.CloudConvert.APIKey, domain.MongoDBLogger) + ccSvc = cloudconvertservice.NewService(cfg.CloudConvert.APIKey, domain.MongoDBLogger) courseSvc.SetCloudConvertService(ccSvc) logger.Info("CloudConvert service initialized") } else { @@ -402,6 +404,9 @@ func main() { // Activity Log service activityLogSvc := activitylogservice.NewService(store, domain.MongoDBLogger) + // Ratings service + ratingSvc := ratingsservice.NewService(repository.NewRatingStore(store)) + // Initialize and start HTTP server app := httpserver.NewApp( assessmentSvc, @@ -413,6 +418,8 @@ func main() { vimeoSvc, teamSvc, activityLogSvc, + ccSvc, + ratingSvc, cfg.Port, v, settingSvc, diff --git a/db/migrations/000017_ratings.down.sql b/db/migrations/000017_ratings.down.sql new file mode 100644 index 0000000..557156c --- /dev/null +++ b/db/migrations/000017_ratings.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS ratings; diff --git a/db/migrations/000017_ratings.up.sql b/db/migrations/000017_ratings.up.sql new file mode 100644 index 0000000..9441493 --- /dev/null +++ b/db/migrations/000017_ratings.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS ratings ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + target_type VARCHAR(20) NOT NULL, -- 'app', 'course', 'sub_course' + target_id BIGINT NOT NULL DEFAULT 0, -- 0 for app rating, course_id or sub_course_id otherwise + stars SMALLINT NOT NULL CHECK (stars >= 1 AND stars <= 5), + review TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (user_id, target_type, target_id) +); + +CREATE INDEX idx_ratings_target ON ratings (target_type, target_id); +CREATE INDEX idx_ratings_user ON ratings (user_id); diff --git a/db/query/ratings.sql b/db/query/ratings.sql new file mode 100644 index 0000000..b585fb4 --- /dev/null +++ b/db/query/ratings.sql @@ -0,0 +1,47 @@ +-- name: UpsertRating :one +INSERT INTO ratings (user_id, target_type, target_id, stars, review) +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (user_id, target_type, target_id) +DO UPDATE SET stars = EXCLUDED.stars, review = EXCLUDED.review, updated_at = NOW() +RETURNING *; + + +-- name: GetRatingByUserAndTarget :one +SELECT * +FROM ratings +WHERE user_id = $1 AND target_type = $2 AND target_id = $3; + + +-- name: GetRatingsByTarget :many +SELECT * +FROM ratings +WHERE target_type = $1 AND target_id = $2 +ORDER BY created_at DESC +LIMIT sqlc.narg('limit')::INT +OFFSET sqlc.narg('offset')::INT; + + +-- name: GetRatingSummary :one +SELECT + COUNT(*)::BIGINT AS total_count, + COALESCE(AVG(stars), 0)::FLOAT AS average_stars +FROM ratings +WHERE target_type = $1 AND target_id = $2; + + +-- name: GetUserRatings :many +SELECT * +FROM ratings +WHERE user_id = $1 +ORDER BY updated_at DESC; + + +-- name: DeleteRating :exec +DELETE FROM ratings +WHERE id = $1 AND user_id = $2; + + +-- name: CountRatingsByTarget :one +SELECT COUNT(*)::BIGINT +FROM ratings +WHERE target_type = $1 AND target_id = $2; diff --git a/docs/docs.go b/docs/docs.go index 27f244e..8905391 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -24,6 +24,131 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/api/v1/activity-logs": { + "get": { + "description": "Returns a filtered, paginated list of activity logs", + "produces": [ + "application/json" + ], + "tags": [ + "activity-logs" + ], + "summary": "Get activity logs", + "parameters": [ + { + "type": "integer", + "description": "Filter by actor ID", + "name": "actor_id", + "in": "query" + }, + { + "type": "string", + "description": "Filter by action", + "name": "action", + "in": "query" + }, + { + "type": "string", + "description": "Filter by resource type", + "name": "resource_type", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by resource ID", + "name": "resource_id", + "in": "query" + }, + { + "type": "string", + "description": "Filter logs after this RFC3339 timestamp", + "name": "after", + "in": "query" + }, + { + "type": "string", + "description": "Filter logs before this RFC3339 timestamp", + "name": "before", + "in": "query" + }, + { + "type": "integer", + "default": 20, + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/activity-logs/{id}": { + "get": { + "description": "Returns a single activity log entry by its ID", + "produces": [ + "application/json" + ], + "tags": [ + "activity-logs" + ], + "summary": "Get activity log by ID", + "parameters": [ + { + "type": "integer", + "description": "Activity Log ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/admin": { "get": { "description": "Get all Admins", @@ -1040,6 +1165,57 @@ const docTemplate = `{ } } }, + "/api/v1/course-management/courses/{id}/thumbnail": { + "post": { + "description": "Uploads and optimizes a thumbnail image, then updates the course", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "courses" + ], + "summary": "Upload a thumbnail image for a course", + "parameters": [ + { + "type": "integer", + "description": "Course ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "file", + "description": "Thumbnail image file (jpg, png, webp)", + "name": "file", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/course-management/learning-tree": { "get": { "description": "Returns the complete learning tree structure with courses and sub-courses", @@ -1316,6 +1492,57 @@ const docTemplate = `{ } } }, + "/api/v1/course-management/sub-courses/{id}/thumbnail": { + "post": { + "description": "Uploads and optimizes a thumbnail image, then updates the sub-course", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "sub-courses" + ], + "summary": "Upload a thumbnail image for a sub-course", + "parameters": [ + { + "type": "integer", + "description": "Sub-course ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "file", + "description": "Thumbnail image file (jpg, png, webp)", + "name": "file", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/course-management/sub-courses/{subCourseId}/videos": { "get": { "description": "Returns all videos under a specific sub-course", @@ -1444,6 +1671,106 @@ const docTemplate = `{ } } }, + "/api/v1/course-management/videos/upload": { + "post": { + "description": "Accepts a video file upload, uploads it to Vimeo via TUS, and creates a sub-course video record", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "sub-course-videos" + ], + "summary": "Upload a video file and create sub-course video", + "parameters": [ + { + "type": "file", + "description": "Video file", + "name": "file", + "in": "formData", + "required": true + }, + { + "type": "integer", + "description": "Sub-course ID", + "name": "sub_course_id", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Video title", + "name": "title", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Video description", + "name": "description", + "in": "formData" + }, + { + "type": "integer", + "description": "Duration in seconds", + "name": "duration", + "in": "formData" + }, + { + "type": "string", + "description": "Video resolution", + "name": "resolution", + "in": "formData" + }, + { + "type": "string", + "description": "Instructor ID", + "name": "instructor_id", + "in": "formData" + }, + { + "type": "string", + "description": "Thumbnail URL", + "name": "thumbnail", + "in": "formData" + }, + { + "type": "string", + "description": "Visibility", + "name": "visibility", + "in": "formData" + }, + { + "type": "integer", + "description": "Display order", + "name": "display_order", + "in": "formData" + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/course-management/videos/vimeo": { "post": { "description": "Creates a video by uploading to Vimeo from a source URL", @@ -1708,6 +2035,423 @@ const docTemplate = `{ } } }, + "/api/v1/issues": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Returns all reported issues with pagination (admin only)", + "produces": [ + "application/json" + ], + "tags": [ + "issues" + ], + "summary": "Get all issues", + "parameters": [ + { + "type": "integer", + "default": 20, + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/handlers.issueListRes" + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Allows any authenticated user to report an issue they encountered", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "issues" + ], + "summary": "Report an issue", + "parameters": [ + { + "description": "Issue report payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createIssueReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/handlers.issueRes" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/issues/me": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Returns paginated issues reported by the authenticated user", + "produces": [ + "application/json" + ], + "tags": [ + "issues" + ], + "summary": "Get my reported issues", + "parameters": [ + { + "type": "integer", + "default": 20, + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/handlers.issueListRes" + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/issues/user/{user_id}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Returns paginated issues reported by a specific user (admin only)", + "produces": [ + "application/json" + ], + "tags": [ + "issues" + ], + "summary": "Get issues for a specific user", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "user_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "default": 20, + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/handlers.issueListRes" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/issues/{id}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Returns a single issue report by its ID (admin only)", + "produces": [ + "application/json" + ], + "tags": [ + "issues" + ], + "summary": "Get issue by ID", + "parameters": [ + { + "type": "integer", + "description": "Issue ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/handlers.issueRes" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Deletes an issue report (admin only)", + "produces": [ + "application/json" + ], + "tags": [ + "issues" + ], + "summary": "Delete an issue", + "parameters": [ + { + "type": "integer", + "description": "Issue ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/issues/{id}/status": { + "patch": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Updates the status of an issue (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "issues" + ], + "summary": "Update issue status", + "parameters": [ + { + "type": "integer", + "description": "Issue ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Status update payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateIssueStatusReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/logs": { "get": { "description": "Fetches application logs from MongoDB with pagination, level filtering, and search", @@ -3107,6 +3851,279 @@ const docTemplate = `{ } } }, + "/api/v1/ratings": { + "get": { + "description": "Returns paginated ratings for a specific target", + "produces": [ + "application/json" + ], + "tags": [ + "ratings" + ], + "summary": "Get ratings for a target", + "parameters": [ + { + "type": "string", + "description": "Target type (app, course, sub_course)", + "name": "target_type", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "Target ID (0 for app)", + "name": "target_id", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "Limit (default 20)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset (default 0)", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "post": { + "description": "Submit a rating for an app, course, or sub-course", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ratings" + ], + "summary": "Submit a rating", + "parameters": [ + { + "description": "Submit rating payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.submitRatingReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/ratings/me": { + "get": { + "description": "Returns the current user's rating for a specific target", + "produces": [ + "application/json" + ], + "tags": [ + "ratings" + ], + "summary": "Get my rating for a target", + "parameters": [ + { + "type": "string", + "description": "Target type (app, course, sub_course)", + "name": "target_type", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "Target ID (0 for app)", + "name": "target_id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/ratings/me/all": { + "get": { + "description": "Returns all ratings submitted by the current user", + "produces": [ + "application/json" + ], + "tags": [ + "ratings" + ], + "summary": "Get all my ratings", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/ratings/summary": { + "get": { + "description": "Returns the total count and average stars for a specific target", + "produces": [ + "application/json" + ], + "tags": [ + "ratings" + ], + "summary": "Get rating summary for a target", + "parameters": [ + { + "type": "string", + "description": "Target type (app, course, sub_course)", + "name": "target_type", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "Target ID (0 for app)", + "name": "target_id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/ratings/{id}": { + "delete": { + "description": "Deletes a rating by ID for the current user", + "produces": [ + "application/json" + ], + "tags": [ + "ratings" + ], + "summary": "Delete a rating", + "parameters": [ + { + "type": "integer", + "description": "Rating ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/sendSMS": { "post": { "description": "Sends an SMS message to a single phone number using AfroMessage", @@ -6331,11 +7348,11 @@ const docTemplate = `{ "domain.Role": { "type": "string", "enum": [ - "super_admin", - "admin", - "student", - "instructor", - "support" + "SUPER_ADMIN", + "ADMIN", + "STUDENT", + "INSTRUCTOR", + "SUPPORT" ], "x-enum-varnames": [ "RoleSuperAdmin", @@ -6998,7 +8015,30 @@ const docTemplate = `{ } }, "handlers.ResetPasswordReq": { - "type": "object" + "type": "object", + "required": [ + "otp", + "password" + ], + "properties": { + "email": { + "type": "string", + "example": "john.doe@example.com" + }, + "otp": { + "type": "string", + "example": "123456" + }, + "password": { + "type": "string", + "minLength": 8, + "example": "newpassword123" + }, + "phone_number": { + "type": "string", + "example": "1234567890" + } + } }, "handlers.SearchUserByNameOrPhoneReq": { "type": "object", @@ -7192,6 +8232,29 @@ const docTemplate = `{ } } }, + "handlers.createIssueReq": { + "type": "object", + "required": [ + "description", + "issue_type", + "subject" + ], + "properties": { + "description": { + "type": "string" + }, + "issue_type": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": true + }, + "subject": { + "type": "string" + } + } + }, "handlers.createPlanReq": { "type": "object", "required": [ @@ -7526,6 +8589,56 @@ const docTemplate = `{ } } }, + "handlers.issueListRes": { + "type": "object", + "properties": { + "issues": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.issueRes" + } + }, + "total_count": { + "type": "integer" + } + } + }, + "handlers.issueRes": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "issue_type": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": true + }, + "status": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "integer" + }, + "user_role": { + "type": "string" + } + } + }, "handlers.loginUserRes": { "type": "object", "properties": { @@ -7607,6 +8720,29 @@ const docTemplate = `{ } } }, + "handlers.submitRatingReq": { + "type": "object", + "required": [ + "stars", + "target_type" + ], + "properties": { + "review": { + "type": "string" + }, + "stars": { + "type": "integer", + "maximum": 5, + "minimum": 1 + }, + "target_id": { + "type": "integer" + }, + "target_type": { + "type": "string" + } + } + }, "handlers.subscribeReq": { "type": "object", "required": [ @@ -7709,6 +8845,23 @@ const docTemplate = `{ } } }, + "handlers.updateIssueStatusReq": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "pending", + "in_progress", + "resolved", + "rejected" + ] + } + } + }, "handlers.updatePlanReq": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index a06f838..854c8a7 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -16,6 +16,131 @@ "version": "1.0.1" }, "paths": { + "/api/v1/activity-logs": { + "get": { + "description": "Returns a filtered, paginated list of activity logs", + "produces": [ + "application/json" + ], + "tags": [ + "activity-logs" + ], + "summary": "Get activity logs", + "parameters": [ + { + "type": "integer", + "description": "Filter by actor ID", + "name": "actor_id", + "in": "query" + }, + { + "type": "string", + "description": "Filter by action", + "name": "action", + "in": "query" + }, + { + "type": "string", + "description": "Filter by resource type", + "name": "resource_type", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by resource ID", + "name": "resource_id", + "in": "query" + }, + { + "type": "string", + "description": "Filter logs after this RFC3339 timestamp", + "name": "after", + "in": "query" + }, + { + "type": "string", + "description": "Filter logs before this RFC3339 timestamp", + "name": "before", + "in": "query" + }, + { + "type": "integer", + "default": 20, + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/activity-logs/{id}": { + "get": { + "description": "Returns a single activity log entry by its ID", + "produces": [ + "application/json" + ], + "tags": [ + "activity-logs" + ], + "summary": "Get activity log by ID", + "parameters": [ + { + "type": "integer", + "description": "Activity Log ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/admin": { "get": { "description": "Get all Admins", @@ -1032,6 +1157,57 @@ } } }, + "/api/v1/course-management/courses/{id}/thumbnail": { + "post": { + "description": "Uploads and optimizes a thumbnail image, then updates the course", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "courses" + ], + "summary": "Upload a thumbnail image for a course", + "parameters": [ + { + "type": "integer", + "description": "Course ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "file", + "description": "Thumbnail image file (jpg, png, webp)", + "name": "file", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/course-management/learning-tree": { "get": { "description": "Returns the complete learning tree structure with courses and sub-courses", @@ -1308,6 +1484,57 @@ } } }, + "/api/v1/course-management/sub-courses/{id}/thumbnail": { + "post": { + "description": "Uploads and optimizes a thumbnail image, then updates the sub-course", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "sub-courses" + ], + "summary": "Upload a thumbnail image for a sub-course", + "parameters": [ + { + "type": "integer", + "description": "Sub-course ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "file", + "description": "Thumbnail image file (jpg, png, webp)", + "name": "file", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/course-management/sub-courses/{subCourseId}/videos": { "get": { "description": "Returns all videos under a specific sub-course", @@ -1436,6 +1663,106 @@ } } }, + "/api/v1/course-management/videos/upload": { + "post": { + "description": "Accepts a video file upload, uploads it to Vimeo via TUS, and creates a sub-course video record", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "sub-course-videos" + ], + "summary": "Upload a video file and create sub-course video", + "parameters": [ + { + "type": "file", + "description": "Video file", + "name": "file", + "in": "formData", + "required": true + }, + { + "type": "integer", + "description": "Sub-course ID", + "name": "sub_course_id", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Video title", + "name": "title", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Video description", + "name": "description", + "in": "formData" + }, + { + "type": "integer", + "description": "Duration in seconds", + "name": "duration", + "in": "formData" + }, + { + "type": "string", + "description": "Video resolution", + "name": "resolution", + "in": "formData" + }, + { + "type": "string", + "description": "Instructor ID", + "name": "instructor_id", + "in": "formData" + }, + { + "type": "string", + "description": "Thumbnail URL", + "name": "thumbnail", + "in": "formData" + }, + { + "type": "string", + "description": "Visibility", + "name": "visibility", + "in": "formData" + }, + { + "type": "integer", + "description": "Display order", + "name": "display_order", + "in": "formData" + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/course-management/videos/vimeo": { "post": { "description": "Creates a video by uploading to Vimeo from a source URL", @@ -1700,6 +2027,423 @@ } } }, + "/api/v1/issues": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Returns all reported issues with pagination (admin only)", + "produces": [ + "application/json" + ], + "tags": [ + "issues" + ], + "summary": "Get all issues", + "parameters": [ + { + "type": "integer", + "default": 20, + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/handlers.issueListRes" + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Allows any authenticated user to report an issue they encountered", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "issues" + ], + "summary": "Report an issue", + "parameters": [ + { + "description": "Issue report payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createIssueReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/handlers.issueRes" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/issues/me": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Returns paginated issues reported by the authenticated user", + "produces": [ + "application/json" + ], + "tags": [ + "issues" + ], + "summary": "Get my reported issues", + "parameters": [ + { + "type": "integer", + "default": 20, + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/handlers.issueListRes" + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/issues/user/{user_id}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Returns paginated issues reported by a specific user (admin only)", + "produces": [ + "application/json" + ], + "tags": [ + "issues" + ], + "summary": "Get issues for a specific user", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "user_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "default": 20, + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/handlers.issueListRes" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/issues/{id}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Returns a single issue report by its ID (admin only)", + "produces": [ + "application/json" + ], + "tags": [ + "issues" + ], + "summary": "Get issue by ID", + "parameters": [ + { + "type": "integer", + "description": "Issue ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/handlers.issueRes" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Deletes an issue report (admin only)", + "produces": [ + "application/json" + ], + "tags": [ + "issues" + ], + "summary": "Delete an issue", + "parameters": [ + { + "type": "integer", + "description": "Issue ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/issues/{id}/status": { + "patch": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Updates the status of an issue (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "issues" + ], + "summary": "Update issue status", + "parameters": [ + { + "type": "integer", + "description": "Issue ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Status update payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateIssueStatusReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/logs": { "get": { "description": "Fetches application logs from MongoDB with pagination, level filtering, and search", @@ -3099,6 +3843,279 @@ } } }, + "/api/v1/ratings": { + "get": { + "description": "Returns paginated ratings for a specific target", + "produces": [ + "application/json" + ], + "tags": [ + "ratings" + ], + "summary": "Get ratings for a target", + "parameters": [ + { + "type": "string", + "description": "Target type (app, course, sub_course)", + "name": "target_type", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "Target ID (0 for app)", + "name": "target_id", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "Limit (default 20)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset (default 0)", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "post": { + "description": "Submit a rating for an app, course, or sub-course", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ratings" + ], + "summary": "Submit a rating", + "parameters": [ + { + "description": "Submit rating payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.submitRatingReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/ratings/me": { + "get": { + "description": "Returns the current user's rating for a specific target", + "produces": [ + "application/json" + ], + "tags": [ + "ratings" + ], + "summary": "Get my rating for a target", + "parameters": [ + { + "type": "string", + "description": "Target type (app, course, sub_course)", + "name": "target_type", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "Target ID (0 for app)", + "name": "target_id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/ratings/me/all": { + "get": { + "description": "Returns all ratings submitted by the current user", + "produces": [ + "application/json" + ], + "tags": [ + "ratings" + ], + "summary": "Get all my ratings", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/ratings/summary": { + "get": { + "description": "Returns the total count and average stars for a specific target", + "produces": [ + "application/json" + ], + "tags": [ + "ratings" + ], + "summary": "Get rating summary for a target", + "parameters": [ + { + "type": "string", + "description": "Target type (app, course, sub_course)", + "name": "target_type", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "Target ID (0 for app)", + "name": "target_id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/ratings/{id}": { + "delete": { + "description": "Deletes a rating by ID for the current user", + "produces": [ + "application/json" + ], + "tags": [ + "ratings" + ], + "summary": "Delete a rating", + "parameters": [ + { + "type": "integer", + "description": "Rating ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/sendSMS": { "post": { "description": "Sends an SMS message to a single phone number using AfroMessage", @@ -6323,11 +7340,11 @@ "domain.Role": { "type": "string", "enum": [ - "super_admin", - "admin", - "student", - "instructor", - "support" + "SUPER_ADMIN", + "ADMIN", + "STUDENT", + "INSTRUCTOR", + "SUPPORT" ], "x-enum-varnames": [ "RoleSuperAdmin", @@ -6990,7 +8007,30 @@ } }, "handlers.ResetPasswordReq": { - "type": "object" + "type": "object", + "required": [ + "otp", + "password" + ], + "properties": { + "email": { + "type": "string", + "example": "john.doe@example.com" + }, + "otp": { + "type": "string", + "example": "123456" + }, + "password": { + "type": "string", + "minLength": 8, + "example": "newpassword123" + }, + "phone_number": { + "type": "string", + "example": "1234567890" + } + } }, "handlers.SearchUserByNameOrPhoneReq": { "type": "object", @@ -7184,6 +8224,29 @@ } } }, + "handlers.createIssueReq": { + "type": "object", + "required": [ + "description", + "issue_type", + "subject" + ], + "properties": { + "description": { + "type": "string" + }, + "issue_type": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": true + }, + "subject": { + "type": "string" + } + } + }, "handlers.createPlanReq": { "type": "object", "required": [ @@ -7518,6 +8581,56 @@ } } }, + "handlers.issueListRes": { + "type": "object", + "properties": { + "issues": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.issueRes" + } + }, + "total_count": { + "type": "integer" + } + } + }, + "handlers.issueRes": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "issue_type": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": true + }, + "status": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "integer" + }, + "user_role": { + "type": "string" + } + } + }, "handlers.loginUserRes": { "type": "object", "properties": { @@ -7599,6 +8712,29 @@ } } }, + "handlers.submitRatingReq": { + "type": "object", + "required": [ + "stars", + "target_type" + ], + "properties": { + "review": { + "type": "string" + }, + "stars": { + "type": "integer", + "maximum": 5, + "minimum": 1 + }, + "target_id": { + "type": "integer" + }, + "target_type": { + "type": "string" + } + } + }, "handlers.subscribeReq": { "type": "object", "required": [ @@ -7701,6 +8837,23 @@ } } }, + "handlers.updateIssueStatusReq": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "pending", + "in_progress", + "resolved", + "rejected" + ] + } + } + }, "handlers.updatePlanReq": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 9891633..d8ea26c 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -243,11 +243,11 @@ definitions: type: object domain.Role: enum: - - super_admin - - admin - - student - - instructor - - support + - SUPER_ADMIN + - ADMIN + - STUDENT + - INSTRUCTOR + - SUPPORT type: string x-enum-varnames: - RoleSuperAdmin @@ -699,6 +699,23 @@ definitions: type: string type: object handlers.ResetPasswordReq: + properties: + email: + example: john.doe@example.com + type: string + otp: + example: "123456" + type: string + password: + example: newpassword123 + minLength: 8 + type: string + phone_number: + example: "1234567890" + type: string + required: + - otp + - password type: object handlers.SearchUserByNameOrPhoneReq: properties: @@ -827,6 +844,22 @@ definitions: - category_id - title type: object + handlers.createIssueReq: + properties: + description: + type: string + issue_type: + type: string + metadata: + additionalProperties: true + type: object + subject: + type: string + required: + - description + - issue_type + - subject + type: object handlers.createPlanReq: properties: currency: @@ -1058,6 +1091,39 @@ definitions: - phone - plan_id type: object + handlers.issueListRes: + properties: + issues: + items: + $ref: '#/definitions/handlers.issueRes' + type: array + total_count: + type: integer + type: object + handlers.issueRes: + properties: + created_at: + type: string + description: + type: string + id: + type: integer + issue_type: + type: string + metadata: + additionalProperties: true + type: object + status: + type: string + subject: + type: string + updated_at: + type: string + user_id: + type: integer + user_role: + type: string + type: object handlers.loginUserRes: properties: access_token: @@ -1112,6 +1178,22 @@ definitions: required: - acceptable_answer type: object + handlers.submitRatingReq: + properties: + review: + type: string + stars: + maximum: 5 + minimum: 1 + type: integer + target_id: + type: integer + target_type: + type: string + required: + - stars + - target_type + type: object handlers.subscribeReq: properties: payment_method: @@ -1181,6 +1263,18 @@ definitions: title: type: string type: object + handlers.updateIssueStatusReq: + properties: + status: + enum: + - pending + - in_progress + - resolved + - rejected + type: string + required: + - status + type: object handlers.updatePlanReq: properties: currency: @@ -1713,6 +1807,89 @@ paths: summary: Get user profile tags: - user + /api/v1/activity-logs: + get: + description: Returns a filtered, paginated list of activity logs + parameters: + - description: Filter by actor ID + in: query + name: actor_id + type: integer + - description: Filter by action + in: query + name: action + type: string + - description: Filter by resource type + in: query + name: resource_type + type: string + - description: Filter by resource ID + in: query + name: resource_id + type: integer + - description: Filter logs after this RFC3339 timestamp + in: query + name: after + type: string + - description: Filter logs before this RFC3339 timestamp + in: query + name: before + type: string + - default: 20 + description: Limit + in: query + name: limit + type: integer + - default: 0 + description: Offset + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get activity logs + tags: + - activity-logs + /api/v1/activity-logs/{id}: + get: + description: Returns a single activity log entry by its ID + parameters: + - description: Activity Log ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get activity log by ID + tags: + - activity-logs /api/v1/admin: get: consumes: @@ -2385,6 +2562,40 @@ paths: summary: Update course tags: - courses + /api/v1/course-management/courses/{id}/thumbnail: + post: + consumes: + - multipart/form-data + description: Uploads and optimizes a thumbnail image, then updates the course + parameters: + - description: Course ID + in: path + name: id + required: true + type: integer + - description: Thumbnail image file (jpg, png, webp) + in: formData + name: file + required: true + type: file + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Upload a thumbnail image for a course + tags: + - courses /api/v1/course-management/learning-tree: get: description: Returns the complete learning tree structure with courses and sub-courses @@ -2550,6 +2761,40 @@ paths: summary: Deactivate sub-course tags: - sub-courses + /api/v1/course-management/sub-courses/{id}/thumbnail: + post: + consumes: + - multipart/form-data + description: Uploads and optimizes a thumbnail image, then updates the sub-course + parameters: + - description: Sub-course ID + in: path + name: id + required: true + type: integer + - description: Thumbnail image file (jpg, png, webp) + in: formData + name: file + required: true + type: file + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Upload a thumbnail image for a sub-course + tags: + - sub-courses /api/v1/course-management/sub-courses/{subCourseId}/videos: get: description: Returns all videos under a specific sub-course @@ -2765,6 +3010,74 @@ paths: summary: Publish sub-course video tags: - sub-course-videos + /api/v1/course-management/videos/upload: + post: + consumes: + - multipart/form-data + description: Accepts a video file upload, uploads it to Vimeo via TUS, and creates + a sub-course video record + parameters: + - description: Video file + in: formData + name: file + required: true + type: file + - description: Sub-course ID + in: formData + name: sub_course_id + required: true + type: integer + - description: Video title + in: formData + name: title + required: true + type: string + - description: Video description + in: formData + name: description + type: string + - description: Duration in seconds + in: formData + name: duration + type: integer + - description: Video resolution + in: formData + name: resolution + type: string + - description: Instructor ID + in: formData + name: instructor_id + type: string + - description: Thumbnail URL + in: formData + name: thumbnail + type: string + - description: Visibility + in: formData + name: visibility + type: string + - description: Display order + in: formData + name: display_order + type: integer + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Upload a video file and create sub-course video + tags: + - sub-course-videos /api/v1/course-management/videos/vimeo: post: consumes: @@ -2825,6 +3138,259 @@ paths: summary: Create a sub-course video from existing Vimeo video tags: - sub-course-videos + /api/v1/issues: + get: + description: Returns all reported issues with pagination (admin only) + parameters: + - default: 20 + description: Limit + in: query + name: limit + type: integer + - default: 0 + description: Offset + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/domain.Response' + - properties: + data: + $ref: '#/definitions/handlers.issueListRes' + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + security: + - Bearer: [] + summary: Get all issues + tags: + - issues + post: + consumes: + - application/json + description: Allows any authenticated user to report an issue they encountered + parameters: + - description: Issue report payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.createIssueReq' + produces: + - application/json + responses: + "201": + description: Created + schema: + allOf: + - $ref: '#/definitions/domain.Response' + - properties: + data: + $ref: '#/definitions/handlers.issueRes' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + security: + - Bearer: [] + summary: Report an issue + tags: + - issues + /api/v1/issues/{id}: + delete: + description: Deletes an issue report (admin only) + parameters: + - description: Issue ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + security: + - Bearer: [] + summary: Delete an issue + tags: + - issues + get: + description: Returns a single issue report by its ID (admin only) + parameters: + - description: Issue ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/domain.Response' + - properties: + data: + $ref: '#/definitions/handlers.issueRes' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + security: + - Bearer: [] + summary: Get issue by ID + tags: + - issues + /api/v1/issues/{id}/status: + patch: + consumes: + - application/json + description: Updates the status of an issue (admin only) + parameters: + - description: Issue ID + in: path + name: id + required: true + type: integer + - description: Status update payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.updateIssueStatusReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + security: + - Bearer: [] + summary: Update issue status + tags: + - issues + /api/v1/issues/me: + get: + description: Returns paginated issues reported by the authenticated user + parameters: + - default: 20 + description: Limit + in: query + name: limit + type: integer + - default: 0 + description: Offset + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/domain.Response' + - properties: + data: + $ref: '#/definitions/handlers.issueListRes' + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + security: + - Bearer: [] + summary: Get my reported issues + tags: + - issues + /api/v1/issues/user/{user_id}: + get: + description: Returns paginated issues reported by a specific user (admin only) + parameters: + - description: User ID + in: path + name: user_id + required: true + type: integer + - default: 20 + description: Limit + in: query + name: limit + type: integer + - default: 0 + description: Offset + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/domain.Response' + - properties: + data: + $ref: '#/definitions/handlers.issueListRes' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + security: + - Bearer: [] + summary: Get issues for a specific user + tags: + - issues /api/v1/logs: get: description: Fetches application logs from MongoDB with pagination, level filtering, @@ -3757,6 +4323,187 @@ paths: summary: Search questions tags: - questions + /api/v1/ratings: + get: + description: Returns paginated ratings for a specific target + parameters: + - description: Target type (app, course, sub_course) + in: query + name: target_type + required: true + type: string + - description: Target ID (0 for app) + in: query + name: target_id + required: true + type: integer + - description: Limit (default 20) + in: query + name: limit + type: integer + - description: Offset (default 0) + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get ratings for a target + tags: + - ratings + post: + consumes: + - application/json + description: Submit a rating for an app, course, or sub-course + parameters: + - description: Submit rating payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.submitRatingReq' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Submit a rating + tags: + - ratings + /api/v1/ratings/{id}: + delete: + description: Deletes a rating by ID for the current user + parameters: + - description: Rating ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Delete a rating + tags: + - ratings + /api/v1/ratings/me: + get: + description: Returns the current user's rating for a specific target + parameters: + - description: Target type (app, course, sub_course) + in: query + name: target_type + required: true + type: string + - description: Target ID (0 for app) + in: query + name: target_id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get my rating for a target + tags: + - ratings + /api/v1/ratings/me/all: + get: + description: Returns all ratings submitted by the current user + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get all my ratings + tags: + - ratings + /api/v1/ratings/summary: + get: + description: Returns the total count and average stars for a specific target + parameters: + - description: Target type (app, course, sub_course) + in: query + name: target_type + required: true + type: string + - description: Target ID (0 for app) + in: query + name: target_id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get rating summary for a target + tags: + - ratings /api/v1/sendSMS: post: consumes: diff --git a/gen/db/models.go b/gen/db/models.go index 08546b3..637b0b0 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -178,6 +178,17 @@ type QuestionShortAnswer struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type Rating struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + TargetType string `json:"target_type"` + TargetID int64 `json:"target_id"` + Stars int16 `json:"stars"` + Review pgtype.Text `json:"review"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type RefreshToken struct { ID int64 `json:"id"` UserID int64 `json:"user_id"` diff --git a/gen/db/ratings.sql.go b/gen/db/ratings.sql.go new file mode 100644 index 0000000..1987ec3 --- /dev/null +++ b/gen/db/ratings.sql.go @@ -0,0 +1,222 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: ratings.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const CountRatingsByTarget = `-- name: CountRatingsByTarget :one +SELECT COUNT(*)::BIGINT +FROM ratings +WHERE target_type = $1 AND target_id = $2 +` + +type CountRatingsByTargetParams struct { + TargetType string `json:"target_type"` + TargetID int64 `json:"target_id"` +} + +func (q *Queries) CountRatingsByTarget(ctx context.Context, arg CountRatingsByTargetParams) (int64, error) { + row := q.db.QueryRow(ctx, CountRatingsByTarget, arg.TargetType, arg.TargetID) + var column_1 int64 + err := row.Scan(&column_1) + return column_1, err +} + +const DeleteRating = `-- name: DeleteRating :exec +DELETE FROM ratings +WHERE id = $1 AND user_id = $2 +` + +type DeleteRatingParams struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` +} + +func (q *Queries) DeleteRating(ctx context.Context, arg DeleteRatingParams) error { + _, err := q.db.Exec(ctx, DeleteRating, arg.ID, arg.UserID) + return err +} + +const GetRatingByUserAndTarget = `-- name: GetRatingByUserAndTarget :one +SELECT id, user_id, target_type, target_id, stars, review, created_at, updated_at +FROM ratings +WHERE user_id = $1 AND target_type = $2 AND target_id = $3 +` + +type GetRatingByUserAndTargetParams struct { + UserID int64 `json:"user_id"` + TargetType string `json:"target_type"` + TargetID int64 `json:"target_id"` +} + +func (q *Queries) GetRatingByUserAndTarget(ctx context.Context, arg GetRatingByUserAndTargetParams) (Rating, error) { + row := q.db.QueryRow(ctx, GetRatingByUserAndTarget, arg.UserID, arg.TargetType, arg.TargetID) + var i Rating + err := row.Scan( + &i.ID, + &i.UserID, + &i.TargetType, + &i.TargetID, + &i.Stars, + &i.Review, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const GetRatingSummary = `-- name: GetRatingSummary :one +SELECT + COUNT(*)::BIGINT AS total_count, + COALESCE(AVG(stars), 0)::FLOAT AS average_stars +FROM ratings +WHERE target_type = $1 AND target_id = $2 +` + +type GetRatingSummaryParams struct { + TargetType string `json:"target_type"` + TargetID int64 `json:"target_id"` +} + +type GetRatingSummaryRow struct { + TotalCount int64 `json:"total_count"` + AverageStars float64 `json:"average_stars"` +} + +func (q *Queries) GetRatingSummary(ctx context.Context, arg GetRatingSummaryParams) (GetRatingSummaryRow, error) { + row := q.db.QueryRow(ctx, GetRatingSummary, arg.TargetType, arg.TargetID) + var i GetRatingSummaryRow + err := row.Scan(&i.TotalCount, &i.AverageStars) + return i, err +} + +const GetRatingsByTarget = `-- name: GetRatingsByTarget :many +SELECT id, user_id, target_type, target_id, stars, review, created_at, updated_at +FROM ratings +WHERE target_type = $1 AND target_id = $2 +ORDER BY created_at DESC +LIMIT $4::INT +OFFSET $3::INT +` + +type GetRatingsByTargetParams struct { + TargetType string `json:"target_type"` + TargetID int64 `json:"target_id"` + Offset pgtype.Int4 `json:"offset"` + Limit pgtype.Int4 `json:"limit"` +} + +func (q *Queries) GetRatingsByTarget(ctx context.Context, arg GetRatingsByTargetParams) ([]Rating, error) { + rows, err := q.db.Query(ctx, GetRatingsByTarget, + arg.TargetType, + arg.TargetID, + arg.Offset, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Rating + for rows.Next() { + var i Rating + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.TargetType, + &i.TargetID, + &i.Stars, + &i.Review, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetUserRatings = `-- name: GetUserRatings :many +SELECT id, user_id, target_type, target_id, stars, review, created_at, updated_at +FROM ratings +WHERE user_id = $1 +ORDER BY updated_at DESC +` + +func (q *Queries) GetUserRatings(ctx context.Context, userID int64) ([]Rating, error) { + rows, err := q.db.Query(ctx, GetUserRatings, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Rating + for rows.Next() { + var i Rating + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.TargetType, + &i.TargetID, + &i.Stars, + &i.Review, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const UpsertRating = `-- name: UpsertRating :one +INSERT INTO ratings (user_id, target_type, target_id, stars, review) +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (user_id, target_type, target_id) +DO UPDATE SET stars = EXCLUDED.stars, review = EXCLUDED.review, updated_at = NOW() +RETURNING id, user_id, target_type, target_id, stars, review, created_at, updated_at +` + +type UpsertRatingParams struct { + UserID int64 `json:"user_id"` + TargetType string `json:"target_type"` + TargetID int64 `json:"target_id"` + Stars int16 `json:"stars"` + Review pgtype.Text `json:"review"` +} + +func (q *Queries) UpsertRating(ctx context.Context, arg UpsertRatingParams) (Rating, error) { + row := q.db.QueryRow(ctx, UpsertRating, + arg.UserID, + arg.TargetType, + arg.TargetID, + arg.Stars, + arg.Review, + ) + var i Rating + err := row.Scan( + &i.ID, + &i.UserID, + &i.TargetType, + &i.TargetID, + &i.Stars, + &i.Review, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/domain/rating.go b/internal/domain/rating.go new file mode 100644 index 0000000..4a0c4a5 --- /dev/null +++ b/internal/domain/rating.go @@ -0,0 +1,27 @@ +package domain + +import "time" + +type RatingTargetType string + +const ( + RatingTargetApp RatingTargetType = "app" + RatingTargetCourse RatingTargetType = "course" + RatingTargetSubCourse RatingTargetType = "sub_course" +) + +type Rating struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + TargetType RatingTargetType `json:"target_type"` + TargetID int64 `json:"target_id"` + Stars int16 `json:"stars"` + Review *string `json:"review"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type RatingSummary struct { + TotalCount int64 `json:"total_count"` + AverageStars float64 `json:"average_stars"` +} diff --git a/internal/pkgs/cloudconvert/client.go b/internal/pkgs/cloudconvert/client.go index 1353245..1108fbe 100644 --- a/internal/pkgs/cloudconvert/client.go +++ b/internal/pkgs/cloudconvert/client.go @@ -271,3 +271,27 @@ func (c *Client) CreateVideoCompressionJob(ctx context.Context) (*Job, error) { return c.CreateJob(ctx, jobReq) } + +func (c *Client) CreateImageOptimizationJob(ctx context.Context, width int, quality int) (*Job, error) { + jobReq := &JobRequest{ + Tasks: map[string]interface{}{ + "import-image": map[string]interface{}{ + "operation": "import/upload", + }, + "convert-image": map[string]interface{}{ + "operation": "convert", + "input": "import-image", + "output_format": "webp", + "quality": quality, + "width": width, + "fit": "max", + }, + "export-image": map[string]interface{}{ + "operation": "export/url", + "input": "convert-image", + }, + }, + } + + return c.CreateJob(ctx, jobReq) +} diff --git a/internal/ports/rating.go b/internal/ports/rating.go new file mode 100644 index 0000000..34a1199 --- /dev/null +++ b/internal/ports/rating.go @@ -0,0 +1,44 @@ +package ports + +import ( + "Yimaru-Backend/internal/domain" + "context" +) + +type RatingStore interface { + UpsertRating( + ctx context.Context, + userID int64, + targetType domain.RatingTargetType, + targetID int64, + stars int16, + review *string, + ) (domain.Rating, error) + GetRatingByUserAndTarget( + ctx context.Context, + userID int64, + targetType domain.RatingTargetType, + targetID int64, + ) (domain.Rating, error) + GetRatingsByTarget( + ctx context.Context, + targetType domain.RatingTargetType, + targetID int64, + limit int32, + offset int32, + ) ([]domain.Rating, error) + GetRatingSummary( + ctx context.Context, + targetType domain.RatingTargetType, + targetID int64, + ) (domain.RatingSummary, error) + GetUserRatings( + ctx context.Context, + userID int64, + ) ([]domain.Rating, error) + DeleteRating( + ctx context.Context, + ratingID int64, + userID int64, + ) error +} diff --git a/internal/repository/ratings.go b/internal/repository/ratings.go new file mode 100644 index 0000000..8663a6c --- /dev/null +++ b/internal/repository/ratings.go @@ -0,0 +1,146 @@ +package repository + +import ( + dbgen "Yimaru-Backend/gen/db" + "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/ports" + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +func NewRatingStore(s *Store) ports.RatingStore { return s } + +func ratingToDomain(r dbgen.Rating) domain.Rating { + var review *string + if r.Review.Valid { + review = &r.Review.String + } + return domain.Rating{ + ID: r.ID, + UserID: r.UserID, + TargetType: domain.RatingTargetType(r.TargetType), + TargetID: r.TargetID, + Stars: r.Stars, + Review: review, + CreatedAt: r.CreatedAt.Time, + UpdatedAt: r.UpdatedAt.Time, + } +} + +func (s *Store) UpsertRating( + ctx context.Context, + userID int64, + targetType domain.RatingTargetType, + targetID int64, + stars int16, + review *string, +) (domain.Rating, error) { + reviewVal := pgtype.Text{Valid: false} + if review != nil { + reviewVal = pgtype.Text{String: *review, Valid: true} + } + + row, err := s.queries.UpsertRating(ctx, dbgen.UpsertRatingParams{ + UserID: userID, + TargetType: string(targetType), + TargetID: targetID, + Stars: stars, + Review: reviewVal, + }) + if err != nil { + return domain.Rating{}, err + } + + return ratingToDomain(row), nil +} + +func (s *Store) GetRatingByUserAndTarget( + ctx context.Context, + userID int64, + targetType domain.RatingTargetType, + targetID int64, +) (domain.Rating, error) { + row, err := s.queries.GetRatingByUserAndTarget(ctx, dbgen.GetRatingByUserAndTargetParams{ + UserID: userID, + TargetType: string(targetType), + TargetID: targetID, + }) + if err != nil { + return domain.Rating{}, err + } + + return ratingToDomain(row), nil +} + +func (s *Store) GetRatingsByTarget( + ctx context.Context, + targetType domain.RatingTargetType, + targetID int64, + limit int32, + offset int32, +) ([]domain.Rating, error) { + rows, err := s.queries.GetRatingsByTarget(ctx, dbgen.GetRatingsByTargetParams{ + TargetType: string(targetType), + TargetID: targetID, + Limit: pgtype.Int4{Int32: limit, Valid: true}, + Offset: pgtype.Int4{Int32: offset, Valid: true}, + }) + if err != nil { + return nil, err + } + + var ratings []domain.Rating + for _, row := range rows { + ratings = append(ratings, ratingToDomain(row)) + } + + return ratings, nil +} + +func (s *Store) GetRatingSummary( + ctx context.Context, + targetType domain.RatingTargetType, + targetID int64, +) (domain.RatingSummary, error) { + row, err := s.queries.GetRatingSummary(ctx, dbgen.GetRatingSummaryParams{ + TargetType: string(targetType), + TargetID: targetID, + }) + if err != nil { + return domain.RatingSummary{}, err + } + + return domain.RatingSummary{ + TotalCount: row.TotalCount, + AverageStars: row.AverageStars, + }, nil +} + +func (s *Store) GetUserRatings( + ctx context.Context, + userID int64, +) ([]domain.Rating, error) { + rows, err := s.queries.GetUserRatings(ctx, userID) + if err != nil { + return nil, err + } + + var ratings []domain.Rating + for _, row := range rows { + ratings = append(ratings, ratingToDomain(row)) + } + + return ratings, nil +} + +func (s *Store) DeleteRating( + ctx context.Context, + ratingID int64, + userID int64, +) error { + return s.queries.DeleteRating(ctx, dbgen.DeleteRatingParams{ + ID: ratingID, + UserID: userID, + }) +} diff --git a/internal/services/cloudconvert/service.go b/internal/services/cloudconvert/service.go index 6c1cd0a..0c1ff2d 100644 --- a/internal/services/cloudconvert/service.go +++ b/internal/services/cloudconvert/service.go @@ -29,6 +29,12 @@ type CompressResult struct { Filename string } +type OptimizeImageResult 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)) @@ -108,3 +114,91 @@ func (s *Service) CompressVideo(ctx context.Context, filename string, fileData i Filename: exportFilename, }, nil } + +func (s *Service) OptimizeImage(ctx context.Context, filename string, fileData io.Reader, fileSize int64, width int, quality int) (*OptimizeImageResult, error) { + s.logger.Info("Creating CloudConvert image optimization job", + zap.String("filename", filename), + zap.Int64("original_size", fileSize), + zap.Int("width", width), + zap.Int("quality", quality), + ) + + job, err := s.client.CreateImageOptimizationJob(ctx, width, quality) + if err != nil { + s.logger.Error("Failed to create CloudConvert image job", zap.Error(err)) + return nil, fmt.Errorf("failed to create image optimization job: %w", err) + } + + var uploadForm *cc.UploadForm + for _, task := range job.Tasks { + if task.Name == "import-image" && task.Result != nil && task.Result.Form != nil { + uploadForm = task.Result.Form + break + } + } + + if uploadForm == nil { + return nil, fmt.Errorf("no upload form found in image job response") + } + + s.logger.Info("Uploading image 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 image to CloudConvert", zap.Error(err)) + return nil, fmt.Errorf("failed to upload image: %w", err) + } + + s.logger.Info("Waiting for CloudConvert image job to complete", zap.String("job_id", job.ID)) + + completedJob, err := s.client.WaitForJob(ctx, job.ID, 3*time.Second, 5*time.Minute) + if err != nil { + s.logger.Error("CloudConvert image job failed", zap.String("job_id", job.ID), zap.Error(err)) + return nil, fmt.Errorf("image optimization job failed: %w", err) + } + + var exportURL string + var exportFilename string + for _, task := range completedJob.Tasks { + if task.Name == "export-image" && 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 image job") + } + + s.logger.Info("Downloading optimized image 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 optimized image: %w", err) + } + + if contentLength <= 0 { + data, err := io.ReadAll(body) + body.Close() + if err != nil { + return nil, fmt.Errorf("failed to read optimized image: %w", err) + } + contentLength = int64(len(data)) + body = io.NopCloser(bytes.NewReader(data)) + } + + s.logger.Info("Image optimization complete", + zap.Int64("original_size", fileSize), + zap.Int64("optimized_size", contentLength), + zap.String("filename", exportFilename), + ) + + return &OptimizeImageResult{ + Data: body, + FileSize: contentLength, + Filename: exportFilename, + }, nil +} diff --git a/internal/services/ratings/service.go b/internal/services/ratings/service.go new file mode 100644 index 0000000..60da2c4 --- /dev/null +++ b/internal/services/ratings/service.go @@ -0,0 +1,48 @@ +package ratings + +import ( + "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/ports" + "context" + "fmt" +) + +type Service struct { + ratingStore ports.RatingStore +} + +func NewService(ratingStore ports.RatingStore) *Service { + return &Service{ + ratingStore: ratingStore, + } +} + +func (s *Service) SubmitRating(ctx context.Context, userID int64, targetType domain.RatingTargetType, targetID int64, stars int16, review *string) (domain.Rating, error) { + if stars < 1 || stars > 5 { + return domain.Rating{}, fmt.Errorf("stars must be between 1 and 5") + } + if targetType != domain.RatingTargetApp && targetType != domain.RatingTargetCourse && targetType != domain.RatingTargetSubCourse { + return domain.Rating{}, fmt.Errorf("invalid target type: %s", targetType) + } + return s.ratingStore.UpsertRating(ctx, userID, targetType, targetID, stars, review) +} + +func (s *Service) GetMyRating(ctx context.Context, userID int64, targetType domain.RatingTargetType, targetID int64) (domain.Rating, error) { + return s.ratingStore.GetRatingByUserAndTarget(ctx, userID, targetType, targetID) +} + +func (s *Service) GetRatingsByTarget(ctx context.Context, targetType domain.RatingTargetType, targetID int64, limit, offset int32) ([]domain.Rating, error) { + return s.ratingStore.GetRatingsByTarget(ctx, targetType, targetID, limit, offset) +} + +func (s *Service) GetRatingSummary(ctx context.Context, targetType domain.RatingTargetType, targetID int64) (domain.RatingSummary, error) { + return s.ratingStore.GetRatingSummary(ctx, targetType, targetID) +} + +func (s *Service) GetUserRatings(ctx context.Context, userID int64) ([]domain.Rating, error) { + return s.ratingStore.GetUserRatings(ctx, userID) +} + +func (s *Service) DeleteRating(ctx context.Context, ratingID, userID int64) error { + return s.ratingStore.DeleteRating(ctx, ratingID, userID) +} diff --git a/internal/web_server/app.go b/internal/web_server/app.go index 4fc6720..4e410a5 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -4,6 +4,8 @@ import ( dbgen "Yimaru-Backend/gen/db" "Yimaru-Backend/internal/config" activitylogservice "Yimaru-Backend/internal/services/activity_log" + cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" + ratingsservice "Yimaru-Backend/internal/services/ratings" "Yimaru-Backend/internal/services/arifpay" "Yimaru-Backend/internal/services/assessment" "Yimaru-Backend/internal/services/authentication" @@ -41,6 +43,8 @@ type App struct { vimeoSvc *vimeoservice.Service teamSvc *team.Service activityLogSvc *activitylogservice.Service + cloudConvertSvc *cloudconvertservice.Service + ratingSvc *ratingsservice.Service fiber *fiber.App recommendationSvc recommendation.RecommendationService cfg *config.Config @@ -68,6 +72,8 @@ func NewApp( vimeoSvc *vimeoservice.Service, teamSvc *team.Service, activityLogSvc *activitylogservice.Service, + cloudConvertSvc *cloudconvertservice.Service, + ratingSvc *ratingsservice.Service, port int, validator *customvalidator.CustomValidator, settingSvc *settings.Service, authSvc *authentication.Service, @@ -106,7 +112,9 @@ func NewApp( arifpaySvc: arifpaySvc, vimeoSvc: vimeoSvc, teamSvc: teamSvc, - activityLogSvc: activityLogSvc, + activityLogSvc: activityLogSvc, + cloudConvertSvc: cloudConvertSvc, + ratingSvc: ratingSvc, issueReportingSvc: issueReportingSvc, fiber: app, port: port, diff --git a/internal/web_server/handlers/course_management.go b/internal/web_server/handlers/course_management.go index e63eca8..33c9f89 100644 --- a/internal/web_server/handlers/course_management.go +++ b/internal/web_server/handlers/course_management.go @@ -2,12 +2,19 @@ package handlers import ( "Yimaru-Backend/internal/domain" + "bytes" "context" "encoding/json" "fmt" + "io" + "net/http" + "os" + "path/filepath" "strconv" "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "go.uber.org/zap" ) // Course Category Handlers @@ -1691,6 +1698,201 @@ func (h *Handler) CreateSubCourseVideoFromVimeoID(c *fiber.Ctx) error { }) } +// UploadCourseThumbnail godoc +// @Summary Upload a thumbnail image for a course +// @Description Uploads and optimizes a thumbnail image, then updates the course +// @Tags courses +// @Accept multipart/form-data +// @Produce json +// @Param id path int true "Course ID" +// @Param file formData file true "Thumbnail image file (jpg, png, webp)" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/courses/{id}/thumbnail [post] +func (h *Handler) UploadCourseThumbnail(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 course ID", + Error: err.Error(), + }) + } + + publicPath, err := h.processAndSaveThumbnail(c, "thumbnails/courses") + if err != nil { + return err + } + + if err := h.courseMgmtSvc.UpdateCourse(c.Context(), id, nil, nil, &publicPath, nil); err != nil { + _ = os.Remove(filepath.Join(".", publicPath)) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to update course thumbnail", + 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{}{"course_id": id, "thumbnail": publicPath}) + go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCourseUpdated, domain.ResourceCourse, &id, fmt.Sprintf("Uploaded thumbnail for course ID: %d", id), meta, &ip, &ua) + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Course thumbnail uploaded successfully", + Data: map[string]string{"thumbnail_url": publicPath}, + Success: true, + }) +} + +// UploadSubCourseThumbnail godoc +// @Summary Upload a thumbnail image for a sub-course +// @Description Uploads and optimizes a thumbnail image, then updates the sub-course +// @Tags sub-courses +// @Accept multipart/form-data +// @Produce json +// @Param id path int true "Sub-course ID" +// @Param file formData file true "Thumbnail image file (jpg, png, webp)" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/sub-courses/{id}/thumbnail [post] +func (h *Handler) UploadSubCourseThumbnail(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 sub-course ID", + Error: err.Error(), + }) + } + + publicPath, err := h.processAndSaveThumbnail(c, "thumbnails/sub_courses") + if err != nil { + return err + } + + if err := h.courseMgmtSvc.UpdateSubCourse(c.Context(), id, nil, nil, &publicPath, nil, nil, nil); err != nil { + _ = os.Remove(filepath.Join(".", publicPath)) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to update sub-course thumbnail", + 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{}{"sub_course_id": id, "thumbnail": publicPath}) + go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionSubCourseUpdated, domain.ResourceSubCourse, &id, fmt.Sprintf("Uploaded thumbnail for sub-course ID: %d", id), meta, &ip, &ua) + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Sub-course thumbnail uploaded successfully", + Data: map[string]string{"thumbnail_url": publicPath}, + Success: true, + }) +} + +// processAndSaveThumbnail handles file validation, CloudConvert optimization, and local storage. +// It returns the public URL path or a fiber error response. +func (h *Handler) processAndSaveThumbnail(c *fiber.Ctx, subDir string) (string, error) { + fileHeader, err := c.FormFile("file") + if err != nil { + return "", c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Image file is required", + Error: err.Error(), + }) + } + + const maxSize = 10 * 1024 * 1024 // 10 MB + if fileHeader.Size > maxSize { + return "", c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "File too large", + Error: "Thumbnail image must be <= 10MB", + }) + } + + fh, err := fileHeader.Open() + if err != nil { + return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to read file", + Error: err.Error(), + }) + } + defer fh.Close() + + head := make([]byte, 512) + n, _ := fh.Read(head) + contentType := http.DetectContentType(head[:n]) + if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/webp" { + return "", c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid file type", + Error: "Only jpg, png and webp images are allowed", + }) + } + + rest, err := io.ReadAll(fh) + if err != nil { + return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to read file", + Error: err.Error(), + }) + } + data := append(head[:n], rest...) + + // Optimize via CloudConvert if available + if h.cloudConvertSvc != nil { + optimized, optErr := h.cloudConvertSvc.OptimizeImage( + c.Context(), fileHeader.Filename, + bytes.NewReader(data), int64(len(data)), + 1200, 80, + ) + if optErr != nil { + h.mongoLoggerSvc.Warn("CloudConvert thumbnail optimization failed, using original", + zap.Error(optErr), + ) + } else { + optimizedData, readErr := io.ReadAll(optimized.Data) + optimized.Data.Close() + if readErr == nil { + data = optimizedData + contentType = "image/webp" + } + } + } + + ext := ".jpg" + switch contentType { + case "image/png": + ext = ".png" + case "image/webp": + ext = ".webp" + } + + dir := filepath.Join(".", "static", subDir) + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to create storage directory", + Error: err.Error(), + }) + } + + filename := uuid.New().String() + ext + fullpath := filepath.Join(dir, filename) + + if err := os.WriteFile(fullpath, data, 0o644); err != nil { + return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to save file", + Error: err.Error(), + }) + } + + return "/static/" + subDir + "/" + filename, nil +} + // Helper function to map video to response func mapVideoToResponse(video domain.SubCourseVideo) subCourseVideoRes { var publishDate *string diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index e743402..65e54c0 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -11,7 +11,9 @@ import ( "Yimaru-Backend/internal/services/assessment" "Yimaru-Backend/internal/services/authentication" course_management "Yimaru-Backend/internal/services/course_management" + cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" issuereporting "Yimaru-Backend/internal/services/issue_reporting" + ratingsservice "Yimaru-Backend/internal/services/ratings" notificationservice "Yimaru-Backend/internal/services/notification" "Yimaru-Backend/internal/services/questions" "Yimaru-Backend/internal/services/recommendation" @@ -49,6 +51,8 @@ type Handler struct { teamSvc *team.Service activityLogSvc *activitylogservice.Service issueReportingSvc *issuereporting.Service + cloudConvertSvc *cloudconvertservice.Service + ratingSvc *ratingsservice.Service jwtConfig jwtutil.JwtConfig validator *customvalidator.CustomValidator Cfg *config.Config @@ -74,6 +78,8 @@ func New( teamSvc *team.Service, activityLogSvc *activitylogservice.Service, issueReportingSvc *issuereporting.Service, + cloudConvertSvc *cloudconvertservice.Service, + ratingSvc *ratingsservice.Service, jwtConfig jwtutil.JwtConfig, cfg *config.Config, mongoLoggerSvc *zap.Logger, @@ -97,6 +103,8 @@ func New( teamSvc: teamSvc, activityLogSvc: activityLogSvc, issueReportingSvc: issueReportingSvc, + cloudConvertSvc: cloudConvertSvc, + ratingSvc: ratingSvc, jwtConfig: jwtConfig, Cfg: cfg, mongoLoggerSvc: mongoLoggerSvc, diff --git a/internal/web_server/handlers/ratings.go b/internal/web_server/handlers/ratings.go new file mode 100644 index 0000000..f2df1fd --- /dev/null +++ b/internal/web_server/handlers/ratings.go @@ -0,0 +1,327 @@ +package handlers + +import ( + "Yimaru-Backend/internal/domain" + "strconv" + + "github.com/gofiber/fiber/v2" +) + +type submitRatingReq struct { + TargetType string `json:"target_type" validate:"required"` + TargetID int64 `json:"target_id"` + Stars int16 `json:"stars" validate:"required,min=1,max=5"` + Review *string `json:"review"` +} + +type ratingRes struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + TargetType string `json:"target_type"` + TargetID int64 `json:"target_id"` + Stars int16 `json:"stars"` + Review *string `json:"review"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type ratingSummaryRes struct { + TotalCount int64 `json:"total_count"` + AverageStars float64 `json:"average_stars"` +} + +func mapRatingToRes(r domain.Rating) ratingRes { + return ratingRes{ + ID: r.ID, + UserID: r.UserID, + TargetType: string(r.TargetType), + TargetID: r.TargetID, + Stars: r.Stars, + Review: r.Review, + CreatedAt: r.CreatedAt.String(), + UpdatedAt: r.UpdatedAt.String(), + } +} + +func isValidTargetType(t string) bool { + switch domain.RatingTargetType(t) { + case domain.RatingTargetApp, domain.RatingTargetCourse, domain.RatingTargetSubCourse: + return true + } + return false +} + +// SubmitRating godoc +// @Summary Submit a rating +// @Description Submit a rating for an app, course, or sub-course +// @Tags ratings +// @Accept json +// @Produce json +// @Param body body submitRatingReq true "Submit rating payload" +// @Success 201 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/ratings [post] +func (h *Handler) SubmitRating(c *fiber.Ctx) error { + userID := c.Locals("user_id").(int64) + + var req submitRatingReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + + if !isValidTargetType(req.TargetType) { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid target_type, must be one of: app, course, sub_course", + }) + } + + targetType := domain.RatingTargetType(req.TargetType) + targetID := req.TargetID + if targetType == domain.RatingTargetApp { + targetID = 0 + } + + rating, err := h.ratingSvc.SubmitRating(c.Context(), userID, targetType, targetID, req.Stars, req.Review) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to submit rating", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusCreated).JSON(domain.Response{ + Message: "Rating submitted successfully", + Data: mapRatingToRes(rating), + }) +} + +// GetMyRating godoc +// @Summary Get my rating for a target +// @Description Returns the current user's rating for a specific target +// @Tags ratings +// @Produce json +// @Param target_type query string true "Target type (app, course, sub_course)" +// @Param target_id query int true "Target ID (0 for app)" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/ratings/me [get] +func (h *Handler) GetMyRating(c *fiber.Ctx) error { + userID := c.Locals("user_id").(int64) + + targetType := c.Query("target_type") + if !isValidTargetType(targetType) { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid target_type, must be one of: app, course, sub_course", + }) + } + + targetID, err := strconv.ParseInt(c.Query("target_id", "0"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid target_id", + Error: err.Error(), + }) + } + + if domain.RatingTargetType(targetType) == domain.RatingTargetApp { + targetID = 0 + } + + rating, err := h.ratingSvc.GetMyRating(c.Context(), userID, domain.RatingTargetType(targetType), targetID) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Rating not found", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Rating retrieved successfully", + Data: mapRatingToRes(rating), + }) +} + +// GetRatingsByTarget godoc +// @Summary Get ratings for a target +// @Description Returns paginated ratings for a specific target +// @Tags ratings +// @Produce json +// @Param target_type query string true "Target type (app, course, sub_course)" +// @Param target_id query int true "Target ID (0 for app)" +// @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/ratings [get] +func (h *Handler) GetRatingsByTarget(c *fiber.Ctx) error { + targetType := c.Query("target_type") + if !isValidTargetType(targetType) { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid target_type, must be one of: app, course, sub_course", + }) + } + + targetID, err := strconv.ParseInt(c.Query("target_id", "0"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid target_id", + Error: err.Error(), + }) + } + + if domain.RatingTargetType(targetType) == domain.RatingTargetApp { + targetID = 0 + } + + limit, err := strconv.ParseInt(c.Query("limit", "20"), 10, 32) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid limit", + Error: err.Error(), + }) + } + + offset, err := strconv.ParseInt(c.Query("offset", "0"), 10, 32) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid offset", + Error: err.Error(), + }) + } + + ratings, err := h.ratingSvc.GetRatingsByTarget(c.Context(), domain.RatingTargetType(targetType), targetID, int32(limit), int32(offset)) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to retrieve ratings", + Error: err.Error(), + }) + } + + res := make([]ratingRes, len(ratings)) + for i, r := range ratings { + res[i] = mapRatingToRes(r) + } + + return c.JSON(domain.Response{ + Message: "Ratings retrieved successfully", + Data: res, + }) +} + +// GetRatingSummary godoc +// @Summary Get rating summary for a target +// @Description Returns the total count and average stars for a specific target +// @Tags ratings +// @Produce json +// @Param target_type query string true "Target type (app, course, sub_course)" +// @Param target_id query int true "Target ID (0 for app)" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/ratings/summary [get] +func (h *Handler) GetRatingSummary(c *fiber.Ctx) error { + targetType := c.Query("target_type") + if !isValidTargetType(targetType) { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid target_type, must be one of: app, course, sub_course", + }) + } + + targetID, err := strconv.ParseInt(c.Query("target_id", "0"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid target_id", + Error: err.Error(), + }) + } + + if domain.RatingTargetType(targetType) == domain.RatingTargetApp { + targetID = 0 + } + + summary, err := h.ratingSvc.GetRatingSummary(c.Context(), domain.RatingTargetType(targetType), targetID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to retrieve rating summary", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Rating summary retrieved successfully", + Data: ratingSummaryRes{ + TotalCount: summary.TotalCount, + AverageStars: summary.AverageStars, + }, + }) +} + +// GetMyRatings godoc +// @Summary Get all my ratings +// @Description Returns all ratings submitted by the current user +// @Tags ratings +// @Produce json +// @Success 200 {object} domain.Response +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/ratings/me/all [get] +func (h *Handler) GetMyRatings(c *fiber.Ctx) error { + userID := c.Locals("user_id").(int64) + + ratings, err := h.ratingSvc.GetUserRatings(c.Context(), userID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to retrieve ratings", + Error: err.Error(), + }) + } + + res := make([]ratingRes, len(ratings)) + for i, r := range ratings { + res[i] = mapRatingToRes(r) + } + + return c.JSON(domain.Response{ + Message: "Ratings retrieved successfully", + Data: res, + }) +} + +// DeleteRating godoc +// @Summary Delete a rating +// @Description Deletes a rating by ID for the current user +// @Tags ratings +// @Produce json +// @Param id path int true "Rating ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/ratings/{id} [delete] +func (h *Handler) DeleteRating(c *fiber.Ctx) error { + userID := c.Locals("user_id").(int64) + + ratingID, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid rating ID", + Error: err.Error(), + }) + } + + if err := h.ratingSvc.DeleteRating(c.Context(), ratingID, userID); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to delete rating", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Rating deleted successfully", + }) +} diff --git a/internal/web_server/handlers/user.go b/internal/web_server/handlers/user.go index 8abc9cb..5ee6a91 100644 --- a/internal/web_server/handlers/user.go +++ b/internal/web_server/handlers/user.go @@ -5,6 +5,7 @@ import ( "Yimaru-Backend/internal/services/authentication" jwtutil "Yimaru-Backend/internal/web_server/jwt" "Yimaru-Backend/internal/web_server/response" + "bytes" "context" "encoding/json" "errors" @@ -1706,6 +1707,28 @@ func (h *Handler) UploadProfilePicture(c *fiber.Ctx) error { // Combine head + rest data := append(head[:n], rest...) + // Optimize image via CloudConvert if available + if h.cloudConvertSvc != nil { + optimized, optErr := h.cloudConvertSvc.OptimizeImage( + c.Context(), fileHeader.Filename, + bytes.NewReader(data), int64(len(data)), + 512, 80, + ) + if optErr != nil { + h.mongoLoggerSvc.Warn("CloudConvert image optimization failed, using original", + zap.Int64("user_id", userID), + zap.Error(optErr), + ) + } else { + optimizedData, readErr := io.ReadAll(optimized.Data) + optimized.Data.Close() + if readErr == nil { + data = optimizedData + contentType = "image/webp" + } + } + } + ext := ".jpg" switch contentType { case "image/png": diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 9e70fd6..020e3a2 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -30,6 +30,8 @@ func (a *App) initAppRoutes() { a.teamSvc, a.activityLogSvc, a.issueReportingSvc, + a.cloudConvertSvc, + a.ratingSvc, a.JwtConfig, a.cfg, a.mongoLoggerSvc, @@ -148,6 +150,7 @@ func (a *App) initAppRoutes() { groupV1.Get("/course-management/courses/:id", a.authMiddleware, h.GetCourseByID) groupV1.Get("/course-management/categories/:categoryId/courses", a.authMiddleware, h.GetCoursesByCategory) groupV1.Put("/course-management/courses/:id", a.authMiddleware, h.UpdateCourse) + groupV1.Post("/course-management/courses/:id/thumbnail", a.authMiddleware, h.UploadCourseThumbnail) groupV1.Delete("/course-management/courses/:id", a.authMiddleware, h.DeleteCourse) // Sub-courses @@ -157,6 +160,7 @@ func (a *App) initAppRoutes() { groupV1.Get("/course-management/courses/:courseId/sub-courses/list", a.authMiddleware, h.ListSubCoursesByCourse) groupV1.Get("/course-management/sub-courses/active", a.authMiddleware, h.ListActiveSubCourses) groupV1.Patch("/course-management/sub-courses/:id", a.authMiddleware, h.UpdateSubCourse) + groupV1.Post("/course-management/sub-courses/:id/thumbnail", a.authMiddleware, h.UploadSubCourseThumbnail) groupV1.Put("/course-management/sub-courses/:id/deactivate", a.authMiddleware, h.DeactivateSubCourse) groupV1.Delete("/course-management/sub-courses/:id", a.authMiddleware, h.DeleteSubCourse) @@ -386,4 +390,12 @@ func (a *App) initAppRoutes() { teamGroup.Delete("/members/:id", a.authMiddleware, a.SuperAdminOnly, h.DeleteTeamMember) // Delete team member teamGroup.Post("/members/:id/change-password", a.authMiddleware, h.ChangeTeamMemberPassword) // Change password + // Ratings + groupV1.Post("/ratings", a.authMiddleware, h.SubmitRating) + groupV1.Get("/ratings", a.authMiddleware, h.GetRatingsByTarget) + groupV1.Get("/ratings/summary", a.authMiddleware, h.GetRatingSummary) + groupV1.Get("/ratings/me", a.authMiddleware, h.GetMyRating) + groupV1.Get("/ratings/me/all", a.authMiddleware, h.GetMyRatings) + groupV1.Delete("/ratings/:id", a.authMiddleware, h.DeleteRating) + }