Compare commits
No commits in common. "9b17c13f44cfffe96cb48fa130df4bf9ff8f2d03" and "aa6194013cabc1e2ab094223f57a71a6675cffc7" have entirely different histories.
9b17c13f44
...
aa6194013c
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,4 +1,3 @@
|
||||||
cmd.exe
|
|
||||||
bin
|
bin
|
||||||
coverage.out
|
coverage.out
|
||||||
coverage
|
coverage
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ import (
|
||||||
"Yimaru-Backend/internal/services/team"
|
"Yimaru-Backend/internal/services/team"
|
||||||
activitylogservice "Yimaru-Backend/internal/services/activity_log"
|
activitylogservice "Yimaru-Backend/internal/services/activity_log"
|
||||||
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
|
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
|
||||||
ratingsservice "Yimaru-Backend/internal/services/ratings"
|
|
||||||
vimeoservice "Yimaru-Backend/internal/services/vimeo"
|
vimeoservice "Yimaru-Backend/internal/services/vimeo"
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
|
@ -370,9 +369,8 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloudConvert service for video compression
|
// CloudConvert service for video compression
|
||||||
var ccSvc *cloudconvertservice.Service
|
|
||||||
if cfg.CloudConvert.Enabled && cfg.CloudConvert.APIKey != "" {
|
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)
|
courseSvc.SetCloudConvertService(ccSvc)
|
||||||
logger.Info("CloudConvert service initialized")
|
logger.Info("CloudConvert service initialized")
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -404,9 +402,6 @@ func main() {
|
||||||
// Activity Log service
|
// Activity Log service
|
||||||
activityLogSvc := activitylogservice.NewService(store, domain.MongoDBLogger)
|
activityLogSvc := activitylogservice.NewService(store, domain.MongoDBLogger)
|
||||||
|
|
||||||
// Ratings service
|
|
||||||
ratingSvc := ratingsservice.NewService(repository.NewRatingStore(store))
|
|
||||||
|
|
||||||
// Initialize and start HTTP server
|
// Initialize and start HTTP server
|
||||||
app := httpserver.NewApp(
|
app := httpserver.NewApp(
|
||||||
assessmentSvc,
|
assessmentSvc,
|
||||||
|
|
@ -418,8 +413,6 @@ func main() {
|
||||||
vimeoSvc,
|
vimeoSvc,
|
||||||
teamSvc,
|
teamSvc,
|
||||||
activityLogSvc,
|
activityLogSvc,
|
||||||
ccSvc,
|
|
||||||
ratingSvc,
|
|
||||||
cfg.Port,
|
cfg.Port,
|
||||||
v,
|
v,
|
||||||
settingSvc,
|
settingSvc,
|
||||||
|
|
|
||||||
|
|
@ -72,10 +72,10 @@ VALUES
|
||||||
'Admin',
|
'Admin',
|
||||||
'Female',
|
'Female',
|
||||||
'1995-01-01',
|
'1995-01-01',
|
||||||
'admin@yimaru.com',
|
'yaredyemane1@gmail.com',
|
||||||
'0911001100',
|
'0911001100',
|
||||||
'ADMIN',
|
'ADMIN',
|
||||||
crypt('password@123', gen_salt('bf'))::bytea,
|
crypt('password123', gen_salt('bf'))::bytea,
|
||||||
'35_44',
|
'35_44',
|
||||||
'Master',
|
'Master',
|
||||||
'Ethiopia',
|
'Ethiopia',
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
DROP TABLE IF EXISTS ratings;
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
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);
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
-- 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;
|
|
||||||
|
|
@ -1,514 +0,0 @@
|
||||||
# Yimaru LMS Backend — Test Plan
|
|
||||||
|
|
||||||
## 1. System Overview
|
|
||||||
|
|
||||||
| Component | Tech |
|
|
||||||
|-----------|------|
|
|
||||||
| Language | Go 1.x |
|
|
||||||
| Framework | Fiber v2 |
|
|
||||||
| Database | PostgreSQL (pgx/v5, sqlc) |
|
|
||||||
| Auth | JWT (access + refresh tokens) |
|
|
||||||
| Video | Vimeo API, CloudConvert |
|
|
||||||
| Payments | ArifPay |
|
|
||||||
| Notifications | WebSocket, SMS (AfroSMS), Resend, Push |
|
|
||||||
| Logging | zap (MongoDB), slog |
|
|
||||||
|
|
||||||
**Roles:** `SUPER_ADMIN`, `ADMIN`, `STUDENT`, `INSTRUCTOR`, `SUPPORT`
|
|
||||||
**Team Roles:** `super_admin`, `admin`, `content_manager`, `support_agent`, `instructor`, `finance`, `hr`, `analyst`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Test Strategy
|
|
||||||
|
|
||||||
### Test Pyramid
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────┐
|
|
||||||
│ E2E / │ ← API integration tests (Postman/httptest)
|
|
||||||
│ API │
|
|
||||||
┌┴─────────┴┐
|
|
||||||
│ Service │ ← Business logic unit tests (mocked stores)
|
|
||||||
┌┴────────────┴┐
|
|
||||||
│ Repository │ ← DB integration tests (test DB + sqlc)
|
|
||||||
┌┴──────────────┴┐
|
|
||||||
│ Domain/Utils │ ← Pure unit tests (no deps)
|
|
||||||
└────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Priority Levels
|
|
||||||
- **P0 — Critical:** Auth, Payments, Subscriptions — money + access
|
|
||||||
- **P1 — High:** Course CRUD, Video upload pipeline, Ratings
|
|
||||||
- **P2 — Medium:** Notifications, Questions, Issue Reporting, Team Mgmt
|
|
||||||
- **P3 — Low:** Activity Logs, Analytics, Settings, Static files
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Module Test Plans
|
|
||||||
|
|
||||||
### 3.1 Authentication & Authorization (P0)
|
|
||||||
|
|
||||||
#### Unit Tests
|
|
||||||
| ID | Test Case | Input | Expected |
|
|
||||||
|----|-----------|-------|----------|
|
|
||||||
| AUTH-01 | Register user with valid data | valid phone, email, password | 201, user created with status=PENDING |
|
|
||||||
| AUTH-02 | Register with existing email | duplicate email | 400/409, `ErrEmailAlreadyRegistered` |
|
|
||||||
| AUTH-03 | Register with existing phone | duplicate phone | 400/409, `ErrPhoneAlreadyRegistered` |
|
|
||||||
| AUTH-04 | Login with correct credentials | valid email+password | 200, access_token + refresh_token |
|
|
||||||
| AUTH-05 | Login with wrong password | valid email, bad password | 401 |
|
|
||||||
| AUTH-06 | Login unverified user | PENDING user | 401, `ErrUserNotVerified` |
|
|
||||||
| AUTH-07 | Refresh token (valid) | valid refresh token | 200, new access_token |
|
|
||||||
| AUTH-08 | Refresh token (expired/revoked) | expired/revoked token | 401 |
|
|
||||||
| AUTH-09 | Google OAuth login (Android) | valid Google ID token | 200, tokens returned |
|
|
||||||
| AUTH-10 | Logout | valid token | 200, refresh token revoked |
|
|
||||||
| AUTH-11 | OTP send | valid phone/email | 200, OTP generated |
|
|
||||||
| AUTH-12 | OTP verify (correct) | correct OTP | 200, user status → ACTIVE |
|
|
||||||
| AUTH-13 | OTP verify (wrong/expired) | wrong OTP | 400 |
|
|
||||||
| AUTH-14 | OTP resend | valid user | 200, new OTP |
|
|
||||||
| AUTH-15 | Password reset flow | sendResetCode → verify → resetPassword | 200 at each step |
|
|
||||||
|
|
||||||
#### Middleware Tests
|
|
||||||
| ID | Test Case | Expected |
|
|
||||||
|----|-----------|----------|
|
|
||||||
| MW-01 | Request without Authorization header | 401 |
|
|
||||||
| MW-02 | Request with malformed JWT | 401 |
|
|
||||||
| MW-03 | Request with expired JWT | 401 |
|
|
||||||
| MW-04 | Valid token → user_id/role in Locals | Next handler receives correct locals |
|
|
||||||
| MW-05 | `SuperAdminOnly` — ADMIN role | 403 |
|
|
||||||
| MW-06 | `SuperAdminOnly` — SUPER_ADMIN role | Next called |
|
|
||||||
| MW-07 | `OnlyAdminAndAbove` — STUDENT role | 403 |
|
|
||||||
| MW-08 | `OnlyAdminAndAbove` — ADMIN role | Next called |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.2 User Management (P1)
|
|
||||||
|
|
||||||
| ID | Test Case | Input | Expected |
|
|
||||||
|----|-----------|-------|----------|
|
|
||||||
| USR-01 | Get user profile (own) | auth token | 200, profile data |
|
|
||||||
| USR-02 | Update user profile | valid fields | 200, updated |
|
|
||||||
| USR-03 | Upload profile picture (jpg) | ≤5MB jpg | 200, `/static/profile_pictures/<uuid>.webp` (if CloudConvert on) |
|
|
||||||
| USR-04 | Upload profile picture (>5MB) | 6MB file | 400, file too large |
|
|
||||||
| USR-05 | Upload profile picture (invalid type) | .pdf file | 400, invalid file type |
|
|
||||||
| USR-06 | Profile picture CloudConvert fallback | CloudConvert disabled | 200, saved as original format |
|
|
||||||
| USR-07 | Check profile completed | user_id | 200, boolean |
|
|
||||||
| USR-08 | Update knowledge level | valid level | 200 |
|
|
||||||
| USR-09 | Get all users (admin) | admin token | 200, paginated list |
|
|
||||||
| USR-10 | Delete user | admin token, user_id | 200, user deleted |
|
|
||||||
| USR-11 | Search user by name/phone | search term | 200, results |
|
|
||||||
| USR-12 | Check phone/email exist | phone+email | 200, exists flags |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.3 Admin Management (P1)
|
|
||||||
|
|
||||||
| ID | Test Case | Input | Expected |
|
|
||||||
|----|-----------|-------|----------|
|
|
||||||
| ADM-01 | Create admin (SUPER_ADMIN) | valid admin data | 201 |
|
|
||||||
| ADM-02 | Create admin (ADMIN role) | valid data | 403, SuperAdminOnly |
|
|
||||||
| ADM-03 | Get all admins | SUPER_ADMIN token | 200, list |
|
|
||||||
| ADM-04 | Update admin | SUPER_ADMIN token | 200 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.4 Course Categories (P1)
|
|
||||||
|
|
||||||
| ID | Test Case | Input | Expected |
|
|
||||||
|----|-----------|-------|----------|
|
|
||||||
| CAT-01 | Create category | `{ "name": "Math" }` | 201, category returned |
|
|
||||||
| CAT-02 | Create category (empty name) | `{ "name": "" }` | 400 |
|
|
||||||
| CAT-03 | Get all categories (paginated) | limit=10, offset=0 | 200, list + total_count |
|
|
||||||
| CAT-04 | Get category by ID | valid ID | 200 |
|
|
||||||
| CAT-05 | Get category by ID (not found) | ID=999999 | 404 |
|
|
||||||
| CAT-06 | Update category | new name | 200 |
|
|
||||||
| CAT-07 | Delete category | valid ID | 200 |
|
|
||||||
| CAT-08 | Delete category (cascade) | category with courses | 200, courses also deleted |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.5 Courses (P1)
|
|
||||||
|
|
||||||
| ID | Test Case | Input | Expected |
|
|
||||||
|----|-----------|-------|----------|
|
|
||||||
| CRS-01 | Create course | category_id + title | 201, sends notifications to students |
|
|
||||||
| CRS-02 | Create course (invalid category) | nonexistent category_id | 500 (FK violation) |
|
|
||||||
| CRS-03 | Get course by ID | valid ID | 200, with thumbnail |
|
|
||||||
| CRS-04 | Get courses by category (paginated) | category_id, limit, offset | 200 |
|
|
||||||
| CRS-05 | Update course | title, description, thumbnail | 200, activity log recorded |
|
|
||||||
| CRS-06 | Upload course thumbnail (jpg→webp) | valid image | 200, stored as webp if CloudConvert enabled |
|
|
||||||
| CRS-07 | Upload course thumbnail (>10MB) | large file | 400 |
|
|
||||||
| CRS-08 | Delete course | valid ID | 200, cascades to sub-courses |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.6 Sub-Courses (P1)
|
|
||||||
|
|
||||||
| ID | Test Case | Input | Expected |
|
|
||||||
|----|-----------|-------|----------|
|
|
||||||
| SUB-01 | Create sub-course | course_id, title, level=BEGINNER | 201, notifications sent |
|
|
||||||
| SUB-02 | Create sub-course (invalid level) | level=EXPERT | 500 (CHECK constraint) |
|
|
||||||
| SUB-03 | List sub-courses by course | course_id | 200, list + count |
|
|
||||||
| SUB-04 | List active sub-courses | — | 200, only is_active=true |
|
|
||||||
| SUB-05 | Update sub-course | title, display_order | 200 |
|
|
||||||
| SUB-06 | Upload sub-course thumbnail | valid image | 200 |
|
|
||||||
| SUB-07 | Deactivate sub-course | valid ID | 200, is_active=false |
|
|
||||||
| SUB-08 | Delete sub-course | valid ID | 200, cascades to videos |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.7 Sub-Course Videos (P1)
|
|
||||||
|
|
||||||
| ID | Test Case | Input | Expected |
|
|
||||||
|----|-----------|-------|----------|
|
|
||||||
| VID-01 | Create video (direct URL) | video_url, title | 201, provider=DIRECT |
|
|
||||||
| VID-02 | Upload video file (with Vimeo) | file + Vimeo configured | 201, provider=VIMEO, status=DRAFT |
|
|
||||||
| VID-03 | Upload video (CloudConvert + Vimeo) | file + both configured | 201, video compressed then uploaded |
|
|
||||||
| VID-04 | Upload video (>500MB) | large file | 400 |
|
|
||||||
| VID-05 | Create video via Vimeo pull URL | source_url | 201 |
|
|
||||||
| VID-06 | Import from existing Vimeo ID | vimeo_video_id | 201, thumbnail from Vimeo |
|
|
||||||
| VID-07 | Publish video | video_id | 200, is_published=true |
|
|
||||||
| VID-08 | Get published videos by sub-course | sub_course_id | 200, only published |
|
|
||||||
| VID-09 | Update video metadata | title, description, status | 200 |
|
|
||||||
| VID-10 | Delete video | video_id | 200 |
|
|
||||||
| VID-11 | CloudConvert disabled fallback | upload without CC | 201, no compression step |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.8 Learning Tree (P2)
|
|
||||||
|
|
||||||
| ID | Test Case | Expected |
|
|
||||||
|----|-----------|----------|
|
|
||||||
| TREE-01 | Full learning tree (populated) | 200, nested courses → sub-courses |
|
|
||||||
| TREE-02 | Full learning tree (empty) | 200, empty array |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.9 Questions System (P2)
|
|
||||||
|
|
||||||
| ID | Test Case | Input | Expected |
|
|
||||||
|----|-----------|-------|----------|
|
|
||||||
| Q-01 | Create MCQ question | question_text, type=MCQ, options | 201 |
|
|
||||||
| Q-02 | Create TRUE_FALSE question | type=TRUE_FALSE | 201 |
|
|
||||||
| Q-03 | Create SHORT_ANSWER question | type=SHORT_ANSWER, acceptable answers | 201 |
|
|
||||||
| Q-04 | List questions (filtered) | type=MCQ, difficulty=EASY | 200, filtered list |
|
|
||||||
| Q-05 | Search questions | search query | 200, matching results |
|
|
||||||
| Q-06 | Update question with options | new options | 200, old options replaced |
|
|
||||||
| Q-07 | Delete question | question_id | 200, cascades options/answers |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.10 Question Sets (P2)
|
|
||||||
|
|
||||||
| ID | Test Case | Input | Expected |
|
|
||||||
|----|-----------|-------|----------|
|
|
||||||
| QS-01 | Create question set (PRACTICE) | type=PRACTICE, owner | 201 |
|
|
||||||
| QS-02 | Create question set (INITIAL_ASSESSMENT) | type=INITIAL_ASSESSMENT | 201 |
|
|
||||||
| QS-03 | Add question to set | set_id, question_id | 201 |
|
|
||||||
| QS-04 | Add duplicate question to set | same set_id+question_id | 409/error |
|
|
||||||
| QS-05 | Reorder question in set | display_order | 200 |
|
|
||||||
| QS-06 | Remove question from set | set_id, question_id | 200 |
|
|
||||||
| QS-07 | Get questions in set | set_id | 200, ordered list |
|
|
||||||
| QS-08 | Add user persona | set_id, user_id | 200 |
|
|
||||||
| QS-09 | Remove user persona | set_id, user_id | 200 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.11 Subscription Plans (P0)
|
|
||||||
|
|
||||||
| ID | Test Case | Input | Expected |
|
|
||||||
|----|-----------|-------|----------|
|
|
||||||
| PLAN-01 | Create plan (admin) | name, price, duration | 201 |
|
|
||||||
| PLAN-02 | List plans (public, no auth) | — | 200 |
|
|
||||||
| PLAN-03 | Get plan by ID (public) | plan_id | 200 |
|
|
||||||
| PLAN-04 | Update plan | new price | 200 |
|
|
||||||
| PLAN-05 | Delete plan | plan_id | 200 |
|
|
||||||
| PLAN-06 | Duration calculation (MONTH) | start + 3 MONTH | +3 months |
|
|
||||||
| PLAN-07 | Duration calculation (YEAR) | start + 1 YEAR | +1 year |
|
|
||||||
| PLAN-08 | Duration calculation (DAY) | start + 30 DAY | +30 days |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.12 User Subscriptions (P0)
|
|
||||||
|
|
||||||
| ID | Test Case | Input | Expected |
|
|
||||||
|----|-----------|-------|----------|
|
|
||||||
| SUBS-01 | Admin creates subscription (no payment) | user_id, plan_id | 201, status=ACTIVE |
|
|
||||||
| SUBS-02 | User subscribes with payment | plan_id | 201, status=PENDING, payment initiated |
|
|
||||||
| SUBS-03 | Get my subscription | auth token | 200, current active sub |
|
|
||||||
| SUBS-04 | Get subscription history | auth token | 200, all subs |
|
|
||||||
| SUBS-05 | Check subscription status | auth token | 200, active/expired |
|
|
||||||
| SUBS-06 | Cancel subscription | subscription_id | 200, status=CANCELLED |
|
|
||||||
| SUBS-07 | Set auto-renew on | subscription_id, true | 200 |
|
|
||||||
| SUBS-08 | Set auto-renew off | subscription_id, false | 200 |
|
|
||||||
| SUBS-09 | Expired subscription status check | expired sub | status=EXPIRED |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.13 Payments — ArifPay (P0)
|
|
||||||
|
|
||||||
| ID | Test Case | Input | Expected |
|
|
||||||
|----|-----------|-------|----------|
|
|
||||||
| PAY-01 | Initiate payment | plan_id, phone, email | 201, payment URL returned |
|
|
||||||
| PAY-02 | Verify payment (success) | valid session_id | 200, status=SUCCESS, subscription activated |
|
|
||||||
| PAY-03 | Verify payment (failed) | failed session | 200, status=FAILED |
|
|
||||||
| PAY-04 | Get my payments | auth token | 200, payment list |
|
|
||||||
| PAY-05 | Cancel payment | payment_id | 200, status=CANCELLED |
|
|
||||||
| PAY-06 | Webhook handler (valid) | valid ArifPay webhook | 200, payment updated |
|
|
||||||
| PAY-07 | Webhook handler (invalid signature) | tampered data | 400/401 |
|
|
||||||
| PAY-08 | Get payment methods (public) | — | 200, methods list |
|
|
||||||
| PAY-09 | Direct payment (OTP flow) | phone, method | 200, OTP sent |
|
|
||||||
| PAY-10 | Verify direct payment OTP | correct OTP | 200, payment confirmed |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.14 Ratings (P1)
|
|
||||||
|
|
||||||
| ID | Test Case | Input | Expected |
|
|
||||||
|----|-----------|-------|----------|
|
|
||||||
| RAT-01 | Rate app (5 stars) | target_type=app, target_id=0, stars=5 | 201 |
|
|
||||||
| RAT-02 | Rate course (3 stars + review) | target_type=course, target_id=1, stars=3, review="good" | 201 |
|
|
||||||
| RAT-03 | Rate sub-course | target_type=sub_course, target_id=1, stars=4 | 201 |
|
|
||||||
| RAT-04 | Update existing rating (upsert) | same user+target, new stars | 200, stars updated |
|
|
||||||
| RAT-05 | Invalid stars (0) | stars=0 | 400 |
|
|
||||||
| RAT-06 | Invalid stars (6) | stars=6 | 400 |
|
|
||||||
| RAT-07 | Invalid target_type | target_type=video | 400 |
|
|
||||||
| RAT-08 | Get my rating for target | target_type=course, target_id=1 | 200, my rating |
|
|
||||||
| RAT-09 | Get my rating (not rated yet) | — | 404 |
|
|
||||||
| RAT-10 | Get ratings by target (paginated) | target_type=course, limit=20 | 200, list |
|
|
||||||
| RAT-11 | Get rating summary | target_type=course, target_id=1 | 200, avg + count |
|
|
||||||
| RAT-12 | Get rating summary (no ratings) | unrated target | 200, avg=0, count=0 |
|
|
||||||
| RAT-13 | Get all my ratings | auth token | 200, list |
|
|
||||||
| RAT-14 | Delete own rating | rating_id | 200 |
|
|
||||||
| RAT-15 | Delete other user's rating | other's rating_id | fails (no rows) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.15 Notifications (P2)
|
|
||||||
|
|
||||||
| ID | Test Case | Input | Expected |
|
|
||||||
|----|-----------|-------|----------|
|
|
||||||
| NOT-01 | WebSocket connection | valid auth | 101 upgrade |
|
|
||||||
| NOT-02 | Get user notifications | auth token | 200, list |
|
|
||||||
| NOT-03 | Mark as read | notification_id | 200 |
|
|
||||||
| NOT-04 | Mark all as read | — | 200 |
|
|
||||||
| NOT-05 | Mark as unread | notification_id | 200 |
|
|
||||||
| NOT-06 | Count unread | — | 200, count |
|
|
||||||
| NOT-07 | Delete user notifications | — | 200 |
|
|
||||||
| NOT-08 | Auto-notify on course creation | create course | students receive in-app notification |
|
|
||||||
| NOT-09 | Auto-notify on sub-course creation | create sub-course | students notified |
|
|
||||||
| NOT-10 | Send SMS (AfroSMS) | phone + message | 200 |
|
|
||||||
| NOT-11 | Register device token (push) | device token | 200 |
|
|
||||||
| NOT-12 | Unregister device token | device token | 200 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.16 Issue Reporting (P2)
|
|
||||||
|
|
||||||
| ID | Test Case | Input | Expected |
|
|
||||||
|----|-----------|-------|----------|
|
|
||||||
| ISS-01 | Create issue | subject, description, type=bug | 201 |
|
|
||||||
| ISS-02 | Get my issues | auth token | 200 |
|
|
||||||
| ISS-03 | Get all issues (admin) | admin token | 200 |
|
|
||||||
| ISS-04 | Update issue status (admin) | status=resolved | 200, notifications sent |
|
|
||||||
| ISS-05 | Delete issue (admin) | issue_id | 200 |
|
|
||||||
| ISS-06 | Get user issues (admin) | user_id | 200 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.17 Team Management (P2)
|
|
||||||
|
|
||||||
| ID | Test Case | Input | Expected |
|
|
||||||
|----|-----------|-------|----------|
|
|
||||||
| TM-01 | Team member login | email + password | 200, tokens |
|
|
||||||
| TM-02 | Create team member (admin) | valid data, role=instructor | 201 |
|
|
||||||
| TM-03 | Create team member (duplicate email) | existing email | 409 |
|
|
||||||
| TM-04 | Get all team members (admin) | admin token | 200, list |
|
|
||||||
| TM-05 | Get team member stats (admin) | admin token | 200 |
|
|
||||||
| TM-06 | Update team member status | status=suspended | 200 |
|
|
||||||
| TM-07 | Delete team member (SUPER_ADMIN only) | super_admin token | 200 |
|
|
||||||
| TM-08 | Delete team member (ADMIN) | admin token | 403 |
|
|
||||||
| TM-09 | Change team member password | old+new password | 200 |
|
|
||||||
| TM-10 | Get my team profile | team auth token | 200 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.18 Vimeo Integration (P2)
|
|
||||||
|
|
||||||
| ID | Test Case | Input | Expected |
|
|
||||||
|----|-----------|-------|----------|
|
|
||||||
| VIM-01 | Get video info | vimeo_video_id | 200, video details |
|
|
||||||
| VIM-02 | Get embed code | vimeo_video_id | 200, iframe HTML |
|
|
||||||
| VIM-03 | Get transcode status | vimeo_video_id | 200, status |
|
|
||||||
| VIM-04 | Delete Vimeo video | vimeo_video_id | 200 |
|
|
||||||
| VIM-05 | Pull upload | source URL | 201 |
|
|
||||||
| VIM-06 | TUS upload | file size + title | 201, upload link |
|
|
||||||
| VIM-07 | OEmbed | Vimeo URL | 200, embed data |
|
|
||||||
| VIM-08 | Vimeo service disabled | no config | graceful handling |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.19 CloudConvert (P2)
|
|
||||||
|
|
||||||
| ID | Test Case | Input | Expected |
|
|
||||||
|----|-----------|-------|----------|
|
|
||||||
| CC-01 | Video compression job | video file | compressed mp4 returned |
|
|
||||||
| CC-02 | Image optimization job | jpg file | webp returned |
|
|
||||||
| CC-03 | Job timeout | slow job | error after 30min (video) / 5min (image) |
|
|
||||||
| CC-04 | Job failure | invalid file | error with task message |
|
|
||||||
| CC-05 | Service disabled | no config | nil service, callers skip |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.20 Activity Logs & Analytics (P3)
|
|
||||||
|
|
||||||
| ID | Test Case | Expected |
|
|
||||||
|----|-----------|----------|
|
|
||||||
| LOG-01 | Get activity logs (admin, filtered) | 200, filtered by action/resource/date |
|
|
||||||
| LOG-02 | Get activity log by ID | 200 |
|
|
||||||
| LOG-03 | Activity log recorded on course CRUD | log entry with actor, IP, UA |
|
|
||||||
| LOG-04 | Analytics dashboard (admin) | 200, dashboard data |
|
|
||||||
| LOG-05 | MongoDB logs endpoint | 200, log entries |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.21 Settings (P3)
|
|
||||||
|
|
||||||
| ID | Test Case | Expected |
|
|
||||||
|----|-----------|----------|
|
|
||||||
| SET-01 | Get settings (SUPER_ADMIN) | 200, settings list |
|
|
||||||
| SET-02 | Get settings (ADMIN) | 403 |
|
|
||||||
| SET-03 | Update settings | 200 |
|
|
||||||
| SET-04 | Get setting by key | 200 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.22 Assessment (P2)
|
|
||||||
|
|
||||||
| ID | Test Case | Expected |
|
|
||||||
|----|-----------|----------|
|
|
||||||
| ASM-01 | Create assessment question | 201 |
|
|
||||||
| ASM-02 | List assessment questions | 200 |
|
|
||||||
| ASM-03 | Get assessment question by ID | 200 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Cross-Cutting Concerns
|
|
||||||
|
|
||||||
### 4.1 Input Validation
|
|
||||||
|
|
||||||
| ID | Test | Expected |
|
|
||||||
|----|------|----------|
|
|
||||||
| VAL-01 | Empty required fields | 400 with field-level errors |
|
|
||||||
| VAL-02 | Invalid email format | 400 |
|
|
||||||
| VAL-03 | Negative IDs in path params | 400 |
|
|
||||||
| VAL-04 | Non-numeric path params (e.g., `/courses/abc`) | 400 |
|
|
||||||
| VAL-05 | Malformed JSON body | 400 |
|
|
||||||
| VAL-06 | Oversized request body (>500MB) | 413 |
|
|
||||||
|
|
||||||
### 4.2 Cascading Deletes
|
|
||||||
|
|
||||||
| ID | Test | Expected |
|
|
||||||
|----|------|----------|
|
|
||||||
| CAS-01 | Delete category → courses deleted | verified via get |
|
|
||||||
| CAS-02 | Delete course → sub-courses deleted | verified via get |
|
|
||||||
| CAS-03 | Delete sub-course → videos deleted | verified via get |
|
|
||||||
| CAS-04 | Delete user → ratings deleted | verified via get |
|
|
||||||
|
|
||||||
### 4.3 Concurrency / Race Conditions
|
|
||||||
|
|
||||||
| ID | Test | Expected |
|
|
||||||
|----|------|----------|
|
|
||||||
| RACE-01 | Two users rating same target simultaneously | both succeed, no duplicates |
|
|
||||||
| RACE-02 | Same user submitting duplicate rating | upsert, only one record |
|
|
||||||
| RACE-03 | Concurrent subscription creation for same user | one succeeds or both idempotent |
|
|
||||||
|
|
||||||
### 4.4 File Upload Security
|
|
||||||
|
|
||||||
| ID | Test | Expected |
|
|
||||||
|----|------|----------|
|
|
||||||
| SEC-01 | Upload with spoofed content-type header (exe as jpg) | 400 (content sniffing rejects) |
|
|
||||||
| SEC-02 | Path traversal in filename | UUID-renamed, no traversal |
|
|
||||||
| SEC-03 | Null bytes in filename | handled safely |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Integration Test Scenarios (E2E Flows)
|
|
||||||
|
|
||||||
### Flow 1: Student Registration → Subscription → Learning
|
|
||||||
```
|
|
||||||
1. POST /user/register → PENDING
|
|
||||||
2. POST /user/verify-otp → ACTIVE
|
|
||||||
3. POST /auth/customer-login → tokens
|
|
||||||
4. GET /subscription-plans → choose plan
|
|
||||||
5. POST /subscriptions/checkout → payment URL
|
|
||||||
6. POST /payments/webhook → payment SUCCESS
|
|
||||||
7. GET /subscriptions/status → ACTIVE
|
|
||||||
8. GET /course-management/learning-tree → courses
|
|
||||||
9. GET /sub-courses/:id/videos/published → watch
|
|
||||||
10. POST /ratings → rate course
|
|
||||||
```
|
|
||||||
|
|
||||||
### Flow 2: Admin Content Management
|
|
||||||
```
|
|
||||||
1. POST /auth/admin-login → tokens
|
|
||||||
2. POST /course-management/categories → create category
|
|
||||||
3. POST /course-management/courses → create course
|
|
||||||
4. POST /courses/:id/thumbnail → upload thumbnail
|
|
||||||
5. POST /course-management/sub-courses → create sub-course
|
|
||||||
6. POST /sub-courses/:id/thumbnail → upload thumbnail
|
|
||||||
7. POST /course-management/videos/upload → upload video (CloudConvert → Vimeo)
|
|
||||||
8. PUT /videos/:id/publish → publish
|
|
||||||
9. GET /activity-logs → verify all actions logged
|
|
||||||
```
|
|
||||||
|
|
||||||
### Flow 3: Issue Resolution
|
|
||||||
```
|
|
||||||
1. Student: POST /issues → bug report
|
|
||||||
2. Admin: GET /issues → see all
|
|
||||||
3. Admin: PATCH /issues/:id/status → resolved
|
|
||||||
4. Student: GET /notifications → sees status update
|
|
||||||
```
|
|
||||||
|
|
||||||
### Flow 4: Team Onboarding
|
|
||||||
```
|
|
||||||
1. SUPER_ADMIN: POST /team/members → create instructor
|
|
||||||
2. Instructor: POST /team/login → tokens
|
|
||||||
3. Instructor: GET /team/me → profile
|
|
||||||
4. SUPER_ADMIN: PATCH /team/members/:id/status → active
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Performance & Load Testing
|
|
||||||
|
|
||||||
| Test | Target | Threshold |
|
|
||||||
|------|--------|-----------|
|
|
||||||
| Login throughput | POST /auth/customer-login | <200ms p95, 100 RPS |
|
|
||||||
| Course listing | GET /categories/:id/courses | <100ms p95 |
|
|
||||||
| Video listing | GET /sub-courses/:id/videos | <100ms p95 |
|
|
||||||
| Rating summary | GET /ratings/summary | <50ms p95 |
|
|
||||||
| Notification list | GET /notifications | <100ms p95 |
|
|
||||||
| File upload (5MB image) | POST /user/:id/profile-picture | <5s (without CC) |
|
|
||||||
| Concurrent ratings | 50 users rating simultaneously | no errors, correct counts |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Test Environment Requirements
|
|
||||||
|
|
||||||
- **Test database:** Isolated PostgreSQL instance, migrated with all 17 migrations
|
|
||||||
- **External services:** Vimeo, CloudConvert, ArifPay, AfroSMS — stub/mock in tests
|
|
||||||
- **Test data seeding:** Create fixtures for users (each role), categories, courses, sub-courses, plans
|
|
||||||
- **Cleanup:** Each test suite truncates its tables or runs in a transaction with rollback
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Recommended Test Tooling
|
|
||||||
|
|
||||||
| Purpose | Tool |
|
|
||||||
|---------|------|
|
|
||||||
| Go tests | `testing` + `testify/assert` |
|
|
||||||
| HTTP testing | `net/http/httptest` or Fiber's `app.Test()` |
|
|
||||||
| DB integration | `testcontainers-go` (Postgres) |
|
|
||||||
| Mocking | `testify/mock` or `gomock` |
|
|
||||||
| API collection | Postman / Bruno (manual & CI) |
|
|
||||||
| Load testing | `k6` or `vegeta` |
|
|
||||||
| CI runner | GitHub Actions / Gitea Actions |
|
|
||||||
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
|
type: object
|
||||||
domain.Role:
|
domain.Role:
|
||||||
enum:
|
enum:
|
||||||
- SUPER_ADMIN
|
- super_admin
|
||||||
- ADMIN
|
- admin
|
||||||
- STUDENT
|
- student
|
||||||
- INSTRUCTOR
|
- instructor
|
||||||
- SUPPORT
|
- support
|
||||||
type: string
|
type: string
|
||||||
x-enum-varnames:
|
x-enum-varnames:
|
||||||
- RoleSuperAdmin
|
- RoleSuperAdmin
|
||||||
|
|
@ -699,23 +699,6 @@ definitions:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
handlers.ResetPasswordReq:
|
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
|
type: object
|
||||||
handlers.SearchUserByNameOrPhoneReq:
|
handlers.SearchUserByNameOrPhoneReq:
|
||||||
properties:
|
properties:
|
||||||
|
|
@ -844,22 +827,6 @@ definitions:
|
||||||
- category_id
|
- category_id
|
||||||
- title
|
- title
|
||||||
type: object
|
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:
|
handlers.createPlanReq:
|
||||||
properties:
|
properties:
|
||||||
currency:
|
currency:
|
||||||
|
|
@ -1091,39 +1058,6 @@ definitions:
|
||||||
- phone
|
- phone
|
||||||
- plan_id
|
- plan_id
|
||||||
type: object
|
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:
|
handlers.loginUserRes:
|
||||||
properties:
|
properties:
|
||||||
access_token:
|
access_token:
|
||||||
|
|
@ -1178,22 +1112,6 @@ definitions:
|
||||||
required:
|
required:
|
||||||
- acceptable_answer
|
- acceptable_answer
|
||||||
type: object
|
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:
|
handlers.subscribeReq:
|
||||||
properties:
|
properties:
|
||||||
payment_method:
|
payment_method:
|
||||||
|
|
@ -1263,18 +1181,6 @@ definitions:
|
||||||
title:
|
title:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
handlers.updateIssueStatusReq:
|
|
||||||
properties:
|
|
||||||
status:
|
|
||||||
enum:
|
|
||||||
- pending
|
|
||||||
- in_progress
|
|
||||||
- resolved
|
|
||||||
- rejected
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- status
|
|
||||||
type: object
|
|
||||||
handlers.updatePlanReq:
|
handlers.updatePlanReq:
|
||||||
properties:
|
properties:
|
||||||
currency:
|
currency:
|
||||||
|
|
@ -1807,89 +1713,6 @@ paths:
|
||||||
summary: Get user profile
|
summary: Get user profile
|
||||||
tags:
|
tags:
|
||||||
- user
|
- 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:
|
/api/v1/admin:
|
||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
|
|
@ -2562,40 +2385,6 @@ paths:
|
||||||
summary: Update course
|
summary: Update course
|
||||||
tags:
|
tags:
|
||||||
- courses
|
- 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:
|
/api/v1/course-management/learning-tree:
|
||||||
get:
|
get:
|
||||||
description: Returns the complete learning tree structure with courses and sub-courses
|
description: Returns the complete learning tree structure with courses and sub-courses
|
||||||
|
|
@ -2761,40 +2550,6 @@ paths:
|
||||||
summary: Deactivate sub-course
|
summary: Deactivate sub-course
|
||||||
tags:
|
tags:
|
||||||
- sub-courses
|
- 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:
|
/api/v1/course-management/sub-courses/{subCourseId}/videos:
|
||||||
get:
|
get:
|
||||||
description: Returns all videos under a specific sub-course
|
description: Returns all videos under a specific sub-course
|
||||||
|
|
@ -3010,74 +2765,6 @@ paths:
|
||||||
summary: Publish sub-course video
|
summary: Publish sub-course video
|
||||||
tags:
|
tags:
|
||||||
- sub-course-videos
|
- 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:
|
/api/v1/course-management/videos/vimeo:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
|
|
@ -3138,259 +2825,6 @@ paths:
|
||||||
summary: Create a sub-course video from existing Vimeo video
|
summary: Create a sub-course video from existing Vimeo video
|
||||||
tags:
|
tags:
|
||||||
- sub-course-videos
|
- 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:
|
/api/v1/logs:
|
||||||
get:
|
get:
|
||||||
description: Fetches application logs from MongoDB with pagination, level filtering,
|
description: Fetches application logs from MongoDB with pagination, level filtering,
|
||||||
|
|
@ -4323,187 +3757,6 @@ paths:
|
||||||
summary: Search questions
|
summary: Search questions
|
||||||
tags:
|
tags:
|
||||||
- questions
|
- 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:
|
/api/v1/sendSMS:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
|
|
|
||||||
|
|
@ -178,17 +178,6 @@ type QuestionShortAnswer struct {
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
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 {
|
type RefreshToken struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
UserID int64 `json:"user_id"`
|
UserID int64 `json:"user_id"`
|
||||||
|
|
|
||||||
|
|
@ -1,222 +0,0 @@
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
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,27 +271,3 @@ func (c *Client) CreateVideoCompressionJob(ctx context.Context) (*Job, error) {
|
||||||
|
|
||||||
return c.CreateJob(ctx, jobReq)
|
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)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
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,12 +29,6 @@ type CompressResult struct {
|
||||||
Filename string
|
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) {
|
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))
|
s.logger.Info("Creating CloudConvert compression job", zap.String("filename", filename), zap.Int64("original_size", fileSize))
|
||||||
|
|
||||||
|
|
@ -114,91 +108,3 @@ func (s *Service) CompressVideo(ctx context.Context, filename string, fileData i
|
||||||
Filename: exportFilename,
|
Filename: exportFilename,
|
||||||
}, nil
|
}, 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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
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,8 +4,6 @@ import (
|
||||||
dbgen "Yimaru-Backend/gen/db"
|
dbgen "Yimaru-Backend/gen/db"
|
||||||
"Yimaru-Backend/internal/config"
|
"Yimaru-Backend/internal/config"
|
||||||
activitylogservice "Yimaru-Backend/internal/services/activity_log"
|
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/arifpay"
|
||||||
"Yimaru-Backend/internal/services/assessment"
|
"Yimaru-Backend/internal/services/assessment"
|
||||||
"Yimaru-Backend/internal/services/authentication"
|
"Yimaru-Backend/internal/services/authentication"
|
||||||
|
|
@ -43,8 +41,6 @@ type App struct {
|
||||||
vimeoSvc *vimeoservice.Service
|
vimeoSvc *vimeoservice.Service
|
||||||
teamSvc *team.Service
|
teamSvc *team.Service
|
||||||
activityLogSvc *activitylogservice.Service
|
activityLogSvc *activitylogservice.Service
|
||||||
cloudConvertSvc *cloudconvertservice.Service
|
|
||||||
ratingSvc *ratingsservice.Service
|
|
||||||
fiber *fiber.App
|
fiber *fiber.App
|
||||||
recommendationSvc recommendation.RecommendationService
|
recommendationSvc recommendation.RecommendationService
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
|
|
@ -72,8 +68,6 @@ func NewApp(
|
||||||
vimeoSvc *vimeoservice.Service,
|
vimeoSvc *vimeoservice.Service,
|
||||||
teamSvc *team.Service,
|
teamSvc *team.Service,
|
||||||
activityLogSvc *activitylogservice.Service,
|
activityLogSvc *activitylogservice.Service,
|
||||||
cloudConvertSvc *cloudconvertservice.Service,
|
|
||||||
ratingSvc *ratingsservice.Service,
|
|
||||||
port int, validator *customvalidator.CustomValidator,
|
port int, validator *customvalidator.CustomValidator,
|
||||||
settingSvc *settings.Service,
|
settingSvc *settings.Service,
|
||||||
authSvc *authentication.Service,
|
authSvc *authentication.Service,
|
||||||
|
|
@ -112,9 +106,7 @@ func NewApp(
|
||||||
arifpaySvc: arifpaySvc,
|
arifpaySvc: arifpaySvc,
|
||||||
vimeoSvc: vimeoSvc,
|
vimeoSvc: vimeoSvc,
|
||||||
teamSvc: teamSvc,
|
teamSvc: teamSvc,
|
||||||
activityLogSvc: activityLogSvc,
|
activityLogSvc: activityLogSvc,
|
||||||
cloudConvertSvc: cloudConvertSvc,
|
|
||||||
ratingSvc: ratingSvc,
|
|
||||||
issueReportingSvc: issueReportingSvc,
|
issueReportingSvc: issueReportingSvc,
|
||||||
fiber: app,
|
fiber: app,
|
||||||
port: port,
|
port: port,
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,12 @@ package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"Yimaru-Backend/internal/domain"
|
"Yimaru-Backend/internal/domain"
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/google/uuid"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Course Category Handlers
|
// Course Category Handlers
|
||||||
|
|
@ -1698,201 +1691,6 @@ 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
|
// Helper function to map video to response
|
||||||
func mapVideoToResponse(video domain.SubCourseVideo) subCourseVideoRes {
|
func mapVideoToResponse(video domain.SubCourseVideo) subCourseVideoRes {
|
||||||
var publishDate *string
|
var publishDate *string
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,7 @@ import (
|
||||||
"Yimaru-Backend/internal/services/assessment"
|
"Yimaru-Backend/internal/services/assessment"
|
||||||
"Yimaru-Backend/internal/services/authentication"
|
"Yimaru-Backend/internal/services/authentication"
|
||||||
course_management "Yimaru-Backend/internal/services/course_management"
|
course_management "Yimaru-Backend/internal/services/course_management"
|
||||||
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
|
|
||||||
issuereporting "Yimaru-Backend/internal/services/issue_reporting"
|
issuereporting "Yimaru-Backend/internal/services/issue_reporting"
|
||||||
ratingsservice "Yimaru-Backend/internal/services/ratings"
|
|
||||||
notificationservice "Yimaru-Backend/internal/services/notification"
|
notificationservice "Yimaru-Backend/internal/services/notification"
|
||||||
"Yimaru-Backend/internal/services/questions"
|
"Yimaru-Backend/internal/services/questions"
|
||||||
"Yimaru-Backend/internal/services/recommendation"
|
"Yimaru-Backend/internal/services/recommendation"
|
||||||
|
|
@ -51,8 +49,6 @@ type Handler struct {
|
||||||
teamSvc *team.Service
|
teamSvc *team.Service
|
||||||
activityLogSvc *activitylogservice.Service
|
activityLogSvc *activitylogservice.Service
|
||||||
issueReportingSvc *issuereporting.Service
|
issueReportingSvc *issuereporting.Service
|
||||||
cloudConvertSvc *cloudconvertservice.Service
|
|
||||||
ratingSvc *ratingsservice.Service
|
|
||||||
jwtConfig jwtutil.JwtConfig
|
jwtConfig jwtutil.JwtConfig
|
||||||
validator *customvalidator.CustomValidator
|
validator *customvalidator.CustomValidator
|
||||||
Cfg *config.Config
|
Cfg *config.Config
|
||||||
|
|
@ -78,8 +74,6 @@ func New(
|
||||||
teamSvc *team.Service,
|
teamSvc *team.Service,
|
||||||
activityLogSvc *activitylogservice.Service,
|
activityLogSvc *activitylogservice.Service,
|
||||||
issueReportingSvc *issuereporting.Service,
|
issueReportingSvc *issuereporting.Service,
|
||||||
cloudConvertSvc *cloudconvertservice.Service,
|
|
||||||
ratingSvc *ratingsservice.Service,
|
|
||||||
jwtConfig jwtutil.JwtConfig,
|
jwtConfig jwtutil.JwtConfig,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
mongoLoggerSvc *zap.Logger,
|
mongoLoggerSvc *zap.Logger,
|
||||||
|
|
@ -103,8 +97,6 @@ func New(
|
||||||
teamSvc: teamSvc,
|
teamSvc: teamSvc,
|
||||||
activityLogSvc: activityLogSvc,
|
activityLogSvc: activityLogSvc,
|
||||||
issueReportingSvc: issueReportingSvc,
|
issueReportingSvc: issueReportingSvc,
|
||||||
cloudConvertSvc: cloudConvertSvc,
|
|
||||||
ratingSvc: ratingSvc,
|
|
||||||
jwtConfig: jwtConfig,
|
jwtConfig: jwtConfig,
|
||||||
Cfg: cfg,
|
Cfg: cfg,
|
||||||
mongoLoggerSvc: mongoLoggerSvc,
|
mongoLoggerSvc: mongoLoggerSvc,
|
||||||
|
|
|
||||||
|
|
@ -1,327 +0,0 @@
|
||||||
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,7 +5,6 @@ import (
|
||||||
"Yimaru-Backend/internal/services/authentication"
|
"Yimaru-Backend/internal/services/authentication"
|
||||||
jwtutil "Yimaru-Backend/internal/web_server/jwt"
|
jwtutil "Yimaru-Backend/internal/web_server/jwt"
|
||||||
"Yimaru-Backend/internal/web_server/response"
|
"Yimaru-Backend/internal/web_server/response"
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
@ -1707,28 +1706,6 @@ func (h *Handler) UploadProfilePicture(c *fiber.Ctx) error {
|
||||||
// Combine head + rest
|
// Combine head + rest
|
||||||
data := append(head[:n], 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"
|
ext := ".jpg"
|
||||||
switch contentType {
|
switch contentType {
|
||||||
case "image/png":
|
case "image/png":
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,6 @@ func (a *App) initAppRoutes() {
|
||||||
a.teamSvc,
|
a.teamSvc,
|
||||||
a.activityLogSvc,
|
a.activityLogSvc,
|
||||||
a.issueReportingSvc,
|
a.issueReportingSvc,
|
||||||
a.cloudConvertSvc,
|
|
||||||
a.ratingSvc,
|
|
||||||
a.JwtConfig,
|
a.JwtConfig,
|
||||||
a.cfg,
|
a.cfg,
|
||||||
a.mongoLoggerSvc,
|
a.mongoLoggerSvc,
|
||||||
|
|
@ -51,10 +49,38 @@ func (a *App) initAppRoutes() {
|
||||||
|
|
||||||
// Groups
|
// Groups
|
||||||
groupV1 := a.fiber.Group("/api/v1")
|
groupV1 := a.fiber.Group("/api/v1")
|
||||||
|
// tenant := groupV1.Group("/tenant/:tenant_slug", a.TenantMiddleware)
|
||||||
|
// groupV1.Get("/test", a.authMiddleware, a.authMiddleware, func(c *fiber.Ctx) error {
|
||||||
|
// fmt.Printf("\nTest Route %v\n", c.Route().Path)
|
||||||
|
// companyID := c.Locals("company_id").(domain.ValidInt64)
|
||||||
|
// if !companyID.Valid {
|
||||||
|
// h.BadRequestLogger().Error("invalid company id")
|
||||||
|
// return fiber.NewError(fiber.StatusBadRequest, "invalid company id")
|
||||||
|
// }
|
||||||
|
|
||||||
|
// fmt.Printf("In the tenant auth test \n")
|
||||||
|
// return c.JSON(fiber.Map{
|
||||||
|
// "message": "Is is fine",
|
||||||
|
// })
|
||||||
|
// })
|
||||||
|
// groupV1.Get("/", func(c *fiber.Ctx) error {
|
||||||
|
// fmt.Printf("\nTenant Route %v\n", c.Route().Path)
|
||||||
|
// companyID := c.Locals("company_id").(domain.ValidInt64)
|
||||||
|
// if !companyID.Valid {
|
||||||
|
// h.BadRequestLogger().Error("invalid company id")
|
||||||
|
// return fiber.NewError(fiber.StatusBadRequest, "invalid company id")
|
||||||
|
// }
|
||||||
|
// return c.JSON(fiber.Map{
|
||||||
|
// "message": "Company Tenant Active",
|
||||||
|
// })
|
||||||
|
// })
|
||||||
|
|
||||||
// Serve static files (profile pictures, etc.)
|
// Serve static files (profile pictures, etc.)
|
||||||
a.fiber.Static("/static", "./static")
|
a.fiber.Static("/static", "./static")
|
||||||
|
|
||||||
|
// Get S
|
||||||
|
groupV1.Get("/tenant", a.authMiddleware, h.GetTenantSlugByToken)
|
||||||
|
|
||||||
// Swagger
|
// Swagger
|
||||||
a.fiber.Get("/swagger/*", fiberSwagger.FiberWrapHandler())
|
a.fiber.Get("/swagger/*", fiberSwagger.FiberWrapHandler())
|
||||||
|
|
||||||
|
|
@ -72,6 +98,42 @@ func (a *App) initAppRoutes() {
|
||||||
// groupV1.Put("/assessment/questions/:id", h.UpdateAssessmentQuestion)
|
// groupV1.Put("/assessment/questions/:id", h.UpdateAssessmentQuestion)
|
||||||
// groupV1.Delete("/assessment/questions/:id", h.DeleteAssessmentQuestion)
|
// groupV1.Delete("/assessment/questions/:id", h.DeleteAssessmentQuestion)
|
||||||
|
|
||||||
|
// Start a new assessment attempt
|
||||||
|
// groupV1.Post(
|
||||||
|
// "/assessment/attempts",
|
||||||
|
// h.StartAssessmentAttempt,
|
||||||
|
// )
|
||||||
|
|
||||||
|
// // Submit or update an answer
|
||||||
|
// groupV1.Post(
|
||||||
|
// "/assessment/attempts/:attempt_id/answers",
|
||||||
|
// h.SubmitAssessmentAnswer,
|
||||||
|
// )
|
||||||
|
|
||||||
|
// // Final submission (locks answers)
|
||||||
|
// groupV1.Post(
|
||||||
|
// "/assessment/attempts/:attempt_id/submit",
|
||||||
|
// h.SubmitAssessmentAttempt,
|
||||||
|
// )
|
||||||
|
|
||||||
|
// // Get attempt details
|
||||||
|
// groupV1.Get(
|
||||||
|
// "/assessment/attempts/:attempt_id",
|
||||||
|
// h.GetAssessmentAttemptByID,
|
||||||
|
// )
|
||||||
|
|
||||||
|
// Get final result + answers
|
||||||
|
// groupV1.Get(
|
||||||
|
// "/assessment/attempts/:attempt_id/result",
|
||||||
|
// h.GetAssessmentResult,
|
||||||
|
// )
|
||||||
|
|
||||||
|
// // Evaluate attempt (admin / system)
|
||||||
|
// groupV1.Post(
|
||||||
|
// "/assessment/attempts/:attempt_id/evaluate",
|
||||||
|
// h.EvaluateAssessmentAttempt,
|
||||||
|
// )
|
||||||
|
|
||||||
// Course Management Routes
|
// Course Management Routes
|
||||||
|
|
||||||
// Course Categories
|
// Course Categories
|
||||||
|
|
@ -86,7 +148,6 @@ func (a *App) initAppRoutes() {
|
||||||
groupV1.Get("/course-management/courses/:id", a.authMiddleware, h.GetCourseByID)
|
groupV1.Get("/course-management/courses/:id", a.authMiddleware, h.GetCourseByID)
|
||||||
groupV1.Get("/course-management/categories/:categoryId/courses", a.authMiddleware, h.GetCoursesByCategory)
|
groupV1.Get("/course-management/categories/:categoryId/courses", a.authMiddleware, h.GetCoursesByCategory)
|
||||||
groupV1.Put("/course-management/courses/:id", a.authMiddleware, h.UpdateCourse)
|
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)
|
groupV1.Delete("/course-management/courses/:id", a.authMiddleware, h.DeleteCourse)
|
||||||
|
|
||||||
// Sub-courses
|
// Sub-courses
|
||||||
|
|
@ -96,7 +157,6 @@ func (a *App) initAppRoutes() {
|
||||||
groupV1.Get("/course-management/courses/:courseId/sub-courses/list", a.authMiddleware, h.ListSubCoursesByCourse)
|
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.Get("/course-management/sub-courses/active", a.authMiddleware, h.ListActiveSubCourses)
|
||||||
groupV1.Patch("/course-management/sub-courses/:id", a.authMiddleware, h.UpdateSubCourse)
|
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.Put("/course-management/sub-courses/:id/deactivate", a.authMiddleware, h.DeactivateSubCourse)
|
||||||
groupV1.Delete("/course-management/sub-courses/:id", a.authMiddleware, h.DeleteSubCourse)
|
groupV1.Delete("/course-management/sub-courses/:id", a.authMiddleware, h.DeleteSubCourse)
|
||||||
|
|
||||||
|
|
@ -249,6 +309,11 @@ func (a *App) initAppRoutes() {
|
||||||
groupV1.Post("/admin", a.authMiddleware, a.SuperAdminOnly, h.CreateAdmin)
|
groupV1.Post("/admin", a.authMiddleware, a.SuperAdminOnly, h.CreateAdmin)
|
||||||
groupV1.Put("/admin/:id", a.authMiddleware, a.SuperAdminOnly, h.UpdateAdmin)
|
groupV1.Put("/admin/:id", a.authMiddleware, a.SuperAdminOnly, h.UpdateAdmin)
|
||||||
|
|
||||||
|
// groupV1.Get("/t-approver", a.authMiddleware, a.OnlyAdminAndAbove, h.GetAllTransactionApprovers)
|
||||||
|
// groupV1.Get("/t-approver/:id", a.authMiddleware, a.OnlyAdminAndAbove, h.GetTransactionApproverByID)
|
||||||
|
// groupV1.Post("/t-approver", a.authMiddleware, a.OnlyAdminAndAbove, h.CreateTransactionApprover)
|
||||||
|
// groupV1.Put("/t-approver/:id", a.authMiddleware, a.OnlyAdminAndAbove, h.UpdateTransactionApprover)
|
||||||
|
|
||||||
//mongoDB logs
|
//mongoDB logs
|
||||||
groupV1.Get("/logs", a.authMiddleware, a.OnlyAdminAndAbove, handlers.GetLogsHandler(context.Background()))
|
groupV1.Get("/logs", a.authMiddleware, a.OnlyAdminAndAbove, handlers.GetLogsHandler(context.Background()))
|
||||||
|
|
||||||
|
|
@ -256,6 +321,11 @@ func (a *App) initAppRoutes() {
|
||||||
groupV1.Get("/activity-logs", a.authMiddleware, a.OnlyAdminAndAbove, h.GetActivityLogs)
|
groupV1.Get("/activity-logs", a.authMiddleware, a.OnlyAdminAndAbove, h.GetActivityLogs)
|
||||||
groupV1.Get("/activity-logs/:id", a.authMiddleware, a.OnlyAdminAndAbove, h.GetActivityLogByID)
|
groupV1.Get("/activity-logs/:id", a.authMiddleware, a.OnlyAdminAndAbove, h.GetActivityLogByID)
|
||||||
|
|
||||||
|
// groupV1.Get("/shop/transaction", a.authMiddleware, a.CompanyOnly, h.GetAllTransactions)
|
||||||
|
// groupV1.Get("/shop/transaction/:id", a.authMiddleware, a.CompanyOnly, h.GetTransactionByID)
|
||||||
|
// groupV1.Get("/shop/transaction/:id/bet", a.authMiddleware, a.CompanyOnly, h.GetShopBetByTransactionID)
|
||||||
|
// groupV1.Put("/shop/transaction/:id", a.authMiddleware, a.CompanyOnly, h.UpdateTransactionVerified)
|
||||||
|
|
||||||
// Notification Routes
|
// Notification Routes
|
||||||
groupV1.Post("/sendSMS", h.SendSingleAfroSMS)
|
groupV1.Post("/sendSMS", h.SendSingleAfroSMS)
|
||||||
groupV1.Get("/ws/connect", a.WebsocketAuthMiddleware, h.ConnectSocket)
|
groupV1.Get("/ws/connect", a.WebsocketAuthMiddleware, h.ConnectSocket)
|
||||||
|
|
@ -316,12 +386,4 @@ func (a *App) initAppRoutes() {
|
||||||
teamGroup.Delete("/members/:id", a.authMiddleware, a.SuperAdminOnly, h.DeleteTeamMember) // Delete team member
|
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
|
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