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