package api import ( "encoding/json" "errors" "fmt" "net/http" "strings" "project/internal/app" "project/internal/domain" "project/internal/framework" ) type HTTPHandler struct { Registry framework.Registry AppService *app.Service } func NewHTTPHandler(registry framework.Registry, appService *app.Service) *HTTPHandler { return &HTTPHandler{ Registry: registry, AppService: appService, } } // Health godoc // // @Summary Health check // @Description Returns holistic readiness and dependency health for the service. // @Tags system // @Produce json // @Success 200 {object} map[string]interface{} "Health report" // @Failure 503 {object} map[string]interface{} "Critical dependency unavailable" // @Router /health [get] func (h *HTTPHandler) Health(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"status": "ok"}) } // ListFrameworks godoc // // @Produce json // @Success 200 {array} object{id=string,name=string} func (h *HTTPHandler) ListFrameworks(w http.ResponseWriter, r *http.Request) { items := h.Registry.List() type item struct { ID string `json:"id"` Name string `json:"name"` } out := []item{} for _, fw := range items { out = append(out, item{ID: fw.Framework.ID, Name: fw.Framework.Name}) } writeJSON(w, http.StatusOK, out) } // CreateSession godoc // // @Accept json // @Produce json // @Param request body CreateSessionRequest true "Request body" // @Success 201 {object} CreateSessionResponse // @Failure 400 {object} ErrorResponse func (h *HTTPHandler) CreateSession(w http.ResponseWriter, r *http.Request) { var req CreateSessionRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if req.FrameworkID == "" { writeError(w, http.StatusBadRequest, "framework_id is required") return } if req.UserID == "" { writeError(w, http.StatusBadRequest, "user_id is required") return } if req.RoomID == "" { writeError(w, http.StatusBadRequest, "room_id is required") return } if req.Language == "" { req.Language = "id-ID" } result, err := h.AppService.StartSession(r.Context(), req.FrameworkID, req.Language, req.UserID, req.RoomID, parseMode(req.Mode)) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusCreated, CreateSessionResponse{ SessionID: result.SessionID, FrameworkID: result.FrameworkID, RoomID: result.RoomID, Status: "active", OpeningMessage: result.OpeningMessage, FirstQuestion: result.FirstQuestion, }) } // SendMessage godoc // // @Accept json // @Produce json // @Param id path string true "Session ID" // @Param request body SendTextRequest true "Request body" // @Success 200 {object} SendTextResponse // @Failure 400 {object} ErrorResponse // @Failure 500 {object} ErrorResponse func (h *HTTPHandler) SendMessage(w http.ResponseWriter, r *http.Request) { sessionID := r.PathValue("id") if sessionID == "" { writeError(w, http.StatusBadRequest, "session_id is required") return } var req SendTextRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if req.Message == "" { writeError(w, http.StatusBadRequest, "message is required") return } if strings.TrimSpace(req.Message) == "/finish" { h.handleFinishCommand(w, r, sessionID) return } result, err := h.AppService.SendMessage(r.Context(), sessionID, req.Message) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, SendTextResponse{Reply: result.Reply, Stage: result.Stage, Finished: result.Finished}) } // FinishSession godoc // // @Produce json // @Param id path string true "Session ID" // @Success 200 {object} FinishSessionResponse // @Failure 400 {object} ErrorResponse // @Failure 500 {object} ErrorResponse func (h *HTTPHandler) FinishSession(w http.ResponseWriter, r *http.Request) { sessionID := r.PathValue("id") if sessionID == "" { writeError(w, http.StatusBadRequest, "session_id is required") return } result, err := h.AppService.FinishSession(r.Context(), sessionID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, FinishSessionResponse{ SessionID: sessionID, RoomID: result.RoomID, Status: "completed", Result: toInterviewResultDTO(result.Result), }) } // GetRoomSession godoc // // @Produce json // @Param room_id path string true "Room ID" // @Success 200 {object} RoomSessionResponse // @Failure 404 {object} ErrorResponse func (h *HTTPHandler) GetRoomSession(w http.ResponseWriter, r *http.Request) { roomID := r.PathValue("room_id") if roomID == "" { writeError(w, http.StatusBadRequest, "room_id is required") return } res, err := h.AppService.GetRoomSession(r.Context(), roomID) if err != nil { writeError(w, http.StatusNotFound, err.Error()) return } turns := make([]TurnDTO, 0, len(res.Turns)) for _, t := range res.Turns { turns = append(turns, TurnDTO{ Speaker: t.Speaker, Text: t.Text, CreatedAt: t.CreatedAt, }) } writeJSON(w, http.StatusOK, RoomSessionResponse{ SessionID: res.Session.SessionID, RoomID: res.Session.RoomID, FrameworkID: res.Session.FrameworkID, FrameworkName: res.Session.FrameworkName, Status: string(res.Session.Status), Mode: string(res.Session.Mode), Language: res.Session.Language, StartedAt: res.Session.StartedAt, Turns: turns, }) } // GetRoomResult godoc // // @Produce json // @Param room_id path string true "Room ID" // @Success 200 {object} InterviewResultDTO // @Failure 404 {object} ErrorResponse func (h *HTTPHandler) GetRoomResult(w http.ResponseWriter, r *http.Request) { roomID := r.PathValue("room_id") if roomID == "" { writeError(w, http.StatusBadRequest, "room_id is required") return } res, err := h.AppService.GetRoomResult(roomID) if err != nil { if errors.Is(err, app.ErrInterviewNotFinished) { writeError(w, http.StatusNotFound, err.Error()) } else { writeError(w, http.StatusInternalServerError, "failed to retrieve interview result") } return } writeJSON(w, http.StatusOK, toInterviewResultDTO(res)) } func (h *HTTPHandler) handleFinishCommand(w http.ResponseWriter, r *http.Request, sessionID string) { result, err := h.AppService.FinishSession(r.Context(), sessionID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, FinishSessionResponse{ SessionID: sessionID, RoomID: result.RoomID, Status: "completed", Result: toInterviewResultDTO(result.Result), }) } func toInterviewResultDTO(r *domain.InterviewResult) *InterviewResultDTO { if r == nil { return nil } sections := make([]SectionResultDTO, 0, len(r.SectionResults)) for _, s := range r.SectionResults { pairs := make([]QAPairDTO, 0, len(s.QAPairs)) for _, p := range s.QAPairs { pairs = append(pairs, toQAPairDTO(p)) } sections = append(sections, SectionResultDTO{ SectionTitle: s.SectionTitle, Objective: s.Objective, QAPairs: pairs, SectionSummary: s.SectionSummary, }) } return &InterviewResultDTO{ FrameworkName: r.FrameworkName, Mode: r.Mode, Language: r.Language, StartedAt: r.StartedAt, EndedAt: r.EndedAt, Summary: r.Summary, Goals: r.Goals, SectionResults: sections, KeyInsights: r.KeyInsights, UnresolvedItems: r.UnresolvedItems, } } func toQAPairDTO(p domain.QAPair) QAPairDTO { followUps := make([]QAPairDTO, 0, len(p.FollowUps)) for _, f := range p.FollowUps { followUps = append(followUps, toQAPairDTO(f)) } dto := QAPairDTO{ QuestionText: p.QuestionText, AnswerCleaned: p.AnswerCleaned, } if len(followUps) > 0 { dto.FollowUps = followUps } return dto } // StreamMessage godoc // // @Accept json // @Produce text/event-stream // @Param id path string true "Session ID" // @Param request body SendTextRequest true "Request body" // @Success 200 {string} string "Server-Sent Events stream token per event, ending with JSON {\"finished\":bool,\"stage\":string}" // @Failure 400 {object} ErrorResponse // @Failure 500 {object} ErrorResponse func (h *HTTPHandler) StreamMessage(w http.ResponseWriter, r *http.Request) { sessionID := r.PathValue("id") if sessionID == "" { writeError(w, http.StatusBadRequest, "session_id is required") return } var req SendTextRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if req.Message == "" { writeError(w, http.StatusBadRequest, "message is required") return } if strings.TrimSpace(req.Message) == "/finish" { h.handleFinishCommand(w, r, sessionID) return } flusher, ok := w.(http.Flusher) if !ok { writeError(w, http.StatusInternalServerError, "streaming not supported") return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("X-Accel-Buffering", "no") w.Header().Set("Connection", "keep-alive") w.WriteHeader(http.StatusOK) result, err := h.AppService.StreamMessage(r.Context(), sessionID, req.Message, func(chunk string) { fmt.Fprintf(w, "data: %s\n\n", chunk) flusher.Flush() }) if err != nil { fmt.Fprintf(w, "data: {\"error\":%q}\n\n", err.Error()) flusher.Flush() return } meta, _ := json.Marshal(map[string]any{"finished": result.Finished, "stage": result.Stage}) fmt.Fprintf(w, "data: %s\n\n", string(meta)) flusher.Flush() } func parseMode(s string) domain.InterviewMode { if s == "audio" { return domain.ModeAudio } return domain.ModeText } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, ErrorResponse{Error: msg}) }