diff --git a/apps/api/app/routes/session.ts b/apps/api/app/routes/session.ts index c5fbc98e..220316e8 100644 --- a/apps/api/app/routes/session.ts +++ b/apps/api/app/routes/session.ts @@ -92,6 +92,27 @@ export namespace SessionApi { .strict(); export const route = new Hono() + .get( + '/', + notPublic, + describeRoute({ + tags: ['Session'], + summary: 'Your runs that have not stopped', + description: + 'At most one per box, newest first. Read this when asking for a run is refused because a box is busy: the run holding it may be the same game, which a client can rejoin by polling it, or another one it can offer to stop.', + responses: { + 200: { + content: { 'application/json': { schema: Result(Session.Info.array()) } }, + description: 'The runs still going, or still asked for' + }, + 401: ErrorResponses[401], + 403: ErrorResponses[403] + } + }), + async (c) => { + return c.json({ data: await Session.activeForOwner(actingPerson()) }); + } + ) .post( '/', notPublic, diff --git a/packages/core/src/session/index.ts b/packages/core/src/session/index.ts index 3c2ff697..42d6792b 100644 --- a/packages/core/src/session/index.ts +++ b/packages/core/src/session/index.ts @@ -513,6 +513,33 @@ export namespace Session { }); }); + /** + * The runs of someone's boxes that have not stopped, newest first. + * + * At most one per box, by the same index {@link activeForBox} leans on. A + * client reads this to tell "your box is busy" apart from "your box is + * busy with the game you just asked for", which it can rejoin rather than + * stop. + */ + export const activeForOwner = fn(z.string(), async (userId) => { + return Database.use(async (tx) => { + return tx + .select({ session: SessionTable }) + .from(SessionTable) + .innerJoin(BoxTable, eq(SessionTable.boxId, BoxTable.id)) + .where( + and( + eq(BoxTable.userId, userId), + isNull(SessionTable.timeDeleted), + isNull(SessionTable.timeStopped), + isNull(BoxTable.timeDeleted) + ) + ) + .orderBy(desc(SessionTable.timeCreated)) + .then((rows) => rows.map((row) => serialize(row.session))); + }); + }); + /** * The owner asks for a run to end. * diff --git a/packages/core/src/session/session.test.ts b/packages/core/src/session/session.test.ts index 38482565..46fcdcf4 100644 --- a/packages/core/src/session/session.test.ts +++ b/packages/core/src/session/session.test.ts @@ -186,6 +186,33 @@ describe('Session', () => { expect((await Session.activeForBox(box.id))?.id).toBe(second.id); }); + test('an owner sees their own runs that have not stopped, and nobody else’s', async () => { + const mine = await scene('ses-owner-a', 5406); + const theirs = await scene('ses-owner-b', 5407); + const ended = await Session.create({ + id: Identifier.ascending('session'), + boxId: mine.box.id, + gameId: mine.gameId, + linkedAccountId: mine.owner.linkedAccountId + }); + await Session.setState({ id: ended.id, state: 'ended', errorMessage: null }); + const going = await Session.create({ + id: Identifier.ascending('session'), + boxId: mine.box.id, + gameId: mine.gameId, + linkedAccountId: mine.owner.linkedAccountId + }); + await Session.create({ + id: Identifier.ascending('session'), + boxId: theirs.box.id, + gameId: theirs.gameId, + linkedAccountId: theirs.owner.linkedAccountId + }); + + const active = await Session.activeForOwner(mine.owner.userId); + expect(active.map((s) => s.id)).toEqual([going.id]); + }); + test('deleting a box takes its sessions with it', async () => { const { owner, box, gameId } = await scene('ses-cascade', 5405); await Session.create({