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

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;