data seed + file upload handle fixes
This commit is contained in:
parent
94d6777c48
commit
b06b8645cf
|
|
@ -136,6 +136,12 @@ VALUES
|
||||||
)
|
)
|
||||||
ON CONFLICT (id) DO NOTHING;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Ensure seeded admin has full panel permissions in legacy team_members.permissions JSON.
|
||||||
|
-- RBAC permissions are managed separately, but this keeps seed behavior consistent.
|
||||||
|
UPDATE team_members
|
||||||
|
SET permissions = '["*"]'::jsonb
|
||||||
|
WHERE id = 2 OR email = 'admin@yimaru.com';
|
||||||
|
|
||||||
-- ======================================================
|
-- ======================================================
|
||||||
-- Global Settings (LMS)
|
-- Global Settings (LMS)
|
||||||
-- ======================================================
|
-- ======================================================
|
||||||
|
|
|
||||||
224
docs/JSON_MEDIA_UPLOAD_INTEGRATION_GUIDE.md
Normal file
224
docs/JSON_MEDIA_UPLOAD_INTEGRATION_GUIDE.md
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
# JSON Media Integration Guide (Admin Panel)
|
||||||
|
|
||||||
|
This guide documents the new media integration pattern introduced in the backend:
|
||||||
|
|
||||||
|
- Upload binary file once through `POST /api/v1/files/upload`
|
||||||
|
- Use the returned URL/key in JSON request bodies for business endpoints
|
||||||
|
|
||||||
|
This replaces direct form-data usage in common admin flows (while legacy multipart compatibility still exists).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) New General Media Upload Endpoint
|
||||||
|
|
||||||
|
### `POST /api/v1/files/upload`
|
||||||
|
|
||||||
|
**Auth:** Bearer token required
|
||||||
|
**Content-Type:** `multipart/form-data`
|
||||||
|
**Purpose:** Upload media and return reference data for subsequent JSON requests.
|
||||||
|
|
||||||
|
### Request fields
|
||||||
|
|
||||||
|
- `media_type` (required): `image` | `audio` | `video`
|
||||||
|
- `file` (required): binary file
|
||||||
|
- `title` (optional, video only): Vimeo video title
|
||||||
|
- `description` (optional, video only): Vimeo video description
|
||||||
|
|
||||||
|
### Storage behavior
|
||||||
|
|
||||||
|
- `media_type=image` -> MinIO
|
||||||
|
- `media_type=audio` -> MinIO
|
||||||
|
- `media_type=video` -> Vimeo
|
||||||
|
|
||||||
|
### Success response shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"status_code": 200,
|
||||||
|
"message": "File uploaded successfully",
|
||||||
|
"data": {
|
||||||
|
"object_key": "image/abc123.webp",
|
||||||
|
"url": "https://...",
|
||||||
|
"content_type": "image/webp",
|
||||||
|
"media_type": "image",
|
||||||
|
"provider": "MINIO"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For videos, response includes Vimeo references:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"status_code": 200,
|
||||||
|
"message": "Video uploaded successfully",
|
||||||
|
"data": {
|
||||||
|
"url": "https://vimeo.com/123456789",
|
||||||
|
"content_type": "video/mp4",
|
||||||
|
"media_type": "video",
|
||||||
|
"provider": "VIMEO",
|
||||||
|
"vimeo_id": "123456789",
|
||||||
|
"embed_url": "https://player.vimeo.com/video/123456789"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Endpoints Updated to JSON Media Reference Flow
|
||||||
|
|
||||||
|
These endpoints now support JSON request bodies for media references.
|
||||||
|
|
||||||
|
## A) Profile Picture
|
||||||
|
|
||||||
|
### `POST /api/v1/user/:id/profile-picture`
|
||||||
|
|
||||||
|
**Old style:** multipart with `file`
|
||||||
|
**New style:** JSON with uploaded URL
|
||||||
|
|
||||||
|
#### JSON request body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"profile_picture_url": "https://your-media-url-or-minio-presigned-url"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Success response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"status_code": 200,
|
||||||
|
"message": "Profile picture URL updated successfully",
|
||||||
|
"data": {
|
||||||
|
"profile_picture_url": "https://your-media-url-or-minio-presigned-url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B) Course Thumbnail
|
||||||
|
|
||||||
|
### `POST /api/v1/course-management/courses/:id/thumbnail`
|
||||||
|
|
||||||
|
**Old style:** multipart with `file`
|
||||||
|
**New style:** JSON with uploaded URL
|
||||||
|
|
||||||
|
#### JSON request body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"thumbnail_url": "https://your-media-url-or-minio-presigned-url"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Success response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Course thumbnail URL updated successfully",
|
||||||
|
"data": {
|
||||||
|
"thumbnail_url": "https://your-media-url-or-minio-presigned-url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C) Sub-course Thumbnail
|
||||||
|
|
||||||
|
### `POST /api/v1/course-management/sub-courses/:id/thumbnail`
|
||||||
|
|
||||||
|
**Old style:** multipart with `file`
|
||||||
|
**New style:** JSON with uploaded URL
|
||||||
|
|
||||||
|
#### JSON request body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"thumbnail_url": "https://your-media-url-or-minio-presigned-url"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Success response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Sub-course thumbnail URL updated successfully",
|
||||||
|
"data": {
|
||||||
|
"thumbnail_url": "https://your-media-url-or-minio-presigned-url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D) Audio Answer Submission
|
||||||
|
|
||||||
|
### `POST /api/v1/questions/audio-answer`
|
||||||
|
|
||||||
|
**Old style:** multipart with `question_id`, `question_set_id`, `file`
|
||||||
|
**New style:** JSON referencing uploaded audio object key
|
||||||
|
|
||||||
|
#### JSON request body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"question_id": 101,
|
||||||
|
"question_set_id": 5,
|
||||||
|
"object_key": "audio/uuid-audio-file.webm"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Success response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"status_code": 200,
|
||||||
|
"message": "Audio answer submitted successfully",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"question_id": 101,
|
||||||
|
"question_set_id": 5,
|
||||||
|
"audio_url": "https://...",
|
||||||
|
"created_at": "2026-03-24T10:30:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Recommended Admin Panel Integration Flow
|
||||||
|
|
||||||
|
For each image/audio/video field:
|
||||||
|
|
||||||
|
1. Call `POST /api/v1/files/upload` with `multipart/form-data`
|
||||||
|
2. Read response `data`
|
||||||
|
- image/audio: use `url` (and keep `object_key` if needed)
|
||||||
|
- video: use `url` / `vimeo_id` / `embed_url` depending on target endpoint
|
||||||
|
3. Call business endpoint with JSON body using returned media reference
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Endpoint List (Quick Reference)
|
||||||
|
|
||||||
|
- `POST /api/v1/files/upload` (new)
|
||||||
|
- `POST /api/v1/user/:id/profile-picture` (now supports JSON)
|
||||||
|
- `POST /api/v1/course-management/courses/:id/thumbnail` (now supports JSON)
|
||||||
|
- `POST /api/v1/course-management/sub-courses/:id/thumbnail` (now supports JSON)
|
||||||
|
- `POST /api/v1/questions/audio-answer` (now supports JSON)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) Backward Compatibility Note
|
||||||
|
|
||||||
|
Legacy multipart behavior for the updated endpoints is still supported to avoid breaking existing clients during migration.
|
||||||
|
Admin panel should migrate to the new JSON-reference flow for consistency.
|
||||||
|
|
||||||
|
|
@ -2130,6 +2130,34 @@ func (h *Handler) UploadCourseThumbnail(c *fiber.Ctx) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.Contains(strings.ToLower(c.Get("Content-Type")), "application/json") {
|
||||||
|
var req struct {
|
||||||
|
ThumbnailURL string `json:"thumbnail_url"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Invalid request body",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.ThumbnailURL) == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "thumbnail_url is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := h.courseMgmtSvc.UpdateCourse(c.Context(), id, nil, nil, &req.ThumbnailURL, nil, nil); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Failed to update course thumbnail",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
||||||
|
Message: "Course thumbnail URL updated successfully",
|
||||||
|
Data: map[string]string{"thumbnail_url": req.ThumbnailURL},
|
||||||
|
Success: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
publicPath, err := h.processAndSaveThumbnail(c, "thumbnails/courses")
|
publicPath, err := h.processAndSaveThumbnail(c, "thumbnails/courses")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -2179,6 +2207,34 @@ func (h *Handler) UploadSubCourseThumbnail(c *fiber.Ctx) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.Contains(strings.ToLower(c.Get("Content-Type")), "application/json") {
|
||||||
|
var req struct {
|
||||||
|
ThumbnailURL string `json:"thumbnail_url"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Invalid request body",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.ThumbnailURL) == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "thumbnail_url is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := h.courseMgmtSvc.UpdateSubCourse(c.Context(), id, nil, nil, &req.ThumbnailURL, nil, nil, nil, nil); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Failed to update sub-course thumbnail",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
||||||
|
Message: "Sub-course thumbnail URL updated successfully",
|
||||||
|
Data: map[string]string{"thumbnail_url": req.ThumbnailURL},
|
||||||
|
Success: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
publicPath, err := h.processAndSaveThumbnail(c, "thumbnails/sub_courses")
|
publicPath, err := h.processAndSaveThumbnail(c, "thumbnails/sub_courses")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,16 @@ import (
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type uploadMediaRes struct {
|
||||||
|
ObjectKey string `json:"object_key"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
MediaType string `json:"media_type"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
VimeoID string `json:"vimeo_id,omitempty"`
|
||||||
|
EmbedURL string `json:"embed_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// resolveFileURL converts a stored file path to a usable URL.
|
// resolveFileURL converts a stored file path to a usable URL.
|
||||||
// If the path starts with "minio://", it generates a presigned URL.
|
// If the path starts with "minio://", it generates a presigned URL.
|
||||||
// Otherwise it returns the path as-is (e.g. "/static/...").
|
// Otherwise it returns the path as-is (e.g. "/static/...").
|
||||||
|
|
@ -64,6 +74,171 @@ func (h *Handler) GetFileURL(c *fiber.Ctx) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UploadMedia uploads an image/audio/video file and returns its URL and key.
|
||||||
|
// @Summary Upload media file
|
||||||
|
// @Tags files
|
||||||
|
// @Accept multipart/form-data
|
||||||
|
// @Param media_type formData string true "Media type: image|audio|video"
|
||||||
|
// @Param file formData file true "Media file"
|
||||||
|
// @Success 200 {object} domain.Response
|
||||||
|
// @Router /api/v1/files/upload [post]
|
||||||
|
func (h *Handler) UploadMedia(c *fiber.Ctx) error {
|
||||||
|
mediaType := strings.ToLower(strings.TrimSpace(c.FormValue("media_type")))
|
||||||
|
if mediaType == "" {
|
||||||
|
mediaType = "file"
|
||||||
|
}
|
||||||
|
if mediaType != "image" && mediaType != "audio" && mediaType != "video" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Invalid media_type",
|
||||||
|
Error: "media_type must be one of: image, audio, video",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fileHeader, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "File is required",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
maxSize := int64(100 * 1024 * 1024) // default 100MB
|
||||||
|
switch mediaType {
|
||||||
|
case "image":
|
||||||
|
maxSize = 10 * 1024 * 1024
|
||||||
|
case "audio":
|
||||||
|
maxSize = 50 * 1024 * 1024
|
||||||
|
case "video":
|
||||||
|
maxSize = 500 * 1024 * 1024
|
||||||
|
}
|
||||||
|
if fileHeader.Size > maxSize {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "File too large",
|
||||||
|
Error: "File exceeds size limit for selected media_type",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if mediaType == "video" && h.vimeoSvc == nil {
|
||||||
|
return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Vimeo service is not available for video uploads",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (mediaType == "image" || mediaType == "audio") && h.minioSvc == nil {
|
||||||
|
return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{
|
||||||
|
Message: "MinIO service is not available for image/audio uploads",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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])
|
||||||
|
|
||||||
|
switch mediaType {
|
||||||
|
case "image":
|
||||||
|
if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/webp" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Invalid file type",
|
||||||
|
Error: "Only jpg, png, and webp images are allowed",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case "audio":
|
||||||
|
allowedAudio := map[string]bool{
|
||||||
|
"audio/mpeg": true, "audio/wav": true, "audio/ogg": true, "audio/mp4": true,
|
||||||
|
"audio/aac": true, "audio/webm": true, "video/ogg": true, "video/webm": true,
|
||||||
|
"audio/x-wav": true, "audio/x-m4a": true, "audio/flac": true,
|
||||||
|
}
|
||||||
|
if !allowedAudio[contentType] {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Invalid file type",
|
||||||
|
Error: "Only supported audio formats are allowed",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case "video":
|
||||||
|
if !strings.HasPrefix(contentType, "video/") {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Invalid file type",
|
||||||
|
Error: "Only video files are allowed",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rest, err := io.ReadAll(fh)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Failed to read file",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
data := append(head[:n], rest...)
|
||||||
|
|
||||||
|
if mediaType == "video" {
|
||||||
|
title := strings.TrimSpace(c.FormValue("title"))
|
||||||
|
if title == "" {
|
||||||
|
title = fileHeader.Filename
|
||||||
|
}
|
||||||
|
description := strings.TrimSpace(c.FormValue("description"))
|
||||||
|
vimeoUpload, err := h.vimeoSvc.UploadVideoFile(c.Context(), title, description, bytes.NewReader(data), int64(len(data)))
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Failed to upload video to Vimeo",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
||||||
|
Message: "Video uploaded successfully",
|
||||||
|
Data: uploadMediaRes{
|
||||||
|
URL: vimeoUpload.Link,
|
||||||
|
ContentType: contentType,
|
||||||
|
MediaType: mediaType,
|
||||||
|
Provider: "VIMEO",
|
||||||
|
VimeoID: vimeoUpload.VimeoID,
|
||||||
|
EmbedURL: "https://player.vimeo.com/video/" + vimeoUpload.VimeoID,
|
||||||
|
},
|
||||||
|
Success: true,
|
||||||
|
StatusCode: fiber.StatusOK,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadResult, err := h.minioSvc.Upload(
|
||||||
|
c.Context(),
|
||||||
|
mediaType,
|
||||||
|
fileHeader.Filename,
|
||||||
|
bytes.NewReader(data),
|
||||||
|
int64(len(data)),
|
||||||
|
contentType,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Failed to upload file",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
storedPath := "minio://" + uploadResult.ObjectKey
|
||||||
|
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
||||||
|
Message: "File uploaded successfully",
|
||||||
|
Data: uploadMediaRes{
|
||||||
|
ObjectKey: uploadResult.ObjectKey,
|
||||||
|
URL: h.resolveFileURL(c, storedPath),
|
||||||
|
ContentType: contentType,
|
||||||
|
MediaType: mediaType,
|
||||||
|
Provider: "MINIO",
|
||||||
|
},
|
||||||
|
Success: true,
|
||||||
|
StatusCode: fiber.StatusOK,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// UploadAudio uploads an audio file to MinIO and returns the object key.
|
// UploadAudio uploads an audio file to MinIO and returns the object key.
|
||||||
// @Summary Upload an audio file
|
// @Summary Upload an audio file
|
||||||
// @Tags files
|
// @Tags files
|
||||||
|
|
@ -201,6 +376,53 @@ func (h *Handler) SubmitAudioAnswer(c *fiber.Ctx) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JSON mode: store reference to previously uploaded media key
|
||||||
|
if strings.Contains(strings.ToLower(c.Get("Content-Type")), "application/json") {
|
||||||
|
var req struct {
|
||||||
|
QuestionID int64 `json:"question_id"`
|
||||||
|
QuestionSetID int64 `json:"question_set_id"`
|
||||||
|
ObjectKey string `json:"object_key"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Invalid request body",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if req.QuestionID <= 0 || req.QuestionSetID <= 0 || strings.TrimSpace(req.ObjectKey) == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "question_id, question_set_id and object_key are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
row, err := h.analyticsDB.CreateUserAudioResponse(c.Context(), dbgen.CreateUserAudioResponseParams{
|
||||||
|
UserID: userID,
|
||||||
|
QuestionID: req.QuestionID,
|
||||||
|
QuestionSetID: req.QuestionSetID,
|
||||||
|
AudioObjectKey: req.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://"+req.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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
questionID, err := strconv.ParseInt(c.FormValue("question_id"), 10, 64)
|
questionID, err := strconv.ParseInt(c.FormValue("question_id"), 10, 64)
|
||||||
if err != nil || questionID <= 0 {
|
if err != nil || questionID <= 0 {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
|
|
@ -1978,6 +1979,40 @@ func (h *Handler) UploadProfilePicture(c *fiber.Ctx) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JSON mode: accept already uploaded URL and set it directly.
|
||||||
|
if strings.Contains(strings.ToLower(c.Get("Content-Type")), "application/json") {
|
||||||
|
var req struct {
|
||||||
|
ProfilePictureURL string `json:"profile_picture_url"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Invalid request body",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.ProfilePictureURL) == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "profile_picture_url is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
updateReq := domain.UpdateUserReq{
|
||||||
|
UserID: userID,
|
||||||
|
ProfilePictureURL: req.ProfilePictureURL,
|
||||||
|
}
|
||||||
|
if err := h.userSvc.UpdateUser(c.Context(), updateReq); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Failed to update user",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
||||||
|
Message: "Profile picture URL updated successfully",
|
||||||
|
Data: map[string]string{"profile_picture_url": req.ProfilePictureURL},
|
||||||
|
Success: true,
|
||||||
|
StatusCode: fiber.StatusOK,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fileHeader, err := c.FormFile("file")
|
fileHeader, err := c.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ func (a *App) initAppRoutes() {
|
||||||
|
|
||||||
// File storage (MinIO)
|
// File storage (MinIO)
|
||||||
groupV1.Get("/files/url", a.authMiddleware, h.GetFileURL)
|
groupV1.Get("/files/url", a.authMiddleware, h.GetFileURL)
|
||||||
|
groupV1.Post("/files/upload", a.authMiddleware, h.UploadMedia)
|
||||||
groupV1.Post("/files/audio", a.authMiddleware, h.UploadAudio)
|
groupV1.Post("/files/audio", a.authMiddleware, h.UploadAudio)
|
||||||
groupV1.Post("/questions/audio-answer", a.authMiddleware, h.SubmitAudioAnswer)
|
groupV1.Post("/questions/audio-answer", a.authMiddleware, h.SubmitAudioAnswer)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user