import type { Selectable } from 'kysely' import type * as Db from '../Db.js' import type * as db_Schema from '../Schema.js' /** Columns of the `memberships` table, derived from `Schema.Membership`. */ export type Table = db_Schema.Membership /** A stored membership row. */ export type Record = Selectable /** A membership with the member's resolved user identity. */ export type DetailedRecord = Record & { address: string | null; email: string | null } /** A membership role. */ export type Role = Record['role'] /** Fixed membership roles, least to most privileged. */ export const roles = ['member', 'admin', 'owner'] as const satisfies readonly Role[] /** Privilege rank per role; higher ranks satisfy lower minimums. */ export const rank = { admin: 1, member: 0, owner: 2 } as const satisfies { [role in Role]: number } /** * Inserts a membership and returns the stored record. * * @param db - The database. * @param input - The membership to insert. * @returns The stored record. */ export function create(db: Db.Db, input: create.Input): Promise { const now = new Date().toISOString() return db.kysely .insertInto('memberships') .values({ createdAt: now, orgId: input.orgId, role: input.role, updatedAt: now, userId: input.userId, }) .returningAll() .executeTakeFirstOrThrow() } export declare namespace create { /** Fields accepted when inserting a membership. */ type Input = { /** Organization id (`org_…`). */ orgId: string /** Granted role. */ role: Role /** Member user id (`usr_…`). */ userId: string } } /** * Reads one user's membership in an organization. * * @param db - The database. * @param orgId - The organization id (`org_…`). * @param userId - The member user id (`usr_…`). * @returns The record, or `undefined` when absent. */ export function get(db: Db.Db, orgId: string, userId: string): Promise { return db.kysely .selectFrom('memberships') .selectAll() .where('orgId', '=', orgId) .where('userId', '=', userId) .executeTakeFirst() } /** * Lists an organization's memberships, oldest first (stable member ordering). * * @param db - The database. * @param orgId - The organization id (`org_…`). * @returns The records. */ export function listByOrg(db: Db.Db, orgId: string): Promise { return db.kysely .selectFrom('memberships') .selectAll() .where('orgId', '=', orgId) .orderBy('createdAt', 'asc') .execute() } /** * Lists an organization's memberships joined with member identity (address, * email), oldest first. * * @param db - The database. * @param orgId - The organization id (`org_…`). * @returns The records with `address`/`email` from the member's user row. */ export function listByOrgDetailed(db: Db.Db, orgId: string): Promise { return db.kysely .selectFrom('memberships') .innerJoin('users', 'users.id', 'memberships.userId') .where('memberships.orgId', '=', orgId) .selectAll('memberships') .select(['users.address', 'users.email']) .orderBy('memberships.createdAt', 'asc') .execute() } /** * Lists a bounded page of an organization's memberships with member identity, * oldest first. * * @param db - The database. * @param orgId - The organization id. * @param options - Keyset and page-size options. * @returns At most `limit + 1` rows for next-cursor derivation. */ export function listByOrgDetailedPage( db: Db.Db, orgId: string, options: listByOrgDetailedPage.Options, ): Promise { const cursor = options.cursor let query = db.kysely .selectFrom('memberships') .innerJoin('users', 'users.id', 'memberships.userId') .where('memberships.orgId', '=', orgId) .selectAll('memberships') .select(['users.address', 'users.email']) if (cursor) query = query.where((eb) => eb.or([ eb('memberships.createdAt', '>', cursor.createdAt), eb.and([ eb('memberships.createdAt', '=', cursor.createdAt), eb('memberships.userId', '>', cursor.userId), ]), ]), ) return query .orderBy('memberships.createdAt', 'asc') .orderBy('memberships.userId', 'asc') .limit(options.limit + 1) .execute() } export declare namespace listByOrgDetailedPage { /** Cursor fields for the last membership on the previous page. */ type Cursor = { /** Membership creation time. */ createdAt: string /** Member user id, used as a deterministic tie-breaker. */ userId: string } /** Options for {@link listByOrgDetailedPage}. */ type Options = { /** Last membership returned by the previous page. */ cursor?: Cursor | undefined /** Requested page size. */ limit: number } } /** * Changes a member's role atomically, refusing to demote the last owner. The * org's owner rows are locked for the transaction, so a concurrent demotion or * removal cannot race two guards into leaving the organization ownerless. * * @param db - The database. * @param orgId - The organization id (`org_…`). * @param userId - The member user id (`usr_…`). * @param input - The fields to update. * @returns The updated record, or an `error` when absent or the last owner. */ export function update( db: Db.Db, orgId: string, userId: string, input: update.Input, ): Promise { const now = new Date().toISOString() return db.kysely.transaction().execute(async (trx) => { const owners = await trx .selectFrom('memberships') .select('userId') .where('orgId', '=', orgId) .where('role', '=', 'owner') .forUpdate() .execute() const current = await trx .selectFrom('memberships') .selectAll() .where('orgId', '=', orgId) .where('userId', '=', userId) .executeTakeFirst() if (!current) return { error: 'not_found' as const } if (current.role === 'owner' && input.role !== 'owner' && owners.length <= 1) return { error: 'last_owner' as const } const record = await trx .updateTable('memberships') .set({ role: input.role, updatedAt: now }) .where('orgId', '=', orgId) .where('userId', '=', userId) .returningAll() .executeTakeFirstOrThrow() return { record } }) } export declare namespace update { /** Mutable membership fields. */ type Input = { /** New role. */ role: Role } /** Outcome of a guarded role change. */ type Result = | { error: 'last_owner' | 'not_found'; record?: undefined } | { error?: undefined; record: Record } } /** * Deletes a membership atomically, refusing to remove the last owner. Locks * the org's owner rows for the transaction (see {@link update}) so concurrent * removals cannot race the organization ownerless. * * @param db - The database. * @param orgId - The organization id (`org_…`). * @param userId - The member user id (`usr_…`). * @returns `removed` on success, otherwise why the removal was refused. */ export function remove(db: Db.Db, orgId: string, userId: string): Promise { return db.kysely.transaction().execute(async (trx) => { const owners = await trx .selectFrom('memberships') .select('userId') .where('orgId', '=', orgId) .where('role', '=', 'owner') .forUpdate() .execute() const current = await trx .selectFrom('memberships') .selectAll() .where('orgId', '=', orgId) .where('userId', '=', userId) .executeTakeFirst() if (!current) return 'not_found' if (current.role === 'owner' && owners.length <= 1) return 'last_owner' await trx .deleteFrom('memberships') .where('orgId', '=', orgId) .where('userId', '=', userId) .execute() return 'removed' }) } export declare namespace remove { /** Outcome of a guarded removal. */ type Result = 'last_owner' | 'not_found' | 'removed' }