diff --git a/src/backend/stores/group/GroupStore.test.ts b/src/backend/stores/group/GroupStore.test.ts index e6b1e6ce4..ec57cb9b6 100644 --- a/src/backend/stores/group/GroupStore.test.ts +++ b/src/backend/stores/group/GroupStore.test.ts @@ -158,4 +158,31 @@ describe('GroupStore', () => { expect(await memberUsernames(a)).toEqual([]); expect(await memberUsernames(b)).toEqual([member.username]); }); + // -- the boundary with TeamStore ---------------------------------- + + it('refuses to add a member to a team, leaving that to TeamStore', async () => { + const uid = uuidv4(); + await server.clients.db.write( + 'INSERT INTO `group` (`uid`, `owner_user_id`, `kind`, `name`, `extra`, `metadata`) ' + + 'VALUES (?, ?, ?, ?, ?, ?)', + [uid, owner.id, 'team', 'Not Yours', '{}', '{}'], + ); + + // Two writers produced the duplicates; the split is enforced in SQL. + await store.addUsers(uid, [member.username]); + expect(await memberUsernames(uid)).toEqual([]); + }); + + it('refuses to remove a member from a team', async () => { + const uid = uuidv4(); + await server.clients.db.write( + 'INSERT INTO `group` (`uid`, `owner_user_id`, `kind`, `name`, `extra`, `metadata`) ' + + 'VALUES (?, ?, ?, ?, ?, ?)', + [uid, owner.id, 'team', 'Also Not Yours', '{}', '{}'], + ); + await server.stores.team.addMember(uid, member.id, { orgOwned: true }); + + await store.removeUsers(uid, [member.username]); + expect(await memberUsernames(uid)).toEqual([member.username]); + }); }); diff --git a/src/backend/stores/group/GroupStore.ts b/src/backend/stores/group/GroupStore.ts index 5b8c9426c..5cf030beb 100644 --- a/src/backend/stores/group/GroupStore.ts +++ b/src/backend/stores/group/GroupStore.ts @@ -33,36 +33,29 @@ import { PuterStore } from '../types'; * which joins the junction table itself. */ export class GroupStore extends PuterStore { - /** - * Adds users (by username) to the group identified by `uid`. No-op if - * `usernames` is empty, and for a user who is already a member. - */ + /** Adds users to a seeded system group. No-op for a team or a member. */ async addUsers(uid: string, usernames: string[]): Promise { if (usernames.length === 0) return; const placeholders = `(${usernames.map(() => '?').join(', ')})`; - // Ignore conflicts on the unique pair index from 0072; re-adding a member - // was a duplicate row before it, and would raise without this. + // Ignore conflicts on the unique pair index from 0072. await this.clients.db.write( `${this.clients.db.insertIgnoreInto('jct_user_group')} ` + '(`user_id`, `group_id`) ' + 'SELECT u.id, g.id FROM `user` u ' + - 'JOIN (SELECT id FROM `group` WHERE uid = ?) g ON 1 = 1 ' + + 'JOIN (SELECT id FROM `group` WHERE uid = ? AND kind IS NULL) g ON 1 = 1 ' + `WHERE u.username IN ${placeholders}` + this.clients.db.insertIgnoreSuffix(), [uid, ...usernames], ); } - /** - * Removes users (by username) from the group identified by `uid`. No-op if - * `usernames` is empty. - */ + /** Removes users from a seeded system group. No-op for a team. */ async removeUsers(uid: string, usernames: string[]): Promise { if (usernames.length === 0) return; const placeholders = `(${usernames.map(() => '?').join(', ')})`; await this.clients.db.write( 'DELETE FROM `jct_user_group` ' + - 'WHERE `group_id` = (SELECT id FROM `group` WHERE uid = ?) ' + + 'WHERE `group_id` = (SELECT id FROM `group` WHERE uid = ? AND kind IS NULL) ' + 'AND `user_id` IN (' + 'SELECT u.id FROM `user` u ' + `WHERE u.username IN ${placeholders})`, diff --git a/src/backend/stores/team/TeamStore.test.ts b/src/backend/stores/team/TeamStore.test.ts index 7b7740bb6..0b7515b68 100644 --- a/src/backend/stores/team/TeamStore.test.ts +++ b/src/backend/stores/team/TeamStore.test.ts @@ -292,4 +292,178 @@ describe('TeamStore', () => { }); expect(team.name).toBe('Padded'); }); + + // -- membership --------------------------------------------------- + + it('adds a member and reads the membership back', async () => { + const team = await store.create({ + ownerUserId: owner.id, + name: 'Members', + handle: freeHandle(), + }); + const member = await makeUser(); + + await expect( + store.addMember(team.uid, member.id, { orgOwned: true }), + ).resolves.toBe(true); + + const row = await store.getMembership(team.uid, member.id); + expect(row).toMatchObject({ user_id: member.id, group_id: team.id }); + expect(Boolean(row?.org_owned)).toBe(true); + await expect(store.isMember(team.uid, member.id)).resolves.toBe(true); + }); + + it('distinguishes the workspace owner by org_owned', async () => { + const team = await store.create({ + ownerUserId: owner.id, + name: 'Payer', + handle: freeHandle(), + }); + await store.addMember(team.uid, owner.id, { orgOwned: false }); + + const row = await store.getMembership(team.uid, owner.id); + // Decides who pays, not who may read. + expect(Boolean(row?.org_owned)).toBe(false); + }); + + it('treats a repeated add as a no-op rather than a duplicate', async () => { + const team = await store.create({ + ownerUserId: owner.id, + name: 'Once', + handle: freeHandle(), + }); + const member = await makeUser(); + + await store.addMember(team.uid, member.id, { orgOwned: true }); + await store.addMember(team.uid, member.id, { orgOwned: true }); + + const page = await store.listMembers(team.uid); + expect(page.items.filter((m) => m.user_id === member.id)).toHaveLength(1); + }); + + it('removes a member and reports whether there was one', async () => { + const team = await store.create({ + ownerUserId: owner.id, + name: 'Leaving', + handle: freeHandle(), + }); + const member = await makeUser(); + await store.addMember(team.uid, member.id, { orgOwned: true }); + + await expect(store.removeMember(team.uid, member.id)).resolves.toBe(true); + await expect(store.removeMember(team.uid, member.id)).resolves.toBe(false); + await expect(store.isMember(team.uid, member.id)).resolves.toBe(false); + }); + + it('scopes membership to the workspace asked for', async () => { + const a = await store.create({ + ownerUserId: owner.id, + name: 'A', + handle: freeHandle(), + }); + const b = await store.create({ + ownerUserId: owner.id, + name: 'B', + handle: freeHandle(), + }); + const member = await makeUser(); + await store.addMember(a.uid, member.id, { orgOwned: true }); + + await expect(store.isMember(b.uid, member.id)).resolves.toBe(false); + expect((await store.listMembers(b.uid)).items).toEqual([]); + }); + + it('lists the workspaces a user belongs to', async () => { + const member = await makeUser(); + const a = await store.create({ + ownerUserId: owner.id, + name: 'One', + handle: freeHandle(), + }); + const b = await store.create({ + ownerUserId: owner.id, + name: 'Two', + handle: freeHandle(), + }); + 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()); + }); + + it('drops a soft-deleted workspace from the user\'s list', async () => { + const member = await makeUser(); + const team = await store.create({ + ownerUserId: owner.id, + name: 'Gone', + handle: freeHandle(), + }); + await store.addMember(team.uid, member.id, { orgOwned: true }); + await store.softDelete(team.uid); + + expect(await store.listTeamsForUser(member.id)).toEqual([]); + await expect(store.isMember(team.uid, member.id)).resolves.toBe(false); + }); + + 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', + )) as { uid: string }[]; + const member = await makeUser(); + + await expect( + store.addMember(seeded.uid, member.id, { orgOwned: true }), + ).resolves.toBe(false); + }); + + // -- pagination --------------------------------------------------- + + it('pages members on `id` and stops when the set is exhausted', async () => { + const team = await store.create({ + ownerUserId: owner.id, + name: 'Paged', + handle: freeHandle(), + }); + const ids: number[] = []; + for (let i = 0; i < 5; i++) { + const m = await makeUser(); + ids.push(m.id); + await store.addMember(team.uid, m.id, { orgOwned: true }); + } + + const seen: number[] = []; + let cursor: string | undefined; + let pages = 0; + do { + const page = await store.listMembers(team.uid, { limit: 2, cursor }); + seen.push(...page.items.map((m) => m.user_id)); + cursor = page.cursor; + pages++; + } while (cursor && pages < 10); + + expect(seen.sort()).toEqual([...ids].sort()); + expect(pages).toBe(3); + }); + + it('caps the page size rather than trusting the caller', async () => { + const team = await store.create({ + ownerUserId: owner.id, + name: 'Capped', + handle: freeHandle(), + }); + // An empty workspace satisfies any cap, so seed enough to page. + for (let i = 0; i < 3; i++) { + const m = await makeUser(); + await store.addMember(team.uid, m.id, { orgOwned: true }); + } + + const capped = await store.listMembers(team.uid, { limit: 100_000 }); + expect(capped.items).toHaveLength(3); + expect(capped.cursor).toBeUndefined(); + + const paged = await store.listMembers(team.uid, { limit: 2 }); + expect(paged.items).toHaveLength(2); + expect(paged.cursor).toBeTruthy(); + }); }); diff --git a/src/backend/stores/team/TeamStore.ts b/src/backend/stores/team/TeamStore.ts index 0d118759b..5cf15bebc 100644 --- a/src/backend/stores/team/TeamStore.ts +++ b/src/backend/stores/team/TeamStore.ts @@ -18,6 +18,12 @@ */ import { v4 as uuidv4 } from 'uuid'; +import { + encodeCursor, + decodeCursor, + normalizeLimit, + type PageResult, +} from '../../util/pagination.js'; import { PuterStore } from '../types'; /** A workspace: a `group` row with `kind = 'team'`. */ @@ -34,6 +40,30 @@ export interface TeamRow { export const TEAM_KIND = 'team'; +/** A membership row, joined to the member's username. */ +/** A seat, joined to the workspace that pays for it. */ +export interface OrgSeatRow { + id: number; + user_id: number; + uuid: string; + username: string; + team_uid: string; + owner_user_id: number; +} + +export interface TeamMemberRow { + id: number; + user_id: number; + group_id: number; + username: string; + org_owned: number; + created_at: string; +} + +/** Default and ceiling for `listMembers`, matching the other paginated stores. */ +export const MEMBER_PAGE_SIZE = 50; +export const MEMBER_PAGE_CAP = 200; + /** Longest handle mysql can store — `varchar(64)` in mysql_mig_26. */ export const HANDLE_MAX_LENGTH = 64; export const HANDLE_MIN_LENGTH = 3; @@ -113,10 +143,7 @@ export const checkHandle = (handle: string): HandleRejection | null => { }; export class TeamStore extends PuterStore { - /** - * Postgres indexes `lower(handle)`; the others are case-insensitive - * already. - */ + /** Postgres indexes lower(handle); the others are already insensitive. */ #handleMatch(): string { return this.clients.db.case({ postgres: 'lower(`handle`) = lower(?)', @@ -224,10 +251,7 @@ export class TeamStore extends PuterStore { return this.getByUid(uid); } - /** - * Releases the handle since nothing addresses by it; keeps `name` for - * history. - */ + /** Releases the handle, since nothing addresses by it; keeps `name`. */ async softDelete(uid: string): Promise { const result = await this.clients.db.write( `UPDATE \`group\` SET \`deleted_at\` = CURRENT_TIMESTAMP, \`handle\` = NULL ` + @@ -236,4 +260,115 @@ export class TeamStore extends PuterStore { ); return result.anyRowsAffected; } + + // -- Membership ---- sole writer of team rows ----------------------- + + /** The membership row for this user in this workspace, or null. */ + async getMembership( + teamUid: string, + userId: number, + ): Promise { + const rows = await this.clients.db.read( + 'SELECT ug.`id`, ug.`user_id`, ug.`group_id`, ug.`org_owned`, ' + + 'ug.`created_at`, u.`username` FROM `jct_user_group` ug ' + + 'JOIN `user` u ON u.`id` = ug.`user_id` ' + + 'JOIN `group` g ON g.`id` = ug.`group_id` ' + + `WHERE g.\`uid\` = ? AND ug.\`user_id\` = ? AND g.${this.#live()}`, + [teamUid, userId, TEAM_KIND], + ); + return (rows[0] as unknown as TeamMemberRow) ?? null; + } + + /** Whether this user belongs to this workspace. */ + async isMember(teamUid: string, userId: number): Promise { + return (await this.getMembership(teamUid, userId)) !== null; + } + + /** A workspace's members, keyset-paginated on `id` per doc/pagination.md. */ + async listMembers( + teamUid: string, + opts: { limit?: unknown; cursor?: string } = {}, + ): Promise> { + const limit = + normalizeLimit(opts.limit, { cap: MEMBER_PAGE_CAP }) ?? + MEMBER_PAGE_SIZE; + const page = decodeCursor(opts.cursor, 'team member cursor'); + const after = typeof page?.id === 'number' ? page.id : null; + + // One row past the limit is how we know a further page exists. + const rows = (await this.clients.db.read( + 'SELECT ug.`id`, ug.`user_id`, ug.`group_id`, ug.`org_owned`, ' + + 'ug.`created_at`, u.`username` FROM `jct_user_group` ug ' + + 'JOIN `user` u ON u.`id` = ug.`user_id` ' + + 'JOIN `group` g ON g.`id` = ug.`group_id` ' + + `WHERE g.\`uid\` = ? AND g.${this.#live()}` + + (after === null ? '' : ' AND ug.`id` > ?') + + ' ORDER BY ug.`id` LIMIT ?', + after === null + ? [teamUid, TEAM_KIND, limit + 1] + : [teamUid, TEAM_KIND, after, limit + 1], + )) as unknown as TeamMemberRow[]; + + const items = rows.slice(0, limit); + const cursor = + rows.length > limit + ? encodeCursor({ id: items[items.length - 1].id }) + : undefined; + return { items, cursor }; + } + + /** Workspaces this user belongs to, oldest first. */ + async listTeamsForUser(userId: number): Promise { + 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[]; + } + + /** `orgOwned` decides who pays: 1 workspace-created, 0 the workspace owner. */ + async addMember( + teamUid: string, + userId: number, + opts: { orgOwned: boolean }, + ): Promise { + // Kind-filtered subquery, so a non-team uid inserts nothing. + const result = await this.clients.db.write( + `${this.clients.db.insertIgnoreInto('jct_user_group')} ` + + '(`user_id`, `group_id`, `org_owned`) ' + + 'SELECT ?, g.`id`, ? FROM `group` g ' + + `WHERE g.\`uid\` = ? AND g.${this.#live()}` + + this.clients.db.insertIgnoreSuffix(), + // Not `booleanValue`: it yields a real boolean on postgres. + [userId, opts.orgOwned ? 1 : 0, teamUid, TEAM_KIND], + ); + return result.anyRowsAffected; + } + + /** Removes a member, returning whether a row was there to remove. */ + async removeMember(teamUid: string, userId: number): Promise { + const result = await this.clients.db.write( + 'DELETE FROM `jct_user_group` WHERE `user_id` = ? AND `group_id` = ' + + `(SELECT \`id\` FROM \`group\` WHERE \`uid\` = ? AND ${this.#live()})`, + [userId, teamUid, TEAM_KIND], + ); + return result.anyRowsAffected; + } + + /** The workspace seat this user is, if any. Soft-deleted workspaces count. */ + async getOrgSeat(userId: number): Promise { + const rows = (await this.clients.db.read( + 'SELECT ug.`id`, ug.`user_id`, u.`uuid`, u.`username`, ' + + 'g.`uid` AS `team_uid`, g.`owner_user_id` ' + + 'FROM `jct_user_group` ug ' + + 'JOIN `user` u ON u.`id` = ug.`user_id` ' + + 'JOIN `group` g ON g.`id` = ug.`group_id` ' + + 'WHERE ug.`user_id` = ? AND ug.`org_owned` = 1 ' + + 'AND g.`kind` = ? ORDER BY g.`id` LIMIT 1', + [userId, TEAM_KIND], + )) as unknown as OrgSeatRow[]; + return rows[0] ?? null; + } }