From 800d2a4b3ac42d19e6148d62d799fbcebc976bdf Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Thu, 12 Mar 2026 07:06:16 -0700 Subject: [PATCH] MinIO integration + speaking implementation adjustment --- cmd/main.go | 19 ++ .../000028_user_audio_responses.down.sql | 2 + .../000028_user_audio_responses.up.sql | 12 + db/query/user_audio_responses.sql | 21 ++ docker-compose.yml | 22 ++ docs/audio-practice-integration.md | 303 +++++++++++++++++ gen/db/models.go | 12 + gen/db/user.sql.go | 15 +- gen/db/user_audio_responses.sql.go | 124 +++++++ go.mod | 18 +- go.sum | 29 +- internal/config/config.go | 27 ++ internal/pkgs/minio/client.go | 64 ++++ internal/services/minio/service.go | 73 +++++ internal/web_server/app.go | 4 + .../web_server/handlers/course_management.go | 16 +- internal/web_server/handlers/file_handler.go | 306 ++++++++++++++++++ internal/web_server/handlers/handlers.go | 4 + .../handlers/notification_handler.go | 15 +- internal/web_server/handlers/user.go | 57 ++-- internal/web_server/routes.go | 6 + 21 files changed, 1109 insertions(+), 40 deletions(-) create mode 100644 db/migrations/000028_user_audio_responses.down.sql create mode 100644 db/migrations/000028_user_audio_responses.up.sql create mode 100644 db/query/user_audio_responses.sql create mode 100644 docs/audio-practice-integration.md create mode 100644 gen/db/user_audio_responses.sql.go create mode 100644 internal/pkgs/minio/client.go create mode 100644 internal/services/minio/service.go create mode 100644 internal/web_server/handlers/file_handler.go diff --git a/cmd/main.go b/cmd/main.go index d315b08..35bcc38 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -25,6 +25,8 @@ import ( "Yimaru-Backend/internal/services/team" activitylogservice "Yimaru-Backend/internal/services/activity_log" cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" + minioservice "Yimaru-Backend/internal/services/minio" + minioclient "Yimaru-Backend/internal/pkgs/minio" ratingsservice "Yimaru-Backend/internal/services/ratings" rbacservice "Yimaru-Backend/internal/services/rbac" vimeoservice "Yimaru-Backend/internal/services/vimeo" @@ -381,6 +383,22 @@ func main() { logger.Info("CloudConvert service disabled (CLOUDCONVERT_ENABLED not set or missing API key)") } + // MinIO service for file storage + var minioSvc *minioservice.Service + if cfg.MinIO.Enabled && cfg.MinIO.Endpoint != "" { + mc, err := minioclient.NewClient(cfg.MinIO.Endpoint, cfg.MinIO.AccessKey, cfg.MinIO.SecretKey, cfg.MinIO.UseSSL, cfg.MinIO.Bucket) + if err != nil { + log.Fatalf("failed to create MinIO client: %v", err) + } + minioSvc = minioservice.NewService(mc, domain.MongoDBLogger) + if err := minioSvc.Init(context.Background()); err != nil { + log.Fatalf("failed to initialize MinIO bucket: %v", err) + } + logger.Info("MinIO service initialized", "endpoint", cfg.MinIO.Endpoint, "bucket", cfg.MinIO.Bucket) + } else { + logger.Info("MinIO service disabled (MINIO_ENABLED not set or missing endpoint)") + } + // Questions service (unified questions system) questionsSvc := questions.NewService(store) @@ -433,6 +451,7 @@ func main() { teamSvc, activityLogSvc, ccSvc, + minioSvc, ratingSvc, cfg.Port, v, diff --git a/db/migrations/000028_user_audio_responses.down.sql b/db/migrations/000028_user_audio_responses.down.sql new file mode 100644 index 0000000..ee1436c --- /dev/null +++ b/db/migrations/000028_user_audio_responses.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_user_audio_responses_user_question; +DROP TABLE IF EXISTS user_audio_responses; diff --git a/db/migrations/000028_user_audio_responses.up.sql b/db/migrations/000028_user_audio_responses.up.sql new file mode 100644 index 0000000..ed77bb6 --- /dev/null +++ b/db/migrations/000028_user_audio_responses.up.sql @@ -0,0 +1,12 @@ +-- Store learner audio answer submissions for AUDIO-type questions +CREATE TABLE IF NOT EXISTS user_audio_responses ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + question_id BIGINT NOT NULL REFERENCES questions(id) ON DELETE CASCADE, + question_set_id BIGINT NOT NULL REFERENCES question_sets(id) ON DELETE CASCADE, + audio_object_key TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_user_audio_responses_user_question + ON user_audio_responses(user_id, question_id, question_set_id); diff --git a/db/query/user_audio_responses.sql b/db/query/user_audio_responses.sql new file mode 100644 index 0000000..1185040 --- /dev/null +++ b/db/query/user_audio_responses.sql @@ -0,0 +1,21 @@ +-- name: CreateUserAudioResponse :one +INSERT INTO user_audio_responses (user_id, question_id, question_set_id, audio_object_key) +VALUES ($1, $2, $3, $4) +RETURNING *; + +-- name: GetUserAudioResponse :one +SELECT * +FROM user_audio_responses +WHERE user_id = $1 AND question_id = $2 AND question_set_id = $3 +ORDER BY created_at DESC +LIMIT 1; + +-- name: ListUserAudioResponsesBySet :many +SELECT * +FROM user_audio_responses +WHERE user_id = $1 AND question_set_id = $2 +ORDER BY created_at DESC; + +-- name: DeleteUserAudioResponse :exec +DELETE FROM user_audio_responses +WHERE id = $1 AND user_id = $2; diff --git a/docker-compose.yml b/docker-compose.yml index 452423e..831bb74 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,6 +57,27 @@ services: timeout: 5s retries: 5 + minio: + container_name: yimaru-minio + image: minio/minio:latest + restart: always + ports: + - "9000:9000" + - "9001:9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + volumes: + - minio_data:/data + networks: + - app + command: server /data --console-address ":9001" + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 10s + timeout: 5s + retries: 5 + migrate: image: migrate/migrate volumes: @@ -111,3 +132,4 @@ volumes: postgres_data: mongo_data: pgadmin_data: + minio_data: diff --git a/docs/audio-practice-integration.md b/docs/audio-practice-integration.md new file mode 100644 index 0000000..7936ce9 --- /dev/null +++ b/docs/audio-practice-integration.md @@ -0,0 +1,303 @@ +# Audio Practice (Speaking Practice) — Admin Panel Integration Guide + +## Overview + +The Yimaru backend **fully supports** audio/speaking practices. This guide covers the steps needed to integrate the feature into the **admin panel frontend**. + +--- + +## Backend Status (✅ Already Complete) + +| Layer | File | What It Does | +|-------|------|--------------| +| Domain | `internal/domain/questions.go` | `QuestionTypeAudio = "AUDIO"` constant, `VoicePrompt`, `SampleAnswerVoicePrompt`, `AudioCorrectAnswerText` fields | +| Handler | `internal/web_server/handlers/questions.go` | `CreateQuestion` accepts `question_type: "AUDIO"` with audio-specific fields | +| Handler | `internal/web_server/handlers/file_handler.go` | `UploadAudio` (general audio upload) and `SubmitAudioAnswer` (learner recording) | +| Migration | `db/migrations/000022_audio_questions.up.sql` | `AUDIO` type constraint + `question_audio_answers` table | +| Migration | `db/migrations/000028_user_audio_responses.up.sql` | `user_audio_responses` table for learner recordings | +| SQL | `db/query/question_audio_answers.sql` | CRUD for correct answer text per audio question | +| SQL | `db/query/user_audio_responses.sql` | CRUD for learner audio submissions | +| Routes | `internal/web_server/routes.go` | `POST /files/audio`, `POST /questions/audio-answer` | + +--- + +## Admin Panel Frontend Steps + +### Step 1: Add "AUDIO" Option to the Question Type Selector + +When creating/editing a question, the admin should be able to select `AUDIO` as a question type alongside `MCQ`, `TRUE_FALSE`, and `SHORT_ANSWER`. + +--- + +### Step 2: Build the AUDIO Question Form + +When `question_type = "AUDIO"` is selected, render these fields: + +| Field | API Field | Type | Required | Description | +|-------|-----------|------|----------|-------------| +| Question Text | `question_text` | `string` | ✅ | The prompt/instruction text shown to the learner | +| Voice Prompt | `voice_prompt` | `string` (URL) | Optional | Audio file URL — the question read aloud or a listening prompt | +| Sample Answer Voice | `sample_answer_voice_prompt` | `string` (URL) | Optional | Audio URL of a model/reference answer | +| Correct Answer Text | `audio_correct_answer_text` | `string` | Optional | Expected textual answer (used for grading/comparison) | +| Image | `image_url` | `string` (URL) | Optional | Supporting image for the question | +| Explanation | `explanation` | `string` | Optional | Shown to the learner after answering | +| Tips | `tips` | `string` | Optional | Hints shown before/during the question | +| Difficulty Level | `difficulty_level` | `string` | Optional | `EASY`, `MEDIUM`, or `HARD` | +| Points | `points` | `int` | Optional | Score value (defaults to 1) | + +> **Note:** Hide the `options` and `short_answers` fields when AUDIO is selected — they are not used for this type. + +--- + +### Step 3: Build an Audio Uploader Component + +Create a reusable audio uploader used for both `voice_prompt` and `sample_answer_voice_prompt`. + +**API Call:** + +``` +POST /files/audio +Content-Type: multipart/form-data + +Form field: "file" — the audio file +``` + +**Constraints:** +- Max file size: **50 MB** +- Allowed formats: `mp3`, `wav`, `ogg`, `m4a`, `aac`, `webm`, `flac` + +**Response:** + +```json +{ + "message": "Audio file uploaded successfully", + "data": { + "object_key": "audio/1710000000_recording.mp3", + "url": "https://...", + "content_type": "audio/mpeg" + }, + "success": true +} +``` + +**Usage:** +1. Admin clicks "Upload Voice Prompt" → file picker opens +2. File is uploaded via `POST /files/audio` +3. Store the returned `object_key` as the value for `voice_prompt` or `sample_answer_voice_prompt` +4. Display the audio player preview (see Step 4) + +--- + +### Step 4: Build an Audio Player / Preview Component + +After uploading, let admins preview the audio. + +**To get a playable URL from an object key:** + +``` +GET /files/url?key= +``` + +**Frontend implementation:** + +```html + +``` + +This component should be shown: +- Next to the voice prompt upload field (after upload) +- Next to the sample answer voice prompt upload field (after upload) +- When viewing/editing an existing AUDIO question + +--- + +### Step 5: Create a Practice with Audio Questions + +The full admin workflow: + +#### 5.1 Create the Practice Set (Question Set) + +``` +POST /question-sets + +{ + "title": "Speaking Practice - Lesson 1", + "description": "Practice your pronunciation", + "set_type": "PRACTICE", + "owner_type": "SUB_COURSE", + "owner_id": 42, + "status": "DRAFT" +} +``` + +#### 5.2 Upload Audio Files + +``` +POST /files/audio → voice_prompt object_key +POST /files/audio → sample_answer_voice_prompt object_key +``` + +#### 5.3 Create AUDIO Questions + +``` +POST /questions + +{ + "question_text": "Listen and repeat the following phrase", + "question_type": "AUDIO", + "voice_prompt": "minio://audio/1710000000_prompt.mp3", + "sample_answer_voice_prompt": "minio://audio/1710000000_sample.mp3", + "audio_correct_answer_text": "Hello, how are you?", + "difficulty_level": "EASY", + "points": 10 +} +``` + +#### 5.4 Link Questions to the Practice Set + +``` +POST /question-sets/:setId/questions + +{ + "question_id": 123, + "display_order": 1 +} +``` + +#### 5.5 (Optional) Add Personas + +``` +POST /question-sets/:setId/personas + +{ + "user_id": 5, + "display_order": 1 +} +``` + +#### 5.6 (Optional) Reorder Practices in a Sub-course + +``` +PUT /course-management/practices/reorder + +{ + "sub_course_id": 42, + "ordered_ids": [10, 11, 12] +} +``` + +--- + +### Step 6: Display Audio Questions in the Question List + +When listing questions (`GET /questions?question_type=AUDIO`), show: + +- An 🔊 audio icon or "AUDIO" badge for the question type +- A mini audio player if `voice_prompt` is set +- The `audio_correct_answer_text` value (if present) + +When viewing a single question (`GET /questions/:id`), the response includes: + +```json +{ + "question_type": "AUDIO", + "voice_prompt": "minio://audio/...", + "sample_answer_voice_prompt": "minio://audio/...", + "audio_correct_answer_text": "Hello, how are you?" +} +``` + +--- + +### Step 7: (Optional) Admin Review of Learner Audio Submissions + +> **⚠️ Not yet built** — requires a new backend endpoint if needed. + +Currently, learner audio submissions are stored in `user_audio_responses` but there's no admin-facing endpoint to list/review them. + +**If this is needed, add:** + +1. **New SQL query** in `db/query/user_audio_responses.sql`: + + ```sql + -- name: ListAudioResponsesByQuestionSet :many + SELECT uar.*, u.first_name, u.last_name + FROM user_audio_responses uar + JOIN users u ON u.id = uar.user_id + WHERE uar.question_set_id = $1 + ORDER BY uar.created_at DESC + LIMIT $2 OFFSET $3; + ``` + +2. **New endpoint** in `routes.go`: + + ``` + GET /admin/question-sets/:setId/audio-responses + ``` + +3. **Admin UI**: A table showing learner name, audio player for their recording, timestamp, and the correct answer text for comparison. + +--- + +## API Flow Summary + +``` +┌─────────────────────────────────────────────────────────┐ +│ ADMIN CREATES PRACTICE │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ POST /question-sets │ +│ → Creates practice shell (set_type: "PRACTICE") │ +│ │ +│ POST /files/audio │ +│ → Uploads voice_prompt audio file │ +│ │ +│ POST /files/audio │ +│ → Uploads sample_answer_voice_prompt audio file │ +│ │ +│ POST /questions │ +│ → Creates AUDIO question with voice prompt URLs │ +│ and correct_answer_text │ +│ │ +│ POST /question-sets/:id/questions │ +│ → Links question to practice set │ +│ │ +└─────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────┐ +│ LEARNER COMPLETES PRACTICE │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ GET /files/url?key=... │ +│ → Streams voice prompt audio │ +│ │ +│ POST /questions/audio-answer │ +│ → Submits learner's audio recording │ +│ │ +│ POST /progress/practices/:id/complete │ +│ → Marks practice as completed │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## Required RBAC Permissions + +Ensure the admin role has these permissions: + +| Permission | Used By | +|------------|---------| +| `questions.create` | Creating AUDIO questions | +| `questions.update` | Editing AUDIO questions | +| `questions.list` | Listing questions (filter by AUDIO) | +| `questions.get` | Viewing a single question | +| `questions.delete` | Deleting questions | +| `question_sets.create` | Creating practice sets | +| `question_sets.update` | Updating practice sets | +| `question_set_items.add` | Adding questions to a set | +| `question_set_items.list` | Listing questions in a set | +| `question_set_items.remove` | Removing questions from a set | +| `question_set_items.update_order` | Reordering questions | +| `question_set_personas.add` | Adding personas | +| `practices.reorder` | Reordering practices in a sub-course | diff --git a/gen/db/models.go b/gen/db/models.go index 146dcf3..a86ef36 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -386,6 +386,18 @@ type User struct { GoogleID pgtype.Text `json:"google_id"` GoogleEmailVerified pgtype.Bool `json:"google_email_verified"` ProfileCompletionPercentage int16 `json:"profile_completion_percentage"` + DeletionRequestedAt pgtype.Timestamptz `json:"deletion_requested_at"` + DeletionScheduledAt pgtype.Timestamptz `json:"deletion_scheduled_at"` + DeletionCancelledAt pgtype.Timestamptz `json:"deletion_cancelled_at"` +} + +type UserAudioResponse struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + QuestionID int64 `json:"question_id"` + QuestionSetID int64 `json:"question_set_id"` + AudioObjectKey string `json:"audio_object_key"` + CreatedAt pgtype.Timestamptz `json:"created_at"` } type UserPracticeProgress struct { diff --git a/gen/db/user.sql.go b/gen/db/user.sql.go index a4b0c9d..68696d5 100644 --- a/gen/db/user.sql.go +++ b/gen/db/user.sql.go @@ -53,7 +53,7 @@ INSERT INTO users ( VALUES ( $1, $2, $3, $4, $5, $6, $7, true, $8 ) -RETURNING id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group, google_id, google_email_verified, profile_completion_percentage +RETURNING id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group, google_id, google_email_verified, profile_completion_percentage, deletion_requested_at, deletion_scheduled_at, deletion_cancelled_at ` type CreateGoogleUserParams struct { @@ -113,6 +113,9 @@ func (q *Queries) CreateGoogleUser(ctx context.Context, arg CreateGoogleUserPara &i.GoogleID, &i.GoogleEmailVerified, &i.ProfileCompletionPercentage, + &i.DeletionRequestedAt, + &i.DeletionScheduledAt, + &i.DeletionCancelledAt, ) return i, err } @@ -671,7 +674,7 @@ func (q *Queries) GetUserByEmailPhone(ctx context.Context, arg GetUserByEmailPho } const GetUserByGoogleID = `-- name: GetUserByGoogleID :one -SELECT id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group, google_id, google_email_verified, profile_completion_percentage +SELECT id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group, google_id, google_email_verified, profile_completion_percentage, deletion_requested_at, deletion_scheduled_at, deletion_cancelled_at FROM users WHERE google_id = $1 ` @@ -713,12 +716,15 @@ func (q *Queries) GetUserByGoogleID(ctx context.Context, googleID pgtype.Text) ( &i.GoogleID, &i.GoogleEmailVerified, &i.ProfileCompletionPercentage, + &i.DeletionRequestedAt, + &i.DeletionScheduledAt, + &i.DeletionCancelledAt, ) return i, err } const GetUserByID = `-- name: GetUserByID :one -SELECT id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group, google_id, google_email_verified, profile_completion_percentage +SELECT id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group, google_id, google_email_verified, profile_completion_percentage, deletion_requested_at, deletion_scheduled_at, deletion_cancelled_at FROM users WHERE id = $1 ` @@ -760,6 +766,9 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { &i.GoogleID, &i.GoogleEmailVerified, &i.ProfileCompletionPercentage, + &i.DeletionRequestedAt, + &i.DeletionScheduledAt, + &i.DeletionCancelledAt, ) return i, err } diff --git a/gen/db/user_audio_responses.sql.go b/gen/db/user_audio_responses.sql.go new file mode 100644 index 0000000..db3017f --- /dev/null +++ b/gen/db/user_audio_responses.sql.go @@ -0,0 +1,124 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: user_audio_responses.sql + +package dbgen + +import ( + "context" +) + +const CreateUserAudioResponse = `-- name: CreateUserAudioResponse :one +INSERT INTO user_audio_responses (user_id, question_id, question_set_id, audio_object_key) +VALUES ($1, $2, $3, $4) +RETURNING id, user_id, question_id, question_set_id, audio_object_key, created_at +` + +type CreateUserAudioResponseParams struct { + UserID int64 `json:"user_id"` + QuestionID int64 `json:"question_id"` + QuestionSetID int64 `json:"question_set_id"` + AudioObjectKey string `json:"audio_object_key"` +} + +func (q *Queries) CreateUserAudioResponse(ctx context.Context, arg CreateUserAudioResponseParams) (UserAudioResponse, error) { + row := q.db.QueryRow(ctx, CreateUserAudioResponse, + arg.UserID, + arg.QuestionID, + arg.QuestionSetID, + arg.AudioObjectKey, + ) + var i UserAudioResponse + err := row.Scan( + &i.ID, + &i.UserID, + &i.QuestionID, + &i.QuestionSetID, + &i.AudioObjectKey, + &i.CreatedAt, + ) + return i, err +} + +const DeleteUserAudioResponse = `-- name: DeleteUserAudioResponse :exec +DELETE FROM user_audio_responses +WHERE id = $1 AND user_id = $2 +` + +type DeleteUserAudioResponseParams struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` +} + +func (q *Queries) DeleteUserAudioResponse(ctx context.Context, arg DeleteUserAudioResponseParams) error { + _, err := q.db.Exec(ctx, DeleteUserAudioResponse, arg.ID, arg.UserID) + return err +} + +const GetUserAudioResponse = `-- name: GetUserAudioResponse :one +SELECT id, user_id, question_id, question_set_id, audio_object_key, created_at +FROM user_audio_responses +WHERE user_id = $1 AND question_id = $2 AND question_set_id = $3 +ORDER BY created_at DESC +LIMIT 1 +` + +type GetUserAudioResponseParams struct { + UserID int64 `json:"user_id"` + QuestionID int64 `json:"question_id"` + QuestionSetID int64 `json:"question_set_id"` +} + +func (q *Queries) GetUserAudioResponse(ctx context.Context, arg GetUserAudioResponseParams) (UserAudioResponse, error) { + row := q.db.QueryRow(ctx, GetUserAudioResponse, arg.UserID, arg.QuestionID, arg.QuestionSetID) + var i UserAudioResponse + err := row.Scan( + &i.ID, + &i.UserID, + &i.QuestionID, + &i.QuestionSetID, + &i.AudioObjectKey, + &i.CreatedAt, + ) + return i, err +} + +const ListUserAudioResponsesBySet = `-- name: ListUserAudioResponsesBySet :many +SELECT id, user_id, question_id, question_set_id, audio_object_key, created_at +FROM user_audio_responses +WHERE user_id = $1 AND question_set_id = $2 +ORDER BY created_at DESC +` + +type ListUserAudioResponsesBySetParams struct { + UserID int64 `json:"user_id"` + QuestionSetID int64 `json:"question_set_id"` +} + +func (q *Queries) ListUserAudioResponsesBySet(ctx context.Context, arg ListUserAudioResponsesBySetParams) ([]UserAudioResponse, error) { + rows, err := q.db.Query(ctx, ListUserAudioResponsesBySet, arg.UserID, arg.QuestionSetID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []UserAudioResponse + for rows.Next() { + var i UserAudioResponse + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.QuestionID, + &i.QuestionSetID, + &i.AudioObjectKey, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/go.mod b/go.mod index 782f5cf..4af199e 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,12 @@ module Yimaru-Backend -go 1.24.0 - -toolchain go1.24.11 +go 1.25 require ( firebase.google.com/go/v4 v4.19.0 github.com/go-playground/validator/v10 v10.29.0 github.com/joho/godotenv v1.5.1 + github.com/minio/minio-go/v7 v7.0.99 github.com/resend/resend-go/v2 v2.28.0 github.com/shopspring/decimal v1.4.0 github.com/swaggo/fiber-swagger v1.3.0 @@ -33,9 +32,11 @@ require ( github.com/MicahParks/keyfunc v1.9.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-ini/ini v1.67.0 // indirect github.com/go-jose/go-jose/v4 v4.1.2 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -44,8 +45,14 @@ require ( github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect + github.com/minio/crc64nvme v1.1.1 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/philhofer/fwd v1.2.0 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/rs/xid v1.6.0 // indirect github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect + github.com/tinylib/msgp v1.6.1 // indirect github.com/zeebo/errs v1.4.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect @@ -56,6 +63,7 @@ require ( go.opentelemetry.io/otel/sdk v1.39.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect @@ -85,7 +93,7 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/josharian/intern v1.0.0 // indirect - github.com/klauspost/cpuid/v2 v2.2.9 // indirect + github.com/klauspost/cpuid/v2 v2.2.11 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mailru/easyjson v0.7.6 // indirect github.com/montanaflynn/stats v0.7.1 // indirect @@ -115,7 +123,7 @@ require ( github.com/gorilla/websocket v1.5.3 // github.com/jackc/pgtype v1.14.4 github.com/jackc/pgx/v5 v5.7.6 - github.com/klauspost/compress v1.17.9 // indirect + github.com/klauspost/compress v1.18.2 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect diff --git a/go.sum b/go.sum index e690e42..8092531 100644 --- a/go.sum +++ b/go.sum @@ -63,6 +63,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= @@ -76,6 +78,8 @@ github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSw github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik= github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI= github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -144,10 +148,13 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= -github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= -github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= -github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= +github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -168,6 +175,12 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= +github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.0.99 h1:2vH/byrwUkIpFQFOilvTfaUpvAX3fEFhEzO+DR3DlCE= +github.com/minio/minio-go/v7 v7.0.99/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw= github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= @@ -176,6 +189,8 @@ github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJ github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs= github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= github.com/otiai10/mint v1.3.3/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -187,6 +202,8 @@ github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= @@ -215,6 +232,8 @@ github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe/go.mod h1:lKJPbtWzJ9J github.com/swaggo/swag v1.8.1/go.mod h1:ugemnJsPZm/kRwFUnzBlbHRd0JY9zE1M4F+uy2pAaPQ= github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= +github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= +github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= @@ -266,6 +285,8 @@ go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= diff --git a/internal/config/config.go b/internal/config/config.go index bb766f9..2cbcef7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -85,6 +85,15 @@ type CloudConvertConfig struct { Enabled bool `mapstructure:"cloudconvert_enabled"` } +type MinIOConfig struct { + Endpoint string `mapstructure:"minio_endpoint"` + AccessKey string `mapstructure:"minio_access_key"` + SecretKey string `mapstructure:"minio_secret_key"` + Bucket string `mapstructure:"minio_bucket"` + UseSSL bool `mapstructure:"minio_use_ssl"` + Enabled bool `mapstructure:"minio_enabled"` +} + type Config struct { GoogleOAuthClientID string GoogleOAuthClientSecret string @@ -92,6 +101,7 @@ type Config struct { AFROSMSConfig AFROSMSConfig `mapstructure:"afro_sms_config"` Vimeo VimeoConfig `mapstructure:"vimeo_config"` CloudConvert CloudConvertConfig `mapstructure:"cloudconvert_config"` + MinIO MinIOConfig `mapstructure:"minio_config"` APP_VERSION string FIXER_API_KEY string FIXER_BASE_URL string @@ -475,6 +485,23 @@ func (c *Config) loadEnv() error { } c.CloudConvert.APIKey = os.Getenv("CLOUDCONVERT_API_KEY") + // MinIO configuration + minioEnabled := os.Getenv("MINIO_ENABLED") + if minioEnabled == "true" || minioEnabled == "1" { + c.MinIO.Enabled = true + } + c.MinIO.Endpoint = os.Getenv("MINIO_ENDPOINT") + c.MinIO.AccessKey = os.Getenv("MINIO_ACCESS_KEY") + c.MinIO.SecretKey = os.Getenv("MINIO_SECRET_KEY") + c.MinIO.Bucket = os.Getenv("MINIO_BUCKET") + if c.MinIO.Bucket == "" { + c.MinIO.Bucket = "yimaru" + } + minioUseSSL := os.Getenv("MINIO_USE_SSL") + if minioUseSSL == "true" || minioUseSSL == "1" { + c.MinIO.UseSSL = true + } + // Two-phase account deletion purge worker configuration accountDeletionPurgeEnabled := strings.TrimSpace(os.Getenv("ACCOUNT_DELETION_PURGE_ENABLED")) if accountDeletionPurgeEnabled == "" { diff --git a/internal/pkgs/minio/client.go b/internal/pkgs/minio/client.go new file mode 100644 index 0000000..095e2a7 --- /dev/null +++ b/internal/pkgs/minio/client.go @@ -0,0 +1,64 @@ +package minio + +import ( + "context" + "io" + "time" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +type Client struct { + mc *minio.Client + bucketName string +} + +func NewClient(endpoint, accessKey, secretKey string, useSSL bool, bucketName string) (*Client, error) { + mc, err := minio.New(endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(accessKey, secretKey, ""), + Secure: useSSL, + }) + if err != nil { + return nil, err + } + + return &Client{ + mc: mc, + bucketName: bucketName, + }, nil +} + +// EnsureBucket creates the bucket if it doesn't exist. +func (c *Client) EnsureBucket(ctx context.Context) error { + exists, err := c.mc.BucketExists(ctx, c.bucketName) + if err != nil { + return err + } + if !exists { + return c.mc.MakeBucket(ctx, c.bucketName, minio.MakeBucketOptions{}) + } + return nil +} + +// UploadFile uploads a file to MinIO and returns the object key. +func (c *Client) UploadFile(ctx context.Context, objectKey string, reader io.Reader, fileSize int64, contentType string) error { + _, err := c.mc.PutObject(ctx, c.bucketName, objectKey, reader, fileSize, minio.PutObjectOptions{ + ContentType: contentType, + }) + return err +} + +// GetFileURL returns a presigned URL valid for the given duration. +func (c *Client) GetFileURL(ctx context.Context, objectKey string, expiry time.Duration) (string, error) { + u, err := c.mc.PresignedGetObject(ctx, c.bucketName, objectKey, expiry, nil) + if err != nil { + return "", err + } + return u.String(), nil +} + +// DeleteFile removes an object from the bucket. +func (c *Client) DeleteFile(ctx context.Context, objectKey string) error { + return c.mc.RemoveObject(ctx, c.bucketName, objectKey, minio.RemoveObjectOptions{}) +} diff --git a/internal/services/minio/service.go b/internal/services/minio/service.go new file mode 100644 index 0000000..057130f --- /dev/null +++ b/internal/services/minio/service.go @@ -0,0 +1,73 @@ +package minio + +import ( + minioclient "Yimaru-Backend/internal/pkgs/minio" + "context" + "fmt" + "io" + "path" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" +) + +type Service struct { + client *minioclient.Client + logger *zap.Logger +} + +func NewService(client *minioclient.Client, logger *zap.Logger) *Service { + return &Service{ + client: client, + logger: logger, + } +} + +// Init ensures the bucket exists. Should be called once at startup. +func (s *Service) Init(ctx context.Context) error { + return s.client.EnsureBucket(ctx) +} + +// UploadResult holds the result of an upload operation. +type UploadResult struct { + ObjectKey string +} + +// Upload stores a file in MinIO under the given subdirectory. +// It generates a unique filename and returns the object key. +func (s *Service) Upload(ctx context.Context, subDir string, filename string, reader io.Reader, fileSize int64, contentType string) (*UploadResult, error) { + ext := path.Ext(filename) + objectKey := subDir + "/" + uuid.New().String() + ext + + s.logger.Info("Uploading file to MinIO", + zap.String("object_key", objectKey), + zap.Int64("size", fileSize), + zap.String("content_type", contentType), + ) + + if err := s.client.UploadFile(ctx, objectKey, reader, fileSize, contentType); err != nil { + s.logger.Error("Failed to upload file to MinIO", + zap.String("object_key", objectKey), + zap.Error(err), + ) + return nil, fmt.Errorf("failed to upload to MinIO: %w", err) + } + + s.logger.Info("File uploaded to MinIO successfully", zap.String("object_key", objectKey)) + + return &UploadResult{ + ObjectKey: objectKey, + }, nil +} + +// GetURL returns a presigned URL for the given object key. +func (s *Service) GetURL(ctx context.Context, objectKey string, expiry time.Duration) (string, error) { + return s.client.GetFileURL(ctx, objectKey, expiry) +} + +// Delete removes a file from MinIO. +func (s *Service) Delete(ctx context.Context, objectKey string) error { + s.logger.Info("Deleting file from MinIO", zap.String("object_key", objectKey)) + return s.client.DeleteFile(ctx, objectKey) +} diff --git a/internal/web_server/app.go b/internal/web_server/app.go index a7e41fb..8fc3f15 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -9,6 +9,7 @@ import ( "Yimaru-Backend/internal/services/authentication" cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" "Yimaru-Backend/internal/services/course_management" + minioservice "Yimaru-Backend/internal/services/minio" issuereporting "Yimaru-Backend/internal/services/issue_reporting" notificationservice "Yimaru-Backend/internal/services/notification" "Yimaru-Backend/internal/services/questions" @@ -47,6 +48,7 @@ type App struct { teamSvc *team.Service activityLogSvc *activitylogservice.Service cloudConvertSvc *cloudconvertservice.Service + minioSvc *minioservice.Service ratingSvc *ratingsservice.Service fiber *fiber.App recommendationSvc recommendation.RecommendationService @@ -78,6 +80,7 @@ func NewApp( teamSvc *team.Service, activityLogSvc *activitylogservice.Service, cloudConvertSvc *cloudconvertservice.Service, + minioSvc *minioservice.Service, ratingSvc *ratingsservice.Service, port int, validator *customvalidator.CustomValidator, settingSvc *settings.Service, @@ -120,6 +123,7 @@ func NewApp( teamSvc: teamSvc, activityLogSvc: activityLogSvc, cloudConvertSvc: cloudConvertSvc, + minioSvc: minioSvc, ratingSvc: ratingSvc, issueReportingSvc: issueReportingSvc, fiber: app, diff --git a/internal/web_server/handlers/course_management.go b/internal/web_server/handlers/course_management.go index 12a47a6..4afb536 100644 --- a/internal/web_server/handlers/course_management.go +++ b/internal/web_server/handlers/course_management.go @@ -2152,7 +2152,7 @@ func (h *Handler) UploadCourseThumbnail(c *fiber.Ctx) error { return c.Status(fiber.StatusOK).JSON(domain.Response{ Message: "Course thumbnail uploaded successfully", - Data: map[string]string{"thumbnail_url": publicPath}, + Data: map[string]string{"thumbnail_url": h.resolveFileURL(c, publicPath)}, Success: true, }) } @@ -2201,7 +2201,7 @@ func (h *Handler) UploadSubCourseThumbnail(c *fiber.Ctx) error { return c.Status(fiber.StatusOK).JSON(domain.Response{ Message: "Sub-course thumbnail uploaded successfully", - Data: map[string]string{"thumbnail_url": publicPath}, + Data: map[string]string{"thumbnail_url": h.resolveFileURL(c, publicPath)}, Success: true, }) } @@ -2282,6 +2282,18 @@ func (h *Handler) processAndSaveThumbnail(c *fiber.Ctx, subDir string) (string, ext = ".webp" } + // Upload to MinIO if available, otherwise save locally + if h.minioSvc != nil { + result, uploadErr := h.minioSvc.Upload(c.Context(), subDir, fileHeader.Filename, bytes.NewReader(data), int64(len(data)), contentType) + if uploadErr != nil { + return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to upload file to storage", + Error: uploadErr.Error(), + }) + } + return "minio://" + result.ObjectKey, nil + } + dir := filepath.Join(".", "static", subDir) if err := os.MkdirAll(dir, 0o755); err != nil { return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ diff --git a/internal/web_server/handlers/file_handler.go b/internal/web_server/handlers/file_handler.go new file mode 100644 index 0000000..0ff0588 --- /dev/null +++ b/internal/web_server/handlers/file_handler.go @@ -0,0 +1,306 @@ +package handlers + +import ( + dbgen "Yimaru-Backend/gen/db" + "Yimaru-Backend/internal/domain" + "bytes" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gofiber/fiber/v2" +) + +// resolveFileURL converts a stored file path to a usable URL. +// If the path starts with "minio://", it generates a presigned URL. +// Otherwise it returns the path as-is (e.g. "/static/..."). +func (h *Handler) resolveFileURL(c *fiber.Ctx, storedPath string) string { + if h.minioSvc == nil || !strings.HasPrefix(storedPath, "minio://") { + return storedPath + } + objectKey := strings.TrimPrefix(storedPath, "minio://") + url, err := h.minioSvc.GetURL(c.Context(), objectKey, 1*time.Hour) + if err != nil { + return storedPath + } + return url +} + +// GetFileURL resolves a MinIO object key to a presigned download URL. +// @Summary Get presigned URL for a file +// @Tags files +// @Param key query string true "MinIO object key (e.g. profile_pictures/uuid.jpg)" +// @Success 200 {object} domain.Response +// @Router /api/v1/files/url [get] +func (h *Handler) GetFileURL(c *fiber.Ctx) error { + key := strings.TrimSpace(c.Query("key")) + if key == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Missing 'key' query parameter", + }) + } + + if h.minioSvc == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{ + Message: "File storage service is not available", + }) + } + + url, err := h.minioSvc.GetURL(c.Context(), key, 1*time.Hour) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to generate file URL", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "File URL generated", + Data: map[string]string{"url": url}, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// UploadAudio uploads an audio file to MinIO and returns the object key. +// @Summary Upload an audio file +// @Tags files +// @Accept multipart/form-data +// @Param file formance file true "Audio file (mp3, wav, ogg, m4a, aac, webm)" +// @Success 200 {object} domain.Response +// @Router /api/v1/files/audio [post] +func (h *Handler) UploadAudio(c *fiber.Ctx) error { + if h.minioSvc == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{ + Message: "File storage service is not available", + }) + } + + fileHeader, err := c.FormFile("file") + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Audio file is required", + Error: err.Error(), + }) + } + + const maxSize = 50 * 1024 * 1024 // 50 MB + if fileHeader.Size > maxSize { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "File too large", + Error: "Audio file must be <= 50MB", + }) + } + + 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]) + + allowedTypes := map[string]bool{ + "audio/mpeg": true, // mp3 + "audio/wav": true, + "audio/ogg": true, + "audio/mp4": true, // m4a + "audio/aac": true, + "audio/webm": true, + "video/ogg": true, // ogg sometimes detected as video/ogg + "video/webm": true, // webm audio sometimes detected as video/webm + "audio/x-wav": true, + "audio/x-m4a": true, + "audio/flac": true, + } + + // DetectContentType may return "application/octet-stream" for some audio formats. + // In that case, fall back to extension-based detection. + if contentType == "application/octet-stream" { + ext := strings.ToLower(strings.TrimLeft(strings.ToLower(fileHeader.Filename[strings.LastIndex(fileHeader.Filename, "."):]), ".")) + extMap := map[string]string{ + "mp3": "audio/mpeg", + "wav": "audio/wav", + "ogg": "audio/ogg", + "m4a": "audio/mp4", + "aac": "audio/aac", + "webm": "audio/webm", + "flac": "audio/flac", + } + if ct, ok := extMap[ext]; ok { + contentType = ct + } + } + + if !allowedTypes[contentType] { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid file type", + Error: "Only audio files are allowed (mp3, wav, ogg, m4a, aac, webm, flac)", + }) + } + + 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...) + + result, err := h.minioSvc.Upload(c.Context(), "audio", fileHeader.Filename, bytes.NewReader(data), int64(len(data)), contentType) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to upload audio file", + Error: err.Error(), + }) + } + + storedPath := "minio://" + result.ObjectKey + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Audio file uploaded successfully", + Data: map[string]string{ + "object_key": result.ObjectKey, + "url": h.resolveFileURL(c, storedPath), + "content_type": contentType, + }, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// SubmitAudioAnswer allows a learner to upload an audio recording as their answer +// to an AUDIO-type question within a question set (practice). +// @Summary Submit audio answer for a question +// @Tags questions +// @Accept multipart/form-data +// @Param question_id formData int true "Question ID" +// @Param question_set_id formData int true "Question Set ID" +// @Param file formData file true "Audio recording" +// @Success 200 {object} domain.Response +// @Router /api/v1/questions/audio-answer [post] +func (h *Handler) SubmitAudioAnswer(c *fiber.Ctx) error { + if h.minioSvc == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{ + Message: "File storage service is not available", + }) + } + + userID, ok := c.Locals("user_id").(int64) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ + Message: "Unauthorized", + }) + } + + questionID, err := strconv.ParseInt(c.FormValue("question_id"), 10, 64) + if err != nil || questionID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid question_id", + }) + } + + questionSetID, err := strconv.ParseInt(c.FormValue("question_set_id"), 10, 64) + if err != nil || questionSetID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid question_set_id", + }) + } + + fileHeader, err := c.FormFile("file") + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Audio file is required", + Error: err.Error(), + }) + } + + const maxSize = 50 * 1024 * 1024 // 50 MB + if fileHeader.Size > maxSize { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "File too large", + Error: "Audio file must be <= 50MB", + }) + } + + 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]) + + // Fallback for audio formats that DetectContentType can't identify + if contentType == "application/octet-stream" { + dotIdx := strings.LastIndex(fileHeader.Filename, ".") + if dotIdx >= 0 { + ext := strings.ToLower(fileHeader.Filename[dotIdx+1:]) + extMap := map[string]string{ + "mp3": "audio/mpeg", "wav": "audio/wav", "ogg": "audio/ogg", + "m4a": "audio/mp4", "aac": "audio/aac", "webm": "audio/webm", "flac": "audio/flac", + } + if ct, ok := extMap[ext]; ok { + contentType = ct + } + } + } + + 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...) + + uploadResult, err := h.minioSvc.Upload(c.Context(), "audio_answers", fileHeader.Filename, bytes.NewReader(data), int64(len(data)), contentType) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to upload audio answer", + Error: err.Error(), + }) + } + + row, err := h.analyticsDB.CreateUserAudioResponse(c.Context(), dbgen.CreateUserAudioResponseParams{ + UserID: userID, + QuestionID: questionID, + QuestionSetID: questionSetID, + AudioObjectKey: uploadResult.ObjectKey, + }) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to save audio answer", + Error: err.Error(), + }) + } + + audioURL := h.resolveFileURL(c, "minio://"+uploadResult.ObjectKey) + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Audio answer submitted successfully", + Data: map[string]interface{}{ + "id": row.ID, + "question_id": row.QuestionID, + "question_set_id": row.QuestionSetID, + "audio_url": audioURL, + "created_at": row.CreatedAt, + }, + Success: true, + StatusCode: fiber.StatusOK, + }) +} diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index 03c5bed..fadc717 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -13,6 +13,7 @@ import ( course_management "Yimaru-Backend/internal/services/course_management" cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" issuereporting "Yimaru-Backend/internal/services/issue_reporting" + minioservice "Yimaru-Backend/internal/services/minio" ratingsservice "Yimaru-Backend/internal/services/ratings" rbacservice "Yimaru-Backend/internal/services/rbac" notificationservice "Yimaru-Backend/internal/services/notification" @@ -53,6 +54,7 @@ type Handler struct { activityLogSvc *activitylogservice.Service issueReportingSvc *issuereporting.Service cloudConvertSvc *cloudconvertservice.Service + minioSvc *minioservice.Service ratingSvc *ratingsservice.Service rbacSvc *rbacservice.Service jwtConfig jwtutil.JwtConfig @@ -81,6 +83,7 @@ func New( activityLogSvc *activitylogservice.Service, issueReportingSvc *issuereporting.Service, cloudConvertSvc *cloudconvertservice.Service, + minioSvc *minioservice.Service, ratingSvc *ratingsservice.Service, rbacSvc *rbacservice.Service, jwtConfig jwtutil.JwtConfig, @@ -107,6 +110,7 @@ func New( activityLogSvc: activityLogSvc, issueReportingSvc: issueReportingSvc, cloudConvertSvc: cloudConvertSvc, + minioSvc: minioSvc, ratingSvc: ratingSvc, rbacSvc: rbacSvc, jwtConfig: jwtConfig, diff --git a/internal/web_server/handlers/notification_handler.go b/internal/web_server/handlers/notification_handler.go index a764688..410666f 100644 --- a/internal/web_server/handlers/notification_handler.go +++ b/internal/web_server/handlers/notification_handler.go @@ -11,6 +11,7 @@ import ( "net" "net/http" "strconv" + "strings" "time" "github.com/gofiber/fiber/v2" @@ -742,7 +743,12 @@ func (h *Handler) SendTestPushNotification(c *fiber.Ctx) error { if saveErr != nil { return saveErr } - imageURL = c.BaseURL() + savedPath + resolved := h.resolveFileURL(c, savedPath) + if strings.HasPrefix(resolved, "/") { + imageURL = c.BaseURL() + resolved + } else { + imageURL = resolved + } } // Create test notification @@ -904,7 +910,12 @@ func (h *Handler) SendBulkPushNotification(c *fiber.Ctx) error { if saveErr != nil { return saveErr } - imageURL = c.BaseURL() + savedPath + resolved := h.resolveFileURL(c, savedPath) + if strings.HasPrefix(resolved, "/") { + imageURL = c.BaseURL() + resolved + } else { + imageURL = resolved + } } notification := &domain.Notification{ diff --git a/internal/web_server/handlers/user.go b/internal/web_server/handlers/user.go index 60da1cd..b7a206a 100644 --- a/internal/web_server/handlers/user.go +++ b/internal/web_server/handlers/user.go @@ -2056,27 +2056,40 @@ func (h *Handler) UploadProfilePicture(c *fiber.Ctx) error { ext = ".webp" } - // Ensure directory exists - dir := filepath.Join(".", "static", "profile_pictures") - 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(), - }) + var publicPath string + + // Upload to MinIO if available, otherwise save locally + if h.minioSvc != nil { + result, uploadErr := h.minioSvc.Upload(c.Context(), "profile_pictures", fileHeader.Filename, bytes.NewReader(data), int64(len(data)), contentType) + if uploadErr != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to upload file to storage", + Error: uploadErr.Error(), + }) + } + publicPath = "minio://" + result.ObjectKey + } else { + // Ensure directory exists + dir := filepath.Join(".", "static", "profile_pictures") + 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(), + }) + } + publicPath = "/static/profile_pictures/" + filename } - 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(), - }) - } - - publicPath := "/static/profile_pictures/" + filename - // Update user profile picture URL req := domain.UpdateUserReq{ UserID: userID, @@ -2084,8 +2097,6 @@ func (h *Handler) UploadProfilePicture(c *fiber.Ctx) error { } if err := h.userSvc.UpdateUser(c.Context(), req); err != nil { - // Attempt to remove file on failure - _ = os.Remove(fullpath) h.mongoLoggerSvc.Error("Failed to update user with profile picture", zap.Int64("user_id", userID), zap.Error(err), @@ -2099,10 +2110,8 @@ func (h *Handler) UploadProfilePicture(c *fiber.Ctx) error { return c.Status(fiber.StatusOK).JSON(domain.Response{ Message: "Profile picture uploaded successfully", - Data: map[string]string{"profile_picture_url": publicPath}, + Data: map[string]string{"profile_picture_url": h.resolveFileURL(c, publicPath)}, Success: true, StatusCode: fiber.StatusOK, }) - - // return response.WriteJSON(c, fiber.StatusOK, "Profile picture uploaded successfully", map[string]string{"profile_picture_url": publicPath}, nil) } diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index a4d9ce5..b5ad504 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -31,6 +31,7 @@ func (a *App) initAppRoutes() { a.activityLogSvc, a.issueReportingSvc, a.cloudConvertSvc, + a.minioSvc, a.ratingSvc, a.rbacSvc, a.JwtConfig, @@ -66,6 +67,11 @@ func (a *App) initAppRoutes() { }) }) + // File storage (MinIO) + groupV1.Get("/files/url", a.authMiddleware, h.GetFileURL) + groupV1.Post("/files/audio", a.authMiddleware, h.UploadAudio) + groupV1.Post("/questions/audio-answer", a.authMiddleware, h.SubmitAudioAnswer) + // Assessment questions (public) groupV1.Post("/assessment/questions", h.CreateAssessmentQuestion) groupV1.Get("/assessment/questions", h.ListAssessmentQuestions)