749 lines
23 KiB
Go
749 lines
23 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/danyel/go-loose/client"
|
|
"github.com/go-chi/chi/v5"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/danyel/go-guess/backend/internal/database/repository"
|
|
"github.com/danyel/go-guess/backend/internal/eventbus"
|
|
"github.com/danyel/go-guess/backend/internal/golooseauth"
|
|
"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 {
|
|
db *gorm.DB
|
|
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
|
|
browser *golooseauth.Registry
|
|
}
|
|
|
|
func New(
|
|
db *gorm.DB,
|
|
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{
|
|
db: db,
|
|
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) Db() *gorm.DB {
|
|
return h.db
|
|
}
|
|
|
|
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) UpdateParticipantCV(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
|
|
}
|
|
cv, _, filename, err := readUpload(r, "cv")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
if len(cv) == 0 {
|
|
writeError(w, http.StatusBadRequest, fmt.Errorf("cv is required"))
|
|
return
|
|
}
|
|
participant, err := h.participants.Get(r.Context(), idParam(r))
|
|
if handleServiceError(w, r, err) {
|
|
return
|
|
}
|
|
participant.CV = cv
|
|
participant.CVFilename = filename
|
|
value, err := h.participants.Update(r.Context(), participant)
|
|
if handleServiceError(w, r, err) {
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, 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.frontendURLForRequest(r))
|
|
}
|
|
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.frontendURLForRequest(r)))
|
|
}
|
|
|
|
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.frontendURLForRequest(r)))
|
|
}
|
|
|
|
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.frontendURLForRequest(r)))
|
|
}
|
|
|
|
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.frontendURLForRequest(r)))
|
|
}
|
|
|
|
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.frontendURLForRequest(r))
|
|
}
|
|
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.frontendURLForRequest(r)))
|
|
}
|
|
|
|
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.frontendURLForRequest(r)))
|
|
}
|
|
|
|
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.frontendURLForRequest(r)))
|
|
}
|
|
|
|
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.frontendURLForRequest(r))
|
|
}
|
|
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.frontendURLForRequest(r)))
|
|
}
|
|
|
|
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.frontendURLForRequest(r))
|
|
}
|
|
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.frontendURLForRequest(r)))
|
|
}
|
|
|
|
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.frontendURLForRequest(r)))
|
|
}
|
|
|
|
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.frontendURLForRequest(r)))
|
|
}
|
|
|
|
func (h *Handler) frontendURLForRequest(r *http.Request) string {
|
|
tenant := strings.ToLower(strings.TrimSpace(r.Header.Get(client.XTenantId)))
|
|
origin := strings.TrimRight(strings.TrimSpace(r.Header.Get("Origin")), "/")
|
|
if tenant == "" || origin == "" {
|
|
return h.frontendURL
|
|
}
|
|
parsed, err := url.Parse(origin)
|
|
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
|
return h.frontendURL
|
|
}
|
|
if strings.EqualFold(parsed.Hostname(), tenant+".guess.local") ||
|
|
strings.EqualFold(parsed.Hostname(), tenant+".guess.dev") {
|
|
return origin
|
|
}
|
|
return 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)
|
|
}
|
|
}
|