diff --git a/packages/server/src/service.ts b/packages/server/src/service.ts index 8bd0a20..9ba683b 100644 --- a/packages/server/src/service.ts +++ b/packages/server/src/service.ts @@ -6,7 +6,8 @@ // a running simulation stops, what it left written back), end a person's // grant to a project or all their sessions (whoever is connected, to the // document or the room, is let go), and list who is signed in to what (the -// dashboard checks it against its shares). FILIUS files (F6.1, +// dashboard checks it against its shares, and ends a teacher's share for one +// session when no tab of theirs has the project open). FILIUS files (F6.1, // transfer.ts): make a project from one of FILIUS' examples or from a .fls // the dashboard sends, copy a project (as it is now, also while people work // in it), and give a project as a .fls (with what it could not hold, as @@ -97,6 +98,23 @@ function uuid(value: unknown, code: string): string { const contextOf = (connection: { context: unknown }) => connection.context as ConnectionContext; +/** + * Who has which project open right now — connected to its document, not only + * granted: a grant lasts the session's school day, a connection ends with the + * tab. The dashboard ends a teacher's write share for one session by it. + * Once per person and project, sorted. + */ +function openDocuments(sync: Sync): { readonly user: string; readonly project: string }[] { + const seen = new Map(); + for (const [project, document] of sync.hocuspocus.documents) { + for (const connection of document.getConnections()) { + const user = contextOf(connection)?.person?.user; + if (typeof user === 'string') seen.set(`${user} ${project}`, { user, project }); + } + } + return [...seen.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([, open]) => open); +} + export async function handleService(request: IncomingMessage, response: ServerResponse, path: string, options: ServiceOptions): Promise { const send = (status: number, value: object) => { response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); @@ -234,6 +252,6 @@ async function route(request: IncomingMessage, path: string, { store, sessions, log.info('svc.revoked', { user, ...(only === undefined ? {} : { project: only }), revoked, disconnected }); return [200, { revoked, disconnected }]; } - if (request.method === 'GET' && path === '/svc/sessions') return [200, await sessions.active()]; + if (request.method === 'GET' && path === '/svc/sessions') return [200, { ...(await sessions.active()), open: openDocuments(sync) }]; throw new HttpError(404, 'notFound'); } diff --git a/packages/server/test/access.test.ts b/packages/server/test/access.test.ts index a0c5319..6e31d45 100644 --- a/packages/server/test/access.test.ts +++ b/packages/server/test/access.test.ts @@ -412,7 +412,25 @@ describe("the dashboard's service", () => { { user: mia, project, level: 'write' }, { user: ben, project: other, level: 'read' }, ].sort((x, y) => (x.user + x.project).localeCompare(y.user + y.project)); - expect(await answer(await service(server, 'GET', '/svc/sessions'))).toEqual([200, { users: [mia, ben].sort(), grants }]); + expect(await answer(await service(server, 'GET', '/svc/sessions'))).toEqual([200, { users: [mia, ben].sort(), grants, open: [] }]); + await server.close(); + }); + + it('lists who has which project open: connected, once per person and project; a closed tab is gone', async () => { + await sql.query('DELETE FROM filius_session'); + const server = await start(); + const [project, other] = [await newProject(), await newProject()]; + const [mia, ben] = [randomUUID(), randomUUID()].sort() as [string, string]; + const miaCookie = await signIn(server, project, { user: mia }); + const tab = await join(server, project, { cookie: miaCookie }); + const second = await join(server, project, { cookie: miaCookie }); + await signIn(server, other, { user: ben }); + const open = async () => ((await (await service(server, 'GET', '/svc/sessions')).json()) as { open: unknown }).open; + expect(await open()).toEqual([{ user: mia, project }]); + tab.close(); + expect(await open()).toEqual([{ user: mia, project }]); + second.close(); + await until(async () => ((await open()) as unknown[]).length === 0); await server.close(); });