MinIO integration + speaking implementation adjustment

This commit is contained in:
Yared Yemane 2026-03-12 07:06:16 -07:00
parent 180e63e975
commit 800d2a4b3a
21 changed files with 1109 additions and 40 deletions

View File

@ -25,6 +25,8 @@ import (
"Yimaru-Backend/internal/services/team" "Yimaru-Backend/internal/services/team"
activitylogservice "Yimaru-Backend/internal/services/activity_log" activitylogservice "Yimaru-Backend/internal/services/activity_log"
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
minioservice "Yimaru-Backend/internal/services/minio"
minioclient "Yimaru-Backend/internal/pkgs/minio"
ratingsservice "Yimaru-Backend/internal/services/ratings" ratingsservice "Yimaru-Backend/internal/services/ratings"
rbacservice "Yimaru-Backend/internal/services/rbac" rbacservice "Yimaru-Backend/internal/services/rbac"
vimeoservice "Yimaru-Backend/internal/services/vimeo" 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)") 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) // Questions service (unified questions system)
questionsSvc := questions.NewService(store) questionsSvc := questions.NewService(store)
@ -433,6 +451,7 @@ func main() {
teamSvc, teamSvc,
activityLogSvc, activityLogSvc,
ccSvc, ccSvc,
minioSvc,
ratingSvc, ratingSvc,
cfg.Port, cfg.Port,
v, v,

View File

@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_user_audio_responses_user_question;
DROP TABLE IF EXISTS user_audio_responses;

View 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);

View 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;

View File

@ -57,6 +57,27 @@ services:
timeout: 5s timeout: 5s
retries: 5 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: migrate:
image: migrate/migrate image: migrate/migrate
volumes: volumes:
@ -111,3 +132,4 @@ volumes:
postgres_data: postgres_data:
mongo_data: mongo_data:
pgadmin_data: pgadmin_data:
minio_data:

View 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 |

View File

@ -386,6 +386,18 @@ type User struct {
GoogleID pgtype.Text `json:"google_id"` GoogleID pgtype.Text `json:"google_id"`
GoogleEmailVerified pgtype.Bool `json:"google_email_verified"` GoogleEmailVerified pgtype.Bool `json:"google_email_verified"`
ProfileCompletionPercentage int16 `json:"profile_completion_percentage"` 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 { type UserPracticeProgress struct {

View File

@ -53,7 +53,7 @@ INSERT INTO users (
VALUES ( VALUES (
$1, $2, $3, $4, $5, $6, $7, true, $8 $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 { type CreateGoogleUserParams struct {
@ -113,6 +113,9 @@ func (q *Queries) CreateGoogleUser(ctx context.Context, arg CreateGoogleUserPara
&i.GoogleID, &i.GoogleID,
&i.GoogleEmailVerified, &i.GoogleEmailVerified,
&i.ProfileCompletionPercentage, &i.ProfileCompletionPercentage,
&i.DeletionRequestedAt,
&i.DeletionScheduledAt,
&i.DeletionCancelledAt,
) )
return i, err return i, err
} }
@ -671,7 +674,7 @@ func (q *Queries) GetUserByEmailPhone(ctx context.Context, arg GetUserByEmailPho
} }
const GetUserByGoogleID = `-- name: GetUserByGoogleID :one 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 FROM users
WHERE google_id = $1 WHERE google_id = $1
` `
@ -713,12 +716,15 @@ func (q *Queries) GetUserByGoogleID(ctx context.Context, googleID pgtype.Text) (
&i.GoogleID, &i.GoogleID,
&i.GoogleEmailVerified, &i.GoogleEmailVerified,
&i.ProfileCompletionPercentage, &i.ProfileCompletionPercentage,
&i.DeletionRequestedAt,
&i.DeletionScheduledAt,
&i.DeletionCancelledAt,
) )
return i, err return i, err
} }
const GetUserByID = `-- name: GetUserByID :one 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 FROM users
WHERE id = $1 WHERE id = $1
` `
@ -760,6 +766,9 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
&i.GoogleID, &i.GoogleID,
&i.GoogleEmailVerified, &i.GoogleEmailVerified,
&i.ProfileCompletionPercentage, &i.ProfileCompletionPercentage,
&i.DeletionRequestedAt,
&i.DeletionScheduledAt,
&i.DeletionCancelledAt,
) )
return i, err return i, err
} }

View 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
View File

@ -1,13 +1,12 @@
module Yimaru-Backend module Yimaru-Backend
go 1.24.0 go 1.25
toolchain go1.24.11
require ( require (
firebase.google.com/go/v4 v4.19.0 firebase.google.com/go/v4 v4.19.0
github.com/go-playground/validator/v10 v10.29.0 github.com/go-playground/validator/v10 v10.29.0
github.com/joho/godotenv v1.5.1 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/resend/resend-go/v2 v2.28.0
github.com/shopspring/decimal v1.4.0 github.com/shopspring/decimal v1.4.0
github.com/swaggo/fiber-swagger v1.3.0 github.com/swaggo/fiber-swagger v1.3.0
@ -33,9 +32,11 @@ require (
github.com/MicahParks/keyfunc v1.9.0 // indirect github.com/MicahParks/keyfunc v1.9.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // 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/go-control-plane/envoy v1.32.4 // indirect
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // 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-jose/go-jose/v4 v4.1.2 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // 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/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // 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/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/spiffe/go-spiffe/v2 v2.5.0 // indirect
github.com/tinylib/msgp v1.6.1 // indirect
github.com/zeebo/errs v1.4.0 // indirect github.com/zeebo/errs v1.4.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // 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 v1.39.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace 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 golang.org/x/time v0.14.0 // indirect
google.golang.org/appengine/v2 v2.0.6 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // 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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/josharian/intern v1.0.0 // 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/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.6 // indirect github.com/mailru/easyjson v0.7.6 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect github.com/montanaflynn/stats v0.7.1 // indirect
@ -115,7 +123,7 @@ require (
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
// github.com/jackc/pgtype v1.14.4 // github.com/jackc/pgtype v1.14.4
github.com/jackc/pgx/v5 v5.7.6 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-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect

29
go.sum
View File

@ -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.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 h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= 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= 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 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= 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/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 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI=
github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo= 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= 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/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/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.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= 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.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 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-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 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 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 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= 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= 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/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.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
github.com/otiai10/mint v1.3.3/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= 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 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= 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= 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/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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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/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 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= 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.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 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= 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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 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= 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/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 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 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 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 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= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=

View File

@ -85,6 +85,15 @@ type CloudConvertConfig struct {
Enabled bool `mapstructure:"cloudconvert_enabled"` 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 { type Config struct {
GoogleOAuthClientID string GoogleOAuthClientID string
GoogleOAuthClientSecret string GoogleOAuthClientSecret string
@ -92,6 +101,7 @@ type Config struct {
AFROSMSConfig AFROSMSConfig `mapstructure:"afro_sms_config"` AFROSMSConfig AFROSMSConfig `mapstructure:"afro_sms_config"`
Vimeo VimeoConfig `mapstructure:"vimeo_config"` Vimeo VimeoConfig `mapstructure:"vimeo_config"`
CloudConvert CloudConvertConfig `mapstructure:"cloudconvert_config"` CloudConvert CloudConvertConfig `mapstructure:"cloudconvert_config"`
MinIO MinIOConfig `mapstructure:"minio_config"`
APP_VERSION string APP_VERSION string
FIXER_API_KEY string FIXER_API_KEY string
FIXER_BASE_URL string FIXER_BASE_URL string
@ -475,6 +485,23 @@ func (c *Config) loadEnv() error {
} }
c.CloudConvert.APIKey = os.Getenv("CLOUDCONVERT_API_KEY") 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 // Two-phase account deletion purge worker configuration
accountDeletionPurgeEnabled := strings.TrimSpace(os.Getenv("ACCOUNT_DELETION_PURGE_ENABLED")) accountDeletionPurgeEnabled := strings.TrimSpace(os.Getenv("ACCOUNT_DELETION_PURGE_ENABLED"))
if accountDeletionPurgeEnabled == "" { if accountDeletionPurgeEnabled == "" {

View 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{})
}

View 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)
}

View File

@ -9,6 +9,7 @@ import (
"Yimaru-Backend/internal/services/authentication" "Yimaru-Backend/internal/services/authentication"
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
"Yimaru-Backend/internal/services/course_management" "Yimaru-Backend/internal/services/course_management"
minioservice "Yimaru-Backend/internal/services/minio"
issuereporting "Yimaru-Backend/internal/services/issue_reporting" issuereporting "Yimaru-Backend/internal/services/issue_reporting"
notificationservice "Yimaru-Backend/internal/services/notification" notificationservice "Yimaru-Backend/internal/services/notification"
"Yimaru-Backend/internal/services/questions" "Yimaru-Backend/internal/services/questions"
@ -47,6 +48,7 @@ type App struct {
teamSvc *team.Service teamSvc *team.Service
activityLogSvc *activitylogservice.Service activityLogSvc *activitylogservice.Service
cloudConvertSvc *cloudconvertservice.Service cloudConvertSvc *cloudconvertservice.Service
minioSvc *minioservice.Service
ratingSvc *ratingsservice.Service ratingSvc *ratingsservice.Service
fiber *fiber.App fiber *fiber.App
recommendationSvc recommendation.RecommendationService recommendationSvc recommendation.RecommendationService
@ -78,6 +80,7 @@ func NewApp(
teamSvc *team.Service, teamSvc *team.Service,
activityLogSvc *activitylogservice.Service, activityLogSvc *activitylogservice.Service,
cloudConvertSvc *cloudconvertservice.Service, cloudConvertSvc *cloudconvertservice.Service,
minioSvc *minioservice.Service,
ratingSvc *ratingsservice.Service, ratingSvc *ratingsservice.Service,
port int, validator *customvalidator.CustomValidator, port int, validator *customvalidator.CustomValidator,
settingSvc *settings.Service, settingSvc *settings.Service,
@ -120,6 +123,7 @@ func NewApp(
teamSvc: teamSvc, teamSvc: teamSvc,
activityLogSvc: activityLogSvc, activityLogSvc: activityLogSvc,
cloudConvertSvc: cloudConvertSvc, cloudConvertSvc: cloudConvertSvc,
minioSvc: minioSvc,
ratingSvc: ratingSvc, ratingSvc: ratingSvc,
issueReportingSvc: issueReportingSvc, issueReportingSvc: issueReportingSvc,
fiber: app, fiber: app,

View File

@ -2152,7 +2152,7 @@ func (h *Handler) UploadCourseThumbnail(c *fiber.Ctx) error {
return c.Status(fiber.StatusOK).JSON(domain.Response{ return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Course thumbnail uploaded successfully", Message: "Course thumbnail uploaded successfully",
Data: map[string]string{"thumbnail_url": publicPath}, Data: map[string]string{"thumbnail_url": h.resolveFileURL(c, publicPath)},
Success: true, Success: true,
}) })
} }
@ -2201,7 +2201,7 @@ func (h *Handler) UploadSubCourseThumbnail(c *fiber.Ctx) error {
return c.Status(fiber.StatusOK).JSON(domain.Response{ return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Sub-course thumbnail uploaded successfully", 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, Success: true,
}) })
} }
@ -2282,6 +2282,18 @@ func (h *Handler) processAndSaveThumbnail(c *fiber.Ctx, subDir string) (string,
ext = ".webp" 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) dir := filepath.Join(".", "static", subDir)
if err := os.MkdirAll(dir, 0o755); err != nil { if err := os.MkdirAll(dir, 0o755); err != nil {
return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{

View 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,
})
}

View File

@ -13,6 +13,7 @@ import (
course_management "Yimaru-Backend/internal/services/course_management" course_management "Yimaru-Backend/internal/services/course_management"
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
issuereporting "Yimaru-Backend/internal/services/issue_reporting" issuereporting "Yimaru-Backend/internal/services/issue_reporting"
minioservice "Yimaru-Backend/internal/services/minio"
ratingsservice "Yimaru-Backend/internal/services/ratings" ratingsservice "Yimaru-Backend/internal/services/ratings"
rbacservice "Yimaru-Backend/internal/services/rbac" rbacservice "Yimaru-Backend/internal/services/rbac"
notificationservice "Yimaru-Backend/internal/services/notification" notificationservice "Yimaru-Backend/internal/services/notification"
@ -53,6 +54,7 @@ type Handler struct {
activityLogSvc *activitylogservice.Service activityLogSvc *activitylogservice.Service
issueReportingSvc *issuereporting.Service issueReportingSvc *issuereporting.Service
cloudConvertSvc *cloudconvertservice.Service cloudConvertSvc *cloudconvertservice.Service
minioSvc *minioservice.Service
ratingSvc *ratingsservice.Service ratingSvc *ratingsservice.Service
rbacSvc *rbacservice.Service rbacSvc *rbacservice.Service
jwtConfig jwtutil.JwtConfig jwtConfig jwtutil.JwtConfig
@ -81,6 +83,7 @@ func New(
activityLogSvc *activitylogservice.Service, activityLogSvc *activitylogservice.Service,
issueReportingSvc *issuereporting.Service, issueReportingSvc *issuereporting.Service,
cloudConvertSvc *cloudconvertservice.Service, cloudConvertSvc *cloudconvertservice.Service,
minioSvc *minioservice.Service,
ratingSvc *ratingsservice.Service, ratingSvc *ratingsservice.Service,
rbacSvc *rbacservice.Service, rbacSvc *rbacservice.Service,
jwtConfig jwtutil.JwtConfig, jwtConfig jwtutil.JwtConfig,
@ -107,6 +110,7 @@ func New(
activityLogSvc: activityLogSvc, activityLogSvc: activityLogSvc,
issueReportingSvc: issueReportingSvc, issueReportingSvc: issueReportingSvc,
cloudConvertSvc: cloudConvertSvc, cloudConvertSvc: cloudConvertSvc,
minioSvc: minioSvc,
ratingSvc: ratingSvc, ratingSvc: ratingSvc,
rbacSvc: rbacSvc, rbacSvc: rbacSvc,
jwtConfig: jwtConfig, jwtConfig: jwtConfig,

View File

@ -11,6 +11,7 @@ import (
"net" "net"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@ -742,7 +743,12 @@ func (h *Handler) SendTestPushNotification(c *fiber.Ctx) error {
if saveErr != nil { if saveErr != nil {
return saveErr 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 // Create test notification
@ -904,7 +910,12 @@ func (h *Handler) SendBulkPushNotification(c *fiber.Ctx) error {
if saveErr != nil { if saveErr != nil {
return saveErr 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{ notification := &domain.Notification{

View File

@ -2056,27 +2056,40 @@ func (h *Handler) UploadProfilePicture(c *fiber.Ctx) error {
ext = ".webp" ext = ".webp"
} }
// Ensure directory exists var publicPath string
dir := filepath.Join(".", "static", "profile_pictures")
if err := os.MkdirAll(dir, 0o755); err != nil { // Upload to MinIO if available, otherwise save locally
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ if h.minioSvc != nil {
Message: "Failed to create storage directory", result, uploadErr := h.minioSvc.Upload(c.Context(), "profile_pictures", fileHeader.Filename, bytes.NewReader(data), int64(len(data)), contentType)
Error: err.Error(), 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 // Update user profile picture URL
req := domain.UpdateUserReq{ req := domain.UpdateUserReq{
UserID: userID, UserID: userID,
@ -2084,8 +2097,6 @@ func (h *Handler) UploadProfilePicture(c *fiber.Ctx) error {
} }
if err := h.userSvc.UpdateUser(c.Context(), req); err != nil { 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", h.mongoLoggerSvc.Error("Failed to update user with profile picture",
zap.Int64("user_id", userID), zap.Int64("user_id", userID),
zap.Error(err), zap.Error(err),
@ -2099,10 +2110,8 @@ func (h *Handler) UploadProfilePicture(c *fiber.Ctx) error {
return c.Status(fiber.StatusOK).JSON(domain.Response{ return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Profile picture uploaded successfully", 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, Success: true,
StatusCode: fiber.StatusOK, StatusCode: fiber.StatusOK,
}) })
// return response.WriteJSON(c, fiber.StatusOK, "Profile picture uploaded successfully", map[string]string{"profile_picture_url": publicPath}, nil)
} }

View File

@ -31,6 +31,7 @@ func (a *App) initAppRoutes() {
a.activityLogSvc, a.activityLogSvc,
a.issueReportingSvc, a.issueReportingSvc,
a.cloudConvertSvc, a.cloudConvertSvc,
a.minioSvc,
a.ratingSvc, a.ratingSvc,
a.rbacSvc, a.rbacSvc,
a.JwtConfig, 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) // Assessment questions (public)
groupV1.Post("/assessment/questions", h.CreateAssessmentQuestion) groupV1.Post("/assessment/questions", h.CreateAssessmentQuestion)
groupV1.Get("/assessment/questions", h.ListAssessmentQuestions) groupV1.Get("/assessment/questions", h.ListAssessmentQuestions)