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
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:
$(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:
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:
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)
api := router.New(
handler.New(
db,
service.NewAuthService(store, tokens),
service.NewJobService(store),
service.NewQuestionService(store),

View file

@ -28,7 +28,7 @@ func Load() (Config, error) {
}
cfg := Config{
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"),
TokenTTL: ttl,
MaxUploadMB: maxUploadMB,

View file

@ -9,7 +9,9 @@ import (
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/danyel/go-guess/backend/internal/web/middleware"
"github.com/jackc/pgx/v5/pgconn"
"github.com/testcontainers/testcontainers-go/log"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@ -61,16 +63,27 @@ type IStore interface {
}
type Store struct {
db *gorm.DB
d *gorm.DB
}
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) {
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 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) {
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
}
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,
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)
}
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) {
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
}
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) {
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 dbmapper.JobToService(value), nil
@ -140,14 +153,14 @@ func (s *Store) CreateJob(ctx context.Context, value servicemodel.JobPosting) (s
if err != nil {
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 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).
result := s.db(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
@ -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) {
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 != "" {
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) {
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 dbmapper.QuestionToService(value), nil
@ -192,14 +205,14 @@ func (s *Store) CreateQuestion(ctx context.Context, value servicemodel.Question)
CodeSnippet: value.CodeSnippet, ReferenceAnswer: value.ReferenceAnswer,
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 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 {
err := s.db(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, "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) {
result := s.db.WithContext(ctx).Model(&dbmodel.Question{}).Where("id = ?", id).
result := s.db(ctx).Model(&dbmodel.Question{}).Where("id = ?", id).
Update("deprecated", deprecated)
if result.Error != nil {
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 {
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)
}
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)
}
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 {
return err
}
if invitations > 0 {
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 {
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)
}
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 {
return err
}
if invitations > 0 {
return ErrConflict
}
return s.db.WithContext(ctx).Model(&job).Association("Questions").
return s.db(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 {
if err := s.db(ctx).Preload("Traits").Order("last_name, first_name").Find(&values).Error; err != nil {
return nil, err
}
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) {
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 dbmapper.ParticipantToService(value), nil
@ -309,7 +322,7 @@ func (s *Store) CreateParticipant(ctx context.Context, value servicemodel.Partic
if err != nil {
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 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) {
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
}
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) {
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 {
return nil, err
}
@ -345,11 +358,11 @@ func (s *Store) CreateInvitation(ctx context.Context, value servicemodel.Invitat
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 {
if err := s.db(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 {
if err := s.db(ctx).Preload("Participant").First(&created, entity.ID).Error; err != nil {
return servicemodel.Invitation{}, mapError(err)
}
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) {
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.Questions.Options").Where("token = ?", token).First(&invitation).Error
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 {
result := s.db.WithContext(ctx).Model(&dbmodel.Invitation{}).
result := s.db(ctx).Model(&dbmodel.Invitation{}).
Where("token = ? AND status = ?", token, "pending").
Updates(map[string]any{"status": "accepted", "accepted_at": acceptedAt})
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 {
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)
}
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"}},
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{}).
result := s.db(ctx).Model(&dbmodel.Invitation{}).
Where("token = ? AND status = ?", token, "accepted").
Updates(map[string]any{"status": "completed", "completed_at": completedAt})
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) {
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 {
return servicemodel.Invitation{}, mapError(err)
}
@ -424,7 +437,7 @@ func (s *Store) GetInvitation(ctx context.Context, jobID, invitationID uint) (se
func (s *Store) UpdateInvitationOutcome(
ctx context.Context, jobID, invitationID uint, outcome string,
) (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)
if result.Error != nil {
return servicemodel.Invitation{}, mapError(result.Error)
@ -443,7 +456,7 @@ func (s *Store) CreateScheduledInterview(
CreatorID: value.CreatorID, StartsAt: value.StartsAt, Location: value.Location,
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 {
return err
}
@ -467,7 +480,7 @@ func (s *Store) CreateScheduledInterview(
func (s *Store) ListScheduledInterviews(ctx context.Context, jobID uint) ([]servicemodel.ScheduledInterview, error) {
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 {
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) {
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 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) {
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 {
return servicemodel.ScheduledInterview{}, mapError(err)
}
@ -494,7 +507,7 @@ func (s *Store) GetScheduledInterviewByToken(ctx context.Context, token string)
func (s *Store) UpdateScheduledInterviewStatus(
ctx context.Context, id uint, status string,
) (servicemodel.ScheduledInterview, error) {
result := s.db.WithContext(ctx).Model(&dbmodel.ScheduledInterview{}).
result := s.db(ctx).Model(&dbmodel.ScheduledInterview{}).
Where("id = ?", id).Update("status", status)
if result.Error != nil {
return servicemodel.ScheduledInterview{}, result.Error
@ -508,7 +521,7 @@ func (s *Store) UpdateScheduledInterviewStatus(
func (s *Store) UpdateScheduledInterviewDocument(
ctx context.Context, id uint, document string,
) (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)
if result.Error != nil {
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) {
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 {
return nil, err
}
@ -538,7 +551,7 @@ func (s *Store) CreateInterviewNote(
entity := dbmodel.InterviewNote{
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
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Select("id", "status").First(&interview, value.InterviewID).Error; err != nil {
@ -554,7 +567,7 @@ func (s *Store) CreateInterviewNote(
}
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 dbmapper.InterviewNoteToService(entity), nil
@ -562,7 +575,7 @@ func (s *Store) CreateInterviewNote(
func (s *Store) ListInbox(ctx context.Context, userID uint) ([]servicemodel.ScheduledInterview, error) {
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").
Where("inbox_attendee.user_id = ?", userID).
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(
ctx context.Context, interviewID, userID uint, status string,
) (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)
if result.Error != nil {
return servicemodel.InterviewAttendee{}, result.Error
@ -583,7 +596,7 @@ func (s *Store) UpdateAttendeeStatus(
return servicemodel.InterviewAttendee{}, ErrNotFound
}
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 {
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) {
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").
Where("interview_attendees.user_id = ? AND interview_attendees.status <> ?", userID, "declined").
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{}{}
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 {
return nil, err
}

View file

@ -1,12 +1,12 @@
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
password_hash = EXCLUDED.password_hash,
display_name = EXCLUDED.display_name,
role = EXCLUDED.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
password_hash = EXCLUDED.password_hash,
display_name = EXCLUDED.display_name,

View file

@ -13,6 +13,7 @@ import (
"time"
"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/eventbus"
@ -24,6 +25,7 @@ import (
)
type Handler struct {
db *gorm.DB
auth service.IAuthService
jobs service.IJobService
questions service.IQuestionService
@ -36,6 +38,7 @@ type Handler struct {
}
func New(
db *gorm.DB,
auth service.IAuthService,
jobs service.IJobService,
questions service.IQuestionService,
@ -47,6 +50,7 @@ func New(
frontendURL string,
) *Handler {
return &Handler{
db: db,
auth: auth, jobs: jobs, questions: questions, participants: participants,
invitations: invitations, maxUpload: maxUploadMB << 20,
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))
}
func (h *Handler) Db() *gorm.DB {
return h.db
}
func (h *Handler) UpdateJob(w http.ResponseWriter, r *http.Request) {
var request webmodel.JobStatusRequest
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"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"time"
"github.com/danyel/go-guess/backend/internal/security"
"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 {
router := chi.NewRouter()
router.Use(middleware.RequestID, middleware.RealIP, middleware.Recoverer, middleware.Compress(5))
router.Use(cors(frontendURL))
router.Use(mw.TenantSchemaMiddleware(h.Db()))
router.Use(middleware.Timeout(2 * time.Minute))
router.Get("/api/health", h.Health)
router.Post("/api/auth/login", h.Login)
router.Get("/api/interviews/{token}", h.GetInterview)

View file

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

View file

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

View file

@ -8,9 +8,8 @@ import { AuthenticatedApp } from './AuthenticatedApp'
import {DataProvider} from './DataContext'
export default function App() {
const location = useLocation()
const { authenticated, sessionExpired, completeLogin, logout } = useAuthSession()
const location = useLocation();
const {authenticated, sessionExpired, completeLogin, logout} = useAuthSession();
if (location.pathname.startsWith('/participant/')) {
return (
<Routes>

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,48 +1,20 @@
import {useState} from 'react'
import { Route, Routes } from 'react-router-dom'
import {authStorage} from '../api/client'
import {AppFooter} from '../components/layout/AppFooter'
import {GoGuessHeader} from '../components/layout/GoGuessHeader'
import {WorkspaceUtilities} from '../components/layout/WorkspaceUtilities'
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'
import ApplicationRoutes from "./ApplicationRoutes";
export function AuthenticatedApp({onLogout}: { onLogout: () => void }) {
const [open, setOpen] = useState(false)
const user = authStorage.user()
return (
<div className="app-shell">
<GoGuessHeader setOpen={setOpen} open={open} onLogout={onLogout}/>
<main className="main-content">
<WorkspaceUtilities user={user}/>
<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>
<ApplicationRoutes/>
<AppFooter/>
</main>
</div>

View file

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

View file

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