package handler import ( "encoding/json" "errors" "fmt" "io" "log/slog" "mime/multipart" "net/http" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "github.com/danyel/go-guess/backend/internal/database/repository" "github.com/danyel/go-guess/backend/internal/eventbus" "github.com/danyel/go-guess/backend/internal/security" "github.com/danyel/go-guess/backend/internal/service" servicemodel "github.com/danyel/go-guess/backend/internal/service/model" webmapper "github.com/danyel/go-guess/backend/internal/web/mapper" webmodel "github.com/danyel/go-guess/backend/internal/web/model" ) type Handler struct { auth service.IAuthService jobs service.IJobService questions service.IQuestionService participants service.IParticipantService invitations service.IInvitationService users service.IUserService interviews service.IScheduledInterviewService maxUpload int64 frontendURL string } func New( auth service.IAuthService, jobs service.IJobService, questions service.IQuestionService, participants service.IParticipantService, invitations service.IInvitationService, users service.IUserService, interviews service.IScheduledInterviewService, maxUploadMB int64, frontendURL string, ) *Handler { return &Handler{ auth: auth, jobs: jobs, questions: questions, participants: participants, invitations: invitations, maxUpload: maxUploadMB << 20, users: users, interviews: interviews, frontendURL: strings.TrimRight(frontendURL, "/"), } } func (h *Handler) Health(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { var request webmodel.LoginRequest if err := decodeJSON(r, &request); err != nil { writeError(w, http.StatusBadRequest, err) return } token, user, err := h.auth.Login(r.Context(), request.Email, request.Password) if err != nil { if errors.Is(err, service.ErrInvalidCredentials) { writeError(w, http.StatusUnauthorized, err) return } internalError(w, r, err) return } writeJSON(w, http.StatusOK, webmodel.LoginResponse{ Token: token, User: webmapper.UserToWeb(user), }) } func (h *Handler) ListUsers(w http.ResponseWriter, r *http.Request) { values, err := h.users.List(r.Context()) if err != nil { internalError(w, r, err) return } response := make([]webmodel.User, len(values)) for i, value := range values { response[i] = webmapper.UserToWeb(value) } writeJSON(w, http.StatusOK, response) } func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) { var request webmodel.CreateUserRequest if err := decodeJSON(r, &request); err != nil { writeError(w, http.StatusBadRequest, err) return } value, err := h.users.Create(r.Context(), request.Email, request.Password, request.DisplayName) if handleServiceError(w, r, err) { return } writeJSON(w, http.StatusCreated, webmapper.UserToWeb(value)) } func (h *Handler) ListJobs(w http.ResponseWriter, r *http.Request) { values, err := h.jobs.List(r.Context()) if err != nil { internalError(w, r, err) return } response := make([]webmodel.JobResponse, len(values)) for i, value := range values { response[i] = webmapper.JobToWeb(value) } writeJSON(w, http.StatusOK, response) } func (h *Handler) GetJob(w http.ResponseWriter, r *http.Request) { value, err := h.jobs.Get(r.Context(), idParam(r)) if handleServiceError(w, r, err) { return } writeJSON(w, http.StatusOK, webmapper.JobToWeb(value)) } func (h *Handler) CreateJob(w http.ResponseWriter, r *http.Request) { var request webmodel.JobRequest if err := decodeJSON(r, &request); err != nil { writeError(w, http.StatusBadRequest, err) return } value, err := h.jobs.Create(r.Context(), webmapper.JobToService(request)) if err != nil { handleServiceError(w, r, err) return } writeJSON(w, http.StatusCreated, webmapper.JobToWeb(value)) } func (h *Handler) UpdateJob(w http.ResponseWriter, r *http.Request) { var request webmodel.JobStatusRequest if err := decodeJSON(r, &request); err != nil { writeError(w, http.StatusBadRequest, err) return } value, err := h.jobs.Update(r.Context(), idParam(r), request.Status, request.DurationMinutes) if handleServiceError(w, r, err) { return } writeJSON(w, http.StatusOK, webmapper.JobToWeb(value)) } func (h *Handler) ListQuestions(w http.ResponseWriter, r *http.Request) { values, err := h.questions.List(r.Context(), r.URL.Query().Get("search")) if err != nil { internalError(w, r, err) return } response := make([]webmodel.QuestionResponse, len(values)) for i, value := range values { response[i] = webmapper.QuestionToWeb(value) } writeJSON(w, http.StatusOK, response) } func (h *Handler) CreateQuestion(w http.ResponseWriter, r *http.Request) { var request webmodel.QuestionRequest if err := decodeJSON(r, &request); err != nil { writeError(w, http.StatusBadRequest, err) return } value, err := h.questions.Create(r.Context(), webmapper.QuestionToService(request)) if err != nil { handleServiceError(w, r, err) return } writeJSON(w, http.StatusCreated, webmapper.QuestionToWeb(value)) } func (h *Handler) UpdateQuestion(w http.ResponseWriter, r *http.Request) { var request webmodel.QuestionRequest if err := decodeJSON(r, &request); err != nil { writeError(w, http.StatusBadRequest, err) return } value, err := h.questions.Update(r.Context(), idParam(r), webmapper.QuestionToService(request)) if handleServiceError(w, r, err) { return } writeJSON(w, http.StatusOK, webmapper.QuestionToWeb(value)) } func (h *Handler) SetQuestionDeprecated(w http.ResponseWriter, r *http.Request) { var request webmodel.QuestionStatusRequest if err := decodeJSON(r, &request); err != nil { writeError(w, http.StatusBadRequest, err) return } value, err := h.questions.SetDeprecated(r.Context(), idParam(r), request.Deprecated) if handleServiceError(w, r, err) { return } writeJSON(w, http.StatusOK, webmapper.QuestionToWeb(value)) } func (h *Handler) AttachQuestion(w http.ResponseWriter, r *http.Request) { if err := h.jobs.AttachQuestion(r.Context(), idParam(r), secondaryIDParam(r, "questionId")); handleServiceError(w, r, err) { return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) DetachQuestion(w http.ResponseWriter, r *http.Request) { if err := h.jobs.DetachQuestion(r.Context(), idParam(r), secondaryIDParam(r, "questionId")); handleServiceError(w, r, err) { return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) Candidates(w http.ResponseWriter, r *http.Request) { values, err := h.jobs.Candidates(r.Context(), idParam(r)) if handleServiceError(w, r, err) { return } response := make([]webmodel.CandidateResponse, len(values)) for i, value := range values { response[i] = webmapper.CandidateToWeb(value) } writeJSON(w, http.StatusOK, response) } func (h *Handler) ListParticipants(w http.ResponseWriter, r *http.Request) { values, err := h.participants.List(r.Context()) if err != nil { internalError(w, r, err) return } response := make([]webmodel.ParticipantResponse, len(values)) for i, value := range values { response[i] = webmapper.ParticipantToWeb(value) } writeJSON(w, http.StatusOK, response) } func (h *Handler) GetParticipant(w http.ResponseWriter, r *http.Request) { value, err := h.participants.Get(r.Context(), idParam(r)) if handleServiceError(w, r, err) { return } writeJSON(w, http.StatusOK, webmapper.ParticipantToWeb(value)) } func (h *Handler) CreateParticipant(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, h.maxUpload) if err := r.ParseMultipartForm(h.maxUpload); err != nil { writeError(w, http.StatusBadRequest, fmt.Errorf("invalid multipart form or upload too large")) return } participant := servicemodel.Participant{ FirstName: r.FormValue("firstName"), LastName: r.FormValue("lastName"), Email: r.FormValue("email"), ContactInfo: r.FormValue("contactInfo"), } if raw := r.FormValue("birthday"); raw != "" { birthday, err := time.Parse("2006-01-02", raw) if err != nil { writeError(w, http.StatusBadRequest, fmt.Errorf("birthday must use YYYY-MM-DD")) return } participant.Birthday = &birthday } var err error participant.Photo, participant.PhotoType, _, err = readUpload(r, "photo") if err != nil { writeError(w, http.StatusBadRequest, err) return } participant.CV, _, participant.CVFilename, err = readUpload(r, "cv") if err != nil { writeError(w, http.StatusBadRequest, err) return } value, err := h.participants.Create(r.Context(), participant) if err != nil { handleServiceError(w, r, err) return } writeJSON(w, http.StatusCreated, webmapper.ParticipantToWeb(value)) } func (h *Handler) ParticipantPhoto(w http.ResponseWriter, r *http.Request) { value, err := h.participants.Get(r.Context(), idParam(r)) if handleServiceError(w, r, err) { return } if len(value.Photo) == 0 { http.NotFound(w, r) return } contentType := value.PhotoType switch contentType { case "image/jpeg", "image/png", "image/gif", "image/webp": default: contentType = "application/octet-stream" } w.Header().Set("Content-Type", contentType) w.Header().Set("Content-Disposition", "inline") w.Header().Set("X-Content-Type-Options", "nosniff") _, _ = w.Write(value.Photo) } func (h *Handler) ParticipantCV(w http.ResponseWriter, r *http.Request) { value, err := h.participants.Get(r.Context(), idParam(r)) if handleServiceError(w, r, err) { return } if len(value.CV) == 0 { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", value.CVFilename)) _, _ = w.Write(value.CV) } func (h *Handler) ListInvitations(w http.ResponseWriter, r *http.Request) { values, err := h.invitations.List(r.Context(), idParam(r)) if err != nil { internalError(w, r, err) return } response := make([]webmodel.InvitationResponse, len(values)) for i, value := range values { response[i] = webmapper.InvitationToWeb(value, h.frontendURL) } writeJSON(w, http.StatusOK, response) } func (h *Handler) CreateInvitation(w http.ResponseWriter, r *http.Request) { var request webmodel.InvitationRequest if err := decodeJSON(r, &request); err != nil { writeError(w, http.StatusBadRequest, err) return } value, err := h.invitations.Create(r.Context(), idParam(r), request.ParticipantID) if handleServiceError(w, r, err) { return } writeJSON(w, http.StatusCreated, webmapper.InvitationToWeb(value, h.frontendURL)) } func (h *Handler) ReviewInvitation(w http.ResponseWriter, r *http.Request) { value, err := h.invitations.Review(r.Context(), idParam(r), secondaryIDParam(r, "invitationId")) if handleServiceError(w, r, err) { return } writeJSON(w, http.StatusOK, webmapper.InvitationReviewToWeb(value, h.frontendURL)) } func (h *Handler) SetInvitationOutcome(w http.ResponseWriter, r *http.Request) { var request webmodel.InvitationOutcomeRequest if err := decodeJSON(r, &request); err != nil { writeError(w, http.StatusBadRequest, err) return } value, err := h.invitations.SetOutcome( r.Context(), idParam(r), secondaryIDParam(r, "invitationId"), request.Outcome, ) if handleServiceError(w, r, err) { return } writeJSON(w, http.StatusOK, webmapper.InvitationToWeb(value, h.frontendURL)) } func (h *Handler) CreateScheduledInterview(w http.ResponseWriter, r *http.Request) { var request webmodel.CreateScheduledInterviewRequest if err := decodeJSON(r, &request); err != nil { writeError(w, http.StatusBadRequest, err) return } value, err := h.interviews.Create(r.Context(), currentUserID(r), idParam(r), servicemodel.ScheduledInterview{ InvitationID: request.InvitationID, StartsAt: request.StartsAt, Location: request.Location, SharedDocument: request.SharedDocument, }, request.InterviewerIDs) if handleServiceError(w, r, err) { return } writeJSON(w, http.StatusCreated, webmapper.ScheduledInterviewToWeb(value, h.frontendURL)) } func (h *Handler) ListScheduledInterviews(w http.ResponseWriter, r *http.Request) { values, err := h.interviews.List(r.Context(), currentUserID(r), idParam(r)) if handleServiceError(w, r, err) { return } response := make([]webmodel.ScheduledInterviewResponse, len(values)) for i, value := range values { response[i] = webmapper.ScheduledInterviewToWeb(value, h.frontendURL) } writeJSON(w, http.StatusOK, response) } func (h *Handler) GetScheduledInterview(w http.ResponseWriter, r *http.Request) { value, err := h.interviews.Get(r.Context(), currentUserID(r), idParam(r)) if handleServiceError(w, r, err) { return } writeJSON(w, http.StatusOK, webmapper.ScheduledInterviewToWeb(value, h.frontendURL)) } func (h *Handler) UpdateScheduledInterviewStatus(w http.ResponseWriter, r *http.Request) { var request webmodel.InterviewStatusRequest if err := decodeJSON(r, &request); err != nil { writeError(w, http.StatusBadRequest, err) return } value, err := h.interviews.UpdateStatus(r.Context(), currentUserID(r), idParam(r), request.Status) if handleServiceError(w, r, err) { return } writeJSON(w, http.StatusOK, webmapper.ScheduledInterviewToWeb(value, h.frontendURL)) } func (h *Handler) UpdateScheduledInterviewDocument(w http.ResponseWriter, r *http.Request) { var request webmodel.InterviewDocumentRequest if err := decodeJSON(r, &request); err != nil { writeError(w, http.StatusBadRequest, err) return } value, err := h.interviews.UpdateDocument( r.Context(), currentUserID(r), idParam(r), request.SharedDocument, ) if handleServiceError(w, r, err) { return } writeJSON(w, http.StatusOK, webmapper.ScheduledInterviewToWeb(value, h.frontendURL)) } func (h *Handler) ListInterviewNotes(w http.ResponseWriter, r *http.Request) { values, err := h.interviews.ListNotes(r.Context(), currentUserID(r), idParam(r)) if handleServiceError(w, r, err) { return } response := make([]webmodel.InterviewNoteResponse, len(values)) for i, value := range values { response[i] = webmapper.InterviewNoteToWeb(value) } writeJSON(w, http.StatusOK, response) } func (h *Handler) CreateInterviewNote(w http.ResponseWriter, r *http.Request) { var request webmodel.InterviewNoteRequest if err := decodeJSON(r, &request); err != nil { writeError(w, http.StatusBadRequest, err) return } value, err := h.interviews.CreateNote(r.Context(), currentUserID(r), idParam(r), request.Body) if handleServiceError(w, r, err) { return } writeJSON(w, http.StatusCreated, webmapper.InterviewNoteToWeb(value)) } func (h *Handler) ListInbox(w http.ResponseWriter, r *http.Request) { values, err := h.interviews.ListInbox(r.Context(), currentUserID(r)) if handleServiceError(w, r, err) { return } response := make([]webmodel.ScheduledInterviewResponse, len(values)) for i, value := range values { response[i] = webmapper.ScheduledInterviewToWeb(value, h.frontendURL) } writeJSON(w, http.StatusOK, response) } func (h *Handler) UpdateInbox(w http.ResponseWriter, r *http.Request) { var request webmodel.AttendeeStatusRequest if err := decodeJSON(r, &request); err != nil { writeError(w, http.StatusBadRequest, err) return } value, err := h.interviews.UpdateInbox(r.Context(), currentUserID(r), idParam(r), request.Status) if handleServiceError(w, r, err) { return } writeJSON(w, http.StatusOK, webmapper.ScheduledInterviewToWeb(value, h.frontendURL)) } func (h *Handler) ListCalendar(w http.ResponseWriter, r *http.Request) { values, err := h.interviews.ListCalendar(r.Context(), currentUserID(r)) if handleServiceError(w, r, err) { return } response := make([]webmodel.ScheduledInterviewResponse, len(values)) for i, value := range values { response[i] = webmapper.ScheduledInterviewToWeb(value, h.frontendURL) } writeJSON(w, http.StatusOK, response) } func (h *Handler) GetParticipantMeeting(w http.ResponseWriter, r *http.Request) { value, err := h.interviews.GetParticipantMeeting(r.Context(), chi.URLParam(r, "token")) if handleServiceError(w, r, err) { return } writeJSON(w, http.StatusOK, webmapper.ParticipantMeetingToWeb(value)) } func (h *Handler) InterviewEvents(w http.ResponseWriter, r *http.Request) { events, err := h.interviews.Subscribe(r.Context(), currentUserID(r), idParam(r)) if handleServiceError(w, r, err) { return } streamInterviewEvents(w, r, events) } func (h *Handler) ParticipantMeetingEvents(w http.ResponseWriter, r *http.Request) { events, err := h.interviews.SubscribeParticipant(r.Context(), chi.URLParam(r, "token")) if handleServiceError(w, r, err) { return } streamInterviewEvents(w, r, events) } func streamInterviewEvents( w http.ResponseWriter, r *http.Request, events <-chan eventbus.InterviewEvent, ) { flusher, ok := w.(http.Flusher) if !ok { internalError(w, r, errors.New("streaming is unsupported")) return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("X-Accel-Buffering", "no") w.WriteHeader(http.StatusOK) flusher.Flush() heartbeat := time.NewTicker(20 * time.Second) defer heartbeat.Stop() for { select { case <-r.Context().Done(): return case <-heartbeat.C: if _, err := io.WriteString(w, ": keepalive\n\n"); err != nil { return } flusher.Flush() case event, ok := <-events: if !ok { return } payload, err := json.Marshal(event) if err != nil { return } if _, err := fmt.Fprintf(w, "event: interview-event\ndata: %s\n\n", payload); err != nil { return } flusher.Flush() } } } func (h *Handler) GetInterview(w http.ResponseWriter, r *http.Request) { value, err := h.invitations.GetInterview(r.Context(), chi.URLParam(r, "token")) if handleServiceError(w, r, err) { return } writeJSON(w, http.StatusOK, webmapper.InterviewToWeb(value, h.frontendURL)) } func (h *Handler) AcceptInterview(w http.ResponseWriter, r *http.Request) { value, err := h.invitations.Accept(r.Context(), chi.URLParam(r, "token")) if handleServiceError(w, r, err) { return } writeJSON(w, http.StatusOK, webmapper.InterviewToWeb(value, h.frontendURL)) } func (h *Handler) SaveAnswer(w http.ResponseWriter, r *http.Request) { var request webmodel.AnswerRequest if err := decodeJSON(r, &request); err != nil { writeError(w, http.StatusBadRequest, err) return } err := h.invitations.SaveAnswer( r.Context(), chi.URLParam(r, "token"), secondaryIDParam(r, "questionId"), request.Answer, ) if handleServiceError(w, r, err) { return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) FinishInterview(w http.ResponseWriter, r *http.Request) { value, err := h.invitations.Finish(r.Context(), chi.URLParam(r, "token")) if handleServiceError(w, r, err) { return } writeJSON(w, http.StatusOK, webmapper.InterviewToWeb(value, h.frontendURL)) } func readUpload(r *http.Request, field string) ([]byte, string, string, error) { file, header, err := r.FormFile(field) if errors.Is(err, http.ErrMissingFile) { return nil, "", "", nil } if err != nil { return nil, "", "", fmt.Errorf("read %s: %w", field, err) } defer file.Close() content, err := io.ReadAll(file) if err != nil { return nil, "", "", fmt.Errorf("read %s: %w", field, err) } return content, header.Header.Get("Content-Type"), safeFilename(header), nil } func safeFilename(header *multipart.FileHeader) string { return strings.ReplaceAll(strings.ReplaceAll(header.Filename, "/", "_"), "\\", "_") } func idParam(r *http.Request) uint { id, _ := strconv.ParseUint(chi.URLParam(r, "id"), 10, 64) return uint(id) } func secondaryIDParam(r *http.Request, name string) uint { id, _ := strconv.ParseUint(chi.URLParam(r, name), 10, 64) return uint(id) } func decodeJSON(r *http.Request, target any) error { decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { return fmt.Errorf("invalid request body: %w", err) } if err := decoder.Decode(&struct{}{}); err != io.EOF { return fmt.Errorf("request body must contain one JSON value") } return nil } func handleServiceError(w http.ResponseWriter, r *http.Request, err error) bool { if err == nil { return false } switch { case errors.Is(err, repository.ErrNotFound): writeError(w, http.StatusNotFound, err) case errors.Is(err, repository.ErrConflict): writeError(w, http.StatusConflict, err) case errors.Is(err, service.ErrValidation), errors.Is(err, service.ErrInvalidQuestion): writeError(w, http.StatusBadRequest, err) case errors.Is(err, service.ErrInvalidState): writeError(w, http.StatusConflict, err) case errors.Is(err, service.ErrNotAvailable): writeError(w, http.StatusNotFound, err) case errors.Is(err, service.ErrForbidden): writeError(w, http.StatusForbidden, err) default: internalError(w, r, err) } return true } func currentUserID(r *http.Request) uint { id, _ := security.UserIDFromContext(r.Context()) return id } func internalError(w http.ResponseWriter, r *http.Request, err error) { slog.ErrorContext(r.Context(), "request failed", "method", r.Method, "path", r.URL.Path, "error", err) writeError(w, http.StatusInternalServerError, errors.New("internal server error")) } func writeError(w http.ResponseWriter, status int, err error) { writeJSON(w, status, webmodel.ErrorResponse{Error: err.Error()}) } func writeJSON(w http.ResponseWriter, status int, value any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(value); err != nil { slog.Error("encode response", "error", err) } }