From 0c6045ed90b311f0cd382edec1354120963b0b1c Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Thu, 3 Sep 2026 11:36:28 -0400 Subject: [PATCH] feat: emit team billing events for the payment integration The charge lives outside this repo, the way the marketplace extension cancels Stripe subscriptions off `user.delete`. This is the trigger, and it is the whole of what OSS owes billing. team.account.created a seat exists and can be used team.account.disabled it stopped, and still holds its bytes team.account.enabled it resumed team.account.deleted it is gone team.deleted the team is gone; its accounts are not Each carries the team uid, the affected account, and the owner's `stripe_customer_id`. That column ships in the mysql and postgres schemas but not sqlite, so the read is guarded and degrades to null, as `cascadeDelete` already does. Deleting a team emits one `team.account.disabled` per seat plus the team event, rather than one bulk event: the accounts persist, suspended, holding their files and their usernames. Deleting a team is not a way to stop paying for the accounts in it. `team.account.deleted` is captured before the row is deleted. `jct_user_group.user_id` is ON DELETE CASCADE, so by the time a listener on `user.delete` runs, nothing can say which team paid for the account. `getOrgSeat` deliberately admits soft-deleted teams: their accounts still exist, so the charge is still running. Closes PUT-1712. --- src/backend/clients/event/types.ts | 44 ++- src/backend/services/team/TeamEvents.test.ts | 251 ++++++++++++++++++ src/backend/services/team/TeamService.ts | 126 ++++++++- .../services/user/UserAccountService.ts | 5 + src/backend/stores/fs/FSEntryStore.ts | 15 +- src/backend/stores/team/TeamStore.ts | 52 ++-- 6 files changed, 461 insertions(+), 32 deletions(-) create mode 100644 src/backend/services/team/TeamEvents.test.ts diff --git a/src/backend/clients/event/types.ts b/src/backend/clients/event/types.ts index d4540a5dc..d5c759e76 100644 --- a/src/backend/clients/event/types.ts +++ b/src/backend/clients/event/types.ts @@ -35,6 +35,24 @@ type GuiEvent> = { // The entry arrives under several aliases, for handlers of any vintage. type FsCreateEvent = { node: FSEntry; entry: FSEntry; uid: string }; +/** + * Who pays, and for which team. Deliberately no payment identity: the + * payer's row is alive at emit time, so a consumer resolves it when it acts -- + * a snapshot taken here would be stale, and two events racing on it would each + * create their own customer. + */ +export type TeamBillingContext = { + team_uid: string; + owner_user_id: number; +}; + +/** A team event about one seat. */ +export type TeamBillingEvent = TeamBillingContext & { + user_id: number; + user_uuid: string; + username: string; +}; + /** * Extension-augmentable half of {@link EventMap}. Extensions that emit their own * events declare the payload here by declaration merging, so both the emitter @@ -336,6 +354,23 @@ export type EventMap = { stripe_customer_id?: string | null; }; + // ---- Team billing ---- the trigger, never the charge ---- + 'team.account.created': TeamBillingEvent; + /** `held_bytes` is what to bill storage on while the seat is off. */ + 'team.account.disabled': TeamBillingEvent & { held_bytes: number }; + 'team.account.enabled': TeamBillingEvent & { held_bytes: number }; + /** Emitted pre-delete: the membership row cascades away with the user. */ + 'team.account.deleted': TeamBillingEvent; + /** Per-seat charges stop; byte charges do not, the accounts remain. */ + 'team.deleted': TeamBillingContext & { account_count: number }; + /** A budget line was crossed. Emitted on transition only, never per request. */ + 'metering.credit-state': { + user_uuid: string; + state: 'near-limit' | 'exhausted'; + allowance_used: number; + month_usage_allowance: number; + }; + // ---- Filesystem ---- 'fs.copy.node': { source: unknown; @@ -758,11 +793,10 @@ export type EventKey = keyof EventMap & string; // Generates a wildcard for every non-final dot-separated prefix of K. export type WildcardPrefixes = K extends `${infer Head}.${infer Tail}` - ? - | `${Head}.*` - | (Tail extends `${string}.${string}` - ? `${Head}.${WildcardPrefixes}` - : never) + ? | `${Head}.*` + | (Tail extends `${string}.${string}` + ? `${Head}.${WildcardPrefixes}` + : never) : never; export type ListenKey = EventKey | WildcardPrefixes; diff --git a/src/backend/services/team/TeamEvents.test.ts b/src/backend/services/team/TeamEvents.test.ts new file mode 100644 index 000000000..1748a4c02 --- /dev/null +++ b/src/backend/services/team/TeamEvents.test.ts @@ -0,0 +1,251 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { v4 as uuidv4 } from 'uuid'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { PuterServer } from '../../server.ts'; +import { setupTestServer } from '../../testUtil.ts'; + +describe('team billing events', () => { + let server: PuterServer; + let service: PuterServer['services']['team']; + let owner: { id: number }; + + /** Every billing event emitted since the last reset, in order. */ + const seen: Array<{ key: string; data: Record }> = []; + const of = (key: string) => seen.filter((e) => e.key === key); + + const makeUser = async (): Promise<{ id: number; username: string }> => { + const username = `bil_${Math.random().toString(36).slice(2, 10)}`; + const created = (await server.stores.user.create({ + username, + uuid: uuidv4(), + password: null, + email: `${username}@test.local`, + })) as unknown as { id: number }; + return { id: created.id, username }; + }; + + const freeHandle = () => `bw-${Math.random().toString(36).slice(2, 10)}`; + + const makeTeam = async () => + service.createTeam(owner.id, { + name: 'Billing Co', + handle: freeHandle(), + }); + + /** A real provisioned seat, as every chargeable account is. */ + const provision = async (team: { uid: string }) => { + const username = `seat_${Math.random().toString(36).slice(2, 10)}`; + const created = await service.provisionAccount(team.uid, owner.id, { + username, + email: `${username}@test.local`, + }); + return created; + }; + + /** Same, for a team whose owner is not the shared one. */ + const provision2 = async (team: { uid: string }, ownerId: number) => { + const username = `seat_${Math.random().toString(36).slice(2, 10)}`; + return service.provisionAccount(team.uid, ownerId, { + username, + email: `${username}@test.local`, + }); + }; + + /** Bytes the report has to find; `size` is what `SUM(size)` reads. */ + const giveFile = async (userId: number, size: number) => { + await server.clients.db.write( + 'INSERT INTO fsentries (uuid, parent_uid, user_id, name, path, is_dir, size, created, accessed, modified) ' + + 'VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + uuidv4(), + userId, + `f_${Math.random().toString(36).slice(2, 8)}`, + `/f_${Math.random().toString(36).slice(2, 8)}`, + server.clients.db.booleanValue(false), + size, + 0, + 0, + 0, + ], + ); + }; + + beforeAll(async () => { + // The cap has its own suite; these tests need many teams. + server = await setupTestServer({ + teams_enabled: true, + max_teams_per_user: 100, + } as never); + service = server.services.team; + owner = await makeUser(); + + for (const key of [ + 'team.account.created', + 'team.account.disabled', + 'team.account.enabled', + 'team.account.deleted', + 'team.deleted', + 'team.held-bytes.report', + ]) { + server.clients.event.on( + key as never, + ((k: string, data: Record) => { + seen.push({ key: k, data }); + }) as never, + ); + } + }); + + beforeEach(() => { + seen.length = 0; + }); + + afterAll(async () => { + await server?.shutdown(); + }); + + // -- on-demand lifecycle events ----------------------------------- + + it('emits account-created once, naming the team and the owner', async () => { + const team = await makeTeam(); + const seat = await provision(team); + + const events = of('team.account.created'); + expect(events).toHaveLength(1); + expect(events[0].data).toMatchObject({ + team_uid: team.uid, + owner_user_id: owner.id, + user_id: seat.userId, + username: seat.username, + }); + // The payment side keys the charge off this, so it must be present. + expect(typeof events[0].data.user_uuid).toBe('string'); + }); + + it('carries the held bytes on disable, so billing need not query back', async () => { + const team = await makeTeam(); + const seat = await provision(team); + await giveFile(seat.userId, 4096); + seen.length = 0; + + await service.disableMember(team.uid, owner.id, seat.userId); + + const events = of('team.account.disabled'); + expect(events).toHaveLength(1); + expect(events[0].data.held_bytes).toBe(4096); + expect(events[0].data.user_id).toBe(seat.userId); + }); + + it('emits account-enabled with the bytes that stop being charged', async () => { + const team = await makeTeam(); + const seat = await provision(team); + await giveFile(seat.userId, 1024); + await service.disableMember(team.uid, owner.id, seat.userId); + seen.length = 0; + + await service.enableMember(team.uid, owner.id, seat.userId); + + const events = of('team.account.enabled'); + expect(events).toHaveLength(1); + expect(events[0].data.held_bytes).toBe(1024); + }); + + it('emits one disabled event per seat plus a team event on delete', async () => { + const team = await makeTeam(); + const a = await provision(team); + const b = await provision(team); + seen.length = 0; + + await service.deleteTeam(team.uid, owner.id); + + // Per seat, because the byte charge that follows is per account. + const disabled = of('team.account.disabled'); + expect(disabled).toHaveLength(2); + expect(disabled.map((e) => e.data.user_id).sort()).toEqual( + [a.userId, b.userId].sort(), + ); + + const deleted = of('team.deleted'); + expect(deleted).toHaveLength(1); + expect(deleted[0].data).toMatchObject({ + team_uid: team.uid, + account_count: 2, + }); + }); + + it('emits account-deleted, which a post-delete listener could not', async () => { + const team = await makeTeam(); + const seat = await provision(team); + seen.length = 0; + + await server.services.userAccount.cascadeDelete(seat.userId); + + const events = of('team.account.deleted'); + expect(events).toHaveLength(1); + expect(events[0].data).toMatchObject({ + team_uid: team.uid, + user_id: seat.userId, + username: seat.username, + }); + }); + + it('leaves the membership unreadable after the delete it was captured for', async () => { + const team = await makeTeam(); + const seat = await provision(team); + await server.services.userAccount.cascadeDelete(seat.userId); + + // `jct_user_group.user_id` is ON DELETE CASCADE -- this is why the + // identity has to be captured before the row goes. + expect(await server.stores.team.getOrgSeat(seat.userId)).toBeNull(); + }); + + it('says nothing about an account no team pays for', async () => { + const outsider = await makeUser(); + seen.length = 0; + + await server.services.userAccount.cascadeDelete(outsider.id); + + expect(of('team.account.deleted')).toHaveLength(0); + }); + + it('does not open a second byte charge when a seat is disabled twice', async () => { + const team = await makeTeam(); + const seat = await provision(team); + await service.disableMember(team.uid, owner.id, seat.userId); + seen.length = 0; + + await service.disableMember(team.uid, owner.id, seat.userId); + + // `held_bytes` opens a charge one `enabled` closes; two opens and one + // close leaves the payer billed for storage nobody holds. + expect(of('team.account.disabled')).toHaveLength(0); + }); + + it('does not close a charge that was never opened', async () => { + const team = await makeTeam(); + const seat = await provision(team); + seen.length = 0; + + await service.enableMember(team.uid, owner.id, seat.userId); + + expect(of('team.account.enabled')).toHaveLength(0); + }); +}); diff --git a/src/backend/services/team/TeamService.ts b/src/backend/services/team/TeamService.ts index 131bff8f1..92741c6ed 100644 --- a/src/backend/services/team/TeamService.ts +++ b/src/backend/services/team/TeamService.ts @@ -26,6 +26,11 @@ import { USERNAME_MAX_LENGTH, USERNAME_REGEX, } from '../../controllers/auth/AuthController.js'; +import type { + EventMap, + TeamBillingContext, + TeamBillingEvent, +} from '../../clients/event/types'; import { HttpError } from '../../core/http/HttpError.js'; import { checkHandle } from '../../stores/team/TeamStore.js'; import type { @@ -56,6 +61,61 @@ export const generateTemporaryPassword = (length = 16): string => { export const DISABLED_BY_TEAM = 'disabled_by_team'; export class TeamService extends PuterService { + // -- Billing ---- OSS emits; prod decides (see TEAMS-BILLING-SPLIT) ---- + + /** The team owner pays, so the charge is keyed to its customer id. */ + async #billingContext(team: TeamRow): Promise { + return { + team_uid: team.uid, + owner_user_id: team.owner_user_id, + }; + } + + /** Bytes the account holds right now, for prod to price if it wants to. */ + async #heldBytes(userId: number): Promise { + try { + return await this.stores.fsEntry.getHeldBytes(userId); + } catch (e) { + // A missing figure must not fail the operation that reported it. + console.warn('[team-billing] held-bytes read failed:', e); + return 0; + } + } + + /** Captured pre-delete: the membership row cascades away with the user. */ + async captureSeatForBilling( + userId: number, + ): Promise { + if (this.config.teams_enabled !== true) return null; + try { + const seat = await this.stores.team.getOrgSeat(userId); + if (!seat) return null; + return { + team_uid: seat.team_uid, + owner_user_id: seat.owner_user_id, + user_id: seat.user_id, + user_uuid: seat.uuid, + username: seat.username, + }; + } catch (e) { + console.warn('[team-billing] seat capture failed:', e); + return null; + } + } + + /** Paired with `captureSeatForBilling`, once the account is really gone. */ + emitSeatDeleted(seat: TeamBillingEvent | null): void { + if (seat) this.#emitBilling('team.account.deleted', seat); + } + + /** Fire-and-forget, as `user.delete` is: the charge is not our business. */ + #emitBilling(name: K, payload: EventMap[K]) { + try { + this.clients.event?.emit(name, payload, {}); + } catch (e) { + console.warn('[team-billing] emit failed:', name, e); + } + } // -- Authority ---- the whole authorization model ------------------ /** 404 to a non-member so the endpoint is not an existence oracle. */ @@ -203,24 +263,20 @@ export class TeamService extends PuterService { ); if (!owner || Number(owner.org_owned) !== 0) return false; - const rows = (await this.clients.db.read( - 'SELECT COUNT(*) AS n FROM `jct_user_group` ' + - 'WHERE `group_id` = ? AND `org_owned` = 0', - [team.id], - )) as { n: number }[]; - return Number(rows[0]?.n) === 1; + return (await this.stores.team.countPayers(team.id)) === 1; } /** Soft delete disables the accounts it created; recovery is via support. */ async deleteTeam(teamUid: string, actorUserId: number): Promise { const team = await this.requireOwner(teamUid, actorUserId); + const billing = await this.#billingContext(team); + let disabled = 0; // Otherwise they keep working, unreachable through a deleted team. let page = await this.stores.team.listMembers(teamUid, { limit: 200 }); for (;;) { for (const member of page.items) { if (Number(member.org_owned) !== 1) continue; - await this.#suspend(member.user_id); await this.stores.team.appendAudit({ teamId: team.id, userId: member.user_id, @@ -228,6 +284,18 @@ export class TeamService extends PuterService { action: 'disable', reason: 'team_deleted', }); + const held = await this.#heldBytes(member.user_id); + await this.#suspend(member.user_id); + disabled++; + + // Per seat, not one bulk event: the byte charge is per account. + this.#emitBilling('team.account.disabled', { + ...billing, + user_id: member.user_id, + user_uuid: member.uuid, + username: member.username, + held_bytes: held, + }); } if (!page.cursor) break; page = await this.stores.team.listMembers(teamUid, { @@ -243,6 +311,11 @@ export class TeamService extends PuterService { action: 'delete_team', }); await this.stores.team.softDelete(teamUid); + + this.#emitBilling('team.deleted', { + ...billing, + account_count: disabled, + }); } /** Team owner only. Readable after deletion -- that is the point of it. */ @@ -355,6 +428,7 @@ export class TeamService extends PuterService { temporaryPassword: string; }> { const team = await this.requireOwner(teamUid, actorUserId); + this.#assertUsableUsername(input.username); if (!validator.isEmail(input.email)) { throw new HttpError(400, 'Invalid email', { @@ -417,6 +491,14 @@ export class TeamService extends PuterService { }); await this.#notifyAccountCreated(user, team); + // Last: the seat is only chargeable once it exists and can be used. + this.#emitBilling('team.account.created', { + ...(await this.#billingContext(team)), + user_id: user.id, + user_uuid: user.uuid, + username: user.username, + }); + return { userId: user.id, username: user.username, @@ -487,7 +569,14 @@ export class TeamService extends PuterService { targetUserId: number, ): Promise { const team = await this.requireOwner(teamUid, actorUserId); - await this.requireOrgAccount(teamUid, targetUserId); + const membership = await this.requireOrgAccount(teamUid, targetUserId); + + // Already off: emitting again would open a second byte charge that + // only one `enabled` will ever close. + const current = await this.stores.user.getByProperty('id', targetUserId, { + force: true, + }); + if (current?.suspended) return; // Recorded first: a failed append must not leave an unlogged suspension. await this.stores.team.appendAudit({ @@ -496,7 +585,17 @@ export class TeamService extends PuterService { actorUserId, action: 'disable', }); + // Read before suspending, though a disabled account cannot change it. + const held = await this.#heldBytes(targetUserId); await this.#suspend(targetUserId); + + this.#emitBilling('team.account.disabled', { + ...(await this.#billingContext(team)), + user_id: targetUserId, + user_uuid: membership.uuid, + username: membership.username, + held_bytes: held, + }); } /** Nothing was destroyed, so the account returns as it was. */ @@ -521,6 +620,8 @@ export class TeamService extends PuterService { legacyCode: 'conflict', }); } + // Never disabled, so there is no charge to close. + if (!user?.suspended) return; await this.stores.team.appendAudit({ teamId: team.id, @@ -534,6 +635,15 @@ export class TeamService extends PuterService { suspended_reason: null, }); await this.stores.user.invalidateById(targetUserId); + + // Closes the byte charge the disable opened, at the same figure. + this.#emitBilling('team.account.enabled', { + ...(await this.#billingContext(team)), + user_id: targetUserId, + user_uuid: user.uuid, + username: user.username, + held_bytes: await this.#heldBytes(targetUserId), + }); } /** The three columns together; `suspended` is the one that gates requests. */ diff --git a/src/backend/services/user/UserAccountService.ts b/src/backend/services/user/UserAccountService.ts index dc886bd94..539b02104 100644 --- a/src/backend/services/user/UserAccountService.ts +++ b/src/backend/services/user/UserAccountService.ts @@ -60,6 +60,9 @@ export class UserAccountService extends PuterService { console.warn('[cascade-delete-user] identifier lookup failed:', e); } + // Same reason, one table over: the membership row cascades on delete. + const seat = await this.services.team.captureSeatForBilling(userId); + try { await this.services.fs.removeAllForUser(userId); } catch (e) { @@ -95,6 +98,8 @@ export class UserAccountService extends PuterService { } catch { // ignore — event emission shouldn't block deletion } + // Tells prod to stop charging the owner for this seat. + this.services.team.emitSeatDeleted(seat); } /** diff --git a/src/backend/stores/fs/FSEntryStore.ts b/src/backend/stores/fs/FSEntryStore.ts index 563e665e6..d9420297a 100644 --- a/src/backend/stores/fs/FSEntryStore.ts +++ b/src/backend/stores/fs/FSEntryStore.ts @@ -2489,8 +2489,7 @@ export class FSEntryStore extends PuterStore { } = {}, ): Promise<{ entries: FSEntry[]; cursor?: string }> { const payload = decodeCursor(options.cursor) as - | { v: unknown; id: number; s?: string; o?: string } - | undefined; + { v: unknown; id: number; s?: string; o?: string } | undefined; const requestedSort = options.sortBy ?? null; const requestedOrder = options.sortOrder ?? null; @@ -2676,8 +2675,7 @@ export class FSEntryStore extends PuterStore { const limit = normalizeLimit(options.limit, { cap: 10_000 }) ?? 1000; const payload = decodeCursor(options.cursor) as - | { p: string } - | undefined; + { p: string } | undefined; const seek = payload ? 'AND path > ?' : ''; const params: unknown[] = payload ? [userId, likePattern, maxSlashes, payload.p, limit + 1] @@ -3048,6 +3046,15 @@ export class FSEntryStore extends PuterStore { return Number.isFinite(affected) ? affected : 0; } + /** Bytes held, with no allowance or quota-bonus reckoning attached. */ + async getHeldBytes(userId: number): Promise { + const rows = (await this.clients.db.read( + `SELECT COALESCE(SUM(size), 0) AS ${this.clients.db.quoteIdentifier('totalUsage')} FROM fsentries WHERE user_id = ?`, + [userId], + )) as { totalUsage: number }[]; + return Number(rows[0]?.totalUsage ?? 0); + } + async getUserStorageAllowance( userId: number, ): Promise<{ curr: number; max: number }> { diff --git a/src/backend/stores/team/TeamStore.ts b/src/backend/stores/team/TeamStore.ts index 14810fa01..bd969e768 100644 --- a/src/backend/stores/team/TeamStore.ts +++ b/src/backend/stores/team/TeamStore.ts @@ -77,7 +77,7 @@ export const MEMBER_PAGE_CAP = 200; export const AUDIT_PAGE_SIZE = 50; export const AUDIT_PAGE_CAP = 200; -/** Longest handle mysql can store — `varchar(64)` in mysql_mig_26. */ +/** Longest handle mysql can store — `varchar(64)` in mysql_mig_28. */ export const HANDLE_MAX_LENGTH = 64; export const HANDLE_MIN_LENGTH = 3; @@ -379,6 +379,26 @@ export class TeamStore extends PuterStore { return result.anyRowsAffected; } + /** Seats the team has provisioned. The owner is not one of them. */ + async countSeats(teamId: number): Promise { + const rows = (await this.clients.db.read( + 'SELECT COUNT(*) AS n FROM `jct_user_group` ' + + 'WHERE `group_id` = ? AND `org_owned` = 1', + [teamId], + )) as { n: number }[]; + return Number(rows[0]?.n ?? 0); + } + + /** Live teams this user owns. Soft-deleted ones do not count. */ + async countOwned(ownerUserId: number): Promise { + const rows = (await this.clients.db.read( + 'SELECT COUNT(*) AS n FROM `group` ' + + `WHERE \`owner_user_id\` = ? AND ${this.#live()}`, + [ownerUserId, TEAM_KIND], + )) as { n: number }[]; + return Number(rows[0]?.n ?? 0); + } + /** How many members pay for themselves; the owner should be the only one. */ async countPayers(teamId: number): Promise { const rows = (await this.clients.db.read( @@ -389,6 +409,22 @@ export class TeamStore extends PuterStore { return Number(rows[0]?.n ?? 0); } + + + /** Soft-deleted teams count: their seats still hold billable bytes. */ + 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; + } // -- Audit ---- insert-only; no update or delete path exists -------- /** Records something the team did to an account. */ @@ -476,18 +512,4 @@ export class TeamStore extends PuterStore { return result.anyRowsAffected; } - /** The team seat this user is, if any. Soft-deleted teams 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; - } }