Compare commits
2 Commits
1f77b93ec7
...
b06b8645cf
| Author | SHA1 | Date | |
|---|---|---|---|
| b06b8645cf | |||
| 94d6777c48 |
|
|
@ -136,6 +136,12 @@ VALUES
|
|||
)
|
||||
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)
|
||||
-- ======================================================
|
||||
|
|
|
|||
67
db/data/009_question_types_seed.sql
Normal file
67
db/data/009_question_types_seed.sql
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
-- Seed TRUE_FALSE and SHORT_ANSWER question types
|
||||
-- Ensures question sets contain non-MCQ questions for end-to-end testing.
|
||||
|
||||
-- ======================================================
|
||||
-- TRUE_FALSE questions (stored in questions + question_options)
|
||||
-- ======================================================
|
||||
INSERT INTO questions (
|
||||
id,
|
||||
question_text,
|
||||
question_type,
|
||||
difficulty_level,
|
||||
points,
|
||||
status,
|
||||
created_at
|
||||
)
|
||||
VALUES
|
||||
(27, 'The Python interpreter executes Python code top-to-bottom.', 'TRUE_FALSE', 'EASY', 1, 'PUBLISHED', CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- question_options for TRUE_FALSE: use two options with exactly one correct
|
||||
INSERT INTO question_options (question_id, option_text, option_order, is_correct)
|
||||
VALUES
|
||||
(27, 'True', 1, TRUE),
|
||||
(27, 'False', 2, FALSE)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ======================================================
|
||||
-- SHORT_ANSWER questions (stored in questions + question_short_answers)
|
||||
-- ======================================================
|
||||
INSERT INTO questions (
|
||||
id,
|
||||
question_text,
|
||||
question_type,
|
||||
difficulty_level,
|
||||
points,
|
||||
status,
|
||||
created_at
|
||||
)
|
||||
VALUES
|
||||
(29, 'What keyword is used in Python to define a function?', 'SHORT_ANSWER', 'EASY', 1, 'PUBLISHED', CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO question_short_answers (question_id, acceptable_answer, match_type)
|
||||
VALUES
|
||||
(29, 'def', 'EXACT')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ======================================================
|
||||
-- Link new questions into existing question sets
|
||||
-- Question Set 1: Initial Assessment (set_id = 1, PUBLISHED)
|
||||
-- Question Set 2: Python Basics Assessment (set_id = 2, PUBLISHED)
|
||||
-- ======================================================
|
||||
INSERT INTO question_set_items (set_id, question_id, display_order)
|
||||
VALUES
|
||||
(1, 27, 17),
|
||||
(1, 29, 18),
|
||||
(2, 27, 3),
|
||||
(2, 29, 4)
|
||||
ON CONFLICT (set_id, question_id) DO NOTHING;
|
||||
|
||||
-- ======================================================
|
||||
-- Reset sequences to avoid ID collisions after seeding
|
||||
-- ======================================================
|
||||
SELECT setval(pg_get_serial_sequence('questions', 'id'), COALESCE((SELECT MAX(id) FROM questions), 1), true);
|
||||
SELECT setval(pg_get_serial_sequence('question_options', 'id'), COALESCE((SELECT MAX(id) FROM question_options), 1), true);
|
||||
SELECT setval(pg_get_serial_sequence('question_short_answers', 'id'), COALESCE((SELECT MAX(id) FROM question_short_answers), 1), true);
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -19,6 +19,9 @@ type RBACStore interface {
|
|||
GetPermissionByKey(ctx context.Context, key string) (domain.Permission, error)
|
||||
|
||||
SetRolePermissions(ctx context.Context, roleID int64, permissionIDs []int64) error
|
||||
// AddRolePermissions inserts permissions into role without removing existing ones.
|
||||
// It is safe to call repeatedly (idempotent) as it relies on ON CONFLICT DO NOTHING.
|
||||
AddRolePermissions(ctx context.Context, roleID int64, permissionIDs []int64) error
|
||||
GetRolePermissions(ctx context.Context, roleID int64) ([]domain.Permission, error)
|
||||
|
||||
GetAllRolesWithPermissions(ctx context.Context) (map[string]map[string]struct{}, error)
|
||||
|
|
|
|||
|
|
@ -150,6 +150,17 @@ func (s *Store) SetRolePermissions(ctx context.Context, roleID int64, permission
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) AddRolePermissions(ctx context.Context, roleID int64, permissionIDs []int64) error {
|
||||
if len(permissionIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Uses ON CONFLICT DO NOTHING at the SQL level (see rbac.sql).
|
||||
return s.queries.BulkAssignPermissionsToRole(ctx, dbgen.BulkAssignPermissionsToRoleParams{
|
||||
RoleID: roleID,
|
||||
Column2: permissionIDs,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) GetRolePermissions(ctx context.Context, roleID int64) ([]domain.Permission, error) {
|
||||
rows, err := s.queries.GetRolePermissions(ctx, roleID)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -76,15 +76,7 @@ func (s *Service) SeedDefaultRolePermissions(ctx context.Context) error {
|
|||
continue
|
||||
}
|
||||
|
||||
existing, err := s.store.GetRolePermissions(ctx, role.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check existing permissions for %s: %w", roleName, err)
|
||||
}
|
||||
if len(existing) > 0 {
|
||||
s.logger.Info("role already has permissions, skipping seed", "role", roleName, "count", len(existing))
|
||||
continue
|
||||
}
|
||||
|
||||
// Insert missing permissions without wiping existing role permissions.
|
||||
var permIDs []int64
|
||||
for _, key := range permKeys {
|
||||
perm, err := s.store.GetPermissionByKey(ctx, key)
|
||||
|
|
@ -95,12 +87,14 @@ func (s *Service) SeedDefaultRolePermissions(ctx context.Context) error {
|
|||
permIDs = append(permIDs, perm.ID)
|
||||
}
|
||||
|
||||
if len(permIDs) > 0 {
|
||||
if err := s.store.SetRolePermissions(ctx, role.ID, permIDs); err != nil {
|
||||
return fmt.Errorf("seed permissions for role %s: %w", roleName, err)
|
||||
if len(permIDs) == 0 {
|
||||
continue
|
||||
}
|
||||
s.logger.Info("seeded default permissions for role", "role", roleName, "count", len(permIDs))
|
||||
|
||||
if err := s.store.AddRolePermissions(ctx, role.ID, permIDs); err != nil {
|
||||
return fmt.Errorf("seed role permissions for %s: %w", roleName, err)
|
||||
}
|
||||
s.logger.Info("ensured default permissions for role", "role", roleName, "count", len(permIDs))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
if err != nil {
|
||||
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")
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -13,6 +13,16 @@ import (
|
|||
"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.
|
||||
// If the path starts with "minio://", it generates a presigned URL.
|
||||
// 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.
|
||||
// @Summary Upload an audio file
|
||||
// @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)
|
||||
if err != nil || questionID <= 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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")
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ func (a *App) initAppRoutes() {
|
|||
|
||||
// File storage (MinIO)
|
||||
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("/questions/audio-answer", a.authMiddleware, h.SubmitAudioAnswer)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user