go-guess/backend/internal/service/services.go
Daniel Noulet ca5765b988 feat: integrate tenant SSO and remote toggle
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-03 00:46:27 +02:00

593 lines
19 KiB
Go

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")
ErrForbidden = errors.New("operation is forbidden")
)
const CandidateThreshold = 60.0
type IAuthService interface {
Login(context.Context, string, string) (string, model.User, error)
EstablishExternalSession(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)
Update(context.Context, model.Participant) (model.Participant, error)
UpdateTraits(context.Context, uint) error
}
type IInvitationService interface {
List(context.Context, uint) ([]model.Invitation, error)
Create(context.Context, uint, uint) (model.Invitation, error)
Review(context.Context, uint, uint) (model.Interview, 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)
SetOutcome(context.Context, uint, uint, string) (model.Invitation, 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
}
func (s *AuthService) EstablishExternalSession(ctx context.Context, email, displayName string) (string, model.User, error) {
email = strings.ToLower(strings.TrimSpace(email))
displayName = strings.TrimSpace(displayName)
if email == "" {
return "", model.User{}, ErrValidation
}
user, err := s.store.FindUserByEmail(ctx, email)
if errors.Is(err, repository.ErrNotFound) {
if displayName == "" {
displayName = email
}
hash, hashErr := security.HashPassword(uuid.NewString())
if hashErr != nil {
return "", model.User{}, hashErr
}
user, err = s.store.CreateUser(ctx, model.User{
Email: email, DisplayName: displayName, Role: "interviewer", PasswordHash: hash,
})
}
if err != nil {
return "", model.User{}, err
}
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)
}
created, err := s.store.CreateJob(ctx, value)
if err != nil {
return model.JobPosting{}, err
}
if err := refreshParticipantTraits(ctx, s.store); err != nil {
return model.JobPosting{}, fmt.Errorf("refresh participant traits: %w", err)
}
return created, nil
}
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)
}
}
updated, err := s.store.UpdateJob(ctx, id, status, durationMinutes)
if err != nil {
return model.JobPosting{}, err
}
if err := refreshParticipantTraits(ctx, s.store); err != nil {
return model.JobPosting{}, fmt.Errorf("refresh participant traits: %w", err)
}
return updated, nil
}
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.CodeSnippet = strings.TrimSpace(value.CodeSnippet)
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 == "code_review" && value.CodeSnippet == "" {
return model.Question{}, fmt.Errorf("%w: a code snippet is required", ErrInvalidQuestion)
}
if value.Type == "open" || value.Type == "code_review" {
value.Options = nil
} else {
value.ReferenceAnswer = ""
}
if value.Type != "code_review" {
value.CodeSnippet = ""
}
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 (s *ParticipantService) Update(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.UpdateParticipant(ctx, value)
}
func (s *ParticipantService) UpdateTraits(ctx context.Context, id uint) error {
participant, err := s.store.GetParticipant(ctx, id)
if err != nil {
return err
}
_, err = s.Update(ctx, participant)
return err
}
func refreshParticipantTraits(ctx context.Context, store repository.IStore) error {
participants, err := store.ListParticipants(ctx)
if err != nil {
return err
}
traits, err := store.AllTraits(ctx)
if err != nil {
return err
}
for _, participant := range participants {
text, err := extractCVText(participant.CVFilename, participant.CV)
if err != nil {
return fmt.Errorf("extract participant %d CV text: %w", participant.ID, err)
}
participant.Traits = extractTraits(text, traits)
if _, err := store.UpdateParticipant(ctx, participant); err != nil {
return fmt.Errorf("update participant %d traits: %w", participant.ID, err)
}
}
return nil
}
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) Review(ctx context.Context, jobID, invitationID uint) (model.Interview, error) {
invitations, err := s.store.ListInvitations(ctx, jobID)
if err != nil {
return model.Interview{}, err
}
for _, invitation := range invitations {
if invitation.ID == invitationID {
return s.store.GetInterview(ctx, invitation.Token)
}
}
return model.Interview{}, repository.ErrNotFound
}
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 (s *InvitationService) SetOutcome(
ctx context.Context, jobID, invitationID uint, outcome string,
) (model.Invitation, error) {
switch outcome {
case "pending":
case "passed", "failed":
invitation, err := s.store.GetInvitation(ctx, jobID, invitationID)
if err != nil {
return model.Invitation{}, err
}
if invitation.Status != "completed" {
return model.Invitation{}, fmt.Errorf("%w: assessment must be completed", ErrInvalidState)
}
default:
return model.Invitation{}, fmt.Errorf("%w: unsupported assessment outcome", ErrValidation)
}
return s.store.UpdateInvitationOutcome(ctx, jobID, invitationID, outcome)
}
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
}
}