initial commit
This commit is contained in:
commit
6ffc0d2c99
54 changed files with 11900 additions and 0 deletions
47
backend/internal/config/config.go
Normal file
47
backend/internal/config/config.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Address string
|
||||
DatabaseURL string
|
||||
JWTSecret string
|
||||
TokenTTL time.Duration
|
||||
MaxUploadMB int64
|
||||
FrontendURL string
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
ttl, err := time.ParseDuration(env("TOKEN_TTL", "8h"))
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("parse TOKEN_TTL: %w", err)
|
||||
}
|
||||
maxUploadMB, err := strconv.ParseInt(env("MAX_UPLOAD_MB", "10"), 10, 64)
|
||||
if err != nil || maxUploadMB < 1 {
|
||||
return Config{}, fmt.Errorf("MAX_UPLOAD_MB must be a positive integer")
|
||||
}
|
||||
cfg := Config{
|
||||
Address: env("ADDRESS", ":8080"),
|
||||
DatabaseURL: env("DATABASE_URL", "postgres://go_guess:go_guess@localhost:5432/go_guess?sslmode=disable"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
TokenTTL: ttl,
|
||||
MaxUploadMB: maxUploadMB,
|
||||
FrontendURL: env("FRONTEND_URL", "http://localhost:5173"),
|
||||
}
|
||||
if len(cfg.JWTSecret) < 32 {
|
||||
return Config{}, fmt.Errorf("JWT_SECRET must contain at least 32 characters")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func env(key, fallback string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
16
backend/internal/database/database.go
Normal file
16
backend/internal/database/database.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func Open(databaseURL string) (*gorm.DB, error) {
|
||||
db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
92
backend/internal/database/entity/models.go
Normal file
92
backend/internal/database/entity/models.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package entity
|
||||
|
||||
import "time"
|
||||
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Email string `gorm:"uniqueIndex;size:320;not null"`
|
||||
PasswordHash string `gorm:"not null"`
|
||||
Role string `gorm:"size:32;not null"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type JobPosting struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Title string `gorm:"size:200;not null"`
|
||||
Description string `gorm:"type:text;not null"`
|
||||
Seniority string `gorm:"size:50;not null"`
|
||||
Position string `gorm:"size:120;not null"`
|
||||
Status string `gorm:"size:32;not null;default:draft"`
|
||||
DurationMinutes int `gorm:"not null;default:60"`
|
||||
Labels []Trait `gorm:"many2many:job_posting_labels"`
|
||||
RequiredSkills []Trait `gorm:"many2many:job_required_skills"`
|
||||
AdditionalSkills []Trait `gorm:"many2many:job_additional_skills"`
|
||||
Questions []Question `gorm:"many2many:job_posting_questions"`
|
||||
Invitations []Invitation `gorm:"foreignKey:JobID;constraint:OnDelete:CASCADE"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Trait struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Name string `gorm:"uniqueIndex;size:100;not null"`
|
||||
Normalized string `gorm:"uniqueIndex;size:100;not null"`
|
||||
}
|
||||
|
||||
type Question struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Text string `gorm:"type:text;not null"`
|
||||
Type string `gorm:"size:32;not null"`
|
||||
ReferenceAnswer string `gorm:"type:text;not null;default:''"`
|
||||
Deprecated bool `gorm:"not null;default:false"`
|
||||
Options []QuestionOption `gorm:"foreignKey:QuestionID;constraint:OnDelete:CASCADE"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type QuestionOption struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
QuestionID uint `gorm:"index;not null"`
|
||||
Text string `gorm:"type:text;not null"`
|
||||
}
|
||||
|
||||
type Participant struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
FirstName string `gorm:"size:100;not null"`
|
||||
LastName string `gorm:"size:100;not null"`
|
||||
Birthday *time.Time
|
||||
Email string `gorm:"uniqueIndex;size:320;not null"`
|
||||
ContactInfo string `gorm:"type:text"`
|
||||
Photo []byte `gorm:"type:bytea"`
|
||||
PhotoType string `gorm:"size:100"`
|
||||
CV []byte `gorm:"type:bytea"`
|
||||
CVFilename string `gorm:"size:255"`
|
||||
Traits []Trait `gorm:"many2many:participant_traits"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Invitation struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
JobID uint `gorm:"index;not null"`
|
||||
ParticipantID uint `gorm:"index;not null"`
|
||||
Token string `gorm:"uniqueIndex;size:64;not null"`
|
||||
Status string `gorm:"size:32;not null;default:pending"`
|
||||
DurationMinutes int `gorm:"not null"`
|
||||
AcceptedAt *time.Time
|
||||
CompletedAt *time.Time
|
||||
Answers []InvitationAnswer `gorm:"foreignKey:InvitationID;constraint:OnDelete:CASCADE"`
|
||||
Job JobPosting
|
||||
Participant Participant
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type InvitationAnswer struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
InvitationID uint `gorm:"uniqueIndex:invitation_question;not null"`
|
||||
QuestionID uint `gorm:"uniqueIndex:invitation_question;not null"`
|
||||
Answer string `gorm:"type:text;not null"`
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
68
backend/internal/database/mapper/mapper.go
Normal file
68
backend/internal/database/mapper/mapper.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package mapper
|
||||
|
||||
import (
|
||||
dbmodel "github.com/danyel/go-guess/backend/internal/database/entity"
|
||||
servicemodel "github.com/danyel/go-guess/backend/internal/service/model"
|
||||
)
|
||||
|
||||
func UserToService(value dbmodel.User) servicemodel.User {
|
||||
return servicemodel.User{ID: value.ID, Email: value.Email, PasswordHash: value.PasswordHash, Role: value.Role}
|
||||
}
|
||||
|
||||
func JobToService(value dbmodel.JobPosting) servicemodel.JobPosting {
|
||||
return servicemodel.JobPosting{
|
||||
ID: value.ID, Title: value.Title, Description: value.Description,
|
||||
Seniority: value.Seniority, Position: value.Position, Status: value.Status,
|
||||
DurationMinutes: value.DurationMinutes,
|
||||
Labels: traitsToService(value.Labels), RequiredSkills: traitsToService(value.RequiredSkills),
|
||||
AdditionalSkills: traitsToService(value.AdditionalSkills),
|
||||
Questions: questionsToService(value.Questions), CreatedAt: value.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func ParticipantToService(value dbmodel.Participant) servicemodel.Participant {
|
||||
return servicemodel.Participant{
|
||||
ID: value.ID, FirstName: value.FirstName, LastName: value.LastName,
|
||||
Birthday: value.Birthday, Email: value.Email, ContactInfo: value.ContactInfo,
|
||||
Photo: value.Photo, PhotoType: value.PhotoType, CV: value.CV,
|
||||
CVFilename: value.CVFilename, Traits: traitsToService(value.Traits), CreatedAt: value.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func traitsToService(values []dbmodel.Trait) []servicemodel.Trait {
|
||||
result := make([]servicemodel.Trait, len(values))
|
||||
for i, value := range values {
|
||||
result[i] = servicemodel.Trait{ID: value.ID, Name: value.Name}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func questionsToService(values []dbmodel.Question) []servicemodel.Question {
|
||||
result := make([]servicemodel.Question, len(values))
|
||||
for i, value := range values {
|
||||
options := make([]string, len(value.Options))
|
||||
for j, option := range value.Options {
|
||||
options[j] = option.Text
|
||||
}
|
||||
result[i] = servicemodel.Question{
|
||||
ID: value.ID, Text: value.Text, Type: value.Type,
|
||||
Options: options, ReferenceAnswer: value.ReferenceAnswer,
|
||||
Deprecated: value.Deprecated,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func QuestionToService(value dbmodel.Question) servicemodel.Question {
|
||||
return questionsToService([]dbmodel.Question{value})[0]
|
||||
}
|
||||
|
||||
func InvitationToService(value dbmodel.Invitation) servicemodel.Invitation {
|
||||
return servicemodel.Invitation{
|
||||
ID: value.ID, JobID: value.JobID, ParticipantID: value.ParticipantID,
|
||||
ParticipantName: value.Participant.FirstName + " " + value.Participant.LastName,
|
||||
ParticipantEmail: value.Participant.Email, Token: value.Token, Status: value.Status,
|
||||
DurationMinutes: value.DurationMinutes, AcceptedAt: value.AcceptedAt,
|
||||
CompletedAt: value.CompletedAt, CreatedAt: value.CreatedAt,
|
||||
}
|
||||
}
|
||||
408
backend/internal/database/repository/store.go
Normal file
408
backend/internal/database/repository/store.go
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dbmodel "github.com/danyel/go-guess/backend/internal/database/entity"
|
||||
dbmapper "github.com/danyel/go-guess/backend/internal/database/mapper"
|
||||
servicemodel "github.com/danyel/go-guess/backend/internal/service/model"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("record not found")
|
||||
ErrConflict = errors.New("record conflicts with existing data")
|
||||
)
|
||||
|
||||
type IStore interface {
|
||||
FindUserByEmail(context.Context, string) (servicemodel.User, error)
|
||||
ListJobs(context.Context) ([]servicemodel.JobPosting, error)
|
||||
GetJob(context.Context, uint) (servicemodel.JobPosting, error)
|
||||
CreateJob(context.Context, servicemodel.JobPosting) (servicemodel.JobPosting, error)
|
||||
UpdateJob(context.Context, uint, string, int) (servicemodel.JobPosting, error)
|
||||
ListQuestions(context.Context, string) ([]servicemodel.Question, error)
|
||||
GetQuestion(context.Context, uint) (servicemodel.Question, error)
|
||||
CreateQuestion(context.Context, servicemodel.Question) (servicemodel.Question, error)
|
||||
UpdateQuestion(context.Context, uint, servicemodel.Question) (servicemodel.Question, error)
|
||||
SetQuestionDeprecated(context.Context, uint, bool) (servicemodel.Question, error)
|
||||
AttachQuestion(context.Context, uint, uint) error
|
||||
DetachQuestion(context.Context, uint, uint) error
|
||||
ListParticipants(context.Context) ([]servicemodel.Participant, error)
|
||||
GetParticipant(context.Context, uint) (servicemodel.Participant, error)
|
||||
CreateParticipant(context.Context, servicemodel.Participant) (servicemodel.Participant, error)
|
||||
AllTraits(context.Context) ([]servicemodel.Trait, error)
|
||||
ListInvitations(context.Context, uint) ([]servicemodel.Invitation, error)
|
||||
CreateInvitation(context.Context, servicemodel.Invitation) (servicemodel.Invitation, error)
|
||||
GetInterview(context.Context, string) (servicemodel.Interview, error)
|
||||
AcceptInvitation(context.Context, string, time.Time) error
|
||||
SaveAnswer(context.Context, string, uint, string) error
|
||||
CompleteInvitation(context.Context, string, time.Time) error
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func New(db *gorm.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
func (s *Store) FindUserByEmail(ctx context.Context, email string) (servicemodel.User, error) {
|
||||
var value dbmodel.User
|
||||
if err := s.db.WithContext(ctx).Where("lower(email) = ?", strings.ToLower(email)).First(&value).Error; err != nil {
|
||||
return servicemodel.User{}, mapError(err)
|
||||
}
|
||||
return dbmapper.UserToService(value), nil
|
||||
}
|
||||
|
||||
func (s *Store) ListJobs(ctx context.Context) ([]servicemodel.JobPosting, error) {
|
||||
var values []dbmodel.JobPosting
|
||||
if err := preloadJob(s.db.WithContext(ctx)).Order("created_at DESC").Find(&values).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]servicemodel.JobPosting, len(values))
|
||||
for i, value := range values {
|
||||
result[i] = dbmapper.JobToService(value)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetJob(ctx context.Context, id uint) (servicemodel.JobPosting, error) {
|
||||
var value dbmodel.JobPosting
|
||||
if err := preloadJob(s.db.WithContext(ctx)).First(&value, id).Error; err != nil {
|
||||
return servicemodel.JobPosting{}, mapError(err)
|
||||
}
|
||||
return dbmapper.JobToService(value), nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateJob(ctx context.Context, value servicemodel.JobPosting) (servicemodel.JobPosting, error) {
|
||||
entity := dbmodel.JobPosting{
|
||||
Title: value.Title, Description: value.Description, Seniority: value.Seniority,
|
||||
Position: value.Position, Status: value.Status, DurationMinutes: value.DurationMinutes,
|
||||
}
|
||||
var err error
|
||||
entity.Labels, err = s.resolveTraits(ctx, value.Labels)
|
||||
if err != nil {
|
||||
return servicemodel.JobPosting{}, err
|
||||
}
|
||||
entity.RequiredSkills, err = s.resolveTraits(ctx, value.RequiredSkills)
|
||||
if err != nil {
|
||||
return servicemodel.JobPosting{}, err
|
||||
}
|
||||
entity.AdditionalSkills, err = s.resolveTraits(ctx, value.AdditionalSkills)
|
||||
if err != nil {
|
||||
return servicemodel.JobPosting{}, err
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Create(&entity).Error; err != nil {
|
||||
return servicemodel.JobPosting{}, mapError(err)
|
||||
}
|
||||
return s.GetJob(ctx, entity.ID)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateJob(ctx context.Context, id uint, status string, durationMinutes int) (servicemodel.JobPosting, error) {
|
||||
result := s.db.WithContext(ctx).Model(&dbmodel.JobPosting{}).Where("id = ?", id).
|
||||
Updates(map[string]any{"status": status, "duration_minutes": durationMinutes})
|
||||
if result.Error != nil {
|
||||
return servicemodel.JobPosting{}, result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return servicemodel.JobPosting{}, ErrNotFound
|
||||
}
|
||||
return s.GetJob(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Store) ListQuestions(ctx context.Context, search string) ([]servicemodel.Question, error) {
|
||||
query := s.db.WithContext(ctx).Preload("Options").Order("deprecated, created_at DESC")
|
||||
if search = strings.TrimSpace(search); search != "" {
|
||||
query = query.Where("text ILIKE ?", "%"+search+"%")
|
||||
}
|
||||
var values []dbmodel.Question
|
||||
if err := query.Find(&values).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]servicemodel.Question, len(values))
|
||||
for i, value := range values {
|
||||
result[i] = dbmapper.QuestionToService(value)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetQuestion(ctx context.Context, id uint) (servicemodel.Question, error) {
|
||||
var value dbmodel.Question
|
||||
if err := s.db.WithContext(ctx).Preload("Options").First(&value, id).Error; err != nil {
|
||||
return servicemodel.Question{}, mapError(err)
|
||||
}
|
||||
return dbmapper.QuestionToService(value), nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateQuestion(ctx context.Context, value servicemodel.Question) (servicemodel.Question, error) {
|
||||
options := make([]dbmodel.QuestionOption, len(value.Options))
|
||||
for i, option := range value.Options {
|
||||
options[i] = dbmodel.QuestionOption{Text: option}
|
||||
}
|
||||
entity := dbmodel.Question{
|
||||
Text: value.Text, Type: value.Type, Options: options,
|
||||
ReferenceAnswer: value.ReferenceAnswer, Deprecated: value.Deprecated,
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Create(&entity).Error; err != nil {
|
||||
return servicemodel.Question{}, mapError(err)
|
||||
}
|
||||
return s.GetQuestion(ctx, entity.ID)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateQuestion(ctx context.Context, id uint, value servicemodel.Question) (servicemodel.Question, error) {
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&dbmodel.Question{}).Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"text": value.Text, "type": value.Type, "reference_answer": value.ReferenceAnswer,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err := tx.Where("question_id = ?", id).Delete(&dbmodel.QuestionOption{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(value.Options) == 0 {
|
||||
return nil
|
||||
}
|
||||
options := make([]dbmodel.QuestionOption, len(value.Options))
|
||||
for i, option := range value.Options {
|
||||
options[i] = dbmodel.QuestionOption{QuestionID: id, Text: option}
|
||||
}
|
||||
return tx.Create(&options).Error
|
||||
})
|
||||
if err != nil {
|
||||
return servicemodel.Question{}, mapError(err)
|
||||
}
|
||||
return s.GetQuestion(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Store) SetQuestionDeprecated(ctx context.Context, id uint, deprecated bool) (servicemodel.Question, error) {
|
||||
result := s.db.WithContext(ctx).Model(&dbmodel.Question{}).Where("id = ?", id).
|
||||
Update("deprecated", deprecated)
|
||||
if result.Error != nil {
|
||||
return servicemodel.Question{}, result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return servicemodel.Question{}, ErrNotFound
|
||||
}
|
||||
return s.GetQuestion(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Store) AttachQuestion(ctx context.Context, jobID, questionID uint) error {
|
||||
var job dbmodel.JobPosting
|
||||
if err := s.db.WithContext(ctx).First(&job, jobID).Error; err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
var question dbmodel.Question
|
||||
if err := s.db.WithContext(ctx).First(&question, questionID).Error; err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
var invitations int64
|
||||
if err := s.db.WithContext(ctx).Model(&dbmodel.Invitation{}).Where("job_id = ?", jobID).
|
||||
Count(&invitations).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if invitations > 0 {
|
||||
return ErrConflict
|
||||
}
|
||||
return mapError(s.db.WithContext(ctx).Model(&job).Association("Questions").Append(&question))
|
||||
}
|
||||
|
||||
func (s *Store) DetachQuestion(ctx context.Context, jobID, questionID uint) error {
|
||||
var job dbmodel.JobPosting
|
||||
if err := s.db.WithContext(ctx).First(&job, jobID).Error; err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
var invitations int64
|
||||
if err := s.db.WithContext(ctx).Model(&dbmodel.Invitation{}).Where("job_id = ?", jobID).
|
||||
Count(&invitations).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if invitations > 0 {
|
||||
return ErrConflict
|
||||
}
|
||||
return s.db.WithContext(ctx).Model(&job).Association("Questions").
|
||||
Delete(&dbmodel.Question{ID: questionID})
|
||||
}
|
||||
|
||||
func (s *Store) ListParticipants(ctx context.Context) ([]servicemodel.Participant, error) {
|
||||
var values []dbmodel.Participant
|
||||
if err := s.db.WithContext(ctx).Preload("Traits").Order("last_name, first_name").Find(&values).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]servicemodel.Participant, len(values))
|
||||
for i, value := range values {
|
||||
result[i] = dbmapper.ParticipantToService(value)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetParticipant(ctx context.Context, id uint) (servicemodel.Participant, error) {
|
||||
var value dbmodel.Participant
|
||||
if err := s.db.WithContext(ctx).Preload("Traits").First(&value, id).Error; err != nil {
|
||||
return servicemodel.Participant{}, mapError(err)
|
||||
}
|
||||
return dbmapper.ParticipantToService(value), nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateParticipant(ctx context.Context, value servicemodel.Participant) (servicemodel.Participant, error) {
|
||||
entity := dbmodel.Participant{
|
||||
FirstName: value.FirstName, LastName: value.LastName, Birthday: value.Birthday,
|
||||
Email: value.Email, ContactInfo: value.ContactInfo, Photo: value.Photo,
|
||||
PhotoType: value.PhotoType, CV: value.CV, CVFilename: value.CVFilename,
|
||||
}
|
||||
var err error
|
||||
entity.Traits, err = s.resolveTraits(ctx, value.Traits)
|
||||
if err != nil {
|
||||
return servicemodel.Participant{}, err
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Create(&entity).Error; err != nil {
|
||||
return servicemodel.Participant{}, mapError(err)
|
||||
}
|
||||
return s.GetParticipant(ctx, entity.ID)
|
||||
}
|
||||
|
||||
func (s *Store) AllTraits(ctx context.Context) ([]servicemodel.Trait, error) {
|
||||
var values []dbmodel.Trait
|
||||
if err := s.db.WithContext(ctx).Order("name").Find(&values).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]servicemodel.Trait, len(values))
|
||||
for i, value := range values {
|
||||
result[i] = servicemodel.Trait{ID: value.ID, Name: value.Name}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListInvitations(ctx context.Context, jobID uint) ([]servicemodel.Invitation, error) {
|
||||
var values []dbmodel.Invitation
|
||||
if err := s.db.WithContext(ctx).Preload("Participant").Where("job_id = ?", jobID).
|
||||
Order("created_at DESC").Find(&values).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]servicemodel.Invitation, len(values))
|
||||
for i, value := range values {
|
||||
result[i] = dbmapper.InvitationToService(value)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateInvitation(ctx context.Context, value servicemodel.Invitation) (servicemodel.Invitation, error) {
|
||||
entity := dbmodel.Invitation{
|
||||
JobID: value.JobID, ParticipantID: value.ParticipantID,
|
||||
Token: value.Token, Status: value.Status, DurationMinutes: value.DurationMinutes,
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Create(&entity).Error; err != nil {
|
||||
return servicemodel.Invitation{}, mapError(err)
|
||||
}
|
||||
var created dbmodel.Invitation
|
||||
if err := s.db.WithContext(ctx).Preload("Participant").First(&created, entity.ID).Error; err != nil {
|
||||
return servicemodel.Invitation{}, mapError(err)
|
||||
}
|
||||
return dbmapper.InvitationToService(created), nil
|
||||
}
|
||||
|
||||
func (s *Store) GetInterview(ctx context.Context, token string) (servicemodel.Interview, error) {
|
||||
var invitation dbmodel.Invitation
|
||||
err := s.db.WithContext(ctx).Preload("Participant").Preload("Answers").
|
||||
Preload("Job.Labels").Preload("Job.RequiredSkills").Preload("Job.AdditionalSkills").
|
||||
Preload("Job.Questions.Options").Where("token = ?", token).First(&invitation).Error
|
||||
if err != nil {
|
||||
return servicemodel.Interview{}, mapError(err)
|
||||
}
|
||||
answers := make(map[uint]string, len(invitation.Answers))
|
||||
for _, answer := range invitation.Answers {
|
||||
answers[answer.QuestionID] = answer.Answer
|
||||
}
|
||||
return servicemodel.Interview{
|
||||
Invitation: dbmapper.InvitationToService(invitation),
|
||||
Job: dbmapper.JobToService(invitation.Job),
|
||||
Answers: answers,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) AcceptInvitation(ctx context.Context, token string, acceptedAt time.Time) error {
|
||||
result := s.db.WithContext(ctx).Model(&dbmodel.Invitation{}).
|
||||
Where("token = ? AND status = ?", token, "pending").
|
||||
Updates(map[string]any{"status": "accepted", "accepted_at": acceptedAt})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SaveAnswer(ctx context.Context, token string, questionID uint, answer string) error {
|
||||
var invitation dbmodel.Invitation
|
||||
if err := s.db.WithContext(ctx).Where("token = ?", token).First(&invitation).Error; err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
value := dbmodel.InvitationAnswer{InvitationID: invitation.ID, QuestionID: questionID, Answer: answer}
|
||||
return s.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "invitation_id"}, {Name: "question_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"answer", "updated_at"}),
|
||||
}).Create(&value).Error
|
||||
}
|
||||
|
||||
func (s *Store) CompleteInvitation(ctx context.Context, token string, completedAt time.Time) error {
|
||||
result := s.db.WithContext(ctx).Model(&dbmodel.Invitation{}).
|
||||
Where("token = ? AND status = ?", token, "accepted").
|
||||
Updates(map[string]any{"status": "completed", "completed_at": completedAt})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) resolveTraits(ctx context.Context, values []servicemodel.Trait) ([]dbmodel.Trait, error) {
|
||||
result := make([]dbmodel.Trait, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
name := strings.TrimSpace(value.Name)
|
||||
normalized := strings.ToLower(name)
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[normalized]; ok {
|
||||
continue
|
||||
}
|
||||
seen[normalized] = struct{}{}
|
||||
var trait dbmodel.Trait
|
||||
if err := s.db.WithContext(ctx).Where(dbmodel.Trait{Normalized: normalized}).
|
||||
Attrs(dbmodel.Trait{Name: name}).FirstOrCreate(&trait).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, trait)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func preloadJob(db *gorm.DB) *gorm.DB {
|
||||
return db.Preload("Labels").Preload("RequiredSkills").Preload("AdditionalSkills").
|
||||
Preload("Questions.Options")
|
||||
}
|
||||
|
||||
func mapError(err error) error {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
var pgError *pgconn.PgError
|
||||
if errors.As(err, &pgError) && pgError.Code == "23505" {
|
||||
return ErrConflict
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
//go:build integration
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/danyel/go-guess/backend/internal/service/model"
|
||||
)
|
||||
|
||||
func TestStoreEditsQuestionWithoutDeletingIt(t *testing.T) {
|
||||
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not set")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := New(db)
|
||||
created, err := store.CreateQuestion(context.Background(), model.Question{
|
||||
Text: "Original?", Type: "open",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Exec("DELETE FROM questions WHERE id = ?", created.ID) })
|
||||
|
||||
updated, err := store.UpdateQuestion(context.Background(), created.ID, model.Question{
|
||||
Text: "Choose two", Type: "multiple_choice", Options: []string{"One", "Two"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.ID != created.ID || updated.Text != "Choose two" || len(updated.Options) != 2 {
|
||||
t.Fatalf("unexpected updated question: %#v", updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorePersistsJobWithTraits(t *testing.T) {
|
||||
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not set")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := New(db)
|
||||
suffix := uuid.NewString()
|
||||
created, err := store.CreateJob(context.Background(), model.JobPosting{
|
||||
Title: "Integration " + suffix, Description: "Database mapping test",
|
||||
Seniority: "Senior", Position: "Backend Engineer",
|
||||
Labels: []model.Trait{{Name: "integration-" + suffix}},
|
||||
RequiredSkills: []model.Trait{{Name: "Go"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Exec("DELETE FROM job_postings WHERE id = ?", created.ID) })
|
||||
|
||||
found, err := store.GetJob(context.Background(), created.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if found.Title != created.Title || len(found.Labels) != 1 || len(found.RequiredSkills) != 1 {
|
||||
t.Fatalf("unexpected stored job: %#v", found)
|
||||
}
|
||||
}
|
||||
75
backend/internal/security/security.go
Normal file
75
backend/internal/security/security.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package security
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var ErrInvalidToken = errors.New("invalid token")
|
||||
|
||||
type Claims struct {
|
||||
UserID uint `json:"userId"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type ITokenManager interface {
|
||||
Issue(uint, string, string) (string, error)
|
||||
Parse(string) (Claims, error)
|
||||
}
|
||||
|
||||
type TokenManager struct {
|
||||
secret []byte
|
||||
ttl time.Duration
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewTokenManager(secret string, ttl time.Duration) *TokenManager {
|
||||
return &TokenManager{secret: []byte(secret), ttl: ttl, now: time.Now}
|
||||
}
|
||||
|
||||
func (m *TokenManager) Issue(userID uint, email, role string) (string, error) {
|
||||
now := m.now()
|
||||
claims := Claims{
|
||||
UserID: userID, Email: email, Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: fmt.Sprint(userID), IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(m.ttl)),
|
||||
},
|
||||
}
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(m.secret)
|
||||
}
|
||||
|
||||
func (m *TokenManager) Parse(raw string) (Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(raw, &Claims{}, func(token *jwt.Token) (any, error) {
|
||||
if token.Method != jwt.SigningMethodHS256 {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
return m.secret, nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return Claims{}, ErrInvalidToken
|
||||
}
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok {
|
||||
return Claims{}, ErrInvalidToken
|
||||
}
|
||||
return *claims, nil
|
||||
}
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
value, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
return string(value), nil
|
||||
}
|
||||
|
||||
func CheckPassword(hash, password string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
33
backend/internal/security/security_test.go
Normal file
33
backend/internal/security/security_test.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package security
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTokenRoundTrip(t *testing.T) {
|
||||
manager := NewTokenManager("01234567890123456789012345678901", time.Hour)
|
||||
raw, err := manager.Issue(42, "interviewer@example.com", "interviewer")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
claims, err := manager.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claims.UserID != 42 || claims.Email != "interviewer@example.com" || claims.Role != "interviewer" {
|
||||
t.Fatalf("unexpected claims: %#v", claims)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsTokenSignedWithAnotherSecret(t *testing.T) {
|
||||
issuer := NewTokenManager("01234567890123456789012345678901", time.Hour)
|
||||
verifier := NewTokenManager("abcdefghijklmnopqrstuvwxyz123456", time.Hour)
|
||||
raw, err := issuer.Issue(1, "user@example.com", "interviewer")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := verifier.Parse(raw); err == nil {
|
||||
t.Fatal("expected token validation to fail")
|
||||
}
|
||||
}
|
||||
78
backend/internal/service/cv.go
Normal file
78
backend/internal/service/cv.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/ledongthuc/pdf"
|
||||
)
|
||||
|
||||
func extractCVText(filename string, content []byte) (text string, err error) {
|
||||
if len(content) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
switch strings.ToLower(filepath.Ext(filename)) {
|
||||
case ".pdf":
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
text = ""
|
||||
err = fmt.Errorf("unreadable PDF")
|
||||
}
|
||||
}()
|
||||
reader, err := pdf.NewReader(bytes.NewReader(content), int64(len(content)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read PDF: %w", err)
|
||||
}
|
||||
plainText, err := reader.GetPlainText()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("extract PDF text: %w", err)
|
||||
}
|
||||
value, err := io.ReadAll(plainText)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read PDF text: %w", err)
|
||||
}
|
||||
return string(value), nil
|
||||
case ".docx":
|
||||
return extractDOCX(content)
|
||||
default:
|
||||
return string(content), nil
|
||||
}
|
||||
}
|
||||
|
||||
func extractDOCX(content []byte) (string, error) {
|
||||
archive, err := zip.NewReader(bytes.NewReader(content), int64(len(content)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open DOCX: %w", err)
|
||||
}
|
||||
for _, file := range archive.File {
|
||||
if file.Name != "word/document.xml" {
|
||||
continue
|
||||
}
|
||||
stream, err := file.Open()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open DOCX document: %w", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
decoder := xml.NewDecoder(stream)
|
||||
var text strings.Builder
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
return text.String(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse DOCX document: %w", err)
|
||||
}
|
||||
if chars, ok := token.(xml.CharData); ok {
|
||||
text.Write(chars)
|
||||
text.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("DOCX document body is missing")
|
||||
}
|
||||
30
backend/internal/service/cv_test.go
Normal file
30
backend/internal/service/cv_test.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractCVTextFromDOCX(t *testing.T) {
|
||||
var content bytes.Buffer
|
||||
archive := zip.NewWriter(&content)
|
||||
document, err := archive.Create("word/document.xml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := document.Write([]byte(`<w:document xmlns:w="urn:test"><w:body><w:p><w:r><w:t>Go and PostgreSQL</w:t></w:r></w:p></w:body></w:document>`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := archive.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
text, err := extractCVText("candidate.docx", content.Bytes())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if text != "Go and PostgreSQL " {
|
||||
t.Fatalf("unexpected extracted text %q", text)
|
||||
}
|
||||
}
|
||||
81
backend/internal/service/model/models.go
Normal file
81
backend/internal/service/model/models.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type User struct {
|
||||
ID uint
|
||||
Email string
|
||||
PasswordHash string
|
||||
Role string
|
||||
}
|
||||
|
||||
type Trait struct {
|
||||
ID uint
|
||||
Name string
|
||||
}
|
||||
|
||||
type JobPosting struct {
|
||||
ID uint
|
||||
Title string
|
||||
Description string
|
||||
Seniority string
|
||||
Position string
|
||||
Status string
|
||||
DurationMinutes int
|
||||
Labels []Trait
|
||||
RequiredSkills []Trait
|
||||
AdditionalSkills []Trait
|
||||
Questions []Question
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Question struct {
|
||||
ID uint
|
||||
Text string
|
||||
Type string
|
||||
Options []string
|
||||
ReferenceAnswer string
|
||||
Deprecated bool
|
||||
}
|
||||
|
||||
type Participant struct {
|
||||
ID uint
|
||||
FirstName string
|
||||
LastName string
|
||||
Birthday *time.Time
|
||||
Email string
|
||||
ContactInfo string
|
||||
Photo []byte
|
||||
PhotoType string
|
||||
CV []byte
|
||||
CVFilename string
|
||||
Traits []Trait
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type CandidateMatch struct {
|
||||
Participant Participant
|
||||
Score float64
|
||||
Matched []string
|
||||
Missing []string
|
||||
}
|
||||
|
||||
type Invitation struct {
|
||||
ID uint
|
||||
JobID uint
|
||||
ParticipantID uint
|
||||
ParticipantName string
|
||||
ParticipantEmail string
|
||||
Token string
|
||||
Status string
|
||||
DurationMinutes int
|
||||
AcceptedAt *time.Time
|
||||
CompletedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Interview struct {
|
||||
Invitation Invitation
|
||||
Job JobPosting
|
||||
Answers map[uint]string
|
||||
}
|
||||
454
backend/internal/service/services.go
Normal file
454
backend/internal/service/services.go
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/danyel/go-guess/backend/internal/database/repository"
|
||||
"github.com/danyel/go-guess/backend/internal/security"
|
||||
"github.com/danyel/go-guess/backend/internal/service/model"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid email or password")
|
||||
ErrInvalidQuestion = errors.New("invalid question")
|
||||
ErrValidation = errors.New("validation failed")
|
||||
ErrInvalidState = errors.New("operation is not allowed in the current state")
|
||||
)
|
||||
|
||||
const CandidateThreshold = 60.0
|
||||
|
||||
type IAuthService interface {
|
||||
Login(context.Context, string, string) (string, model.User, error)
|
||||
}
|
||||
|
||||
type IJobService interface {
|
||||
List(context.Context) ([]model.JobPosting, error)
|
||||
Get(context.Context, uint) (model.JobPosting, error)
|
||||
Create(context.Context, model.JobPosting) (model.JobPosting, error)
|
||||
Update(context.Context, uint, string, int) (model.JobPosting, error)
|
||||
AttachQuestion(context.Context, uint, uint) error
|
||||
DetachQuestion(context.Context, uint, uint) error
|
||||
Candidates(context.Context, uint) ([]model.CandidateMatch, error)
|
||||
}
|
||||
|
||||
type IQuestionService interface {
|
||||
List(context.Context, string) ([]model.Question, error)
|
||||
Create(context.Context, model.Question) (model.Question, error)
|
||||
Update(context.Context, uint, model.Question) (model.Question, error)
|
||||
SetDeprecated(context.Context, uint, bool) (model.Question, error)
|
||||
}
|
||||
|
||||
type IParticipantService interface {
|
||||
List(context.Context) ([]model.Participant, error)
|
||||
Get(context.Context, uint) (model.Participant, error)
|
||||
Create(context.Context, model.Participant) (model.Participant, error)
|
||||
}
|
||||
|
||||
type IInvitationService interface {
|
||||
List(context.Context, uint) ([]model.Invitation, error)
|
||||
Create(context.Context, uint, uint) (model.Invitation, error)
|
||||
GetInterview(context.Context, string) (model.Interview, error)
|
||||
Accept(context.Context, string) (model.Interview, error)
|
||||
SaveAnswer(context.Context, string, uint, string) error
|
||||
Finish(context.Context, string) (model.Interview, error)
|
||||
}
|
||||
|
||||
type AuthService struct {
|
||||
store repository.IStore
|
||||
tokens security.ITokenManager
|
||||
}
|
||||
|
||||
func NewAuthService(store repository.IStore, tokens security.ITokenManager) *AuthService {
|
||||
return &AuthService{store: store, tokens: tokens}
|
||||
}
|
||||
|
||||
func (s *AuthService) Login(ctx context.Context, email, password string) (string, model.User, error) {
|
||||
user, err := s.store.FindUserByEmail(ctx, strings.TrimSpace(email))
|
||||
if err != nil || !security.CheckPassword(user.PasswordHash, password) {
|
||||
return "", model.User{}, ErrInvalidCredentials
|
||||
}
|
||||
token, err := s.tokens.Issue(user.ID, user.Email, user.Role)
|
||||
if err != nil {
|
||||
return "", model.User{}, err
|
||||
}
|
||||
user.PasswordHash = ""
|
||||
return token, user, nil
|
||||
}
|
||||
|
||||
type JobService struct {
|
||||
store repository.IStore
|
||||
}
|
||||
|
||||
func NewJobService(store repository.IStore) *JobService {
|
||||
return &JobService{store: store}
|
||||
}
|
||||
|
||||
func (s *JobService) List(ctx context.Context) ([]model.JobPosting, error) {
|
||||
return s.store.ListJobs(ctx)
|
||||
}
|
||||
|
||||
func (s *JobService) Get(ctx context.Context, id uint) (model.JobPosting, error) {
|
||||
return s.store.GetJob(ctx, id)
|
||||
}
|
||||
|
||||
func (s *JobService) Create(ctx context.Context, value model.JobPosting) (model.JobPosting, error) {
|
||||
value.Title = strings.TrimSpace(value.Title)
|
||||
value.Description = strings.TrimSpace(value.Description)
|
||||
value.Position = strings.TrimSpace(value.Position)
|
||||
value.Seniority = strings.TrimSpace(value.Seniority)
|
||||
if value.Title == "" || value.Description == "" || value.Position == "" || value.Seniority == "" {
|
||||
return model.JobPosting{}, fmt.Errorf("%w: title, description, position, and seniority are required", ErrValidation)
|
||||
}
|
||||
value.Status = "draft"
|
||||
if value.DurationMinutes == 0 {
|
||||
value.DurationMinutes = 60
|
||||
}
|
||||
if value.DurationMinutes < 1 || value.DurationMinutes > 480 {
|
||||
return model.JobPosting{}, fmt.Errorf("%w: duration must be between 1 and 480 minutes", ErrValidation)
|
||||
}
|
||||
return s.store.CreateJob(ctx, value)
|
||||
}
|
||||
|
||||
func (s *JobService) Update(ctx context.Context, id uint, status string, durationMinutes int) (model.JobPosting, error) {
|
||||
if !validJobStatus(status) {
|
||||
return model.JobPosting{}, fmt.Errorf("%w: unsupported job status", ErrValidation)
|
||||
}
|
||||
if durationMinutes < 1 || durationMinutes > 480 {
|
||||
return model.JobPosting{}, fmt.Errorf("%w: duration must be between 1 and 480 minutes", ErrValidation)
|
||||
}
|
||||
if status == "published" {
|
||||
job, err := s.store.GetJob(ctx, id)
|
||||
if err != nil {
|
||||
return model.JobPosting{}, err
|
||||
}
|
||||
if len(job.Questions) == 0 {
|
||||
return model.JobPosting{}, fmt.Errorf("%w: a published job requires at least one question", ErrInvalidState)
|
||||
}
|
||||
}
|
||||
return s.store.UpdateJob(ctx, id, status, durationMinutes)
|
||||
}
|
||||
|
||||
func (s *JobService) AttachQuestion(ctx context.Context, jobID, questionID uint) error {
|
||||
question, err := s.store.GetQuestion(ctx, questionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if question.Deprecated {
|
||||
return fmt.Errorf("%w: deprecated questions cannot be newly attached", ErrInvalidState)
|
||||
}
|
||||
return s.store.AttachQuestion(ctx, jobID, questionID)
|
||||
}
|
||||
|
||||
func (s *JobService) DetachQuestion(ctx context.Context, jobID, questionID uint) error {
|
||||
return s.store.DetachQuestion(ctx, jobID, questionID)
|
||||
}
|
||||
|
||||
type QuestionService struct {
|
||||
store repository.IStore
|
||||
}
|
||||
|
||||
func NewQuestionService(store repository.IStore) *QuestionService {
|
||||
return &QuestionService{store: store}
|
||||
}
|
||||
|
||||
func (s *QuestionService) List(ctx context.Context, search string) ([]model.Question, error) {
|
||||
return s.store.ListQuestions(ctx, search)
|
||||
}
|
||||
|
||||
func (s *QuestionService) Create(ctx context.Context, value model.Question) (model.Question, error) {
|
||||
value, err := validateQuestion(value)
|
||||
if err != nil {
|
||||
return model.Question{}, err
|
||||
}
|
||||
return s.store.CreateQuestion(ctx, value)
|
||||
}
|
||||
|
||||
func (s *QuestionService) Update(ctx context.Context, id uint, value model.Question) (model.Question, error) {
|
||||
value, err := validateQuestion(value)
|
||||
if err != nil {
|
||||
return model.Question{}, err
|
||||
}
|
||||
return s.store.UpdateQuestion(ctx, id, value)
|
||||
}
|
||||
|
||||
func validateQuestion(value model.Question) (model.Question, error) {
|
||||
value.Text = strings.TrimSpace(value.Text)
|
||||
value.ReferenceAnswer = strings.TrimSpace(value.ReferenceAnswer)
|
||||
switch value.Type {
|
||||
case "open", "multiple_choice", "radio", "code_review":
|
||||
default:
|
||||
return model.Question{}, fmt.Errorf("%w: unsupported type", ErrInvalidQuestion)
|
||||
}
|
||||
if value.Text == "" {
|
||||
return model.Question{}, fmt.Errorf("%w: text is required", ErrInvalidQuestion)
|
||||
}
|
||||
options := make([]string, 0, len(value.Options))
|
||||
for _, option := range value.Options {
|
||||
if option = strings.TrimSpace(option); option != "" {
|
||||
options = append(options, option)
|
||||
}
|
||||
}
|
||||
value.Options = options
|
||||
if (value.Type == "multiple_choice" || value.Type == "radio") && len(value.Options) < 2 {
|
||||
return model.Question{}, fmt.Errorf("%w: at least two options are required", ErrInvalidQuestion)
|
||||
}
|
||||
if (value.Type == "open" || value.Type == "code_review") && value.ReferenceAnswer == "" {
|
||||
return model.Question{}, fmt.Errorf("%w: a reference answer is required", ErrInvalidQuestion)
|
||||
}
|
||||
if value.Type == "open" || value.Type == "code_review" {
|
||||
value.Options = nil
|
||||
} else {
|
||||
value.ReferenceAnswer = ""
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *QuestionService) SetDeprecated(ctx context.Context, id uint, deprecated bool) (model.Question, error) {
|
||||
return s.store.SetQuestionDeprecated(ctx, id, deprecated)
|
||||
}
|
||||
|
||||
func (s *JobService) Candidates(ctx context.Context, jobID uint) ([]model.CandidateMatch, error) {
|
||||
job, err := s.store.GetJob(ctx, jobID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
participants, err := s.store.ListParticipants(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
required := uniqueTraits(job.Labels, job.RequiredSkills, job.AdditionalSkills)
|
||||
if len(required) == 0 {
|
||||
return []model.CandidateMatch{}, nil
|
||||
}
|
||||
result := make([]model.CandidateMatch, 0)
|
||||
for _, participant := range participants {
|
||||
owned := make(map[string]struct{}, len(participant.Traits))
|
||||
for _, trait := range participant.Traits {
|
||||
owned[normalize(trait.Name)] = struct{}{}
|
||||
}
|
||||
text, err := extractCVText(participant.CVFilename, participant.CV)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("extract participant %d CV text: %w", participant.ID, err)
|
||||
}
|
||||
for name := range required {
|
||||
if containsTerm(normalize(text), name) {
|
||||
owned[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
match := model.CandidateMatch{Participant: participant}
|
||||
for name, display := range required {
|
||||
if _, ok := owned[name]; ok {
|
||||
match.Matched = append(match.Matched, display)
|
||||
} else {
|
||||
match.Missing = append(match.Missing, display)
|
||||
}
|
||||
}
|
||||
match.Score = math.Round(float64(len(match.Matched))/float64(len(required))*1000) / 10
|
||||
if match.Score >= CandidateThreshold {
|
||||
sort.Strings(match.Matched)
|
||||
sort.Strings(match.Missing)
|
||||
result = append(result, match)
|
||||
}
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].Score > result[j].Score })
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type ParticipantService struct {
|
||||
store repository.IStore
|
||||
}
|
||||
|
||||
func NewParticipantService(store repository.IStore) *ParticipantService {
|
||||
return &ParticipantService{store: store}
|
||||
}
|
||||
|
||||
func (s *ParticipantService) List(ctx context.Context) ([]model.Participant, error) {
|
||||
return s.store.ListParticipants(ctx)
|
||||
}
|
||||
|
||||
func (s *ParticipantService) Get(ctx context.Context, id uint) (model.Participant, error) {
|
||||
return s.store.GetParticipant(ctx, id)
|
||||
}
|
||||
|
||||
func (s *ParticipantService) Create(ctx context.Context, value model.Participant) (model.Participant, error) {
|
||||
value.FirstName = strings.TrimSpace(value.FirstName)
|
||||
value.LastName = strings.TrimSpace(value.LastName)
|
||||
value.Email = strings.ToLower(strings.TrimSpace(value.Email))
|
||||
if value.FirstName == "" || value.LastName == "" || value.Email == "" {
|
||||
return model.Participant{}, fmt.Errorf("%w: first name, last name, and email are required", ErrValidation)
|
||||
}
|
||||
allTraits, err := s.store.AllTraits(ctx)
|
||||
if err != nil {
|
||||
return model.Participant{}, err
|
||||
}
|
||||
text, err := extractCVText(value.CVFilename, value.CV)
|
||||
if err != nil {
|
||||
return model.Participant{}, fmt.Errorf("%w: extract CV text: %v", ErrValidation, err)
|
||||
}
|
||||
value.Traits = extractTraits(text, allTraits)
|
||||
return s.store.CreateParticipant(ctx, value)
|
||||
}
|
||||
|
||||
func extractTraits(content string, traits []model.Trait) []model.Trait {
|
||||
text := normalize(content)
|
||||
result := make([]model.Trait, 0)
|
||||
for _, trait := range traits {
|
||||
name := normalize(trait.Name)
|
||||
if name != "" && containsTerm(text, name) {
|
||||
result = append(result, trait)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func containsTerm(text, term string) bool {
|
||||
return strings.Contains(" "+text+" ", " "+term+" ")
|
||||
}
|
||||
|
||||
func uniqueTraits(groups ...[]model.Trait) map[string]string {
|
||||
result := make(map[string]string)
|
||||
for _, group := range groups {
|
||||
for _, trait := range group {
|
||||
if name := normalize(trait.Name); name != "" {
|
||||
result[name] = trait.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalize(value string) string {
|
||||
return strings.Join(strings.FieldsFunc(strings.ToLower(value), func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsNumber(r) && r != '+' && r != '#'
|
||||
}), " ")
|
||||
}
|
||||
|
||||
type InvitationService struct {
|
||||
store repository.IStore
|
||||
frontendURL string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewInvitationService(store repository.IStore, frontendURL string) *InvitationService {
|
||||
return &InvitationService{store: store, frontendURL: strings.TrimRight(frontendURL, "/"), now: time.Now}
|
||||
}
|
||||
|
||||
func (s *InvitationService) List(ctx context.Context, jobID uint) ([]model.Invitation, error) {
|
||||
return s.store.ListInvitations(ctx, jobID)
|
||||
}
|
||||
|
||||
func (s *InvitationService) Create(ctx context.Context, jobID, participantID uint) (model.Invitation, error) {
|
||||
job, err := s.store.GetJob(ctx, jobID)
|
||||
if err != nil {
|
||||
return model.Invitation{}, err
|
||||
}
|
||||
if job.Status != "published" {
|
||||
return model.Invitation{}, fmt.Errorf("%w: invitations require a published job", ErrInvalidState)
|
||||
}
|
||||
if _, err := s.store.GetParticipant(ctx, participantID); err != nil {
|
||||
return model.Invitation{}, err
|
||||
}
|
||||
matches, err := NewJobService(s.store).Candidates(ctx, jobID)
|
||||
if err != nil {
|
||||
return model.Invitation{}, err
|
||||
}
|
||||
eligible := false
|
||||
for _, match := range matches {
|
||||
if match.Participant.ID == participantID {
|
||||
eligible = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !eligible {
|
||||
return model.Invitation{}, fmt.Errorf("%w: participant does not meet the %.0f%% threshold", ErrInvalidState, CandidateThreshold)
|
||||
}
|
||||
return s.store.CreateInvitation(ctx, model.Invitation{
|
||||
JobID: jobID, ParticipantID: participantID, Token: uuid.NewString(),
|
||||
Status: "pending", DurationMinutes: job.DurationMinutes,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *InvitationService) GetInterview(ctx context.Context, token string) (model.Interview, error) {
|
||||
interview, err := s.store.GetInterview(ctx, token)
|
||||
if err != nil {
|
||||
return model.Interview{}, err
|
||||
}
|
||||
if interview.Job.Status != "published" && interview.Invitation.Status == "pending" {
|
||||
return model.Interview{}, ErrNotAvailable
|
||||
}
|
||||
interview.Job.DurationMinutes = interview.Invitation.DurationMinutes
|
||||
return interview, nil
|
||||
}
|
||||
|
||||
var ErrNotAvailable = errors.New("interview is not available")
|
||||
|
||||
func (s *InvitationService) Accept(ctx context.Context, token string) (model.Interview, error) {
|
||||
interview, err := s.GetInterview(ctx, token)
|
||||
if err != nil {
|
||||
return model.Interview{}, err
|
||||
}
|
||||
if interview.Invitation.Status != "pending" {
|
||||
return model.Interview{}, ErrInvalidState
|
||||
}
|
||||
if err := s.store.AcceptInvitation(ctx, token, s.now()); err != nil {
|
||||
return model.Interview{}, err
|
||||
}
|
||||
return s.GetInterview(ctx, token)
|
||||
}
|
||||
|
||||
func (s *InvitationService) SaveAnswer(ctx context.Context, token string, questionID uint, answer string) error {
|
||||
interview, err := s.GetInterview(ctx, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if interview.Invitation.Status != "accepted" || interviewExpired(interview, s.now()) {
|
||||
return ErrInvalidState
|
||||
}
|
||||
found := false
|
||||
for _, question := range interview.Job.Questions {
|
||||
if question.ID == questionID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return repository.ErrNotFound
|
||||
}
|
||||
return s.store.SaveAnswer(ctx, token, questionID, strings.TrimSpace(answer))
|
||||
}
|
||||
|
||||
func (s *InvitationService) Finish(ctx context.Context, token string) (model.Interview, error) {
|
||||
interview, err := s.GetInterview(ctx, token)
|
||||
if err != nil {
|
||||
return model.Interview{}, err
|
||||
}
|
||||
if interview.Invitation.Status != "accepted" {
|
||||
return model.Interview{}, ErrInvalidState
|
||||
}
|
||||
if err := s.store.CompleteInvitation(ctx, token, s.now()); err != nil {
|
||||
return model.Interview{}, err
|
||||
}
|
||||
return s.GetInterview(ctx, token)
|
||||
}
|
||||
|
||||
func interviewExpired(interview model.Interview, now time.Time) bool {
|
||||
return interview.Invitation.AcceptedAt != nil &&
|
||||
!now.Before(interview.Invitation.AcceptedAt.Add(time.Duration(interview.Job.DurationMinutes)*time.Minute))
|
||||
}
|
||||
|
||||
func validJobStatus(status string) bool {
|
||||
switch status {
|
||||
case "draft", "published", "deprecated":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
182
backend/internal/service/services_test.go
Normal file
182
backend/internal/service/services_test.go
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/danyel/go-guess/backend/internal/service/model"
|
||||
)
|
||||
|
||||
type fakeStore struct {
|
||||
job model.JobPosting
|
||||
participants []model.Participant
|
||||
traits []model.Trait
|
||||
}
|
||||
|
||||
type invitationStore struct {
|
||||
*fakeStore
|
||||
interview model.Interview
|
||||
saved bool
|
||||
}
|
||||
|
||||
func (f *invitationStore) CreateInvitation(_ context.Context, value model.Invitation) (model.Invitation, error) {
|
||||
value.ID = 1
|
||||
return value, nil
|
||||
}
|
||||
func (f *invitationStore) GetInterview(context.Context, string) (model.Interview, error) {
|
||||
return f.interview, nil
|
||||
}
|
||||
func (f *invitationStore) SaveAnswer(context.Context, string, uint, string) error {
|
||||
f.saved = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) FindUserByEmail(context.Context, string) (model.User, error) {
|
||||
return model.User{}, errors.New("unused")
|
||||
}
|
||||
func (f *fakeStore) ListJobs(context.Context) ([]model.JobPosting, error) { return nil, nil }
|
||||
func (f *fakeStore) GetJob(context.Context, uint) (model.JobPosting, error) { return f.job, nil }
|
||||
func (f *fakeStore) CreateJob(_ context.Context, value model.JobPosting) (model.JobPosting, error) {
|
||||
return value, nil
|
||||
}
|
||||
func (f *fakeStore) CreateQuestion(_ context.Context, value model.Question) (model.Question, error) {
|
||||
return value, nil
|
||||
}
|
||||
func (f *fakeStore) UpdateQuestion(_ context.Context, _ uint, value model.Question) (model.Question, error) {
|
||||
return value, nil
|
||||
}
|
||||
func (f *fakeStore) ListParticipants(context.Context) ([]model.Participant, error) {
|
||||
return f.participants, nil
|
||||
}
|
||||
func (f *fakeStore) GetParticipant(context.Context, uint) (model.Participant, error) {
|
||||
return model.Participant{}, nil
|
||||
}
|
||||
func (f *fakeStore) CreateParticipant(_ context.Context, value model.Participant) (model.Participant, error) {
|
||||
return value, nil
|
||||
}
|
||||
func (f *fakeStore) AllTraits(context.Context) ([]model.Trait, error) { return f.traits, nil }
|
||||
func (f *fakeStore) UpdateJob(_ context.Context, _ uint, _ string, _ int) (model.JobPosting, error) {
|
||||
return f.job, nil
|
||||
}
|
||||
func (f *fakeStore) ListQuestions(context.Context, string) ([]model.Question, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeStore) GetQuestion(context.Context, uint) (model.Question, error) {
|
||||
return model.Question{}, nil
|
||||
}
|
||||
func (f *fakeStore) SetQuestionDeprecated(_ context.Context, _ uint, _ bool) (model.Question, error) {
|
||||
return model.Question{}, nil
|
||||
}
|
||||
func (f *fakeStore) AttachQuestion(context.Context, uint, uint) error { return nil }
|
||||
func (f *fakeStore) DetachQuestion(context.Context, uint, uint) error { return nil }
|
||||
func (f *fakeStore) ListInvitations(context.Context, uint) ([]model.Invitation, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeStore) CreateInvitation(_ context.Context, value model.Invitation) (model.Invitation, error) {
|
||||
return value, nil
|
||||
}
|
||||
func (f *fakeStore) GetInterview(context.Context, string) (model.Interview, error) {
|
||||
return model.Interview{}, nil
|
||||
}
|
||||
func (f *fakeStore) AcceptInvitation(context.Context, string, time.Time) error { return nil }
|
||||
func (f *fakeStore) SaveAnswer(context.Context, string, uint, string) error { return nil }
|
||||
func (f *fakeStore) CompleteInvitation(context.Context, string, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCandidatesRequireSixtyPercent(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
job: model.JobPosting{
|
||||
Labels: []model.Trait{{Name: "Backend"}},
|
||||
RequiredSkills: []model.Trait{{Name: "Go"}, {Name: "PostgreSQL"}, {Name: "Docker"}, {Name: "React"}},
|
||||
},
|
||||
participants: []model.Participant{
|
||||
{ID: 1, Traits: []model.Trait{{Name: "Go"}, {Name: "PostgreSQL"}, {Name: "Docker"}}},
|
||||
{ID: 2, Traits: []model.Trait{{Name: "Go"}, {Name: "React"}}},
|
||||
},
|
||||
}
|
||||
|
||||
matches, err := NewJobService(store).Candidates(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(matches) != 1 || matches[0].Participant.ID != 1 || matches[0].Score != 60 {
|
||||
t.Fatalf("expected only participant 1 at 60%%, got %#v", matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParticipantCreateExtractsKnownTraits(t *testing.T) {
|
||||
store := &fakeStore{traits: []model.Trait{{Name: "Go"}, {Name: "React"}, {Name: "C++"}}}
|
||||
value, err := NewParticipantService(store).Create(context.Background(), model.Participant{
|
||||
FirstName: "Ada", LastName: "Lovelace", Email: "ADA@EXAMPLE.COM",
|
||||
CV: []byte("Backend engineer using Go and C++ daily."), CVFilename: "cv.txt",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value.Email != "ada@example.com" || len(value.Traits) != 2 {
|
||||
t.Fatalf("unexpected participant: %#v", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvitationRequiresPublishedMatchingJob(t *testing.T) {
|
||||
store := &invitationStore{fakeStore: &fakeStore{
|
||||
job: model.JobPosting{
|
||||
ID: 1, Status: "published",
|
||||
RequiredSkills: []model.Trait{{Name: "Go"}},
|
||||
},
|
||||
participants: []model.Participant{{ID: 7, Traits: []model.Trait{{Name: "Go"}}}},
|
||||
}}
|
||||
invitation, err := NewInvitationService(store, "http://localhost:5173").Create(context.Background(), 1, 7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if invitation.Status != "pending" || invitation.Token == "" {
|
||||
t.Fatalf("unexpected invitation: %#v", invitation)
|
||||
}
|
||||
|
||||
store.job.Status = "draft"
|
||||
if _, err := NewInvitationService(store, "").Create(context.Background(), 1, 7); !errors.Is(err, ErrInvalidState) {
|
||||
t.Fatalf("expected invalid state for draft job, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiredInterviewRejectsAnswers(t *testing.T) {
|
||||
acceptedAt := time.Now().Add(-61 * time.Minute)
|
||||
store := &invitationStore{
|
||||
fakeStore: &fakeStore{},
|
||||
interview: model.Interview{
|
||||
Invitation: model.Invitation{Status: "accepted", AcceptedAt: &acceptedAt},
|
||||
Job: model.JobPosting{
|
||||
Status: "published", DurationMinutes: 60,
|
||||
Questions: []model.Question{{ID: 9}},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := NewInvitationService(store, "").SaveAnswer(context.Background(), "token", 9, "answer")
|
||||
if !errors.Is(err, ErrInvalidState) || store.saved {
|
||||
t.Fatalf("expected expired answer to be rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuestionReferenceAnswerValidation(t *testing.T) {
|
||||
service := NewQuestionService(&fakeStore{})
|
||||
if _, err := service.Create(context.Background(), model.Question{
|
||||
Text: "Explain cancellation", Type: "open",
|
||||
}); !errors.Is(err, ErrInvalidQuestion) {
|
||||
t.Fatalf("expected missing reference answer error, got %v", err)
|
||||
}
|
||||
|
||||
question, err := service.Create(context.Background(), model.Question{
|
||||
Text: "Choose", Type: "radio", Options: []string{" One ", "Two"},
|
||||
ReferenceAnswer: "must be cleared",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if question.ReferenceAnswer != "" || question.Options[0] != "One" {
|
||||
t.Fatalf("unexpected normalized question: %#v", question)
|
||||
}
|
||||
}
|
||||
438
backend/internal/web/handler/handler.go
Normal file
438
backend/internal/web/handler/handler.go
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/danyel/go-guess/backend/internal/database/repository"
|
||||
"github.com/danyel/go-guess/backend/internal/service"
|
||||
servicemodel "github.com/danyel/go-guess/backend/internal/service/model"
|
||||
webmapper "github.com/danyel/go-guess/backend/internal/web/mapper"
|
||||
webmodel "github.com/danyel/go-guess/backend/internal/web/model"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
auth service.IAuthService
|
||||
jobs service.IJobService
|
||||
questions service.IQuestionService
|
||||
participants service.IParticipantService
|
||||
invitations service.IInvitationService
|
||||
maxUpload int64
|
||||
frontendURL string
|
||||
}
|
||||
|
||||
func New(
|
||||
auth service.IAuthService,
|
||||
jobs service.IJobService,
|
||||
questions service.IQuestionService,
|
||||
participants service.IParticipantService,
|
||||
invitations service.IInvitationService,
|
||||
maxUploadMB int64,
|
||||
frontendURL string,
|
||||
) *Handler {
|
||||
return &Handler{
|
||||
auth: auth, jobs: jobs, questions: questions, participants: participants,
|
||||
invitations: invitations, maxUpload: maxUploadMB << 20,
|
||||
frontendURL: strings.TrimRight(frontendURL, "/"),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Health(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var request webmodel.LoginRequest
|
||||
if err := decodeJSON(r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
token, user, err := h.auth.Login(r.Context(), request.Email, request.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrInvalidCredentials) {
|
||||
writeError(w, http.StatusUnauthorized, err)
|
||||
return
|
||||
}
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, webmodel.LoginResponse{
|
||||
Token: token, User: webmodel.User{ID: user.ID, Email: user.Email, Role: user.Role},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) ListJobs(w http.ResponseWriter, r *http.Request) {
|
||||
values, err := h.jobs.List(r.Context())
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
response := make([]webmodel.JobResponse, len(values))
|
||||
for i, value := range values {
|
||||
response[i] = webmapper.JobToWeb(value)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (h *Handler) GetJob(w http.ResponseWriter, r *http.Request) {
|
||||
value, err := h.jobs.Get(r.Context(), idParam(r))
|
||||
if handleServiceError(w, r, err) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, webmapper.JobToWeb(value))
|
||||
}
|
||||
|
||||
func (h *Handler) CreateJob(w http.ResponseWriter, r *http.Request) {
|
||||
var request webmodel.JobRequest
|
||||
if err := decodeJSON(r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
value, err := h.jobs.Create(r.Context(), webmapper.JobToService(request))
|
||||
if err != nil {
|
||||
handleServiceError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, webmapper.JobToWeb(value))
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateJob(w http.ResponseWriter, r *http.Request) {
|
||||
var request webmodel.JobStatusRequest
|
||||
if err := decodeJSON(r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
value, err := h.jobs.Update(r.Context(), idParam(r), request.Status, request.DurationMinutes)
|
||||
if handleServiceError(w, r, err) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, webmapper.JobToWeb(value))
|
||||
}
|
||||
|
||||
func (h *Handler) ListQuestions(w http.ResponseWriter, r *http.Request) {
|
||||
values, err := h.questions.List(r.Context(), r.URL.Query().Get("search"))
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
response := make([]webmodel.QuestionResponse, len(values))
|
||||
for i, value := range values {
|
||||
response[i] = webmapper.QuestionToWeb(value)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateQuestion(w http.ResponseWriter, r *http.Request) {
|
||||
var request webmodel.QuestionRequest
|
||||
if err := decodeJSON(r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
value, err := h.questions.Create(r.Context(), webmapper.QuestionToService(request))
|
||||
if err != nil {
|
||||
handleServiceError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, webmapper.QuestionToWeb(value))
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateQuestion(w http.ResponseWriter, r *http.Request) {
|
||||
var request webmodel.QuestionRequest
|
||||
if err := decodeJSON(r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
value, err := h.questions.Update(r.Context(), idParam(r), webmapper.QuestionToService(request))
|
||||
if handleServiceError(w, r, err) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, webmapper.QuestionToWeb(value))
|
||||
}
|
||||
|
||||
func (h *Handler) SetQuestionDeprecated(w http.ResponseWriter, r *http.Request) {
|
||||
var request webmodel.QuestionStatusRequest
|
||||
if err := decodeJSON(r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
value, err := h.questions.SetDeprecated(r.Context(), idParam(r), request.Deprecated)
|
||||
if handleServiceError(w, r, err) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, webmapper.QuestionToWeb(value))
|
||||
}
|
||||
|
||||
func (h *Handler) AttachQuestion(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.jobs.AttachQuestion(r.Context(), idParam(r), secondaryIDParam(r, "questionId")); handleServiceError(w, r, err) {
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) DetachQuestion(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.jobs.DetachQuestion(r.Context(), idParam(r), secondaryIDParam(r, "questionId")); handleServiceError(w, r, err) {
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) Candidates(w http.ResponseWriter, r *http.Request) {
|
||||
values, err := h.jobs.Candidates(r.Context(), idParam(r))
|
||||
if handleServiceError(w, r, err) {
|
||||
return
|
||||
}
|
||||
response := make([]webmodel.CandidateResponse, len(values))
|
||||
for i, value := range values {
|
||||
response[i] = webmapper.CandidateToWeb(value)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (h *Handler) ListParticipants(w http.ResponseWriter, r *http.Request) {
|
||||
values, err := h.participants.List(r.Context())
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
response := make([]webmodel.ParticipantResponse, len(values))
|
||||
for i, value := range values {
|
||||
response[i] = webmapper.ParticipantToWeb(value)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (h *Handler) GetParticipant(w http.ResponseWriter, r *http.Request) {
|
||||
value, err := h.participants.Get(r.Context(), idParam(r))
|
||||
if handleServiceError(w, r, err) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, webmapper.ParticipantToWeb(value))
|
||||
}
|
||||
|
||||
func (h *Handler) CreateParticipant(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, h.maxUpload)
|
||||
if err := r.ParseMultipartForm(h.maxUpload); err != nil {
|
||||
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid multipart form or upload too large"))
|
||||
return
|
||||
}
|
||||
participant := servicemodel.Participant{
|
||||
FirstName: r.FormValue("firstName"), LastName: r.FormValue("lastName"),
|
||||
Email: r.FormValue("email"), ContactInfo: r.FormValue("contactInfo"),
|
||||
}
|
||||
if raw := r.FormValue("birthday"); raw != "" {
|
||||
birthday, err := time.Parse("2006-01-02", raw)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, fmt.Errorf("birthday must use YYYY-MM-DD"))
|
||||
return
|
||||
}
|
||||
participant.Birthday = &birthday
|
||||
}
|
||||
var err error
|
||||
participant.Photo, participant.PhotoType, _, err = readUpload(r, "photo")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
participant.CV, _, participant.CVFilename, err = readUpload(r, "cv")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
value, err := h.participants.Create(r.Context(), participant)
|
||||
if err != nil {
|
||||
handleServiceError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, webmapper.ParticipantToWeb(value))
|
||||
}
|
||||
|
||||
func (h *Handler) ParticipantPhoto(w http.ResponseWriter, r *http.Request) {
|
||||
value, err := h.participants.Get(r.Context(), idParam(r))
|
||||
if handleServiceError(w, r, err) {
|
||||
return
|
||||
}
|
||||
if len(value.Photo) == 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
contentType := value.PhotoType
|
||||
switch contentType {
|
||||
case "image/jpeg", "image/png", "image/gif", "image/webp":
|
||||
default:
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Disposition", "inline")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
_, _ = w.Write(value.Photo)
|
||||
}
|
||||
|
||||
func (h *Handler) ParticipantCV(w http.ResponseWriter, r *http.Request) {
|
||||
value, err := h.participants.Get(r.Context(), idParam(r))
|
||||
if handleServiceError(w, r, err) {
|
||||
return
|
||||
}
|
||||
if len(value.CV) == 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", value.CVFilename))
|
||||
_, _ = w.Write(value.CV)
|
||||
}
|
||||
|
||||
func (h *Handler) ListInvitations(w http.ResponseWriter, r *http.Request) {
|
||||
values, err := h.invitations.List(r.Context(), idParam(r))
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
response := make([]webmodel.InvitationResponse, len(values))
|
||||
for i, value := range values {
|
||||
response[i] = webmapper.InvitationToWeb(value, h.frontendURL)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateInvitation(w http.ResponseWriter, r *http.Request) {
|
||||
var request webmodel.InvitationRequest
|
||||
if err := decodeJSON(r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
value, err := h.invitations.Create(r.Context(), idParam(r), request.ParticipantID)
|
||||
if handleServiceError(w, r, err) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, webmapper.InvitationToWeb(value, h.frontendURL))
|
||||
}
|
||||
|
||||
func (h *Handler) GetInterview(w http.ResponseWriter, r *http.Request) {
|
||||
value, err := h.invitations.GetInterview(r.Context(), chi.URLParam(r, "token"))
|
||||
if handleServiceError(w, r, err) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, webmapper.InterviewToWeb(value, h.frontendURL))
|
||||
}
|
||||
|
||||
func (h *Handler) AcceptInterview(w http.ResponseWriter, r *http.Request) {
|
||||
value, err := h.invitations.Accept(r.Context(), chi.URLParam(r, "token"))
|
||||
if handleServiceError(w, r, err) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, webmapper.InterviewToWeb(value, h.frontendURL))
|
||||
}
|
||||
|
||||
func (h *Handler) SaveAnswer(w http.ResponseWriter, r *http.Request) {
|
||||
var request webmodel.AnswerRequest
|
||||
if err := decodeJSON(r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
err := h.invitations.SaveAnswer(
|
||||
r.Context(), chi.URLParam(r, "token"), secondaryIDParam(r, "questionId"), request.Answer,
|
||||
)
|
||||
if handleServiceError(w, r, err) {
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) FinishInterview(w http.ResponseWriter, r *http.Request) {
|
||||
value, err := h.invitations.Finish(r.Context(), chi.URLParam(r, "token"))
|
||||
if handleServiceError(w, r, err) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, webmapper.InterviewToWeb(value, h.frontendURL))
|
||||
}
|
||||
|
||||
func readUpload(r *http.Request, field string) ([]byte, string, string, error) {
|
||||
file, header, err := r.FormFile(field)
|
||||
if errors.Is(err, http.ErrMissingFile) {
|
||||
return nil, "", "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", "", fmt.Errorf("read %s: %w", field, err)
|
||||
}
|
||||
defer file.Close()
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return nil, "", "", fmt.Errorf("read %s: %w", field, err)
|
||||
}
|
||||
return content, header.Header.Get("Content-Type"), safeFilename(header), nil
|
||||
}
|
||||
|
||||
func safeFilename(header *multipart.FileHeader) string {
|
||||
return strings.ReplaceAll(strings.ReplaceAll(header.Filename, "/", "_"), "\\", "_")
|
||||
}
|
||||
|
||||
func idParam(r *http.Request) uint {
|
||||
id, _ := strconv.ParseUint(chi.URLParam(r, "id"), 10, 64)
|
||||
return uint(id)
|
||||
}
|
||||
|
||||
func secondaryIDParam(r *http.Request, name string) uint {
|
||||
id, _ := strconv.ParseUint(chi.URLParam(r, name), 10, 64)
|
||||
return uint(id)
|
||||
}
|
||||
|
||||
func decodeJSON(r *http.Request, target any) error {
|
||||
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return fmt.Errorf("invalid request body: %w", err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
return fmt.Errorf("request body must contain one JSON value")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleServiceError(w http.ResponseWriter, r *http.Request, err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
switch {
|
||||
case errors.Is(err, repository.ErrNotFound):
|
||||
writeError(w, http.StatusNotFound, err)
|
||||
case errors.Is(err, repository.ErrConflict):
|
||||
writeError(w, http.StatusConflict, err)
|
||||
case errors.Is(err, service.ErrValidation), errors.Is(err, service.ErrInvalidQuestion):
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
case errors.Is(err, service.ErrInvalidState):
|
||||
writeError(w, http.StatusConflict, err)
|
||||
case errors.Is(err, service.ErrNotAvailable):
|
||||
writeError(w, http.StatusNotFound, err)
|
||||
default:
|
||||
internalError(w, r, err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func internalError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
slog.ErrorContext(r.Context(), "request failed", "method", r.Method, "path", r.URL.Path, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, errors.New("internal server error"))
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, err error) {
|
||||
writeJSON(w, status, webmodel.ErrorResponse{Error: err.Error()})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(value); err != nil {
|
||||
slog.Error("encode response", "error", err)
|
||||
}
|
||||
}
|
||||
116
backend/internal/web/mapper/mapper.go
Normal file
116
backend/internal/web/mapper/mapper.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package mapper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
servicemodel "github.com/danyel/go-guess/backend/internal/service/model"
|
||||
webmodel "github.com/danyel/go-guess/backend/internal/web/model"
|
||||
)
|
||||
|
||||
func JobToService(value webmodel.JobRequest) servicemodel.JobPosting {
|
||||
return servicemodel.JobPosting{
|
||||
Title: value.Title, Description: value.Description, Seniority: value.Seniority,
|
||||
Position: value.Position, DurationMinutes: value.DurationMinutes,
|
||||
Labels: traitsToService(value.Labels), RequiredSkills: traitsToService(value.RequiredSkills),
|
||||
AdditionalSkills: traitsToService(value.AdditionalSkills),
|
||||
}
|
||||
}
|
||||
|
||||
func QuestionToService(value webmodel.QuestionRequest) servicemodel.Question {
|
||||
return servicemodel.Question{
|
||||
Text: value.Text, Type: value.Type, Options: value.Options,
|
||||
ReferenceAnswer: value.ReferenceAnswer,
|
||||
}
|
||||
}
|
||||
|
||||
func JobToWeb(value servicemodel.JobPosting) webmodel.JobResponse {
|
||||
questions := make([]webmodel.QuestionResponse, len(value.Questions))
|
||||
for i, question := range value.Questions {
|
||||
questions[i] = QuestionToWeb(question)
|
||||
}
|
||||
return webmodel.JobResponse{
|
||||
ID: value.ID, Title: value.Title, Description: value.Description,
|
||||
Seniority: value.Seniority, Position: value.Position, Status: value.Status,
|
||||
DurationMinutes: value.DurationMinutes,
|
||||
Labels: traitsToWeb(value.Labels), RequiredSkills: traitsToWeb(value.RequiredSkills),
|
||||
AdditionalSkills: traitsToWeb(value.AdditionalSkills), Questions: questions,
|
||||
CreatedAt: value.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func QuestionToWeb(value servicemodel.Question) webmodel.QuestionResponse {
|
||||
return webmodel.QuestionResponse{
|
||||
ID: value.ID, Text: value.Text, Type: value.Type, Options: value.Options,
|
||||
ReferenceAnswer: value.ReferenceAnswer, Deprecated: value.Deprecated,
|
||||
}
|
||||
}
|
||||
|
||||
func ParticipantToWeb(value servicemodel.Participant) webmodel.ParticipantResponse {
|
||||
result := webmodel.ParticipantResponse{
|
||||
ID: value.ID, FirstName: value.FirstName, LastName: value.LastName,
|
||||
Email: value.Email, ContactInfo: value.ContactInfo, CVFilename: value.CVFilename,
|
||||
Traits: traitsToWeb(value.Traits), CreatedAt: value.CreatedAt,
|
||||
}
|
||||
if value.Birthday != nil {
|
||||
birthday := value.Birthday.Format("2006-01-02")
|
||||
result.Birthday = &birthday
|
||||
}
|
||||
if len(value.Photo) > 0 {
|
||||
result.PhotoURL = fmt.Sprintf("/api/participants/%d/photo", value.ID)
|
||||
}
|
||||
if len(value.CV) > 0 {
|
||||
result.CVURL = fmt.Sprintf("/api/participants/%d/cv", value.ID)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func CandidateToWeb(value servicemodel.CandidateMatch) webmodel.CandidateResponse {
|
||||
return webmodel.CandidateResponse{
|
||||
Participant: ParticipantToWeb(value.Participant), Score: value.Score,
|
||||
Matched: value.Matched, Missing: value.Missing,
|
||||
}
|
||||
}
|
||||
|
||||
func InvitationToWeb(value servicemodel.Invitation, frontendURL string) webmodel.InvitationResponse {
|
||||
return webmodel.InvitationResponse{
|
||||
ID: value.ID, JobID: value.JobID, ParticipantID: value.ParticipantID,
|
||||
ParticipantName: value.ParticipantName, ParticipantEmail: value.ParticipantEmail,
|
||||
Token: value.Token, Status: value.Status,
|
||||
ParticipantURL: frontendURL + "/interview/" + value.Token,
|
||||
AcceptedAt: value.AcceptedAt, CompletedAt: value.CompletedAt, CreatedAt: value.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func InterviewToWeb(value servicemodel.Interview, frontendURL string) webmodel.InterviewResponse {
|
||||
questions := make([]webmodel.InterviewQuestionResponse, len(value.Job.Questions))
|
||||
for i, question := range value.Job.Questions {
|
||||
questions[i] = webmodel.InterviewQuestionResponse{
|
||||
ID: question.ID, Text: question.Text, Type: question.Type, Options: question.Options,
|
||||
}
|
||||
}
|
||||
return webmodel.InterviewResponse{
|
||||
Invitation: InvitationToWeb(value.Invitation, frontendURL),
|
||||
Job: webmodel.InterviewJobResponse{
|
||||
ID: value.Job.ID, Title: value.Job.Title, Description: value.Job.Description,
|
||||
Position: value.Job.Position, Seniority: value.Job.Seniority,
|
||||
DurationMinutes: value.Job.DurationMinutes, Questions: questions,
|
||||
},
|
||||
Answers: value.Answers,
|
||||
}
|
||||
}
|
||||
|
||||
func traitsToService(values []string) []servicemodel.Trait {
|
||||
result := make([]servicemodel.Trait, len(values))
|
||||
for i, value := range values {
|
||||
result[i] = servicemodel.Trait{Name: value}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func traitsToWeb(values []servicemodel.Trait) []string {
|
||||
result := make([]string, len(values))
|
||||
for i, value := range values {
|
||||
result[i] = value.Name
|
||||
}
|
||||
return result
|
||||
}
|
||||
140
backend/internal/web/model/models.go
Normal file
140
backend/internal/web/model/models.go
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
User User `json:"user"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID uint `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type JobRequest struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Seniority string `json:"seniority"`
|
||||
Position string `json:"position"`
|
||||
DurationMinutes int `json:"durationMinutes"`
|
||||
Labels []string `json:"labels"`
|
||||
RequiredSkills []string `json:"requiredSkills"`
|
||||
AdditionalSkills []string `json:"additionalSkills"`
|
||||
}
|
||||
|
||||
type JobResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Seniority string `json:"seniority"`
|
||||
Position string `json:"position"`
|
||||
Status string `json:"status"`
|
||||
DurationMinutes int `json:"durationMinutes"`
|
||||
Labels []string `json:"labels"`
|
||||
RequiredSkills []string `json:"requiredSkills"`
|
||||
AdditionalSkills []string `json:"additionalSkills"`
|
||||
Questions []QuestionResponse `json:"questions"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type QuestionRequest struct {
|
||||
Text string `json:"text"`
|
||||
Type string `json:"type"`
|
||||
Options []string `json:"options"`
|
||||
ReferenceAnswer string `json:"referenceAnswer"`
|
||||
}
|
||||
|
||||
type QuestionResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Type string `json:"type"`
|
||||
Options []string `json:"options"`
|
||||
ReferenceAnswer string `json:"referenceAnswer"`
|
||||
Deprecated bool `json:"deprecated"`
|
||||
}
|
||||
|
||||
type QuestionStatusRequest struct {
|
||||
Deprecated bool `json:"deprecated"`
|
||||
}
|
||||
|
||||
type JobStatusRequest struct {
|
||||
Status string `json:"status"`
|
||||
DurationMinutes int `json:"durationMinutes"`
|
||||
}
|
||||
|
||||
type InvitationRequest struct {
|
||||
ParticipantID uint `json:"participantId"`
|
||||
}
|
||||
|
||||
type ParticipantResponse struct {
|
||||
ID uint `json:"id"`
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
Birthday *string `json:"birthday,omitempty"`
|
||||
Email string `json:"email"`
|
||||
ContactInfo string `json:"contactInfo"`
|
||||
PhotoURL string `json:"photoUrl,omitempty"`
|
||||
CVFilename string `json:"cvFilename,omitempty"`
|
||||
CVURL string `json:"cvUrl,omitempty"`
|
||||
Traits []string `json:"traits"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type CandidateResponse struct {
|
||||
Participant ParticipantResponse `json:"participant"`
|
||||
Score float64 `json:"score"`
|
||||
Matched []string `json:"matchedTraits"`
|
||||
Missing []string `json:"missingTraits"`
|
||||
}
|
||||
|
||||
type InvitationResponse struct {
|
||||
ID uint `json:"id"`
|
||||
JobID uint `json:"jobId"`
|
||||
ParticipantID uint `json:"participantId"`
|
||||
ParticipantName string `json:"participantName"`
|
||||
ParticipantEmail string `json:"participantEmail"`
|
||||
Token string `json:"token"`
|
||||
Status string `json:"status"`
|
||||
ParticipantURL string `json:"participantUrl"`
|
||||
AcceptedAt *time.Time `json:"acceptedAt,omitempty"`
|
||||
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type InterviewJobResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Position string `json:"position"`
|
||||
Seniority string `json:"seniority"`
|
||||
DurationMinutes int `json:"durationMinutes"`
|
||||
Questions []InterviewQuestionResponse `json:"questions"`
|
||||
}
|
||||
|
||||
type InterviewQuestionResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Type string `json:"type"`
|
||||
Options []string `json:"options"`
|
||||
}
|
||||
|
||||
type InterviewResponse struct {
|
||||
Invitation InvitationResponse `json:"invitation"`
|
||||
Job InterviewJobResponse `json:"job"`
|
||||
Answers map[uint]string `json:"answers"`
|
||||
}
|
||||
|
||||
type AnswerRequest struct {
|
||||
Answer string `json:"answer"`
|
||||
}
|
||||
93
backend/internal/web/router/router.go
Normal file
93
backend/internal/web/router/router.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"github.com/danyel/go-guess/backend/internal/security"
|
||||
"github.com/danyel/go-guess/backend/internal/web/handler"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const claimsKey contextKey = "claims"
|
||||
|
||||
func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string) http.Handler {
|
||||
router := chi.NewRouter()
|
||||
router.Use(middleware.RequestID, middleware.RealIP, middleware.Recoverer, middleware.Compress(5))
|
||||
router.Use(cors(frontendURL))
|
||||
router.Get("/api/health", h.Health)
|
||||
router.Post("/api/auth/login", h.Login)
|
||||
router.Get("/api/interviews/{token}", h.GetInterview)
|
||||
router.Post("/api/interviews/{token}/accept", h.AcceptInterview)
|
||||
router.Put("/api/interviews/{token}/answers/{questionId}", h.SaveAnswer)
|
||||
router.Post("/api/interviews/{token}/finish", h.FinishInterview)
|
||||
router.Group(func(protected chi.Router) {
|
||||
protected.Use(authenticate(tokens))
|
||||
protected.Get("/api/jobs", h.ListJobs)
|
||||
protected.Post("/api/jobs", h.CreateJob)
|
||||
protected.Get("/api/jobs/{id}", h.GetJob)
|
||||
protected.Patch("/api/jobs/{id}", h.UpdateJob)
|
||||
protected.Post("/api/jobs/{id}/questions/{questionId}", h.AttachQuestion)
|
||||
protected.Delete("/api/jobs/{id}/questions/{questionId}", h.DetachQuestion)
|
||||
protected.Get("/api/jobs/{id}/candidates", h.Candidates)
|
||||
protected.Get("/api/jobs/{id}/invitations", h.ListInvitations)
|
||||
protected.Post("/api/jobs/{id}/invitations", h.CreateInvitation)
|
||||
protected.Get("/api/questions", h.ListQuestions)
|
||||
protected.Post("/api/questions", h.CreateQuestion)
|
||||
protected.Put("/api/questions/{id}", h.UpdateQuestion)
|
||||
protected.Patch("/api/questions/{id}", h.SetQuestionDeprecated)
|
||||
protected.Get("/api/participants", h.ListParticipants)
|
||||
protected.Post("/api/participants", h.CreateParticipant)
|
||||
protected.Get("/api/participants/{id}", h.GetParticipant)
|
||||
protected.Get("/api/participants/{id}/photo", h.ParticipantPhoto)
|
||||
protected.Get("/api/participants/{id}/cv", h.ParticipantCV)
|
||||
})
|
||||
return router
|
||||
}
|
||||
|
||||
func writeUnauthorized(w http.ResponseWriter, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
|
||||
}
|
||||
|
||||
func authenticate(tokens security.ITokenManager) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
scheme, raw, ok := strings.Cut(r.Header.Get("Authorization"), " ")
|
||||
if !ok || !strings.EqualFold(scheme, "Bearer") {
|
||||
writeUnauthorized(w, "missing bearer token")
|
||||
return
|
||||
}
|
||||
claims, err := tokens.Parse(raw)
|
||||
if err != nil {
|
||||
writeUnauthorized(w, "invalid bearer token")
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), claimsKey, claims)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func cors(origin string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
w.Header().Add("Vary", "Origin")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
112
backend/internal/web/router/router_test.go
Normal file
112
backend/internal/web/router/router_test.go
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/danyel/go-guess/backend/internal/security"
|
||||
"github.com/danyel/go-guess/backend/internal/service"
|
||||
"github.com/danyel/go-guess/backend/internal/service/model"
|
||||
"github.com/danyel/go-guess/backend/internal/web/handler"
|
||||
)
|
||||
|
||||
type authStub struct{}
|
||||
|
||||
func (authStub) Login(context.Context, string, string) (string, model.User, error) {
|
||||
return "", model.User{}, service.ErrInvalidCredentials
|
||||
}
|
||||
|
||||
type jobStub struct{}
|
||||
|
||||
func (jobStub) List(context.Context) ([]model.JobPosting, error) { return []model.JobPosting{}, nil }
|
||||
func (jobStub) Get(context.Context, uint) (model.JobPosting, error) { return model.JobPosting{}, nil }
|
||||
func (jobStub) Create(context.Context, model.JobPosting) (model.JobPosting, error) {
|
||||
return model.JobPosting{}, nil
|
||||
}
|
||||
func (jobStub) Update(context.Context, uint, string, int) (model.JobPosting, error) {
|
||||
return model.JobPosting{}, nil
|
||||
}
|
||||
func (jobStub) AttachQuestion(context.Context, uint, uint) error { return nil }
|
||||
func (jobStub) DetachQuestion(context.Context, uint, uint) error { return nil }
|
||||
func (jobStub) Candidates(context.Context, uint) ([]model.CandidateMatch, error) {
|
||||
return []model.CandidateMatch{}, nil
|
||||
}
|
||||
|
||||
type participantStub struct{}
|
||||
|
||||
func (participantStub) List(context.Context) ([]model.Participant, error) { return nil, nil }
|
||||
func (participantStub) Get(context.Context, uint) (model.Participant, error) {
|
||||
return model.Participant{}, nil
|
||||
}
|
||||
func (participantStub) Create(context.Context, model.Participant) (model.Participant, error) {
|
||||
return model.Participant{}, nil
|
||||
}
|
||||
|
||||
type questionStub struct{}
|
||||
|
||||
func (questionStub) List(context.Context, string) ([]model.Question, error) { return nil, nil }
|
||||
func (questionStub) Create(context.Context, model.Question) (model.Question, error) {
|
||||
return model.Question{}, nil
|
||||
}
|
||||
func (questionStub) Update(context.Context, uint, model.Question) (model.Question, error) {
|
||||
return model.Question{}, nil
|
||||
}
|
||||
func (questionStub) SetDeprecated(context.Context, uint, bool) (model.Question, error) {
|
||||
return model.Question{}, nil
|
||||
}
|
||||
|
||||
type invitationStub struct{}
|
||||
|
||||
func (invitationStub) List(context.Context, uint) ([]model.Invitation, error) { return nil, nil }
|
||||
func (invitationStub) Create(context.Context, uint, uint) (model.Invitation, error) {
|
||||
return model.Invitation{}, nil
|
||||
}
|
||||
func (invitationStub) GetInterview(context.Context, string) (model.Interview, error) {
|
||||
return model.Interview{}, nil
|
||||
}
|
||||
func (invitationStub) Accept(context.Context, string) (model.Interview, error) {
|
||||
return model.Interview{}, nil
|
||||
}
|
||||
func (invitationStub) SaveAnswer(context.Context, string, uint, string) error { return nil }
|
||||
func (invitationStub) Finish(context.Context, string) (model.Interview, error) {
|
||||
return model.Interview{}, nil
|
||||
}
|
||||
|
||||
func TestHealthDoesNotRequireAuthentication(t *testing.T) {
|
||||
h := handler.New(authStub{}, jobStub{}, questionStub{}, participantStub{}, invitationStub{}, 10, "http://localhost:5173")
|
||||
tokens := security.NewTokenManager("01234567890123456789012345678901", time.Hour)
|
||||
router := New(h, tokens, "http://localhost:5173")
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectedRouteRequiresValidToken(t *testing.T) {
|
||||
h := handler.New(authStub{}, jobStub{}, questionStub{}, participantStub{}, invitationStub{}, 10, "http://localhost:5173")
|
||||
tokens := security.NewTokenManager("01234567890123456789012345678901", time.Hour)
|
||||
router := New(h, tokens, "http://localhost:5173")
|
||||
|
||||
unauthorized := httptest.NewRecorder()
|
||||
router.ServeHTTP(unauthorized, httptest.NewRequest(http.MethodGet, "/api/jobs", nil))
|
||||
if unauthorized.Code != http.StatusUnauthorized || unauthorized.Header().Get("Content-Type") != "application/json" {
|
||||
t.Fatalf("expected JSON 401, got %d %q", unauthorized.Code, unauthorized.Header().Get("Content-Type"))
|
||||
}
|
||||
|
||||
token, err := tokens.Issue(1, "interviewer@example.com", "interviewer")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/jobs", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
authorized := httptest.NewRecorder()
|
||||
router.ServeHTTP(authorized, request)
|
||||
if authorized.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", authorized.Code)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue