package documents import ( "bufio" "encoding/json" "errors" "io" "log/slog" "mime" "mime/multipart" "net/http" "strings" "time" "github.com/google/uuid" "project/internal/api" "project/internal/auth" ) type Handler struct { svc *Service } func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} } // ListDocTypes godoc // // @Summary List tipe dokumen yang didukung // @Tags documents // @Produce json // @Success 200 {object} api.APIResponse{data=[]DocTypeInfo} // @Security BearerAuth // @Router /api/v1/documents/doctypes [get] func (h *Handler) ListDocTypes(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, api.APIResponse{ Status: "success", Message: "supported document types", Data: h.svc.ListDocTypes(), }) } // ListByUser godoc // // @Summary List dokumen milik user // @Tags documents // @Produce json // @Param user_id path string true "User ID" // @Success 200 {object} api.APIResponse{data=[]Document} // @Failure 500 {object} api.APIResponse // @Security BearerAuth // @Router /api/v1/documents/{user_id} [get] func (h *Handler) ListByUser(w http.ResponseWriter, r *http.Request) { userID := r.PathValue("user_id") if rejectUserMismatch(w, r, userID) { return } docs, err := h.svc.ListByUser(r.Context(), userID) if err != nil { slog.Error("list documents", "user_id", userID, "err", err) writeJSON(w, http.StatusInternalServerError, api.APIResponse{Status: "error", Message: "internal server error"}) return } writeJSON(w, http.StatusOK, api.APIResponse{Status: "success", Message: "documents", Data: docs}) } // Upload godoc // // @Summary Upload dokumen ke private object storage // @Description Mendukung pdf, docx, txt, csv, xlsx. Maksimum 10 MB. Stream ke private object storage tanpa mengubah kontrak upload frontend. // @Tags documents // @Accept multipart/form-data // @Produce json // @Param user_id formData string true "User ID" // @Param file formData file true "File dokumen" // @Success 201 {object} api.APIResponse{data=Document} // @Failure 400 {object} api.APIResponse // @Failure 500 {object} api.APIResponse // @Security BearerAuth // @Router /api/v1/document/upload [post] func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) { reqStart := time.Now() requestID := r.Header.Get("X-Request-Id") if requestID == "" { requestID = uuid.New().String() } slog.Info("upload: request received", "request_id", requestID, "remote_addr", r.RemoteAddr, "content_length", r.ContentLength, "user_agent", r.UserAgent(), ) // r.MultipartReader() uses Go's default 4 KB socket read buffer, causing // ~675 syscalls for a 2.7 MB file. On Windows each syscall hits the TCP // delayed-ACK timer (~26 ms), adding up to ~17 s. Wrapping r.Body with a // 1 MB bufio.Reader reduces that to ~3 syscalls. mediaType, params, parseErr := mime.ParseMediaType(r.Header.Get("Content-Type")) if parseErr != nil || mediaType != "multipart/form-data" || params["boundary"] == "" { writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "multipart/form-data required"}) return } mr := multipart.NewReader(bufio.NewReaderSize(r.Body, 1*1024*1024), params["boundary"]) var userID string var filename string var fileSize int64 var filePart io.Reader for { part, err := mr.NextPart() if err == io.EOF { break } if err != nil { writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "failed to parse form"}) return } switch part.FormName() { case "user_id": data, _ := io.ReadAll(io.LimitReader(part, 512)) userID = strings.TrimSpace(string(data)) case "file": filename = part.FileName() fileSize = r.ContentLength filePart = part } if userID != "" && filePart != nil { break } } if userID == "" { writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "user_id is required"}) return } if rejectUserMismatch(w, r, userID) { return } if filePart == nil { writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "file is required"}) return } slog.Info("upload: multipart parsed", "request_id", requestID, "user_id", userID, "filename", filename, "content_length", fileSize, "elapsed_ms", time.Since(reqStart).Milliseconds(), ) doc, err := h.svc.Upload(r.Context(), userID, filename, filePart, fileSize, requestID) if err != nil { elapsed := time.Since(reqStart).Milliseconds() switch { case errors.Is(err, ErrUnsupportedType): slog.Warn("upload: unsupported file type", "request_id", requestID, "filename", filename, "elapsed_ms", elapsed) writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "unsupported file type"}) case errors.Is(err, ErrFileTooLarge): slog.Warn("upload: file too large", "request_id", requestID, "filename", filename, "elapsed_ms", elapsed) writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "file too large (max 10 MB)"}) default: slog.Error("upload: failed", "request_id", requestID, "err", err, "elapsed_ms", elapsed) writeJSON(w, http.StatusInternalServerError, api.APIResponse{Status: "error", Message: "upload failed"}) } return } slog.Info("upload: completed", "request_id", requestID, "doc_id", doc.ID, "user_id", userID, "filename", filename, "file_size_bytes", doc.FileSize, "elapsed_ms", time.Since(reqStart).Milliseconds(), ) writeJSON(w, http.StatusCreated, api.APIResponse{Status: "success", Message: "file uploaded", Data: doc}) } // UploadLocal godoc // // @Summary Upload dokumen ke local filesystem (benchmarking) // @Description Sama seperti /upload tetapi menyimpan file ke folder files/ lokal. Digunakan untuk membandingkan performa vs private object storage. // @Tags documents // @Accept multipart/form-data // @Produce json // @Param user_id formData string true "User ID" // @Param file formData file true "File dokumen" // @Success 201 {object} api.APIResponse // @Failure 400 {object} api.APIResponse // @Failure 500 {object} api.APIResponse // @Security BearerAuth // @Router /api/v1/document/upload-local [post] func (h *Handler) UploadLocal(w http.ResponseWriter, r *http.Request) { mediaType, params, parseErr := mime.ParseMediaType(r.Header.Get("Content-Type")) if parseErr != nil || mediaType != "multipart/form-data" || params["boundary"] == "" { writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "multipart/form-data required"}) return } mr := multipart.NewReader(bufio.NewReaderSize(r.Body, 1*1024*1024), params["boundary"]) var userID string var filename string var fileSize int64 var filePart io.Reader for { part, err := mr.NextPart() if err == io.EOF { break } if err != nil { writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "failed to parse form"}) return } switch part.FormName() { case "user_id": data, _ := io.ReadAll(io.LimitReader(part, 512)) userID = strings.TrimSpace(string(data)) case "file": filename = part.FileName() fileSize = r.ContentLength filePart = part } if userID != "" && filePart != nil { break } } if userID == "" { writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "user_id is required"}) return } if rejectUserMismatch(w, r, userID) { return } if filePart == nil { writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "file is required"}) return } destPath, err := h.svc.UploadLocal(r.Context(), userID, filename, filePart, fileSize) if err != nil { switch { case errors.Is(err, ErrUnsupportedType): writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "unsupported file type"}) case errors.Is(err, ErrFileTooLarge): writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "file too large (max 10 MB)"}) default: slog.Error("local upload failed", "err", err) writeJSON(w, http.StatusInternalServerError, api.APIResponse{Status: "error", Message: "local upload failed"}) } return } writeJSON(w, http.StatusCreated, api.APIResponse{Status: "success", Message: "file saved locally", Data: map[string]string{"path": destPath}}) } // Process godoc // // @Summary Proses dokumen secara async (extract text / convert ke Parquet) // @Description Processing berjalan di background. Response 202 dikembalikan segera setelah validasi. Pantau progress via GET /api/v1/documents/{user_id}. // @Tags documents // @Accept json // @Produce json // @Param body body object{document_id=string,user_id=string} true "Document ID dan User ID" // @Success 202 {object} api.APIResponse{data=ProcessResult} // @Failure 400 {object} api.APIResponse // @Failure 403 {object} api.APIResponse // @Failure 404 {object} api.APIResponse // @Failure 500 {object} api.APIResponse // @Security BearerAuth // @Router /api/v1/document/process [post] func (h *Handler) Process(w http.ResponseWriter, r *http.Request) { var req struct { DocumentID string `json:"document_id"` UserID string `json:"user_id"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "invalid request body"}) return } if req.DocumentID == "" || req.UserID == "" { writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "document_id and user_id are required"}) return } if rejectUserMismatch(w, r, req.UserID) { return } result, err := h.svc.Process(r.Context(), req.DocumentID, req.UserID) if err != nil { switch { case errors.Is(err, ErrNotFound): writeJSON(w, http.StatusNotFound, api.APIResponse{Status: "error", Message: "document not found"}) case errors.Is(err, ErrForbidden): writeJSON(w, http.StatusForbidden, api.APIResponse{Status: "error", Message: "access denied"}) default: writeJSON(w, http.StatusInternalServerError, api.APIResponse{Status: "error", Message: err.Error()}) } return } writeJSON(w, http.StatusAccepted, api.APIResponse{Status: "success", Message: "document processing started", Data: result}) } // Delete godoc // // @Summary Hapus dokumen // @Description Menghapus file dari object storage, embeddings, Parquet, dan record database // @Tags documents // @Accept json // @Produce json // @Param body body object{document_id=string,user_id=string} true "Document ID dan User ID" // @Success 200 {object} api.APIResponse // @Failure 400 {object} api.APIResponse // @Failure 403 {object} api.APIResponse // @Failure 404 {object} api.APIResponse // @Failure 500 {object} api.APIResponse // @Security BearerAuth // @Router /api/v1/document/delete [delete] func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { var req struct { DocumentID string `json:"document_id"` UserID string `json:"user_id"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "invalid request body"}) return } if req.DocumentID == "" || req.UserID == "" { writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "document_id and user_id are required"}) return } if rejectUserMismatch(w, r, req.UserID) { return } if err := h.svc.Delete(r.Context(), req.DocumentID, req.UserID); err != nil { switch { case errors.Is(err, ErrNotFound): writeJSON(w, http.StatusNotFound, api.APIResponse{Status: "error", Message: "document not found"}) case errors.Is(err, ErrForbidden): writeJSON(w, http.StatusForbidden, api.APIResponse{Status: "error", Message: "access denied"}) default: writeJSON(w, http.StatusInternalServerError, api.APIResponse{Status: "error", Message: "delete failed"}) } return } writeJSON(w, http.StatusOK, api.APIResponse{Status: "success", Message: "document deleted"}) } func rejectUserMismatch(w http.ResponseWriter, r *http.Request, userID string) bool { if err := auth.MatchContextUserID(r.Context(), userID); err != nil { writeJSON(w, http.StatusForbidden, api.APIResponse{Status: "error", Message: "user_id does not match authenticated user"}) return true } return false } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) }