data seed + file upload handle fixes

This commit is contained in:
Yared Yemane 2026-03-24 04:58:05 -07:00
parent 94d6777c48
commit b06b8645cf
6 changed files with 556 additions and 12 deletions

View File

@ -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)
-- ====================================================== -- ======================================================

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

View File

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

View File

@ -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{

View File

@ -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{

View File

@ -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)