diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4547459..de4a115 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -74,7 +74,7 @@ interviewer review uses Only completed assessment invitations can receive a `passed` or `failed` outcome, and only passed candidates can be scheduled for an interview. Scheduled interviews include the creator plus selected co-interviewers. Attendees manage -their invitation through `/api/inbox`, see accepted interviews in `/api/calendar`, +their invitation through `/api/invitations`, see accepted interviews in `/api/schedule`, and can write notes only after the interview has started. Notes are private to authenticated attendees. The opaque `/participant/meeting/:candidateToken` view exposes only candidate-safe schedule and shared-document data. diff --git a/Makefile b/Makefile index c26976c..d904ae5 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ dev: docker compose up --build db-up: - docker compose up -d db + docker compose up -d db rabbitmq db-down: docker compose down diff --git a/backend/internal/eventbus/eventbus.go b/backend/internal/eventbus/eventbus.go index 349966c..6819a58 100644 --- a/backend/internal/eventbus/eventbus.go +++ b/backend/internal/eventbus/eventbus.go @@ -15,11 +15,20 @@ type InterviewNoteEvent struct { CreatedAt time.Time `json:"createdAt"` } +type InterviewAttendeeEvent struct { + UserID uint `json:"userId"` + DisplayName string `json:"displayName"` + Email string `json:"email"` + Status string `json:"status"` +} + type InterviewEvent struct { - Type string `json:"type"` - InterviewID uint `json:"interviewId"` - Note *InterviewNoteEvent `json:"note,omitempty"` - SharedDocument string `json:"sharedDocument,omitempty"` + Type string `json:"type"` + InterviewID uint `json:"interviewId"` + Note *InterviewNoteEvent `json:"note,omitempty"` + Attendee *InterviewAttendeeEvent `json:"attendee,omitempty"` + Status string `json:"status,omitempty"` + SharedDocument string `json:"sharedDocument,omitempty"` } type IEventBus interface { diff --git a/backend/internal/integration/api_test.go b/backend/internal/integration/api_test.go index 3d29387..025d3b5 100644 --- a/backend/internal/integration/api_test.go +++ b/backend/internal/integration/api_test.go @@ -297,14 +297,14 @@ func TestCompleteInterviewWorkflow(t *testing.T) { }, http.StatusOK, &coLogin) var inbox []webmodel.ScheduledInterviewResponse - requestJSON(t, http.MethodGet, "/api/inbox", coLogin.Token, nil, http.StatusOK, &inbox) + requestJSON(t, http.MethodGet, "/api/invitations", coLogin.Token, nil, http.StatusOK, &inbox) if len(inbox) != 1 || inbox[0].ID != scheduled.ID { t.Fatalf("scheduled interview missing from co-interviewer inbox: %#v", inbox) } - requestJSON(t, http.MethodPatch, fmt.Sprintf("/api/inbox/%d", scheduled.ID), + requestJSON(t, http.MethodPatch, fmt.Sprintf("/api/invitations/%d", scheduled.ID), coLogin.Token, map[string]any{"status": "accepted"}, http.StatusOK, &scheduled) var calendar []webmodel.ScheduledInterviewResponse - requestJSON(t, http.MethodGet, "/api/calendar", coLogin.Token, nil, http.StatusOK, &calendar) + requestJSON(t, http.MethodGet, "/api/schedule", coLogin.Token, nil, http.StatusOK, &calendar) if len(calendar) != 1 || calendar[0].ID != scheduled.ID { t.Fatalf("accepted interview missing from calendar: %#v", calendar) } diff --git a/backend/internal/service/interviews.go b/backend/internal/service/interviews.go index a0e77ac..d00d440 100644 --- a/backend/internal/service/interviews.go +++ b/backend/internal/service/interviews.go @@ -166,7 +166,16 @@ func (s *ScheduledInterviewService) UpdateStatus( if !valid { return model.ScheduledInterview{}, fmt.Errorf("%w: invalid interview status transition", ErrInvalidState) } - return s.store.UpdateScheduledInterviewStatus(ctx, interviewID, status) + updated, err := s.store.UpdateScheduledInterviewStatus(ctx, interviewID, status) + if err != nil { + return model.ScheduledInterview{}, err + } + if err := s.bus.PublishInterviewEvent(ctx, eventbus.InterviewEvent{ + Type: "status.updated", InterviewID: interviewID, Status: updated.Status, + }); err != nil { + return model.ScheduledInterview{}, fmt.Errorf("publish persisted interview status: %w", err) + } + return updated, nil } func (s *ScheduledInterviewService) UpdateDocument( @@ -243,10 +252,24 @@ func (s *ScheduledInterviewService) UpdateInbox( if status != "accepted" && status != "declined" { return model.ScheduledInterview{}, fmt.Errorf("%w: attendee status must be accepted or declined", ErrValidation) } - if _, err := s.store.UpdateAttendeeStatus(ctx, interviewID, userID, status); err != nil { + attendee, err := s.store.UpdateAttendeeStatus(ctx, interviewID, userID, status) + if err != nil { return model.ScheduledInterview{}, err } - return s.Get(ctx, userID, interviewID) + updated, err := s.Get(ctx, userID, interviewID) + if err != nil { + return model.ScheduledInterview{}, err + } + if err := s.bus.PublishInterviewEvent(ctx, eventbus.InterviewEvent{ + Type: "attendee.updated", InterviewID: interviewID, + Attendee: &eventbus.InterviewAttendeeEvent{ + UserID: attendee.UserID, DisplayName: attendee.User.DisplayName, + Email: attendee.User.Email, Status: attendee.Status, + }, + }); err != nil { + return model.ScheduledInterview{}, fmt.Errorf("publish persisted attendee status: %w", err) + } + return updated, nil } func (s *ScheduledInterviewService) ListCalendar( @@ -303,7 +326,7 @@ func (s *ScheduledInterviewService) subscribeToInterview( if event.InterviewID != interviewID { continue } - if documentsOnly && event.Type != "document.updated" { + if documentsOnly && event.Type != "document.updated" && event.Type != "status.updated" { continue } select { diff --git a/backend/internal/service/services_test.go b/backend/internal/service/services_test.go index 9d050b5..c433389 100644 --- a/backend/internal/service/services_test.go +++ b/backend/internal/service/services_test.go @@ -34,6 +34,7 @@ type scheduledStore struct { createdIDs []uint status string document string + attendee model.InterviewAttendee } func (f *scheduledStore) GetInvitation(context.Context, uint, uint) (model.Invitation, error) { @@ -68,6 +69,20 @@ func (f *scheduledStore) UpdateScheduledInterviewDocument( f.interview.SharedDocument = document return f.interview, nil } +func (f *scheduledStore) UpdateAttendeeStatus( + _ context.Context, _, userID uint, status string, +) (model.InterviewAttendee, error) { + f.attendee = model.InterviewAttendee{ + UserID: userID, Status: status, + User: model.User{ID: userID, DisplayName: "Ada", Email: "ada@example.com"}, + } + for index := range f.interview.Attendees { + if f.interview.Attendees[index].UserID == userID { + f.interview.Attendees[index] = f.attendee + } + } + return f.attendee, nil +} func (f *scheduledStore) CreateInterviewNote( _ context.Context, value model.InterviewNote, ) (model.InterviewNote, error) { @@ -414,6 +429,7 @@ func TestUpdateDocumentPublishesEvent(t *testing.T) { ID: 9, Status: "scheduled", Attendees: []model.InterviewAttendee{{UserID: 1}}, }, } + bus := eventbus.NewMemoryBus() events, err := bus.SubscribeInterviewEvents(context.Background()) if err != nil { @@ -431,6 +447,36 @@ func TestUpdateDocumentPublishesEvent(t *testing.T) { } } +func TestStatusAndAttendeeUpdatesPublishEvents(t *testing.T) { + store := &scheduledStore{ + fakeStore: &fakeStore{}, + interview: model.ScheduledInterview{ + ID: 9, Status: "scheduled", + Attendees: []model.InterviewAttendee{{UserID: 1, Status: "pending"}}, + }, + } + bus := eventbus.NewMemoryBus() + events, err := bus.SubscribeInterviewEvents(context.Background()) + if err != nil { + t.Fatal(err) + } + interviews := NewScheduledInterviewService(store, bus) + if _, err := interviews.UpdateStatus(context.Background(), 1, 9, "started"); err != nil { + t.Fatal(err) + } + if event := <-events; event.Type != "status.updated" || event.Status != "started" { + t.Fatalf("unexpected status event: %#v", event) + } + store.interview.Status = "scheduled" + if _, err := interviews.UpdateInbox(context.Background(), 1, 9, "accepted"); err != nil { + t.Fatal(err) + } + if event := <-events; event.Type != "attendee.updated" || event.Attendee == nil || + event.Attendee.UserID != 1 || event.Attendee.Status != "accepted" { + t.Fatalf("unexpected attendee event: %#v", event) + } +} + func TestParticipantSubscriptionFiltersPrivateNotes(t *testing.T) { bus := eventbus.NewMemoryBus() interviews := NewScheduledInterviewService(&fakeStore{}, bus) @@ -455,14 +501,27 @@ func TestParticipantSubscriptionFiltersPrivateNotes(t *testing.T) { }); err != nil { t.Fatal(err) } + if err := bus.PublishInterviewEvent(ctx, eventbus.InterviewEvent{ + Type: "status.updated", InterviewID: 0, Status: "started", + }); err != nil { + t.Fatal(err) + } select { case event := <-events: if event.Type != "document.updated" || event.SharedDocument != "candidate-safe" { - t.Fatalf("participant received unexpected event: %#v", event) + t.Fatalf("participant received unexpected document event: %#v", event) } case <-time.After(time.Second): t.Fatal("participant did not receive document event") } + select { + case event := <-events: + if event.Type != "status.updated" || event.Status != "started" { + t.Fatalf("participant received unexpected event: %#v", event) + } + case <-time.After(time.Second): + t.Fatal("participant did not receive status event") + } } func TestUserCreationHashesPasswordAndUsesCoInterviewerRole(t *testing.T) { diff --git a/backend/internal/web/router/router.go b/backend/internal/web/router/router.go index 3dfc11c..eff56e9 100644 --- a/backend/internal/web/router/router.go +++ b/backend/internal/web/router/router.go @@ -47,9 +47,9 @@ func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string) protected.Get("/api/scheduled-interviews/{id}/events", h.InterviewEvents) protected.Get("/api/users", h.ListUsers) protected.Post("/api/users", h.CreateUser) - protected.Get("/api/inbox", h.ListInbox) - protected.Patch("/api/inbox/{id}", h.UpdateInbox) - protected.Get("/api/calendar", h.ListCalendar) + protected.Get("/api/invitations", h.ListInbox) + protected.Patch("/api/invitations/{id}", h.UpdateInbox) + protected.Get("/api/schedule", h.ListCalendar) protected.Get("/api/questions", h.ListQuestions) protected.Post("/api/questions", h.CreateQuestion) protected.Put("/api/questions/{id}", h.UpdateQuestion) diff --git a/bdd/features/recruitment-workspace.feature b/bdd/features/recruitment-workspace.feature index 643ea9e..e9a9a8c 100644 --- a/bdd/features/recruitment-workspace.feature +++ b/bdd/features/recruitment-workspace.feature @@ -75,11 +75,11 @@ Feature: Recruitment workspace Scenario: User story - an interviewer can reach collaboration areas Given I am signed in as a valid interviewer - When I follow the "Inbox" link - Then I should see the heading "Inbox" - And I should see "Inbox zero" - When I follow the "Calendar" link - Then I should be on the "/calendar" page + When I follow the "Invitations" link + Then I should see the heading "Invitations" + And I should see "Invitation zero" + When I follow the "Schedule" link + Then I should be on the "/schedule" page And I should see "Schedule" When I follow the "Users" link Then I should see the heading "Interview team" diff --git a/deploy/local.yaml b/deploy/local.yaml new file mode 100644 index 0000000..35633c7 --- /dev/null +++ b/deploy/local.yaml @@ -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" diff --git a/frontend/README.md b/frontend/README.md index b30f4d2..3706d17 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -67,8 +67,8 @@ Supported routes: - `PUT /api/interviews/:token/answers/:questionId` - `POST /api/interviews/:token/finish` - `GET|POST /api/users` -- `GET /api/inbox` and `PATCH /api/inbox/:interviewId` -- `GET /api/calendar` +- `GET /api/invitations` and `PATCH /api/invitations/:interviewId` +- `GET /api/schedule` - `GET /api/scheduled-interviews/:id` - `PATCH /api/scheduled-interviews/:id/status` - `PATCH /api/scheduled-interviews/:id/document` @@ -98,9 +98,9 @@ form for the date, location, co-interviewers, and candidate-visible shared docum The authenticated workspace also includes: - **Users** for listing and creating co-interviewers. -- **Inbox** in the top-right account navigation for accepting or declining assignments. +- **Invitations** in the top-right account navigation for accepting or declining assignments. - **Profile** in the top-right account navigation for the logged-in user's identity and role. -- **Calendar** for interviews grouped by date. +- **Schedule** for interviews grouped by date. - **Interviewer sessions** for changing status, editing prominent shared documentation, and posting private notes while an interview is started. Notes and shared-document changes arrive live through an authenticated streaming `fetch`; the app intentionally does not use diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts index 78f80f4..6c76d72 100644 --- a/frontend/src/api/client.test.ts +++ b/frontend/src/api/client.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { api, authStorage, SESSION_EXPIRED_EVENT } from './client' +import { subscribeToDomainEvent } from './domainEvents' describe('API client', () => { beforeEach(() => { @@ -82,4 +83,62 @@ describe('API client', () => { const form = options.body as FormData expect([...form.keys()]).toEqual(['firstName', 'lastName', 'birthday', 'email', 'contactInfo']) }) + + it('publishes an entity event after a successful mutation', async () => { + const job = { + id: 7, + title: 'Backend engineer', + description: 'Build APIs', + seniority: 'senior', + position: 'backend', + labels: [], + requiredSkills: ['Go'], + additionalSkills: [], + status: 'published', + durationMinutes: 60, + questions: [], + createdAt: '2026-09-01T00:00:00Z', + } as const + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify(job), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + const listener = vi.fn() + const unsubscribe = subscribeToDomainEvent(`jobs/${job.id}`, listener) + + await api.jobs.update(job.id, { status: 'published', durationMinutes: 60 }) + + expect(listener).toHaveBeenCalledWith({ + resource: 'jobs/7', + type: 'job.updated', + data: job, + }) + unsubscribe() + }) + + it('does not publish an event when a mutation fails', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: 'invalid transition' }), { + status: 409, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + const listener = vi.fn() + const unsubscribe = subscribeToDomainEvent('scheduled-interviews/80', listener) + + await expect(api.scheduledInterviews.setStatus(80, 'started')).rejects.toMatchObject({ + status: 409, + }) + + expect(listener).not.toHaveBeenCalled() + unsubscribe() + }) }) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index fbbfe2b..fcb241e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -17,6 +17,7 @@ import type { User, InterviewNote, } from '../types' +import { publishDomainEvent } from './domainEvents' const TOKEN_KEY = 'go-guess-token' const USER_KEY = 'go-guess-user' @@ -115,9 +116,20 @@ async function requestBlob(path: string): Promise { if (response.status === 401) invalidateSession() throw new ApiError(`Request failed with status ${response.status}`, response.status) } + return response.blob() } +async function mutation( + response: Promise, + type: string, + resources: (data: T) => string[], +): Promise { + const data = await response + publishDomainEvent(resources(data), type, data) + return data +} + async function subscribeToInterviewEvents( path: string, onEvent: (event: InterviewEvent) => void, @@ -181,18 +193,34 @@ export const api = { list: () => request('/jobs'), get: (id: number) => request(`/jobs/${id}`), create: (input: CreateJobInput) => - request('/jobs', { method: 'POST', body: JSON.stringify(input) }), + mutation( + request('/jobs', { method: 'POST', body: JSON.stringify(input) }), + 'job.created', + (job) => ['jobs', `jobs/${job.id}`], + ), update: (jobId: number, input: { status: JobStatus; durationMinutes: number }) => - request(`/jobs/${jobId}`, { - method: 'PATCH', - body: JSON.stringify(input), - }), + mutation( + request(`/jobs/${jobId}`, { + method: 'PATCH', + body: JSON.stringify(input), + }), + 'job.updated', + () => ['jobs', `jobs/${jobId}`], + ), attachQuestion: (jobId: number, questionId: number) => - request(`/jobs/${jobId}/questions/${questionId}`, { - method: 'POST', - }), + mutation( + request(`/jobs/${jobId}/questions/${questionId}`, { + method: 'POST', + }), + 'job.question-attached', + () => ['jobs', `jobs/${jobId}`, `questions/${questionId}`], + ), detachQuestion: (jobId: number, questionId: number) => - request(`/jobs/${jobId}/questions/${questionId}`, { method: 'DELETE' }), + mutation( + request(`/jobs/${jobId}/questions/${questionId}`, { method: 'DELETE' }), + 'job.question-detached', + () => ['jobs', `jobs/${jobId}`, `questions/${questionId}`], + ), candidates: async (jobId: number) => { const matches = await request(`/jobs/${jobId}/candidates`) return matches.map((match) => ({ @@ -205,50 +233,100 @@ export const api = { invitation: (jobId: number, invitationId: number) => request(`/jobs/${jobId}/invitations/${invitationId}`), invite: (jobId: number, participantId: number) => - request(`/jobs/${jobId}/invitations`, { - method: 'POST', - body: JSON.stringify({ participantId }), - }), + mutation( + request(`/jobs/${jobId}/invitations`, { + method: 'POST', + body: JSON.stringify({ participantId }), + }), + 'invitation.created', + (invitation) => [ + `jobs/${jobId}`, + `job-invitations/${jobId}`, + `assessment-invitations/${invitation.id}`, + ], + ), setInvitationOutcome: (jobId: number, invitationId: number, outcome: Invitation['outcome']) => - request(`/jobs/${jobId}/invitations/${invitationId}/outcome`, { - method: 'PATCH', - body: JSON.stringify({ outcome }), - }), + mutation( + request(`/jobs/${jobId}/invitations/${invitationId}/outcome`, { + method: 'PATCH', + body: JSON.stringify({ outcome }), + }), + 'invitation.outcome-updated', + () => [ + `jobs/${jobId}`, + `job-invitations/${jobId}`, + `assessment-invitations/${invitationId}`, + ], + ), interviews: (jobId: number) => request(`/jobs/${jobId}/interviews`), scheduleInterview: (jobId: number, input: CreateScheduledInterviewInput) => - request(`/jobs/${jobId}/interviews`, { - method: 'POST', - body: JSON.stringify(input), - }), + mutation( + request(`/jobs/${jobId}/interviews`, { + method: 'POST', + body: JSON.stringify(input), + }), + 'scheduled-interview.created', + (interview) => [ + `jobs/${jobId}`, + 'scheduled-interviews', + `scheduled-interviews/${interview.id}`, + 'invitations', + 'schedule', + ], + ), }, questions: { list: (search = '') => request(`/questions${search ? `?search=${encodeURIComponent(search)}` : ''}`), create: (input: CreateQuestionInput) => - request('/questions', { method: 'POST', body: JSON.stringify(input) }), + mutation( + request('/questions', { method: 'POST', body: JSON.stringify(input) }), + 'question.created', + (question) => ['questions', `questions/${question.id}`], + ), update: (id: number, input: CreateQuestionInput) => - request(`/questions/${id}`, { - method: 'PUT', - body: JSON.stringify(input), - }), + mutation( + request(`/questions/${id}`, { + method: 'PUT', + body: JSON.stringify(input), + }), + 'question.updated', + () => ['questions', `questions/${id}`], + ), setDeprecated: (id: number, deprecated: boolean) => - request(`/questions/${id}`, { - method: 'PATCH', - body: JSON.stringify({ deprecated }), - }), + mutation( + request(`/questions/${id}`, { + method: 'PATCH', + body: JSON.stringify({ deprecated }), + }), + 'question.deprecation-updated', + () => ['questions', `questions/${id}`], + ), }, interviews: { get: (token: string) => request(`/interviews/${token}`, {}, false), accept: (token: string) => - request(`/interviews/${token}/accept`, { method: 'POST' }, false), + mutation( + request(`/interviews/${token}/accept`, { method: 'POST' }, false), + 'assessment-invitation.accepted', + (interview) => [`interviews/${token}`, `assessment-invitations/${interview.invitation.id}`], + ), answer: (token: string, questionId: number, answer: string) => - request( - `/interviews/${token}/answers/${questionId}`, - { method: 'PUT', body: JSON.stringify({ answer }) }, - false, + mutation( + request( + `/interviews/${token}/answers/${questionId}`, + { method: 'PUT', body: JSON.stringify({ answer }) }, + false, + ), + 'assessment-answer.saved', + () => [`interviews/${token}`], ), finish: (token: string) => - request(`/interviews/${token}/finish`, { method: 'POST' }, false), + mutation( + request(`/interviews/${token}/finish`, { method: 'POST' }, false), + 'assessment-invitation.completed', + (interview) => [`interviews/${token}`, `assessment-invitations/${interview.invitation.id}`], + ), }, participants: { list: () => request('/participants'), @@ -264,41 +342,70 @@ export const api = { form.append('contactInfo', input.contactInfo) if (input.photo) form.append('photo', input.photo) if (input.cv) form.append('cv', input.cv) - return request('/participants', { method: 'POST', body: form }) + return mutation( + request('/participants', { method: 'POST', body: form }), + 'participant.created', + (participant) => ['participants', `participants/${participant.id}`], + ) }, }, users: { list: () => request('/users'), create: (input: { email: string; password: string; displayName: string }) => - request('/users', { method: 'POST', body: JSON.stringify(input) }), + mutation( + request('/users', { method: 'POST', body: JSON.stringify(input) }), + 'user.created', + (user) => ['users', `users/${user.id}`], + ), }, inbox: { - list: () => request('/inbox'), + list: () => request('/invitations'), respond: (interviewId: number, status: 'accepted' | 'declined') => - request(`/inbox/${interviewId}`, { - method: 'PATCH', - body: JSON.stringify({ status }), - }), + mutation( + request(`/invitations/${interviewId}`, { + method: 'PATCH', + body: JSON.stringify({ status }), + }), + 'interview-invitation.responded', + () => [ + 'invitations', + `invitations/${interviewId}`, + 'schedule', + `scheduled-interviews/${interviewId}`, + ], + ), }, - calendar: () => request('/calendar'), + calendar: () => request('/schedule'), scheduledInterviews: { get: (id: number) => request(`/scheduled-interviews/${id}`), setStatus: (id: number, status: string) => - request(`/scheduled-interviews/${id}/status`, { - method: 'PATCH', - body: JSON.stringify({ status }), - }), + mutation( + request(`/scheduled-interviews/${id}/status`, { + method: 'PATCH', + body: JSON.stringify({ status }), + }), + 'scheduled-interview.status-updated', + () => ['scheduled-interviews', `scheduled-interviews/${id}`, 'schedule', 'invitations'], + ), updateDocument: (id: number, sharedDocument: string) => - request(`/scheduled-interviews/${id}/document`, { - method: 'PATCH', - body: JSON.stringify({ sharedDocument }), - }), + mutation( + request(`/scheduled-interviews/${id}/document`, { + method: 'PATCH', + body: JSON.stringify({ sharedDocument }), + }), + 'scheduled-interview.document-updated', + () => [`scheduled-interviews/${id}`], + ), notes: (id: number) => request(`/scheduled-interviews/${id}/notes`), addNote: (id: number, body: string) => - request(`/scheduled-interviews/${id}/notes`, { - method: 'POST', - body: JSON.stringify({ body }), - }), + mutation( + request(`/scheduled-interviews/${id}/notes`, { + method: 'POST', + body: JSON.stringify({ body }), + }), + 'scheduled-interview.note-created', + () => [`scheduled-interviews/${id}/notes`], + ), subscribe: ( id: number, onEvent: (event: InterviewEvent) => void, diff --git a/frontend/src/api/domainEvents.test.ts b/frontend/src/api/domainEvents.test.ts new file mode 100644 index 0000000..e4a90ce --- /dev/null +++ b/frontend/src/api/domainEvents.test.ts @@ -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() + }) +}) diff --git a/frontend/src/api/domainEvents.ts b/frontend/src/api/domainEvents.ts new file mode 100644 index 0000000..42df106 --- /dev/null +++ b/frontend/src/api/domainEvents.ts @@ -0,0 +1,29 @@ +export interface DomainEvent { + type: string + resource: string + data: T +} + +type DomainEventListener = (event: DomainEvent) => void + +const target = new EventTarget() + +export function publishDomainEvent(resources: string[], type: string, data: T) { + for (const resource of resources) { + target.dispatchEvent( + new CustomEvent(resource, { + detail: { type, resource, data } satisfies DomainEvent, + }), + ) + } +} + +export function subscribeToDomainEvent( + resource: string, + listener: DomainEventListener, + signal?: AbortSignal, +) { + const handle = (event: Event) => listener((event as CustomEvent>).detail) + target.addEventListener(resource, handle, { signal }) + return () => target.removeEventListener(resource, handle) +} diff --git a/frontend/src/app/App.test.tsx b/frontend/src/app/App.test.tsx index a5e0622..a6498e6 100644 --- a/frontend/src/app/App.test.tsx +++ b/frontend/src/app/App.test.tsx @@ -224,7 +224,7 @@ describe('Go Guess frontend', () => { }), ) - renderApp('/calendar') + renderApp('/schedule') expect( await screen.findByRole('heading', { name: 'Sign in to your workspace' }), @@ -251,9 +251,9 @@ describe('Go Guess frontend', () => { expect(screen.getByText('admin@example.com')).toBeInTheDocument() const accountNavigation = screen.getByRole('navigation', { name: 'Account navigation' }) - expect(within(accountNavigation).getByRole('link', { name: 'Inbox' })).toHaveAttribute( + expect(within(accountNavigation).getByRole('link', { name: 'Invitations' })).toHaveAttribute( 'href', - '/inbox', + '/invitations', ) expect(within(accountNavigation).getByRole('link', { name: 'Admin' })).toHaveAttribute( 'href', @@ -261,7 +261,9 @@ describe('Go Guess frontend', () => { ) const primaryNavigation = screen.getByRole('navigation', { name: 'Primary navigation' }) - expect(within(primaryNavigation).queryByRole('link', { name: 'Inbox' })).not.toBeInTheDocument() + expect( + within(primaryNavigation).queryByRole('link', { name: 'Invitations' }), + ).not.toBeInTheDocument() const footerNavigation = screen.getByRole('navigation', { name: 'Footer navigation' }) expect(within(footerNavigation).getByRole('link', { name: 'Jobs' })).toHaveAttribute( @@ -407,11 +409,24 @@ describe('Go Guess frontend', () => { it('attaches and detaches library questions without deleting them', async () => { authenticate() const user = userEvent.setup() + let attached = false + const defaultFetch = vi.mocked(fetch).getMockImplementation() + vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => { + const path = String(input) + const method = options?.method ?? 'GET' + if (path === '/api/jobs/7/questions/11') { + attached = method !== 'DELETE' + return response({}, method === 'DELETE' ? 200 : 201) + } + if (path === '/api/jobs/7') return response({ ...job, questions: attached ? [question] : [] }) + if (!defaultFetch) throw new Error(`Unexpected request: ${path}`) + return defaultFetch(input, options) + }) renderApp('/jobs/7') await user.click(await screen.findByRole('tab', { name: /questions/i })) await user.click(await screen.findByRole('button', { name: 'Attach' })) - await user.click(screen.getByRole('button', { name: `Detach ${question.text}` })) + await user.click(await screen.findByRole('button', { name: `Detach ${question.text}` })) expect(fetch).toHaveBeenCalledWith( '/api/jobs/7/questions/11', @@ -838,7 +853,7 @@ describe('Go Guess frontend', () => { ) }) - it('accepts and declines interviews from the inbox', async () => { + it('updates an invitation card through its entity-scoped event', async () => { authenticate() const user = userEvent.setup() const scheduled = { @@ -853,33 +868,48 @@ describe('Go Guess frontend', () => { candidateToken: 'meeting-token', candidateUrl: '/participant/meeting/meeting-token', sharedDocument: 'Agenda', - attendees: [], + attendees: [ + { + userId: 1, + displayName: 'Admin', + email: 'admin@example.com', + status: 'pending', + }, + ], notes: [], } const defaultFetch = vi.mocked(fetch).getMockImplementation() vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => { const path = String(input) - if (path === '/api/inbox' && !options?.method) return response([scheduled]) - if (path === '/api/inbox/80') - return response({ ...scheduled, status: JSON.parse(String(options?.body)).status }) + if (path === '/api/invitations' && !options?.method) return response([scheduled]) + if (path === '/api/invitations/80') + return response({ + ...scheduled, + attendees: [ + { + ...scheduled.attendees[0], + status: JSON.parse(String(options?.body)).status, + }, + ], + }) if (!defaultFetch) throw new Error(`Unexpected request: ${path}`) return defaultFetch(input, options) }) - renderApp('/inbox') + renderApp('/invitations') await user.click(await screen.findByRole('button', { name: 'Accept' })) - await user.click(screen.getByRole('button', { name: 'Decline' })) expect(fetch).toHaveBeenCalledWith( - '/api/inbox/80', + '/api/invitations/80', expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ status: 'accepted' }) }), ) - expect(screen.getByText('declined')).toBeInTheDocument() + expect(await screen.findByRole('link', { name: 'Open session' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Accept' })).not.toBeInTheDocument() }) it('groups calendar interviews by date and links to the session', async () => { authenticate() const defaultFetch = vi.mocked(fetch).getMockImplementation() vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => { - if (String(input) === '/api/calendar') + if (String(input) === '/api/schedule') return response([ { id: 80, @@ -900,7 +930,7 @@ describe('Go Guess frontend', () => { if (!defaultFetch) throw new Error(`Unexpected request: ${String(input)}`) return defaultFetch(input, options) }) - renderApp('/calendar') + renderApp('/schedule') expect(await screen.findByText('Amélie Dubois')).toBeInTheDocument() expect(screen.getByRole('link', { name: 'Open session' })).toHaveAttribute( 'href', diff --git a/frontend/src/app/AuthenticatedApp.tsx b/frontend/src/app/AuthenticatedApp.tsx index a9b4874..52019b0 100644 --- a/frontend/src/app/AuthenticatedApp.tsx +++ b/frontend/src/app/AuthenticatedApp.tsx @@ -6,7 +6,7 @@ import { GoGuessHeader } from '../components/layout/GoGuessHeader' import { WorkspaceUtilities } from '../components/layout/WorkspaceUtilities' import { NotFound } from '../components/ui/NotFound' import { DashboardPage } from '../features/dashboard/DashboardPage' -import { InboxPage, ScheduledInterviewPage } from '../features/interviews' +import { InvitationPage, ScheduledInterviewPage } from '../features/interviews' import { CreateJobPage, JobDetailPage, JobsPage } from '../features/jobs' import { CreateParticipantPage, @@ -14,7 +14,7 @@ import { ParticipantsPage, } from '../features/participants' import { QuestionLibraryPage, QuestionPage } from '../features/questions' -import { CalendarPage, ProfilePage, UsersPage } from '../features/users' +import { SchedulePage, ProfilePage, UsersPage } from '../features/users' export function AuthenticatedApp({ onLogout }: { onLogout: () => void }) { const [open, setOpen] = useState(false) @@ -37,8 +37,8 @@ export function AuthenticatedApp({ onLogout }: { onLogout: () => void }) { } /> } /> } /> - } /> - } /> + } /> + } /> } /> } /> diff --git a/frontend/src/app/DataContext.tsx b/frontend/src/app/DataContext.tsx index fc108c1..59bb3db 100644 --- a/frontend/src/app/DataContext.tsx +++ b/frontend/src/app/DataContext.tsx @@ -3,6 +3,7 @@ import { api } from '../api/client' import { errorMessage } from '../components/actions' import type { CreateJobInput, CreateParticipantInput, Job, Participant } from '../types' import { DataContext } from './dataState' +import { useDomainEvent } from './useDomainEvent' export function DataProvider({ children }: { children: ReactNode }) { const [jobs, setJobs] = useState([]) @@ -10,8 +11,8 @@ export function DataProvider({ children }: { children: ReactNode }) { const [loading, setLoading] = useState(true) const [error, setError] = useState('') - async function reload() { - setLoading(true) + async function refresh(showLoading: boolean) { + if (showLoading) setLoading(true) setError('') try { const [jobItems, participantItems] = await Promise.all([ @@ -23,10 +24,14 @@ export function DataProvider({ children }: { children: ReactNode }) { } catch (requestError) { setError(errorMessage(requestError)) } finally { - setLoading(false) + if (showLoading) setLoading(false) } } + async function reload() { + await refresh(true) + } + useEffect(() => { let active = true void Promise.all([api.jobs.list(), api.participants.list()]) @@ -46,16 +51,28 @@ export function DataProvider({ children }: { children: ReactNode }) { } }, []) + useDomainEvent('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('participants', (event) => { + setParticipants((current) => [ + event.data, + ...current.filter((participant) => participant.id !== event.data.id), + ]) + }) + async function createJob(input: CreateJobInput) { - const created = await api.jobs.create(input) - setJobs((current) => [created, ...current]) - return created + return api.jobs.create(input) } async function createParticipant(input: CreateParticipantInput) { - const created = await api.participants.create(input) - setParticipants((current) => [created, ...current]) - return created + return api.participants.create(input) } return ( diff --git a/frontend/src/app/useDomainEvent.ts b/frontend/src/app/useDomainEvent.ts new file mode 100644 index 0000000..b6dbc11 --- /dev/null +++ b/frontend/src/app/useDomainEvent.ts @@ -0,0 +1,16 @@ +import { useEffect, useRef } from 'react' +import { type DomainEvent, subscribeToDomainEvent } from '../api/domainEvents' + +export function useDomainEvent(resource: string, listener: (event: DomainEvent) => void) { + const listenerRef = useRef(listener) + + useEffect(() => { + listenerRef.current = listener + }, [listener]) + + useEffect(() => { + const controller = new AbortController() + subscribeToDomainEvent(resource, (event) => listenerRef.current(event), controller.signal) + return () => controller.abort() + }, [resource]) +} diff --git a/frontend/src/components/actions/errorMessage.ts b/frontend/src/components/actions/errorMessage.ts index e2a6b9b..7107a0e 100644 --- a/frontend/src/components/actions/errorMessage.ts +++ b/frontend/src/components/actions/errorMessage.ts @@ -8,6 +8,6 @@ export function errorMessage(error: unknown) { export function message(error: unknown) { return error instanceof ApiError || error instanceof Error - ? error.message - : 'Something went wrong' -} \ No newline at end of file + ? error.message + : 'Something went wrong' +} diff --git a/frontend/src/components/layout/WorkspaceUtilities.tsx b/frontend/src/components/layout/WorkspaceUtilities.tsx index 109b174..c627174 100644 --- a/frontend/src/components/layout/WorkspaceUtilities.tsx +++ b/frontend/src/components/layout/WorkspaceUtilities.tsx @@ -6,13 +6,13 @@ export function WorkspaceUtilities({ user }: { user: User | null }) { const profileLabel = user?.displayName || user?.email || 'Profile' return (