From 30e3391adb65f235d7f97369afad88079e0ea2ab Mon Sep 17 00:00:00 2001 From: Daniel Noulet Date: Thu, 1 Oct 2026 09:06:58 +0200 Subject: [PATCH] feat: multi tenant support --- Makefile | 6 +- backend/cmd/api/main.go | 1 + backend/internal/config/config.go | 2 +- backend/internal/database/repository/store.go | 111 ++++++++++-------- .../internal/database/seed/development.sql | 4 +- backend/internal/web/handler/handler.go | 8 ++ .../web/middleware/TenantSchemaMiddleware.go | 73 ++++++++++++ backend/internal/web/router/router.go | 9 +- docker-compose.yml | 4 +- frontend/src/api/client.ts | 25 +++- frontend/src/app/App.tsx | 51 ++++---- frontend/src/app/ApplicationRoutes.tsx | 33 ++++++ frontend/src/app/AuthenticatedApp.tsx | 66 +++-------- .../features/interviews/InvitationPage.tsx | 2 +- frontend/src/features/jobs/pages.tsx | 3 +- frontend/src/main.tsx | 16 +-- frontend/src/styles/global.css | 2 +- 17 files changed, 271 insertions(+), 145 deletions(-) create mode 100644 backend/internal/web/middleware/TenantSchemaMiddleware.go create mode 100644 frontend/src/app/ApplicationRoutes.tsx diff --git a/Makefile b/Makefile index 439652c..18d2ab7 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 94ac3e5..3ee70ff 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -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), diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index b0cd69d..4355a53 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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, diff --git a/backend/internal/database/repository/store.go b/backend/internal/database/repository/store.go index bdc994d..02104b1 100644 --- a/backend/internal/database/repository/store.go +++ b/backend/internal/database/repository/store.go @@ -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 } diff --git a/backend/internal/database/seed/development.sql b/backend/internal/database/seed/development.sql index 3c65c64..56e8539 100644 --- a/backend/internal/database/seed/development.sql +++ b/backend/internal/database/seed/development.sql @@ -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, diff --git a/backend/internal/web/handler/handler.go b/backend/internal/web/handler/handler.go index 13bd539..e536767 100644 --- a/backend/internal/web/handler/handler.go +++ b/backend/internal/web/handler/handler.go @@ -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 { diff --git a/backend/internal/web/middleware/TenantSchemaMiddleware.go b/backend/internal/web/middleware/TenantSchemaMiddleware.go new file mode 100644 index 0000000..af4af7a --- /dev/null +++ b/backend/internal/web/middleware/TenantSchemaMiddleware.go @@ -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") +} diff --git a/backend/internal/web/router/router.go b/backend/internal/web/router/router.go index eff56e9..969a2e6 100644 --- a/backend/internal/web/router/router.go +++ b/backend/internal/web/router/router.go @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml index a6053c5..4a1db4e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index fcb241e..93d8af7 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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( options: RequestInit = {}, protectedRequest = true, ): Promise { + 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( 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( async function requestBlob(path: string): Promise { 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( '/auth/login', - { method: 'POST', body: JSON.stringify({ email, password }) }, + { method: 'POST', body: JSON.stringify({ email, password }), headers: {'X-Tenant-Id': getLiveTenant()!!} }, false, ), jobs: { diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 118f1f7..04a610c 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -1,31 +1,30 @@ -import { Route, Routes, useLocation } from 'react-router-dom' -import { useAuthSession } from '../components/actions' -import { NotFound } from '../components/ui/NotFound' -import { ParticipantInterviewPage } from '../features/assessments' -import { ParticipantMeetingPage } from '../features/interviews' -import { LoginPage } from '../features/login/LoginPage' -import { AuthenticatedApp } from './AuthenticatedApp' -import { DataProvider } from './DataContext' +import {Route, Routes, useLocation} from 'react-router-dom' +import {useAuthSession} from '../components/actions' +import {NotFound} from '../components/ui/NotFound' +import {ParticipantInterviewPage} from '../features/assessments' +import {ParticipantMeetingPage} from '../features/interviews' +import {LoginPage} from '../features/login/LoginPage' +import {AuthenticatedApp} from './AuthenticatedApp' +import {DataProvider} from './DataContext' export default function App() { - const location = useLocation() - const { authenticated, sessionExpired, completeLogin, logout } = useAuthSession() - - if (location.pathname.startsWith('/participant/')) { + const location = useLocation(); + const {authenticated, sessionExpired, completeLogin, logout} = useAuthSession(); + if (location.pathname.startsWith('/participant/')) { + return ( + + }/> + }/> + }/> + + ) + } + if (!authenticated) { + return + } return ( - - } /> - } /> - } /> - + + + ) - } - if (!authenticated) { - return - } - return ( - - - - ) } diff --git a/frontend/src/app/ApplicationRoutes.tsx b/frontend/src/app/ApplicationRoutes.tsx new file mode 100644 index 0000000..2f126f5 --- /dev/null +++ b/frontend/src/app/ApplicationRoutes.tsx @@ -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 ( +
+ + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + +
+ + ); +} \ No newline at end of file diff --git a/frontend/src/app/AuthenticatedApp.tsx b/frontend/src/app/AuthenticatedApp.tsx index 52019b0..2c1d489 100644 --- a/frontend/src/app/AuthenticatedApp.tsx +++ b/frontend/src/app/AuthenticatedApp.tsx @@ -1,50 +1,22 @@ -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 {useState} from 'react' +import {authStorage} from '../api/client' +import {AppFooter} from '../components/layout/AppFooter' +import {GoGuessHeader} from '../components/layout/GoGuessHeader' +import {WorkspaceUtilities} from '../components/layout/WorkspaceUtilities' +import ApplicationRoutes from "./ApplicationRoutes"; -export function AuthenticatedApp({ onLogout }: { onLogout: () => void }) { - const [open, setOpen] = useState(false) - const user = authStorage.user() - return ( -
- -
- -
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - +export function AuthenticatedApp({onLogout}: { onLogout: () => void }) { + const [open, setOpen] = useState(false) + const user = authStorage.user() + + return ( +
+ +
+ + + +
- -
-
- ) + ) } diff --git a/frontend/src/features/interviews/InvitationPage.tsx b/frontend/src/features/interviews/InvitationPage.tsx index 27c3725..0844b4c 100644 --- a/frontend/src/features/interviews/InvitationPage.tsx +++ b/frontend/src/features/interviews/InvitationPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { api } from '../../api/client' +import {api} from '../../api/client' import { useDomainEvent } from '../../app/useDomainEvent' import { errorMessage } from '../../components/actions' import { ErrorAlert, Loading } from '../../components/ui/AsyncState' diff --git a/frontend/src/features/jobs/pages.tsx b/frontend/src/features/jobs/pages.tsx index 89c30e8..7b95f30 100644 --- a/frontend/src/features/jobs/pages.tsx +++ b/frontend/src/features/jobs/pages.tsx @@ -516,6 +516,7 @@ function InvitationsView({ } async function reviewAnswers(invitationId: number) { + setReview(undefined) setReviewingId(invitationId) setError('') try { @@ -681,7 +682,7 @@ function InvitationAnswerReview({

{current.invitation.participantName}

{current.invitation.participantEmail}

- {current.invitation.status === 'completed' && ( + {current.invitation.outcome !== 'passed' && (
Assessment decision diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index d4597ed..b11b630 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,13 +1,13 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import { BrowserRouter } from 'react-router-dom' +import {StrictMode} from 'react' +import {createRoot} from 'react-dom/client' +import {BrowserRouter} from 'react-router-dom' import App from './app/App' import './styles/global.css' createRoot(document.getElementById('root')!).render( - - - - - , + + + + + , ) diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 38479d9..528f010 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -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;