feat: multi tenant support
This commit is contained in:
parent
a660c591ff
commit
30e3391adb
17 changed files with 271 additions and 145 deletions
6
Makefile
6
Makefile
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
73
backend/internal/web/middleware/TenantSchemaMiddleware.go
Normal file
73
backend/internal/web/middleware/TenantSchemaMiddleware.go
Normal 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")
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/participant/meeting/:token" element={<ParticipantMeetingPage/>}/>
|
||||||
|
<Route path="/participant/:invitationId" element={<ParticipantInterviewPage/>}/>
|
||||||
|
<Route path="*" element={<NotFound/>}/>
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!authenticated) {
|
||||||
|
return <LoginPage onLogin={completeLogin} sessionExpired={sessionExpired}/>
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<DataProvider>
|
||||||
<Route path="/participant/meeting/:token" element={<ParticipantMeetingPage />} />
|
<AuthenticatedApp onLogout={logout}/>
|
||||||
<Route path="/participant/:invitationId" element={<ParticipantInterviewPage />} />
|
</DataProvider>
|
||||||
<Route path="*" element={<NotFound />} />
|
|
||||||
</Routes>
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
if (!authenticated) {
|
|
||||||
return <LoginPage onLogin={completeLogin} sessionExpired={sessionExpired} />
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<DataProvider>
|
|
||||||
<AuthenticatedApp onLogout={logout} />
|
|
||||||
</DataProvider>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
33
frontend/src/app/ApplicationRoutes.tsx
Normal file
33
frontend/src/app/ApplicationRoutes.tsx
Normal 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>
|
||||||
|
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,50 +1,22 @@
|
||||||
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 (
|
|
||||||
<div className="app-shell">
|
return (
|
||||||
<GoGuessHeader setOpen={setOpen} open={open} onLogout={onLogout} />
|
<div className="app-shell">
|
||||||
<main className="main-content">
|
<GoGuessHeader setOpen={setOpen} open={open} onLogout={onLogout}/>
|
||||||
<WorkspaceUtilities user={user} />
|
<main className="main-content">
|
||||||
<div className="workspace-content">
|
<WorkspaceUtilities user={user}/>
|
||||||
<Routes>
|
<ApplicationRoutes/>
|
||||||
<Route index element={<DashboardPage />} />
|
<AppFooter/>
|
||||||
<Route path="/jobs" element={<JobsPage />} />
|
</main>
|
||||||
<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>
|
</div>
|
||||||
<AppFooter />
|
)
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue