initial commit
This commit is contained in:
commit
6ffc0d2c99
54 changed files with 11900 additions and 0 deletions
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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue