feat: multi tenant support

This commit is contained in:
Daniel Noulet 2026-10-01 09:06:58 +02:00
parent a660c591ff
commit 30e3391adb
17 changed files with 271 additions and 145 deletions

View file

@ -48,13 +48,13 @@ db-down:
docker compose down docker compose down
migrate: migrate:
$(GOOSE) -dir migrations postgres "$(DATABASE_URL)" up $(GOOSE) -dir migrations postgres "host=localhost user=go_guess password=go_guess dbname=go_guess port=5432 sslmode=disable search_path=ypto" up
migrate-down: migrate-down:
$(GOOSE) -dir migrations postgres "$(DATABASE_URL)" down $(GOOSE) -dir migrations postgres "host=localhost user=go_guess password=go_guess dbname=go_guess port=5432 sslmode=disable search_path=ypto" down
seed: seed:
cd backend && DATABASE_URL="$(DATABASE_URL)" APP_ENV="$${APP_ENV:-development}" go run ./cmd/seed cd backend && DATABASE_URL="$(DATABASE_URL)&search_path=nmbs,public" APP_ENV="$${APP_ENV:-development}" go run ./cmd/seed
backend: backend:
cd backend && DATABASE_URL="$(DATABASE_URL)" JWT_SECRET="$(JWT_SECRET)" go run ./cmd/api cd backend && DATABASE_URL="$(DATABASE_URL)" JWT_SECRET="$(JWT_SECRET)" go run ./cmd/api

View file

@ -50,6 +50,7 @@ func main() {
interviews := service.NewScheduledInterviewService(store, bus) interviews := service.NewScheduledInterviewService(store, bus)
api := router.New( api := router.New(
handler.New( handler.New(
db,
service.NewAuthService(store, tokens), service.NewAuthService(store, tokens),
service.NewJobService(store), service.NewJobService(store),
service.NewQuestionService(store), service.NewQuestionService(store),

View file

@ -28,7 +28,7 @@ func Load() (Config, error) {
} }
cfg := Config{ cfg := Config{
Address: env("ADDRESS", ":8080"), Address: env("ADDRESS", ":8080"),
DatabaseURL: env("DATABASE_URL", "postgres://go_guess:go_guess@localhost:5432/go_guess?sslmode=disable"), DatabaseURL: env("DATABASE_URL", "postgres://go_guess:go_guess@localhost:5432/?sslmode=disable"),
JWTSecret: os.Getenv("JWT_SECRET"), JWTSecret: os.Getenv("JWT_SECRET"),
TokenTTL: ttl, TokenTTL: ttl,
MaxUploadMB: maxUploadMB, MaxUploadMB: maxUploadMB,

View file

@ -9,7 +9,9 @@ import (
dbmodel "github.com/danyel/go-guess/backend/internal/database/entity" dbmodel "github.com/danyel/go-guess/backend/internal/database/entity"
dbmapper "github.com/danyel/go-guess/backend/internal/database/mapper" dbmapper "github.com/danyel/go-guess/backend/internal/database/mapper"
servicemodel "github.com/danyel/go-guess/backend/internal/service/model" servicemodel "github.com/danyel/go-guess/backend/internal/service/model"
"github.com/danyel/go-guess/backend/internal/web/middleware"
"github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgconn"
"github.com/testcontainers/testcontainers-go/log"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
@ -61,16 +63,27 @@ type IStore interface {
} }
type Store struct { type Store struct {
db *gorm.DB d *gorm.DB
} }
func New(db *gorm.DB) *Store { func New(db *gorm.DB) *Store {
return &Store{db: db} return &Store{d: db}
}
func (s *Store) db(ctx context.Context) *gorm.DB {
// Vervang 'yourproject/middleware' door het echte importpad naar je middleware
if tx, ok := ctx.Value(middleware.TxKey).(*gorm.DB); ok {
log.Printf("Using tenant-isolated database schema transaction")
return tx.WithContext(ctx)
}
log.Printf("WARNING: Falling back to global public database connection!")
return s.d.WithContext(ctx)
} }
func (s *Store) FindUserByEmail(ctx context.Context, email string) (servicemodel.User, error) { func (s *Store) FindUserByEmail(ctx context.Context, email string) (servicemodel.User, error) {
var value dbmodel.User var value dbmodel.User
if err := s.db.WithContext(ctx).Where("lower(email) = ?", strings.ToLower(email)).First(&value).Error; err != nil { if err := s.db(ctx).Where("lower(email) = ?", strings.ToLower(email)).First(&value).Error; err != nil {
return servicemodel.User{}, mapError(err) return servicemodel.User{}, mapError(err)
} }
return dbmapper.UserToService(value), nil return dbmapper.UserToService(value), nil
@ -78,7 +91,7 @@ func (s *Store) FindUserByEmail(ctx context.Context, email string) (servicemodel
func (s *Store) ListUsers(ctx context.Context) ([]servicemodel.User, error) { func (s *Store) ListUsers(ctx context.Context) ([]servicemodel.User, error) {
var values []dbmodel.User var values []dbmodel.User
if err := s.db.WithContext(ctx).Order("display_name, email").Find(&values).Error; err != nil { if err := s.db(ctx).Order("display_name, email").Find(&values).Error; err != nil {
return nil, err return nil, err
} }
result := make([]servicemodel.User, len(values)) result := make([]servicemodel.User, len(values))
@ -94,7 +107,7 @@ func (s *Store) CreateUser(ctx context.Context, value servicemodel.User) (servic
Email: value.Email, PasswordHash: value.PasswordHash, Email: value.Email, PasswordHash: value.PasswordHash,
DisplayName: value.DisplayName, Role: value.Role, DisplayName: value.DisplayName, Role: value.Role,
} }
if err := s.db.WithContext(ctx).Create(&entity).Error; err != nil { if err := s.db(ctx).Create(&entity).Error; err != nil {
return servicemodel.User{}, mapError(err) return servicemodel.User{}, mapError(err)
} }
result := dbmapper.UserToService(entity) result := dbmapper.UserToService(entity)
@ -104,7 +117,7 @@ func (s *Store) CreateUser(ctx context.Context, value servicemodel.User) (servic
func (s *Store) ListJobs(ctx context.Context) ([]servicemodel.JobPosting, error) { func (s *Store) ListJobs(ctx context.Context) ([]servicemodel.JobPosting, error) {
var values []dbmodel.JobPosting var values []dbmodel.JobPosting
if err := preloadJob(s.db.WithContext(ctx)).Order("created_at DESC").Find(&values).Error; err != nil { if err := preloadJob(s.db(ctx)).Order("created_at DESC").Find(&values).Error; err != nil {
return nil, err return nil, err
} }
result := make([]servicemodel.JobPosting, len(values)) result := make([]servicemodel.JobPosting, len(values))
@ -116,7 +129,7 @@ func (s *Store) ListJobs(ctx context.Context) ([]servicemodel.JobPosting, error)
func (s *Store) GetJob(ctx context.Context, id uint) (servicemodel.JobPosting, error) { func (s *Store) GetJob(ctx context.Context, id uint) (servicemodel.JobPosting, error) {
var value dbmodel.JobPosting var value dbmodel.JobPosting
if err := preloadJob(s.db.WithContext(ctx)).First(&value, id).Error; err != nil { if err := preloadJob(s.db(ctx)).First(&value, id).Error; err != nil {
return servicemodel.JobPosting{}, mapError(err) return servicemodel.JobPosting{}, mapError(err)
} }
return dbmapper.JobToService(value), nil return dbmapper.JobToService(value), nil
@ -140,14 +153,14 @@ func (s *Store) CreateJob(ctx context.Context, value servicemodel.JobPosting) (s
if err != nil { if err != nil {
return servicemodel.JobPosting{}, err return servicemodel.JobPosting{}, err
} }
if err := s.db.WithContext(ctx).Create(&entity).Error; err != nil { if err := s.db(ctx).Create(&entity).Error; err != nil {
return servicemodel.JobPosting{}, mapError(err) return servicemodel.JobPosting{}, mapError(err)
} }
return s.GetJob(ctx, entity.ID) return s.GetJob(ctx, entity.ID)
} }
func (s *Store) UpdateJob(ctx context.Context, id uint, status string, durationMinutes int) (servicemodel.JobPosting, error) { 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). result := s.db(ctx).Model(&dbmodel.JobPosting{}).Where("id = ?", id).
Updates(map[string]any{"status": status, "duration_minutes": durationMinutes}) Updates(map[string]any{"status": status, "duration_minutes": durationMinutes})
if result.Error != nil { if result.Error != nil {
return servicemodel.JobPosting{}, result.Error return servicemodel.JobPosting{}, result.Error
@ -159,7 +172,7 @@ func (s *Store) UpdateJob(ctx context.Context, id uint, status string, durationM
} }
func (s *Store) ListQuestions(ctx context.Context, search string) ([]servicemodel.Question, error) { func (s *Store) ListQuestions(ctx context.Context, search string) ([]servicemodel.Question, error) {
query := s.db.WithContext(ctx).Preload("Options").Order("deprecated, created_at DESC") query := s.db(ctx).Preload("Options").Order("deprecated, created_at DESC")
if search = strings.TrimSpace(search); search != "" { if search = strings.TrimSpace(search); search != "" {
query = query.Where("text ILIKE ?", "%"+search+"%") query = query.Where("text ILIKE ?", "%"+search+"%")
} }
@ -176,7 +189,7 @@ func (s *Store) ListQuestions(ctx context.Context, search string) ([]servicemode
func (s *Store) GetQuestion(ctx context.Context, id uint) (servicemodel.Question, error) { func (s *Store) GetQuestion(ctx context.Context, id uint) (servicemodel.Question, error) {
var value dbmodel.Question var value dbmodel.Question
if err := s.db.WithContext(ctx).Preload("Options").First(&value, id).Error; err != nil { if err := s.db(ctx).Preload("Options").First(&value, id).Error; err != nil {
return servicemodel.Question{}, mapError(err) return servicemodel.Question{}, mapError(err)
} }
return dbmapper.QuestionToService(value), nil return dbmapper.QuestionToService(value), nil
@ -192,14 +205,14 @@ func (s *Store) CreateQuestion(ctx context.Context, value servicemodel.Question)
CodeSnippet: value.CodeSnippet, ReferenceAnswer: value.ReferenceAnswer, CodeSnippet: value.CodeSnippet, ReferenceAnswer: value.ReferenceAnswer,
Deprecated: value.Deprecated, Deprecated: value.Deprecated,
} }
if err := s.db.WithContext(ctx).Create(&entity).Error; err != nil { if err := s.db(ctx).Create(&entity).Error; err != nil {
return servicemodel.Question{}, mapError(err) return servicemodel.Question{}, mapError(err)
} }
return s.GetQuestion(ctx, entity.ID) return s.GetQuestion(ctx, entity.ID)
} }
func (s *Store) UpdateQuestion(ctx context.Context, id uint, value servicemodel.Question) (servicemodel.Question, error) { 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 { err := s.db(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&dbmodel.Question{}).Where("id = ?", id). result := tx.Model(&dbmodel.Question{}).Where("id = ?", id).
Updates(map[string]any{ Updates(map[string]any{
"text": value.Text, "type": value.Type, "code_snippet": value.CodeSnippet, "text": value.Text, "type": value.Type, "code_snippet": value.CodeSnippet,
@ -230,7 +243,7 @@ func (s *Store) UpdateQuestion(ctx context.Context, id uint, value servicemodel.
} }
func (s *Store) SetQuestionDeprecated(ctx context.Context, id uint, deprecated bool) (servicemodel.Question, error) { 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). result := s.db(ctx).Model(&dbmodel.Question{}).Where("id = ?", id).
Update("deprecated", deprecated) Update("deprecated", deprecated)
if result.Error != nil { if result.Error != nil {
return servicemodel.Question{}, result.Error return servicemodel.Question{}, result.Error
@ -243,44 +256,44 @@ func (s *Store) SetQuestionDeprecated(ctx context.Context, id uint, deprecated b
func (s *Store) AttachQuestion(ctx context.Context, jobID, questionID uint) error { func (s *Store) AttachQuestion(ctx context.Context, jobID, questionID uint) error {
var job dbmodel.JobPosting var job dbmodel.JobPosting
if err := s.db.WithContext(ctx).First(&job, jobID).Error; err != nil { if err := s.db(ctx).First(&job, jobID).Error; err != nil {
return mapError(err) return mapError(err)
} }
var question dbmodel.Question var question dbmodel.Question
if err := s.db.WithContext(ctx).First(&question, questionID).Error; err != nil { if err := s.db(ctx).First(&question, questionID).Error; err != nil {
return mapError(err) return mapError(err)
} }
var invitations int64 var invitations int64
if err := s.db.WithContext(ctx).Model(&dbmodel.Invitation{}).Where("job_id = ?", jobID). if err := s.db(ctx).Model(&dbmodel.Invitation{}).Where("job_id = ?", jobID).
Count(&invitations).Error; err != nil { Count(&invitations).Error; err != nil {
return err return err
} }
if invitations > 0 { if invitations > 0 {
return ErrConflict return ErrConflict
} }
return mapError(s.db.WithContext(ctx).Model(&job).Association("Questions").Append(&question)) return mapError(s.db(ctx).Model(&job).Association("Questions").Append(&question))
} }
func (s *Store) DetachQuestion(ctx context.Context, jobID, questionID uint) error { func (s *Store) DetachQuestion(ctx context.Context, jobID, questionID uint) error {
var job dbmodel.JobPosting var job dbmodel.JobPosting
if err := s.db.WithContext(ctx).First(&job, jobID).Error; err != nil { if err := s.db(ctx).First(&job, jobID).Error; err != nil {
return mapError(err) return mapError(err)
} }
var invitations int64 var invitations int64
if err := s.db.WithContext(ctx).Model(&dbmodel.Invitation{}).Where("job_id = ?", jobID). if err := s.db(ctx).Model(&dbmodel.Invitation{}).Where("job_id = ?", jobID).
Count(&invitations).Error; err != nil { Count(&invitations).Error; err != nil {
return err return err
} }
if invitations > 0 { if invitations > 0 {
return ErrConflict return ErrConflict
} }
return s.db.WithContext(ctx).Model(&job).Association("Questions"). return s.db(ctx).Model(&job).Association("Questions").
Delete(&dbmodel.Question{ID: questionID}) Delete(&dbmodel.Question{ID: questionID})
} }
func (s *Store) ListParticipants(ctx context.Context) ([]servicemodel.Participant, error) { func (s *Store) ListParticipants(ctx context.Context) ([]servicemodel.Participant, error) {
var values []dbmodel.Participant var values []dbmodel.Participant
if err := s.db.WithContext(ctx).Preload("Traits").Order("last_name, first_name").Find(&values).Error; err != nil { if err := s.db(ctx).Preload("Traits").Order("last_name, first_name").Find(&values).Error; err != nil {
return nil, err return nil, err
} }
result := make([]servicemodel.Participant, len(values)) result := make([]servicemodel.Participant, len(values))
@ -292,7 +305,7 @@ func (s *Store) ListParticipants(ctx context.Context) ([]servicemodel.Participan
func (s *Store) GetParticipant(ctx context.Context, id uint) (servicemodel.Participant, error) { func (s *Store) GetParticipant(ctx context.Context, id uint) (servicemodel.Participant, error) {
var value dbmodel.Participant var value dbmodel.Participant
if err := s.db.WithContext(ctx).Preload("Traits").First(&value, id).Error; err != nil { if err := s.db(ctx).Preload("Traits").First(&value, id).Error; err != nil {
return servicemodel.Participant{}, mapError(err) return servicemodel.Participant{}, mapError(err)
} }
return dbmapper.ParticipantToService(value), nil return dbmapper.ParticipantToService(value), nil
@ -309,7 +322,7 @@ func (s *Store) CreateParticipant(ctx context.Context, value servicemodel.Partic
if err != nil { if err != nil {
return servicemodel.Participant{}, err return servicemodel.Participant{}, err
} }
if err := s.db.WithContext(ctx).Create(&entity).Error; err != nil { if err := s.db(ctx).Create(&entity).Error; err != nil {
return servicemodel.Participant{}, mapError(err) return servicemodel.Participant{}, mapError(err)
} }
return s.GetParticipant(ctx, entity.ID) return s.GetParticipant(ctx, entity.ID)
@ -317,7 +330,7 @@ func (s *Store) CreateParticipant(ctx context.Context, value servicemodel.Partic
func (s *Store) AllTraits(ctx context.Context) ([]servicemodel.Trait, error) { func (s *Store) AllTraits(ctx context.Context) ([]servicemodel.Trait, error) {
var values []dbmodel.Trait var values []dbmodel.Trait
if err := s.db.WithContext(ctx).Order("name").Find(&values).Error; err != nil { if err := s.db(ctx).Order("name").Find(&values).Error; err != nil {
return nil, err return nil, err
} }
result := make([]servicemodel.Trait, len(values)) result := make([]servicemodel.Trait, len(values))
@ -329,7 +342,7 @@ func (s *Store) AllTraits(ctx context.Context) ([]servicemodel.Trait, error) {
func (s *Store) ListInvitations(ctx context.Context, jobID uint) ([]servicemodel.Invitation, error) { func (s *Store) ListInvitations(ctx context.Context, jobID uint) ([]servicemodel.Invitation, error) {
var values []dbmodel.Invitation var values []dbmodel.Invitation
if err := s.db.WithContext(ctx).Preload("Participant").Where("job_id = ?", jobID). if err := s.db(ctx).Preload("Participant").Where("job_id = ?", jobID).
Order("created_at DESC").Find(&values).Error; err != nil { Order("created_at DESC").Find(&values).Error; err != nil {
return nil, err return nil, err
} }
@ -345,11 +358,11 @@ func (s *Store) CreateInvitation(ctx context.Context, value servicemodel.Invitat
JobID: value.JobID, ParticipantID: value.ParticipantID, JobID: value.JobID, ParticipantID: value.ParticipantID,
Token: value.Token, Status: value.Status, DurationMinutes: value.DurationMinutes, Token: value.Token, Status: value.Status, DurationMinutes: value.DurationMinutes,
} }
if err := s.db.WithContext(ctx).Create(&entity).Error; err != nil { if err := s.db(ctx).Create(&entity).Error; err != nil {
return servicemodel.Invitation{}, mapError(err) return servicemodel.Invitation{}, mapError(err)
} }
var created dbmodel.Invitation var created dbmodel.Invitation
if err := s.db.WithContext(ctx).Preload("Participant").First(&created, entity.ID).Error; err != nil { if err := s.db(ctx).Preload("Participant").First(&created, entity.ID).Error; err != nil {
return servicemodel.Invitation{}, mapError(err) return servicemodel.Invitation{}, mapError(err)
} }
return dbmapper.InvitationToService(created), nil return dbmapper.InvitationToService(created), nil
@ -357,7 +370,7 @@ func (s *Store) CreateInvitation(ctx context.Context, value servicemodel.Invitat
func (s *Store) GetInterview(ctx context.Context, token string) (servicemodel.Interview, error) { func (s *Store) GetInterview(ctx context.Context, token string) (servicemodel.Interview, error) {
var invitation dbmodel.Invitation var invitation dbmodel.Invitation
err := s.db.WithContext(ctx).Preload("Participant").Preload("Answers"). err := s.db(ctx).Preload("Participant").Preload("Answers").
Preload("Job.Labels").Preload("Job.RequiredSkills").Preload("Job.AdditionalSkills"). Preload("Job.Labels").Preload("Job.RequiredSkills").Preload("Job.AdditionalSkills").
Preload("Job.Questions.Options").Where("token = ?", token).First(&invitation).Error Preload("Job.Questions.Options").Where("token = ?", token).First(&invitation).Error
if err != nil { if err != nil {
@ -375,7 +388,7 @@ func (s *Store) GetInterview(ctx context.Context, token string) (servicemodel.In
} }
func (s *Store) AcceptInvitation(ctx context.Context, token string, acceptedAt time.Time) error { func (s *Store) AcceptInvitation(ctx context.Context, token string, acceptedAt time.Time) error {
result := s.db.WithContext(ctx).Model(&dbmodel.Invitation{}). result := s.db(ctx).Model(&dbmodel.Invitation{}).
Where("token = ? AND status = ?", token, "pending"). Where("token = ? AND status = ?", token, "pending").
Updates(map[string]any{"status": "accepted", "accepted_at": acceptedAt}) Updates(map[string]any{"status": "accepted", "accepted_at": acceptedAt})
if result.Error != nil { if result.Error != nil {
@ -389,18 +402,18 @@ func (s *Store) AcceptInvitation(ctx context.Context, token string, acceptedAt t
func (s *Store) SaveAnswer(ctx context.Context, token string, questionID uint, answer string) error { func (s *Store) SaveAnswer(ctx context.Context, token string, questionID uint, answer string) error {
var invitation dbmodel.Invitation var invitation dbmodel.Invitation
if err := s.db.WithContext(ctx).Where("token = ?", token).First(&invitation).Error; err != nil { if err := s.db(ctx).Where("token = ?", token).First(&invitation).Error; err != nil {
return mapError(err) return mapError(err)
} }
value := dbmodel.InvitationAnswer{InvitationID: invitation.ID, QuestionID: questionID, Answer: answer} value := dbmodel.InvitationAnswer{InvitationID: invitation.ID, QuestionID: questionID, Answer: answer}
return s.db.WithContext(ctx).Clauses(clause.OnConflict{ return s.db(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "invitation_id"}, {Name: "question_id"}}, Columns: []clause.Column{{Name: "invitation_id"}, {Name: "question_id"}},
DoUpdates: clause.AssignmentColumns([]string{"answer", "updated_at"}), DoUpdates: clause.AssignmentColumns([]string{"answer", "updated_at"}),
}).Create(&value).Error }).Create(&value).Error
} }
func (s *Store) CompleteInvitation(ctx context.Context, token string, completedAt time.Time) error { func (s *Store) CompleteInvitation(ctx context.Context, token string, completedAt time.Time) error {
result := s.db.WithContext(ctx).Model(&dbmodel.Invitation{}). result := s.db(ctx).Model(&dbmodel.Invitation{}).
Where("token = ? AND status = ?", token, "accepted"). Where("token = ? AND status = ?", token, "accepted").
Updates(map[string]any{"status": "completed", "completed_at": completedAt}) Updates(map[string]any{"status": "completed", "completed_at": completedAt})
if result.Error != nil { if result.Error != nil {
@ -414,7 +427,7 @@ func (s *Store) CompleteInvitation(ctx context.Context, token string, completedA
func (s *Store) GetInvitation(ctx context.Context, jobID, invitationID uint) (servicemodel.Invitation, error) { func (s *Store) GetInvitation(ctx context.Context, jobID, invitationID uint) (servicemodel.Invitation, error) {
var value dbmodel.Invitation var value dbmodel.Invitation
if err := s.db.WithContext(ctx).Preload("Participant"). if err := s.db(ctx).Preload("Participant").
Where("id = ? AND job_id = ?", invitationID, jobID).First(&value).Error; err != nil { Where("id = ? AND job_id = ?", invitationID, jobID).First(&value).Error; err != nil {
return servicemodel.Invitation{}, mapError(err) return servicemodel.Invitation{}, mapError(err)
} }
@ -424,7 +437,7 @@ func (s *Store) GetInvitation(ctx context.Context, jobID, invitationID uint) (se
func (s *Store) UpdateInvitationOutcome( func (s *Store) UpdateInvitationOutcome(
ctx context.Context, jobID, invitationID uint, outcome string, ctx context.Context, jobID, invitationID uint, outcome string,
) (servicemodel.Invitation, error) { ) (servicemodel.Invitation, error) {
result := s.db.WithContext(ctx).Model(&dbmodel.Invitation{}). result := s.db(ctx).Model(&dbmodel.Invitation{}).
Where("id = ? AND job_id = ?", invitationID, jobID).Update("outcome", outcome) Where("id = ? AND job_id = ?", invitationID, jobID).Update("outcome", outcome)
if result.Error != nil { if result.Error != nil {
return servicemodel.Invitation{}, mapError(result.Error) return servicemodel.Invitation{}, mapError(result.Error)
@ -443,7 +456,7 @@ func (s *Store) CreateScheduledInterview(
CreatorID: value.CreatorID, StartsAt: value.StartsAt, Location: value.Location, CreatorID: value.CreatorID, StartsAt: value.StartsAt, Location: value.Location,
CandidateToken: value.CandidateToken, SharedDocument: value.SharedDocument, Status: value.Status, CandidateToken: value.CandidateToken, SharedDocument: value.SharedDocument, Status: value.Status,
} }
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err := s.db(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&entity).Error; err != nil { if err := tx.Create(&entity).Error; err != nil {
return err return err
} }
@ -467,7 +480,7 @@ func (s *Store) CreateScheduledInterview(
func (s *Store) ListScheduledInterviews(ctx context.Context, jobID uint) ([]servicemodel.ScheduledInterview, error) { func (s *Store) ListScheduledInterviews(ctx context.Context, jobID uint) ([]servicemodel.ScheduledInterview, error) {
var values []dbmodel.ScheduledInterview var values []dbmodel.ScheduledInterview
if err := preloadScheduledInterview(s.db.WithContext(ctx)).Where("job_id = ?", jobID). if err := preloadScheduledInterview(s.db(ctx)).Where("job_id = ?", jobID).
Order("starts_at").Find(&values).Error; err != nil { Order("starts_at").Find(&values).Error; err != nil {
return nil, err return nil, err
} }
@ -476,7 +489,7 @@ func (s *Store) ListScheduledInterviews(ctx context.Context, jobID uint) ([]serv
func (s *Store) GetScheduledInterview(ctx context.Context, id uint) (servicemodel.ScheduledInterview, error) { func (s *Store) GetScheduledInterview(ctx context.Context, id uint) (servicemodel.ScheduledInterview, error) {
var value dbmodel.ScheduledInterview var value dbmodel.ScheduledInterview
if err := preloadScheduledInterview(s.db.WithContext(ctx)).First(&value, id).Error; err != nil { if err := preloadScheduledInterview(s.db(ctx)).First(&value, id).Error; err != nil {
return servicemodel.ScheduledInterview{}, mapError(err) return servicemodel.ScheduledInterview{}, mapError(err)
} }
return dbmapper.ScheduledInterviewToService(value), nil return dbmapper.ScheduledInterviewToService(value), nil
@ -484,7 +497,7 @@ func (s *Store) GetScheduledInterview(ctx context.Context, id uint) (servicemode
func (s *Store) GetScheduledInterviewByToken(ctx context.Context, token string) (servicemodel.ScheduledInterview, error) { func (s *Store) GetScheduledInterviewByToken(ctx context.Context, token string) (servicemodel.ScheduledInterview, error) {
var value dbmodel.ScheduledInterview var value dbmodel.ScheduledInterview
if err := preloadScheduledInterview(s.db.WithContext(ctx)). if err := preloadScheduledInterview(s.db(ctx)).
Where("candidate_token = ?", token).First(&value).Error; err != nil { Where("candidate_token = ?", token).First(&value).Error; err != nil {
return servicemodel.ScheduledInterview{}, mapError(err) return servicemodel.ScheduledInterview{}, mapError(err)
} }
@ -494,7 +507,7 @@ func (s *Store) GetScheduledInterviewByToken(ctx context.Context, token string)
func (s *Store) UpdateScheduledInterviewStatus( func (s *Store) UpdateScheduledInterviewStatus(
ctx context.Context, id uint, status string, ctx context.Context, id uint, status string,
) (servicemodel.ScheduledInterview, error) { ) (servicemodel.ScheduledInterview, error) {
result := s.db.WithContext(ctx).Model(&dbmodel.ScheduledInterview{}). result := s.db(ctx).Model(&dbmodel.ScheduledInterview{}).
Where("id = ?", id).Update("status", status) Where("id = ?", id).Update("status", status)
if result.Error != nil { if result.Error != nil {
return servicemodel.ScheduledInterview{}, result.Error return servicemodel.ScheduledInterview{}, result.Error
@ -508,7 +521,7 @@ func (s *Store) UpdateScheduledInterviewStatus(
func (s *Store) UpdateScheduledInterviewDocument( func (s *Store) UpdateScheduledInterviewDocument(
ctx context.Context, id uint, document string, ctx context.Context, id uint, document string,
) (servicemodel.ScheduledInterview, error) { ) (servicemodel.ScheduledInterview, error) {
result := s.db.WithContext(ctx).Model(&dbmodel.ScheduledInterview{}). result := s.db(ctx).Model(&dbmodel.ScheduledInterview{}).
Where("id = ?", id).Update("shared_document", document) Where("id = ?", id).Update("shared_document", document)
if result.Error != nil { if result.Error != nil {
return servicemodel.ScheduledInterview{}, result.Error return servicemodel.ScheduledInterview{}, result.Error
@ -521,7 +534,7 @@ func (s *Store) UpdateScheduledInterviewDocument(
func (s *Store) ListInterviewNotes(ctx context.Context, interviewID uint) ([]servicemodel.InterviewNote, error) { func (s *Store) ListInterviewNotes(ctx context.Context, interviewID uint) ([]servicemodel.InterviewNote, error) {
var values []dbmodel.InterviewNote var values []dbmodel.InterviewNote
if err := s.db.WithContext(ctx).Preload("Author").Where("interview_id = ?", interviewID). if err := s.db(ctx).Preload("Author").Where("interview_id = ?", interviewID).
Order("created_at, id").Find(&values).Error; err != nil { Order("created_at, id").Find(&values).Error; err != nil {
return nil, err return nil, err
} }
@ -538,7 +551,7 @@ func (s *Store) CreateInterviewNote(
entity := dbmodel.InterviewNote{ entity := dbmodel.InterviewNote{
InterviewID: value.InterviewID, AuthorID: value.AuthorID, Body: value.Body, InterviewID: value.InterviewID, AuthorID: value.AuthorID, Body: value.Body,
} }
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := s.db(ctx).Transaction(func(tx *gorm.DB) error {
var interview dbmodel.ScheduledInterview var interview dbmodel.ScheduledInterview
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Select("id", "status").First(&interview, value.InterviewID).Error; err != nil { Select("id", "status").First(&interview, value.InterviewID).Error; err != nil {
@ -554,7 +567,7 @@ func (s *Store) CreateInterviewNote(
} }
return servicemodel.InterviewNote{}, mapError(err) return servicemodel.InterviewNote{}, mapError(err)
} }
if err := s.db.WithContext(ctx).Preload("Author").First(&entity, entity.ID).Error; err != nil { if err := s.db(ctx).Preload("Author").First(&entity, entity.ID).Error; err != nil {
return servicemodel.InterviewNote{}, mapError(err) return servicemodel.InterviewNote{}, mapError(err)
} }
return dbmapper.InterviewNoteToService(entity), nil return dbmapper.InterviewNoteToService(entity), nil
@ -562,7 +575,7 @@ func (s *Store) CreateInterviewNote(
func (s *Store) ListInbox(ctx context.Context, userID uint) ([]servicemodel.ScheduledInterview, error) { func (s *Store) ListInbox(ctx context.Context, userID uint) ([]servicemodel.ScheduledInterview, error) {
var values []dbmodel.ScheduledInterview var values []dbmodel.ScheduledInterview
if err := preloadScheduledInterview(s.db.WithContext(ctx)). if err := preloadScheduledInterview(s.db(ctx)).
Joins("JOIN interview_attendees inbox_attendee ON inbox_attendee.interview_id = scheduled_interviews.id"). Joins("JOIN interview_attendees inbox_attendee ON inbox_attendee.interview_id = scheduled_interviews.id").
Where("inbox_attendee.user_id = ?", userID). Where("inbox_attendee.user_id = ?", userID).
Order("inbox_attendee.created_at DESC").Find(&values).Error; err != nil { Order("inbox_attendee.created_at DESC").Find(&values).Error; err != nil {
@ -574,7 +587,7 @@ func (s *Store) ListInbox(ctx context.Context, userID uint) ([]servicemodel.Sche
func (s *Store) UpdateAttendeeStatus( func (s *Store) UpdateAttendeeStatus(
ctx context.Context, interviewID, userID uint, status string, ctx context.Context, interviewID, userID uint, status string,
) (servicemodel.InterviewAttendee, error) { ) (servicemodel.InterviewAttendee, error) {
result := s.db.WithContext(ctx).Model(&dbmodel.InterviewAttendee{}). result := s.db(ctx).Model(&dbmodel.InterviewAttendee{}).
Where("interview_id = ? AND user_id = ?", interviewID, userID).Update("status", status) Where("interview_id = ? AND user_id = ?", interviewID, userID).Update("status", status)
if result.Error != nil { if result.Error != nil {
return servicemodel.InterviewAttendee{}, result.Error return servicemodel.InterviewAttendee{}, result.Error
@ -583,7 +596,7 @@ func (s *Store) UpdateAttendeeStatus(
return servicemodel.InterviewAttendee{}, ErrNotFound return servicemodel.InterviewAttendee{}, ErrNotFound
} }
var value dbmodel.InterviewAttendee var value dbmodel.InterviewAttendee
if err := s.db.WithContext(ctx).Preload("User"). if err := s.db(ctx).Preload("User").
Where("interview_id = ? AND user_id = ?", interviewID, userID).First(&value).Error; err != nil { Where("interview_id = ? AND user_id = ?", interviewID, userID).First(&value).Error; err != nil {
return servicemodel.InterviewAttendee{}, mapError(err) return servicemodel.InterviewAttendee{}, mapError(err)
} }
@ -598,7 +611,7 @@ func (s *Store) UpdateAttendeeStatus(
func (s *Store) ListCalendar(ctx context.Context, userID uint) ([]servicemodel.ScheduledInterview, error) { func (s *Store) ListCalendar(ctx context.Context, userID uint) ([]servicemodel.ScheduledInterview, error) {
var values []dbmodel.ScheduledInterview var values []dbmodel.ScheduledInterview
if err := preloadScheduledInterview(s.db.WithContext(ctx)). if err := preloadScheduledInterview(s.db(ctx)).
Joins("JOIN interview_attendees ON interview_attendees.interview_id = scheduled_interviews.id"). Joins("JOIN interview_attendees ON interview_attendees.interview_id = scheduled_interviews.id").
Where("interview_attendees.user_id = ? AND interview_attendees.status <> ?", userID, "declined"). Where("interview_attendees.user_id = ? AND interview_attendees.status <> ?", userID, "declined").
Order("scheduled_interviews.starts_at").Find(&values).Error; err != nil { Order("scheduled_interviews.starts_at").Find(&values).Error; err != nil {
@ -621,7 +634,7 @@ func (s *Store) resolveTraits(ctx context.Context, values []servicemodel.Trait)
} }
seen[normalized] = struct{}{} seen[normalized] = struct{}{}
var trait dbmodel.Trait var trait dbmodel.Trait
if err := s.db.WithContext(ctx).Where(dbmodel.Trait{Normalized: normalized}). if err := s.db(ctx).Where(dbmodel.Trait{Normalized: normalized}).
Attrs(dbmodel.Trait{Name: name}).FirstOrCreate(&trait).Error; err != nil { Attrs(dbmodel.Trait{Name: name}).FirstOrCreate(&trait).Error; err != nil {
return nil, err return nil, err
} }

View file

@ -1,12 +1,12 @@
INSERT INTO users (email, password_hash, display_name, role) INSERT INTO users (email, password_hash, display_name, role)
VALUES ('interviewer@go-guess.local', crypt('admin123', gen_salt('bf')), 'Lead Interviewer', 'interviewer') VALUES ('interviewer@nmbs.local', crypt('admin123', gen_salt('bf')), 'Lead Interviewer', 'interviewer')
ON CONFLICT (email) DO UPDATE SET ON CONFLICT (email) DO UPDATE SET
password_hash = EXCLUDED.password_hash, password_hash = EXCLUDED.password_hash,
display_name = EXCLUDED.display_name, display_name = EXCLUDED.display_name,
role = EXCLUDED.role; role = EXCLUDED.role;
INSERT INTO users (email, password_hash, display_name, role) INSERT INTO users (email, password_hash, display_name, role)
VALUES ('co-interviewer@go-guess.local', crypt('admin123', gen_salt('bf')), 'Co-Interviewer', 'interviewer') VALUES ('co-interviewer@nmbs.local', crypt('admin123', gen_salt('bf')), 'Co-Interviewer', 'interviewer')
ON CONFLICT (email) DO UPDATE SET ON CONFLICT (email) DO UPDATE SET
password_hash = EXCLUDED.password_hash, password_hash = EXCLUDED.password_hash,
display_name = EXCLUDED.display_name, display_name = EXCLUDED.display_name,

View file

@ -13,6 +13,7 @@ import (
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"gorm.io/gorm"
"github.com/danyel/go-guess/backend/internal/database/repository" "github.com/danyel/go-guess/backend/internal/database/repository"
"github.com/danyel/go-guess/backend/internal/eventbus" "github.com/danyel/go-guess/backend/internal/eventbus"
@ -24,6 +25,7 @@ import (
) )
type Handler struct { type Handler struct {
db *gorm.DB
auth service.IAuthService auth service.IAuthService
jobs service.IJobService jobs service.IJobService
questions service.IQuestionService questions service.IQuestionService
@ -36,6 +38,7 @@ type Handler struct {
} }
func New( func New(
db *gorm.DB,
auth service.IAuthService, auth service.IAuthService,
jobs service.IJobService, jobs service.IJobService,
questions service.IQuestionService, questions service.IQuestionService,
@ -47,6 +50,7 @@ func New(
frontendURL string, frontendURL string,
) *Handler { ) *Handler {
return &Handler{ return &Handler{
db: db,
auth: auth, jobs: jobs, questions: questions, participants: participants, auth: auth, jobs: jobs, questions: questions, participants: participants,
invitations: invitations, maxUpload: maxUploadMB << 20, invitations: invitations, maxUpload: maxUploadMB << 20,
users: users, interviews: interviews, users: users, interviews: interviews,
@ -139,6 +143,10 @@ func (h *Handler) CreateJob(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, webmapper.JobToWeb(value)) writeJSON(w, http.StatusCreated, webmapper.JobToWeb(value))
} }
func (h *Handler) Db() *gorm.DB {
return h.db
}
func (h *Handler) UpdateJob(w http.ResponseWriter, r *http.Request) { func (h *Handler) UpdateJob(w http.ResponseWriter, r *http.Request) {
var request webmodel.JobStatusRequest var request webmodel.JobStatusRequest
if err := decodeJSON(r, &request); err != nil { if err := decodeJSON(r, &request); err != nil {

View file

@ -0,0 +1,73 @@
package middleware
import (
"context"
"fmt"
"net/http"
"regexp"
"gorm.io/gorm"
)
type contextKey string
const TxKey contextKey = "tenant_db_tx"
// Alphanumeric or underscores only to prevent SQL Injection in schema names
var safeSchemaRegex = regexp.MustCompile(`^[a-zA-Z0-9_:-]+$`)
// TenantSchemaMiddleware isolates database connections per request using PostgreSQL schemas
func TenantSchemaMiddleware(db *gorm.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tenantID := r.Header.Get("X-Tenant-Id")
if tenantID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error": "Missing X-Tenant-Id header"}`))
return
}
// Validate schema name to guarantee safety against SQL injection
if !safeSchemaRegex.MatchString(tenantID) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error": "Invalid characters in tenant ID"}`))
return
}
// Start a fresh transaction scoped exclusively to this HTTP request
tx := db.Begin()
// Set the local search_path for the duration of this transaction block
if err := tx.Exec(fmt.Sprintf("SET LOCAL search_path TO %s", tenantID)).Error; err != nil {
tx.Rollback()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error": "Failed to resolve tenant storage isolation"}`))
return
}
// Embed the transaction database instance into the request context
ctx := context.WithValue(r.Context(), TxKey, tx)
// Execute the subsequent request handlers
next.ServeHTTP(w, r.WithContext(ctx))
// Commit or Rollback based on the transaction error status
if tx.Error != nil {
tx.Rollback()
} else {
tx.Commit()
}
})
}
}
// GetDB retrieves the transaction-bound tenant GORM DB instance from context
func GetDB(ctx context.Context) *gorm.DB {
if tx, ok := ctx.Value(TxKey).(*gorm.DB); ok {
return tx.WithContext(ctx)
}
panic("GetDB was invoked outside of TenantSchemaMiddleware")
}

View file

@ -4,18 +4,21 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings" "strings"
"time"
"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/security"
"github.com/danyel/go-guess/backend/internal/web/handler" "github.com/danyel/go-guess/backend/internal/web/handler"
mw "github.com/danyel/go-guess/backend/internal/web/middleware"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
) )
func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string) http.Handler { func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string) http.Handler {
router := chi.NewRouter() router := chi.NewRouter()
router.Use(middleware.RequestID, middleware.RealIP, middleware.Recoverer, middleware.Compress(5)) router.Use(middleware.RequestID, middleware.RealIP, middleware.Recoverer, middleware.Compress(5))
router.Use(cors(frontendURL)) router.Use(cors(frontendURL))
router.Use(mw.TenantSchemaMiddleware(h.Db()))
router.Use(middleware.Timeout(2 * time.Minute))
router.Get("/api/health", h.Health) router.Get("/api/health", h.Health)
router.Post("/api/auth/login", h.Login) router.Post("/api/auth/login", h.Login)
router.Get("/api/interviews/{token}", h.GetInterview) router.Get("/api/interviews/{token}", h.GetInterview)

View file

@ -8,7 +8,7 @@ services:
ports: ports:
- "${DB_PORT:-5432}:5432" - "${DB_PORT:-5432}:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql - postgres_data3:/var/lib/postgresql
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U go_guess -d go_guess"] test: ["CMD-SHELL", "pg_isready -U go_guess -d go_guess"]
interval: 3s interval: 3s
@ -51,3 +51,5 @@ services:
volumes: volumes:
postgres_data: postgres_data:
postgres_data2:
postgres_data3:

View file

@ -19,6 +19,9 @@ import type {
} from '../types' } from '../types'
import { publishDomainEvent } from './domainEvents' import { publishDomainEvent } from './domainEvents'
let activeTenant: string | null = null;
const TOKEN_KEY = 'go-guess-token' const TOKEN_KEY = 'go-guess-token'
const USER_KEY = 'go-guess-user' const USER_KEY = 'go-guess-user'
export const SESSION_EXPIRED_EVENT = 'go-guess:session-expired' export const SESSION_EXPIRED_EVENT = 'go-guess:session-expired'
@ -71,6 +74,12 @@ async function request<T>(
options: RequestInit = {}, options: RequestInit = {},
protectedRequest = true, protectedRequest = true,
): Promise<T> { ): Promise<T> {
let activeTenant: string | undefined
const hostname = window.location.hostname;
const parts = hostname.split('.');
if (parts.length > 1) {
activeTenant = parts[0];
}
const token = authStorage.token() const token = authStorage.token()
const response = await fetch(`/api${path}`, { const response = await fetch(`/api${path}`, {
...options, ...options,
@ -78,6 +87,7 @@ async function request<T>(
Accept: 'application/json', Accept: 'application/json',
...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), ...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
...(protectedRequest && token ? { Authorization: `Bearer ${token}` } : {}), ...(protectedRequest && token ? { Authorization: `Bearer ${token}` } : {}),
...(activeTenant ? { 'X-Tenant-Id': activeTenant } : {}),
...options.headers, ...options.headers,
}, },
}) })
@ -106,9 +116,16 @@ async function request<T>(
async function requestBlob(path: string): Promise<Blob> { async function requestBlob(path: string): Promise<Blob> {
const token = authStorage.token() const token = authStorage.token()
let activeTenant: string | undefined
const hostname = window.location.hostname;
const parts = hostname.split('.');
if (parts.length > 1) {
activeTenant = parts[0];
}
const response = await fetch(`/api${path}`, { const response = await fetch(`/api${path}`, {
headers: { headers: {
Accept: '*/*', Accept: '*/*',
...(activeTenant ? { 'X-Tenant-Id': activeTenant } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
}, },
}) })
@ -181,12 +198,16 @@ async function subscribeToInterviewEvents(
} }
} }
} }
function getLiveTenant(): string | null {
const hostname = window.location.hostname;
const parts = hostname.split('.');
return parts.length > 1 ? parts[0] : null;
}
export const api = { export const api = {
login: (email: string, password: string) => login: (email: string, password: string) =>
request<AuthResponse>( request<AuthResponse>(
'/auth/login', '/auth/login',
{ method: 'POST', body: JSON.stringify({ email, password }) }, { method: 'POST', body: JSON.stringify({ email, password }), headers: {'X-Tenant-Id': getLiveTenant()!!} },
false, false,
), ),
jobs: { jobs: {

View file

@ -1,31 +1,30 @@
import { Route, Routes, useLocation } from 'react-router-dom' import {Route, Routes, useLocation} from 'react-router-dom'
import { useAuthSession } from '../components/actions' import {useAuthSession} from '../components/actions'
import { NotFound } from '../components/ui/NotFound' import {NotFound} from '../components/ui/NotFound'
import { ParticipantInterviewPage } from '../features/assessments' import {ParticipantInterviewPage} from '../features/assessments'
import { ParticipantMeetingPage } from '../features/interviews' import {ParticipantMeetingPage} from '../features/interviews'
import { LoginPage } from '../features/login/LoginPage' import {LoginPage} from '../features/login/LoginPage'
import { AuthenticatedApp } from './AuthenticatedApp' import {AuthenticatedApp} from './AuthenticatedApp'
import { DataProvider } from './DataContext' import {DataProvider} from './DataContext'
export default function App() { export default function App() {
const location = useLocation() const location = useLocation();
const { authenticated, sessionExpired, completeLogin, logout } = useAuthSession() const {authenticated, sessionExpired, completeLogin, logout} = useAuthSession();
if (location.pathname.startsWith('/participant/')) { if (location.pathname.startsWith('/participant/')) {
return ( return (
<Routes> <Routes>
<Route path="/participant/meeting/:token" element={<ParticipantMeetingPage />} /> <Route path="/participant/meeting/:token" element={<ParticipantMeetingPage/>}/>
<Route path="/participant/:invitationId" element={<ParticipantInterviewPage />} /> <Route path="/participant/:invitationId" element={<ParticipantInterviewPage/>}/>
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound/>}/>
</Routes> </Routes>
) )
} }
if (!authenticated) { if (!authenticated) {
return <LoginPage onLogin={completeLogin} sessionExpired={sessionExpired} /> return <LoginPage onLogin={completeLogin} sessionExpired={sessionExpired}/>
} }
return ( return (
<DataProvider> <DataProvider>
<AuthenticatedApp onLogout={logout} /> <AuthenticatedApp onLogout={logout}/>
</DataProvider> </DataProvider>
) )
} }

View file

@ -0,0 +1,33 @@
import {NotFound} from "../components/ui/NotFound";
import {Route, Routes} from "react-router-dom";
import {InvitationPage, ScheduledInterviewPage} from "../features/interviews";
import {ProfilePage, SchedulePage, UsersPage} from "../features/users";
import {CreateParticipantPage, ParticipantDetailPage, ParticipantsPage} from "../features/participants";
import {QuestionLibraryPage, QuestionPage} from "../features/questions";
import {CreateJobPage, JobDetailPage, JobsPage} from "../features/jobs";
import {DashboardPage} from "../features/dashboard/DashboardPage";
export default function ApplicationRoutes() {
return (
<div className="workspace-content">
<Routes>
<Route index element={<DashboardPage/>}/>
<Route path="/jobs" element={<JobsPage/>}/>
<Route path="/jobs/new" element={<CreateJobPage/>}/>
<Route path="/jobs/:jobId" element={<JobDetailPage/>}/>
<Route path="/jobs/:jobId/questions/new" element={<QuestionPage/>}/>
<Route path="/questions" element={<QuestionLibraryPage/>}/>
<Route path="/participants" element={<ParticipantsPage/>}/>
<Route path="/participants/new" element={<CreateParticipantPage/>}/>
<Route path="/participants/:participantId" element={<ParticipantDetailPage/>}/>
<Route path="/users" element={<UsersPage/>}/>
<Route path="/profile" element={<ProfilePage/>}/>
<Route path="/invitations" element={<InvitationPage/>}/>
<Route path="/schedule" element={<SchedulePage/>}/>
<Route path="/scheduled-interviews/:id" element={<ScheduledInterviewPage/>}/>
<Route path="*" element={<NotFound/>}/>
</Routes>
</div>
);
}

View file

@ -1,49 +1,21 @@
import { useState } from 'react' import {useState} from 'react'
import { Route, Routes } from 'react-router-dom' import {authStorage} from '../api/client'
import { authStorage } from '../api/client' import {AppFooter} from '../components/layout/AppFooter'
import { AppFooter } from '../components/layout/AppFooter' import {GoGuessHeader} from '../components/layout/GoGuessHeader'
import { GoGuessHeader } from '../components/layout/GoGuessHeader' import {WorkspaceUtilities} from '../components/layout/WorkspaceUtilities'
import { WorkspaceUtilities } from '../components/layout/WorkspaceUtilities' import ApplicationRoutes from "./ApplicationRoutes";
import { NotFound } from '../components/ui/NotFound'
import { DashboardPage } from '../features/dashboard/DashboardPage'
import { InvitationPage, ScheduledInterviewPage } from '../features/interviews'
import { CreateJobPage, JobDetailPage, JobsPage } from '../features/jobs'
import {
CreateParticipantPage,
ParticipantDetailPage,
ParticipantsPage,
} from '../features/participants'
import { QuestionLibraryPage, QuestionPage } from '../features/questions'
import { SchedulePage, ProfilePage, UsersPage } from '../features/users'
export function AuthenticatedApp({ onLogout }: { onLogout: () => void }) { export function AuthenticatedApp({onLogout}: { onLogout: () => void }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const user = authStorage.user() const user = authStorage.user()
return ( return (
<div className="app-shell"> <div className="app-shell">
<GoGuessHeader setOpen={setOpen} open={open} onLogout={onLogout} /> <GoGuessHeader setOpen={setOpen} open={open} onLogout={onLogout}/>
<main className="main-content"> <main className="main-content">
<WorkspaceUtilities user={user} /> <WorkspaceUtilities user={user}/>
<div className="workspace-content"> <ApplicationRoutes/>
<Routes> <AppFooter/>
<Route index element={<DashboardPage />} />
<Route path="/jobs" element={<JobsPage />} />
<Route path="/jobs/new" element={<CreateJobPage />} />
<Route path="/jobs/:jobId" element={<JobDetailPage />} />
<Route path="/jobs/:jobId/questions/new" element={<QuestionPage />} />
<Route path="/questions" element={<QuestionLibraryPage />} />
<Route path="/participants" element={<ParticipantsPage />} />
<Route path="/participants/new" element={<CreateParticipantPage />} />
<Route path="/participants/:participantId" element={<ParticipantDetailPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/invitations" element={<InvitationPage />} />
<Route path="/schedule" element={<SchedulePage />} />
<Route path="/scheduled-interviews/:id" element={<ScheduledInterviewPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
</div>
<AppFooter />
</main> </main>
</div> </div>
) )

View file

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { api } from '../../api/client' import {api} from '../../api/client'
import { useDomainEvent } from '../../app/useDomainEvent' import { useDomainEvent } from '../../app/useDomainEvent'
import { errorMessage } from '../../components/actions' import { errorMessage } from '../../components/actions'
import { ErrorAlert, Loading } from '../../components/ui/AsyncState' import { ErrorAlert, Loading } from '../../components/ui/AsyncState'

View file

@ -516,6 +516,7 @@ function InvitationsView({
} }
async function reviewAnswers(invitationId: number) { async function reviewAnswers(invitationId: number) {
setReview(undefined)
setReviewingId(invitationId) setReviewingId(invitationId)
setError('') setError('')
try { try {
@ -681,7 +682,7 @@ function InvitationAnswerReview({
<h2 id="answer-review-title">{current.invitation.participantName}</h2> <h2 id="answer-review-title">{current.invitation.participantName}</h2>
<p>{current.invitation.participantEmail}</p> <p>{current.invitation.participantEmail}</p>
</div> </div>
{current.invitation.status === 'completed' && ( {current.invitation.outcome !== 'passed' && (
<div className="outcome-actions" aria-label="Candidate outcome"> <div className="outcome-actions" aria-label="Candidate outcome">
<div> <div>
<strong>Assessment decision</strong> <strong>Assessment decision</strong>

View file

@ -1,13 +1,13 @@
import { StrictMode } from 'react' import {StrictMode} from 'react'
import { createRoot } from 'react-dom/client' import {createRoot} from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom' import {BrowserRouter} from 'react-router-dom'
import App from './app/App' import App from './app/App'
import './styles/global.css' import './styles/global.css'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<BrowserRouter> <BrowserRouter>
<App /> <App/>
</BrowserRouter> </BrowserRouter>
</StrictMode>, </StrictMode>,
) )

View file

@ -498,7 +498,7 @@ button {
.main-content { .main-content {
margin-left: 255px; margin-left: 255px;
padding: clamp(1rem, 2vw, 1.8rem) clamp(1.2rem, 4vw, 4.8rem) 0; padding: clamp(1rem, 2vw, 1.8rem) clamp(1.2rem, 4vw, 4.8rem) 0;
max-width: 1550px; /*max-width: 1550px;*/
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;