From f3b05ca822b9b78318124d3e549bf32fd8a1b6d7 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Tue, 6 Oct 2026 15:55:22 -0400 Subject: [PATCH] fix: page GET /teams like every other team listing `listTeamsForUser` returned every row and the controller never read `req.query`, so `limit`, `cursor` and `includeTotal` were accepted and ignored on the one team route that did not paginate. The docs promise a capped page there, and every sibling listing already honours it. Keyset-paginated on `id` per doc/pagination.md, with the envelope the other routes return. The app-actor directory filter still runs over the page, so an app sees the same teams it saw before, just a page at a time. --- .../controllers/team/TeamController.ts | 15 ++++-- src/backend/stores/team/TeamStore.test.ts | 37 ++++++++++++-- src/backend/stores/team/TeamStore.ts | 48 ++++++++++++++++--- 3 files changed, 88 insertions(+), 12 deletions(-) diff --git a/src/backend/controllers/team/TeamController.ts b/src/backend/controllers/team/TeamController.ts index 0f176efc8..dd208e218 100644 --- a/src/backend/controllers/team/TeamController.ts +++ b/src/backend/controllers/team/TeamController.ts @@ -111,16 +111,25 @@ export class TeamController extends PuterController { async listTeams(req: Request, res: Response): Promise { const userId = this.#requireUserId(req); await this.#requireTeamsAvailable(req, userId); - const teams = await this.stores.team.listTeamsForUser(userId); + const page = await this.stores.team.listTeamsForUser(userId, { + limit: req.query.limit, + cursor: + typeof req.query.cursor === 'string' + ? req.query.cursor + : undefined, + includeTotal: req.query.includeTotal === 'true', + }); // An app only sees a team whose owner opened the directory to apps; // a team that hasn't is indistinguishable from no team at all. const visible = isAccountContext(req.actor) - ? teams - : teams.filter((t) => this.services.team.isDirectoryOpen(t)); + ? page.items + : page.items.filter((t) => this.services.team.isDirectoryOpen(t)); res.json({ items: visible.map((t) => toClientTeam(t, t.owner_user_id === userId), ), + ...(page.cursor ? { cursor: page.cursor } : {}), + ...(page.total === undefined ? {} : { total: page.total }), }); } diff --git a/src/backend/stores/team/TeamStore.test.ts b/src/backend/stores/team/TeamStore.test.ts index cf35beaab..e1bfdc04f 100644 --- a/src/backend/stores/team/TeamStore.test.ts +++ b/src/backend/stores/team/TeamStore.test.ts @@ -467,8 +467,8 @@ describe('TeamStore', () => { await store.addMember(a.uid, member.id, { orgOwned: true }); await store.addMember(b.uid, member.id, { orgOwned: true }); - const teams = await store.listTeamsForUser(member.id); - expect(teams.map((t) => t.uid).sort()).toEqual([a.uid, b.uid].sort()); + const { items } = await store.listTeamsForUser(member.id); + expect(items.map((t) => t.uid).sort()).toEqual([a.uid, b.uid].sort()); }); it('drops a soft-deleted team from the user\'s list', async () => { @@ -481,10 +481,41 @@ describe('TeamStore', () => { await store.addMember(team.uid, member.id, { orgOwned: true }); await store.softDelete(team.uid); - expect(await store.listTeamsForUser(member.id)).toEqual([]); + expect((await store.listTeamsForUser(member.id)).items).toEqual([]); await expect(store.isMember(team.uid, member.id)).resolves.toBe(false); }); + it('pages the user team list and counts on request', async () => { + const member = await makeUser(); + const made = []; + for (let i = 0; i < 3; i++) { + const team = await store.create({ + ownerUserId: owner.id, + name: `Paged ${i}`, + handle: freeHandle(), + }); + await store.addMember(team.uid, member.id, { orgOwned: true }); + made.push(team.uid); + } + + const first = await store.listTeamsForUser(member.id, { + limit: 2, + includeTotal: true, + }); + expect(first.items).toHaveLength(2); + expect(first.total).toBe(3); + expect(first.cursor).toBeTruthy(); + + const second = await store.listTeamsForUser(member.id, { + cursor: first.cursor, + }); + expect(second.items).toHaveLength(1); + expect(second.cursor).toBeUndefined(); + expect( + [...first.items, ...second.items].map((t) => t.uid).sort(), + ).toEqual([...made].sort()); + }); + it('refuses to add a member to a group that is not a team', async () => { const [seeded] = (await server.clients.db.read( 'SELECT `uid` FROM `group` WHERE `kind` IS NULL LIMIT 1', diff --git a/src/backend/stores/team/TeamStore.ts b/src/backend/stores/team/TeamStore.ts index a85b5ae1d..84878ded7 100644 --- a/src/backend/stores/team/TeamStore.ts +++ b/src/backend/stores/team/TeamStore.ts @@ -610,14 +610,50 @@ export class TeamStore extends PuterStore { } /** Teams this user belongs to, oldest first. */ - async listTeamsForUser(userId: number): Promise { - const rows = await this.clients.db.read( + /** A user's teams, keyset-paginated on `id` per doc/pagination.md. */ + async listTeamsForUser( + userId: number, + opts: { limit?: unknown; cursor?: string; includeTotal?: boolean } = {}, + ): Promise> { + const limit = + normalizeLimit(opts.limit, { cap: MEMBER_PAGE_CAP }) ?? + MEMBER_PAGE_SIZE; + const page = decodeCursor(opts.cursor, 'team cursor'); + const after = typeof page?.id === 'number' ? page.id : null; + + const rows = (await this.clients.db.read( 'SELECT g.* FROM `group` g ' + 'JOIN `jct_user_group` ug ON ug.`group_id` = g.`id` ' + - `WHERE ug.\`user_id\` = ? AND g.${this.#live()} ORDER BY g.\`id\``, - [userId, TEAM_KIND], - ); - return rows as unknown as TeamRow[]; + `WHERE ug.\`user_id\` = ? AND g.${this.#live()}` + + (after === null ? '' : ' AND g.`id` > ?') + + ' ORDER BY g.`id` LIMIT ?', + after === null + ? [userId, TEAM_KIND, limit + 1] + : [userId, TEAM_KIND, after, limit + 1], + )) as unknown as TeamRow[]; + + const items = rows.slice(0, limit); + const cursor = + rows.length > limit + ? encodeCursor({ id: items[items.length - 1].id }) + : undefined; + + let total: number | undefined; + if (opts.includeTotal) { + const totals = (await this.clients.db.read( + 'SELECT COUNT(*) AS n FROM `group` g ' + + 'JOIN `jct_user_group` ug ON ug.`group_id` = g.`id` ' + + `WHERE ug.\`user_id\` = ? AND g.${this.#live()}`, + [userId, TEAM_KIND], + )) as unknown as Array<{ n: number | string }>; + total = Number(totals[0]?.n ?? 0); + } + + return { + items, + ...(cursor ? { cursor } : {}), + ...(total === undefined ? {} : { total }), + }; } /** `orgOwned` decides who pays: 1 team-created, 0 the team owner. */