feat: add collaborative interview scheduling
Add candidate decisions, co-interviewer accounts, scheduled interview inbox and calendar views, candidate-safe meeting links, shared documents, and private live notes backed by RabbitMQ. Extend the disposable integration environment to validate PostgreSQL migrations, seeded users, interview lifecycle behavior, and RabbitMQ fanout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
fd06fbbb5d
commit
75e3c73a61
35 changed files with 3621 additions and 58 deletions
|
|
@ -15,6 +15,7 @@ import (
|
|||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/danyel/go-guess/backend/internal/database/repository"
|
||||
"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"
|
||||
|
|
@ -27,6 +28,8 @@ type Handler struct {
|
|||
questions service.IQuestionService
|
||||
participants service.IParticipantService
|
||||
invitations service.IInvitationService
|
||||
users service.IUserService
|
||||
interviews service.IScheduledInterviewService
|
||||
maxUpload int64
|
||||
frontendURL string
|
||||
}
|
||||
|
|
@ -37,12 +40,15 @@ func New(
|
|||
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, "/"),
|
||||
}
|
||||
}
|
||||
|
|
@ -67,10 +73,36 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, webmodel.LoginResponse{
|
||||
Token: token, User: webmodel.User{ID: user.ID, Email: user.Email, Role: user.Role},
|
||||
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 {
|
||||
|
|
@ -325,6 +357,193 @@ func (h *Handler) ReviewInvitation(w http.ResponseWriter, r *http.Request) {
|
|||
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
|
||||
}
|
||||
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.WriteHeader(http.StatusOK)
|
||||
flusher.Flush()
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case event, ok := <-events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if event.InterviewID != idParam(r) {
|
||||
continue
|
||||
}
|
||||
payload, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "event: interview-note\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) {
|
||||
|
|
@ -422,12 +641,19 @@ func handleServiceError(w http.ResponseWriter, r *http.Request, err error) bool
|
|||
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"))
|
||||
|
|
|
|||
|
|
@ -85,11 +85,64 @@ func InvitationToWeb(value servicemodel.Invitation, frontendURL string) webmodel
|
|||
ID: value.ID, JobID: value.JobID, ParticipantID: value.ParticipantID,
|
||||
ParticipantName: value.ParticipantName, ParticipantEmail: value.ParticipantEmail,
|
||||
Token: value.Token, Status: value.Status,
|
||||
Outcome: value.Outcome,
|
||||
ParticipantURL: frontendURL + "/participant/" + value.Token,
|
||||
AcceptedAt: value.AcceptedAt, CompletedAt: value.CompletedAt, CreatedAt: value.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func UserToWeb(value servicemodel.User) webmodel.User {
|
||||
return webmodel.User{
|
||||
ID: value.ID, Email: value.Email, DisplayName: value.DisplayName, Role: value.Role,
|
||||
}
|
||||
}
|
||||
|
||||
func ScheduledInterviewToWeb(
|
||||
value servicemodel.ScheduledInterview, frontendURL string,
|
||||
) webmodel.ScheduledInterviewResponse {
|
||||
attendees := make([]webmodel.InterviewAttendeeResponse, len(value.Attendees))
|
||||
for i, attendee := range value.Attendees {
|
||||
attendees[i] = InterviewAttendeeToWeb(attendee)
|
||||
}
|
||||
notes := make([]webmodel.InterviewNoteResponse, len(value.Notes))
|
||||
for i, note := range value.Notes {
|
||||
notes[i] = InterviewNoteToWeb(note)
|
||||
}
|
||||
return webmodel.ScheduledInterviewResponse{
|
||||
ID: value.ID, JobID: value.JobID, JobTitle: value.Job.Title,
|
||||
ParticipantID: value.ParticipantID,
|
||||
ParticipantName: value.Participant.FirstName + " " + value.Participant.LastName,
|
||||
StartsAt: value.StartsAt, Location: value.Location, CandidateToken: value.CandidateToken,
|
||||
CandidateURL: frontendURL + "/participant/meeting/" + value.CandidateToken,
|
||||
SharedDocument: value.SharedDocument, Status: value.Status,
|
||||
Attendees: attendees, Notes: notes,
|
||||
CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func ParticipantMeetingToWeb(value servicemodel.ScheduledInterview) webmodel.ParticipantMeetingResponse {
|
||||
return webmodel.ParticipantMeetingResponse{
|
||||
ID: value.ID, JobID: value.JobID, JobTitle: value.Job.Title,
|
||||
ParticipantName: value.Participant.FirstName + " " + value.Participant.LastName,
|
||||
StartsAt: value.StartsAt, Location: value.Location,
|
||||
SharedDocument: value.SharedDocument, Status: value.Status,
|
||||
}
|
||||
}
|
||||
|
||||
func InterviewAttendeeToWeb(value servicemodel.InterviewAttendee) webmodel.InterviewAttendeeResponse {
|
||||
return webmodel.InterviewAttendeeResponse{
|
||||
UserID: value.UserID, DisplayName: value.User.DisplayName,
|
||||
Email: value.User.Email, Status: value.Status,
|
||||
}
|
||||
}
|
||||
|
||||
func InterviewNoteToWeb(value servicemodel.InterviewNote) webmodel.InterviewNoteResponse {
|
||||
return webmodel.InterviewNoteResponse{
|
||||
ID: value.ID, AuthorUserID: value.AuthorID, AuthorName: value.Author.DisplayName,
|
||||
Body: value.Body, 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 {
|
||||
|
|
|
|||
|
|
@ -17,9 +17,16 @@ type LoginResponse struct {
|
|||
}
|
||||
|
||||
type User struct {
|
||||
ID uint `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
ID uint `json:"id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type CreateUserRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
DisplayName string `json:"displayName"`
|
||||
}
|
||||
|
||||
type JobRequest struct {
|
||||
|
|
@ -108,6 +115,7 @@ type InvitationResponse struct {
|
|||
ParticipantEmail string `json:"participantEmail"`
|
||||
Token string `json:"token"`
|
||||
Status string `json:"status"`
|
||||
Outcome string `json:"outcome"`
|
||||
ParticipantURL string `json:"participantUrl"`
|
||||
AcceptedAt *time.Time `json:"acceptedAt,omitempty"`
|
||||
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||
|
|
@ -147,3 +155,75 @@ type InvitationReviewResponse struct {
|
|||
type AnswerRequest struct {
|
||||
Answer string `json:"answer"`
|
||||
}
|
||||
|
||||
type InvitationOutcomeRequest struct {
|
||||
Outcome string `json:"outcome"`
|
||||
}
|
||||
|
||||
type CreateScheduledInterviewRequest struct {
|
||||
InvitationID uint `json:"invitationId"`
|
||||
StartsAt time.Time `json:"startsAt"`
|
||||
Location string `json:"location"`
|
||||
InterviewerIDs []uint `json:"interviewerIds"`
|
||||
SharedDocument string `json:"sharedDocument"`
|
||||
}
|
||||
|
||||
type InterviewStatusRequest struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type InterviewDocumentRequest struct {
|
||||
SharedDocument string `json:"sharedDocument"`
|
||||
}
|
||||
|
||||
type InterviewNoteRequest struct {
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
type AttendeeStatusRequest struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type InterviewAttendeeResponse struct {
|
||||
UserID uint `json:"userId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type ScheduledInterviewResponse struct {
|
||||
ID uint `json:"id"`
|
||||
JobID uint `json:"jobId"`
|
||||
JobTitle string `json:"jobTitle"`
|
||||
ParticipantID uint `json:"participantId"`
|
||||
ParticipantName string `json:"participantName"`
|
||||
StartsAt time.Time `json:"startsAt"`
|
||||
Location string `json:"location"`
|
||||
CandidateToken string `json:"candidateToken"`
|
||||
CandidateURL string `json:"candidateUrl"`
|
||||
SharedDocument string `json:"sharedDocument"`
|
||||
Status string `json:"status"`
|
||||
Attendees []InterviewAttendeeResponse `json:"attendees"`
|
||||
Notes []InterviewNoteResponse `json:"notes"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type ParticipantMeetingResponse struct {
|
||||
ID uint `json:"id"`
|
||||
JobID uint `json:"jobId"`
|
||||
JobTitle string `json:"jobTitle"`
|
||||
ParticipantName string `json:"participantName"`
|
||||
StartsAt time.Time `json:"startsAt"`
|
||||
Location string `json:"location"`
|
||||
SharedDocument string `json:"sharedDocument"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type InterviewNoteResponse struct {
|
||||
ID uint `json:"id"`
|
||||
AuthorUserID uint `json:"authorUserId"`
|
||||
AuthorName string `json:"authorName"`
|
||||
Body string `json:"body"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
|
@ -13,10 +12,6 @@ import (
|
|||
"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))
|
||||
|
|
@ -27,6 +22,7 @@ func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string)
|
|||
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.Get("/api/participant-meetings/{token}", h.GetParticipantMeeting)
|
||||
router.Group(func(protected chi.Router) {
|
||||
protected.Use(authenticate(tokens))
|
||||
protected.Get("/api/jobs", h.ListJobs)
|
||||
|
|
@ -39,6 +35,20 @@ func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string)
|
|||
protected.Get("/api/jobs/{id}/invitations", h.ListInvitations)
|
||||
protected.Post("/api/jobs/{id}/invitations", h.CreateInvitation)
|
||||
protected.Get("/api/jobs/{id}/invitations/{invitationId}", h.ReviewInvitation)
|
||||
protected.Patch("/api/jobs/{id}/invitations/{invitationId}/outcome", h.SetInvitationOutcome)
|
||||
protected.Get("/api/jobs/{id}/interviews", h.ListScheduledInterviews)
|
||||
protected.Post("/api/jobs/{id}/interviews", h.CreateScheduledInterview)
|
||||
protected.Get("/api/scheduled-interviews/{id}", h.GetScheduledInterview)
|
||||
protected.Patch("/api/scheduled-interviews/{id}/status", h.UpdateScheduledInterviewStatus)
|
||||
protected.Patch("/api/scheduled-interviews/{id}/document", h.UpdateScheduledInterviewDocument)
|
||||
protected.Get("/api/scheduled-interviews/{id}/notes", h.ListInterviewNotes)
|
||||
protected.Post("/api/scheduled-interviews/{id}/notes", h.CreateInterviewNote)
|
||||
protected.Get("/api/scheduled-interviews/{id}/events", h.InterviewEvents)
|
||||
protected.Get("/api/users", h.ListUsers)
|
||||
protected.Post("/api/users", h.CreateUser)
|
||||
protected.Get("/api/inbox", h.ListInbox)
|
||||
protected.Patch("/api/inbox/{id}", h.UpdateInbox)
|
||||
protected.Get("/api/calendar", h.ListCalendar)
|
||||
protected.Get("/api/questions", h.ListQuestions)
|
||||
protected.Post("/api/questions", h.CreateQuestion)
|
||||
protected.Put("/api/questions/{id}", h.UpdateQuestion)
|
||||
|
|
@ -72,7 +82,7 @@ func authenticate(tokens security.ITokenManager) func(http.Handler) http.Handler
|
|||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), claimsKey, claims)))
|
||||
next.ServeHTTP(w, r.WithContext(security.WithClaims(r.Context(), claims)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
package router
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/danyel/go-guess/backend/internal/eventbus"
|
||||
"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"
|
||||
|
|
@ -77,9 +80,87 @@ func (invitationStub) SaveAnswer(context.Context, string, uint, string) error {
|
|||
func (invitationStub) Finish(context.Context, string) (model.Interview, error) {
|
||||
return model.Interview{}, nil
|
||||
}
|
||||
func (invitationStub) SetOutcome(context.Context, uint, uint, string) (model.Invitation, error) {
|
||||
return model.Invitation{}, nil
|
||||
}
|
||||
|
||||
type userStub struct{}
|
||||
|
||||
func (userStub) List(context.Context) ([]model.User, error) { return nil, nil }
|
||||
func (userStub) Create(context.Context, string, string, string) (model.User, error) {
|
||||
return model.User{}, nil
|
||||
}
|
||||
|
||||
type interviewStub struct{}
|
||||
|
||||
func (interviewStub) Create(
|
||||
context.Context, uint, uint, model.ScheduledInterview, []uint,
|
||||
) (model.ScheduledInterview, error) {
|
||||
return model.ScheduledInterview{}, nil
|
||||
}
|
||||
func (interviewStub) List(context.Context, uint, uint) ([]model.ScheduledInterview, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (interviewStub) Get(context.Context, uint, uint) (model.ScheduledInterview, error) {
|
||||
return model.ScheduledInterview{}, nil
|
||||
}
|
||||
func (interviewStub) GetParticipantMeeting(context.Context, string) (model.ScheduledInterview, error) {
|
||||
return model.ScheduledInterview{}, nil
|
||||
}
|
||||
func (interviewStub) UpdateStatus(context.Context, uint, uint, string) (model.ScheduledInterview, error) {
|
||||
return model.ScheduledInterview{}, nil
|
||||
}
|
||||
func (interviewStub) UpdateDocument(context.Context, uint, uint, string) (model.ScheduledInterview, error) {
|
||||
return model.ScheduledInterview{}, nil
|
||||
}
|
||||
func (interviewStub) ListNotes(context.Context, uint, uint) ([]model.InterviewNote, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (interviewStub) CreateNote(context.Context, uint, uint, string) (model.InterviewNote, error) {
|
||||
return model.InterviewNote{}, nil
|
||||
}
|
||||
func (interviewStub) ListInbox(context.Context, uint) ([]model.ScheduledInterview, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (interviewStub) UpdateInbox(context.Context, uint, uint, string) (model.ScheduledInterview, error) {
|
||||
return model.ScheduledInterview{}, nil
|
||||
}
|
||||
func (interviewStub) ListCalendar(context.Context, uint) ([]model.ScheduledInterview, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (interviewStub) Subscribe(context.Context, uint, uint) (<-chan eventbus.InterviewNoteEvent, error) {
|
||||
return make(chan eventbus.InterviewNoteEvent), nil
|
||||
}
|
||||
|
||||
type interviewCapture struct {
|
||||
interviewStub
|
||||
userID uint
|
||||
}
|
||||
|
||||
func (s *interviewCapture) Get(
|
||||
_ context.Context, userID, interviewID uint,
|
||||
) (model.ScheduledInterview, error) {
|
||||
s.userID = userID
|
||||
return model.ScheduledInterview{
|
||||
ID: interviewID,
|
||||
Attendees: []model.InterviewAttendee{{
|
||||
ID: 3, UserID: userID, User: model.User{ID: userID, DisplayName: "Interviewer"},
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *interviewCapture) GetParticipantMeeting(
|
||||
context.Context, string,
|
||||
) (model.ScheduledInterview, error) {
|
||||
return model.ScheduledInterview{
|
||||
ID: 4, JobID: 5, Job: model.JobPosting{Title: "Backend Engineer"},
|
||||
Participant: model.Participant{FirstName: "Candidate", LastName: "Person", Email: "private@example.com"},
|
||||
SharedDocument: "candidate document",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestHealthDoesNotRequireAuthentication(t *testing.T) {
|
||||
h := handler.New(authStub{}, jobStub{}, questionStub{}, participantStub{}, invitationStub{}, 10, "http://localhost:5173")
|
||||
h := handler.New(authStub{}, jobStub{}, questionStub{}, participantStub{}, invitationStub{}, userStub{}, interviewStub{}, 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)
|
||||
|
|
@ -91,7 +172,7 @@ func TestHealthDoesNotRequireAuthentication(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestProtectedRouteRequiresValidToken(t *testing.T) {
|
||||
h := handler.New(authStub{}, jobStub{}, questionStub{}, participantStub{}, invitationStub{}, 10, "http://localhost:5173")
|
||||
h := handler.New(authStub{}, jobStub{}, questionStub{}, participantStub{}, invitationStub{}, userStub{}, interviewStub{}, 10, "http://localhost:5173")
|
||||
tokens := security.NewTokenManager("01234567890123456789012345678901", time.Hour)
|
||||
router := New(h, tokens, "http://localhost:5173")
|
||||
|
||||
|
|
@ -100,7 +181,6 @@ func TestProtectedRouteRequiresValidToken(t *testing.T) {
|
|||
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)
|
||||
|
|
@ -113,3 +193,48 @@ func TestProtectedRouteRequiresValidToken(t *testing.T) {
|
|||
t.Fatalf("expected 200, got %d", authorized.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectedInterviewUsesAuthenticatedUserID(t *testing.T) {
|
||||
interviews := &interviewCapture{}
|
||||
h := handler.New(
|
||||
authStub{}, jobStub{}, questionStub{}, participantStub{}, invitationStub{},
|
||||
userStub{}, interviews, 10, "http://localhost:5173",
|
||||
)
|
||||
tokens := security.NewTokenManager("01234567890123456789012345678901", time.Hour)
|
||||
api := New(h, tokens, "http://localhost:5173")
|
||||
token, err := tokens.Issue(42, "co@example.com", "co_interviewer")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/scheduled-interviews/9", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
response := httptest.NewRecorder()
|
||||
api.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || interviews.userID != 42 {
|
||||
t.Fatalf("expected authenticated user 42, got status=%d user=%d", response.Code, interviews.userID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParticipantMeetingDoesNotExposeInternalCandidateData(t *testing.T) {
|
||||
interviews := &interviewCapture{}
|
||||
h := handler.New(
|
||||
authStub{}, jobStub{}, questionStub{}, participantStub{}, invitationStub{},
|
||||
userStub{}, interviews, 10, "http://localhost:5173",
|
||||
)
|
||||
api := New(h, security.NewTokenManager("01234567890123456789012345678901", time.Hour), "http://localhost:5173")
|
||||
response := httptest.NewRecorder()
|
||||
api.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/participant-meetings/token", nil))
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", response.Code)
|
||||
}
|
||||
body := response.Body.Bytes()
|
||||
if bytes.Contains(body, []byte("private@example.com")) ||
|
||||
bytes.Contains(body, []byte("referenceAnswer")) ||
|
||||
bytes.Contains(body, []byte("notes")) {
|
||||
t.Fatalf("public response leaked internal data: %s", body)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(body, &payload); err != nil || payload["sharedDocument"] != "candidate document" {
|
||||
t.Fatalf("unexpected public response: %s", body)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue