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:
Daniel Noulet 2026-09-19 22:22:42 +02:00
parent e4fee06e2f
commit c050815900
16 changed files with 438 additions and 101 deletions

View file

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

View file

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

View file

@ -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">

View file

@ -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