feat: sync interview content in real time
Publish shared-document and note events through RabbitMQ, stream token-scoped document updates to participants, and update connected interviewer and participant browsers through resilient SSE subscriptions. Add snapshots, heartbeats, reconnection behavior, proxy streaming configuration, privacy filtering, and end-to-end RabbitMQ coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
e4fee06e2f
commit
c050815900
16 changed files with 438 additions and 101 deletions
|
|
@ -71,6 +71,7 @@ Supported routes:
|
|||
- `GET|POST /api/scheduled-interviews/:id/notes`
|
||||
- `GET /api/scheduled-interviews/:id/events` (authenticated fetch-based SSE)
|
||||
- `GET /api/participant-meetings/:token` (public)
|
||||
- `GET /api/participant-meetings/:token/events` (token-authorized SSE)
|
||||
|
||||
Participant creation uses multipart fields `firstName`, `lastName`, `birthday`, `email`,
|
||||
`contactInfo`, `photo`, and `cv`.
|
||||
|
|
@ -96,9 +97,9 @@ The authenticated workspace also includes:
|
|||
- **Inbox** for accepting or declining interview assignments.
|
||||
- **Calendar** for interviews grouped by date.
|
||||
- **Interviewer sessions** for changing status, editing prominent shared documentation, and
|
||||
posting private notes while an interview is started. Notes arrive live through an authenticated
|
||||
streaming `fetch`; the app intentionally does not use `EventSource`, because the request needs
|
||||
the JWT authorization header.
|
||||
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
|
||||
`EventSource`, because the request needs the JWT authorization header.
|
||||
|
||||
Participants open the generated `/participant/:invitationId` URL without signing in. The opaque
|
||||
invitation ID authorizes the existing `/api/interviews/:token` API. Code-review questions are shown
|
||||
|
|
@ -108,8 +109,9 @@ derived from the backend `acceptedAt` timestamp plus the job duration so refresh
|
|||
it. Questions stay hidden until the participant explicitly accepts the invitation.
|
||||
|
||||
Scheduled candidates use `/participant/meeting/:token`. This public view shows the interview date,
|
||||
location, status, and read-only shared documentation. It never renders interviewer notes or
|
||||
assessment reference answers.
|
||||
location, status, and read-only shared documentation. A token-authorized event stream updates the
|
||||
document immediately when an interviewer saves it in another browser. The participant view never
|
||||
receives interviewer notes or assessment reference answers.
|
||||
|
||||
## Container
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ server {
|
|||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 1h;
|
||||
}
|
||||
|
||||
location / {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
CreateQuestionInput,
|
||||
CreateScheduledInterviewInput,
|
||||
Interview,
|
||||
InterviewEvent,
|
||||
Invitation,
|
||||
Job,
|
||||
JobStatus,
|
||||
|
|
@ -97,6 +98,56 @@ async function requestBlob(path: string): Promise<Blob> {
|
|||
return response.blob()
|
||||
}
|
||||
|
||||
async function subscribeToInterviewEvents(
|
||||
path: string,
|
||||
onEvent: (event: InterviewEvent) => void,
|
||||
signal: AbortSignal,
|
||||
protectedRequest: boolean,
|
||||
onConnectionError?: (error: unknown) => void,
|
||||
) {
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
const token = authStorage.token()
|
||||
const response = await fetch(`/api${path}`, {
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
...(protectedRequest && token ? { Authorization: 'Bearer ' + token } : {}),
|
||||
},
|
||||
signal,
|
||||
})
|
||||
if (!response.ok || !response.body)
|
||||
throw new ApiError(`Live updates failed with status ${response.status}`, response.status)
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
while (!signal.aborted) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n')
|
||||
let boundary = buffer.indexOf('\n\n')
|
||||
while (boundary >= 0) {
|
||||
const message = buffer.slice(0, boundary)
|
||||
buffer = buffer.slice(boundary + 2)
|
||||
const data = message
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.slice(5).trimStart())
|
||||
.join('\n')
|
||||
if (data) onEvent(JSON.parse(data) as InterviewEvent)
|
||||
boundary = buffer.indexOf('\n\n')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal.aborted) return
|
||||
if (error instanceof ApiError && error.status >= 400 && error.status < 500) throw error
|
||||
onConnectionError?.(error)
|
||||
}
|
||||
if (!signal.aborted) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 1000))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
login: (email: string, password: string) =>
|
||||
request<AuthResponse>(
|
||||
|
|
@ -226,44 +277,35 @@ export const api = {
|
|||
method: 'POST',
|
||||
body: JSON.stringify({ body }),
|
||||
}),
|
||||
subscribe: async (id: number, onNote: (note: InterviewNote) => void, signal: AbortSignal) => {
|
||||
const token = authStorage.token()
|
||||
const response = await fetch(`/api/scheduled-interviews/${id}/events`, {
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
...(token ? { Authorization: 'Bearer ' + token } : {}),
|
||||
},
|
||||
subscribe: (
|
||||
id: number,
|
||||
onEvent: (event: InterviewEvent) => void,
|
||||
signal: AbortSignal,
|
||||
onConnectionError?: (error: unknown) => void,
|
||||
) =>
|
||||
subscribeToInterviewEvents(
|
||||
`/scheduled-interviews/${id}/events`,
|
||||
onEvent,
|
||||
signal,
|
||||
})
|
||||
if (!response.ok || !response.body)
|
||||
throw new ApiError(`Live updates failed with status ${response.status}`, response.status)
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
while (!signal.aborted) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n')
|
||||
let boundary = buffer.indexOf('\n\n')
|
||||
while (boundary >= 0) {
|
||||
const message = buffer.slice(0, boundary)
|
||||
buffer = buffer.slice(boundary + 2)
|
||||
const data = message
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.slice(5).trimStart())
|
||||
.join('\n')
|
||||
if (data) {
|
||||
const event = JSON.parse(data) as InterviewNote | { note: InterviewNote }
|
||||
onNote('note' in event ? event.note : event)
|
||||
}
|
||||
boundary = buffer.indexOf('\n\n')
|
||||
}
|
||||
}
|
||||
},
|
||||
true,
|
||||
onConnectionError,
|
||||
),
|
||||
},
|
||||
participantMeetings: {
|
||||
get: (token: string) =>
|
||||
request<ParticipantMeeting>(`/participant-meetings/${token}`, {}, false),
|
||||
subscribe: (
|
||||
token: string,
|
||||
onEvent: (event: InterviewEvent) => void,
|
||||
signal: AbortSignal,
|
||||
onConnectionError?: (error: unknown) => void,
|
||||
) =>
|
||||
subscribeToInterviewEvents(
|
||||
`/participant-meetings/${token}/events`,
|
||||
onEvent,
|
||||
signal,
|
||||
false,
|
||||
onConnectionError,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -853,7 +853,17 @@ describe('Go Guess frontend', () => {
|
|||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(`event: note\ndata: ${JSON.stringify(liveNote)}\n\n`),
|
||||
new TextEncoder().encode(
|
||||
`event: interview-event\ndata: ${JSON.stringify({
|
||||
type: 'note.created',
|
||||
interviewId: 80,
|
||||
note: liveNote,
|
||||
})}\n\nevent: interview-event\ndata: ${JSON.stringify({
|
||||
type: 'document.updated',
|
||||
interviewId: 80,
|
||||
sharedDocument: 'Remote agenda',
|
||||
})}\n\n`,
|
||||
),
|
||||
)
|
||||
controller.close()
|
||||
},
|
||||
|
|
@ -867,6 +877,7 @@ describe('Go Guess frontend', () => {
|
|||
})
|
||||
renderApp('/scheduled-interviews/80')
|
||||
expect(await screen.findByText('Strong system design answer.')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Shared documentation')).toHaveValue('Remote agenda')
|
||||
await user.click(screen.getByRole('button', { name: 'Copy participant interview link' }))
|
||||
expect(writeText).toHaveBeenCalledWith('/participant/meeting/meeting-token')
|
||||
expect(
|
||||
|
|
@ -903,11 +914,30 @@ describe('Go Guess frontend', () => {
|
|||
status: 'scheduled',
|
||||
sharedDocument: 'Please prepare the architecture exercise.',
|
||||
})
|
||||
if (String(input) === '/api/participant-meetings/meeting-token/events') {
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(
|
||||
`event: interview-event\ndata: ${JSON.stringify({
|
||||
type: 'document.updated',
|
||||
interviewId: 80,
|
||||
sharedDocument: 'The architecture exercise changed live.',
|
||||
})}\n\n`,
|
||||
),
|
||||
)
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
return Promise.resolve(
|
||||
new Response(stream, { status: 200, headers: { 'Content-Type': 'text/event-stream' } }),
|
||||
)
|
||||
}
|
||||
throw new Error(`Unexpected request: ${String(input)}`)
|
||||
})
|
||||
renderApp('/participant/meeting/meeting-token')
|
||||
expect(await screen.findByRole('heading', { name: job.title })).toBeInTheDocument()
|
||||
expect(screen.getByText('Please prepare the architecture exercise.')).toBeInTheDocument()
|
||||
expect(await screen.findByText('The architecture exercise changed live.')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/live notes/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(question.referenceAnswer)).not.toBeInTheDocument()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ export function ScheduledInterviewPage() {
|
|||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [linkCopied, setLinkCopied] = useState(false)
|
||||
const loadedInterviewID = interview?.id
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
|
@ -163,23 +164,34 @@ export function ScheduledInterviewPage() {
|
|||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
if (interview?.status !== 'started') return
|
||||
if (!loadedInterviewID) return
|
||||
const controller = new AbortController()
|
||||
void api.scheduledInterviews
|
||||
.subscribe(
|
||||
id,
|
||||
(note) => {
|
||||
setNotes((current) =>
|
||||
current.some((item) => item.id === note.id) ? current : [...current, note],
|
||||
)
|
||||
(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, interview?.status])
|
||||
}, [id, loadedInterviewID])
|
||||
|
||||
async function setStatus(status: string) {
|
||||
setError('')
|
||||
|
|
@ -363,10 +375,44 @@ export function ParticipantMeetingPage() {
|
|||
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(setMeeting)
|
||||
.catch((value) => setError(message(value)))
|
||||
.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">
|
||||
|
|
|
|||
|
|
@ -90,6 +90,18 @@ export interface InterviewNote {
|
|||
createdAt: string
|
||||
}
|
||||
|
||||
export type InterviewEvent =
|
||||
| {
|
||||
type: 'note.created'
|
||||
interviewId: number
|
||||
note: InterviewNote
|
||||
}
|
||||
| {
|
||||
type: 'document.updated'
|
||||
interviewId: number
|
||||
sharedDocument: string
|
||||
}
|
||||
|
||||
export interface ScheduledInterview {
|
||||
id: number
|
||||
jobId: number
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue