feat: add entity-scoped application events

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Daniel Noulet 2026-09-20 12:29:11 +02:00
parent 3364eacbcc
commit e787359c54
40 changed files with 2143 additions and 1437 deletions

View file

@ -74,7 +74,7 @@ interviewer review uses
Only completed assessment invitations can receive a `passed` or `failed` outcome,
and only passed candidates can be scheduled for an interview. Scheduled
interviews include the creator plus selected co-interviewers. Attendees manage
their invitation through `/api/inbox`, see accepted interviews in `/api/calendar`,
their invitation through `/api/invitations`, see accepted interviews in `/api/schedule`,
and can write notes only after the interview has started. Notes are private to
authenticated attendees. The opaque `/participant/meeting/:candidateToken` view
exposes only candidate-safe schedule and shared-document data.

View file

@ -38,7 +38,7 @@ dev:
docker compose up --build
db-up:
docker compose up -d db
docker compose up -d db rabbitmq
db-down:
docker compose down

View file

@ -15,10 +15,19 @@ type InterviewNoteEvent struct {
CreatedAt time.Time `json:"createdAt"`
}
type InterviewAttendeeEvent struct {
UserID uint `json:"userId"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
Status string `json:"status"`
}
type InterviewEvent struct {
Type string `json:"type"`
InterviewID uint `json:"interviewId"`
Note *InterviewNoteEvent `json:"note,omitempty"`
Attendee *InterviewAttendeeEvent `json:"attendee,omitempty"`
Status string `json:"status,omitempty"`
SharedDocument string `json:"sharedDocument,omitempty"`
}

View file

@ -297,14 +297,14 @@ func TestCompleteInterviewWorkflow(t *testing.T) {
}, http.StatusOK, &coLogin)
var inbox []webmodel.ScheduledInterviewResponse
requestJSON(t, http.MethodGet, "/api/inbox", coLogin.Token, nil, http.StatusOK, &inbox)
requestJSON(t, http.MethodGet, "/api/invitations", coLogin.Token, nil, http.StatusOK, &inbox)
if len(inbox) != 1 || inbox[0].ID != scheduled.ID {
t.Fatalf("scheduled interview missing from co-interviewer inbox: %#v", inbox)
}
requestJSON(t, http.MethodPatch, fmt.Sprintf("/api/inbox/%d", scheduled.ID),
requestJSON(t, http.MethodPatch, fmt.Sprintf("/api/invitations/%d", scheduled.ID),
coLogin.Token, map[string]any{"status": "accepted"}, http.StatusOK, &scheduled)
var calendar []webmodel.ScheduledInterviewResponse
requestJSON(t, http.MethodGet, "/api/calendar", coLogin.Token, nil, http.StatusOK, &calendar)
requestJSON(t, http.MethodGet, "/api/schedule", coLogin.Token, nil, http.StatusOK, &calendar)
if len(calendar) != 1 || calendar[0].ID != scheduled.ID {
t.Fatalf("accepted interview missing from calendar: %#v", calendar)
}

View file

@ -166,7 +166,16 @@ func (s *ScheduledInterviewService) UpdateStatus(
if !valid {
return model.ScheduledInterview{}, fmt.Errorf("%w: invalid interview status transition", ErrInvalidState)
}
return s.store.UpdateScheduledInterviewStatus(ctx, interviewID, status)
updated, err := s.store.UpdateScheduledInterviewStatus(ctx, interviewID, status)
if err != nil {
return model.ScheduledInterview{}, err
}
if err := s.bus.PublishInterviewEvent(ctx, eventbus.InterviewEvent{
Type: "status.updated", InterviewID: interviewID, Status: updated.Status,
}); err != nil {
return model.ScheduledInterview{}, fmt.Errorf("publish persisted interview status: %w", err)
}
return updated, nil
}
func (s *ScheduledInterviewService) UpdateDocument(
@ -243,10 +252,24 @@ func (s *ScheduledInterviewService) UpdateInbox(
if status != "accepted" && status != "declined" {
return model.ScheduledInterview{}, fmt.Errorf("%w: attendee status must be accepted or declined", ErrValidation)
}
if _, err := s.store.UpdateAttendeeStatus(ctx, interviewID, userID, status); err != nil {
attendee, err := s.store.UpdateAttendeeStatus(ctx, interviewID, userID, status)
if err != nil {
return model.ScheduledInterview{}, err
}
return s.Get(ctx, userID, interviewID)
updated, err := s.Get(ctx, userID, interviewID)
if err != nil {
return model.ScheduledInterview{}, err
}
if err := s.bus.PublishInterviewEvent(ctx, eventbus.InterviewEvent{
Type: "attendee.updated", InterviewID: interviewID,
Attendee: &eventbus.InterviewAttendeeEvent{
UserID: attendee.UserID, DisplayName: attendee.User.DisplayName,
Email: attendee.User.Email, Status: attendee.Status,
},
}); err != nil {
return model.ScheduledInterview{}, fmt.Errorf("publish persisted attendee status: %w", err)
}
return updated, nil
}
func (s *ScheduledInterviewService) ListCalendar(
@ -303,7 +326,7 @@ func (s *ScheduledInterviewService) subscribeToInterview(
if event.InterviewID != interviewID {
continue
}
if documentsOnly && event.Type != "document.updated" {
if documentsOnly && event.Type != "document.updated" && event.Type != "status.updated" {
continue
}
select {

View file

@ -34,6 +34,7 @@ type scheduledStore struct {
createdIDs []uint
status string
document string
attendee model.InterviewAttendee
}
func (f *scheduledStore) GetInvitation(context.Context, uint, uint) (model.Invitation, error) {
@ -68,6 +69,20 @@ func (f *scheduledStore) UpdateScheduledInterviewDocument(
f.interview.SharedDocument = document
return f.interview, nil
}
func (f *scheduledStore) UpdateAttendeeStatus(
_ context.Context, _, userID uint, status string,
) (model.InterviewAttendee, error) {
f.attendee = model.InterviewAttendee{
UserID: userID, Status: status,
User: model.User{ID: userID, DisplayName: "Ada", Email: "ada@example.com"},
}
for index := range f.interview.Attendees {
if f.interview.Attendees[index].UserID == userID {
f.interview.Attendees[index] = f.attendee
}
}
return f.attendee, nil
}
func (f *scheduledStore) CreateInterviewNote(
_ context.Context, value model.InterviewNote,
) (model.InterviewNote, error) {
@ -414,6 +429,7 @@ func TestUpdateDocumentPublishesEvent(t *testing.T) {
ID: 9, Status: "scheduled", Attendees: []model.InterviewAttendee{{UserID: 1}},
},
}
bus := eventbus.NewMemoryBus()
events, err := bus.SubscribeInterviewEvents(context.Background())
if err != nil {
@ -431,6 +447,36 @@ func TestUpdateDocumentPublishesEvent(t *testing.T) {
}
}
func TestStatusAndAttendeeUpdatesPublishEvents(t *testing.T) {
store := &scheduledStore{
fakeStore: &fakeStore{},
interview: model.ScheduledInterview{
ID: 9, Status: "scheduled",
Attendees: []model.InterviewAttendee{{UserID: 1, Status: "pending"}},
},
}
bus := eventbus.NewMemoryBus()
events, err := bus.SubscribeInterviewEvents(context.Background())
if err != nil {
t.Fatal(err)
}
interviews := NewScheduledInterviewService(store, bus)
if _, err := interviews.UpdateStatus(context.Background(), 1, 9, "started"); err != nil {
t.Fatal(err)
}
if event := <-events; event.Type != "status.updated" || event.Status != "started" {
t.Fatalf("unexpected status event: %#v", event)
}
store.interview.Status = "scheduled"
if _, err := interviews.UpdateInbox(context.Background(), 1, 9, "accepted"); err != nil {
t.Fatal(err)
}
if event := <-events; event.Type != "attendee.updated" || event.Attendee == nil ||
event.Attendee.UserID != 1 || event.Attendee.Status != "accepted" {
t.Fatalf("unexpected attendee event: %#v", event)
}
}
func TestParticipantSubscriptionFiltersPrivateNotes(t *testing.T) {
bus := eventbus.NewMemoryBus()
interviews := NewScheduledInterviewService(&fakeStore{}, bus)
@ -455,14 +501,27 @@ func TestParticipantSubscriptionFiltersPrivateNotes(t *testing.T) {
}); err != nil {
t.Fatal(err)
}
if err := bus.PublishInterviewEvent(ctx, eventbus.InterviewEvent{
Type: "status.updated", InterviewID: 0, Status: "started",
}); err != nil {
t.Fatal(err)
}
select {
case event := <-events:
if event.Type != "document.updated" || event.SharedDocument != "candidate-safe" {
t.Fatalf("participant received unexpected event: %#v", event)
t.Fatalf("participant received unexpected document event: %#v", event)
}
case <-time.After(time.Second):
t.Fatal("participant did not receive document event")
}
select {
case event := <-events:
if event.Type != "status.updated" || event.Status != "started" {
t.Fatalf("participant received unexpected event: %#v", event)
}
case <-time.After(time.Second):
t.Fatal("participant did not receive status event")
}
}
func TestUserCreationHashesPasswordAndUsesCoInterviewerRole(t *testing.T) {

View file

@ -47,9 +47,9 @@ func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string)
protected.Get("/api/scheduled-interviews/{id}/events", h.InterviewEvents)
protected.Get("/api/users", h.ListUsers)
protected.Post("/api/users", h.CreateUser)
protected.Get("/api/inbox", h.ListInbox)
protected.Patch("/api/inbox/{id}", h.UpdateInbox)
protected.Get("/api/calendar", h.ListCalendar)
protected.Get("/api/invitations", h.ListInbox)
protected.Patch("/api/invitations/{id}", h.UpdateInbox)
protected.Get("/api/schedule", h.ListCalendar)
protected.Get("/api/questions", h.ListQuestions)
protected.Post("/api/questions", h.CreateQuestion)
protected.Put("/api/questions/{id}", h.UpdateQuestion)

View file

@ -75,11 +75,11 @@ Feature: Recruitment workspace
Scenario: User story - an interviewer can reach collaboration areas
Given I am signed in as a valid interviewer
When I follow the "Inbox" link
Then I should see the heading "Inbox"
And I should see "Inbox zero"
When I follow the "Calendar" link
Then I should be on the "/calendar" page
When I follow the "Invitations" link
Then I should see the heading "Invitations"
And I should see "Invitation zero"
When I follow the "Schedule" link
Then I should be on the "/schedule" page
And I should see "Schedule"
When I follow the "Users" link
Then I should see the heading "Interview team"

55
deploy/local.yaml Normal file
View file

@ -0,0 +1,55 @@
# Generated by Rancher
# name: kubeconfig-ldfvp
# createdTimestamp: 2026-09-20T09:13:04Z
# ttl: 2592000
apiVersion: v1
kind: Config
clusters:
- name: "rancher"
cluster:
server: "https://rancher.urpi.be"
certificate-authority-data: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJ2VENDQ\
VdPZ0F3SUJBZ0lCQURBS0JnZ3Foa2pPUFFRREFqQkdNUnd3R2dZRFZRUUtFeE5rZVc1aGJXbGoKY\
kdsemRHVnVaWEl0YjNKbk1TWXdKQVlEVlFRRERCMWtlVzVoYldsamJHbHpkR1Z1WlhJdFkyRkFNV\
GM0T1RnNQpNVGczTURBZUZ3MHlOakE1TWpBd056RXhNVEJhRncwek5qQTVNVGN3TnpFeE1UQmFNR\
Vl4SERBYUJnTlZCQW9UCkUyUjVibUZ0YVdOc2FYTjBaVzVsY2kxdmNtY3hKakFrQmdOVkJBTU1IV\
1I1Ym1GdGFXTnNhWE4wWlc1bGNpMWoKWVVBeE56ZzVPRGt4T0Rjd01Ga3dFd1lIS29aSXpqMENBU\
VlJS29aSXpqMERBUWNEUWdBRXNIVEFSdUJJcjNZQQo0NlVzRmM5N1lkRHd1eGtnNHNSU21ML3ZCS\
Th3dWh5Z2M4Wm1jVUI4VWhWbUZvdEd4UnFqR3dWNG1GYXlxdGdQCmNnQVZmdkJ4bUtOQ01FQXdEZ\
1lEVlIwUEFRSC9CQVFEQWdLa01BOEdBMVVkRXdFQi93UUZNQU1CQWY4d0hRWUQKVlIwT0JCWUVGT\
kNyVndRcjFXeWFpdnJMS1BLZ0hmbUVjRUFyTUFvR0NDcUdTTTQ5QkFNQ0EwZ0FNRVVDSVFERAorU\
2lMaXdOODFNU2FEOWEzaStUNkJGOHVjdXNKQm5xM2pJdHV4WGpFMlFJZ1Z5L3FNaEtRSFRIa3pzV\
jIwWThBCll5SWdTWWZ1czZkaFdwNEI5TFl2eEVzPQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0t"
- name: "local"
cluster:
server: "https://rancher.urpi.be/k8s/clusters/local"
certificate-authority-data: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJ2VENDQ\
VdPZ0F3SUJBZ0lCQURBS0JnZ3Foa2pPUFFRREFqQkdNUnd3R2dZRFZRUUtFeE5rZVc1aGJXbGoKY\
kdsemRHVnVaWEl0YjNKbk1TWXdKQVlEVlFRRERCMWtlVzVoYldsamJHbHpkR1Z1WlhJdFkyRkFNV\
GM0T1RnNQpNVGczTURBZUZ3MHlOakE1TWpBd056RXhNVEJhRncwek5qQTVNVGN3TnpFeE1UQmFNR\
Vl4SERBYUJnTlZCQW9UCkUyUjVibUZ0YVdOc2FYTjBaVzVsY2kxdmNtY3hKakFrQmdOVkJBTU1IV\
1I1Ym1GdGFXTnNhWE4wWlc1bGNpMWoKWVVBeE56ZzVPRGt4T0Rjd01Ga3dFd1lIS29aSXpqMENBU\
VlJS29aSXpqMERBUWNEUWdBRXNIVEFSdUJJcjNZQQo0NlVzRmM5N1lkRHd1eGtnNHNSU21ML3ZCS\
Th3dWh5Z2M4Wm1jVUI4VWhWbUZvdEd4UnFqR3dWNG1GYXlxdGdQCmNnQVZmdkJ4bUtOQ01FQXdEZ\
1lEVlIwUEFRSC9CQVFEQWdLa01BOEdBMVVkRXdFQi93UUZNQU1CQWY4d0hRWUQKVlIwT0JCWUVGT\
kNyVndRcjFXeWFpdnJMS1BLZ0hmbUVjRUFyTUFvR0NDcUdTTTQ5QkFNQ0EwZ0FNRVVDSVFERAorU\
2lMaXdOODFNU2FEOWEzaStUNkJGOHVjdXNKQm5xM2pJdHV4WGpFMlFJZ1Z5L3FNaEtRSFRIa3pzV\
jIwWThBCll5SWdTWWZ1czZkaFdwNEI5TFl2eEVzPQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0t"
users:
- name: "rancher"
user:
token: "ext/token-9fzp2:d729wqhv4gbjhd9qjghs74f7h6wqcmml9c79stw8bw5nj2w9p752nw"
contexts:
- name: "rancher"
context:
user: "rancher"
cluster: "rancher"
- name: "local"
context:
user: "rancher"
cluster: "local"
current-context: "local"

View file

@ -67,8 +67,8 @@ Supported routes:
- `PUT /api/interviews/:token/answers/:questionId`
- `POST /api/interviews/:token/finish`
- `GET|POST /api/users`
- `GET /api/inbox` and `PATCH /api/inbox/:interviewId`
- `GET /api/calendar`
- `GET /api/invitations` and `PATCH /api/invitations/:interviewId`
- `GET /api/schedule`
- `GET /api/scheduled-interviews/:id`
- `PATCH /api/scheduled-interviews/:id/status`
- `PATCH /api/scheduled-interviews/:id/document`
@ -98,9 +98,9 @@ form for the date, location, co-interviewers, and candidate-visible shared docum
The authenticated workspace also includes:
- **Users** for listing and creating co-interviewers.
- **Inbox** in the top-right account navigation for accepting or declining assignments.
- **Invitations** in the top-right account navigation for accepting or declining assignments.
- **Profile** in the top-right account navigation for the logged-in user's identity and role.
- **Calendar** for interviews grouped by date.
- **Schedule** for interviews grouped by date.
- **Interviewer sessions** for changing status, editing prominent shared documentation, and
posting private notes while an interview is started. Notes and shared-document changes arrive
live through an authenticated streaming `fetch`; the app intentionally does not use

View file

@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api, authStorage, SESSION_EXPIRED_EVENT } from './client'
import { subscribeToDomainEvent } from './domainEvents'
describe('API client', () => {
beforeEach(() => {
@ -82,4 +83,62 @@ describe('API client', () => {
const form = options.body as FormData
expect([...form.keys()]).toEqual(['firstName', 'lastName', 'birthday', 'email', 'contactInfo'])
})
it('publishes an entity event after a successful mutation', async () => {
const job = {
id: 7,
title: 'Backend engineer',
description: 'Build APIs',
seniority: 'senior',
position: 'backend',
labels: [],
requiredSkills: ['Go'],
additionalSkills: [],
status: 'published',
durationMinutes: 60,
questions: [],
createdAt: '2026-09-01T00:00:00Z',
} as const
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(JSON.stringify(job), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
),
)
const listener = vi.fn()
const unsubscribe = subscribeToDomainEvent(`jobs/${job.id}`, listener)
await api.jobs.update(job.id, { status: 'published', durationMinutes: 60 })
expect(listener).toHaveBeenCalledWith({
resource: 'jobs/7',
type: 'job.updated',
data: job,
})
unsubscribe()
})
it('does not publish an event when a mutation fails', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: 'invalid transition' }), {
status: 409,
headers: { 'Content-Type': 'application/json' },
}),
),
)
const listener = vi.fn()
const unsubscribe = subscribeToDomainEvent('scheduled-interviews/80', listener)
await expect(api.scheduledInterviews.setStatus(80, 'started')).rejects.toMatchObject({
status: 409,
})
expect(listener).not.toHaveBeenCalled()
unsubscribe()
})
})

View file

@ -17,6 +17,7 @@ import type {
User,
InterviewNote,
} from '../types'
import { publishDomainEvent } from './domainEvents'
const TOKEN_KEY = 'go-guess-token'
const USER_KEY = 'go-guess-user'
@ -115,9 +116,20 @@ async function requestBlob(path: string): Promise<Blob> {
if (response.status === 401) invalidateSession()
throw new ApiError(`Request failed with status ${response.status}`, response.status)
}
return response.blob()
}
async function mutation<T>(
response: Promise<T>,
type: string,
resources: (data: T) => string[],
): Promise<T> {
const data = await response
publishDomainEvent(resources(data), type, data)
return data
}
async function subscribeToInterviewEvents(
path: string,
onEvent: (event: InterviewEvent) => void,
@ -181,18 +193,34 @@ export const api = {
list: () => request<Job[]>('/jobs'),
get: (id: number) => request<Job>(`/jobs/${id}`),
create: (input: CreateJobInput) =>
mutation(
request<Job>('/jobs', { method: 'POST', body: JSON.stringify(input) }),
'job.created',
(job) => ['jobs', `jobs/${job.id}`],
),
update: (jobId: number, input: { status: JobStatus; durationMinutes: number }) =>
mutation(
request<Job>(`/jobs/${jobId}`, {
method: 'PATCH',
body: JSON.stringify(input),
}),
'job.updated',
() => ['jobs', `jobs/${jobId}`],
),
attachQuestion: (jobId: number, questionId: number) =>
mutation(
request<void>(`/jobs/${jobId}/questions/${questionId}`, {
method: 'POST',
}),
'job.question-attached',
() => ['jobs', `jobs/${jobId}`, `questions/${questionId}`],
),
detachQuestion: (jobId: number, questionId: number) =>
mutation(
request<void>(`/jobs/${jobId}/questions/${questionId}`, { method: 'DELETE' }),
'job.question-detached',
() => ['jobs', `jobs/${jobId}`, `questions/${questionId}`],
),
candidates: async (jobId: number) => {
const matches = await request<CandidateMatch[]>(`/jobs/${jobId}/candidates`)
return matches.map((match) => ({
@ -205,50 +233,100 @@ export const api = {
invitation: (jobId: number, invitationId: number) =>
request<Interview>(`/jobs/${jobId}/invitations/${invitationId}`),
invite: (jobId: number, participantId: number) =>
mutation(
request<Invitation>(`/jobs/${jobId}/invitations`, {
method: 'POST',
body: JSON.stringify({ participantId }),
}),
'invitation.created',
(invitation) => [
`jobs/${jobId}`,
`job-invitations/${jobId}`,
`assessment-invitations/${invitation.id}`,
],
),
setInvitationOutcome: (jobId: number, invitationId: number, outcome: Invitation['outcome']) =>
mutation(
request<Invitation>(`/jobs/${jobId}/invitations/${invitationId}/outcome`, {
method: 'PATCH',
body: JSON.stringify({ outcome }),
}),
'invitation.outcome-updated',
() => [
`jobs/${jobId}`,
`job-invitations/${jobId}`,
`assessment-invitations/${invitationId}`,
],
),
interviews: (jobId: number) => request<ScheduledInterview[]>(`/jobs/${jobId}/interviews`),
scheduleInterview: (jobId: number, input: CreateScheduledInterviewInput) =>
mutation(
request<ScheduledInterview>(`/jobs/${jobId}/interviews`, {
method: 'POST',
body: JSON.stringify(input),
}),
'scheduled-interview.created',
(interview) => [
`jobs/${jobId}`,
'scheduled-interviews',
`scheduled-interviews/${interview.id}`,
'invitations',
'schedule',
],
),
},
questions: {
list: (search = '') =>
request<Question[]>(`/questions${search ? `?search=${encodeURIComponent(search)}` : ''}`),
create: (input: CreateQuestionInput) =>
mutation(
request<Question>('/questions', { method: 'POST', body: JSON.stringify(input) }),
'question.created',
(question) => ['questions', `questions/${question.id}`],
),
update: (id: number, input: CreateQuestionInput) =>
mutation(
request<Question>(`/questions/${id}`, {
method: 'PUT',
body: JSON.stringify(input),
}),
'question.updated',
() => ['questions', `questions/${id}`],
),
setDeprecated: (id: number, deprecated: boolean) =>
mutation(
request<Question>(`/questions/${id}`, {
method: 'PATCH',
body: JSON.stringify({ deprecated }),
}),
'question.deprecation-updated',
() => ['questions', `questions/${id}`],
),
},
interviews: {
get: (token: string) => request<Interview>(`/interviews/${token}`, {}, false),
accept: (token: string) =>
mutation(
request<Interview>(`/interviews/${token}/accept`, { method: 'POST' }, false),
'assessment-invitation.accepted',
(interview) => [`interviews/${token}`, `assessment-invitations/${interview.invitation.id}`],
),
answer: (token: string, questionId: number, answer: string) =>
mutation(
request<void>(
`/interviews/${token}/answers/${questionId}`,
{ method: 'PUT', body: JSON.stringify({ answer }) },
false,
),
'assessment-answer.saved',
() => [`interviews/${token}`],
),
finish: (token: string) =>
mutation(
request<Interview>(`/interviews/${token}/finish`, { method: 'POST' }, false),
'assessment-invitation.completed',
(interview) => [`interviews/${token}`, `assessment-invitations/${interview.invitation.id}`],
),
},
participants: {
list: () => request<Participant[]>('/participants'),
@ -264,41 +342,70 @@ export const api = {
form.append('contactInfo', input.contactInfo)
if (input.photo) form.append('photo', input.photo)
if (input.cv) form.append('cv', input.cv)
return request<Participant>('/participants', { method: 'POST', body: form })
return mutation(
request<Participant>('/participants', { method: 'POST', body: form }),
'participant.created',
(participant) => ['participants', `participants/${participant.id}`],
)
},
},
users: {
list: () => request<User[]>('/users'),
create: (input: { email: string; password: string; displayName: string }) =>
mutation(
request<User>('/users', { method: 'POST', body: JSON.stringify(input) }),
'user.created',
(user) => ['users', `users/${user.id}`],
),
},
inbox: {
list: () => request<ScheduledInterview[]>('/inbox'),
list: () => request<ScheduledInterview[]>('/invitations'),
respond: (interviewId: number, status: 'accepted' | 'declined') =>
request<ScheduledInterview>(`/inbox/${interviewId}`, {
mutation(
request<ScheduledInterview>(`/invitations/${interviewId}`, {
method: 'PATCH',
body: JSON.stringify({ status }),
}),
'interview-invitation.responded',
() => [
'invitations',
`invitations/${interviewId}`,
'schedule',
`scheduled-interviews/${interviewId}`,
],
),
},
calendar: () => request<ScheduledInterview[]>('/calendar'),
calendar: () => request<ScheduledInterview[]>('/schedule'),
scheduledInterviews: {
get: (id: number) => request<ScheduledInterview>(`/scheduled-interviews/${id}`),
setStatus: (id: number, status: string) =>
mutation(
request<ScheduledInterview>(`/scheduled-interviews/${id}/status`, {
method: 'PATCH',
body: JSON.stringify({ status }),
}),
'scheduled-interview.status-updated',
() => ['scheduled-interviews', `scheduled-interviews/${id}`, 'schedule', 'invitations'],
),
updateDocument: (id: number, sharedDocument: string) =>
mutation(
request<ScheduledInterview>(`/scheduled-interviews/${id}/document`, {
method: 'PATCH',
body: JSON.stringify({ sharedDocument }),
}),
'scheduled-interview.document-updated',
() => [`scheduled-interviews/${id}`],
),
notes: (id: number) => request<InterviewNote[]>(`/scheduled-interviews/${id}/notes`),
addNote: (id: number, body: string) =>
mutation(
request<InterviewNote>(`/scheduled-interviews/${id}/notes`, {
method: 'POST',
body: JSON.stringify({ body }),
}),
'scheduled-interview.note-created',
() => [`scheduled-interviews/${id}/notes`],
),
subscribe: (
id: number,
onEvent: (event: InterviewEvent) => void,

View file

@ -0,0 +1,32 @@
import { describe, expect, it, vi } from 'vitest'
import { publishDomainEvent, subscribeToDomainEvent } from './domainEvents'
describe('domain events', () => {
it('delivers an event only to its resource subscribers', () => {
const jobListener = vi.fn()
const otherListener = vi.fn()
const unsubscribeJob = subscribeToDomainEvent('jobs/7', jobListener)
const unsubscribeOther = subscribeToDomainEvent('jobs/8', otherListener)
publishDomainEvent(['jobs', 'jobs/7'], 'job.updated', { id: 7 })
expect(jobListener).toHaveBeenCalledWith({
resource: 'jobs/7',
type: 'job.updated',
data: { id: 7 },
})
expect(otherListener).not.toHaveBeenCalled()
unsubscribeJob()
unsubscribeOther()
})
it('stops delivering events after unsubscribe', () => {
const listener = vi.fn()
const unsubscribe = subscribeToDomainEvent('questions/2', listener)
unsubscribe()
publishDomainEvent(['questions/2'], 'question.updated', { id: 2 })
expect(listener).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,29 @@
export interface DomainEvent<T = unknown> {
type: string
resource: string
data: T
}
type DomainEventListener<T> = (event: DomainEvent<T>) => void
const target = new EventTarget()
export function publishDomainEvent<T>(resources: string[], type: string, data: T) {
for (const resource of resources) {
target.dispatchEvent(
new CustomEvent(resource, {
detail: { type, resource, data } satisfies DomainEvent<T>,
}),
)
}
}
export function subscribeToDomainEvent<T>(
resource: string,
listener: DomainEventListener<T>,
signal?: AbortSignal,
) {
const handle = (event: Event) => listener((event as CustomEvent<DomainEvent<T>>).detail)
target.addEventListener(resource, handle, { signal })
return () => target.removeEventListener(resource, handle)
}

View file

@ -224,7 +224,7 @@ describe('Go Guess frontend', () => {
}),
)
renderApp('/calendar')
renderApp('/schedule')
expect(
await screen.findByRole('heading', { name: 'Sign in to your workspace' }),
@ -251,9 +251,9 @@ describe('Go Guess frontend', () => {
expect(screen.getByText('admin@example.com')).toBeInTheDocument()
const accountNavigation = screen.getByRole('navigation', { name: 'Account navigation' })
expect(within(accountNavigation).getByRole('link', { name: 'Inbox' })).toHaveAttribute(
expect(within(accountNavigation).getByRole('link', { name: 'Invitations' })).toHaveAttribute(
'href',
'/inbox',
'/invitations',
)
expect(within(accountNavigation).getByRole('link', { name: 'Admin' })).toHaveAttribute(
'href',
@ -261,7 +261,9 @@ describe('Go Guess frontend', () => {
)
const primaryNavigation = screen.getByRole('navigation', { name: 'Primary navigation' })
expect(within(primaryNavigation).queryByRole('link', { name: 'Inbox' })).not.toBeInTheDocument()
expect(
within(primaryNavigation).queryByRole('link', { name: 'Invitations' }),
).not.toBeInTheDocument()
const footerNavigation = screen.getByRole('navigation', { name: 'Footer navigation' })
expect(within(footerNavigation).getByRole('link', { name: 'Jobs' })).toHaveAttribute(
@ -407,11 +409,24 @@ describe('Go Guess frontend', () => {
it('attaches and detaches library questions without deleting them', async () => {
authenticate()
const user = userEvent.setup()
let attached = false
const defaultFetch = vi.mocked(fetch).getMockImplementation()
vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => {
const path = String(input)
const method = options?.method ?? 'GET'
if (path === '/api/jobs/7/questions/11') {
attached = method !== 'DELETE'
return response({}, method === 'DELETE' ? 200 : 201)
}
if (path === '/api/jobs/7') return response({ ...job, questions: attached ? [question] : [] })
if (!defaultFetch) throw new Error(`Unexpected request: ${path}`)
return defaultFetch(input, options)
})
renderApp('/jobs/7')
await user.click(await screen.findByRole('tab', { name: /questions/i }))
await user.click(await screen.findByRole('button', { name: 'Attach' }))
await user.click(screen.getByRole('button', { name: `Detach ${question.text}` }))
await user.click(await screen.findByRole('button', { name: `Detach ${question.text}` }))
expect(fetch).toHaveBeenCalledWith(
'/api/jobs/7/questions/11',
@ -838,7 +853,7 @@ describe('Go Guess frontend', () => {
)
})
it('accepts and declines interviews from the inbox', async () => {
it('updates an invitation card through its entity-scoped event', async () => {
authenticate()
const user = userEvent.setup()
const scheduled = {
@ -853,33 +868,48 @@ describe('Go Guess frontend', () => {
candidateToken: 'meeting-token',
candidateUrl: '/participant/meeting/meeting-token',
sharedDocument: 'Agenda',
attendees: [],
attendees: [
{
userId: 1,
displayName: 'Admin',
email: 'admin@example.com',
status: 'pending',
},
],
notes: [],
}
const defaultFetch = vi.mocked(fetch).getMockImplementation()
vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => {
const path = String(input)
if (path === '/api/inbox' && !options?.method) return response([scheduled])
if (path === '/api/inbox/80')
return response({ ...scheduled, status: JSON.parse(String(options?.body)).status })
if (path === '/api/invitations' && !options?.method) return response([scheduled])
if (path === '/api/invitations/80')
return response({
...scheduled,
attendees: [
{
...scheduled.attendees[0],
status: JSON.parse(String(options?.body)).status,
},
],
})
if (!defaultFetch) throw new Error(`Unexpected request: ${path}`)
return defaultFetch(input, options)
})
renderApp('/inbox')
renderApp('/invitations')
await user.click(await screen.findByRole('button', { name: 'Accept' }))
await user.click(screen.getByRole('button', { name: 'Decline' }))
expect(fetch).toHaveBeenCalledWith(
'/api/inbox/80',
'/api/invitations/80',
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ status: 'accepted' }) }),
)
expect(screen.getByText('declined')).toBeInTheDocument()
expect(await screen.findByRole('link', { name: 'Open session' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Accept' })).not.toBeInTheDocument()
})
it('groups calendar interviews by date and links to the session', async () => {
authenticate()
const defaultFetch = vi.mocked(fetch).getMockImplementation()
vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => {
if (String(input) === '/api/calendar')
if (String(input) === '/api/schedule')
return response([
{
id: 80,
@ -900,7 +930,7 @@ describe('Go Guess frontend', () => {
if (!defaultFetch) throw new Error(`Unexpected request: ${String(input)}`)
return defaultFetch(input, options)
})
renderApp('/calendar')
renderApp('/schedule')
expect(await screen.findByText('Amélie Dubois')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Open session' })).toHaveAttribute(
'href',

View file

@ -6,7 +6,7 @@ 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 { InboxPage, ScheduledInterviewPage } from '../features/interviews'
import { InvitationPage, ScheduledInterviewPage } from '../features/interviews'
import { CreateJobPage, JobDetailPage, JobsPage } from '../features/jobs'
import {
CreateParticipantPage,
@ -14,7 +14,7 @@ import {
ParticipantsPage,
} from '../features/participants'
import { QuestionLibraryPage, QuestionPage } from '../features/questions'
import { CalendarPage, ProfilePage, UsersPage } from '../features/users'
import { SchedulePage, ProfilePage, UsersPage } from '../features/users'
export function AuthenticatedApp({ onLogout }: { onLogout: () => void }) {
const [open, setOpen] = useState(false)
@ -37,8 +37,8 @@ export function AuthenticatedApp({ onLogout }: { onLogout: () => void }) {
<Route path="/participants/:participantId" element={<ParticipantDetailPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/inbox" element={<InboxPage />} />
<Route path="/calendar" element={<CalendarPage />} />
<Route path="/invitations" element={<InvitationPage />} />
<Route path="/schedule" element={<SchedulePage />} />
<Route path="/scheduled-interviews/:id" element={<ScheduledInterviewPage />} />
<Route path="*" element={<NotFound />} />
</Routes>

View file

@ -3,6 +3,7 @@ import { api } from '../api/client'
import { errorMessage } from '../components/actions'
import type { CreateJobInput, CreateParticipantInput, Job, Participant } from '../types'
import { DataContext } from './dataState'
import { useDomainEvent } from './useDomainEvent'
export function DataProvider({ children }: { children: ReactNode }) {
const [jobs, setJobs] = useState<Job[]>([])
@ -10,8 +11,8 @@ export function DataProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
async function reload() {
setLoading(true)
async function refresh(showLoading: boolean) {
if (showLoading) setLoading(true)
setError('')
try {
const [jobItems, participantItems] = await Promise.all([
@ -23,10 +24,14 @@ export function DataProvider({ children }: { children: ReactNode }) {
} catch (requestError) {
setError(errorMessage(requestError))
} finally {
setLoading(false)
if (showLoading) setLoading(false)
}
}
async function reload() {
await refresh(true)
}
useEffect(() => {
let active = true
void Promise.all([api.jobs.list(), api.participants.list()])
@ -46,16 +51,28 @@ export function DataProvider({ children }: { children: ReactNode }) {
}
}, [])
useDomainEvent<Job | undefined>('jobs', (event) => {
const job = event.data
if (job && (event.type === 'job.created' || event.type === 'job.updated')) {
setJobs((current) => [job, ...current.filter((currentJob) => currentJob.id !== job.id)])
return
}
void refresh(false)
})
useDomainEvent<Participant>('participants', (event) => {
setParticipants((current) => [
event.data,
...current.filter((participant) => participant.id !== event.data.id),
])
})
async function createJob(input: CreateJobInput) {
const created = await api.jobs.create(input)
setJobs((current) => [created, ...current])
return created
return api.jobs.create(input)
}
async function createParticipant(input: CreateParticipantInput) {
const created = await api.participants.create(input)
setParticipants((current) => [created, ...current])
return created
return api.participants.create(input)
}
return (

View file

@ -0,0 +1,16 @@
import { useEffect, useRef } from 'react'
import { type DomainEvent, subscribeToDomainEvent } from '../api/domainEvents'
export function useDomainEvent<T>(resource: string, listener: (event: DomainEvent<T>) => void) {
const listenerRef = useRef(listener)
useEffect(() => {
listenerRef.current = listener
}, [listener])
useEffect(() => {
const controller = new AbortController()
subscribeToDomainEvent<T>(resource, (event) => listenerRef.current(event), controller.signal)
return () => controller.abort()
}, [resource])
}

View file

@ -6,13 +6,13 @@ export function WorkspaceUtilities({ user }: { user: User | null }) {
const profileLabel = user?.displayName || user?.email || 'Profile'
return (
<nav className="workspace-utilities" aria-label="Account navigation">
<NavLink className="utility-link" to="/inbox">
<NavLink className="utility-link" to="/invitations">
<Mail size={18} aria-hidden="true" />
<span>Inbox</span>
<span>Invitations</span>
</NavLink>
<NavLink className="utility-link" to="/calendar">
<NavLink className="utility-link" to="/schedule">
<CalendarDays size={18} aria-hidden="true" />
<span>Calendar</span>
<span>Schedule</span>
</NavLink>
<NavLink className="utility-link profile-utility" to="/profile">
<span className="avatar tiny" aria-hidden="true">

View file

@ -20,8 +20,8 @@ export const footerLinkGroups: FooterLinkGroup[] = [
{
title: 'Useful links',
links: [
{ label: 'Your inbox', href: '/inbox' },
{ label: 'Calendar', href: '/calendar' },
{ label: 'Invitations', href: '/invitations' },
{ label: 'Schedule', href: '/schedule' },
{ label: 'Your profile', href: '/profile' },
{ label: 'Interview team', href: '/users' },
],

View file

@ -2,6 +2,7 @@ import { ArrowLeft, ArrowRight, Check, CircleHelp, Clock3 } from 'lucide-react'
import { type ReactNode, useEffect, useRef, useState } from 'react'
import { useParams } from 'react-router-dom'
import { api } from '../../api/client'
import { useDomainEvent } from '../../app/useDomainEvent'
import { ErrorAlert, errorMessage, Loading } from '../../components/actions'
import Logo from '../../components/layout/Logo'
import type { Interview } from '../../types'
@ -40,6 +41,16 @@ export function ParticipantInterviewPage() {
}
}, [token])
useDomainEvent<Interview | undefined>(`interviews/${token}`, (event) => {
if (
!event.data ||
!['assessment-invitation.accepted', 'assessment-invitation.completed'].includes(event.type)
)
return
setInterview(event.data)
setAnswers(event.data.answers ?? {})
})
const acceptedAt = interview?.invitation.acceptedAt
const durationMinutes = interview?.job.durationMinutes
useEffect(() => {
@ -57,9 +68,7 @@ export function ParticipantInterviewPage() {
setWorking(true)
setError('')
try {
const accepted = await api.interviews.accept(token)
setInterview(accepted)
setAnswers(accepted.answers ?? {})
await api.interviews.accept(token)
} catch (requestError) {
setError(errorMessage(requestError))
} finally {
@ -107,7 +116,7 @@ export function ParticipantInterviewPage() {
setError('')
try {
await persistCurrent()
setInterview(await api.interviews.finish(token))
await api.interviews.finish(token)
} catch (requestError) {
finishing.current = false
setError(errorMessage(requestError))

View file

@ -217,7 +217,7 @@ export function DashboardPage() {
</span>
<ArrowRight size={18} />
</Link>
<Link to="/calendar">
<Link to="/schedule">
<Sparkles size={20} />
<span>
<strong>Review the calendar</strong>

View file

@ -0,0 +1,53 @@
import { useEffect, useState } from 'react'
import { api } from '../../api/client'
import { useDomainEvent } from '../../app/useDomainEvent'
import { errorMessage } from '../../components/actions'
import { ErrorAlert, Loading } from '../../components/ui/AsyncState'
import type { ScheduledInterview } from '../../types'
import { InvitationCard } from './components/InvitationCard'
export function InvitationPage() {
const [items, setItems] = useState<ScheduledInterview[]>([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
void api.inbox
.list()
.then(setItems)
.catch((value) => setError(errorMessage(value)))
.finally(() => setLoading(false))
}, [])
useDomainEvent<ScheduledInterview>('invitations', (event) => {
setItems((current) => current.map((item) => (item.id === event.data.id ? event.data : item)))
})
return (
<>
<header className="page-header">
<div>
<span className="eyebrow">Interview team</span>
<h1>Invitations</h1>
<p>Respond to interview invitations assigned to you.</p>
</div>
</header>
{error && <ErrorAlert error={error} />}
{loading ? (
<Loading />
) : (
<section className="meeting-list">
{items.map((item) => (
<InvitationCard invitation={item} onError={setError} key={item.id} />
))}
{!items.length && (
<div className="card empty-state">
<h3>Invitation zero</h3>
<p>You have no interview invitations.</p>
</div>
)}
</section>
)}
</>
)
}

View file

@ -0,0 +1,98 @@
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { api } from '../../api/client'
import { errorMessage } from '../../components/actions'
import { ErrorAlert, Loading } from '../../components/ui/AsyncState'
import type { ParticipantMeeting } from '../../types'
import { DateAndPlace } from './components/InterviewCard'
export function ParticipantMeetingPage() {
const token = useParams().token ?? ''
const [meeting, setMeeting] = useState<ParticipantMeeting>()
const [error, setError] = useState('')
useEffect(() => {
let active = true
let latestDocument: string | undefined
let latestStatus: string | undefined
const controller = new AbortController()
void api.participantMeetings
.subscribe(
token,
(event) => {
if (event.type === 'document.updated') latestDocument = event.sharedDocument
if (event.type === 'status.updated') latestStatus = event.status
setMeeting((current) => {
if (!current) return current
if (event.type === 'document.updated') {
return { ...current, sharedDocument: event.sharedDocument }
}
if (event.type === 'status.updated') return { ...current, status: event.status }
return current
})
},
controller.signal,
(value) => {
if (active) setError(errorMessage(value))
},
)
.catch((value) => {
if (active && !controller.signal.aborted) setError(errorMessage(value))
})
void api.participantMeetings
.get(token)
.then((value) => {
if (active) {
setMeeting({
...value,
status: latestStatus ?? value.status,
sharedDocument: latestDocument ?? value.sharedDocument,
})
}
})
.catch((value) => {
if (active) setError(errorMessage(value))
})
return () => {
active = false
controller.abort()
}
}, [token])
return (
<main className="public-interview">
<header>
<Link className="logo" to="/" aria-label="Go Guess home">
<span className="logo-mark">G</span>
<span>
<strong>GO GUESS</strong>
<small>Talent workspace</small>
</span>
</Link>
</header>
{!meeting && !error && (
<section className="interview-card">
<Loading />
</section>
)}
{error && (
<section className="interview-card">
<ErrorAlert error={error} />
</section>
)}
{meeting && (
<section className="interview-card meeting-public-card">
<span className="eyebrow">Your scheduled interview</span>
<h1>{meeting.jobTitle}</h1>
<p>Welcome, {meeting.participantName}.</p>
<span className={`status status-${meeting.status}`}>{meeting.status}</span>
<DateAndPlace interview={meeting} />
<section className="public-document" aria-labelledby="shared-document-title">
<h2 id="shared-document-title">Shared documentation</h2>
<div>{meeting.sharedDocument || 'No shared documentation has been added yet.'}</div>
</section>
</section>
)}
</main>
)
}

View file

@ -0,0 +1,170 @@
import { type Dispatch, type FormEvent, type SetStateAction, useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { api } from '../../api/client'
import { useDomainEvent } from '../../app/useDomainEvent'
import { errorMessage } from '../../components/actions'
import { ErrorAlert, Loading } from '../../components/ui/AsyncState'
import type { InterviewEvent, InterviewNote, ScheduledInterview } from '../../types'
import { DateAndPlace } from './components/InterviewCard'
import {
AttendeeList,
InterviewActions,
NotesPanel,
SessionGrid,
SharedDocumentPanel,
} from './components/ScheduledInterviewWorkspace'
import { applyInterviewEvent } from './interviewEvents'
export function ScheduledInterviewPage() {
const id = Number(useParams().id)
const [interview, setInterview] = useState<ScheduledInterview>()
const [document, setDocument] = useState('')
const [notes, setNotes] = useState<InterviewNote[]>([])
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
const [linkCopied, setLinkCopied] = useState(false)
useEffect(() => {
let active = true
void Promise.all([api.scheduledInterviews.get(id), api.scheduledInterviews.notes(id)])
.then(([item, loadedNotes]) => {
if (!active) return
setInterview(item)
setDocument(item.sharedDocument)
setNotes(loadedNotes)
})
.catch((value) => active && setError(errorMessage(value)))
return () => {
active = false
}
}, [id])
useDomainEvent<ScheduledInterview>(`scheduled-interviews/${id}`, (event) => {
if (event.type === 'scheduled-interview.note-created') return
setInterview(event.data)
setDocument(event.data.sharedDocument)
})
useDomainEvent<InterviewNote>(`scheduled-interviews/${id}/notes`, (event) => {
setNotes((current) =>
current.some((note) => note.id === event.data.id) ? current : [...current, event.data],
)
})
const interviewLoaded = interview !== undefined
useEffect(() => {
if (!interviewLoaded) return
const controller = new AbortController()
void api.scheduledInterviews
.subscribe(
id,
(event) => {
setInterview((current) => (current ? applyInterviewEvent(current, event) : current))
applyContentEvent(event, setDocument, setNotes)
},
controller.signal,
(value) => setError(errorMessage(value)),
)
.catch((value) => {
if (!controller.signal.aborted) setError(errorMessage(value))
})
return () => controller.abort()
}, [id, interviewLoaded])
async function setStatus(status: string) {
setError('')
try {
await api.scheduledInterviews.setStatus(id, status)
} catch (value) {
setError(errorMessage(value))
}
}
async function saveDocument(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setSaving(true)
setError('')
try {
await api.scheduledInterviews.updateDocument(id, document)
} catch (value) {
setError(errorMessage(value))
} finally {
setSaving(false)
}
}
async function addNote(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const body = String(new FormData(form).get('body')).trim()
if (!body) return
setError('')
try {
await api.scheduledInterviews.addNote(id, body)
form.reset()
} catch (value) {
setError(errorMessage(value))
}
}
async function copyParticipantLink() {
if (!interview) return
setError('')
try {
await navigator.clipboard.writeText(interview.candidateUrl)
setLinkCopied(true)
} catch {
setError('Could not copy the participant interview link')
}
}
if (!interview && !error) return <Loading />
if (!interview) return <ErrorAlert error={error} />
return (
<>
<Link className="back-link" to="/schedule">
← Schedule
</Link>
<header className="page-header">
<div>
<span className="eyebrow">Interviewer session</span>
<h1>{interview.participantName}</h1>
<p>{interview.jobTitle}</p>
</div>
<span className={`status status-${interview.status}`}>{interview.status}</span>
</header>
{error && <ErrorAlert error={error} />}
<DateAndPlace interview={interview} />
<InterviewActions
interview={interview}
linkCopied={linkCopied}
onCopyLink={() => void copyParticipantLink()}
onSetStatus={(status) => void setStatus(status)}
/>
<SessionGrid>
<SharedDocumentPanel
document={document}
saving={saving}
onDocumentChange={setDocument}
onSave={saveDocument}
/>
<NotesPanel notes={notes} canAdd={interview.status === 'started'} onAdd={addNote} />
</SessionGrid>
<AttendeeList interview={interview} />
</>
)
}
function applyContentEvent(
event: InterviewEvent,
setDocument: (value: string) => void,
setNotes: Dispatch<SetStateAction<InterviewNote[]>>,
) {
if (event.type === 'document.updated') setDocument(event.sharedDocument)
if (event.type === 'note.created') {
setNotes((current) =>
current.some((note) => note.id === event.note.id) ? current : [...current, event.note],
)
}
}

View file

@ -1,6 +1,9 @@
import { CalendarDays, MapPin } from 'lucide-react'
import type { ReactNode } from 'react'
import { type ReactNode, useEffect, useState } from 'react'
import { api } from '../../../api/client'
import { useDomainEvent } from '../../../app/useDomainEvent'
import type { ParticipantMeeting, ScheduledInterview } from '../../../types'
import { applyInterviewEvent } from '../interviewEvents'
export function DateAndPlace({
interview,
@ -24,19 +27,53 @@ export function DateAndPlace({
export function InterviewCard({
interview,
children,
onConnectionError,
}: {
interview: ScheduledInterview
children?: ReactNode
children?: ReactNode | ((current: ScheduledInterview) => ReactNode)
onConnectionError?: (error: unknown) => void
}) {
const [current, setCurrent] = useState(interview)
useDomainEvent<ScheduledInterview>(`scheduled-interviews/${interview.id}`, (event) => {
if (
event.type === 'scheduled-interview.created' ||
event.type === 'scheduled-interview.status-updated' ||
event.type === 'scheduled-interview.document-updated' ||
event.type === 'interview-invitation.responded'
) {
setCurrent(event.data)
}
})
useEffect(() => {
const controller = new AbortController()
const reportError = (error: unknown) => {
if (onConnectionError) onConnectionError(error)
else console.error('interview event stream failed', error)
}
void api.scheduledInterviews
.subscribe(
interview.id,
(event) => setCurrent((value) => applyInterviewEvent(value, event)),
controller.signal,
reportError,
)
.catch((error) => {
if (!controller.signal.aborted) reportError(error)
})
return () => controller.abort()
}, [interview.id, onConnectionError])
return (
<article className="meeting-card">
<div>
<span className={`status status-${interview.status}`}>{interview.status}</span>
<h2>{interview.participantName}</h2>
<p>{interview.jobTitle}</p>
<span className={`status status-${current.status}`}>{current.status}</span>
<h2>{current.participantName}</h2>
<p>{current.jobTitle}</p>
</div>
<DateAndPlace interview={interview} />
{children}
<DateAndPlace interview={current} />
{typeof children === 'function' ? children(current) : children}
</article>
)
}

View file

@ -0,0 +1,56 @@
import { Check, X } from 'lucide-react'
import { Link } from 'react-router-dom'
import { api, authStorage } from '../../../api/client'
import { errorMessage } from '../../../components/actions'
import type { ScheduledInterview } from '../../../types'
import { InterviewCard } from './InterviewCard'
export function InvitationCard({
invitation,
onError,
}: {
invitation: ScheduledInterview
onError: (message: string) => void
}) {
async function respond(status: 'accepted' | 'declined') {
onError('')
try {
await api.inbox.respond(invitation.id, status)
} catch (error) {
onError(errorMessage(error))
}
}
return (
<InterviewCard
interview={invitation}
onConnectionError={(error) => onError(errorMessage(error))}
>
{(current) =>
(current.attendees.find((attendee) => attendee.userId === authStorage.user()?.id)?.status ??
current.status) === 'pending' ? (
<div className="meeting-actions">
<button
className="button primary"
type="button"
onClick={() => void respond('accepted')}
>
<Check size={17} /> Accept
</button>
<button
className="button secondary danger"
type="button"
onClick={() => void respond('declined')}
>
<X size={17} /> Decline
</button>
</div>
) : (
<Link className="button secondary" to={`/scheduled-interviews/${current.id}`}>
Open session
</Link>
)
}
</InterviewCard>
)
}

View file

@ -0,0 +1,151 @@
import { Check, Copy, UsersRound } from 'lucide-react'
import { type FormEvent, type ReactNode } from 'react'
import type { InterviewNote, ScheduledInterview } from '../../../types'
export function InterviewActions({
interview,
linkCopied,
onCopyLink,
onSetStatus,
}: {
interview: ScheduledInterview
linkCopied: boolean
onCopyLink: () => void
onSetStatus: (status: string) => void
}) {
return (
<div className="session-actions">
<button
className="button secondary"
type="button"
onClick={onCopyLink}
aria-label="Copy participant interview link"
>
{linkCopied ? <Check size={17} /> : <Copy size={17} />}
{linkCopied ? 'Link copied' : 'Copy participant link'}
</button>
{interview.status !== 'started' && interview.status !== 'completed' && (
<button className="button primary" type="button" onClick={() => onSetStatus('started')}>
Start interview
</button>
)}
{interview.status === 'started' && (
<button className="button primary" type="button" onClick={() => onSetStatus('completed')}>
Complete interview
</button>
)}
</div>
)
}
export function SharedDocumentPanel({
document,
saving,
onDocumentChange,
onSave,
}: {
document: string
saving: boolean
onDocumentChange: (value: string) => void
onSave: (event: FormEvent<HTMLFormElement>) => void
}) {
return (
<form className="card shared-document" onSubmit={onSave}>
<div className="section-heading">
<div>
<span className="eyebrow">Visible to candidate</span>
<h2>Shared documentation</h2>
</div>
</div>
<div className="form-section">
<textarea
aria-label="Shared documentation"
rows={18}
value={document}
onChange={(event) => onDocumentChange(event.target.value)}
/>
<button className="button primary" type="submit" disabled={saving}>
{saving ? 'Saving…' : 'Save shared documentation'}
</button>
</div>
</form>
)
}
export function NotesPanel({
notes,
canAdd,
onAdd,
}: {
notes: InterviewNote[]
canAdd: boolean
onAdd: (event: FormEvent<HTMLFormElement>) => void
}) {
return (
<section className="card notes-panel">
<div className="section-heading">
<div>
<span className="eyebrow">Interviewers only</span>
<h2>Live notes</h2>
</div>
</div>
<div className="notes-list" aria-live="polite">
{notes.map((note) => (
<article className="note" key={note.id}>
<strong>{note.authorName}</strong>
<time>{new Date(note.createdAt).toLocaleTimeString()}</time>
<p>{note.body}</p>
</article>
))}
</div>
{canAdd ? (
<form className="note-form" onSubmit={onAdd}>
<label className="sr-only" htmlFor="interview-note">
Add a note
</label>
<textarea
id="interview-note"
name="body"
rows={3}
required
placeholder="Add a private interviewer note…"
/>
<button className="button primary" type="submit">
Post note
</button>
</form>
) : (
<p className="panel-padding muted">Notes can be added while the interview is started.</p>
)}
</section>
)
}
export function AttendeeList({ interview }: { interview: ScheduledInterview }) {
return (
<section className="card attendee-section">
<div className="section-heading">
<div>
<h2>Attendees</h2>
<p>Interview team and response status.</p>
</div>
</div>
<div className="user-list">
{interview.attendees.map((attendee) => (
<article className="user-row" key={attendee.userId}>
<UsersRound />
<div>
<strong>{attendee.displayName}</strong>
<small>{attendee.email}</small>
</div>
<span className={`status status-${attendee.status}`}>{attendee.status}</span>
</article>
))}
</div>
</section>
)
}
export function SessionGrid({ children }: { children: ReactNode }) {
return <div className="session-grid">{children}</div>
}

View file

@ -1,2 +1,4 @@
export { InboxPage, ParticipantMeetingPage, ScheduledInterviewPage } from './pages'
export { InvitationPage } from './InvitationPage'
export { ParticipantMeetingPage } from './ParticipantMeetingPage'
export { ScheduledInterviewPage } from './ScheduledInterviewPage'
export { ScheduleInterviewForm } from './components/ScheduleInterviewForm'

View file

@ -0,0 +1,20 @@
import type { InterviewEvent, ScheduledInterview } from '../../types'
export function applyInterviewEvent(
interview: ScheduledInterview,
event: InterviewEvent,
): ScheduledInterview {
if (event.type === 'status.updated') return { ...interview, status: event.status }
if (event.type === 'document.updated') {
return { ...interview, sharedDocument: event.sharedDocument }
}
if (event.type === 'attendee.updated') {
return {
...interview,
attendees: interview.attendees.map((attendee) =>
attendee.userId === event.attendee.userId ? event.attendee : attendee,
),
}
}
return interview
}

View file

@ -1,392 +0,0 @@
import {Check, Copy, UsersRound, X} from 'lucide-react'
import {type FormEvent, useEffect, useState} from 'react'
import {Link, useParams} from 'react-router-dom'
import {api} from '../../api/client'
import type {ParticipantMeeting, ScheduledInterview} from '../../types'
import {ErrorAlert, Loading} from '../../components/ui/AsyncState'
import {DateAndPlace, InterviewCard} from './components/InterviewCard'
import {message} from "../../components/actions";
export function InboxPage() {
const [items, setItems] = useState<ScheduledInterview[]>([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
void api.inbox
.list()
.then(setItems)
.catch((value) => setError(message(value)))
.finally(() => setLoading(false))
}, [])
async function respond(id: number, status: 'accepted' | 'declined') {
setError('')
try {
const updated = await api.inbox.respond(id, status)
setItems((current) => current.map((item) => (item.id === id ? updated : item)))
} catch (value) {
setError(message(value))
}
}
return (
<>
<header className="page-header">
<div>
<span className="eyebrow">Invitations</span>
<h1>Inbox</h1>
<p>Respond to interview invitations assigned to you.</p>
</div>
</header>
{error && <ErrorAlert error={error}/>}
{loading ? (
<Loading/>
) : (
<section className="meeting-list">
{items.map((item) => (
<InterviewCard interview={item} key={item.id}>
<div className="meeting-actions">
<button
className="button primary"
type="button"
onClick={() => void respond(item.id, 'accepted')}
>
<Check size={17}/> Accept
</button>
<button
className="button secondary danger"
type="button"
onClick={() => void respond(item.id, 'declined')}
>
<X size={17}/> Decline
</button>
</div>
</InterviewCard>
))}
{!items.length && (
<div className="card empty-state">
<h3>Inbox zero</h3>
<p>You have no interview invitations.</p>
</div>
)}
</section>
)}
</>
)
}
export function ScheduledInterviewPage() {
const id = Number(useParams().id)
const [interview, setInterview] = useState<ScheduledInterview>()
const [document, setDocument] = useState('')
const [notes, setNotes] = useState<ScheduledInterview['notes']>([])
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
const [linkCopied, setLinkCopied] = useState(false)
const loadedInterviewID = interview?.id
useEffect(() => {
let active = true
void Promise.all([api.scheduledInterviews.get(id), api.scheduledInterviews.notes(id)])
.then(([item, loadedNotes]) => {
if (!active) return
setInterview(item)
setDocument(item.sharedDocument)
setNotes(loadedNotes)
})
.catch((value) => active && setError(message(value)))
return () => {
active = false
}
}, [id])
useEffect(() => {
if (!loadedInterviewID) return
const controller = new AbortController()
void api.scheduledInterviews
.subscribe(
id,
(event) => {
if (event.type === 'note.created') {
setNotes((current) =>
current.some((item) => item.id === event.note.id)
? current
: [...current, event.note],
)
}
if (event.type === 'document.updated') {
setDocument(event.sharedDocument)
setInterview((current) =>
current ? {...current, sharedDocument: event.sharedDocument} : current,
)
}
},
controller.signal,
(value) => setError(message(value)),
)
.catch((value) => {
if (!controller.signal.aborted) setError(message(value))
})
return () => controller.abort()
}, [id, loadedInterviewID])
async function setStatus(status: string) {
setError('')
try {
setInterview(await api.scheduledInterviews.setStatus(id, status))
} catch (value) {
setError(message(value))
}
}
async function saveDocument(event: FormEvent) {
event.preventDefault()
setSaving(true)
setError('')
try {
setInterview(await api.scheduledInterviews.updateDocument(id, document))
} catch (value) {
setError(message(value))
} finally {
setSaving(false)
}
}
async function addNote(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const body = String(new FormData(form).get('body')).trim()
if (!body) return
try {
const note = await api.scheduledInterviews.addNote(id, body)
setNotes((current) =>
current.some((item) => item.id === note.id) ? current : [...current, note],
)
form.reset()
} catch (value) {
setError(message(value))
}
}
async function copyParticipantLink(url: string) {
setError('')
try {
await navigator.clipboard.writeText(url)
setLinkCopied(true)
} catch {
setError('Could not copy the participant interview link')
}
}
if (!interview && !error) return <Loading/>
if (!interview) return <ErrorAlert error={error}/>
return (
<>
<Link className="back-link" to="/calendar">
← Calendar
</Link>
<header className="page-header">
<div>
<span className="eyebrow">Interviewer session</span>
<h1>{interview.participantName}</h1>
<p>{interview.jobTitle}</p>
</div>
<span className={`status status-${interview.status}`}>{interview.status}</span>
</header>
{error && <ErrorAlert error={error}/>}
<DateAndPlace interview={interview}/>
<div className="session-actions">
<button
className="button secondary"
type="button"
onClick={() => void copyParticipantLink(interview.candidateUrl)}
aria-label="Copy participant interview link"
>
{linkCopied ? <Check size={17}/> : <Copy size={17}/>}
{linkCopied ? 'Link copied' : 'Copy participant link'}
</button>
{interview.status !== 'started' && interview.status !== 'completed' && (
<button
className="button primary"
type="button"
onClick={() => void setStatus('started')}
>
Start interview
</button>
)}
{interview.status === 'started' && (
<button
className="button primary"
type="button"
onClick={() => void setStatus('completed')}
>
Complete interview
</button>
)}
</div>
<div className="session-grid">
<form className="card shared-document" onSubmit={saveDocument}>
<div className="section-heading">
<div>
<span className="eyebrow">Visible to candidate</span>
<h2>Shared documentation</h2>
</div>
</div>
<div className="form-section">
<textarea
aria-label="Shared documentation"
rows={18}
value={document}
onChange={(event) => setDocument(event.target.value)}
/>
<button className="button primary" type="submit" disabled={saving}>
{saving ? 'Saving…' : 'Save shared documentation'}
</button>
</div>
</form>
<section className="card notes-panel">
<div className="section-heading">
<div>
<span className="eyebrow">Interviewers only</span>
<h2>Live notes</h2>
</div>
</div>
<div className="notes-list" aria-live="polite">
{notes.map((note) => (
<article className="note" key={note.id}>
<strong>{note.authorName}</strong>
<time>{new Date(note.createdAt).toLocaleTimeString()}</time>
<p>{note.body}</p>
</article>
))}
</div>
{interview.status === 'started' ? (
<form className="note-form" onSubmit={addNote}>
<label className="sr-only" htmlFor="interview-note">
Add a note
</label>
<textarea
id="interview-note"
name="body"
rows={3}
required
placeholder="Add a private interviewer note…"
/>
<button className="button primary" type="submit">
Post note
</button>
</form>
) : (
<p className="panel-padding muted">
Notes can be added while the interview is started.
</p>
)}
</section>
</div>
<section className="card attendee-section">
<div className="section-heading">
<div>
<h2>Attendees</h2>
<p>Interview team and response status.</p>
</div>
</div>
<div className="user-list">
{interview.attendees.map((attendee) => (
<article className="user-row" key={attendee.userId}>
<UsersRound/>
<div>
<strong>{attendee.displayName}</strong>
<small>{attendee.email}</small>
</div>
<span className={`status status-${attendee.status}`}>{attendee.status}</span>
</article>
))}
</div>
</section>
</>
)
}
export function ParticipantMeetingPage() {
const token = useParams().token ?? ''
const [meeting, setMeeting] = useState<ParticipantMeeting>()
const [error, setError] = useState('')
useEffect(() => {
let active = true
let latestDocument: string | undefined
const controller = new AbortController()
void api.participantMeetings
.subscribe(
token,
(event) => {
if (event.type !== 'document.updated') return
latestDocument = event.sharedDocument
setMeeting((current) =>
current ? {...current, sharedDocument: event.sharedDocument} : current,
)
},
controller.signal,
(value) => {
if (active) setError(message(value))
},
)
.catch((value) => {
if (active && !controller.signal.aborted) setError(message(value))
})
void api.participantMeetings
.get(token)
.then((value) => {
if (active) {
setMeeting({
...value,
sharedDocument: latestDocument ?? value.sharedDocument,
})
}
})
.catch((value) => {
if (active) setError(message(value))
})
return () => {
active = false
controller.abort()
}
}, [token])
return (
<main className="public-interview">
<header>
<Link className="logo" to="/" aria-label="Go Guess home">
<span className="logo-mark">G</span>
<span>
<strong>GO GUESS</strong>
<small>Talent workspace</small>
</span>
</Link>
</header>
{!meeting && !error && (
<section className="interview-card">
<Loading/>
</section>
)}
{error && (
<section className="interview-card">
<ErrorAlert error={error}/>
</section>
)}
{meeting && (
<section className="interview-card meeting-public-card">
<span className="eyebrow">Your scheduled interview</span>
<h1>{meeting.jobTitle}</h1>
<p>Welcome, {meeting.participantName}.</p>
<span className={`status status-${meeting.status}`}>{meeting.status}</span>
<DateAndPlace interview={meeting}/>
<section className="public-document" aria-labelledby="shared-document-title">
<h2 id="shared-document-title">Shared documentation</h2>
<div>{meeting.sharedDocument || 'No shared documentation has been added yet.'}</div>
</section>
</section>
)}
</main>
)
}

View file

@ -12,22 +12,47 @@ import {
Trash2,
X,
} from 'lucide-react'
import {type ReactNode, SyntheticEvent, useEffect, useState} from 'react'
import {Link, useNavigate, useParams} from 'react-router-dom'
import {api} from '../../api/client'
import {ErrorAlert, errorMessage, Loading} from '../../components/actions'
import {FormActions, FormField} from '../../components/form'
import {EmptyState, Fact, Metric, StatusBadge} from '../../components/ui/Display'
import {NotFound} from '../../components/ui/NotFound'
import {PageHeader} from '../../components/ui/PageHeader'
import {useData} from '../../app/useData'
import {CodePanel} from '../assessments/CodeReview'
import {ScheduleInterviewForm} from '../interviews'
import {Avatar, SkillList} from '../participants'
import type {CandidateMatch, Interview, Invitation, Job, JobStatus, Question, ScheduledInterview,} from '../../types'
import { type ReactNode, SyntheticEvent, useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { api } from '../../api/client'
import { ErrorAlert, errorMessage, Loading } from '../../components/actions'
import { FormActions, FormField } from '../../components/form'
import { EmptyState, Fact, Metric, StatusBadge } from '../../components/ui/Display'
import { NotFound } from '../../components/ui/NotFound'
import { PageHeader } from '../../components/ui/PageHeader'
import { useData } from '../../app/useData'
import { useDomainEvent } from '../../app/useDomainEvent'
import { CodePanel } from '../assessments/CodeReview'
import { ScheduleInterviewForm } from '../interviews'
import { Avatar, SkillList } from '../participants'
import type {
CandidateMatch,
Interview,
Invitation,
Job,
JobStatus,
Question,
ScheduledInterview,
} from '../../types'
function isJob(value: unknown): value is Job {
return typeof value === 'object' && value !== null && 'questions' in value
}
function isInvitation(value: unknown): value is Invitation {
return typeof value === 'object' && value !== null && 'participantEmail' in value
}
function isScheduledInterview(value: unknown): value is ScheduledInterview {
return typeof value === 'object' && value !== null && 'startsAt' in value && 'attendees' in value
}
function upsertById<T extends { id: number }>(items: T[], value: T) {
return [value, ...items.filter((item) => item.id !== value.id)]
}
export function JobsPage() {
const {jobs, loading, error, reload, participants} = useData()
const { jobs, loading, error, reload, participants } = useData()
const [query, setQuery] = useState('')
const filtered = jobs.filter((job) =>
`${job.title} ${job.position} ${job.seniority} ${job.labels.join(' ')}`
@ -42,14 +67,14 @@ export function JobsPage() {
description="Create roles, build assessments, and compare candidate fit."
action={
<Link to="/jobs/new" className="button primary">
<Plus size={18}/> Create job
<Plus size={18} /> Create job
</Link>
}
/>
{error && <ErrorAlert message={error} retry={reload}/>}
{error && <ErrorAlert message={error} retry={reload} />}
<section className="metrics">
<Metric label="Job postings" value={String(jobs.length)}/>
<Metric label="Participants" value={String(participants.length)}/>
<Metric label="Job postings" value={String(jobs.length)} />
<Metric label="Participants" value={String(participants.length)} />
<Metric
label="Assessments"
value={String(jobs.reduce((sum, job) => sum + job.questions.length, 0))}
@ -58,7 +83,7 @@ export function JobsPage() {
<section className="card">
<div className="toolbar">
<label className="search-field">
<Search size={18}/>
<Search size={18} />
<span className="sr-only">Search jobs</span>
<input
value={query}
@ -69,7 +94,7 @@ export function JobsPage() {
<span className="result-count">{filtered.length} jobs</span>
</div>
{loading ? (
<Loading/>
<Loading />
) : (
<div className="table-wrap">
<table>
@ -95,11 +120,11 @@ export function JobsPage() {
<small>{job.labels.join(' · ') || 'No labels'}</small>
</td>
<td>
<StatusBadge value={job.seniority}/>
<StatusBadge value={job.seniority} />
</td>
<td>{job.position}</td>
<td>
<StatusBadge value={job.status}/>
<StatusBadge value={job.status} />
</td>
<td>{job.questions.length}</td>
<td>
@ -108,7 +133,7 @@ export function JobsPage() {
to={`/jobs/${job.id}`}
aria-label={`View ${job.title}`}
>
<ChevronRight/>
<ChevronRight />
</Link>
</td>
</tr>
@ -118,7 +143,7 @@ export function JobsPage() {
</div>
)}
{!loading && !filtered.length && !error && (
<EmptyState title="No jobs found" description="Create a job or adjust your search."/>
<EmptyState title="No jobs found" description="Create a job or adjust your search." />
)}
</section>
</>
@ -137,6 +162,32 @@ export function JobDetailPage() {
'overview',
)
useDomainEvent<Job | Invitation | ScheduledInterview | undefined>(`jobs/${id}`, (event) => {
const data = event.data
if (isJob(data)) {
setJob(data)
return
}
if (isInvitation(data)) {
setInvitations((current) => upsertById(current, data))
return
}
if (isScheduledInterview(data)) {
setScheduledInterviews((current) => upsertById(current, data))
return
}
if (event.type === 'job.question-attached' || event.type === 'job.question-detached') {
void api.jobs
.get(id)
.then(setJob)
.catch((value) => setError(errorMessage(value)))
}
})
useDomainEvent<Invitation>(`job-invitations/${id}`, (event) => {
setInvitations((current) => upsertById(current, event.data))
})
async function load() {
setLoading(true)
setError('')
@ -183,14 +234,14 @@ export function JobDetailPage() {
active = false
}
}, [id])
if (loading) return <Loading/>
if (error) return <ErrorAlert message={error} retry={load}/>
if (!job) return <NotFound/>
if (loading) return <Loading />
if (error) return <ErrorAlert message={error} retry={load} />
if (!job) return <NotFound />
return (
<>
<Link className="back-link" to="/jobs">
<ArrowLeft size={17}/> All jobs
<ArrowLeft size={17} /> All jobs
</Link>
<PageHeader
eyebrow={`${job.seniority} · ${job.position}`}
@ -222,44 +273,37 @@ export function JobDetailPage() {
<h2>About the role</h2>
<p>{job.description}</p>
<h3>Required skills</h3>
<SkillList skills={job.requiredSkills}/>
<SkillList skills={job.requiredSkills} />
<h3>Additional skills</h3>
<SkillList skills={job.additionalSkills}/>
<SkillList skills={job.additionalSkills} />
</section>
<aside className="card facts">
<h2>Job details</h2>
<Fact label="Seniority" value={job.seniority}/>
<Fact label="Position" value={job.position}/>
<Fact label="Status" value={job.status}/>
<Fact label="Duration" value={`${job.durationMinutes} minutes`}/>
<Fact label="Labels" value={job.labels.join(', ') || 'None'}/>
<Fact label="Created" value={new Date(job.createdAt).toLocaleDateString()}/>
<JobSettings job={job} onUpdated={setJob}/>
<Fact label="Seniority" value={job.seniority} />
<Fact label="Position" value={job.position} />
<Fact label="Status" value={job.status} />
<Fact label="Duration" value={`${job.durationMinutes} minutes`} />
<Fact label="Labels" value={job.labels.join(', ') || 'None'} />
<Fact label="Created" value={new Date(job.createdAt).toLocaleDateString()} />
<JobSettings job={job} />
</aside>
</div>
)}
{tab === 'questions' && <JobQuestionsView job={job} onUpdated={setJob}/>}
{tab === 'candidates' && <CandidateMatchView matches={matches}/>}
{tab === 'questions' && <JobQuestionsView job={job} />}
{tab === 'candidates' && <CandidateMatchView matches={matches} />}
{tab === 'invitations' && (
<InvitationsView
job={job}
matches={matches}
invitations={invitations}
scheduledInterviews={scheduledInterviews}
onInvited={(invitation) => setInvitations((current) => [invitation, ...current])}
onInvitationUpdated={(invitation) =>
setInvitations((current) =>
current.map((item) => (item.id === invitation.id ? invitation : item)),
)
}
onScheduled={(interview) => setScheduledInterviews((current) => [interview, ...current])}
/>
)}
</>
)
}
function JobSettings({job, onUpdated}: { job: Job; onUpdated: (job: Job) => void }) {
function JobSettings({ job }: { job: Job }) {
const [status, setStatus] = useState<JobStatus>(job.status)
const [durationMinutes, setDurationMinutes] = useState(job.durationMinutes)
const [saving, setSaving] = useState(false)
@ -270,7 +314,7 @@ function JobSettings({job, onUpdated}: { job: Job; onUpdated: (job: Job) => void
setSaving(true)
setError('')
try {
onUpdated(await api.jobs.update(job.id, {status, durationMinutes}))
await api.jobs.update(job.id, { status, durationMinutes })
} catch (requestError) {
setError(errorMessage(requestError))
} finally {
@ -301,7 +345,7 @@ function JobSettings({job, onUpdated}: { job: Job; onUpdated: (job: Job) => void
onChange={(event) => setDurationMinutes(Number(event.target.value))}
/>
</FormField>
{error && <ErrorAlert message={error}/>}
{error && <ErrorAlert message={error} />}
<button className="button secondary wide" type="submit" disabled={saving}>
{saving ? 'Saving…' : 'Save interview settings'}
</button>
@ -309,7 +353,7 @@ function JobSettings({job, onUpdated}: { job: Job; onUpdated: (job: Job) => void
)
}
function JobQuestionsView({job, onUpdated}: { job: Job; onUpdated: (job: Job) => void }) {
function JobQuestionsView({ job }: { job: Job }) {
const [query, setQuery] = useState('')
const [results, setResults] = useState<Question[]>([])
const [error, setError] = useState('')
@ -337,7 +381,6 @@ function JobQuestionsView({job, onUpdated}: { job: Job; onUpdated: (job: Job) =>
setError('')
try {
await api.jobs.attachQuestion(job.id, question.id)
onUpdated({...job, questions: [...job.questions, question]})
} catch (requestError) {
setError(errorMessage(requestError))
}
@ -347,10 +390,6 @@ function JobQuestionsView({job, onUpdated}: { job: Job; onUpdated: (job: Job) =>
setError('')
try {
await api.jobs.detachQuestion(job.id, question.id)
onUpdated({
...job,
questions: job.questions.filter((item) => item.id !== question.id),
})
} catch (requestError) {
setError(errorMessage(requestError))
}
@ -378,7 +417,7 @@ function JobQuestionsView({job, onUpdated}: { job: Job; onUpdated: (job: Job) =>
onClick={() => void detach(question)}
aria-label={`Detach ${question.text}`}
>
<Trash2 size={16}/> Detach
<Trash2 size={16} /> Detach
</button>
}
/>
@ -403,7 +442,7 @@ function JobQuestionsView({job, onUpdated}: { job: Job; onUpdated: (job: Job) =>
</div>
<div className="panel-padding">
<label className="search-field">
<Search size={18}/>
<Search size={18} />
<span className="sr-only">Search question library</span>
<input
value={query}
@ -411,7 +450,7 @@ function JobQuestionsView({job, onUpdated}: { job: Job; onUpdated: (job: Job) =>
placeholder="Search question library…"
/>
</label>
{error && <ErrorAlert message={error}/>}
{error && <ErrorAlert message={error} />}
</div>
<div className="question-list compact">
{results
@ -426,7 +465,7 @@ function JobQuestionsView({job, onUpdated}: { job: Job; onUpdated: (job: Job) =>
type="button"
onClick={() => void attach(question)}
>
<Plus size={16}/> Attach
<Plus size={16} /> Attach
</button>
}
/>
@ -442,17 +481,11 @@ function InvitationsView({
matches,
invitations,
scheduledInterviews,
onInvited,
onInvitationUpdated,
onScheduled,
}: {
}: {
job: Job
matches: CandidateMatch[]
invitations: Invitation[]
scheduledInterviews: ScheduledInterview[]
onInvited: (invitation: Invitation) => void
onInvitationUpdated: (invitation: Invitation) => void
onScheduled: (interview: ScheduledInterview) => void
}) {
const [sendingId, setSendingId] = useState<number>()
const [review, setReview] = useState<Interview>()
@ -466,7 +499,7 @@ function InvitationsView({
setSendingId(participantId)
setError('')
try {
onInvited(await api.jobs.invite(job.id, participantId))
await api.jobs.invite(job.id, participantId)
} catch (requestError) {
setError(errorMessage(requestError))
} finally {
@ -503,9 +536,9 @@ function InvitationsView({
</div>
</div>
<div className="eligible-list">
{eligible.map(({participant}) => (
{eligible.map(({ participant }) => (
<article className="eligible-row" key={participant.id}>
<Avatar person={participant}/>
<Avatar person={participant} />
<div>
<strong>
{participant.firstName} {participant.lastName}
@ -518,14 +551,14 @@ function InvitationsView({
disabled={sendingId === participant.id}
onClick={() => void send(participant.id)}
>
<Send size={16}/>{' '}
<Send size={16} />{' '}
{sendingId === participant.id ? 'Generating…' : 'Generate invitation'}
</button>
</article>
))}
{!eligible.length && <p className="muted">All eligible candidates have been invited.</p>}
</div>
{error && <ErrorAlert message={error}/>}
{error && <ErrorAlert message={error} />}
<div className="invitation-list">
{invitations.map((invitation) => (
<article className="invitation-row" key={invitation.id}>
@ -533,7 +566,7 @@ function InvitationsView({
<strong>{invitation.participantName}</strong>
<small>{invitation.participantEmail}</small>
</div>
<StatusBadge value={invitation.status}/>
<StatusBadge value={invitation.status} />
<code>{invitation.participantUrl}</code>
<button
className="button secondary"
@ -541,7 +574,7 @@ function InvitationsView({
onClick={() => void copy(invitation.participantUrl)}
aria-label={`Copy interview URL for ${invitation.participantName}`}
>
<Copy size={16}/> Copy URL
<Copy size={16} /> Copy URL
</button>
{['accepted', 'completed'].includes(invitation.status) && (
<button
@ -556,26 +589,16 @@ function InvitationsView({
</article>
))}
{!invitations.length && (
<EmptyState title="No invitations" description="Invite a candidate to this interview."/>
<EmptyState title="No invitations" description="Invite a candidate to this interview." />
)}
</div>
{review && (
<InvitationAnswerReview
interview={review}
onClose={() => setReview(undefined)}
onOutcome={(invitation) => {
onInvitationUpdated(invitation)
setReview((current) => current && {...current, invitation})
}}
/>
)}
{review && <InvitationAnswerReview interview={review} onClose={() => setReview(undefined)} />}
{scheduling && (
<ScheduleInterviewForm
jobId={job.id}
invitation={scheduling}
onCancel={() => setScheduling(undefined)}
onCreated={(interview) => {
onScheduled(interview)
onCreated={() => {
setScheduling(undefined)
}}
/>
@ -596,7 +619,7 @@ function InvitationsView({
key={invitation.id}
onClick={() => setScheduling(invitation)}
>
<CalendarDays size={17}/> Schedule {invitation.participantName}
<CalendarDays size={17} /> Schedule {invitation.participantName}
</button>
))}
{scheduledInterviews.map((interview) => (
@ -611,7 +634,7 @@ function InvitationsView({
{new Date(interview.startsAt).toLocaleString()} · {interview.location}
</small>
</span>
<StatusBadge value={interview.status}/>
<StatusBadge value={interview.status} />
</Link>
))}
</div>
@ -622,25 +645,26 @@ function InvitationsView({
function InvitationAnswerReview({
interview,
onClose,
onOutcome,
}: {
}: {
interview: Interview
onClose: () => void
onOutcome: (invitation: Invitation) => void
}) {
const [current, setCurrent] = useState(interview)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useDomainEvent<Invitation>(`assessment-invitations/${interview.invitation.id}`, (event) => {
setCurrent((value) => ({ ...value, invitation: event.data }))
})
async function decide(outcome: 'passed' | 'failed') {
setSaving(true)
setError('')
try {
onOutcome(
await api.jobs.setInvitationOutcome(
interview.invitation.jobId,
interview.invitation.id,
outcome,
),
)
} catch (requestError) {
setError(errorMessage(requestError))
@ -654,10 +678,10 @@ function InvitationAnswerReview({
<div className="section-heading">
<div>
<span className="eyebrow">Participant submission</span>
<h2 id="answer-review-title">{interview.invitation.participantName}</h2>
<p>{interview.invitation.participantEmail}</p>
<h2 id="answer-review-title">{current.invitation.participantName}</h2>
<p>{current.invitation.participantEmail}</p>
</div>
{interview.invitation.status === 'completed' && (
{current.invitation.status === 'completed' && (
<div className="outcome-actions" aria-label="Candidate outcome">
<div>
<strong>Assessment decision</strong>
@ -669,7 +693,7 @@ function InvitationAnswerReview({
disabled={saving}
onClick={() => void decide('passed')}
>
<Check size={18}/> Pass candidate
<Check size={18} /> Pass candidate
</button>
<button
className="button fail"
@ -677,22 +701,22 @@ function InvitationAnswerReview({
disabled={saving}
onClick={() => void decide('failed')}
>
<X size={18}/> Fail candidate
<X size={18} /> Fail candidate
</button>
{interview.invitation.outcome && <StatusBadge value={interview.invitation.outcome}/>}
{current.invitation.outcome && <StatusBadge value={current.invitation.outcome} />}
</div>
)}
{error && <ErrorAlert message={error}/>}
{error && <ErrorAlert message={error} />}
<div className="review-heading-actions">
<StatusBadge value={interview.invitation.status}/>
<StatusBadge value={current.invitation.status} />
<button className="button ghost" type="button" onClick={onClose}>
Close review
</button>
</div>
</div>
<div className="review-question-list">
{interview.job.questions.map((question, index) => {
const answer = interview.answers[String(question.id)]?.trim()
{current.job.questions.map((question, index) => {
const answer = current.answers[String(question.id)]?.trim()
return (
<article className="review-question" key={question.id}>
<span className="question-number">{index + 1}</span>
@ -700,7 +724,7 @@ function InvitationAnswerReview({
<span className="question-type">{question.type.replace('_', ' ')}</span>
<h3>{question.text}</h3>
{question.type === 'code_review' && question.codeSnippet && (
<CodePanel codeSnippet={question.codeSnippet}/>
<CodePanel codeSnippet={question.codeSnippet} />
)}
{(question.type === 'multiple_choice' || question.type === 'radio') &&
question.options.length > 0 && (
@ -733,18 +757,18 @@ export function QuestionRow({
question,
number,
action,
}: {
}: {
question: Question
number?: number
action?: ReactNode
}) {
return (
<article className="question-row">
{number ? <span className="question-number">{number}</span> : <CircleHelp size={22}/>}
{number ? <span className="question-number">{number}</span> : <CircleHelp size={22} />}
<div>
<div className="question-meta">
<h3>{question.type.replace('_', ' ')}</h3>
{question.deprecated && <StatusBadge value="deprecated"/>}
{question.deprecated && <StatusBadge value="deprecated" />}
</div>
<p>{question.text}</p>
{question.options.length > 0 && <small>{question.options.join(' · ')}</small>}
@ -765,7 +789,7 @@ export function QuestionRow({
)
}
function CandidateMatchView({matches}: { matches: CandidateMatch[] }) {
function CandidateMatchView({ matches }: { matches: CandidateMatch[] }) {
return (
<section className="card">
<div className="section-heading">
@ -774,14 +798,14 @@ function CandidateMatchView({matches}: { matches: CandidateMatch[] }) {
<p>Trait alignment calculated by the API.</p>
</div>
<span className="ai-label">
<Sparkles size={16}/> Match insight
<Sparkles size={16} /> Match insight
</span>
</div>
<div className="match-list">
{matches.map((match, index) => (
<article className="match-card" key={match.participant.id}>
<span className="rank">{index + 1}</span>
<Avatar person={match.participant}/>
<Avatar person={match.participant} />
<div className="match-person">
<Link to={`/participants/${match.participant.id}`}>
{match.participant.firstName} {match.participant.lastName}
@ -794,7 +818,7 @@ function CandidateMatchView({matches}: { matches: CandidateMatch[] }) {
<strong>{match.matchedTraits.length}</strong>
</div>
<span className="score-track">
<span style={{width: `${Math.min(match.score, 100)}%`}}/>
<span style={{ width: `${Math.min(match.score, 100)}%` }} />
</span>
</div>
<div className="score">
@ -821,7 +845,7 @@ function CandidateMatchView({matches}: { matches: CandidateMatch[] }) {
}
export function CreateJobPage() {
const {createJob} = useData()
const { createJob } = useData()
const navigate = useNavigate()
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
@ -856,7 +880,7 @@ export function CreateJobPage() {
return (
<>
<Link className="back-link" to="/jobs">
<ArrowLeft size={17}/> Cancel
<ArrowLeft size={17} /> Cancel
</Link>
<PageHeader
eyebrow="New posting"
@ -868,33 +892,33 @@ export function CreateJobPage() {
<h2>Role information</h2>
<div className="form-grid">
<FormField label="Job title" htmlFor="title">
<input id="title" name="title" required/>
<input id="title" name="title" required />
</FormField>
<FormField label="Position" htmlFor="position">
<input id="position" name="position" required placeholder="e.g. Backend Engineer"/>
<input id="position" name="position" required placeholder="e.g. Backend Engineer" />
</FormField>
<FormField label="Seniority" htmlFor="seniority">
<input id="seniority" name="seniority" required placeholder="e.g. Senior"/>
<input id="seniority" name="seniority" required placeholder="e.g. Senior" />
</FormField>
<FormField label="Labels" htmlFor="labels" hint="Comma separated">
<input id="labels" name="labels"/>
<input id="labels" name="labels" />
</FormField>
</div>
</div>
<div className="form-section">
<h2>Role profile</h2>
<FormField label="Description" htmlFor="description">
<textarea id="description" name="description" required rows={7}/>
<textarea id="description" name="description" required rows={7} />
</FormField>
<FormField label="Required skills" htmlFor="requiredSkills" hint="Comma separated">
<input id="requiredSkills" name="requiredSkills" required/>
<input id="requiredSkills" name="requiredSkills" required />
</FormField>
<FormField label="Additional skills" htmlFor="additionalSkills" hint="Comma separated">
<input id="additionalSkills" name="additionalSkills"/>
<input id="additionalSkills" name="additionalSkills" />
</FormField>
</div>
{error && <ErrorAlert message={error}/>}
<FormActions saving={saving} cancelTo="/jobs" label="Create job"/>
{error && <ErrorAlert message={error} />}
<FormActions saving={saving} cancelTo="/jobs" label="Create job" />
</form>
</>
)

View file

@ -11,6 +11,7 @@ import {
import { type FormEvent, useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { api } from '../../api/client'
import { useDomainEvent } from '../../app/useDomainEvent'
import { useData } from '../../app/useData'
import { ErrorAlert, errorMessage, Loading } from '../../components/actions'
import { FormActions, FormField } from '../../components/form'
@ -210,6 +211,7 @@ export function ParticipantDetailPage() {
.then(setPerson)
.catch((value) => setError(errorMessage(value)))
}, [id])
useDomainEvent<Participant>(`participants/${id}`, (event) => setPerson(event.data))
if (error) return <ErrorAlert message={error} />
if (!person) return <Loading />

View file

@ -2,6 +2,7 @@ import { ArrowLeft, Pencil, Plus, Search, X } from 'lucide-react'
import { type FormEvent, useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { api } from '../../api/client'
import { useDomainEvent } from '../../app/useDomainEvent'
import { ErrorAlert, errorMessage, Loading } from '../../components/actions'
import { FormField } from '../../components/form'
import { EmptyState } from '../../components/ui/Display'
@ -266,25 +267,31 @@ export function QuestionLibraryPage() {
}
}, [query])
useDomainEvent<Question>('questions', (event) => {
setQuestions((current) => {
const existing = current.some((question) => question.id === event.data.id)
return existing
? current.map((question) => (question.id === event.data.id ? event.data : question))
: [event.data, ...current]
})
setEditing((current) => (current?.id === event.data.id ? event.data : current))
})
async function create(input: CreateQuestionInput) {
const created = await api.questions.create(input)
setQuestions((current) => [created, ...current])
await api.questions.create(input)
setFormVersion((current) => current + 1)
}
async function update(input: CreateQuestionInput) {
if (!editing) return
const updated = await api.questions.update(editing.id, input)
setQuestions((current) => current.map((item) => (item.id === updated.id ? updated : item)))
await api.questions.update(editing.id, input)
setEditing(undefined)
}
async function toggle(question: Question) {
setError('')
try {
const updated = await api.questions.setDeprecated(question.id, !question.deprecated)
setQuestions((current) => current.map((item) => (item.id === updated.id ? updated : item)))
if (editing?.id === updated.id) setEditing(updated)
await api.questions.setDeprecated(question.id, !question.deprecated)
} catch (requestError) {
setError(errorMessage(requestError))
}

View file

@ -1,62 +0,0 @@
import {ScheduledInterview} from "../../types";
import {useEffect, useMemo, useState} from "react";
import {message} from "../../components/actions";
import {api} from "../../api/client";
import {ErrorAlert, Loading} from "../../components/ui/AsyncState";
import {InterviewCard} from "../interviews/components/InterviewCard";
import {Link} from "react-router-dom";
export function CalendarPage() {
const [items, setItems] = useState<ScheduledInterview[]>([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
void api
.calendar()
.then(setItems)
.catch((value) => setError(message(value)))
.finally(() => setLoading(false))
}, [])
const groups = useMemo(() => {
return [...items]
.sort((a, b) => a.startsAt.localeCompare(b.startsAt))
.reduce<Record<string, ScheduledInterview[]>>((result, item) => {
const key = new Date(item.startsAt).toLocaleDateString(undefined, {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
})
;(result[key] ??= []).push(item)
return result
}, {})
}, [items])
return (
<>
<header className="page-header">
<div>
<span className="eyebrow">Schedule</span>
</div>
</header>
{error && <ErrorAlert error={error}/>}
{loading ? (
<Loading/>
) : (
Object.entries(groups).map(([date, interviews]) => (
<section className="calendar-day" key={date}>
<h2>{date}</h2>
<div className="meeting-list">
{interviews.map((item) => (
<InterviewCard interview={item} key={item.id}>
<Link className="button secondary" to={`/scheduled-interviews/${item.id}`}>
Open session
</Link>
</InterviewCard>
))}
</div>
</section>
))
)}
</>
)
}

View file

@ -0,0 +1,81 @@
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { api, authStorage } from '../../api/client'
import { useDomainEvent } from '../../app/useDomainEvent'
import { errorMessage } from '../../components/actions'
import { ErrorAlert, Loading } from '../../components/ui/AsyncState'
import type { ScheduledInterview } from '../../types'
import { InterviewCard } from '../interviews/components/InterviewCard'
export function SchedulePage() {
const [items, setItems] = useState<ScheduledInterview[]>([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
void api
.calendar()
.then(setItems)
.catch((value) => setError(errorMessage(value)))
.finally(() => setLoading(false))
}, [])
useDomainEvent<ScheduledInterview>('schedule', (event) => {
setItems((current) => {
const attendee = event.data.attendees.find((item) => item.userId === authStorage.user()?.id)
const accepted = attendee?.status === 'accepted'
if (!accepted) return current.filter((item) => item.id !== event.data.id)
return [event.data, ...current.filter((item) => item.id !== event.data.id)]
})
})
const groups = useMemo(() => {
return [...items]
.sort((a, b) => a.startsAt.localeCompare(b.startsAt))
.reduce<Record<string, ScheduledInterview[]>>((result, item) => {
const key = new Date(item.startsAt).toLocaleDateString(undefined, {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
})
;(result[key] ??= []).push(item)
return result
}, {})
}, [items])
return (
<>
<header className="page-header">
<div>
<span className="eyebrow">Interviews</span>
<h1>Schedule</h1>
<p>Review accepted and upcoming interviews.</p>
</div>
</header>
{error && <ErrorAlert error={error} />}
{loading ? (
<Loading />
) : (
Object.entries(groups).map(([date, interviews]) => (
<section className="calendar-day" key={date}>
<h2>{date}</h2>
<div className="meeting-list">
{interviews.map((item) => (
<InterviewCard
interview={item}
key={item.id}
onConnectionError={(value) => setError(errorMessage(value))}
>
<Link className="button secondary" to={`/scheduled-interviews/${item.id}`}>
Open session
</Link>
</InterviewCard>
))}
</div>
</section>
))
)}
</>
)
}

View file

@ -1,6 +1,7 @@
import { Plus } from 'lucide-react'
import { type FormEvent, useEffect, useState } from 'react'
import { api, ApiError } from '../../api/client'
import { useDomainEvent } from '../../app/useDomainEvent'
import { ErrorAlert, Loading } from '../../components/ui/AsyncState'
import type { User } from '../../types'
@ -24,6 +25,10 @@ export function UsersPage() {
.finally(() => setLoading(false))
}, [])
useDomainEvent<User>('users', (event) => {
setUsers((current) => [...current.filter((user) => user.id !== event.data.id), event.data])
})
async function create(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setSaving(true)
@ -31,12 +36,11 @@ export function UsersPage() {
const form = event.currentTarget
const values = new FormData(form)
try {
const created = await api.users.create({
await api.users.create({
email: String(values.get('email')),
password: String(values.get('password')),
displayName: String(values.get('displayName')),
})
setUsers((current) => [...current, created])
form.reset()
} catch (value) {
setError(errorMessage(value))

View file

@ -1,3 +1,3 @@
export {ProfilePage} from './ProfilePage';
export {UsersPage} from './UsersPage';
export {CalendarPage} from './CalendarPage';
export { ProfilePage } from './ProfilePage'
export { SchedulePage } from './SchedulePage'
export { UsersPage } from './UsersPage'

View file

@ -101,6 +101,16 @@ export type InterviewEvent =
interviewId: number
sharedDocument: string
}
| {
type: 'status.updated'
interviewId: number
status: string
}
| {
type: 'attendee.updated'
interviewId: number
attendee: InterviewAttendee
}
export interface ScheduledInterview {
id: number