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, Only completed assessment invitations can receive a `passed` or `failed` outcome,
and only passed candidates can be scheduled for an interview. Scheduled and only passed candidates can be scheduled for an interview. Scheduled
interviews include the creator plus selected co-interviewers. Attendees manage 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 and can write notes only after the interview has started. Notes are private to
authenticated attendees. The opaque `/participant/meeting/:candidateToken` view authenticated attendees. The opaque `/participant/meeting/:candidateToken` view
exposes only candidate-safe schedule and shared-document data. exposes only candidate-safe schedule and shared-document data.

View file

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

View file

@ -15,10 +15,19 @@ type InterviewNoteEvent struct {
CreatedAt time.Time `json:"createdAt"` 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 InterviewEvent struct {
Type string `json:"type"` Type string `json:"type"`
InterviewID uint `json:"interviewId"` InterviewID uint `json:"interviewId"`
Note *InterviewNoteEvent `json:"note,omitempty"` Note *InterviewNoteEvent `json:"note,omitempty"`
Attendee *InterviewAttendeeEvent `json:"attendee,omitempty"`
Status string `json:"status,omitempty"`
SharedDocument string `json:"sharedDocument,omitempty"` SharedDocument string `json:"sharedDocument,omitempty"`
} }

View file

@ -297,14 +297,14 @@ func TestCompleteInterviewWorkflow(t *testing.T) {
}, http.StatusOK, &coLogin) }, http.StatusOK, &coLogin)
var inbox []webmodel.ScheduledInterviewResponse 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 { if len(inbox) != 1 || inbox[0].ID != scheduled.ID {
t.Fatalf("scheduled interview missing from co-interviewer inbox: %#v", inbox) 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) coLogin.Token, map[string]any{"status": "accepted"}, http.StatusOK, &scheduled)
var calendar []webmodel.ScheduledInterviewResponse 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 { if len(calendar) != 1 || calendar[0].ID != scheduled.ID {
t.Fatalf("accepted interview missing from calendar: %#v", calendar) t.Fatalf("accepted interview missing from calendar: %#v", calendar)
} }

View file

@ -166,7 +166,16 @@ func (s *ScheduledInterviewService) UpdateStatus(
if !valid { if !valid {
return model.ScheduledInterview{}, fmt.Errorf("%w: invalid interview status transition", ErrInvalidState) 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( func (s *ScheduledInterviewService) UpdateDocument(
@ -243,10 +252,24 @@ func (s *ScheduledInterviewService) UpdateInbox(
if status != "accepted" && status != "declined" { if status != "accepted" && status != "declined" {
return model.ScheduledInterview{}, fmt.Errorf("%w: attendee status must be accepted or declined", ErrValidation) 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 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( func (s *ScheduledInterviewService) ListCalendar(
@ -303,7 +326,7 @@ func (s *ScheduledInterviewService) subscribeToInterview(
if event.InterviewID != interviewID { if event.InterviewID != interviewID {
continue continue
} }
if documentsOnly && event.Type != "document.updated" { if documentsOnly && event.Type != "document.updated" && event.Type != "status.updated" {
continue continue
} }
select { select {

View file

@ -34,6 +34,7 @@ type scheduledStore struct {
createdIDs []uint createdIDs []uint
status string status string
document string document string
attendee model.InterviewAttendee
} }
func (f *scheduledStore) GetInvitation(context.Context, uint, uint) (model.Invitation, error) { func (f *scheduledStore) GetInvitation(context.Context, uint, uint) (model.Invitation, error) {
@ -68,6 +69,20 @@ func (f *scheduledStore) UpdateScheduledInterviewDocument(
f.interview.SharedDocument = document f.interview.SharedDocument = document
return f.interview, nil 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( func (f *scheduledStore) CreateInterviewNote(
_ context.Context, value model.InterviewNote, _ context.Context, value model.InterviewNote,
) (model.InterviewNote, error) { ) (model.InterviewNote, error) {
@ -414,6 +429,7 @@ func TestUpdateDocumentPublishesEvent(t *testing.T) {
ID: 9, Status: "scheduled", Attendees: []model.InterviewAttendee{{UserID: 1}}, ID: 9, Status: "scheduled", Attendees: []model.InterviewAttendee{{UserID: 1}},
}, },
} }
bus := eventbus.NewMemoryBus() bus := eventbus.NewMemoryBus()
events, err := bus.SubscribeInterviewEvents(context.Background()) events, err := bus.SubscribeInterviewEvents(context.Background())
if err != nil { 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) { func TestParticipantSubscriptionFiltersPrivateNotes(t *testing.T) {
bus := eventbus.NewMemoryBus() bus := eventbus.NewMemoryBus()
interviews := NewScheduledInterviewService(&fakeStore{}, bus) interviews := NewScheduledInterviewService(&fakeStore{}, bus)
@ -455,14 +501,27 @@ func TestParticipantSubscriptionFiltersPrivateNotes(t *testing.T) {
}); err != nil { }); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := bus.PublishInterviewEvent(ctx, eventbus.InterviewEvent{
Type: "status.updated", InterviewID: 0, Status: "started",
}); err != nil {
t.Fatal(err)
}
select { select {
case event := <-events: case event := <-events:
if event.Type != "document.updated" || event.SharedDocument != "candidate-safe" { 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): case <-time.After(time.Second):
t.Fatal("participant did not receive document event") 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) { 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/scheduled-interviews/{id}/events", h.InterviewEvents)
protected.Get("/api/users", h.ListUsers) protected.Get("/api/users", h.ListUsers)
protected.Post("/api/users", h.CreateUser) protected.Post("/api/users", h.CreateUser)
protected.Get("/api/inbox", h.ListInbox) protected.Get("/api/invitations", h.ListInbox)
protected.Patch("/api/inbox/{id}", h.UpdateInbox) protected.Patch("/api/invitations/{id}", h.UpdateInbox)
protected.Get("/api/calendar", h.ListCalendar) protected.Get("/api/schedule", h.ListCalendar)
protected.Get("/api/questions", h.ListQuestions) protected.Get("/api/questions", h.ListQuestions)
protected.Post("/api/questions", h.CreateQuestion) protected.Post("/api/questions", h.CreateQuestion)
protected.Put("/api/questions/{id}", h.UpdateQuestion) 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 Scenario: User story - an interviewer can reach collaboration areas
Given I am signed in as a valid interviewer Given I am signed in as a valid interviewer
When I follow the "Inbox" link When I follow the "Invitations" link
Then I should see the heading "Inbox" Then I should see the heading "Invitations"
And I should see "Inbox zero" And I should see "Invitation zero"
When I follow the "Calendar" link When I follow the "Schedule" link
Then I should be on the "/calendar" page Then I should be on the "/schedule" page
And I should see "Schedule" And I should see "Schedule"
When I follow the "Users" link When I follow the "Users" link
Then I should see the heading "Interview team" 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` - `PUT /api/interviews/:token/answers/:questionId`
- `POST /api/interviews/:token/finish` - `POST /api/interviews/:token/finish`
- `GET|POST /api/users` - `GET|POST /api/users`
- `GET /api/inbox` and `PATCH /api/inbox/:interviewId` - `GET /api/invitations` and `PATCH /api/invitations/:interviewId`
- `GET /api/calendar` - `GET /api/schedule`
- `GET /api/scheduled-interviews/:id` - `GET /api/scheduled-interviews/:id`
- `PATCH /api/scheduled-interviews/:id/status` - `PATCH /api/scheduled-interviews/:id/status`
- `PATCH /api/scheduled-interviews/:id/document` - `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: The authenticated workspace also includes:
- **Users** for listing and creating co-interviewers. - **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. - **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 - **Interviewer sessions** for changing status, editing prominent shared documentation, and
posting private notes while an interview is started. Notes and shared-document changes arrive 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 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 { beforeEach, describe, expect, it, vi } from 'vitest'
import { api, authStorage, SESSION_EXPIRED_EVENT } from './client' import { api, authStorage, SESSION_EXPIRED_EVENT } from './client'
import { subscribeToDomainEvent } from './domainEvents'
describe('API client', () => { describe('API client', () => {
beforeEach(() => { beforeEach(() => {
@ -82,4 +83,62 @@ describe('API client', () => {
const form = options.body as FormData const form = options.body as FormData
expect([...form.keys()]).toEqual(['firstName', 'lastName', 'birthday', 'email', 'contactInfo']) 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, User,
InterviewNote, InterviewNote,
} from '../types' } from '../types'
import { publishDomainEvent } from './domainEvents'
const TOKEN_KEY = 'go-guess-token' const TOKEN_KEY = 'go-guess-token'
const USER_KEY = 'go-guess-user' const USER_KEY = 'go-guess-user'
@ -115,9 +116,20 @@ async function requestBlob(path: string): Promise<Blob> {
if (response.status === 401) invalidateSession() if (response.status === 401) invalidateSession()
throw new ApiError(`Request failed with status ${response.status}`, response.status) throw new ApiError(`Request failed with status ${response.status}`, response.status)
} }
return response.blob() 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( async function subscribeToInterviewEvents(
path: string, path: string,
onEvent: (event: InterviewEvent) => void, onEvent: (event: InterviewEvent) => void,
@ -181,18 +193,34 @@ export const api = {
list: () => request<Job[]>('/jobs'), list: () => request<Job[]>('/jobs'),
get: (id: number) => request<Job>(`/jobs/${id}`), get: (id: number) => request<Job>(`/jobs/${id}`),
create: (input: CreateJobInput) => create: (input: CreateJobInput) =>
mutation(
request<Job>('/jobs', { method: 'POST', body: JSON.stringify(input) }), request<Job>('/jobs', { method: 'POST', body: JSON.stringify(input) }),
'job.created',
(job) => ['jobs', `jobs/${job.id}`],
),
update: (jobId: number, input: { status: JobStatus; durationMinutes: number }) => update: (jobId: number, input: { status: JobStatus; durationMinutes: number }) =>
mutation(
request<Job>(`/jobs/${jobId}`, { request<Job>(`/jobs/${jobId}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(input), body: JSON.stringify(input),
}), }),
'job.updated',
() => ['jobs', `jobs/${jobId}`],
),
attachQuestion: (jobId: number, questionId: number) => attachQuestion: (jobId: number, questionId: number) =>
mutation(
request<void>(`/jobs/${jobId}/questions/${questionId}`, { request<void>(`/jobs/${jobId}/questions/${questionId}`, {
method: 'POST', method: 'POST',
}), }),
'job.question-attached',
() => ['jobs', `jobs/${jobId}`, `questions/${questionId}`],
),
detachQuestion: (jobId: number, questionId: number) => detachQuestion: (jobId: number, questionId: number) =>
mutation(
request<void>(`/jobs/${jobId}/questions/${questionId}`, { method: 'DELETE' }), request<void>(`/jobs/${jobId}/questions/${questionId}`, { method: 'DELETE' }),
'job.question-detached',
() => ['jobs', `jobs/${jobId}`, `questions/${questionId}`],
),
candidates: async (jobId: number) => { candidates: async (jobId: number) => {
const matches = await request<CandidateMatch[]>(`/jobs/${jobId}/candidates`) const matches = await request<CandidateMatch[]>(`/jobs/${jobId}/candidates`)
return matches.map((match) => ({ return matches.map((match) => ({
@ -205,50 +233,100 @@ export const api = {
invitation: (jobId: number, invitationId: number) => invitation: (jobId: number, invitationId: number) =>
request<Interview>(`/jobs/${jobId}/invitations/${invitationId}`), request<Interview>(`/jobs/${jobId}/invitations/${invitationId}`),
invite: (jobId: number, participantId: number) => invite: (jobId: number, participantId: number) =>
mutation(
request<Invitation>(`/jobs/${jobId}/invitations`, { request<Invitation>(`/jobs/${jobId}/invitations`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ participantId }), body: JSON.stringify({ participantId }),
}), }),
'invitation.created',
(invitation) => [
`jobs/${jobId}`,
`job-invitations/${jobId}`,
`assessment-invitations/${invitation.id}`,
],
),
setInvitationOutcome: (jobId: number, invitationId: number, outcome: Invitation['outcome']) => setInvitationOutcome: (jobId: number, invitationId: number, outcome: Invitation['outcome']) =>
mutation(
request<Invitation>(`/jobs/${jobId}/invitations/${invitationId}/outcome`, { request<Invitation>(`/jobs/${jobId}/invitations/${invitationId}/outcome`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({ outcome }), body: JSON.stringify({ outcome }),
}), }),
'invitation.outcome-updated',
() => [
`jobs/${jobId}`,
`job-invitations/${jobId}`,
`assessment-invitations/${invitationId}`,
],
),
interviews: (jobId: number) => request<ScheduledInterview[]>(`/jobs/${jobId}/interviews`), interviews: (jobId: number) => request<ScheduledInterview[]>(`/jobs/${jobId}/interviews`),
scheduleInterview: (jobId: number, input: CreateScheduledInterviewInput) => scheduleInterview: (jobId: number, input: CreateScheduledInterviewInput) =>
mutation(
request<ScheduledInterview>(`/jobs/${jobId}/interviews`, { request<ScheduledInterview>(`/jobs/${jobId}/interviews`, {
method: 'POST', method: 'POST',
body: JSON.stringify(input), body: JSON.stringify(input),
}), }),
'scheduled-interview.created',
(interview) => [
`jobs/${jobId}`,
'scheduled-interviews',
`scheduled-interviews/${interview.id}`,
'invitations',
'schedule',
],
),
}, },
questions: { questions: {
list: (search = '') => list: (search = '') =>
request<Question[]>(`/questions${search ? `?search=${encodeURIComponent(search)}` : ''}`), request<Question[]>(`/questions${search ? `?search=${encodeURIComponent(search)}` : ''}`),
create: (input: CreateQuestionInput) => create: (input: CreateQuestionInput) =>
mutation(
request<Question>('/questions', { method: 'POST', body: JSON.stringify(input) }), request<Question>('/questions', { method: 'POST', body: JSON.stringify(input) }),
'question.created',
(question) => ['questions', `questions/${question.id}`],
),
update: (id: number, input: CreateQuestionInput) => update: (id: number, input: CreateQuestionInput) =>
mutation(
request<Question>(`/questions/${id}`, { request<Question>(`/questions/${id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(input), body: JSON.stringify(input),
}), }),
'question.updated',
() => ['questions', `questions/${id}`],
),
setDeprecated: (id: number, deprecated: boolean) => setDeprecated: (id: number, deprecated: boolean) =>
mutation(
request<Question>(`/questions/${id}`, { request<Question>(`/questions/${id}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({ deprecated }), body: JSON.stringify({ deprecated }),
}), }),
'question.deprecation-updated',
() => ['questions', `questions/${id}`],
),
}, },
interviews: { interviews: {
get: (token: string) => request<Interview>(`/interviews/${token}`, {}, false), get: (token: string) => request<Interview>(`/interviews/${token}`, {}, false),
accept: (token: string) => accept: (token: string) =>
mutation(
request<Interview>(`/interviews/${token}/accept`, { method: 'POST' }, false), 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) => answer: (token: string, questionId: number, answer: string) =>
mutation(
request<void>( request<void>(
`/interviews/${token}/answers/${questionId}`, `/interviews/${token}/answers/${questionId}`,
{ method: 'PUT', body: JSON.stringify({ answer }) }, { method: 'PUT', body: JSON.stringify({ answer }) },
false, false,
), ),
'assessment-answer.saved',
() => [`interviews/${token}`],
),
finish: (token: string) => finish: (token: string) =>
mutation(
request<Interview>(`/interviews/${token}/finish`, { method: 'POST' }, false), request<Interview>(`/interviews/${token}/finish`, { method: 'POST' }, false),
'assessment-invitation.completed',
(interview) => [`interviews/${token}`, `assessment-invitations/${interview.invitation.id}`],
),
}, },
participants: { participants: {
list: () => request<Participant[]>('/participants'), list: () => request<Participant[]>('/participants'),
@ -264,41 +342,70 @@ export const api = {
form.append('contactInfo', input.contactInfo) form.append('contactInfo', input.contactInfo)
if (input.photo) form.append('photo', input.photo) if (input.photo) form.append('photo', input.photo)
if (input.cv) form.append('cv', input.cv) 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: { users: {
list: () => request<User[]>('/users'), list: () => request<User[]>('/users'),
create: (input: { email: string; password: string; displayName: string }) => create: (input: { email: string; password: string; displayName: string }) =>
mutation(
request<User>('/users', { method: 'POST', body: JSON.stringify(input) }), request<User>('/users', { method: 'POST', body: JSON.stringify(input) }),
'user.created',
(user) => ['users', `users/${user.id}`],
),
}, },
inbox: { inbox: {
list: () => request<ScheduledInterview[]>('/inbox'), list: () => request<ScheduledInterview[]>('/invitations'),
respond: (interviewId: number, status: 'accepted' | 'declined') => respond: (interviewId: number, status: 'accepted' | 'declined') =>
request<ScheduledInterview>(`/inbox/${interviewId}`, { mutation(
request<ScheduledInterview>(`/invitations/${interviewId}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({ status }), body: JSON.stringify({ status }),
}), }),
'interview-invitation.responded',
() => [
'invitations',
`invitations/${interviewId}`,
'schedule',
`scheduled-interviews/${interviewId}`,
],
),
}, },
calendar: () => request<ScheduledInterview[]>('/calendar'), calendar: () => request<ScheduledInterview[]>('/schedule'),
scheduledInterviews: { scheduledInterviews: {
get: (id: number) => request<ScheduledInterview>(`/scheduled-interviews/${id}`), get: (id: number) => request<ScheduledInterview>(`/scheduled-interviews/${id}`),
setStatus: (id: number, status: string) => setStatus: (id: number, status: string) =>
mutation(
request<ScheduledInterview>(`/scheduled-interviews/${id}/status`, { request<ScheduledInterview>(`/scheduled-interviews/${id}/status`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({ status }), body: JSON.stringify({ status }),
}), }),
'scheduled-interview.status-updated',
() => ['scheduled-interviews', `scheduled-interviews/${id}`, 'schedule', 'invitations'],
),
updateDocument: (id: number, sharedDocument: string) => updateDocument: (id: number, sharedDocument: string) =>
mutation(
request<ScheduledInterview>(`/scheduled-interviews/${id}/document`, { request<ScheduledInterview>(`/scheduled-interviews/${id}/document`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({ sharedDocument }), body: JSON.stringify({ sharedDocument }),
}), }),
'scheduled-interview.document-updated',
() => [`scheduled-interviews/${id}`],
),
notes: (id: number) => request<InterviewNote[]>(`/scheduled-interviews/${id}/notes`), notes: (id: number) => request<InterviewNote[]>(`/scheduled-interviews/${id}/notes`),
addNote: (id: number, body: string) => addNote: (id: number, body: string) =>
mutation(
request<InterviewNote>(`/scheduled-interviews/${id}/notes`, { request<InterviewNote>(`/scheduled-interviews/${id}/notes`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body }), body: JSON.stringify({ body }),
}), }),
'scheduled-interview.note-created',
() => [`scheduled-interviews/${id}/notes`],
),
subscribe: ( subscribe: (
id: number, id: number,
onEvent: (event: InterviewEvent) => void, 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( expect(
await screen.findByRole('heading', { name: 'Sign in to your workspace' }), 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() expect(screen.getByText('admin@example.com')).toBeInTheDocument()
const accountNavigation = screen.getByRole('navigation', { name: 'Account navigation' }) 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', 'href',
'/inbox', '/invitations',
) )
expect(within(accountNavigation).getByRole('link', { name: 'Admin' })).toHaveAttribute( expect(within(accountNavigation).getByRole('link', { name: 'Admin' })).toHaveAttribute(
'href', 'href',
@ -261,7 +261,9 @@ describe('Go Guess frontend', () => {
) )
const primaryNavigation = screen.getByRole('navigation', { name: 'Primary navigation' }) 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' }) const footerNavigation = screen.getByRole('navigation', { name: 'Footer navigation' })
expect(within(footerNavigation).getByRole('link', { name: 'Jobs' })).toHaveAttribute( 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 () => { it('attaches and detaches library questions without deleting them', async () => {
authenticate() authenticate()
const user = userEvent.setup() 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') renderApp('/jobs/7')
await user.click(await screen.findByRole('tab', { name: /questions/i })) await user.click(await screen.findByRole('tab', { name: /questions/i }))
await user.click(await screen.findByRole('button', { name: 'Attach' })) 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( expect(fetch).toHaveBeenCalledWith(
'/api/jobs/7/questions/11', '/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() authenticate()
const user = userEvent.setup() const user = userEvent.setup()
const scheduled = { const scheduled = {
@ -853,33 +868,48 @@ describe('Go Guess frontend', () => {
candidateToken: 'meeting-token', candidateToken: 'meeting-token',
candidateUrl: '/participant/meeting/meeting-token', candidateUrl: '/participant/meeting/meeting-token',
sharedDocument: 'Agenda', sharedDocument: 'Agenda',
attendees: [], attendees: [
{
userId: 1,
displayName: 'Admin',
email: 'admin@example.com',
status: 'pending',
},
],
notes: [], notes: [],
} }
const defaultFetch = vi.mocked(fetch).getMockImplementation() const defaultFetch = vi.mocked(fetch).getMockImplementation()
vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => { vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => {
const path = String(input) const path = String(input)
if (path === '/api/inbox' && !options?.method) return response([scheduled]) if (path === '/api/invitations' && !options?.method) return response([scheduled])
if (path === '/api/inbox/80') if (path === '/api/invitations/80')
return response({ ...scheduled, status: JSON.parse(String(options?.body)).status }) return response({
...scheduled,
attendees: [
{
...scheduled.attendees[0],
status: JSON.parse(String(options?.body)).status,
},
],
})
if (!defaultFetch) throw new Error(`Unexpected request: ${path}`) if (!defaultFetch) throw new Error(`Unexpected request: ${path}`)
return defaultFetch(input, options) return defaultFetch(input, options)
}) })
renderApp('/inbox') renderApp('/invitations')
await user.click(await screen.findByRole('button', { name: 'Accept' })) await user.click(await screen.findByRole('button', { name: 'Accept' }))
await user.click(screen.getByRole('button', { name: 'Decline' }))
expect(fetch).toHaveBeenCalledWith( expect(fetch).toHaveBeenCalledWith(
'/api/inbox/80', '/api/invitations/80',
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ status: 'accepted' }) }), 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 () => { it('groups calendar interviews by date and links to the session', async () => {
authenticate() authenticate()
const defaultFetch = vi.mocked(fetch).getMockImplementation() const defaultFetch = vi.mocked(fetch).getMockImplementation()
vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => { vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => {
if (String(input) === '/api/calendar') if (String(input) === '/api/schedule')
return response([ return response([
{ {
id: 80, id: 80,
@ -900,7 +930,7 @@ describe('Go Guess frontend', () => {
if (!defaultFetch) throw new Error(`Unexpected request: ${String(input)}`) if (!defaultFetch) throw new Error(`Unexpected request: ${String(input)}`)
return defaultFetch(input, options) return defaultFetch(input, options)
}) })
renderApp('/calendar') renderApp('/schedule')
expect(await screen.findByText('Amélie Dubois')).toBeInTheDocument() expect(await screen.findByText('Amélie Dubois')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Open session' })).toHaveAttribute( expect(screen.getByRole('link', { name: 'Open session' })).toHaveAttribute(
'href', 'href',

View file

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

View file

@ -3,6 +3,7 @@ import { api } from '../api/client'
import { errorMessage } from '../components/actions' import { errorMessage } from '../components/actions'
import type { CreateJobInput, CreateParticipantInput, Job, Participant } from '../types' import type { CreateJobInput, CreateParticipantInput, Job, Participant } from '../types'
import { DataContext } from './dataState' import { DataContext } from './dataState'
import { useDomainEvent } from './useDomainEvent'
export function DataProvider({ children }: { children: ReactNode }) { export function DataProvider({ children }: { children: ReactNode }) {
const [jobs, setJobs] = useState<Job[]>([]) const [jobs, setJobs] = useState<Job[]>([])
@ -10,8 +11,8 @@ export function DataProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
async function reload() { async function refresh(showLoading: boolean) {
setLoading(true) if (showLoading) setLoading(true)
setError('') setError('')
try { try {
const [jobItems, participantItems] = await Promise.all([ const [jobItems, participantItems] = await Promise.all([
@ -23,10 +24,14 @@ export function DataProvider({ children }: { children: ReactNode }) {
} catch (requestError) { } catch (requestError) {
setError(errorMessage(requestError)) setError(errorMessage(requestError))
} finally { } finally {
setLoading(false) if (showLoading) setLoading(false)
} }
} }
async function reload() {
await refresh(true)
}
useEffect(() => { useEffect(() => {
let active = true let active = true
void Promise.all([api.jobs.list(), api.participants.list()]) 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) { async function createJob(input: CreateJobInput) {
const created = await api.jobs.create(input) return api.jobs.create(input)
setJobs((current) => [created, ...current])
return created
} }
async function createParticipant(input: CreateParticipantInput) { async function createParticipant(input: CreateParticipantInput) {
const created = await api.participants.create(input) return api.participants.create(input)
setParticipants((current) => [created, ...current])
return created
} }
return ( 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' const profileLabel = user?.displayName || user?.email || 'Profile'
return ( return (
<nav className="workspace-utilities" aria-label="Account navigation"> <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" /> <Mail size={18} aria-hidden="true" />
<span>Inbox</span> <span>Invitations</span>
</NavLink> </NavLink>
<NavLink className="utility-link" to="/calendar"> <NavLink className="utility-link" to="/schedule">
<CalendarDays size={18} aria-hidden="true" /> <CalendarDays size={18} aria-hidden="true" />
<span>Calendar</span> <span>Schedule</span>
</NavLink> </NavLink>
<NavLink className="utility-link profile-utility" to="/profile"> <NavLink className="utility-link profile-utility" to="/profile">
<span className="avatar tiny" aria-hidden="true"> <span className="avatar tiny" aria-hidden="true">

View file

@ -20,8 +20,8 @@ export const footerLinkGroups: FooterLinkGroup[] = [
{ {
title: 'Useful links', title: 'Useful links',
links: [ links: [
{ label: 'Your inbox', href: '/inbox' }, { label: 'Invitations', href: '/invitations' },
{ label: 'Calendar', href: '/calendar' }, { label: 'Schedule', href: '/schedule' },
{ label: 'Your profile', href: '/profile' }, { label: 'Your profile', href: '/profile' },
{ label: 'Interview team', href: '/users' }, { 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 { type ReactNode, useEffect, useRef, useState } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { api } from '../../api/client' import { api } from '../../api/client'
import { useDomainEvent } from '../../app/useDomainEvent'
import { ErrorAlert, errorMessage, Loading } from '../../components/actions' import { ErrorAlert, errorMessage, Loading } from '../../components/actions'
import Logo from '../../components/layout/Logo' import Logo from '../../components/layout/Logo'
import type { Interview } from '../../types' import type { Interview } from '../../types'
@ -40,6 +41,16 @@ export function ParticipantInterviewPage() {
} }
}, [token]) }, [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 acceptedAt = interview?.invitation.acceptedAt
const durationMinutes = interview?.job.durationMinutes const durationMinutes = interview?.job.durationMinutes
useEffect(() => { useEffect(() => {
@ -57,9 +68,7 @@ export function ParticipantInterviewPage() {
setWorking(true) setWorking(true)
setError('') setError('')
try { try {
const accepted = await api.interviews.accept(token) await api.interviews.accept(token)
setInterview(accepted)
setAnswers(accepted.answers ?? {})
} catch (requestError) { } catch (requestError) {
setError(errorMessage(requestError)) setError(errorMessage(requestError))
} finally { } finally {
@ -107,7 +116,7 @@ export function ParticipantInterviewPage() {
setError('') setError('')
try { try {
await persistCurrent() await persistCurrent()
setInterview(await api.interviews.finish(token)) await api.interviews.finish(token)
} catch (requestError) { } catch (requestError) {
finishing.current = false finishing.current = false
setError(errorMessage(requestError)) setError(errorMessage(requestError))

View file

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

View file

@ -11,6 +11,7 @@ import {
import { type FormEvent, useEffect, useState } from 'react' import { type FormEvent, useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import { api } from '../../api/client' import { api } from '../../api/client'
import { useDomainEvent } from '../../app/useDomainEvent'
import { useData } from '../../app/useData' import { useData } from '../../app/useData'
import { ErrorAlert, errorMessage, Loading } from '../../components/actions' import { ErrorAlert, errorMessage, Loading } from '../../components/actions'
import { FormActions, FormField } from '../../components/form' import { FormActions, FormField } from '../../components/form'
@ -210,6 +211,7 @@ export function ParticipantDetailPage() {
.then(setPerson) .then(setPerson)
.catch((value) => setError(errorMessage(value))) .catch((value) => setError(errorMessage(value)))
}, [id]) }, [id])
useDomainEvent<Participant>(`participants/${id}`, (event) => setPerson(event.data))
if (error) return <ErrorAlert message={error} /> if (error) return <ErrorAlert message={error} />
if (!person) return <Loading /> 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 { type FormEvent, useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import { api } from '../../api/client' import { api } from '../../api/client'
import { useDomainEvent } from '../../app/useDomainEvent'
import { ErrorAlert, errorMessage, Loading } from '../../components/actions' import { ErrorAlert, errorMessage, Loading } from '../../components/actions'
import { FormField } from '../../components/form' import { FormField } from '../../components/form'
import { EmptyState } from '../../components/ui/Display' import { EmptyState } from '../../components/ui/Display'
@ -266,25 +267,31 @@ export function QuestionLibraryPage() {
} }
}, [query]) }, [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) { async function create(input: CreateQuestionInput) {
const created = await api.questions.create(input) await api.questions.create(input)
setQuestions((current) => [created, ...current])
setFormVersion((current) => current + 1) setFormVersion((current) => current + 1)
} }
async function update(input: CreateQuestionInput) { async function update(input: CreateQuestionInput) {
if (!editing) return if (!editing) return
const updated = await api.questions.update(editing.id, input) await api.questions.update(editing.id, input)
setQuestions((current) => current.map((item) => (item.id === updated.id ? updated : item)))
setEditing(undefined) setEditing(undefined)
} }
async function toggle(question: Question) { async function toggle(question: Question) {
setError('') setError('')
try { try {
const updated = await api.questions.setDeprecated(question.id, !question.deprecated) 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)
} catch (requestError) { } catch (requestError) {
setError(errorMessage(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 { Plus } from 'lucide-react'
import { type FormEvent, useEffect, useState } from 'react' import { type FormEvent, useEffect, useState } from 'react'
import { api, ApiError } from '../../api/client' import { api, ApiError } from '../../api/client'
import { useDomainEvent } from '../../app/useDomainEvent'
import { ErrorAlert, Loading } from '../../components/ui/AsyncState' import { ErrorAlert, Loading } from '../../components/ui/AsyncState'
import type { User } from '../../types' import type { User } from '../../types'
@ -24,6 +25,10 @@ export function UsersPage() {
.finally(() => setLoading(false)) .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>) { async function create(event: FormEvent<HTMLFormElement>) {
event.preventDefault() event.preventDefault()
setSaving(true) setSaving(true)
@ -31,12 +36,11 @@ export function UsersPage() {
const form = event.currentTarget const form = event.currentTarget
const values = new FormData(form) const values = new FormData(form)
try { try {
const created = await api.users.create({ await api.users.create({
email: String(values.get('email')), email: String(values.get('email')),
password: String(values.get('password')), password: String(values.get('password')),
displayName: String(values.get('displayName')), displayName: String(values.get('displayName')),
}) })
setUsers((current) => [...current, created])
form.reset() form.reset()
} catch (value) { } catch (value) {
setError(errorMessage(value)) setError(errorMessage(value))

View file

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

View file

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