initial commit
This commit is contained in:
commit
6ffc0d2c99
54 changed files with 11900 additions and 0 deletions
93
.github/copilot-instructions.md
vendored
Normal file
93
.github/copilot-instructions.md
vendored
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
# Copilot instructions
|
||||||
|
|
||||||
|
## Build, test, and lint
|
||||||
|
|
||||||
|
Run repository-wide commands from the root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev # build and run PostgreSQL, API, and frontend
|
||||||
|
make db-up # start only PostgreSQL
|
||||||
|
make migrate # apply Goose migrations
|
||||||
|
make migrate-down # roll back one migration
|
||||||
|
make test # backend and frontend unit/component tests
|
||||||
|
make test-integration # PostgreSQL-backed repository tests; database must be running
|
||||||
|
make lint # go vet and ESLint
|
||||||
|
make build # compile the API and production frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
Run focused tests from the owning application:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && go test ./internal/service -run '^TestCandidatesRequireSixtyPercent$'
|
||||||
|
cd frontend && npm test -- src/App.test.tsx -t "filters jobs by title"
|
||||||
|
```
|
||||||
|
|
||||||
|
The required toolchain is Go 1.27.1 and React 19.3 with TypeScript/Vite. Frontend
|
||||||
|
formatting can be checked with `cd frontend && npm run format:check`; backend files
|
||||||
|
are formatted with `gofmt`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The repository has two applications:
|
||||||
|
|
||||||
|
- `backend/` is a Go HTTP API. `cmd/api` wires configuration, PostgreSQL, security,
|
||||||
|
services, handlers, and routing. Requests flow through
|
||||||
|
`internal/web/{router,handler,model,mapper}` to
|
||||||
|
`internal/service/{model,services}` and then
|
||||||
|
`internal/database/{repository,mapper,entity}`. Do not bypass these mapping
|
||||||
|
boundaries by exposing GORM entities from handlers.
|
||||||
|
- `frontend/` is a React SPA. `src/App.tsx` owns routing/layout/screens,
|
||||||
|
`src/api.ts` is the typed HTTP boundary, and `src/types.ts` contains client domain
|
||||||
|
types. Vite proxies `/api` to the Go service in development.
|
||||||
|
|
||||||
|
Goose SQL files in `backend/migrations/` are the database schema source of truth.
|
||||||
|
The initial migration also supplies representative jobs, questions, participants,
|
||||||
|
traits, and the local interviewer account.
|
||||||
|
|
||||||
|
Authentication is custom bearer-token authentication: passwords are bcrypt hashes,
|
||||||
|
`POST /api/auth/login` issues an HMAC-signed JWT, and all domain routes require that
|
||||||
|
token. Never return password hashes or participant photo/CV bytes in JSON; those
|
||||||
|
files have dedicated endpoints.
|
||||||
|
|
||||||
|
CV ingestion stores the original bytes and extracts text from plain text, PDF, or
|
||||||
|
DOCX input. Trait extraction only considers normalized traits already present in
|
||||||
|
the database. Candidate matching compares a participant's extracted traits with a
|
||||||
|
job's labels, required skills, and additional skills; include matches only at the
|
||||||
|
`service.CandidateThreshold` of 60% or higher.
|
||||||
|
|
||||||
|
Questions are permanent, reusable library records and jobs own many-to-many links
|
||||||
|
to them. Detaching removes only the link. The lifecycle is
|
||||||
|
`draft -> published -> deprecated`; publishing requires at least one question, and
|
||||||
|
only published jobs can create invitations. Once an invitation exists, its question
|
||||||
|
set cannot be changed, and its duration is snapshotted so later job edits do not
|
||||||
|
alter an active interview.
|
||||||
|
|
||||||
|
Invitation URLs use an opaque UUID token and public `/api/interviews/{token}` routes;
|
||||||
|
they do not use interviewer JWT authentication. An invitation progresses from
|
||||||
|
`pending` to `accepted` to `completed`. Answers may only be saved while accepted
|
||||||
|
and before the snapshotted timer expires.
|
||||||
|
|
||||||
|
## Repository conventions
|
||||||
|
|
||||||
|
- Every Go interface name starts with `I` (`IStore`, `IJobService`,
|
||||||
|
`IParticipantService`, `ITokenManager`).
|
||||||
|
- Keep transport, service, and persistence structs separate. Conversion belongs in
|
||||||
|
the adjacent `mapper` package, not in handlers or GORM entities.
|
||||||
|
- Question types are the exact API values `open`, `multiple_choice`, `radio`, and
|
||||||
|
`code_review`. Multiple-choice and radio questions require at least two options.
|
||||||
|
- Never delete question records through job operations. Use
|
||||||
|
`job_posting_questions` to attach/detach, and use the `deprecated` flag to retire
|
||||||
|
library questions. Full question edits use `PUT /api/questions/{id}`; deprecation
|
||||||
|
remains a separate `PATCH`.
|
||||||
|
- The question create/edit form builds multiple-choice and radio options one at a
|
||||||
|
time. Keep option editing state separate from the saved options array and require
|
||||||
|
at least two options for those types. Open and code-review questions instead
|
||||||
|
require a `referenceAnswer` textarea. Never include that interviewer-only answer
|
||||||
|
in public interview responses.
|
||||||
|
- Traits are de-duplicated case-insensitively through their normalized database
|
||||||
|
value. Labels and both skill groups all reuse the same `traits` table.
|
||||||
|
- Participant creation is multipart form data using `firstName`, `lastName`,
|
||||||
|
`birthday`, `email`, `contactInfo`, `photo`, and `cv`.
|
||||||
|
- Return explicit errors. Handlers convert expected validation/not-found/authentication
|
||||||
|
failures to HTTP responses and log unexpected failures rather than silently
|
||||||
|
falling back to demo data.
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
.idea/
|
||||||
|
.env
|
||||||
|
backend/api
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/coverage/
|
||||||
47
Makefile
Normal file
47
Makefile
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
SHELL := /bin/sh
|
||||||
|
|
||||||
|
DATABASE_URL ?= postgres://go_guess:go_guess@localhost:5432/go_guess?sslmode=disable
|
||||||
|
JWT_SECRET ?= local-development-secret-change-me-12345
|
||||||
|
GOOSE := cd backend && go run github.com/pressly/goose/v3/cmd/goose@v3.26.0
|
||||||
|
|
||||||
|
.PHONY: dev db-up db-down migrate migrate-down backend frontend test test-backend test-frontend test-integration lint build
|
||||||
|
|
||||||
|
dev:
|
||||||
|
docker compose up --build
|
||||||
|
|
||||||
|
db-up:
|
||||||
|
docker compose up -d db
|
||||||
|
|
||||||
|
db-down:
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
$(GOOSE) -dir migrations postgres "$(DATABASE_URL)" up
|
||||||
|
|
||||||
|
migrate-down:
|
||||||
|
$(GOOSE) -dir migrations postgres "$(DATABASE_URL)" down
|
||||||
|
|
||||||
|
backend:
|
||||||
|
cd backend && DATABASE_URL="$(DATABASE_URL)" JWT_SECRET="$(JWT_SECRET)" go run ./cmd/api
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
cd frontend && npm run dev
|
||||||
|
|
||||||
|
test: test-backend test-frontend
|
||||||
|
|
||||||
|
test-backend:
|
||||||
|
cd backend && go test ./...
|
||||||
|
|
||||||
|
test-frontend:
|
||||||
|
cd frontend && npm test
|
||||||
|
|
||||||
|
test-integration:
|
||||||
|
cd backend && TEST_DATABASE_URL="$(DATABASE_URL)" go test -tags=integration ./internal/database/repository
|
||||||
|
|
||||||
|
lint:
|
||||||
|
cd backend && go vet ./...
|
||||||
|
cd frontend && npm run lint
|
||||||
|
|
||||||
|
build:
|
||||||
|
cd backend && go build ./...
|
||||||
|
cd frontend && npm run build
|
||||||
86
README.md
Normal file
86
README.md
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
# go-guess
|
||||||
|
|
||||||
|
Recruitment workspace for interviewers to define job profiles and a reusable
|
||||||
|
question library, manage participants, extract known traits from uploaded CVs,
|
||||||
|
invite candidates who match at least 60% of a posting's labels and skills, and run
|
||||||
|
timed participant interviews.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Go 1.27.1 HTTP API using Chi, GORM, PostgreSQL, Goose, bcrypt, and signed JWTs
|
||||||
|
- React 19.3, TypeScript, and Vite
|
||||||
|
- PostgreSQL 18
|
||||||
|
|
||||||
|
## Run locally
|
||||||
|
|
||||||
|
The complete stack runs with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open <http://localhost:5173> and sign in with:
|
||||||
|
|
||||||
|
- Email: `interviewer@go-guess.local`
|
||||||
|
- Password: `admin123`
|
||||||
|
|
||||||
|
To run the applications outside containers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make db-up
|
||||||
|
make migrate
|
||||||
|
make backend
|
||||||
|
make frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
The API listens on <http://localhost:8080>. Set a different secure `JWT_SECRET`
|
||||||
|
outside local development.
|
||||||
|
|
||||||
|
## Quality commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test
|
||||||
|
make test-integration
|
||||||
|
make lint
|
||||||
|
make build
|
||||||
|
```
|
||||||
|
|
||||||
|
Run one backend test:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && go test ./internal/service -run '^TestCandidatesRequireSixtyPercent$'
|
||||||
|
```
|
||||||
|
|
||||||
|
Run one frontend test:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm test -- src/App.test.tsx -t "filters jobs by title"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Requests enter the Chi router and web handlers under `backend/internal/web`.
|
||||||
|
Web request/response models are translated to service models before business logic
|
||||||
|
runs. Services own validation, CV trait extraction, and candidate scoring. The
|
||||||
|
repository maps service models to GORM entities and persists them in PostgreSQL.
|
||||||
|
|
||||||
|
The React application uses a typed API client and routes for authentication, job
|
||||||
|
postings, the searchable question library, participants, candidate matches, and
|
||||||
|
invitations. Public `/interview/:token` routes provide the participant welcome,
|
||||||
|
timed question navigation, autosaved answers, and completion flow without requiring
|
||||||
|
an interviewer login. Vite proxies `/api` to the Go service during local development.
|
||||||
|
|
||||||
|
Goose migrations in `backend/migrations` are the schema source of truth and include
|
||||||
|
representative local data. Uploaded CVs are stored as bytes; plain text, PDF, and
|
||||||
|
DOCX content is inspected for normalized traits already defined by job postings.
|
||||||
|
|
||||||
|
Jobs move through `draft`, `published`, and `deprecated` states. Questions are
|
||||||
|
editable, permanent library records: jobs attach and detach links rather than
|
||||||
|
owning or deleting questions. Deprecated questions remain available for historical
|
||||||
|
jobs but cannot be newly attached. Invitations snapshot the interview duration and
|
||||||
|
can only be generated for matching participants on published jobs.
|
||||||
|
|
||||||
|
Open and code-review questions store an interviewer-only reference answer.
|
||||||
|
Multiple-choice and radio questions instead store a list of possible responses.
|
||||||
|
Reference answers are deliberately omitted from the public participant interview
|
||||||
|
payload.
|
||||||
19
backend/Dockerfile
Normal file
19
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
FROM golang:1.27.1-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api
|
||||||
|
|
||||||
|
FROM golang:1.27.1-alpine AS tools
|
||||||
|
RUN GOBIN=/out go install github.com/pressly/goose/v3/cmd/goose@v3.26.0
|
||||||
|
|
||||||
|
FROM alpine:3.22
|
||||||
|
RUN addgroup -S app && adduser -S app -G app
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /out/api /usr/local/bin/api
|
||||||
|
COPY --from=tools /out/goose /usr/local/bin/goose
|
||||||
|
COPY migrations ./migrations
|
||||||
|
USER app
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["sh", "-c", "goose -dir migrations postgres \"$DATABASE_URL\" up && exec api"]
|
||||||
77
backend/cmd/api/main.go
Normal file
77
backend/cmd/api/main.go
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/danyel/go-guess/backend/internal/config"
|
||||||
|
"github.com/danyel/go-guess/backend/internal/database"
|
||||||
|
"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"
|
||||||
|
"github.com/danyel/go-guess/backend/internal/web/handler"
|
||||||
|
"github.com/danyel/go-guess/backend/internal/web/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("load configuration", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
db, err := database.Open(cfg.DatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("connect database", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("get database connection", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer sqlDB.Close()
|
||||||
|
|
||||||
|
store := repository.New(db)
|
||||||
|
tokens := security.NewTokenManager(cfg.JWTSecret, cfg.TokenTTL)
|
||||||
|
invitations := service.NewInvitationService(store, cfg.FrontendURL)
|
||||||
|
api := router.New(
|
||||||
|
handler.New(
|
||||||
|
service.NewAuthService(store, tokens),
|
||||||
|
service.NewJobService(store),
|
||||||
|
service.NewQuestionService(store),
|
||||||
|
service.NewParticipantService(store),
|
||||||
|
invitations,
|
||||||
|
cfg.MaxUploadMB,
|
||||||
|
cfg.FrontendURL,
|
||||||
|
),
|
||||||
|
tokens,
|
||||||
|
cfg.FrontendURL,
|
||||||
|
)
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: cfg.Address, Handler: api, ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
slog.Info("API listening", "address", cfg.Address)
|
||||||
|
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
slog.Error("serve API", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
stop, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer cancel()
|
||||||
|
<-stop.Done()
|
||||||
|
ctx, cancelShutdown := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancelShutdown()
|
||||||
|
if err := server.Shutdown(ctx); err != nil {
|
||||||
|
slog.Error("shutdown API", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
25
backend/go.mod
Normal file
25
backend/go.mod
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
module github.com/danyel/go-guess/backend
|
||||||
|
|
||||||
|
go 1.27.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-chi/chi/v5 v5.2.3
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/jackc/pgx/v5 v5.7.5
|
||||||
|
github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0
|
||||||
|
golang.org/x/crypto v0.43.0
|
||||||
|
gorm.io/driver/postgres v1.6.0
|
||||||
|
gorm.io/gorm v1.31.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/stretchr/testify v1.11.0 // indirect
|
||||||
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
golang.org/x/text v0.30.0 // indirect
|
||||||
|
)
|
||||||
44
backend/go.sum
Normal file
44
backend/go.sum
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
|
||||||
|
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0 h1:7Q+xNAZFmnfYOMweHN3c/PDFUKKfY1pVJ26K++QvVfU=
|
||||||
|
github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8=
|
||||||
|
github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||||
|
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||||
|
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||||
|
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
|
||||||
|
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
47
backend/internal/config/config.go
Normal file
47
backend/internal/config/config.go
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Address string
|
||||||
|
DatabaseURL string
|
||||||
|
JWTSecret string
|
||||||
|
TokenTTL time.Duration
|
||||||
|
MaxUploadMB int64
|
||||||
|
FrontendURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (Config, error) {
|
||||||
|
ttl, err := time.ParseDuration(env("TOKEN_TTL", "8h"))
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, fmt.Errorf("parse TOKEN_TTL: %w", err)
|
||||||
|
}
|
||||||
|
maxUploadMB, err := strconv.ParseInt(env("MAX_UPLOAD_MB", "10"), 10, 64)
|
||||||
|
if err != nil || maxUploadMB < 1 {
|
||||||
|
return Config{}, fmt.Errorf("MAX_UPLOAD_MB must be a positive integer")
|
||||||
|
}
|
||||||
|
cfg := Config{
|
||||||
|
Address: env("ADDRESS", ":8080"),
|
||||||
|
DatabaseURL: env("DATABASE_URL", "postgres://go_guess:go_guess@localhost:5432/go_guess?sslmode=disable"),
|
||||||
|
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||||
|
TokenTTL: ttl,
|
||||||
|
MaxUploadMB: maxUploadMB,
|
||||||
|
FrontendURL: env("FRONTEND_URL", "http://localhost:5173"),
|
||||||
|
}
|
||||||
|
if len(cfg.JWTSecret) < 32 {
|
||||||
|
return Config{}, fmt.Errorf("JWT_SECRET must contain at least 32 characters")
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func env(key, fallback string) string {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
16
backend/internal/database/database.go
Normal file
16
backend/internal/database/database.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Open(databaseURL string) (*gorm.DB, error) {
|
||||||
|
db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open database: %w", err)
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
92
backend/internal/database/entity/models.go
Normal file
92
backend/internal/database/entity/models.go
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
package entity
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
Email string `gorm:"uniqueIndex;size:320;not null"`
|
||||||
|
PasswordHash string `gorm:"not null"`
|
||||||
|
Role string `gorm:"size:32;not null"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type JobPosting struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
Title string `gorm:"size:200;not null"`
|
||||||
|
Description string `gorm:"type:text;not null"`
|
||||||
|
Seniority string `gorm:"size:50;not null"`
|
||||||
|
Position string `gorm:"size:120;not null"`
|
||||||
|
Status string `gorm:"size:32;not null;default:draft"`
|
||||||
|
DurationMinutes int `gorm:"not null;default:60"`
|
||||||
|
Labels []Trait `gorm:"many2many:job_posting_labels"`
|
||||||
|
RequiredSkills []Trait `gorm:"many2many:job_required_skills"`
|
||||||
|
AdditionalSkills []Trait `gorm:"many2many:job_additional_skills"`
|
||||||
|
Questions []Question `gorm:"many2many:job_posting_questions"`
|
||||||
|
Invitations []Invitation `gorm:"foreignKey:JobID;constraint:OnDelete:CASCADE"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Trait struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
Name string `gorm:"uniqueIndex;size:100;not null"`
|
||||||
|
Normalized string `gorm:"uniqueIndex;size:100;not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Question struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
Text string `gorm:"type:text;not null"`
|
||||||
|
Type string `gorm:"size:32;not null"`
|
||||||
|
ReferenceAnswer string `gorm:"type:text;not null;default:''"`
|
||||||
|
Deprecated bool `gorm:"not null;default:false"`
|
||||||
|
Options []QuestionOption `gorm:"foreignKey:QuestionID;constraint:OnDelete:CASCADE"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuestionOption struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
QuestionID uint `gorm:"index;not null"`
|
||||||
|
Text string `gorm:"type:text;not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Participant struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
FirstName string `gorm:"size:100;not null"`
|
||||||
|
LastName string `gorm:"size:100;not null"`
|
||||||
|
Birthday *time.Time
|
||||||
|
Email string `gorm:"uniqueIndex;size:320;not null"`
|
||||||
|
ContactInfo string `gorm:"type:text"`
|
||||||
|
Photo []byte `gorm:"type:bytea"`
|
||||||
|
PhotoType string `gorm:"size:100"`
|
||||||
|
CV []byte `gorm:"type:bytea"`
|
||||||
|
CVFilename string `gorm:"size:255"`
|
||||||
|
Traits []Trait `gorm:"many2many:participant_traits"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Invitation struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
JobID uint `gorm:"index;not null"`
|
||||||
|
ParticipantID uint `gorm:"index;not null"`
|
||||||
|
Token string `gorm:"uniqueIndex;size:64;not null"`
|
||||||
|
Status string `gorm:"size:32;not null;default:pending"`
|
||||||
|
DurationMinutes int `gorm:"not null"`
|
||||||
|
AcceptedAt *time.Time
|
||||||
|
CompletedAt *time.Time
|
||||||
|
Answers []InvitationAnswer `gorm:"foreignKey:InvitationID;constraint:OnDelete:CASCADE"`
|
||||||
|
Job JobPosting
|
||||||
|
Participant Participant
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type InvitationAnswer struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
InvitationID uint `gorm:"uniqueIndex:invitation_question;not null"`
|
||||||
|
QuestionID uint `gorm:"uniqueIndex:invitation_question;not null"`
|
||||||
|
Answer string `gorm:"type:text;not null"`
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
68
backend/internal/database/mapper/mapper.go
Normal file
68
backend/internal/database/mapper/mapper.go
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
package mapper
|
||||||
|
|
||||||
|
import (
|
||||||
|
dbmodel "github.com/danyel/go-guess/backend/internal/database/entity"
|
||||||
|
servicemodel "github.com/danyel/go-guess/backend/internal/service/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func UserToService(value dbmodel.User) servicemodel.User {
|
||||||
|
return servicemodel.User{ID: value.ID, Email: value.Email, PasswordHash: value.PasswordHash, Role: value.Role}
|
||||||
|
}
|
||||||
|
|
||||||
|
func JobToService(value dbmodel.JobPosting) servicemodel.JobPosting {
|
||||||
|
return servicemodel.JobPosting{
|
||||||
|
ID: value.ID, Title: value.Title, Description: value.Description,
|
||||||
|
Seniority: value.Seniority, Position: value.Position, Status: value.Status,
|
||||||
|
DurationMinutes: value.DurationMinutes,
|
||||||
|
Labels: traitsToService(value.Labels), RequiredSkills: traitsToService(value.RequiredSkills),
|
||||||
|
AdditionalSkills: traitsToService(value.AdditionalSkills),
|
||||||
|
Questions: questionsToService(value.Questions), CreatedAt: value.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParticipantToService(value dbmodel.Participant) servicemodel.Participant {
|
||||||
|
return servicemodel.Participant{
|
||||||
|
ID: value.ID, FirstName: value.FirstName, LastName: value.LastName,
|
||||||
|
Birthday: value.Birthday, Email: value.Email, ContactInfo: value.ContactInfo,
|
||||||
|
Photo: value.Photo, PhotoType: value.PhotoType, CV: value.CV,
|
||||||
|
CVFilename: value.CVFilename, Traits: traitsToService(value.Traits), CreatedAt: value.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func traitsToService(values []dbmodel.Trait) []servicemodel.Trait {
|
||||||
|
result := make([]servicemodel.Trait, len(values))
|
||||||
|
for i, value := range values {
|
||||||
|
result[i] = servicemodel.Trait{ID: value.ID, Name: value.Name}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func questionsToService(values []dbmodel.Question) []servicemodel.Question {
|
||||||
|
result := make([]servicemodel.Question, len(values))
|
||||||
|
for i, value := range values {
|
||||||
|
options := make([]string, len(value.Options))
|
||||||
|
for j, option := range value.Options {
|
||||||
|
options[j] = option.Text
|
||||||
|
}
|
||||||
|
result[i] = servicemodel.Question{
|
||||||
|
ID: value.ID, Text: value.Text, Type: value.Type,
|
||||||
|
Options: options, ReferenceAnswer: value.ReferenceAnswer,
|
||||||
|
Deprecated: value.Deprecated,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func QuestionToService(value dbmodel.Question) servicemodel.Question {
|
||||||
|
return questionsToService([]dbmodel.Question{value})[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func InvitationToService(value dbmodel.Invitation) servicemodel.Invitation {
|
||||||
|
return servicemodel.Invitation{
|
||||||
|
ID: value.ID, JobID: value.JobID, ParticipantID: value.ParticipantID,
|
||||||
|
ParticipantName: value.Participant.FirstName + " " + value.Participant.LastName,
|
||||||
|
ParticipantEmail: value.Participant.Email, Token: value.Token, Status: value.Status,
|
||||||
|
DurationMinutes: value.DurationMinutes, AcceptedAt: value.AcceptedAt,
|
||||||
|
CompletedAt: value.CompletedAt, CreatedAt: value.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
408
backend/internal/database/repository/store.go
Normal file
408
backend/internal/database/repository/store.go
Normal file
|
|
@ -0,0 +1,408 @@
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
dbmodel "github.com/danyel/go-guess/backend/internal/database/entity"
|
||||||
|
dbmapper "github.com/danyel/go-guess/backend/internal/database/mapper"
|
||||||
|
servicemodel "github.com/danyel/go-guess/backend/internal/service/model"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotFound = errors.New("record not found")
|
||||||
|
ErrConflict = errors.New("record conflicts with existing data")
|
||||||
|
)
|
||||||
|
|
||||||
|
type IStore interface {
|
||||||
|
FindUserByEmail(context.Context, string) (servicemodel.User, error)
|
||||||
|
ListJobs(context.Context) ([]servicemodel.JobPosting, error)
|
||||||
|
GetJob(context.Context, uint) (servicemodel.JobPosting, error)
|
||||||
|
CreateJob(context.Context, servicemodel.JobPosting) (servicemodel.JobPosting, error)
|
||||||
|
UpdateJob(context.Context, uint, string, int) (servicemodel.JobPosting, error)
|
||||||
|
ListQuestions(context.Context, string) ([]servicemodel.Question, error)
|
||||||
|
GetQuestion(context.Context, uint) (servicemodel.Question, error)
|
||||||
|
CreateQuestion(context.Context, servicemodel.Question) (servicemodel.Question, error)
|
||||||
|
UpdateQuestion(context.Context, uint, servicemodel.Question) (servicemodel.Question, error)
|
||||||
|
SetQuestionDeprecated(context.Context, uint, bool) (servicemodel.Question, error)
|
||||||
|
AttachQuestion(context.Context, uint, uint) error
|
||||||
|
DetachQuestion(context.Context, uint, uint) error
|
||||||
|
ListParticipants(context.Context) ([]servicemodel.Participant, error)
|
||||||
|
GetParticipant(context.Context, uint) (servicemodel.Participant, error)
|
||||||
|
CreateParticipant(context.Context, servicemodel.Participant) (servicemodel.Participant, error)
|
||||||
|
AllTraits(context.Context) ([]servicemodel.Trait, error)
|
||||||
|
ListInvitations(context.Context, uint) ([]servicemodel.Invitation, error)
|
||||||
|
CreateInvitation(context.Context, servicemodel.Invitation) (servicemodel.Invitation, error)
|
||||||
|
GetInterview(context.Context, string) (servicemodel.Interview, error)
|
||||||
|
AcceptInvitation(context.Context, string, time.Time) error
|
||||||
|
SaveAnswer(context.Context, string, uint, string) error
|
||||||
|
CompleteInvitation(context.Context, string, time.Time) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db *gorm.DB) *Store {
|
||||||
|
return &Store{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) FindUserByEmail(ctx context.Context, email string) (servicemodel.User, error) {
|
||||||
|
var value dbmodel.User
|
||||||
|
if err := s.db.WithContext(ctx).Where("lower(email) = ?", strings.ToLower(email)).First(&value).Error; err != nil {
|
||||||
|
return servicemodel.User{}, mapError(err)
|
||||||
|
}
|
||||||
|
return dbmapper.UserToService(value), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListJobs(ctx context.Context) ([]servicemodel.JobPosting, error) {
|
||||||
|
var values []dbmodel.JobPosting
|
||||||
|
if err := preloadJob(s.db.WithContext(ctx)).Order("created_at DESC").Find(&values).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]servicemodel.JobPosting, len(values))
|
||||||
|
for i, value := range values {
|
||||||
|
result[i] = dbmapper.JobToService(value)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetJob(ctx context.Context, id uint) (servicemodel.JobPosting, error) {
|
||||||
|
var value dbmodel.JobPosting
|
||||||
|
if err := preloadJob(s.db.WithContext(ctx)).First(&value, id).Error; err != nil {
|
||||||
|
return servicemodel.JobPosting{}, mapError(err)
|
||||||
|
}
|
||||||
|
return dbmapper.JobToService(value), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateJob(ctx context.Context, value servicemodel.JobPosting) (servicemodel.JobPosting, error) {
|
||||||
|
entity := dbmodel.JobPosting{
|
||||||
|
Title: value.Title, Description: value.Description, Seniority: value.Seniority,
|
||||||
|
Position: value.Position, Status: value.Status, DurationMinutes: value.DurationMinutes,
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
entity.Labels, err = s.resolveTraits(ctx, value.Labels)
|
||||||
|
if err != nil {
|
||||||
|
return servicemodel.JobPosting{}, err
|
||||||
|
}
|
||||||
|
entity.RequiredSkills, err = s.resolveTraits(ctx, value.RequiredSkills)
|
||||||
|
if err != nil {
|
||||||
|
return servicemodel.JobPosting{}, err
|
||||||
|
}
|
||||||
|
entity.AdditionalSkills, err = s.resolveTraits(ctx, value.AdditionalSkills)
|
||||||
|
if err != nil {
|
||||||
|
return servicemodel.JobPosting{}, err
|
||||||
|
}
|
||||||
|
if err := s.db.WithContext(ctx).Create(&entity).Error; err != nil {
|
||||||
|
return servicemodel.JobPosting{}, mapError(err)
|
||||||
|
}
|
||||||
|
return s.GetJob(ctx, entity.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateJob(ctx context.Context, id uint, status string, durationMinutes int) (servicemodel.JobPosting, error) {
|
||||||
|
result := s.db.WithContext(ctx).Model(&dbmodel.JobPosting{}).Where("id = ?", id).
|
||||||
|
Updates(map[string]any{"status": status, "duration_minutes": durationMinutes})
|
||||||
|
if result.Error != nil {
|
||||||
|
return servicemodel.JobPosting{}, result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return servicemodel.JobPosting{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return s.GetJob(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListQuestions(ctx context.Context, search string) ([]servicemodel.Question, error) {
|
||||||
|
query := s.db.WithContext(ctx).Preload("Options").Order("deprecated, created_at DESC")
|
||||||
|
if search = strings.TrimSpace(search); search != "" {
|
||||||
|
query = query.Where("text ILIKE ?", "%"+search+"%")
|
||||||
|
}
|
||||||
|
var values []dbmodel.Question
|
||||||
|
if err := query.Find(&values).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]servicemodel.Question, len(values))
|
||||||
|
for i, value := range values {
|
||||||
|
result[i] = dbmapper.QuestionToService(value)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetQuestion(ctx context.Context, id uint) (servicemodel.Question, error) {
|
||||||
|
var value dbmodel.Question
|
||||||
|
if err := s.db.WithContext(ctx).Preload("Options").First(&value, id).Error; err != nil {
|
||||||
|
return servicemodel.Question{}, mapError(err)
|
||||||
|
}
|
||||||
|
return dbmapper.QuestionToService(value), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateQuestion(ctx context.Context, value servicemodel.Question) (servicemodel.Question, error) {
|
||||||
|
options := make([]dbmodel.QuestionOption, len(value.Options))
|
||||||
|
for i, option := range value.Options {
|
||||||
|
options[i] = dbmodel.QuestionOption{Text: option}
|
||||||
|
}
|
||||||
|
entity := dbmodel.Question{
|
||||||
|
Text: value.Text, Type: value.Type, Options: options,
|
||||||
|
ReferenceAnswer: value.ReferenceAnswer, Deprecated: value.Deprecated,
|
||||||
|
}
|
||||||
|
if err := s.db.WithContext(ctx).Create(&entity).Error; err != nil {
|
||||||
|
return servicemodel.Question{}, mapError(err)
|
||||||
|
}
|
||||||
|
return s.GetQuestion(ctx, entity.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateQuestion(ctx context.Context, id uint, value servicemodel.Question) (servicemodel.Question, error) {
|
||||||
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
result := tx.Model(&dbmodel.Question{}).Where("id = ?", id).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"text": value.Text, "type": value.Type, "reference_answer": value.ReferenceAnswer,
|
||||||
|
})
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if err := tx.Where("question_id = ?", id).Delete(&dbmodel.QuestionOption{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(value.Options) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
options := make([]dbmodel.QuestionOption, len(value.Options))
|
||||||
|
for i, option := range value.Options {
|
||||||
|
options[i] = dbmodel.QuestionOption{QuestionID: id, Text: option}
|
||||||
|
}
|
||||||
|
return tx.Create(&options).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return servicemodel.Question{}, mapError(err)
|
||||||
|
}
|
||||||
|
return s.GetQuestion(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SetQuestionDeprecated(ctx context.Context, id uint, deprecated bool) (servicemodel.Question, error) {
|
||||||
|
result := s.db.WithContext(ctx).Model(&dbmodel.Question{}).Where("id = ?", id).
|
||||||
|
Update("deprecated", deprecated)
|
||||||
|
if result.Error != nil {
|
||||||
|
return servicemodel.Question{}, result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return servicemodel.Question{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return s.GetQuestion(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AttachQuestion(ctx context.Context, jobID, questionID uint) error {
|
||||||
|
var job dbmodel.JobPosting
|
||||||
|
if err := s.db.WithContext(ctx).First(&job, jobID).Error; err != nil {
|
||||||
|
return mapError(err)
|
||||||
|
}
|
||||||
|
var question dbmodel.Question
|
||||||
|
if err := s.db.WithContext(ctx).First(&question, questionID).Error; err != nil {
|
||||||
|
return mapError(err)
|
||||||
|
}
|
||||||
|
var invitations int64
|
||||||
|
if err := s.db.WithContext(ctx).Model(&dbmodel.Invitation{}).Where("job_id = ?", jobID).
|
||||||
|
Count(&invitations).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if invitations > 0 {
|
||||||
|
return ErrConflict
|
||||||
|
}
|
||||||
|
return mapError(s.db.WithContext(ctx).Model(&job).Association("Questions").Append(&question))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DetachQuestion(ctx context.Context, jobID, questionID uint) error {
|
||||||
|
var job dbmodel.JobPosting
|
||||||
|
if err := s.db.WithContext(ctx).First(&job, jobID).Error; err != nil {
|
||||||
|
return mapError(err)
|
||||||
|
}
|
||||||
|
var invitations int64
|
||||||
|
if err := s.db.WithContext(ctx).Model(&dbmodel.Invitation{}).Where("job_id = ?", jobID).
|
||||||
|
Count(&invitations).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if invitations > 0 {
|
||||||
|
return ErrConflict
|
||||||
|
}
|
||||||
|
return s.db.WithContext(ctx).Model(&job).Association("Questions").
|
||||||
|
Delete(&dbmodel.Question{ID: questionID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListParticipants(ctx context.Context) ([]servicemodel.Participant, error) {
|
||||||
|
var values []dbmodel.Participant
|
||||||
|
if err := s.db.WithContext(ctx).Preload("Traits").Order("last_name, first_name").Find(&values).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]servicemodel.Participant, len(values))
|
||||||
|
for i, value := range values {
|
||||||
|
result[i] = dbmapper.ParticipantToService(value)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetParticipant(ctx context.Context, id uint) (servicemodel.Participant, error) {
|
||||||
|
var value dbmodel.Participant
|
||||||
|
if err := s.db.WithContext(ctx).Preload("Traits").First(&value, id).Error; err != nil {
|
||||||
|
return servicemodel.Participant{}, mapError(err)
|
||||||
|
}
|
||||||
|
return dbmapper.ParticipantToService(value), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateParticipant(ctx context.Context, value servicemodel.Participant) (servicemodel.Participant, error) {
|
||||||
|
entity := dbmodel.Participant{
|
||||||
|
FirstName: value.FirstName, LastName: value.LastName, Birthday: value.Birthday,
|
||||||
|
Email: value.Email, ContactInfo: value.ContactInfo, Photo: value.Photo,
|
||||||
|
PhotoType: value.PhotoType, CV: value.CV, CVFilename: value.CVFilename,
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
entity.Traits, err = s.resolveTraits(ctx, value.Traits)
|
||||||
|
if err != nil {
|
||||||
|
return servicemodel.Participant{}, err
|
||||||
|
}
|
||||||
|
if err := s.db.WithContext(ctx).Create(&entity).Error; err != nil {
|
||||||
|
return servicemodel.Participant{}, mapError(err)
|
||||||
|
}
|
||||||
|
return s.GetParticipant(ctx, entity.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AllTraits(ctx context.Context) ([]servicemodel.Trait, error) {
|
||||||
|
var values []dbmodel.Trait
|
||||||
|
if err := s.db.WithContext(ctx).Order("name").Find(&values).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]servicemodel.Trait, len(values))
|
||||||
|
for i, value := range values {
|
||||||
|
result[i] = servicemodel.Trait{ID: value.ID, Name: value.Name}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListInvitations(ctx context.Context, jobID uint) ([]servicemodel.Invitation, error) {
|
||||||
|
var values []dbmodel.Invitation
|
||||||
|
if err := s.db.WithContext(ctx).Preload("Participant").Where("job_id = ?", jobID).
|
||||||
|
Order("created_at DESC").Find(&values).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]servicemodel.Invitation, len(values))
|
||||||
|
for i, value := range values {
|
||||||
|
result[i] = dbmapper.InvitationToService(value)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateInvitation(ctx context.Context, value servicemodel.Invitation) (servicemodel.Invitation, error) {
|
||||||
|
entity := dbmodel.Invitation{
|
||||||
|
JobID: value.JobID, ParticipantID: value.ParticipantID,
|
||||||
|
Token: value.Token, Status: value.Status, DurationMinutes: value.DurationMinutes,
|
||||||
|
}
|
||||||
|
if err := s.db.WithContext(ctx).Create(&entity).Error; err != nil {
|
||||||
|
return servicemodel.Invitation{}, mapError(err)
|
||||||
|
}
|
||||||
|
var created dbmodel.Invitation
|
||||||
|
if err := s.db.WithContext(ctx).Preload("Participant").First(&created, entity.ID).Error; err != nil {
|
||||||
|
return servicemodel.Invitation{}, mapError(err)
|
||||||
|
}
|
||||||
|
return dbmapper.InvitationToService(created), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetInterview(ctx context.Context, token string) (servicemodel.Interview, error) {
|
||||||
|
var invitation dbmodel.Invitation
|
||||||
|
err := s.db.WithContext(ctx).Preload("Participant").Preload("Answers").
|
||||||
|
Preload("Job.Labels").Preload("Job.RequiredSkills").Preload("Job.AdditionalSkills").
|
||||||
|
Preload("Job.Questions.Options").Where("token = ?", token).First(&invitation).Error
|
||||||
|
if err != nil {
|
||||||
|
return servicemodel.Interview{}, mapError(err)
|
||||||
|
}
|
||||||
|
answers := make(map[uint]string, len(invitation.Answers))
|
||||||
|
for _, answer := range invitation.Answers {
|
||||||
|
answers[answer.QuestionID] = answer.Answer
|
||||||
|
}
|
||||||
|
return servicemodel.Interview{
|
||||||
|
Invitation: dbmapper.InvitationToService(invitation),
|
||||||
|
Job: dbmapper.JobToService(invitation.Job),
|
||||||
|
Answers: answers,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AcceptInvitation(ctx context.Context, token string, acceptedAt time.Time) error {
|
||||||
|
result := s.db.WithContext(ctx).Model(&dbmodel.Invitation{}).
|
||||||
|
Where("token = ? AND status = ?", token, "pending").
|
||||||
|
Updates(map[string]any{"status": "accepted", "accepted_at": acceptedAt})
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return ErrConflict
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SaveAnswer(ctx context.Context, token string, questionID uint, answer string) error {
|
||||||
|
var invitation dbmodel.Invitation
|
||||||
|
if err := s.db.WithContext(ctx).Where("token = ?", token).First(&invitation).Error; err != nil {
|
||||||
|
return mapError(err)
|
||||||
|
}
|
||||||
|
value := dbmodel.InvitationAnswer{InvitationID: invitation.ID, QuestionID: questionID, Answer: answer}
|
||||||
|
return s.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "invitation_id"}, {Name: "question_id"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{"answer", "updated_at"}),
|
||||||
|
}).Create(&value).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CompleteInvitation(ctx context.Context, token string, completedAt time.Time) error {
|
||||||
|
result := s.db.WithContext(ctx).Model(&dbmodel.Invitation{}).
|
||||||
|
Where("token = ? AND status = ?", token, "accepted").
|
||||||
|
Updates(map[string]any{"status": "completed", "completed_at": completedAt})
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return ErrConflict
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) resolveTraits(ctx context.Context, values []servicemodel.Trait) ([]dbmodel.Trait, error) {
|
||||||
|
result := make([]dbmodel.Trait, 0, len(values))
|
||||||
|
seen := make(map[string]struct{}, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
name := strings.TrimSpace(value.Name)
|
||||||
|
normalized := strings.ToLower(name)
|
||||||
|
if normalized == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[normalized]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[normalized] = struct{}{}
|
||||||
|
var trait dbmodel.Trait
|
||||||
|
if err := s.db.WithContext(ctx).Where(dbmodel.Trait{Normalized: normalized}).
|
||||||
|
Attrs(dbmodel.Trait{Name: name}).FirstOrCreate(&trait).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, trait)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func preloadJob(db *gorm.DB) *gorm.DB {
|
||||||
|
return db.Preload("Labels").Preload("RequiredSkills").Preload("AdditionalSkills").
|
||||||
|
Preload("Questions.Options")
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapError(err error) error {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
var pgError *pgconn.PgError
|
||||||
|
if errors.As(err, &pgError) && pgError.Code == "23505" {
|
||||||
|
return ErrConflict
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/danyel/go-guess/backend/internal/service/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStoreEditsQuestionWithoutDeletingIt(t *testing.T) {
|
||||||
|
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||||
|
if databaseURL == "" {
|
||||||
|
t.Skip("TEST_DATABASE_URL is not set")
|
||||||
|
}
|
||||||
|
db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
store := New(db)
|
||||||
|
created, err := store.CreateQuestion(context.Background(), model.Question{
|
||||||
|
Text: "Original?", Type: "open",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Exec("DELETE FROM questions WHERE id = ?", created.ID) })
|
||||||
|
|
||||||
|
updated, err := store.UpdateQuestion(context.Background(), created.ID, model.Question{
|
||||||
|
Text: "Choose two", Type: "multiple_choice", Options: []string{"One", "Two"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if updated.ID != created.ID || updated.Text != "Choose two" || len(updated.Options) != 2 {
|
||||||
|
t.Fatalf("unexpected updated question: %#v", updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStorePersistsJobWithTraits(t *testing.T) {
|
||||||
|
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||||
|
if databaseURL == "" {
|
||||||
|
t.Skip("TEST_DATABASE_URL is not set")
|
||||||
|
}
|
||||||
|
db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
store := New(db)
|
||||||
|
suffix := uuid.NewString()
|
||||||
|
created, err := store.CreateJob(context.Background(), model.JobPosting{
|
||||||
|
Title: "Integration " + suffix, Description: "Database mapping test",
|
||||||
|
Seniority: "Senior", Position: "Backend Engineer",
|
||||||
|
Labels: []model.Trait{{Name: "integration-" + suffix}},
|
||||||
|
RequiredSkills: []model.Trait{{Name: "Go"}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Exec("DELETE FROM job_postings WHERE id = ?", created.ID) })
|
||||||
|
|
||||||
|
found, err := store.GetJob(context.Background(), created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if found.Title != created.Title || len(found.Labels) != 1 || len(found.RequiredSkills) != 1 {
|
||||||
|
t.Fatalf("unexpected stored job: %#v", found)
|
||||||
|
}
|
||||||
|
}
|
||||||
75
backend/internal/security/security.go
Normal file
75
backend/internal/security/security.go
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidToken = errors.New("invalid token")
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
UserID uint `json:"userId"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
type ITokenManager interface {
|
||||||
|
Issue(uint, string, string) (string, error)
|
||||||
|
Parse(string) (Claims, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenManager struct {
|
||||||
|
secret []byte
|
||||||
|
ttl time.Duration
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTokenManager(secret string, ttl time.Duration) *TokenManager {
|
||||||
|
return &TokenManager{secret: []byte(secret), ttl: ttl, now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *TokenManager) Issue(userID uint, email, role string) (string, error) {
|
||||||
|
now := m.now()
|
||||||
|
claims := Claims{
|
||||||
|
UserID: userID, Email: email, Role: role,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Subject: fmt.Sprint(userID), IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(m.ttl)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(m.secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *TokenManager) Parse(raw string) (Claims, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(raw, &Claims{}, func(token *jwt.Token) (any, error) {
|
||||||
|
if token.Method != jwt.SigningMethodHS256 {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
return m.secret, nil
|
||||||
|
})
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
return Claims{}, ErrInvalidToken
|
||||||
|
}
|
||||||
|
claims, ok := token.Claims.(*Claims)
|
||||||
|
if !ok {
|
||||||
|
return Claims{}, ErrInvalidToken
|
||||||
|
}
|
||||||
|
return *claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
|
value, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("hash password: %w", err)
|
||||||
|
}
|
||||||
|
return string(value), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckPassword(hash, password string) bool {
|
||||||
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||||
|
}
|
||||||
33
backend/internal/security/security_test.go
Normal file
33
backend/internal/security/security_test.go
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTokenRoundTrip(t *testing.T) {
|
||||||
|
manager := NewTokenManager("01234567890123456789012345678901", time.Hour)
|
||||||
|
raw, err := manager.Issue(42, "interviewer@example.com", "interviewer")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
claims, err := manager.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if claims.UserID != 42 || claims.Email != "interviewer@example.com" || claims.Role != "interviewer" {
|
||||||
|
t.Fatalf("unexpected claims: %#v", claims)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectsTokenSignedWithAnotherSecret(t *testing.T) {
|
||||||
|
issuer := NewTokenManager("01234567890123456789012345678901", time.Hour)
|
||||||
|
verifier := NewTokenManager("abcdefghijklmnopqrstuvwxyz123456", time.Hour)
|
||||||
|
raw, err := issuer.Issue(1, "user@example.com", "interviewer")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := verifier.Parse(raw); err == nil {
|
||||||
|
t.Fatal("expected token validation to fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
78
backend/internal/service/cv.go
Normal file
78
backend/internal/service/cv.go
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ledongthuc/pdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
func extractCVText(filename string, content []byte) (text string, err error) {
|
||||||
|
if len(content) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
switch strings.ToLower(filepath.Ext(filename)) {
|
||||||
|
case ".pdf":
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
text = ""
|
||||||
|
err = fmt.Errorf("unreadable PDF")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
reader, err := pdf.NewReader(bytes.NewReader(content), int64(len(content)))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read PDF: %w", err)
|
||||||
|
}
|
||||||
|
plainText, err := reader.GetPlainText()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("extract PDF text: %w", err)
|
||||||
|
}
|
||||||
|
value, err := io.ReadAll(plainText)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read PDF text: %w", err)
|
||||||
|
}
|
||||||
|
return string(value), nil
|
||||||
|
case ".docx":
|
||||||
|
return extractDOCX(content)
|
||||||
|
default:
|
||||||
|
return string(content), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractDOCX(content []byte) (string, error) {
|
||||||
|
archive, err := zip.NewReader(bytes.NewReader(content), int64(len(content)))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("open DOCX: %w", err)
|
||||||
|
}
|
||||||
|
for _, file := range archive.File {
|
||||||
|
if file.Name != "word/document.xml" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stream, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("open DOCX document: %w", err)
|
||||||
|
}
|
||||||
|
defer stream.Close()
|
||||||
|
decoder := xml.NewDecoder(stream)
|
||||||
|
var text strings.Builder
|
||||||
|
for {
|
||||||
|
token, err := decoder.Token()
|
||||||
|
if err == io.EOF {
|
||||||
|
return text.String(), nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("parse DOCX document: %w", err)
|
||||||
|
}
|
||||||
|
if chars, ok := token.(xml.CharData); ok {
|
||||||
|
text.Write(chars)
|
||||||
|
text.WriteByte(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("DOCX document body is missing")
|
||||||
|
}
|
||||||
30
backend/internal/service/cv_test.go
Normal file
30
backend/internal/service/cv_test.go
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtractCVTextFromDOCX(t *testing.T) {
|
||||||
|
var content bytes.Buffer
|
||||||
|
archive := zip.NewWriter(&content)
|
||||||
|
document, err := archive.Create("word/document.xml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := document.Write([]byte(`<w:document xmlns:w="urn:test"><w:body><w:p><w:r><w:t>Go and PostgreSQL</w:t></w:r></w:p></w:body></w:document>`)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := archive.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
text, err := extractCVText("candidate.docx", content.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if text != "Go and PostgreSQL " {
|
||||||
|
t.Fatalf("unexpected extracted text %q", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
81
backend/internal/service/model/models.go
Normal file
81
backend/internal/service/model/models.go
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID uint
|
||||||
|
Email string
|
||||||
|
PasswordHash string
|
||||||
|
Role string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Trait struct {
|
||||||
|
ID uint
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
type JobPosting struct {
|
||||||
|
ID uint
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
Seniority string
|
||||||
|
Position string
|
||||||
|
Status string
|
||||||
|
DurationMinutes int
|
||||||
|
Labels []Trait
|
||||||
|
RequiredSkills []Trait
|
||||||
|
AdditionalSkills []Trait
|
||||||
|
Questions []Question
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Question struct {
|
||||||
|
ID uint
|
||||||
|
Text string
|
||||||
|
Type string
|
||||||
|
Options []string
|
||||||
|
ReferenceAnswer string
|
||||||
|
Deprecated bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Participant struct {
|
||||||
|
ID uint
|
||||||
|
FirstName string
|
||||||
|
LastName string
|
||||||
|
Birthday *time.Time
|
||||||
|
Email string
|
||||||
|
ContactInfo string
|
||||||
|
Photo []byte
|
||||||
|
PhotoType string
|
||||||
|
CV []byte
|
||||||
|
CVFilename string
|
||||||
|
Traits []Trait
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type CandidateMatch struct {
|
||||||
|
Participant Participant
|
||||||
|
Score float64
|
||||||
|
Matched []string
|
||||||
|
Missing []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Invitation struct {
|
||||||
|
ID uint
|
||||||
|
JobID uint
|
||||||
|
ParticipantID uint
|
||||||
|
ParticipantName string
|
||||||
|
ParticipantEmail string
|
||||||
|
Token string
|
||||||
|
Status string
|
||||||
|
DurationMinutes int
|
||||||
|
AcceptedAt *time.Time
|
||||||
|
CompletedAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Interview struct {
|
||||||
|
Invitation Invitation
|
||||||
|
Job JobPosting
|
||||||
|
Answers map[uint]string
|
||||||
|
}
|
||||||
454
backend/internal/service/services.go
Normal file
454
backend/internal/service/services.go
Normal file
|
|
@ -0,0 +1,454 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"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/model"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidCredentials = errors.New("invalid email or password")
|
||||||
|
ErrInvalidQuestion = errors.New("invalid question")
|
||||||
|
ErrValidation = errors.New("validation failed")
|
||||||
|
ErrInvalidState = errors.New("operation is not allowed in the current state")
|
||||||
|
)
|
||||||
|
|
||||||
|
const CandidateThreshold = 60.0
|
||||||
|
|
||||||
|
type IAuthService interface {
|
||||||
|
Login(context.Context, string, string) (string, model.User, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type IJobService interface {
|
||||||
|
List(context.Context) ([]model.JobPosting, error)
|
||||||
|
Get(context.Context, uint) (model.JobPosting, error)
|
||||||
|
Create(context.Context, model.JobPosting) (model.JobPosting, error)
|
||||||
|
Update(context.Context, uint, string, int) (model.JobPosting, error)
|
||||||
|
AttachQuestion(context.Context, uint, uint) error
|
||||||
|
DetachQuestion(context.Context, uint, uint) error
|
||||||
|
Candidates(context.Context, uint) ([]model.CandidateMatch, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type IQuestionService interface {
|
||||||
|
List(context.Context, string) ([]model.Question, error)
|
||||||
|
Create(context.Context, model.Question) (model.Question, error)
|
||||||
|
Update(context.Context, uint, model.Question) (model.Question, error)
|
||||||
|
SetDeprecated(context.Context, uint, bool) (model.Question, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type IParticipantService interface {
|
||||||
|
List(context.Context) ([]model.Participant, error)
|
||||||
|
Get(context.Context, uint) (model.Participant, error)
|
||||||
|
Create(context.Context, model.Participant) (model.Participant, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type IInvitationService interface {
|
||||||
|
List(context.Context, uint) ([]model.Invitation, error)
|
||||||
|
Create(context.Context, uint, uint) (model.Invitation, error)
|
||||||
|
GetInterview(context.Context, string) (model.Interview, error)
|
||||||
|
Accept(context.Context, string) (model.Interview, error)
|
||||||
|
SaveAnswer(context.Context, string, uint, string) error
|
||||||
|
Finish(context.Context, string) (model.Interview, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthService struct {
|
||||||
|
store repository.IStore
|
||||||
|
tokens security.ITokenManager
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthService(store repository.IStore, tokens security.ITokenManager) *AuthService {
|
||||||
|
return &AuthService{store: store, tokens: tokens}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) Login(ctx context.Context, email, password string) (string, model.User, error) {
|
||||||
|
user, err := s.store.FindUserByEmail(ctx, strings.TrimSpace(email))
|
||||||
|
if err != nil || !security.CheckPassword(user.PasswordHash, password) {
|
||||||
|
return "", model.User{}, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
token, err := s.tokens.Issue(user.ID, user.Email, user.Role)
|
||||||
|
if err != nil {
|
||||||
|
return "", model.User{}, err
|
||||||
|
}
|
||||||
|
user.PasswordHash = ""
|
||||||
|
return token, user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type JobService struct {
|
||||||
|
store repository.IStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewJobService(store repository.IStore) *JobService {
|
||||||
|
return &JobService{store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JobService) List(ctx context.Context) ([]model.JobPosting, error) {
|
||||||
|
return s.store.ListJobs(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JobService) Get(ctx context.Context, id uint) (model.JobPosting, error) {
|
||||||
|
return s.store.GetJob(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JobService) Create(ctx context.Context, value model.JobPosting) (model.JobPosting, error) {
|
||||||
|
value.Title = strings.TrimSpace(value.Title)
|
||||||
|
value.Description = strings.TrimSpace(value.Description)
|
||||||
|
value.Position = strings.TrimSpace(value.Position)
|
||||||
|
value.Seniority = strings.TrimSpace(value.Seniority)
|
||||||
|
if value.Title == "" || value.Description == "" || value.Position == "" || value.Seniority == "" {
|
||||||
|
return model.JobPosting{}, fmt.Errorf("%w: title, description, position, and seniority are required", ErrValidation)
|
||||||
|
}
|
||||||
|
value.Status = "draft"
|
||||||
|
if value.DurationMinutes == 0 {
|
||||||
|
value.DurationMinutes = 60
|
||||||
|
}
|
||||||
|
if value.DurationMinutes < 1 || value.DurationMinutes > 480 {
|
||||||
|
return model.JobPosting{}, fmt.Errorf("%w: duration must be between 1 and 480 minutes", ErrValidation)
|
||||||
|
}
|
||||||
|
return s.store.CreateJob(ctx, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JobService) Update(ctx context.Context, id uint, status string, durationMinutes int) (model.JobPosting, error) {
|
||||||
|
if !validJobStatus(status) {
|
||||||
|
return model.JobPosting{}, fmt.Errorf("%w: unsupported job status", ErrValidation)
|
||||||
|
}
|
||||||
|
if durationMinutes < 1 || durationMinutes > 480 {
|
||||||
|
return model.JobPosting{}, fmt.Errorf("%w: duration must be between 1 and 480 minutes", ErrValidation)
|
||||||
|
}
|
||||||
|
if status == "published" {
|
||||||
|
job, err := s.store.GetJob(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return model.JobPosting{}, err
|
||||||
|
}
|
||||||
|
if len(job.Questions) == 0 {
|
||||||
|
return model.JobPosting{}, fmt.Errorf("%w: a published job requires at least one question", ErrInvalidState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.store.UpdateJob(ctx, id, status, durationMinutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JobService) AttachQuestion(ctx context.Context, jobID, questionID uint) error {
|
||||||
|
question, err := s.store.GetQuestion(ctx, questionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if question.Deprecated {
|
||||||
|
return fmt.Errorf("%w: deprecated questions cannot be newly attached", ErrInvalidState)
|
||||||
|
}
|
||||||
|
return s.store.AttachQuestion(ctx, jobID, questionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JobService) DetachQuestion(ctx context.Context, jobID, questionID uint) error {
|
||||||
|
return s.store.DetachQuestion(ctx, jobID, questionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuestionService struct {
|
||||||
|
store repository.IStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewQuestionService(store repository.IStore) *QuestionService {
|
||||||
|
return &QuestionService{store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuestionService) List(ctx context.Context, search string) ([]model.Question, error) {
|
||||||
|
return s.store.ListQuestions(ctx, search)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuestionService) Create(ctx context.Context, value model.Question) (model.Question, error) {
|
||||||
|
value, err := validateQuestion(value)
|
||||||
|
if err != nil {
|
||||||
|
return model.Question{}, err
|
||||||
|
}
|
||||||
|
return s.store.CreateQuestion(ctx, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuestionService) Update(ctx context.Context, id uint, value model.Question) (model.Question, error) {
|
||||||
|
value, err := validateQuestion(value)
|
||||||
|
if err != nil {
|
||||||
|
return model.Question{}, err
|
||||||
|
}
|
||||||
|
return s.store.UpdateQuestion(ctx, id, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateQuestion(value model.Question) (model.Question, error) {
|
||||||
|
value.Text = strings.TrimSpace(value.Text)
|
||||||
|
value.ReferenceAnswer = strings.TrimSpace(value.ReferenceAnswer)
|
||||||
|
switch value.Type {
|
||||||
|
case "open", "multiple_choice", "radio", "code_review":
|
||||||
|
default:
|
||||||
|
return model.Question{}, fmt.Errorf("%w: unsupported type", ErrInvalidQuestion)
|
||||||
|
}
|
||||||
|
if value.Text == "" {
|
||||||
|
return model.Question{}, fmt.Errorf("%w: text is required", ErrInvalidQuestion)
|
||||||
|
}
|
||||||
|
options := make([]string, 0, len(value.Options))
|
||||||
|
for _, option := range value.Options {
|
||||||
|
if option = strings.TrimSpace(option); option != "" {
|
||||||
|
options = append(options, option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value.Options = options
|
||||||
|
if (value.Type == "multiple_choice" || value.Type == "radio") && len(value.Options) < 2 {
|
||||||
|
return model.Question{}, fmt.Errorf("%w: at least two options are required", ErrInvalidQuestion)
|
||||||
|
}
|
||||||
|
if (value.Type == "open" || value.Type == "code_review") && value.ReferenceAnswer == "" {
|
||||||
|
return model.Question{}, fmt.Errorf("%w: a reference answer is required", ErrInvalidQuestion)
|
||||||
|
}
|
||||||
|
if value.Type == "open" || value.Type == "code_review" {
|
||||||
|
value.Options = nil
|
||||||
|
} else {
|
||||||
|
value.ReferenceAnswer = ""
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuestionService) SetDeprecated(ctx context.Context, id uint, deprecated bool) (model.Question, error) {
|
||||||
|
return s.store.SetQuestionDeprecated(ctx, id, deprecated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JobService) Candidates(ctx context.Context, jobID uint) ([]model.CandidateMatch, error) {
|
||||||
|
job, err := s.store.GetJob(ctx, jobID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
participants, err := s.store.ListParticipants(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
required := uniqueTraits(job.Labels, job.RequiredSkills, job.AdditionalSkills)
|
||||||
|
if len(required) == 0 {
|
||||||
|
return []model.CandidateMatch{}, nil
|
||||||
|
}
|
||||||
|
result := make([]model.CandidateMatch, 0)
|
||||||
|
for _, participant := range participants {
|
||||||
|
owned := make(map[string]struct{}, len(participant.Traits))
|
||||||
|
for _, trait := range participant.Traits {
|
||||||
|
owned[normalize(trait.Name)] = struct{}{}
|
||||||
|
}
|
||||||
|
text, err := extractCVText(participant.CVFilename, participant.CV)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("extract participant %d CV text: %w", participant.ID, err)
|
||||||
|
}
|
||||||
|
for name := range required {
|
||||||
|
if containsTerm(normalize(text), name) {
|
||||||
|
owned[name] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match := model.CandidateMatch{Participant: participant}
|
||||||
|
for name, display := range required {
|
||||||
|
if _, ok := owned[name]; ok {
|
||||||
|
match.Matched = append(match.Matched, display)
|
||||||
|
} else {
|
||||||
|
match.Missing = append(match.Missing, display)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match.Score = math.Round(float64(len(match.Matched))/float64(len(required))*1000) / 10
|
||||||
|
if match.Score >= CandidateThreshold {
|
||||||
|
sort.Strings(match.Matched)
|
||||||
|
sort.Strings(match.Missing)
|
||||||
|
result = append(result, match)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(result, func(i, j int) bool { return result[i].Score > result[j].Score })
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParticipantService struct {
|
||||||
|
store repository.IStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewParticipantService(store repository.IStore) *ParticipantService {
|
||||||
|
return &ParticipantService{store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ParticipantService) List(ctx context.Context) ([]model.Participant, error) {
|
||||||
|
return s.store.ListParticipants(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ParticipantService) Get(ctx context.Context, id uint) (model.Participant, error) {
|
||||||
|
return s.store.GetParticipant(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ParticipantService) Create(ctx context.Context, value model.Participant) (model.Participant, error) {
|
||||||
|
value.FirstName = strings.TrimSpace(value.FirstName)
|
||||||
|
value.LastName = strings.TrimSpace(value.LastName)
|
||||||
|
value.Email = strings.ToLower(strings.TrimSpace(value.Email))
|
||||||
|
if value.FirstName == "" || value.LastName == "" || value.Email == "" {
|
||||||
|
return model.Participant{}, fmt.Errorf("%w: first name, last name, and email are required", ErrValidation)
|
||||||
|
}
|
||||||
|
allTraits, err := s.store.AllTraits(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return model.Participant{}, err
|
||||||
|
}
|
||||||
|
text, err := extractCVText(value.CVFilename, value.CV)
|
||||||
|
if err != nil {
|
||||||
|
return model.Participant{}, fmt.Errorf("%w: extract CV text: %v", ErrValidation, err)
|
||||||
|
}
|
||||||
|
value.Traits = extractTraits(text, allTraits)
|
||||||
|
return s.store.CreateParticipant(ctx, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractTraits(content string, traits []model.Trait) []model.Trait {
|
||||||
|
text := normalize(content)
|
||||||
|
result := make([]model.Trait, 0)
|
||||||
|
for _, trait := range traits {
|
||||||
|
name := normalize(trait.Name)
|
||||||
|
if name != "" && containsTerm(text, name) {
|
||||||
|
result = append(result, trait)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsTerm(text, term string) bool {
|
||||||
|
return strings.Contains(" "+text+" ", " "+term+" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueTraits(groups ...[]model.Trait) map[string]string {
|
||||||
|
result := make(map[string]string)
|
||||||
|
for _, group := range groups {
|
||||||
|
for _, trait := range group {
|
||||||
|
if name := normalize(trait.Name); name != "" {
|
||||||
|
result[name] = trait.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalize(value string) string {
|
||||||
|
return strings.Join(strings.FieldsFunc(strings.ToLower(value), func(r rune) bool {
|
||||||
|
return !unicode.IsLetter(r) && !unicode.IsNumber(r) && r != '+' && r != '#'
|
||||||
|
}), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
type InvitationService struct {
|
||||||
|
store repository.IStore
|
||||||
|
frontendURL string
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInvitationService(store repository.IStore, frontendURL string) *InvitationService {
|
||||||
|
return &InvitationService{store: store, frontendURL: strings.TrimRight(frontendURL, "/"), now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InvitationService) List(ctx context.Context, jobID uint) ([]model.Invitation, error) {
|
||||||
|
return s.store.ListInvitations(ctx, jobID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InvitationService) Create(ctx context.Context, jobID, participantID uint) (model.Invitation, error) {
|
||||||
|
job, err := s.store.GetJob(ctx, jobID)
|
||||||
|
if err != nil {
|
||||||
|
return model.Invitation{}, err
|
||||||
|
}
|
||||||
|
if job.Status != "published" {
|
||||||
|
return model.Invitation{}, fmt.Errorf("%w: invitations require a published job", ErrInvalidState)
|
||||||
|
}
|
||||||
|
if _, err := s.store.GetParticipant(ctx, participantID); err != nil {
|
||||||
|
return model.Invitation{}, err
|
||||||
|
}
|
||||||
|
matches, err := NewJobService(s.store).Candidates(ctx, jobID)
|
||||||
|
if err != nil {
|
||||||
|
return model.Invitation{}, err
|
||||||
|
}
|
||||||
|
eligible := false
|
||||||
|
for _, match := range matches {
|
||||||
|
if match.Participant.ID == participantID {
|
||||||
|
eligible = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !eligible {
|
||||||
|
return model.Invitation{}, fmt.Errorf("%w: participant does not meet the %.0f%% threshold", ErrInvalidState, CandidateThreshold)
|
||||||
|
}
|
||||||
|
return s.store.CreateInvitation(ctx, model.Invitation{
|
||||||
|
JobID: jobID, ParticipantID: participantID, Token: uuid.NewString(),
|
||||||
|
Status: "pending", DurationMinutes: job.DurationMinutes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InvitationService) GetInterview(ctx context.Context, token string) (model.Interview, error) {
|
||||||
|
interview, err := s.store.GetInterview(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
return model.Interview{}, err
|
||||||
|
}
|
||||||
|
if interview.Job.Status != "published" && interview.Invitation.Status == "pending" {
|
||||||
|
return model.Interview{}, ErrNotAvailable
|
||||||
|
}
|
||||||
|
interview.Job.DurationMinutes = interview.Invitation.DurationMinutes
|
||||||
|
return interview, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrNotAvailable = errors.New("interview is not available")
|
||||||
|
|
||||||
|
func (s *InvitationService) Accept(ctx context.Context, token string) (model.Interview, error) {
|
||||||
|
interview, err := s.GetInterview(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
return model.Interview{}, err
|
||||||
|
}
|
||||||
|
if interview.Invitation.Status != "pending" {
|
||||||
|
return model.Interview{}, ErrInvalidState
|
||||||
|
}
|
||||||
|
if err := s.store.AcceptInvitation(ctx, token, s.now()); err != nil {
|
||||||
|
return model.Interview{}, err
|
||||||
|
}
|
||||||
|
return s.GetInterview(ctx, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InvitationService) SaveAnswer(ctx context.Context, token string, questionID uint, answer string) error {
|
||||||
|
interview, err := s.GetInterview(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if interview.Invitation.Status != "accepted" || interviewExpired(interview, s.now()) {
|
||||||
|
return ErrInvalidState
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, question := range interview.Job.Questions {
|
||||||
|
if question.ID == questionID {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return repository.ErrNotFound
|
||||||
|
}
|
||||||
|
return s.store.SaveAnswer(ctx, token, questionID, strings.TrimSpace(answer))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InvitationService) Finish(ctx context.Context, token string) (model.Interview, error) {
|
||||||
|
interview, err := s.GetInterview(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
return model.Interview{}, err
|
||||||
|
}
|
||||||
|
if interview.Invitation.Status != "accepted" {
|
||||||
|
return model.Interview{}, ErrInvalidState
|
||||||
|
}
|
||||||
|
if err := s.store.CompleteInvitation(ctx, token, s.now()); err != nil {
|
||||||
|
return model.Interview{}, err
|
||||||
|
}
|
||||||
|
return s.GetInterview(ctx, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func interviewExpired(interview model.Interview, now time.Time) bool {
|
||||||
|
return interview.Invitation.AcceptedAt != nil &&
|
||||||
|
!now.Before(interview.Invitation.AcceptedAt.Add(time.Duration(interview.Job.DurationMinutes)*time.Minute))
|
||||||
|
}
|
||||||
|
|
||||||
|
func validJobStatus(status string) bool {
|
||||||
|
switch status {
|
||||||
|
case "draft", "published", "deprecated":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
182
backend/internal/service/services_test.go
Normal file
182
backend/internal/service/services_test.go
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/danyel/go-guess/backend/internal/service/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeStore struct {
|
||||||
|
job model.JobPosting
|
||||||
|
participants []model.Participant
|
||||||
|
traits []model.Trait
|
||||||
|
}
|
||||||
|
|
||||||
|
type invitationStore struct {
|
||||||
|
*fakeStore
|
||||||
|
interview model.Interview
|
||||||
|
saved bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *invitationStore) CreateInvitation(_ context.Context, value model.Invitation) (model.Invitation, error) {
|
||||||
|
value.ID = 1
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
func (f *invitationStore) GetInterview(context.Context, string) (model.Interview, error) {
|
||||||
|
return f.interview, nil
|
||||||
|
}
|
||||||
|
func (f *invitationStore) SaveAnswer(context.Context, string, uint, string) error {
|
||||||
|
f.saved = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) FindUserByEmail(context.Context, string) (model.User, error) {
|
||||||
|
return model.User{}, errors.New("unused")
|
||||||
|
}
|
||||||
|
func (f *fakeStore) ListJobs(context.Context) ([]model.JobPosting, error) { return nil, nil }
|
||||||
|
func (f *fakeStore) GetJob(context.Context, uint) (model.JobPosting, error) { return f.job, nil }
|
||||||
|
func (f *fakeStore) CreateJob(_ context.Context, value model.JobPosting) (model.JobPosting, error) {
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) CreateQuestion(_ context.Context, value model.Question) (model.Question, error) {
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) UpdateQuestion(_ context.Context, _ uint, value model.Question) (model.Question, error) {
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) ListParticipants(context.Context) ([]model.Participant, error) {
|
||||||
|
return f.participants, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) GetParticipant(context.Context, uint) (model.Participant, error) {
|
||||||
|
return model.Participant{}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) CreateParticipant(_ context.Context, value model.Participant) (model.Participant, error) {
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) AllTraits(context.Context) ([]model.Trait, error) { return f.traits, nil }
|
||||||
|
func (f *fakeStore) UpdateJob(_ context.Context, _ uint, _ string, _ int) (model.JobPosting, error) {
|
||||||
|
return f.job, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) ListQuestions(context.Context, string) ([]model.Question, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) GetQuestion(context.Context, uint) (model.Question, error) {
|
||||||
|
return model.Question{}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) SetQuestionDeprecated(_ context.Context, _ uint, _ bool) (model.Question, error) {
|
||||||
|
return model.Question{}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) AttachQuestion(context.Context, uint, uint) error { return nil }
|
||||||
|
func (f *fakeStore) DetachQuestion(context.Context, uint, uint) error { return nil }
|
||||||
|
func (f *fakeStore) ListInvitations(context.Context, uint) ([]model.Invitation, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) CreateInvitation(_ context.Context, value model.Invitation) (model.Invitation, error) {
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) GetInterview(context.Context, string) (model.Interview, error) {
|
||||||
|
return model.Interview{}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) AcceptInvitation(context.Context, string, time.Time) error { return nil }
|
||||||
|
func (f *fakeStore) SaveAnswer(context.Context, string, uint, string) error { return nil }
|
||||||
|
func (f *fakeStore) CompleteInvitation(context.Context, string, time.Time) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCandidatesRequireSixtyPercent(t *testing.T) {
|
||||||
|
store := &fakeStore{
|
||||||
|
job: model.JobPosting{
|
||||||
|
Labels: []model.Trait{{Name: "Backend"}},
|
||||||
|
RequiredSkills: []model.Trait{{Name: "Go"}, {Name: "PostgreSQL"}, {Name: "Docker"}, {Name: "React"}},
|
||||||
|
},
|
||||||
|
participants: []model.Participant{
|
||||||
|
{ID: 1, Traits: []model.Trait{{Name: "Go"}, {Name: "PostgreSQL"}, {Name: "Docker"}}},
|
||||||
|
{ID: 2, Traits: []model.Trait{{Name: "Go"}, {Name: "React"}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
matches, err := NewJobService(store).Candidates(context.Background(), 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(matches) != 1 || matches[0].Participant.ID != 1 || matches[0].Score != 60 {
|
||||||
|
t.Fatalf("expected only participant 1 at 60%%, got %#v", matches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParticipantCreateExtractsKnownTraits(t *testing.T) {
|
||||||
|
store := &fakeStore{traits: []model.Trait{{Name: "Go"}, {Name: "React"}, {Name: "C++"}}}
|
||||||
|
value, err := NewParticipantService(store).Create(context.Background(), model.Participant{
|
||||||
|
FirstName: "Ada", LastName: "Lovelace", Email: "ADA@EXAMPLE.COM",
|
||||||
|
CV: []byte("Backend engineer using Go and C++ daily."), CVFilename: "cv.txt",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if value.Email != "ada@example.com" || len(value.Traits) != 2 {
|
||||||
|
t.Fatalf("unexpected participant: %#v", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvitationRequiresPublishedMatchingJob(t *testing.T) {
|
||||||
|
store := &invitationStore{fakeStore: &fakeStore{
|
||||||
|
job: model.JobPosting{
|
||||||
|
ID: 1, Status: "published",
|
||||||
|
RequiredSkills: []model.Trait{{Name: "Go"}},
|
||||||
|
},
|
||||||
|
participants: []model.Participant{{ID: 7, Traits: []model.Trait{{Name: "Go"}}}},
|
||||||
|
}}
|
||||||
|
invitation, err := NewInvitationService(store, "http://localhost:5173").Create(context.Background(), 1, 7)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if invitation.Status != "pending" || invitation.Token == "" {
|
||||||
|
t.Fatalf("unexpected invitation: %#v", invitation)
|
||||||
|
}
|
||||||
|
|
||||||
|
store.job.Status = "draft"
|
||||||
|
if _, err := NewInvitationService(store, "").Create(context.Background(), 1, 7); !errors.Is(err, ErrInvalidState) {
|
||||||
|
t.Fatalf("expected invalid state for draft job, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiredInterviewRejectsAnswers(t *testing.T) {
|
||||||
|
acceptedAt := time.Now().Add(-61 * time.Minute)
|
||||||
|
store := &invitationStore{
|
||||||
|
fakeStore: &fakeStore{},
|
||||||
|
interview: model.Interview{
|
||||||
|
Invitation: model.Invitation{Status: "accepted", AcceptedAt: &acceptedAt},
|
||||||
|
Job: model.JobPosting{
|
||||||
|
Status: "published", DurationMinutes: 60,
|
||||||
|
Questions: []model.Question{{ID: 9}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := NewInvitationService(store, "").SaveAnswer(context.Background(), "token", 9, "answer")
|
||||||
|
if !errors.Is(err, ErrInvalidState) || store.saved {
|
||||||
|
t.Fatalf("expected expired answer to be rejected, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuestionReferenceAnswerValidation(t *testing.T) {
|
||||||
|
service := NewQuestionService(&fakeStore{})
|
||||||
|
if _, err := service.Create(context.Background(), model.Question{
|
||||||
|
Text: "Explain cancellation", Type: "open",
|
||||||
|
}); !errors.Is(err, ErrInvalidQuestion) {
|
||||||
|
t.Fatalf("expected missing reference answer error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
question, err := service.Create(context.Background(), model.Question{
|
||||||
|
Text: "Choose", Type: "radio", Options: []string{" One ", "Two"},
|
||||||
|
ReferenceAnswer: "must be cleared",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if question.ReferenceAnswer != "" || question.Options[0] != "One" {
|
||||||
|
t.Fatalf("unexpected normalized question: %#v", question)
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
136
backend/migrations/00001_initial.sql
Normal file
136
backend/migrations/00001_initial.sql
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
-- +goose Up
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
email VARCHAR(320) NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role VARCHAR(32) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE traits (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
normalized VARCHAR(100) NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE job_postings (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
seniority VARCHAR(50) NOT NULL,
|
||||||
|
position VARCHAR(120) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE questions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
job_id BIGINT NOT NULL REFERENCES job_postings(id) ON DELETE CASCADE,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
type VARCHAR(32) NOT NULL CHECK (type IN ('open', 'multiple_choice', 'radio', 'code_review')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE question_options (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
question_id BIGINT NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
|
||||||
|
text TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE participants (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
first_name VARCHAR(100) NOT NULL,
|
||||||
|
last_name VARCHAR(100) NOT NULL,
|
||||||
|
birthday DATE,
|
||||||
|
email VARCHAR(320) NOT NULL UNIQUE,
|
||||||
|
contact_info TEXT,
|
||||||
|
photo BYTEA,
|
||||||
|
photo_type VARCHAR(100),
|
||||||
|
cv BYTEA,
|
||||||
|
cv_filename VARCHAR(255),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE job_posting_labels (
|
||||||
|
job_posting_id BIGINT NOT NULL REFERENCES job_postings(id) ON DELETE CASCADE,
|
||||||
|
trait_id BIGINT NOT NULL REFERENCES traits(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (job_posting_id, trait_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE job_required_skills (
|
||||||
|
job_posting_id BIGINT NOT NULL REFERENCES job_postings(id) ON DELETE CASCADE,
|
||||||
|
trait_id BIGINT NOT NULL REFERENCES traits(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (job_posting_id, trait_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE job_additional_skills (
|
||||||
|
job_posting_id BIGINT NOT NULL REFERENCES job_postings(id) ON DELETE CASCADE,
|
||||||
|
trait_id BIGINT NOT NULL REFERENCES traits(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (job_posting_id, trait_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE participant_traits (
|
||||||
|
participant_id BIGINT NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
|
||||||
|
trait_id BIGINT NOT NULL REFERENCES traits(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (participant_id, trait_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO users (email, password_hash, role)
|
||||||
|
VALUES ('interviewer@go-guess.local', crypt('admin123', gen_salt('bf')), 'interviewer');
|
||||||
|
|
||||||
|
INSERT INTO traits (name, normalized) VALUES
|
||||||
|
('Go', 'go'), ('PostgreSQL', 'postgresql'), ('Docker', 'docker'),
|
||||||
|
('React', 'react'), ('TypeScript', 'typescript'), ('Kubernetes', 'kubernetes'),
|
||||||
|
('Backend', 'backend');
|
||||||
|
|
||||||
|
INSERT INTO job_postings (title, description, seniority, position) VALUES
|
||||||
|
('Senior Go Backend Engineer', 'Build reliable APIs for passenger-facing railway systems.', 'Senior', 'Backend Engineer'),
|
||||||
|
('Full-stack Product Engineer', 'Deliver accessible operational tools from database to browser.', 'Medior', 'Full-stack Engineer');
|
||||||
|
|
||||||
|
INSERT INTO job_posting_labels (job_posting_id, trait_id)
|
||||||
|
SELECT 1, id FROM traits WHERE normalized IN ('backend', 'go')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO job_required_skills (job_posting_id, trait_id)
|
||||||
|
SELECT 1, id FROM traits WHERE normalized IN ('go', 'postgresql', 'docker');
|
||||||
|
INSERT INTO job_additional_skills (job_posting_id, trait_id)
|
||||||
|
SELECT 1, id FROM traits WHERE normalized = 'kubernetes';
|
||||||
|
|
||||||
|
INSERT INTO job_posting_labels (job_posting_id, trait_id)
|
||||||
|
SELECT 2, id FROM traits WHERE normalized IN ('react', 'typescript');
|
||||||
|
INSERT INTO job_required_skills (job_posting_id, trait_id)
|
||||||
|
SELECT 2, id FROM traits WHERE normalized IN ('go', 'react', 'typescript');
|
||||||
|
INSERT INTO job_additional_skills (job_posting_id, trait_id)
|
||||||
|
SELECT 2, id FROM traits WHERE normalized = 'docker';
|
||||||
|
|
||||||
|
INSERT INTO questions (job_id, text, type) VALUES
|
||||||
|
(1, 'How do you design graceful shutdown for an HTTP service?', 'open'),
|
||||||
|
(1, 'Which construct protects shared data across goroutines?', 'radio'),
|
||||||
|
(2, 'Review this component and identify unnecessary renders.', 'code_review');
|
||||||
|
INSERT INTO question_options (question_id, text) VALUES
|
||||||
|
(2, 'sync.Mutex'), (2, 'context.Context'), (2, 'http.Client');
|
||||||
|
|
||||||
|
INSERT INTO participants (first_name, last_name, birthday, email, contact_info, cv, cv_filename) VALUES
|
||||||
|
('Ada', 'Lovelace', '1990-12-10', 'ada@example.com', '+32 470 00 00 01', encode('Senior backend engineer with Go, PostgreSQL and Docker experience.', 'escape')::bytea, 'ada-cv.txt'),
|
||||||
|
('Grace', 'Hopper', '1988-12-09', 'grace@example.com', '+32 470 00 00 02', encode('Full-stack developer working with Go, React and TypeScript.', 'escape')::bytea, 'grace-cv.txt');
|
||||||
|
|
||||||
|
INSERT INTO participant_traits (participant_id, trait_id)
|
||||||
|
SELECT 1, id FROM traits WHERE normalized IN ('go', 'postgresql', 'docker');
|
||||||
|
INSERT INTO participant_traits (participant_id, trait_id)
|
||||||
|
SELECT 2, id FROM traits WHERE normalized IN ('go', 'react', 'typescript');
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE IF EXISTS participant_traits;
|
||||||
|
DROP TABLE IF EXISTS job_additional_skills;
|
||||||
|
DROP TABLE IF EXISTS job_required_skills;
|
||||||
|
DROP TABLE IF EXISTS job_posting_labels;
|
||||||
|
DROP TABLE IF EXISTS participants;
|
||||||
|
DROP TABLE IF EXISTS question_options;
|
||||||
|
DROP TABLE IF EXISTS questions;
|
||||||
|
DROP TABLE IF EXISTS job_postings;
|
||||||
|
DROP TABLE IF EXISTS traits;
|
||||||
|
DROP TABLE IF EXISTS users;
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE job_postings
|
||||||
|
ADD COLUMN status VARCHAR(32) NOT NULL DEFAULT 'draft'
|
||||||
|
CHECK (status IN ('draft', 'published', 'deprecated')),
|
||||||
|
ADD COLUMN duration_minutes INTEGER NOT NULL DEFAULT 60
|
||||||
|
CHECK (duration_minutes BETWEEN 1 AND 480);
|
||||||
|
|
||||||
|
UPDATE job_postings SET status = 'published';
|
||||||
|
|
||||||
|
ALTER TABLE questions ADD COLUMN deprecated BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
CREATE TABLE job_posting_questions (
|
||||||
|
job_posting_id BIGINT NOT NULL REFERENCES job_postings(id) ON DELETE CASCADE,
|
||||||
|
question_id BIGINT NOT NULL REFERENCES questions(id) ON DELETE RESTRICT,
|
||||||
|
PRIMARY KEY (job_posting_id, question_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO job_posting_questions (job_posting_id, question_id)
|
||||||
|
SELECT job_id, id FROM questions WHERE job_id IS NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE questions DROP COLUMN job_id;
|
||||||
|
|
||||||
|
CREATE TABLE invitations (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
job_id BIGINT NOT NULL REFERENCES job_postings(id) ON DELETE CASCADE,
|
||||||
|
participant_id BIGINT NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
|
||||||
|
token VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK (status IN ('pending', 'accepted', 'completed')),
|
||||||
|
accepted_at TIMESTAMPTZ,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (job_id, participant_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE invitation_answers (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
invitation_id BIGINT NOT NULL REFERENCES invitations(id) ON DELETE CASCADE,
|
||||||
|
question_id BIGINT NOT NULL REFERENCES questions(id) ON DELETE RESTRICT,
|
||||||
|
answer TEXT NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (invitation_id, question_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE IF EXISTS invitation_answers;
|
||||||
|
DROP TABLE IF EXISTS invitations;
|
||||||
|
ALTER TABLE questions ADD COLUMN job_id BIGINT REFERENCES job_postings(id) ON DELETE CASCADE;
|
||||||
|
UPDATE questions q
|
||||||
|
SET job_id = links.job_posting_id
|
||||||
|
FROM (
|
||||||
|
SELECT question_id, min(job_posting_id) AS job_posting_id
|
||||||
|
FROM job_posting_questions
|
||||||
|
GROUP BY question_id
|
||||||
|
) links
|
||||||
|
WHERE links.question_id = q.id;
|
||||||
|
DROP TABLE IF EXISTS job_posting_questions;
|
||||||
|
ALTER TABLE questions DROP COLUMN deprecated;
|
||||||
|
ALTER TABLE job_postings DROP COLUMN duration_minutes;
|
||||||
|
ALTER TABLE job_postings DROP COLUMN status;
|
||||||
12
backend/migrations/00003_invitation_duration.sql
Normal file
12
backend/migrations/00003_invitation_duration.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE invitations
|
||||||
|
ADD COLUMN duration_minutes INTEGER NOT NULL DEFAULT 60
|
||||||
|
CHECK (duration_minutes BETWEEN 1 AND 480);
|
||||||
|
|
||||||
|
UPDATE invitations i
|
||||||
|
SET duration_minutes = j.duration_minutes
|
||||||
|
FROM job_postings j
|
||||||
|
WHERE j.id = i.job_id;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE invitations DROP COLUMN duration_minutes;
|
||||||
13
backend/migrations/00004_question_reference_answer.sql
Normal file
13
backend/migrations/00004_question_reference_answer.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE questions
|
||||||
|
ADD COLUMN reference_answer TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
UPDATE questions
|
||||||
|
SET reference_answer = CASE
|
||||||
|
WHEN type = 'open' THEN 'Use context cancellation, stop accepting work, drain active requests, and close dependencies within a deadline.'
|
||||||
|
WHEN type = 'code_review' THEN 'Identify unstable dependencies or state updates that cause avoidable renders and propose memoization only where measured.'
|
||||||
|
ELSE reference_answer
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE questions DROP COLUMN reference_answer;
|
||||||
38
docker-compose.yml
Normal file
38
docker-compose.yml
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:18-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: go_guess
|
||||||
|
POSTGRES_USER: go_guess
|
||||||
|
POSTGRES_PASSWORD: go_guess
|
||||||
|
ports:
|
||||||
|
- "${DB_PORT:-5432}:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U go_guess -d go_guess"]
|
||||||
|
interval: 3s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
api:
|
||||||
|
build: ./backend
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgres://go_guess:go_guess@db:5432/go_guess?sslmode=disable
|
||||||
|
JWT_SECRET: local-development-secret-change-me-12345
|
||||||
|
FRONTEND_URL: http://localhost:5173
|
||||||
|
ports:
|
||||||
|
- "${API_PORT:-8080}:8080"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
web:
|
||||||
|
build: ./frontend
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-5173}:80"
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
5
frontend/.dockerignore
Normal file
5
frontend/.dockerignore
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
.git
|
||||||
|
*.tsbuildinfo
|
||||||
5
frontend/.gitignore
vendored
Normal file
5
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
*.local
|
||||||
|
*.tsbuildinfo
|
||||||
3
frontend/.prettierignore
Normal file
3
frontend/.prettierignore
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
6
frontend/.prettierrc.json
Normal file
6
frontend/.prettierrc.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
FROM node:26-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.29-alpine
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
71
frontend/README.md
Normal file
71
frontend/README.md
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
# Go Guess frontend
|
||||||
|
|
||||||
|
React and TypeScript recruitment workspace built with Vite.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
npm run typecheck
|
||||||
|
npm run lint
|
||||||
|
npm run format
|
||||||
|
npm run format:check
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Run one test:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- src/App.test.tsx -t "shows answer inputs"
|
||||||
|
```
|
||||||
|
|
||||||
|
## API integration
|
||||||
|
|
||||||
|
Vite proxies `/api` to `http://localhost:8080`. Login stores the returned JWT in
|
||||||
|
`sessionStorage`; protected requests send it as `Authorization: Bearer <token>`. API failures are
|
||||||
|
shown in the UI and are never replaced with demo data.
|
||||||
|
|
||||||
|
Supported routes:
|
||||||
|
|
||||||
|
- `POST /api/auth/login`
|
||||||
|
- `GET|POST /api/jobs`
|
||||||
|
- `GET /api/jobs/:id`
|
||||||
|
- `PATCH /api/jobs/:id` for interview status and duration
|
||||||
|
- `POST|DELETE /api/jobs/:id/questions/:questionId`
|
||||||
|
- `GET /api/jobs/:id/candidates`
|
||||||
|
- `GET|POST /api/jobs/:id/invitations`
|
||||||
|
- `GET|POST /api/questions` (`GET` accepts `?search=`)
|
||||||
|
- `PUT /api/questions/:id` to update question text, type, options, and reference answer
|
||||||
|
- `PATCH /api/questions/:id` to deprecate or restore a question
|
||||||
|
- `GET|POST /api/participants`
|
||||||
|
- `GET /api/participants/:id`
|
||||||
|
- `GET /api/participants/:id/photo`
|
||||||
|
- `GET /api/participants/:id/cv`
|
||||||
|
- `GET /api/interviews/:token`
|
||||||
|
- `POST /api/interviews/:token/accept`
|
||||||
|
- `PUT /api/interviews/:token/answers/:questionId`
|
||||||
|
- `POST /api/interviews/:token/finish`
|
||||||
|
|
||||||
|
Participant creation uses multipart fields `firstName`, `lastName`, `birthday`, `email`,
|
||||||
|
`contactInfo`, `photo`, and `cv`.
|
||||||
|
|
||||||
|
## Interview workflow
|
||||||
|
|
||||||
|
Authenticated interviewers create and edit reusable questions at `/questions`, configure job
|
||||||
|
status and duration, attach active library questions, and issue participant invitations from a job
|
||||||
|
detail page. Multiple-choice and radio questions require at least two explicitly added options.
|
||||||
|
Detaching a question removes only the job association; questions can be deprecated but not deleted.
|
||||||
|
Open-answer and code-review questions include an interviewer-only reference answer that is never
|
||||||
|
rendered in the participant interview.
|
||||||
|
|
||||||
|
Participants open the generated `/interview/:token` URL without signing in. The invitation token
|
||||||
|
authorizes the public interview API. Answers are saved when navigating between questions or
|
||||||
|
finishing, and the countdown is derived from the backend `acceptedAt` timestamp plus the job
|
||||||
|
duration so refreshing cannot reset it.
|
||||||
|
|
||||||
|
## Container
|
||||||
|
|
||||||
|
Build with `docker build -t go-guess-frontend .`. The production Nginx server serves the SPA and
|
||||||
|
proxies `/api` to the Docker Compose service `api:8080`.
|
||||||
25
frontend/eslint.config.js
Normal file
25
frontend/eslint.config.js
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist', 'coverage'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
globals: { ...globals.browser, ...globals.node },
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
14
frontend/index.html
Normal file
14
frontend/index.html
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#081c2c" />
|
||||||
|
<meta name="description" content="Go Guess recruitment assessment workspace" />
|
||||||
|
<title>Go Guess · Recruitment workspace</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
20
frontend/nginx.conf
Normal file
20
frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
3937
frontend/package-lock.json
generated
Normal file
3937
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
42
frontend/package.json
Normal file
42
frontend/package.json
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"name": "go-guess-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"typecheck": "tsc -b --pretty false",
|
||||||
|
"lint": "eslint . --max-warnings 0",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lucide-react": "^0.544.0",
|
||||||
|
"react": "^19.3.0",
|
||||||
|
"react-dom": "^19.3.0",
|
||||||
|
"react-router-dom": "^7.18.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.0",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.3",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
|
"eslint": "^10.11.0",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^17.3.0",
|
||||||
|
"jsdom": "^30.1.0",
|
||||||
|
"prettier": "^3.9.0",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"typescript-eslint": "^8.70.0",
|
||||||
|
"vite": "^8.3.0",
|
||||||
|
"vitest": "^5.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
395
frontend/src/App.test.tsx
Normal file
395
frontend/src/App.test.tsx
Normal file
|
|
@ -0,0 +1,395 @@
|
||||||
|
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { BrowserRouter, MemoryRouter } from 'react-router-dom'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import App from './App'
|
||||||
|
import type { Job, Participant } from './types'
|
||||||
|
|
||||||
|
const question = {
|
||||||
|
id: 11,
|
||||||
|
text: 'How do you design a resilient Go service?',
|
||||||
|
type: 'open' as const,
|
||||||
|
options: [],
|
||||||
|
referenceAnswer: 'Use timeouts, retries, and idempotency.',
|
||||||
|
deprecated: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const job: Job = {
|
||||||
|
id: 7,
|
||||||
|
title: 'Senior Go Engineer',
|
||||||
|
description: 'Build reliable services.',
|
||||||
|
seniority: 'Senior',
|
||||||
|
position: 'Backend Engineer',
|
||||||
|
labels: ['Platform'],
|
||||||
|
requiredSkills: ['Go'],
|
||||||
|
additionalSkills: ['Kubernetes'],
|
||||||
|
status: 'draft',
|
||||||
|
durationMinutes: 10,
|
||||||
|
questions: [],
|
||||||
|
createdAt: '2026-09-01T10:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
const participant: Participant = {
|
||||||
|
id: 3,
|
||||||
|
firstName: 'Amélie',
|
||||||
|
lastName: 'Dubois',
|
||||||
|
email: 'amelie@example.com',
|
||||||
|
contactInfo: 'Brussels · +32 470 00 00 00',
|
||||||
|
traits: ['Go', 'PostgreSQL'],
|
||||||
|
createdAt: '2026-09-02T10:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
function response(body: unknown, status = 200) {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockApi() {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn((input: RequestInfo | URL, options?: RequestInit) => {
|
||||||
|
const path = String(input)
|
||||||
|
const method = options?.method ?? 'GET'
|
||||||
|
if (path === '/api/auth/login')
|
||||||
|
return response({
|
||||||
|
token: 'jwt-token',
|
||||||
|
user: { id: 1, email: 'admin@example.com', role: 'admin' },
|
||||||
|
})
|
||||||
|
if (path === '/api/jobs') return response([job])
|
||||||
|
if (path === '/api/participants') return response([participant])
|
||||||
|
if (path === '/api/jobs/7') return response(job)
|
||||||
|
if (path === '/api/jobs/7/candidates')
|
||||||
|
return response([
|
||||||
|
{
|
||||||
|
participant,
|
||||||
|
score: 90,
|
||||||
|
matchedTraits: ['Go'],
|
||||||
|
missingTraits: [],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
if (path === '/api/jobs/7/invitations' && method === 'GET') return response([])
|
||||||
|
if (path === '/api/jobs/7/invitations' && method === 'POST')
|
||||||
|
return response({
|
||||||
|
id: 31,
|
||||||
|
jobId: 7,
|
||||||
|
participantId: 3,
|
||||||
|
participantName: 'Amélie Dubois',
|
||||||
|
participantEmail: 'amelie@example.com',
|
||||||
|
token: 'invite-token',
|
||||||
|
status: 'pending',
|
||||||
|
participantUrl: 'https://example.test/interview/invite-token',
|
||||||
|
acceptedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
createdAt: '2026-09-19T10:00:00Z',
|
||||||
|
})
|
||||||
|
if (path.startsWith('/api/questions') && method === 'GET') return response([question])
|
||||||
|
if (path === '/api/questions' && method === 'POST')
|
||||||
|
return response({ ...question, id: 12, text: 'Explain channels.' }, 201)
|
||||||
|
if (path === '/api/questions/11' && method === 'PATCH')
|
||||||
|
return response({ ...question, deprecated: true })
|
||||||
|
if (path === '/api/questions/11' && method === 'PUT')
|
||||||
|
return response({
|
||||||
|
...question,
|
||||||
|
text: 'Updated resilience question',
|
||||||
|
referenceAnswer: 'Prefer bounded retries with jitter.',
|
||||||
|
})
|
||||||
|
if (path === '/api/jobs/7/questions/11') return response({}, method === 'DELETE' ? 200 : 201)
|
||||||
|
throw new Error(`Unexpected request: ${path}`)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function authenticate() {
|
||||||
|
sessionStorage.setItem('go-guess-token', 'jwt-token')
|
||||||
|
sessionStorage.setItem(
|
||||||
|
'go-guess-user',
|
||||||
|
JSON.stringify({ id: 1, email: 'admin@example.com', role: 'admin' }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderApp(path = '/') {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={[path]}>
|
||||||
|
<App />
|
||||||
|
</MemoryRouter>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Go Guess frontend', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
sessionStorage.clear()
|
||||||
|
mockApi()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists login, navigates from the login URL, and shows API jobs', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
window.history.replaceState({}, '', '/login')
|
||||||
|
render(
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>,
|
||||||
|
)
|
||||||
|
await user.type(screen.getByLabelText(/email address/i), 'interviewer@go-guess.local')
|
||||||
|
await user.type(screen.getByLabelText(/password/i), 'admin123')
|
||||||
|
await user.click(screen.getByRole('button', { name: /sign in/i }))
|
||||||
|
|
||||||
|
expect(await screen.findByRole('heading', { name: 'Job postings' })).toBeInTheDocument()
|
||||||
|
expect(await screen.findByRole('link', { name: 'Senior Go Engineer' })).toBeInTheDocument()
|
||||||
|
expect(sessionStorage.getItem('go-guess-token')).toBe('jwt-token')
|
||||||
|
expect(window.location.pathname).toBe('/jobs')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters jobs by title', async () => {
|
||||||
|
authenticate()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderApp('/jobs')
|
||||||
|
expect(await screen.findByRole('link', { name: 'Senior Go Engineer' })).toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.type(screen.getByRole('textbox', { name: /search jobs/i }), 'designer')
|
||||||
|
expect(screen.queryByRole('link', { name: 'Senior Go Engineer' })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds multiple choice options with the accessible plus button', async () => {
|
||||||
|
authenticate()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderApp('/jobs/7/questions/new')
|
||||||
|
await screen.findByRole('heading', { name: 'Create a question' })
|
||||||
|
await user.type(screen.getByLabelText('Reference answer'), 'This should be cleared.')
|
||||||
|
await user.selectOptions(screen.getByLabelText('Response type'), 'multiple_choice')
|
||||||
|
expect(screen.queryByLabelText('Reference answer')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
const optionInput = screen.getByRole('textbox', { name: 'New option' })
|
||||||
|
await user.type(optionInput, 'Retries')
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Add option' }))
|
||||||
|
|
||||||
|
expect(screen.getByText('Retries')).toBeInTheDocument()
|
||||||
|
expect(optionInput).toHaveValue('')
|
||||||
|
|
||||||
|
await user.selectOptions(screen.getByLabelText('Response type'), 'open')
|
||||||
|
expect(screen.getByLabelText('Reference answer')).toHaveValue('')
|
||||||
|
expect(screen.queryByRole('button', { name: 'Remove option Retries' })).not.toBeInTheDocument()
|
||||||
|
await user.selectOptions(screen.getByLabelText('Response type'), 'code_review')
|
||||||
|
expect(screen.getByLabelText('Expected answer')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('surfaces API failures instead of demo content', async () => {
|
||||||
|
authenticate()
|
||||||
|
vi.mocked(fetch).mockImplementation(() => response({ error: 'database unavailable' }, 500))
|
||||||
|
renderApp('/jobs')
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('database unavailable')
|
||||||
|
expect(screen.queryByRole('link', { name: 'Senior Go Engineer' })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exposes participant multipart fields', async () => {
|
||||||
|
authenticate()
|
||||||
|
renderApp('/participants/new')
|
||||||
|
await waitFor(() => expect(fetch).toHaveBeenCalled())
|
||||||
|
|
||||||
|
expect(screen.getByRole('heading', { name: 'Add a participant' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText(/birthday/i)).toHaveAttribute('type', 'date')
|
||||||
|
expect(screen.getByLabelText(/contact information/i)).toBeRequired()
|
||||||
|
expect(screen.getByLabelText(/upload cv/i)).toHaveAttribute('accept', '.pdf,.doc,.docx')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates and deprecates questions in the standalone library', async () => {
|
||||||
|
authenticate()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderApp('/questions')
|
||||||
|
|
||||||
|
expect(await screen.findByText(question.text)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(question.referenceAnswer)).toBeInTheDocument()
|
||||||
|
await user.click(screen.getByRole('checkbox', { name: `Deprecated: ${question.text}` }))
|
||||||
|
await user.type(screen.getByLabelText('Question prompt'), 'Explain channels.')
|
||||||
|
await user.type(screen.getByLabelText('Reference answer'), 'Mention ownership and closure.')
|
||||||
|
await user.click(screen.getByRole('button', { name: /create question/i }))
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/questions',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
text: 'Explain channels.',
|
||||||
|
type: 'open',
|
||||||
|
options: [],
|
||||||
|
referenceAnswer: 'Mention ownership and closure.',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/questions/11',
|
||||||
|
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ deprecated: true }) }),
|
||||||
|
)
|
||||||
|
expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('populates and persists the reference answer while editing', async () => {
|
||||||
|
authenticate()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderApp('/questions')
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: `Edit ${question.text}` }))
|
||||||
|
const prompt = screen.getByLabelText('Question prompt')
|
||||||
|
await user.clear(prompt)
|
||||||
|
await user.type(prompt, 'Updated resilience question')
|
||||||
|
const reference = screen.getByLabelText('Reference answer')
|
||||||
|
expect(reference).toHaveValue(question.referenceAnswer)
|
||||||
|
await user.clear(reference)
|
||||||
|
await user.type(reference, 'Prefer bounded retries with jitter.')
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Save changes' }))
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/questions/11',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
text: 'Updated resilience question',
|
||||||
|
type: 'open',
|
||||||
|
options: [],
|
||||||
|
referenceAnswer: 'Prefer bounded retries with jitter.',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('attaches and detaches library questions without deleting them', async () => {
|
||||||
|
authenticate()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderApp('/jobs/7')
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('tab', { name: /questions/i }))
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Attach' }))
|
||||||
|
await user.click(screen.getByRole('button', { name: `Detach ${question.text}` }))
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/jobs/7/questions/11',
|
||||||
|
expect.objectContaining({ method: 'POST' }),
|
||||||
|
)
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/jobs/7/questions/11',
|
||||||
|
expect.objectContaining({ method: 'DELETE' }),
|
||||||
|
)
|
||||||
|
expect(fetch).not.toHaveBeenCalledWith(
|
||||||
|
'/api/questions/11',
|
||||||
|
expect.objectContaining({ method: 'DELETE' }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends invitations and displays the generated participant URL', async () => {
|
||||||
|
authenticate()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderApp('/jobs/7')
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('tab', { name: /invitations/i }))
|
||||||
|
await user.selectOptions(screen.getByLabelText('Eligible candidate'), '3')
|
||||||
|
await user.click(screen.getByRole('button', { name: /send invitation/i }))
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText('https://example.test/interview/invite-token'),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/jobs/7/invitations',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ participantId: 3 }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('navigates participant questions, persists answers, and derives the timer from acceptedAt', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const now = new Date('2026-09-19T12:00:00Z')
|
||||||
|
vi.setSystemTime(now)
|
||||||
|
const interview = {
|
||||||
|
invitation: {
|
||||||
|
id: 31,
|
||||||
|
jobId: 7,
|
||||||
|
participantId: 3,
|
||||||
|
participantName: 'Amélie Dubois',
|
||||||
|
participantEmail: 'amelie@example.com',
|
||||||
|
token: 'invite-token',
|
||||||
|
status: 'pending',
|
||||||
|
participantUrl: 'https://example.test/interview/invite-token',
|
||||||
|
acceptedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
createdAt: now.toISOString(),
|
||||||
|
},
|
||||||
|
job: {
|
||||||
|
id: 7,
|
||||||
|
title: job.title,
|
||||||
|
description: job.description,
|
||||||
|
position: job.position,
|
||||||
|
seniority: job.seniority,
|
||||||
|
durationMinutes: 10,
|
||||||
|
questions: [question, { ...question, id: 12, text: 'How do you test it?' }],
|
||||||
|
},
|
||||||
|
answers: {},
|
||||||
|
}
|
||||||
|
const acceptedInterview = {
|
||||||
|
...interview,
|
||||||
|
invitation: {
|
||||||
|
...interview.invitation,
|
||||||
|
status: 'accepted',
|
||||||
|
acceptedAt: now.toISOString(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => {
|
||||||
|
const path = String(input)
|
||||||
|
if (path === '/api/interviews/invite-token' && !options?.method) return response(interview)
|
||||||
|
if (path.endsWith('/accept')) return response(acceptedInterview)
|
||||||
|
if (path.includes('/answers/')) return response({})
|
||||||
|
if (path.endsWith('/finish'))
|
||||||
|
return response({
|
||||||
|
...acceptedInterview,
|
||||||
|
invitation: {
|
||||||
|
...acceptedInterview.invitation,
|
||||||
|
status: 'completed',
|
||||||
|
completedAt: now.toISOString(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
throw new Error(`Unexpected request: ${path}`)
|
||||||
|
})
|
||||||
|
renderApp('/interview/invite-token')
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
})
|
||||||
|
expect(screen.queryByRole('textbox', { name: 'Your answer' })).not.toBeInTheDocument()
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Accept and start' }))
|
||||||
|
})
|
||||||
|
expect(screen.getByText(question.text)).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText(question.referenceAnswer)).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('countdown')).toHaveTextContent('10:00')
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(1000)
|
||||||
|
})
|
||||||
|
expect(screen.getByTestId('countdown')).toHaveTextContent('09:59')
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('Your answer'), {
|
||||||
|
target: { value: 'Use retries and timeouts.' },
|
||||||
|
})
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Next' }))
|
||||||
|
})
|
||||||
|
expect(screen.getByText('How do you test it?')).toBeInTheDocument()
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/interviews/invite-token/answers/11',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ answer: 'Use retries and timeouts.' }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'First' }))
|
||||||
|
})
|
||||||
|
expect(screen.getByText(question.text)).toBeInTheDocument()
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
})
|
||||||
2100
frontend/src/App.tsx
Normal file
2100
frontend/src/App.tsx
Normal file
File diff suppressed because it is too large
Load diff
60
frontend/src/api.test.ts
Normal file
60
frontend/src/api.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { api, authStorage } from './api'
|
||||||
|
|
||||||
|
describe('API client', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends the persisted bearer token on protected requests', async () => {
|
||||||
|
authStorage.save({
|
||||||
|
token: 'signed-jwt',
|
||||||
|
user: { id: 1, email: 'admin@example.com', role: 'admin' },
|
||||||
|
})
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(new Response('[]', { status: 200 }))
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
|
||||||
|
await api.jobs.list()
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'/api/jobs',
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.objectContaining({ Authorization: 'Bearer signed-jwt' }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the backend participant multipart field names', async () => {
|
||||||
|
authStorage.save({
|
||||||
|
token: 'signed-jwt',
|
||||||
|
user: { id: 1, email: 'admin@example.com', role: 'admin' },
|
||||||
|
})
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
id: 4,
|
||||||
|
firstName: 'A',
|
||||||
|
lastName: 'B',
|
||||||
|
email: 'a@example.com',
|
||||||
|
contactInfo: 'Brussels',
|
||||||
|
traits: [],
|
||||||
|
createdAt: '2026-09-01T00:00:00Z',
|
||||||
|
}),
|
||||||
|
{ status: 201, headers: { 'Content-Type': 'application/json' } },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
|
||||||
|
await api.participants.create({
|
||||||
|
firstName: 'A',
|
||||||
|
lastName: 'B',
|
||||||
|
birthday: '1990-01-02',
|
||||||
|
email: 'a@example.com',
|
||||||
|
contactInfo: 'Brussels',
|
||||||
|
})
|
||||||
|
|
||||||
|
const options = fetchMock.mock.calls[0][1] as RequestInit
|
||||||
|
const form = options.body as FormData
|
||||||
|
expect([...form.keys()]).toEqual(['firstName', 'lastName', 'birthday', 'email', 'contactInfo'])
|
||||||
|
})
|
||||||
|
})
|
||||||
172
frontend/src/api.ts
Normal file
172
frontend/src/api.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
import type {
|
||||||
|
AuthResponse,
|
||||||
|
CandidateMatch,
|
||||||
|
CreateJobInput,
|
||||||
|
CreateParticipantInput,
|
||||||
|
CreateQuestionInput,
|
||||||
|
Interview,
|
||||||
|
Invitation,
|
||||||
|
Job,
|
||||||
|
JobStatus,
|
||||||
|
Participant,
|
||||||
|
Question,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'go-guess-token'
|
||||||
|
const USER_KEY = 'go-guess-user'
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public status: number,
|
||||||
|
public details?: unknown,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authStorage = {
|
||||||
|
token: () => sessionStorage.getItem(TOKEN_KEY),
|
||||||
|
user: () => {
|
||||||
|
const value = sessionStorage.getItem(USER_KEY)
|
||||||
|
return value ? (JSON.parse(value) as AuthResponse['user']) : null
|
||||||
|
},
|
||||||
|
save: (auth: AuthResponse) => {
|
||||||
|
sessionStorage.setItem(TOKEN_KEY, auth.token)
|
||||||
|
sessionStorage.setItem(USER_KEY, JSON.stringify(auth.user))
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
sessionStorage.removeItem(TOKEN_KEY)
|
||||||
|
sessionStorage.removeItem(USER_KEY)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
protectedRequest = true,
|
||||||
|
): Promise<T> {
|
||||||
|
const token = authStorage.token()
|
||||||
|
const response = await fetch(`/api${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
|
||||||
|
...(protectedRequest && token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
let details: unknown
|
||||||
|
let message = `Request failed with status ${response.status}`
|
||||||
|
try {
|
||||||
|
details = await response.json()
|
||||||
|
if (
|
||||||
|
typeof details === 'object' &&
|
||||||
|
details !== null &&
|
||||||
|
'error' in details &&
|
||||||
|
typeof details.error === 'string'
|
||||||
|
) {
|
||||||
|
message = details.error
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
details = undefined
|
||||||
|
}
|
||||||
|
if (response.status === 401 && protectedRequest) authStorage.clear()
|
||||||
|
throw new ApiError(message, response.status, details)
|
||||||
|
}
|
||||||
|
if (response.status === 204) return undefined as T
|
||||||
|
return response.json() as Promise<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestBlob(path: string): Promise<Blob> {
|
||||||
|
const token = authStorage.token()
|
||||||
|
const response = await fetch(`/api${path}`, {
|
||||||
|
headers: {
|
||||||
|
Accept: '*/*',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!response.ok)
|
||||||
|
throw new ApiError(`Request failed with status ${response.status}`, response.status)
|
||||||
|
return response.blob()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
login: (email: string, password: string) =>
|
||||||
|
request<AuthResponse>(
|
||||||
|
'/auth/login',
|
||||||
|
{ method: 'POST', body: JSON.stringify({ email, password }) },
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
jobs: {
|
||||||
|
list: () => request<Job[]>('/jobs'),
|
||||||
|
get: (id: number) => request<Job>(`/jobs/${id}`),
|
||||||
|
create: (input: CreateJobInput) =>
|
||||||
|
request<Job>('/jobs', { method: 'POST', body: JSON.stringify(input) }),
|
||||||
|
update: (jobId: number, input: { status: JobStatus; durationMinutes: number }) =>
|
||||||
|
request<Job>(`/jobs/${jobId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
|
attachQuestion: (jobId: number, questionId: number) =>
|
||||||
|
request<void>(`/jobs/${jobId}/questions/${questionId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
detachQuestion: (jobId: number, questionId: number) =>
|
||||||
|
request<void>(`/jobs/${jobId}/questions/${questionId}`, { method: 'DELETE' }),
|
||||||
|
candidates: (jobId: number) => request<CandidateMatch[]>(`/jobs/${jobId}/candidates`),
|
||||||
|
invitations: (jobId: number) => request<Invitation[]>(`/jobs/${jobId}/invitations`),
|
||||||
|
invite: (jobId: number, participantId: number) =>
|
||||||
|
request<Invitation>(`/jobs/${jobId}/invitations`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ participantId }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
questions: {
|
||||||
|
list: (search = '') =>
|
||||||
|
request<Question[]>(`/questions${search ? `?search=${encodeURIComponent(search)}` : ''}`),
|
||||||
|
create: (input: CreateQuestionInput) =>
|
||||||
|
request<Question>('/questions', { method: 'POST', body: JSON.stringify(input) }),
|
||||||
|
update: (id: number, input: CreateQuestionInput) =>
|
||||||
|
request<Question>(`/questions/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
|
setDeprecated: (id: number, deprecated: boolean) =>
|
||||||
|
request<Question>(`/questions/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ deprecated }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
interviews: {
|
||||||
|
get: (token: string) => request<Interview>(`/interviews/${token}`, {}, false),
|
||||||
|
accept: (token: string) =>
|
||||||
|
request<Interview>(`/interviews/${token}/accept`, { method: 'POST' }, false),
|
||||||
|
answer: (token: string, questionId: number, answer: string) =>
|
||||||
|
request<void>(
|
||||||
|
`/interviews/${token}/answers/${questionId}`,
|
||||||
|
{ method: 'PUT', body: JSON.stringify({ answer }) },
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
finish: (token: string) =>
|
||||||
|
request<Interview>(`/interviews/${token}/finish`, { method: 'POST' }, false),
|
||||||
|
},
|
||||||
|
participants: {
|
||||||
|
list: () => request<Participant[]>('/participants'),
|
||||||
|
get: (id: number) => request<Participant>(`/participants/${id}`),
|
||||||
|
photo: (id: number) => requestBlob(`/participants/${id}/photo`),
|
||||||
|
cv: (id: number) => requestBlob(`/participants/${id}/cv`),
|
||||||
|
create: (input: CreateParticipantInput) => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('firstName', input.firstName)
|
||||||
|
form.append('lastName', input.lastName)
|
||||||
|
form.append('birthday', input.birthday ?? '')
|
||||||
|
form.append('email', input.email)
|
||||||
|
form.append('contactInfo', input.contactInfo)
|
||||||
|
if (input.photo) form.append('photo', input.photo)
|
||||||
|
if (input.cv) form.append('cv', input.cv)
|
||||||
|
return request<Participant>('/participants', { method: 'POST', body: form })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
13
frontend/src/main.tsx
Normal file
13
frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import App from './App'
|
||||||
|
import './styles.css'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
1668
frontend/src/styles.css
Normal file
1668
frontend/src/styles.css
Normal file
File diff suppressed because it is too large
Load diff
5
frontend/src/test/setup.ts
Normal file
5
frontend/src/test/setup.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import '@testing-library/jest-dom/vitest'
|
||||||
|
import { cleanup } from '@testing-library/react'
|
||||||
|
import { afterEach } from 'vitest'
|
||||||
|
|
||||||
|
afterEach(cleanup)
|
||||||
97
frontend/src/types.ts
Normal file
97
frontend/src/types.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
export type QuestionType = 'open' | 'multiple_choice' | 'radio' | 'code_review'
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number
|
||||||
|
email: string
|
||||||
|
role: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
token: string
|
||||||
|
user: User
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Question {
|
||||||
|
id: number
|
||||||
|
text: string
|
||||||
|
type: QuestionType
|
||||||
|
options: string[]
|
||||||
|
referenceAnswer: string
|
||||||
|
deprecated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JobStatus = 'draft' | 'published' | 'deprecated'
|
||||||
|
|
||||||
|
export interface Job {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
seniority: string
|
||||||
|
position: string
|
||||||
|
labels: string[]
|
||||||
|
requiredSkills: string[]
|
||||||
|
additionalSkills: string[]
|
||||||
|
status: JobStatus
|
||||||
|
durationMinutes: number
|
||||||
|
questions: Question[]
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Participant {
|
||||||
|
id: number
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
birthday?: string
|
||||||
|
email: string
|
||||||
|
contactInfo: string
|
||||||
|
photoUrl?: string
|
||||||
|
cvFilename?: string
|
||||||
|
cvUrl?: string
|
||||||
|
traits: string[]
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CandidateMatch {
|
||||||
|
participant: Participant
|
||||||
|
score: number
|
||||||
|
matchedTraits: string[]
|
||||||
|
missingTraits: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Invitation {
|
||||||
|
id: number
|
||||||
|
jobId: number
|
||||||
|
participantId: number
|
||||||
|
participantName: string
|
||||||
|
participantEmail: string
|
||||||
|
token: string
|
||||||
|
status: string
|
||||||
|
participantUrl: string
|
||||||
|
acceptedAt: string | null
|
||||||
|
completedAt: string | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interview {
|
||||||
|
invitation: Invitation
|
||||||
|
job: Pick<
|
||||||
|
Job,
|
||||||
|
'id' | 'title' | 'description' | 'position' | 'seniority' | 'durationMinutes' | 'questions'
|
||||||
|
>
|
||||||
|
answers: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateJobInput = Omit<
|
||||||
|
Job,
|
||||||
|
'id' | 'questions' | 'createdAt' | 'status' | 'durationMinutes'
|
||||||
|
>
|
||||||
|
export type CreateQuestionInput = Pick<Question, 'text' | 'type' | 'options' | 'referenceAnswer'>
|
||||||
|
export interface CreateParticipantInput {
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
birthday?: string
|
||||||
|
email: string
|
||||||
|
contactInfo: string
|
||||||
|
photo?: File
|
||||||
|
cv?: File
|
||||||
|
}
|
||||||
21
frontend/tsconfig.app.json
Normal file
21
frontend/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
4
frontend/tsconfig.json
Normal file
4
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts", "eslint.config.js"]
|
||||||
|
}
|
||||||
19
frontend/vite.config.ts
Normal file
19
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: './src/test/setup.ts',
|
||||||
|
css: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue