MinIO integration + speaking implementation adjustment
This commit is contained in:
parent
180e63e975
commit
800d2a4b3a
19
cmd/main.go
19
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,
|
||||
|
|
|
|||
2
db/migrations/000028_user_audio_responses.down.sql
Normal file
2
db/migrations/000028_user_audio_responses.down.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
DROP INDEX IF EXISTS idx_user_audio_responses_user_question;
|
||||
DROP TABLE IF EXISTS user_audio_responses;
|
||||
12
db/migrations/000028_user_audio_responses.up.sql
Normal file
12
db/migrations/000028_user_audio_responses.up.sql
Normal file
|
|
@ -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);
|
||||
21
db/query/user_audio_responses.sql
Normal file
21
db/query/user_audio_responses.sql
Normal file
|
|
@ -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;
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
303
docs/audio-practice-integration.md
Normal file
303
docs/audio-practice-integration.md
Normal file
|
|
@ -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=<object_key>
|
||||
```
|
||||
|
||||
**Frontend implementation:**
|
||||
|
||||
```html
|
||||
<audio controls src="<resolved_url>"></audio>
|
||||
```
|
||||
|
||||
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 |
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
124
gen/db/user_audio_responses.sql.go
Normal file
124
gen/db/user_audio_responses.sql.go
Normal file
|
|
@ -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
|
||||
}
|
||||
18
go.mod
18
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
|
||||
|
|
|
|||
29
go.sum
29
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=
|
||||
|
|
|
|||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
64
internal/pkgs/minio/client.go
Normal file
64
internal/pkgs/minio/client.go
Normal file
|
|
@ -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{})
|
||||
}
|
||||
73
internal/services/minio/service.go
Normal file
73
internal/services/minio/service.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
306
internal/web_server/handlers/file_handler.go
Normal file
306
internal/web_server/handlers/file_handler.go
Normal file
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -2056,6 +2056,19 @@ func (h *Handler) UploadProfilePicture(c *fiber.Ctx) error {
|
|||
ext = ".webp"
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -2074,8 +2087,8 @@ func (h *Handler) UploadProfilePicture(c *fiber.Ctx) error {
|
|||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
publicPath := "/static/profile_pictures/" + filename
|
||||
publicPath = "/static/profile_pictures/" + filename
|
||||
}
|
||||
|
||||
// Update user profile picture URL
|
||||
req := domain.UpdateUserReq{
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user