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,11 +15,20 @@ 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"`
SharedDocument string `json:"sharedDocument,omitempty"` Attendee *InterviewAttendeeEvent `json:"attendee,omitempty"`
Status string `json:"status,omitempty"`
SharedDocument string `json:"sharedDocument,omitempty"`
} }
type IEventBus interface { type IEventBus interface {

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) =>
request<Job>('/jobs', { method: 'POST', body: JSON.stringify(input) }), mutation(
request<Job>('/jobs', { method: 'POST', body: JSON.stringify(input) }),
'job.created',
(job) => ['jobs', `jobs/${job.id}`],
),
update: (jobId: number, input: { status: JobStatus; durationMinutes: number }) => update: (jobId: number, input: { status: JobStatus; durationMinutes: number }) =>
request<Job>(`/jobs/${jobId}`, { mutation(
method: 'PATCH', request<Job>(`/jobs/${jobId}`, {
body: JSON.stringify(input), method: 'PATCH',
}), body: JSON.stringify(input),
}),
'job.updated',
() => ['jobs', `jobs/${jobId}`],
),
attachQuestion: (jobId: number, questionId: number) => attachQuestion: (jobId: number, questionId: number) =>
request<void>(`/jobs/${jobId}/questions/${questionId}`, { mutation(
method: 'POST', request<void>(`/jobs/${jobId}/questions/${questionId}`, {
}), method: 'POST',
}),
'job.question-attached',
() => ['jobs', `jobs/${jobId}`, `questions/${questionId}`],
),
detachQuestion: (jobId: number, questionId: number) => detachQuestion: (jobId: number, questionId: number) =>
request<void>(`/jobs/${jobId}/questions/${questionId}`, { method: 'DELETE' }), mutation(
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) =>
request<Invitation>(`/jobs/${jobId}/invitations`, { mutation(
method: 'POST', request<Invitation>(`/jobs/${jobId}/invitations`, {
body: JSON.stringify({ participantId }), method: 'POST',
}), body: JSON.stringify({ participantId }),
}),
'invitation.created',
(invitation) => [
`jobs/${jobId}`,
`job-invitations/${jobId}`,
`assessment-invitations/${invitation.id}`,
],
),
setInvitationOutcome: (jobId: number, invitationId: number, outcome: Invitation['outcome']) => setInvitationOutcome: (jobId: number, invitationId: number, outcome: Invitation['outcome']) =>
request<Invitation>(`/jobs/${jobId}/invitations/${invitationId}/outcome`, { mutation(
method: 'PATCH', request<Invitation>(`/jobs/${jobId}/invitations/${invitationId}/outcome`, {
body: JSON.stringify({ outcome }), method: 'PATCH',
}), body: JSON.stringify({ outcome }),
}),
'invitation.outcome-updated',
() => [
`jobs/${jobId}`,
`job-invitations/${jobId}`,
`assessment-invitations/${invitationId}`,
],
),
interviews: (jobId: number) => request<ScheduledInterview[]>(`/jobs/${jobId}/interviews`), interviews: (jobId: number) => request<ScheduledInterview[]>(`/jobs/${jobId}/interviews`),
scheduleInterview: (jobId: number, input: CreateScheduledInterviewInput) => scheduleInterview: (jobId: number, input: CreateScheduledInterviewInput) =>
request<ScheduledInterview>(`/jobs/${jobId}/interviews`, { mutation(
method: 'POST', request<ScheduledInterview>(`/jobs/${jobId}/interviews`, {
body: JSON.stringify(input), method: 'POST',
}), 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) =>
request<Question>('/questions', { method: 'POST', body: JSON.stringify(input) }), mutation(
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) =>
request<Question>(`/questions/${id}`, { mutation(
method: 'PUT', request<Question>(`/questions/${id}`, {
body: JSON.stringify(input), method: 'PUT',
}), body: JSON.stringify(input),
}),
'question.updated',
() => ['questions', `questions/${id}`],
),
setDeprecated: (id: number, deprecated: boolean) => setDeprecated: (id: number, deprecated: boolean) =>
request<Question>(`/questions/${id}`, { mutation(
method: 'PATCH', request<Question>(`/questions/${id}`, {
body: JSON.stringify({ deprecated }), method: 'PATCH',
}), 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) =>
request<Interview>(`/interviews/${token}/accept`, { method: 'POST' }, false), mutation(
request<Interview>(`/interviews/${token}/accept`, { method: 'POST' }, false),
'assessment-invitation.accepted',
(interview) => [`interviews/${token}`, `assessment-invitations/${interview.invitation.id}`],
),
answer: (token: string, questionId: number, answer: string) => answer: (token: string, questionId: number, answer: string) =>
request<void>( mutation(
`/interviews/${token}/answers/${questionId}`, request<void>(
{ method: 'PUT', body: JSON.stringify({ answer }) }, `/interviews/${token}/answers/${questionId}`,
false, { method: 'PUT', body: JSON.stringify({ answer }) },
false,
),
'assessment-answer.saved',
() => [`interviews/${token}`],
), ),
finish: (token: string) => finish: (token: string) =>
request<Interview>(`/interviews/${token}/finish`, { method: 'POST' }, false), mutation(
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 }) =>
request<User>('/users', { method: 'POST', body: JSON.stringify(input) }), mutation(
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(
method: 'PATCH', request<ScheduledInterview>(`/invitations/${interviewId}`, {
body: JSON.stringify({ status }), method: 'PATCH',
}), body: JSON.stringify({ status }),
}),
'interview-invitation.responded',
() => [
'invitations',
`invitations/${interviewId}`,
'schedule',
`scheduled-interviews/${interviewId}`,
],
),
}, },
calendar: () => request<ScheduledInterview[]>('/calendar'), calendar: () => request<ScheduledInterview[]>('/schedule'),
scheduledInterviews: { 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) =>
request<ScheduledInterview>(`/scheduled-interviews/${id}/status`, { mutation(
method: 'PATCH', request<ScheduledInterview>(`/scheduled-interviews/${id}/status`, {
body: JSON.stringify({ status }), method: 'PATCH',
}), 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) =>
request<ScheduledInterview>(`/scheduled-interviews/${id}/document`, { mutation(
method: 'PATCH', request<ScheduledInterview>(`/scheduled-interviews/${id}/document`, {
body: JSON.stringify({ sharedDocument }), method: 'PATCH',
}), 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) =>
request<InterviewNote>(`/scheduled-interviews/${id}/notes`, { mutation(
method: 'POST', request<InterviewNote>(`/scheduled-interviews/${id}/notes`, {
body: JSON.stringify({ body }), method: 'POST',
}), 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

@ -8,6 +8,6 @@ export function errorMessage(error: unknown) {
export function message(error: unknown) { export function message(error: unknown) {
return error instanceof ApiError || error instanceof Error return error instanceof ApiError || error instanceof Error
? error.message ? error.message
: 'Something went wrong' : 'Something went wrong'
} }

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>
)
}

File diff suppressed because it is too large Load diff

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