Spaces:
Sleeping
Sleeping
| package api | |
| import ( | |
| "encoding/json" | |
| "log/slog" | |
| "net/http" | |
| "strconv" | |
| "strings" | |
| "github.com/google/uuid" | |
| "project/internal/auth" | |
| "project/internal/models" | |
| "project/internal/services" | |
| ) | |
| type AnalysisHandler struct { | |
| service services.AnalysisService | |
| logger *slog.Logger | |
| } | |
| type CreateAnalysisRequest struct { | |
| AnalysisTitle string `json:"analysis_title" binding:"required"` | |
| Objective string `json:"objective" binding:"required"` | |
| BusinessQuestions models.BusinessQuestionList `json:"business_questions" binding:"required"` | |
| DataBind models.DataBindList `json:"data_bind" binding:"required"` | |
| } | |
| type UpdateAnalysisRequest struct { | |
| AnalysisTitle string `json:"analysis_title,omitempty"` | |
| Objective string `json:"objective,omitempty"` | |
| Status string `json:"status,omitempty"` | |
| } | |
| type UpdateDataBindRequest struct { | |
| ExpectedVersion int `json:"expected_version"` | |
| DataBind models.DataBindList `json:"data_bind"` | |
| } | |
| type CreateMessageRequest struct { | |
| Role string `json:"role"` | |
| Content string `json:"content"` | |
| // MessageID is required for role=ai; it is always ignored for role=user, | |
| // which gets a system-generated message_id regardless of any value sent here. | |
| MessageID string `json:"message_id,omitempty"` | |
| } | |
| func NewAnalysisHandler(service services.AnalysisService, logger *slog.Logger) *AnalysisHandler { | |
| return &AnalysisHandler{service: service, logger: logger} | |
| } | |
| // Create godoc | |
| // | |
| // @Summary Buat analysis baru | |
| // @Description Membuat analysis aktif dengan business_questions dan minimal satu data source yang sudah dimiliki authenticated user. | |
| // @Tags analyses | |
| // @Accept json | |
| // @Produce json | |
| // @Param request body CreateAnalysisRequest true "Payload analysis baru" | |
| // @Success 201 {object} APIResponse{data=models.Analysis} | |
| // @Failure 400 {object} APIResponse | |
| // @Failure 401 {object} APIResponse | |
| // @Failure 409 {object} APIResponse "Limit active analysis tercapai" | |
| // @Security BearerAuth | |
| // @Router /api/v1/analyses [post] | |
| func (h *AnalysisHandler) Create(w http.ResponseWriter, r *http.Request) { | |
| userID := h.extractUserID(r) | |
| if userID == "" { | |
| writeJSON(w, http.StatusUnauthorized, APIResponse{Status: "error", Message: "User not authenticated"}) | |
| return | |
| } | |
| var req CreateAnalysisRequest | |
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |
| writeJSON(w, http.StatusBadRequest, APIResponse{Status: "error", Message: "Invalid request body"}) | |
| return | |
| } | |
| analysis, err := h.service.CreateAnalysis(r.Context(), req.AnalysisTitle, req.Objective, userID, req.BusinessQuestions, req.DataBind) | |
| if err != nil { | |
| h.writeServiceError(w, err) | |
| return | |
| } | |
| writeJSON(w, http.StatusCreated, APIResponse{Status: "success", Message: "Analysis created", Data: analysis}) | |
| } | |
| // Get godoc | |
| // | |
| // @Summary Ambil detail analysis | |
| // @Description Mengambil satu analysis berdasarkan ID dan authenticated user. Analysis milik user lain tidak akan dikembalikan. | |
| // @Tags analyses | |
| // @Produce json | |
| // @Param id path string true "Analysis ID" | |
| // @Success 200 {object} APIResponse{data=models.Analysis} | |
| // @Failure 400 {object} APIResponse | |
| // @Failure 401 {object} APIResponse | |
| // @Failure 404 {object} APIResponse | |
| // @Security BearerAuth | |
| // @Router /api/v1/analyses/{id} [get] | |
| func (h *AnalysisHandler) Get(w http.ResponseWriter, r *http.Request) { | |
| userID := h.extractUserID(r) | |
| if userID == "" { | |
| writeJSON(w, http.StatusUnauthorized, APIResponse{Status: "error", Message: "User not authenticated"}) | |
| return | |
| } | |
| id, err := uuid.Parse(r.PathValue("id")) | |
| if err != nil { | |
| writeJSON(w, http.StatusBadRequest, APIResponse{Status: "error", Message: "Invalid analysis ID"}) | |
| return | |
| } | |
| analysis, err := h.service.GetAnalysis(r.Context(), id, userID) | |
| if err != nil { | |
| writeJSON(w, http.StatusNotFound, APIResponse{Status: "error", Message: "Analysis not found"}) | |
| return | |
| } | |
| writeJSON(w, http.StatusOK, APIResponse{Status: "success", Message: "Analysis retrieved", Data: analysis}) | |
| } | |
| // List godoc | |
| // | |
| // @Summary List analysis user | |
| // @Description Mengembalikan daftar analysis milik authenticated user. Default status adalah active. | |
| // @Tags analyses | |
| // @Produce json | |
| // @Param status query string false "Filter status: active atau inactive" Enums(active,inactive) | |
| // @Param page query int false "Nomor halaman" default(1) | |
| // @Param limit query int false "Jumlah item per halaman, maksimal 100" default(20) | |
| // @Success 200 {object} APIResponse | |
| // @Failure 401 {object} APIResponse | |
| // @Failure 500 {object} APIResponse | |
| // @Security BearerAuth | |
| // @Router /api/v1/analyses [get] | |
| func (h *AnalysisHandler) List(w http.ResponseWriter, r *http.Request) { | |
| userID := h.extractUserID(r) | |
| if userID == "" { | |
| writeJSON(w, http.StatusUnauthorized, APIResponse{Status: "error", Message: "User not authenticated"}) | |
| return | |
| } | |
| page, limit := parsePageLimit(r, 20, 100) | |
| status := r.URL.Query().Get("status") | |
| if status == "" { | |
| status = "active" | |
| } | |
| analyses, err := h.service.ListAnalyses(r.Context(), userID, status, limit, (page-1)*limit) | |
| if err != nil { | |
| writeJSON(w, http.StatusInternalServerError, APIResponse{Status: "error", Message: "Failed to list analyses"}) | |
| return | |
| } | |
| writeJSON(w, http.StatusOK, APIResponse{Status: "success", Message: "Analyses retrieved", Data: map[string]any{"analyses": analyses, "pagination": map[string]int{"page": page, "limit": limit}}}) | |
| } | |
| // Update godoc | |
| // | |
| // @Summary Update metadata analysis | |
| // @Description Mengubah judul, objective, atau status analysis. Status hanya active atau inactive. | |
| // @Tags analyses | |
| // @Accept json | |
| // @Produce json | |
| // @Param id path string true "Analysis ID" | |
| // @Param request body UpdateAnalysisRequest true "Field yang ingin diubah" | |
| // @Success 200 {object} APIResponse{data=models.Analysis} | |
| // @Failure 400 {object} APIResponse | |
| // @Failure 401 {object} APIResponse | |
| // @Failure 404 {object} APIResponse | |
| // @Security BearerAuth | |
| // @Router /api/v1/analyses/{id} [patch] | |
| func (h *AnalysisHandler) Update(w http.ResponseWriter, r *http.Request) { | |
| userID := h.extractUserID(r) | |
| if userID == "" { | |
| writeJSON(w, http.StatusUnauthorized, APIResponse{Status: "error", Message: "User not authenticated"}) | |
| return | |
| } | |
| id, err := uuid.Parse(r.PathValue("id")) | |
| if err != nil { | |
| writeJSON(w, http.StatusBadRequest, APIResponse{Status: "error", Message: "Invalid analysis ID"}) | |
| return | |
| } | |
| var req UpdateAnalysisRequest | |
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |
| writeJSON(w, http.StatusBadRequest, APIResponse{Status: "error", Message: "Invalid request body"}) | |
| return | |
| } | |
| analysis, err := h.service.UpdateAnalysis(r.Context(), id, userID, req.AnalysisTitle, req.Objective, req.Status) | |
| if err != nil { | |
| h.writeServiceError(w, err) | |
| return | |
| } | |
| writeJSON(w, http.StatusOK, APIResponse{Status: "success", Message: "Analysis updated", Data: analysis}) | |
| } | |
| // Delete godoc | |
| // | |
| // @Summary Hapus analysis | |
| // @Description Menghapus analysis milik authenticated user. | |
| // @Tags analyses | |
| // @Produce json | |
| // @Param id path string true "Analysis ID" | |
| // @Success 204 | |
| // @Failure 400 {object} APIResponse | |
| // @Failure 401 {object} APIResponse | |
| // @Failure 404 {object} APIResponse | |
| // @Security BearerAuth | |
| // @Router /api/v1/analyses/{id} [delete] | |
| func (h *AnalysisHandler) Delete(w http.ResponseWriter, r *http.Request) { | |
| userID := h.extractUserID(r) | |
| if userID == "" { | |
| writeJSON(w, http.StatusUnauthorized, APIResponse{Status: "error", Message: "User not authenticated"}) | |
| return | |
| } | |
| id, err := uuid.Parse(r.PathValue("id")) | |
| if err != nil { | |
| writeJSON(w, http.StatusBadRequest, APIResponse{Status: "error", Message: "Invalid analysis ID"}) | |
| return | |
| } | |
| if err := h.service.DeleteAnalysis(r.Context(), id, userID); err != nil { | |
| h.writeServiceError(w, err) | |
| return | |
| } | |
| w.WriteHeader(http.StatusNoContent) | |
| } | |
| // UpdateDataBind godoc | |
| // | |
| // @Summary Update data source binding analysis | |
| // @Description Mengganti daftar data source yang ter-bind ke analysis secara atomic menggunakan expected_version. Minimal satu data source harus tersisa dan semua source harus milik authenticated user. | |
| // @Tags analyses | |
| // @Accept json | |
| // @Produce json | |
| // @Param id path string true "Analysis ID" | |
| // @Param request body UpdateDataBindRequest true "Versi dan daftar data source baru" | |
| // @Success 200 {object} APIResponse{data=models.Analysis} | |
| // @Failure 400 {object} APIResponse | |
| // @Failure 401 {object} APIResponse | |
| // @Failure 409 {object} APIResponse "Stale version, inactive analysis, atau limit violation" | |
| // @Security BearerAuth | |
| // @Router /api/v1/analyses/{id}/data-bind [put] | |
| func (h *AnalysisHandler) UpdateDataBind(w http.ResponseWriter, r *http.Request) { | |
| userID := h.extractUserID(r) | |
| if userID == "" { | |
| writeJSON(w, http.StatusUnauthorized, APIResponse{Status: "error", Message: "User not authenticated"}) | |
| return | |
| } | |
| id, err := uuid.Parse(r.PathValue("id")) | |
| if err != nil { | |
| writeJSON(w, http.StatusBadRequest, APIResponse{Status: "error", Message: "Invalid analysis ID"}) | |
| return | |
| } | |
| var req UpdateDataBindRequest | |
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |
| writeJSON(w, http.StatusBadRequest, APIResponse{Status: "error", Message: "Invalid request body"}) | |
| return | |
| } | |
| analysis, err := h.service.UpdateDataBind(r.Context(), id, userID, req.ExpectedVersion, req.DataBind) | |
| if err != nil { | |
| h.writeServiceError(w, err) | |
| return | |
| } | |
| writeJSON(w, http.StatusOK, APIResponse{Status: "success", Message: "Data binding updated", Data: analysis}) | |
| } | |
| // ListMessages godoc | |
| // | |
| // @Summary List pesan analysis | |
| // @Description Mengambil riwayat percakapan analysis milik authenticated user dari analyses_messages. Setiap pesan menyertakan status (success|failed) dan note (hanya terisi jika status=failed). | |
| // @Tags analysis-messages | |
| // @Produce json | |
| // @Param id path string true "Analysis ID" | |
| // @Param limit query int false "Jumlah pesan, maksimal 100" default(100) | |
| // @Success 200 {object} APIResponse | |
| // @Failure 400 {object} APIResponse | |
| // @Failure 401 {object} APIResponse | |
| // @Failure 404 {object} APIResponse | |
| // @Security BearerAuth | |
| // @Router /api/v1/analyses/{id}/messages [get] | |
| func (h *AnalysisHandler) ListMessages(w http.ResponseWriter, r *http.Request) { | |
| userID := h.extractUserID(r) | |
| if userID == "" { | |
| writeJSON(w, http.StatusUnauthorized, APIResponse{Status: "error", Message: "User not authenticated"}) | |
| return | |
| } | |
| id, err := uuid.Parse(r.PathValue("id")) | |
| if err != nil { | |
| writeJSON(w, http.StatusBadRequest, APIResponse{Status: "error", Message: "Invalid analysis ID"}) | |
| return | |
| } | |
| _, limit := parsePageLimit(r, 1, 100) | |
| messages, err := h.service.ListMessages(r.Context(), id, userID, limit, 0) | |
| if err != nil { | |
| h.writeServiceError(w, err) | |
| return | |
| } | |
| writeJSON(w, http.StatusOK, APIResponse{Status: "success", Message: "Messages retrieved", Data: map[string]any{"messages": messages}}) | |
| } | |
| // SendMessage godoc | |
| // | |
| // @Summary Tambah pesan conversation analysis | |
| // @Description Menyimpan tepat satu pesan role=user atau role=ai ke analyses_messages. Endpoint ini tidak memanggil agentic backend dan tidak membuat balasan AI otomatis. Untuk role=ai, message_id wajib diisi; jika kosong, pesan tetap tersimpan (201) namun dengan status=failed, message_id default, dan content fallback hardcoded. Untuk role=user, message_id TIDAK PERLU dikirim frontend dan selalu diabaikan — message_id selalu memakai nilai default (system-generated). | |
| // @Tags analysis-messages | |
| // @Accept json | |
| // @Produce json | |
| // @Param id path string true "Analysis ID" | |
| // @Param request body CreateMessageRequest true "Pesan conversation" | |
| // @Success 201 {object} APIResponse | |
| // @Failure 400 {object} APIResponse | |
| // @Failure 401 {object} APIResponse | |
| // @Failure 409 {object} APIResponse "Analysis inactive" | |
| // @Security BearerAuth | |
| // @Router /api/v1/analyses/{id}/messages [post] | |
| func (h *AnalysisHandler) SendMessage(w http.ResponseWriter, r *http.Request) { | |
| userID := h.extractUserID(r) | |
| if userID == "" { | |
| writeJSON(w, http.StatusUnauthorized, APIResponse{Status: "error", Message: "User not authenticated"}) | |
| return | |
| } | |
| id, err := uuid.Parse(r.PathValue("id")) | |
| if err != nil { | |
| writeJSON(w, http.StatusBadRequest, APIResponse{Status: "error", Message: "Invalid analysis ID"}) | |
| return | |
| } | |
| var req CreateMessageRequest | |
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |
| writeJSON(w, http.StatusBadRequest, APIResponse{Status: "error", Message: "Invalid request body"}) | |
| return | |
| } | |
| messageID := uuid.Nil | |
| if req.Role == models.RoleAI { | |
| if trimmed := strings.TrimSpace(req.MessageID); trimmed != "" { | |
| if parsed, parseErr := uuid.Parse(trimmed); parseErr == nil { | |
| messageID = parsed | |
| } | |
| } | |
| } | |
| message, err := h.service.AppendMessage(r.Context(), id, userID, req.Role, req.Content, messageID) | |
| if err != nil { | |
| h.writeServiceError(w, err) | |
| return | |
| } | |
| writeJSON(w, http.StatusCreated, APIResponse{Status: "success", Message: "Message created", Data: map[string]any{"message": message}}) | |
| } | |
| // GenerateReport godoc | |
| // | |
| // @Summary Generate report analysis | |
| // @Description Membuat report placeholder untuk analysis aktif setelah jumlah pesan user memenuhi batas minimum. | |
| // @Tags analysis-reports | |
| // @Produce json | |
| // @Param id path string true "Analysis ID" | |
| // @Success 201 {object} APIResponse{data=models.Report} | |
| // @Failure 400 {object} APIResponse | |
| // @Failure 401 {object} APIResponse | |
| // @Failure 409 {object} APIResponse "Minimum messages belum terpenuhi, inactive analysis, atau report limit" | |
| func (h *AnalysisHandler) GenerateReport(w http.ResponseWriter, r *http.Request) { | |
| userID := h.extractUserID(r) | |
| if userID == "" { | |
| writeJSON(w, http.StatusUnauthorized, APIResponse{Status: "error", Message: "User not authenticated"}) | |
| return | |
| } | |
| id, err := uuid.Parse(r.PathValue("id")) | |
| if err != nil { | |
| writeJSON(w, http.StatusBadRequest, APIResponse{Status: "error", Message: "Invalid analysis ID"}) | |
| return | |
| } | |
| report, err := h.service.GenerateReport(r.Context(), id, userID) | |
| if err != nil { | |
| h.writeServiceError(w, err) | |
| return | |
| } | |
| writeJSON(w, http.StatusCreated, APIResponse{Status: "success", Message: "Report generated", Data: report}) | |
| } | |
| // ListReports godoc | |
| // | |
| // @Summary List report analysis | |
| // @Description Mengambil daftar report yang pernah dibuat untuk analysis milik X-User-ID. | |
| // @Tags analysis-reports | |
| // @Produce json | |
| // @Param id path string true "Analysis ID" | |
| // @Param page query int false "Nomor halaman" default(1) | |
| // @Param limit query int false "Jumlah report, maksimal 100" default(100) | |
| // @Success 200 {object} APIResponse | |
| // @Failure 400 {object} APIResponse | |
| // @Failure 401 {object} APIResponse | |
| // @Failure 404 {object} APIResponse | |
| func (h *AnalysisHandler) ListReports(w http.ResponseWriter, r *http.Request) { | |
| userID := h.extractUserID(r) | |
| if userID == "" { | |
| writeJSON(w, http.StatusUnauthorized, APIResponse{Status: "error", Message: "User not authenticated"}) | |
| return | |
| } | |
| id, err := uuid.Parse(r.PathValue("id")) | |
| if err != nil { | |
| writeJSON(w, http.StatusBadRequest, APIResponse{Status: "error", Message: "Invalid analysis ID"}) | |
| return | |
| } | |
| page, limit := parsePageLimit(r, 100, 100) | |
| reports, err := h.service.ListReports(r.Context(), id, userID, limit, (page-1)*limit) | |
| if err != nil { | |
| h.writeServiceError(w, err) | |
| return | |
| } | |
| writeJSON(w, http.StatusOK, APIResponse{Status: "success", Message: "Reports retrieved", Data: map[string]any{"reports": reports}}) | |
| } | |
| func (h *AnalysisHandler) extractUserID(r *http.Request) string { | |
| if userID := auth.UserIDFromContext(r.Context()); userID != "" { | |
| return userID | |
| } | |
| return r.Header.Get("X-User-ID") | |
| } | |
| func (h *AnalysisHandler) writeServiceError(w http.ResponseWriter, err error) { | |
| msg := err.Error() | |
| code := ErrorCodeValidation | |
| status := http.StatusBadRequest | |
| if stringsContains(msg, "maximum") { | |
| code = ErrorCodeLimitExceeded | |
| status = http.StatusConflict | |
| } | |
| if stringsContains(msg, "stale") { | |
| code = ErrorCodeStaleVersion | |
| status = http.StatusConflict | |
| } | |
| if stringsContains(msg, "inactive") { | |
| code = ErrorCodeInactiveAnalysis | |
| } | |
| writeJSON(w, status, APIResponse{Status: "error", Message: msg, Data: map[string]string{"code": code}}) | |
| } | |
| func parsePageLimit(r *http.Request, defaultLimit, maxLimit int) (int, int) { | |
| page := 1 | |
| limit := defaultLimit | |
| if p := r.URL.Query().Get("page"); p != "" { | |
| if v, err := strconv.Atoi(p); err == nil && v > 0 { | |
| page = v | |
| } | |
| } | |
| if l := r.URL.Query().Get("limit"); l != "" { | |
| if v, err := strconv.Atoi(l); err == nil && v > 0 && v <= maxLimit { | |
| limit = v | |
| } | |
| } | |
| return page, limit | |
| } | |
| func stringsContains(s, sub string) bool { | |
| return len(sub) == 0 || (len(s) >= len(sub) && (s == sub || strings.Contains(s, sub))) | |
| } | |