feat: add collaborative interview scheduling

Add candidate decisions, co-interviewer accounts, scheduled interview inbox and calendar views, candidate-safe meeting links, shared documents, and private live notes backed by RabbitMQ.

Extend the disposable integration environment to validate PostgreSQL migrations, seeded users, interview lifecycle behavior, and RabbitMQ fanout.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Daniel Noulet 2026-09-19 21:58:24 +02:00
parent fd06fbbb5d
commit 75e3c73a61
35 changed files with 3621 additions and 58 deletions

View file

@ -6,6 +6,7 @@ type User struct {
ID uint `gorm:"primaryKey"`
Email string `gorm:"uniqueIndex;size:320;not null"`
PasswordHash string `gorm:"not null"`
DisplayName string `gorm:"size:200;not null"`
Role string `gorm:"size:32;not null"`
CreatedAt time.Time
UpdatedAt time.Time
@ -74,6 +75,7 @@ type Invitation struct {
ParticipantID uint `gorm:"index;not null"`
Token string `gorm:"uniqueIndex;size:64;not null"`
Status string `gorm:"size:32;not null;default:pending"`
Outcome string `gorm:"size:32;not null;default:pending"`
DurationMinutes int `gorm:"not null"`
AcceptedAt *time.Time
CompletedAt *time.Time
@ -84,6 +86,46 @@ type Invitation struct {
UpdatedAt time.Time
}
type ScheduledInterview struct {
ID uint `gorm:"primaryKey"`
JobID uint `gorm:"index;not null"`
InvitationID uint `gorm:"uniqueIndex;not null"`
ParticipantID uint `gorm:"index;not null"`
CreatorID uint `gorm:"index;not null"`
StartsAt time.Time
Location string `gorm:"size:500;not null"`
CandidateToken string `gorm:"uniqueIndex;size:64;not null"`
SharedDocument string `gorm:"type:text;not null;default:''"`
Status string `gorm:"size:32;not null;default:scheduled"`
Job JobPosting
Invitation Invitation
Participant Participant
Creator User
Attendees []InterviewAttendee `gorm:"foreignKey:InterviewID;constraint:OnDelete:CASCADE"`
Notes []InterviewNote `gorm:"foreignKey:InterviewID;constraint:OnDelete:CASCADE"`
CreatedAt time.Time
UpdatedAt time.Time
}
type InterviewAttendee struct {
ID uint `gorm:"primaryKey"`
InterviewID uint `gorm:"uniqueIndex:interview_user;not null"`
UserID uint `gorm:"uniqueIndex:interview_user;index;not null"`
Status string `gorm:"size:32;not null;default:invited"`
User User
CreatedAt time.Time
UpdatedAt time.Time
}
type InterviewNote struct {
ID uint `gorm:"primaryKey"`
InterviewID uint `gorm:"index;not null"`
AuthorID uint `gorm:"index;not null"`
Body string `gorm:"type:text;not null"`
Author User
CreatedAt time.Time
}
type InvitationAnswer struct {
ID uint `gorm:"primaryKey"`
InvitationID uint `gorm:"uniqueIndex:invitation_question;not null"`

View file

@ -6,7 +6,10 @@ import (
)
func UserToService(value dbmodel.User) servicemodel.User {
return servicemodel.User{ID: value.ID, Email: value.Email, PasswordHash: value.PasswordHash, Role: value.Role}
return servicemodel.User{
ID: value.ID, Email: value.Email, PasswordHash: value.PasswordHash,
DisplayName: value.DisplayName, Role: value.Role,
}
}
func JobToService(value dbmodel.JobPosting) servicemodel.JobPosting {
@ -63,7 +66,45 @@ func InvitationToService(value dbmodel.Invitation) servicemodel.Invitation {
ID: value.ID, JobID: value.JobID, ParticipantID: value.ParticipantID,
ParticipantName: value.Participant.FirstName + " " + value.Participant.LastName,
ParticipantEmail: value.Participant.Email, Token: value.Token, Status: value.Status,
Outcome: value.Outcome,
DurationMinutes: value.DurationMinutes, AcceptedAt: value.AcceptedAt,
CompletedAt: value.CompletedAt, CreatedAt: value.CreatedAt,
}
}
func ScheduledInterviewToService(value dbmodel.ScheduledInterview) servicemodel.ScheduledInterview {
attendees := make([]servicemodel.InterviewAttendee, len(value.Attendees))
for i, attendee := range value.Attendees {
attendees[i] = servicemodel.InterviewAttendee{
ID: attendee.ID, InterviewID: attendee.InterviewID, UserID: attendee.UserID,
Status: attendee.Status, User: UserToService(attendee.User),
CreatedAt: attendee.CreatedAt, UpdatedAt: attendee.UpdatedAt,
}
attendees[i].User.PasswordHash = ""
}
creator := UserToService(value.Creator)
creator.PasswordHash = ""
notes := make([]servicemodel.InterviewNote, len(value.Notes))
for i, note := range value.Notes {
notes[i] = InterviewNoteToService(note)
}
return servicemodel.ScheduledInterview{
ID: value.ID, JobID: value.JobID, InvitationID: value.InvitationID,
ParticipantID: value.ParticipantID, CreatorID: value.CreatorID,
StartsAt: value.StartsAt, Location: value.Location, CandidateToken: value.CandidateToken,
SharedDocument: value.SharedDocument, Status: value.Status,
Job: JobToService(value.Job), Invitation: InvitationToService(value.Invitation),
Participant: ParticipantToService(value.Participant), Creator: creator,
Attendees: attendees, Notes: notes,
CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt,
}
}
func InterviewNoteToService(value dbmodel.InterviewNote) servicemodel.InterviewNote {
author := UserToService(value.Author)
author.PasswordHash = ""
return servicemodel.InterviewNote{
ID: value.ID, InterviewID: value.InterviewID, AuthorID: value.AuthorID,
Author: author, Body: value.Body, CreatedAt: value.CreatedAt,
}
}

View file

@ -15,12 +15,15 @@ import (
)
var (
ErrNotFound = errors.New("record not found")
ErrConflict = errors.New("record conflicts with existing data")
ErrNotFound = errors.New("record not found")
ErrConflict = errors.New("record conflicts with existing data")
ErrInterviewNotStarted = errors.New("interview is not started")
)
type IStore interface {
FindUserByEmail(context.Context, string) (servicemodel.User, error)
ListUsers(context.Context) ([]servicemodel.User, error)
CreateUser(context.Context, servicemodel.User) (servicemodel.User, error)
ListJobs(context.Context) ([]servicemodel.JobPosting, error)
GetJob(context.Context, uint) (servicemodel.JobPosting, error)
CreateJob(context.Context, servicemodel.JobPosting) (servicemodel.JobPosting, error)
@ -42,6 +45,19 @@ type IStore interface {
AcceptInvitation(context.Context, string, time.Time) error
SaveAnswer(context.Context, string, uint, string) error
CompleteInvitation(context.Context, string, time.Time) error
GetInvitation(context.Context, uint, uint) (servicemodel.Invitation, error)
UpdateInvitationOutcome(context.Context, uint, uint, string) (servicemodel.Invitation, error)
CreateScheduledInterview(context.Context, servicemodel.ScheduledInterview, []uint) (servicemodel.ScheduledInterview, error)
ListScheduledInterviews(context.Context, uint) ([]servicemodel.ScheduledInterview, error)
GetScheduledInterview(context.Context, uint) (servicemodel.ScheduledInterview, error)
GetScheduledInterviewByToken(context.Context, string) (servicemodel.ScheduledInterview, error)
UpdateScheduledInterviewStatus(context.Context, uint, string) (servicemodel.ScheduledInterview, error)
UpdateScheduledInterviewDocument(context.Context, uint, string) (servicemodel.ScheduledInterview, error)
ListInterviewNotes(context.Context, uint) ([]servicemodel.InterviewNote, error)
CreateInterviewNote(context.Context, servicemodel.InterviewNote) (servicemodel.InterviewNote, error)
ListInbox(context.Context, uint) ([]servicemodel.ScheduledInterview, error)
UpdateAttendeeStatus(context.Context, uint, uint, string) (servicemodel.InterviewAttendee, error)
ListCalendar(context.Context, uint) ([]servicemodel.ScheduledInterview, error)
}
type Store struct {
@ -60,6 +76,32 @@ func (s *Store) FindUserByEmail(ctx context.Context, email string) (servicemodel
return dbmapper.UserToService(value), nil
}
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 {
return nil, err
}
result := make([]servicemodel.User, len(values))
for i, value := range values {
result[i] = dbmapper.UserToService(value)
result[i].PasswordHash = ""
}
return result, nil
}
func (s *Store) CreateUser(ctx context.Context, value servicemodel.User) (servicemodel.User, error) {
entity := dbmodel.User{
Email: value.Email, PasswordHash: value.PasswordHash,
DisplayName: value.DisplayName, Role: value.Role,
}
if err := s.db.WithContext(ctx).Create(&entity).Error; err != nil {
return servicemodel.User{}, mapError(err)
}
result := dbmapper.UserToService(entity)
result.PasswordHash = ""
return result, nil
}
func (s *Store) ListJobs(ctx context.Context) ([]servicemodel.JobPosting, error) {
var values []dbmodel.JobPosting
if err := preloadJob(s.db.WithContext(ctx)).Order("created_at DESC").Find(&values).Error; err != nil {
@ -370,6 +412,201 @@ func (s *Store) CompleteInvitation(ctx context.Context, token string, completedA
return nil
}
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").
Where("id = ? AND job_id = ?", invitationID, jobID).First(&value).Error; err != nil {
return servicemodel.Invitation{}, mapError(err)
}
return dbmapper.InvitationToService(value), nil
}
func (s *Store) UpdateInvitationOutcome(
ctx context.Context, jobID, invitationID uint, outcome string,
) (servicemodel.Invitation, error) {
result := s.db.WithContext(ctx).Model(&dbmodel.Invitation{}).
Where("id = ? AND job_id = ?", invitationID, jobID).Update("outcome", outcome)
if result.Error != nil {
return servicemodel.Invitation{}, mapError(result.Error)
}
if result.RowsAffected == 0 {
return servicemodel.Invitation{}, ErrNotFound
}
return s.GetInvitation(ctx, jobID, invitationID)
}
func (s *Store) CreateScheduledInterview(
ctx context.Context, value servicemodel.ScheduledInterview, interviewerIDs []uint,
) (servicemodel.ScheduledInterview, error) {
entity := dbmodel.ScheduledInterview{
JobID: value.JobID, InvitationID: value.InvitationID, ParticipantID: value.ParticipantID,
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 {
if err := tx.Create(&entity).Error; err != nil {
return err
}
attendees := make([]dbmodel.InterviewAttendee, 0, len(interviewerIDs))
for _, userID := range interviewerIDs {
status := "invited"
if userID == value.CreatorID {
status = "accepted"
}
attendees = append(attendees, dbmodel.InterviewAttendee{
InterviewID: entity.ID, UserID: userID, Status: status,
})
}
return tx.Create(&attendees).Error
})
if err != nil {
return servicemodel.ScheduledInterview{}, mapError(err)
}
return s.GetScheduledInterview(ctx, entity.ID)
}
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).
Order("starts_at").Find(&values).Error; err != nil {
return nil, err
}
return scheduledInterviewsToService(values), nil
}
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 {
return servicemodel.ScheduledInterview{}, mapError(err)
}
return dbmapper.ScheduledInterviewToService(value), nil
}
func (s *Store) GetScheduledInterviewByToken(ctx context.Context, token string) (servicemodel.ScheduledInterview, error) {
var value dbmodel.ScheduledInterview
if err := preloadScheduledInterview(s.db.WithContext(ctx)).
Where("candidate_token = ?", token).First(&value).Error; err != nil {
return servicemodel.ScheduledInterview{}, mapError(err)
}
return dbmapper.ScheduledInterviewToService(value), nil
}
func (s *Store) UpdateScheduledInterviewStatus(
ctx context.Context, id uint, status string,
) (servicemodel.ScheduledInterview, error) {
result := s.db.WithContext(ctx).Model(&dbmodel.ScheduledInterview{}).
Where("id = ?", id).Update("status", status)
if result.Error != nil {
return servicemodel.ScheduledInterview{}, result.Error
}
if result.RowsAffected == 0 {
return servicemodel.ScheduledInterview{}, ErrNotFound
}
return s.GetScheduledInterview(ctx, id)
}
func (s *Store) UpdateScheduledInterviewDocument(
ctx context.Context, id uint, document string,
) (servicemodel.ScheduledInterview, error) {
result := s.db.WithContext(ctx).Model(&dbmodel.ScheduledInterview{}).
Where("id = ?", id).Update("shared_document", document)
if result.Error != nil {
return servicemodel.ScheduledInterview{}, result.Error
}
if result.RowsAffected == 0 {
return servicemodel.ScheduledInterview{}, ErrNotFound
}
return s.GetScheduledInterview(ctx, id)
}
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).
Order("created_at, id").Find(&values).Error; err != nil {
return nil, err
}
result := make([]servicemodel.InterviewNote, len(values))
for i, value := range values {
result[i] = dbmapper.InterviewNoteToService(value)
}
return result, nil
}
func (s *Store) CreateInterviewNote(
ctx context.Context, value servicemodel.InterviewNote,
) (servicemodel.InterviewNote, error) {
entity := dbmodel.InterviewNote{
InterviewID: value.InterviewID, AuthorID: value.AuthorID, Body: value.Body,
}
if err := s.db.WithContext(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 {
return err
}
if interview.Status != "started" {
return ErrInterviewNotStarted
}
return tx.Create(&entity).Error
}); err != nil {
if errors.Is(err, ErrInterviewNotStarted) {
return servicemodel.InterviewNote{}, err
}
return servicemodel.InterviewNote{}, mapError(err)
}
if err := s.db.WithContext(ctx).Preload("Author").First(&entity, entity.ID).Error; err != nil {
return servicemodel.InterviewNote{}, mapError(err)
}
return dbmapper.InterviewNoteToService(entity), nil
}
func (s *Store) ListInbox(ctx context.Context, userID uint) ([]servicemodel.ScheduledInterview, error) {
var values []dbmodel.ScheduledInterview
if err := preloadScheduledInterview(s.db.WithContext(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 {
return nil, err
}
return scheduledInterviewsToService(values), nil
}
func (s *Store) UpdateAttendeeStatus(
ctx context.Context, interviewID, userID uint, status string,
) (servicemodel.InterviewAttendee, error) {
result := s.db.WithContext(ctx).Model(&dbmodel.InterviewAttendee{}).
Where("interview_id = ? AND user_id = ?", interviewID, userID).Update("status", status)
if result.Error != nil {
return servicemodel.InterviewAttendee{}, result.Error
}
if result.RowsAffected == 0 {
return servicemodel.InterviewAttendee{}, ErrNotFound
}
var value dbmodel.InterviewAttendee
if err := s.db.WithContext(ctx).Preload("User").
Where("interview_id = ? AND user_id = ?", interviewID, userID).First(&value).Error; err != nil {
return servicemodel.InterviewAttendee{}, mapError(err)
}
attendee := servicemodel.InterviewAttendee{
ID: value.ID, InterviewID: value.InterviewID, UserID: value.UserID,
Status: value.Status, User: dbmapper.UserToService(value.User),
CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt,
}
attendee.User.PasswordHash = ""
return attendee, nil
}
func (s *Store) ListCalendar(ctx context.Context, userID uint) ([]servicemodel.ScheduledInterview, error) {
var values []dbmodel.ScheduledInterview
if err := preloadScheduledInterview(s.db.WithContext(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 {
return nil, err
}
return scheduledInterviewsToService(values), nil
}
func (s *Store) resolveTraits(ctx context.Context, values []servicemodel.Trait) ([]dbmodel.Trait, error) {
result := make([]dbmodel.Trait, 0, len(values))
seen := make(map[string]struct{}, len(values))
@ -398,6 +635,23 @@ func preloadJob(db *gorm.DB) *gorm.DB {
Preload("Questions.Options")
}
func preloadScheduledInterview(db *gorm.DB) *gorm.DB {
return db.Preload("Job", func(db *gorm.DB) *gorm.DB {
return db.Select("id", "title")
}).Preload("Participant", func(db *gorm.DB) *gorm.DB {
return db.Select("id", "first_name", "last_name")
}).
Preload("Creator").Preload("Attendees.User").Preload("Notes.Author")
}
func scheduledInterviewsToService(values []dbmodel.ScheduledInterview) []servicemodel.ScheduledInterview {
result := make([]servicemodel.ScheduledInterview, len(values))
for i, value := range values {
result[i] = dbmapper.ScheduledInterviewToService(value)
}
return result
}
func mapError(err error) error {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrNotFound

View file

@ -1,6 +1,16 @@
INSERT INTO users (email, password_hash, role)
VALUES ('interviewer@go-guess.local', crypt('admin123', gen_salt('bf')), 'interviewer')
ON CONFLICT (email) DO NOTHING;
INSERT INTO users (email, password_hash, display_name, role)
VALUES ('interviewer@go-guess.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')
ON CONFLICT (email) DO UPDATE SET
password_hash = EXCLUDED.password_hash,
display_name = EXCLUDED.display_name,
role = EXCLUDED.role;
INSERT INTO job_postings (title, description, seniority, position, status, duration_minutes)
SELECT 'Senior Go Backend Engineer', 'Build reliable APIs for passenger-facing railway systems.',

View file

@ -1,6 +1,9 @@
INSERT INTO users (email, password_hash, role)
VALUES ('integration@example.com', crypt('integration-password', gen_salt('bf')), 'interviewer')
ON CONFLICT (email) DO NOTHING;
INSERT INTO users (email, password_hash, display_name, role)
VALUES ('integration@example.com', crypt('integration-password', gen_salt('bf')), 'Integration Interviewer', 'interviewer')
ON CONFLICT (email) DO UPDATE SET
password_hash = EXCLUDED.password_hash,
display_name = EXCLUDED.display_name,
role = EXCLUDED.role;
INSERT INTO job_postings (title, description, seniority, position, status, duration_minutes)
SELECT 'Integration Backend Engineer', 'Deterministic job fixture for API and UI integration tests.',