initial commit
This commit is contained in:
commit
6ffc0d2c99
54 changed files with 11900 additions and 0 deletions
438
backend/internal/web/handler/handler.go
Normal file
438
backend/internal/web/handler/handler.go
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
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/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
|
||||
maxUpload int64
|
||||
frontendURL string
|
||||
}
|
||||
|
||||
func New(
|
||||
auth service.IAuthService,
|
||||
jobs service.IJobService,
|
||||
questions service.IQuestionService,
|
||||
participants service.IParticipantService,
|
||||
invitations service.IInvitationService,
|
||||
maxUploadMB int64,
|
||||
frontendURL string,
|
||||
) *Handler {
|
||||
return &Handler{
|
||||
auth: auth, jobs: jobs, questions: questions, participants: participants,
|
||||
invitations: invitations, maxUpload: maxUploadMB << 20,
|
||||
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: webmodel.User{ID: user.ID, Email: user.Email, Role: user.Role},
|
||||
})
|
||||
}
|
||||
|
||||
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) 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)
|
||||
default:
|
||||
internalError(w, r, err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
116
backend/internal/web/mapper/mapper.go
Normal file
116
backend/internal/web/mapper/mapper.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package mapper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
servicemodel "github.com/danyel/go-guess/backend/internal/service/model"
|
||||
webmodel "github.com/danyel/go-guess/backend/internal/web/model"
|
||||
)
|
||||
|
||||
func JobToService(value webmodel.JobRequest) servicemodel.JobPosting {
|
||||
return servicemodel.JobPosting{
|
||||
Title: value.Title, Description: value.Description, Seniority: value.Seniority,
|
||||
Position: value.Position, DurationMinutes: value.DurationMinutes,
|
||||
Labels: traitsToService(value.Labels), RequiredSkills: traitsToService(value.RequiredSkills),
|
||||
AdditionalSkills: traitsToService(value.AdditionalSkills),
|
||||
}
|
||||
}
|
||||
|
||||
func QuestionToService(value webmodel.QuestionRequest) servicemodel.Question {
|
||||
return servicemodel.Question{
|
||||
Text: value.Text, Type: value.Type, Options: value.Options,
|
||||
ReferenceAnswer: value.ReferenceAnswer,
|
||||
}
|
||||
}
|
||||
|
||||
func JobToWeb(value servicemodel.JobPosting) webmodel.JobResponse {
|
||||
questions := make([]webmodel.QuestionResponse, len(value.Questions))
|
||||
for i, question := range value.Questions {
|
||||
questions[i] = QuestionToWeb(question)
|
||||
}
|
||||
return webmodel.JobResponse{
|
||||
ID: value.ID, Title: value.Title, Description: value.Description,
|
||||
Seniority: value.Seniority, Position: value.Position, Status: value.Status,
|
||||
DurationMinutes: value.DurationMinutes,
|
||||
Labels: traitsToWeb(value.Labels), RequiredSkills: traitsToWeb(value.RequiredSkills),
|
||||
AdditionalSkills: traitsToWeb(value.AdditionalSkills), Questions: questions,
|
||||
CreatedAt: value.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func QuestionToWeb(value servicemodel.Question) webmodel.QuestionResponse {
|
||||
return webmodel.QuestionResponse{
|
||||
ID: value.ID, Text: value.Text, Type: value.Type, Options: value.Options,
|
||||
ReferenceAnswer: value.ReferenceAnswer, Deprecated: value.Deprecated,
|
||||
}
|
||||
}
|
||||
|
||||
func ParticipantToWeb(value servicemodel.Participant) webmodel.ParticipantResponse {
|
||||
result := webmodel.ParticipantResponse{
|
||||
ID: value.ID, FirstName: value.FirstName, LastName: value.LastName,
|
||||
Email: value.Email, ContactInfo: value.ContactInfo, CVFilename: value.CVFilename,
|
||||
Traits: traitsToWeb(value.Traits), CreatedAt: value.CreatedAt,
|
||||
}
|
||||
if value.Birthday != nil {
|
||||
birthday := value.Birthday.Format("2006-01-02")
|
||||
result.Birthday = &birthday
|
||||
}
|
||||
if len(value.Photo) > 0 {
|
||||
result.PhotoURL = fmt.Sprintf("/api/participants/%d/photo", value.ID)
|
||||
}
|
||||
if len(value.CV) > 0 {
|
||||
result.CVURL = fmt.Sprintf("/api/participants/%d/cv", value.ID)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func CandidateToWeb(value servicemodel.CandidateMatch) webmodel.CandidateResponse {
|
||||
return webmodel.CandidateResponse{
|
||||
Participant: ParticipantToWeb(value.Participant), Score: value.Score,
|
||||
Matched: value.Matched, Missing: value.Missing,
|
||||
}
|
||||
}
|
||||
|
||||
func InvitationToWeb(value servicemodel.Invitation, frontendURL string) webmodel.InvitationResponse {
|
||||
return webmodel.InvitationResponse{
|
||||
ID: value.ID, JobID: value.JobID, ParticipantID: value.ParticipantID,
|
||||
ParticipantName: value.ParticipantName, ParticipantEmail: value.ParticipantEmail,
|
||||
Token: value.Token, Status: value.Status,
|
||||
ParticipantURL: frontendURL + "/interview/" + value.Token,
|
||||
AcceptedAt: value.AcceptedAt, CompletedAt: value.CompletedAt, CreatedAt: value.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func InterviewToWeb(value servicemodel.Interview, frontendURL string) webmodel.InterviewResponse {
|
||||
questions := make([]webmodel.InterviewQuestionResponse, len(value.Job.Questions))
|
||||
for i, question := range value.Job.Questions {
|
||||
questions[i] = webmodel.InterviewQuestionResponse{
|
||||
ID: question.ID, Text: question.Text, Type: question.Type, Options: question.Options,
|
||||
}
|
||||
}
|
||||
return webmodel.InterviewResponse{
|
||||
Invitation: InvitationToWeb(value.Invitation, frontendURL),
|
||||
Job: webmodel.InterviewJobResponse{
|
||||
ID: value.Job.ID, Title: value.Job.Title, Description: value.Job.Description,
|
||||
Position: value.Job.Position, Seniority: value.Job.Seniority,
|
||||
DurationMinutes: value.Job.DurationMinutes, Questions: questions,
|
||||
},
|
||||
Answers: value.Answers,
|
||||
}
|
||||
}
|
||||
|
||||
func traitsToService(values []string) []servicemodel.Trait {
|
||||
result := make([]servicemodel.Trait, len(values))
|
||||
for i, value := range values {
|
||||
result[i] = servicemodel.Trait{Name: value}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func traitsToWeb(values []servicemodel.Trait) []string {
|
||||
result := make([]string, len(values))
|
||||
for i, value := range values {
|
||||
result[i] = value.Name
|
||||
}
|
||||
return result
|
||||
}
|
||||
140
backend/internal/web/model/models.go
Normal file
140
backend/internal/web/model/models.go
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
User User `json:"user"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID uint `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type JobRequest struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Seniority string `json:"seniority"`
|
||||
Position string `json:"position"`
|
||||
DurationMinutes int `json:"durationMinutes"`
|
||||
Labels []string `json:"labels"`
|
||||
RequiredSkills []string `json:"requiredSkills"`
|
||||
AdditionalSkills []string `json:"additionalSkills"`
|
||||
}
|
||||
|
||||
type JobResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Seniority string `json:"seniority"`
|
||||
Position string `json:"position"`
|
||||
Status string `json:"status"`
|
||||
DurationMinutes int `json:"durationMinutes"`
|
||||
Labels []string `json:"labels"`
|
||||
RequiredSkills []string `json:"requiredSkills"`
|
||||
AdditionalSkills []string `json:"additionalSkills"`
|
||||
Questions []QuestionResponse `json:"questions"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type QuestionRequest struct {
|
||||
Text string `json:"text"`
|
||||
Type string `json:"type"`
|
||||
Options []string `json:"options"`
|
||||
ReferenceAnswer string `json:"referenceAnswer"`
|
||||
}
|
||||
|
||||
type QuestionResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Type string `json:"type"`
|
||||
Options []string `json:"options"`
|
||||
ReferenceAnswer string `json:"referenceAnswer"`
|
||||
Deprecated bool `json:"deprecated"`
|
||||
}
|
||||
|
||||
type QuestionStatusRequest struct {
|
||||
Deprecated bool `json:"deprecated"`
|
||||
}
|
||||
|
||||
type JobStatusRequest struct {
|
||||
Status string `json:"status"`
|
||||
DurationMinutes int `json:"durationMinutes"`
|
||||
}
|
||||
|
||||
type InvitationRequest struct {
|
||||
ParticipantID uint `json:"participantId"`
|
||||
}
|
||||
|
||||
type ParticipantResponse struct {
|
||||
ID uint `json:"id"`
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
Birthday *string `json:"birthday,omitempty"`
|
||||
Email string `json:"email"`
|
||||
ContactInfo string `json:"contactInfo"`
|
||||
PhotoURL string `json:"photoUrl,omitempty"`
|
||||
CVFilename string `json:"cvFilename,omitempty"`
|
||||
CVURL string `json:"cvUrl,omitempty"`
|
||||
Traits []string `json:"traits"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type CandidateResponse struct {
|
||||
Participant ParticipantResponse `json:"participant"`
|
||||
Score float64 `json:"score"`
|
||||
Matched []string `json:"matchedTraits"`
|
||||
Missing []string `json:"missingTraits"`
|
||||
}
|
||||
|
||||
type InvitationResponse struct {
|
||||
ID uint `json:"id"`
|
||||
JobID uint `json:"jobId"`
|
||||
ParticipantID uint `json:"participantId"`
|
||||
ParticipantName string `json:"participantName"`
|
||||
ParticipantEmail string `json:"participantEmail"`
|
||||
Token string `json:"token"`
|
||||
Status string `json:"status"`
|
||||
ParticipantURL string `json:"participantUrl"`
|
||||
AcceptedAt *time.Time `json:"acceptedAt,omitempty"`
|
||||
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type InterviewJobResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Position string `json:"position"`
|
||||
Seniority string `json:"seniority"`
|
||||
DurationMinutes int `json:"durationMinutes"`
|
||||
Questions []InterviewQuestionResponse `json:"questions"`
|
||||
}
|
||||
|
||||
type InterviewQuestionResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Type string `json:"type"`
|
||||
Options []string `json:"options"`
|
||||
}
|
||||
|
||||
type InterviewResponse struct {
|
||||
Invitation InvitationResponse `json:"invitation"`
|
||||
Job InterviewJobResponse `json:"job"`
|
||||
Answers map[uint]string `json:"answers"`
|
||||
}
|
||||
|
||||
type AnswerRequest struct {
|
||||
Answer string `json:"answer"`
|
||||
}
|
||||
93
backend/internal/web/router/router.go
Normal file
93
backend/internal/web/router/router.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"github.com/danyel/go-guess/backend/internal/security"
|
||||
"github.com/danyel/go-guess/backend/internal/web/handler"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const claimsKey contextKey = "claims"
|
||||
|
||||
func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string) http.Handler {
|
||||
router := chi.NewRouter()
|
||||
router.Use(middleware.RequestID, middleware.RealIP, middleware.Recoverer, middleware.Compress(5))
|
||||
router.Use(cors(frontendURL))
|
||||
router.Get("/api/health", h.Health)
|
||||
router.Post("/api/auth/login", h.Login)
|
||||
router.Get("/api/interviews/{token}", h.GetInterview)
|
||||
router.Post("/api/interviews/{token}/accept", h.AcceptInterview)
|
||||
router.Put("/api/interviews/{token}/answers/{questionId}", h.SaveAnswer)
|
||||
router.Post("/api/interviews/{token}/finish", h.FinishInterview)
|
||||
router.Group(func(protected chi.Router) {
|
||||
protected.Use(authenticate(tokens))
|
||||
protected.Get("/api/jobs", h.ListJobs)
|
||||
protected.Post("/api/jobs", h.CreateJob)
|
||||
protected.Get("/api/jobs/{id}", h.GetJob)
|
||||
protected.Patch("/api/jobs/{id}", h.UpdateJob)
|
||||
protected.Post("/api/jobs/{id}/questions/{questionId}", h.AttachQuestion)
|
||||
protected.Delete("/api/jobs/{id}/questions/{questionId}", h.DetachQuestion)
|
||||
protected.Get("/api/jobs/{id}/candidates", h.Candidates)
|
||||
protected.Get("/api/jobs/{id}/invitations", h.ListInvitations)
|
||||
protected.Post("/api/jobs/{id}/invitations", h.CreateInvitation)
|
||||
protected.Get("/api/questions", h.ListQuestions)
|
||||
protected.Post("/api/questions", h.CreateQuestion)
|
||||
protected.Put("/api/questions/{id}", h.UpdateQuestion)
|
||||
protected.Patch("/api/questions/{id}", h.SetQuestionDeprecated)
|
||||
protected.Get("/api/participants", h.ListParticipants)
|
||||
protected.Post("/api/participants", h.CreateParticipant)
|
||||
protected.Get("/api/participants/{id}", h.GetParticipant)
|
||||
protected.Get("/api/participants/{id}/photo", h.ParticipantPhoto)
|
||||
protected.Get("/api/participants/{id}/cv", h.ParticipantCV)
|
||||
})
|
||||
return router
|
||||
}
|
||||
|
||||
func writeUnauthorized(w http.ResponseWriter, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
|
||||
}
|
||||
|
||||
func authenticate(tokens security.ITokenManager) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
scheme, raw, ok := strings.Cut(r.Header.Get("Authorization"), " ")
|
||||
if !ok || !strings.EqualFold(scheme, "Bearer") {
|
||||
writeUnauthorized(w, "missing bearer token")
|
||||
return
|
||||
}
|
||||
claims, err := tokens.Parse(raw)
|
||||
if err != nil {
|
||||
writeUnauthorized(w, "invalid bearer token")
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), claimsKey, claims)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func cors(origin string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
w.Header().Add("Vary", "Origin")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
112
backend/internal/web/router/router_test.go
Normal file
112
backend/internal/web/router/router_test.go
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/danyel/go-guess/backend/internal/security"
|
||||
"github.com/danyel/go-guess/backend/internal/service"
|
||||
"github.com/danyel/go-guess/backend/internal/service/model"
|
||||
"github.com/danyel/go-guess/backend/internal/web/handler"
|
||||
)
|
||||
|
||||
type authStub struct{}
|
||||
|
||||
func (authStub) Login(context.Context, string, string) (string, model.User, error) {
|
||||
return "", model.User{}, service.ErrInvalidCredentials
|
||||
}
|
||||
|
||||
type jobStub struct{}
|
||||
|
||||
func (jobStub) List(context.Context) ([]model.JobPosting, error) { return []model.JobPosting{}, nil }
|
||||
func (jobStub) Get(context.Context, uint) (model.JobPosting, error) { return model.JobPosting{}, nil }
|
||||
func (jobStub) Create(context.Context, model.JobPosting) (model.JobPosting, error) {
|
||||
return model.JobPosting{}, nil
|
||||
}
|
||||
func (jobStub) Update(context.Context, uint, string, int) (model.JobPosting, error) {
|
||||
return model.JobPosting{}, nil
|
||||
}
|
||||
func (jobStub) AttachQuestion(context.Context, uint, uint) error { return nil }
|
||||
func (jobStub) DetachQuestion(context.Context, uint, uint) error { return nil }
|
||||
func (jobStub) Candidates(context.Context, uint) ([]model.CandidateMatch, error) {
|
||||
return []model.CandidateMatch{}, nil
|
||||
}
|
||||
|
||||
type participantStub struct{}
|
||||
|
||||
func (participantStub) List(context.Context) ([]model.Participant, error) { return nil, nil }
|
||||
func (participantStub) Get(context.Context, uint) (model.Participant, error) {
|
||||
return model.Participant{}, nil
|
||||
}
|
||||
func (participantStub) Create(context.Context, model.Participant) (model.Participant, error) {
|
||||
return model.Participant{}, nil
|
||||
}
|
||||
|
||||
type questionStub struct{}
|
||||
|
||||
func (questionStub) List(context.Context, string) ([]model.Question, error) { return nil, nil }
|
||||
func (questionStub) Create(context.Context, model.Question) (model.Question, error) {
|
||||
return model.Question{}, nil
|
||||
}
|
||||
func (questionStub) Update(context.Context, uint, model.Question) (model.Question, error) {
|
||||
return model.Question{}, nil
|
||||
}
|
||||
func (questionStub) SetDeprecated(context.Context, uint, bool) (model.Question, error) {
|
||||
return model.Question{}, nil
|
||||
}
|
||||
|
||||
type invitationStub struct{}
|
||||
|
||||
func (invitationStub) List(context.Context, uint) ([]model.Invitation, error) { return nil, nil }
|
||||
func (invitationStub) Create(context.Context, uint, uint) (model.Invitation, error) {
|
||||
return model.Invitation{}, nil
|
||||
}
|
||||
func (invitationStub) GetInterview(context.Context, string) (model.Interview, error) {
|
||||
return model.Interview{}, nil
|
||||
}
|
||||
func (invitationStub) Accept(context.Context, string) (model.Interview, error) {
|
||||
return model.Interview{}, nil
|
||||
}
|
||||
func (invitationStub) SaveAnswer(context.Context, string, uint, string) error { return nil }
|
||||
func (invitationStub) Finish(context.Context, string) (model.Interview, error) {
|
||||
return model.Interview{}, nil
|
||||
}
|
||||
|
||||
func TestHealthDoesNotRequireAuthentication(t *testing.T) {
|
||||
h := handler.New(authStub{}, jobStub{}, questionStub{}, participantStub{}, invitationStub{}, 10, "http://localhost:5173")
|
||||
tokens := security.NewTokenManager("01234567890123456789012345678901", time.Hour)
|
||||
router := New(h, tokens, "http://localhost:5173")
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectedRouteRequiresValidToken(t *testing.T) {
|
||||
h := handler.New(authStub{}, jobStub{}, questionStub{}, participantStub{}, invitationStub{}, 10, "http://localhost:5173")
|
||||
tokens := security.NewTokenManager("01234567890123456789012345678901", time.Hour)
|
||||
router := New(h, tokens, "http://localhost:5173")
|
||||
|
||||
unauthorized := httptest.NewRecorder()
|
||||
router.ServeHTTP(unauthorized, httptest.NewRequest(http.MethodGet, "/api/jobs", nil))
|
||||
if unauthorized.Code != http.StatusUnauthorized || unauthorized.Header().Get("Content-Type") != "application/json" {
|
||||
t.Fatalf("expected JSON 401, got %d %q", unauthorized.Code, unauthorized.Header().Get("Content-Type"))
|
||||
}
|
||||
|
||||
token, err := tokens.Issue(1, "interviewer@example.com", "interviewer")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/jobs", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
authorized := httptest.NewRecorder()
|
||||
router.ServeHTTP(authorized, request)
|
||||
if authorized.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", authorized.Code)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue