From b06b8645cfb808f8cad7c9a47f42b45a66881845 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Tue, 24 Mar 2026 04:58:05 -0700 Subject: [PATCH] data seed + file upload handle fixes --- db/data/001_initial_seed_data.sql | 6 + docs/JSON_MEDIA_UPLOAD_INTEGRATION_GUIDE.md | 224 ++++++++++++++++ .../web_server/handlers/course_management.go | 58 ++++- internal/web_server/handlers/file_handler.go | 244 +++++++++++++++++- internal/web_server/handlers/user.go | 35 +++ internal/web_server/routes.go | 1 + 6 files changed, 556 insertions(+), 12 deletions(-) create mode 100644 docs/JSON_MEDIA_UPLOAD_INTEGRATION_GUIDE.md diff --git a/db/data/001_initial_seed_data.sql b/db/data/001_initial_seed_data.sql index 6e987a8..02659df 100644 --- a/db/data/001_initial_seed_data.sql +++ b/db/data/001_initial_seed_data.sql @@ -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) -- ====================================================== diff --git a/docs/JSON_MEDIA_UPLOAD_INTEGRATION_GUIDE.md b/docs/JSON_MEDIA_UPLOAD_INTEGRATION_GUIDE.md new file mode 100644 index 0000000..d841dbb --- /dev/null +++ b/docs/JSON_MEDIA_UPLOAD_INTEGRATION_GUIDE.md @@ -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. + diff --git a/internal/web_server/handlers/course_management.go b/internal/web_server/handlers/course_management.go index 4afb536..f4c6da5 100644 --- a/internal/web_server/handlers/course_management.go +++ b/internal/web_server/handlers/course_management.go @@ -579,7 +579,7 @@ type createSubCourseReq struct { Description *string `json:"description"` Thumbnail *string `json:"thumbnail"` DisplayOrder *int32 `json:"display_order"` - Level string `json:"level" validate:"required"` // BEGINNER, INTERMEDIATE, ADVANCED + Level string `json:"level" validate:"required"` // BEGINNER, INTERMEDIATE, ADVANCED SubLevel string `json:"sub_level" validate:"required"` // A1..C3 depending on level } @@ -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 diff --git a/internal/web_server/handlers/file_handler.go b/internal/web_server/handlers/file_handler.go index 0ff0588..b5a5055 100644 --- a/internal/web_server/handlers/file_handler.go +++ b/internal/web_server/handlers/file_handler.go @@ -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 @@ -108,17 +283,17 @@ func (h *Handler) UploadAudio(c *fiber.Ctx) error { 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, + "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. @@ -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{ diff --git a/internal/web_server/handlers/user.go b/internal/web_server/handlers/user.go index b7a206a..d30910d 100644 --- a/internal/web_server/handlers/user.go +++ b/internal/web_server/handlers/user.go @@ -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{ diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index b5ad504..905025d 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -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)