added cloudconvert for thumbnails + added ratings service
This commit is contained in:
parent
aa6194013c
commit
130421e971
|
|
@ -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,
|
||||
|
|
|
|||
1
db/migrations/000017_ratings.down.sql
Normal file
1
db/migrations/000017_ratings.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS ratings;
|
||||
14
db/migrations/000017_ratings.up.sql
Normal file
14
db/migrations/000017_ratings.up.sql
Normal file
|
|
@ -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);
|
||||
47
db/query/ratings.sql
Normal file
47
db/query/ratings.sql
Normal file
|
|
@ -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;
|
||||
1165
docs/docs.go
1165
docs/docs.go
File diff suppressed because it is too large
Load Diff
1165
docs/swagger.json
1165
docs/swagger.json
File diff suppressed because it is too large
Load Diff
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
222
gen/db/ratings.sql.go
Normal file
222
gen/db/ratings.sql.go
Normal file
|
|
@ -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
|
||||
}
|
||||
27
internal/domain/rating.go
Normal file
27
internal/domain/rating.go
Normal file
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
44
internal/ports/rating.go
Normal file
44
internal/ports/rating.go
Normal file
|
|
@ -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
|
||||
}
|
||||
146
internal/repository/ratings.go
Normal file
146
internal/repository/ratings.go
Normal file
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
48
internal/services/ratings/service.go
Normal file
48
internal/services/ratings/service.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
327
internal/web_server/handlers/ratings.go
Normal file
327
internal/web_server/handlers/ratings.go
Normal file
|
|
@ -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",
|
||||
})
|
||||
}
|
||||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user