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.
This commit is contained in:
Juan Castro committed 2026-10-06 15:55:22 -04:00
1 parent 71399c5939
commit f3b05ca822
3 files changed
+88 -12

No files matched your search

+12 -3
View File
@@ -111,16 +111,25 @@ export class TeamController extends PuterController {
async listTeams(req: Request, res: Response): Promise<void> {
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 }),
});
}
+34 -3
View File
@@ -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',
+42 -6
View File
@@ -610,14 +610,50 @@ export class TeamStore extends PuterStore {
}
/** Teams this user belongs to, oldest first. */
async listTeamsForUser(userId: number): Promise<TeamRow[]> {
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<PageResult<TeamRow>> {
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. */