go-guess/backend/internal/web/handler/handler.go

701 lines
21 KiB
Go
Raw Normal View History

2026-09-19 20:31:14 +02:00
package handler
import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"mime/multipart"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
2026-10-01 09:06:58 +02:00
"gorm.io/gorm"
2026-09-19 20:31:14 +02:00
"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"
2026-09-19 20:31:14 +02:00
"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 {
2026-10-01 09:06:58 +02:00
db *gorm.DB
2026-09-19 20:31:14 +02:00
auth service.IAuthService
jobs service.IJobService
questions service.IQuestionService
participants service.IParticipantService
invitations service.IInvitationService
users service.IUserService
interviews service.IScheduledInterviewService
2026-09-19 20:31:14 +02:00
maxUpload int64
frontendURL string
}
func New(
2026-10-01 09:06:58 +02:00
db *gorm.DB,
2026-09-19 20:31:14 +02:00
auth service.IAuthService,
jobs service.IJobService,
questions service.IQuestionService,
participants service.IParticipantService,
invitations service.IInvitationService,
users service.IUserService,
interviews service.IScheduledInterviewService,
2026-09-19 20:31:14 +02:00
maxUploadMB int64,
frontendURL string,
) *Handler {
return &Handler{
2026-10-01 09:06:58 +02:00
db: db,
2026-09-19 20:31:14 +02:00
auth: auth, jobs: jobs, questions: questions, participants: participants,
invitations: invitations, maxUpload: maxUploadMB << 20,
users: users, interviews: interviews,
2026-09-19 20:31:14 +02:00
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),
2026-09-19 20:31:14 +02:00
})
}
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))
}
2026-09-19 20:31:14 +02:00
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))
}
2026-10-01 09:06:58 +02:00
func (h *Handler) Db() *gorm.DB {
return h.db
}
2026-09-19 20:31:14 +02:00
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))
}
2026-09-19 20:56:27 +02:00
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()
}
}
}
2026-09-19 20:31:14 +02:00
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)
2026-09-19 20:31:14 +02:00
default:
internalError(w, r, err)
}
return true
}
func currentUserID(r *http.Request) uint {
id, _ := security.UserIDFromContext(r.Context())
return id
}
2026-09-19 20:31:14 +02:00
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)
}
}