initial commit

This commit is contained in:
Daniel Noulet 2026-09-19 20:31:14 +02:00
commit 6ffc0d2c99
54 changed files with 11900 additions and 0 deletions

93
.github/copilot-instructions.md vendored Normal file
View 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
View file

@ -0,0 +1,6 @@
.idea/
.env
backend/api
frontend/node_modules/
frontend/dist/
frontend/coverage/

47
Makefile Normal file
View 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
View 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
View 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
View 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
View 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
View 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=

View 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
}

View 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
}

View 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
}

View 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,
}
}

View 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
}

View file

@ -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)
}
}

View 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
}

View 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")
}
}

View 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")
}

View 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)
}
}

View 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
}

View 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
}
}

View 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)
}
}

View 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)
}
}

View 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
}

View 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"`
}

View 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)
})
}
}

View 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)
}
}

View 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;

View file

@ -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;

View 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;

View 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
View 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
View file

@ -0,0 +1,5 @@
node_modules
dist
coverage
.git
*.tsbuildinfo

5
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules
dist
coverage
*.local
*.tsbuildinfo

3
frontend/.prettierignore Normal file
View file

@ -0,0 +1,3 @@
node_modules
dist
coverage

View file

@ -0,0 +1,6 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100
}

12
frontend/Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

42
frontend/package.json Normal file
View 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
View 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

File diff suppressed because it is too large Load diff

60
frontend/src/api.test.ts Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

View 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
View 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
}

View 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
View file

@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

View 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
View 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,
},
})