harryagasi
feat(analysis): add success/failed status to chat messages
981b42b
Raw
History Blame Contribute Delete
16.9 kB
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)))
}