import type { Selectable } from 'kysely' import * as Id from '../../internal/Id.js' import type * as Db from '../Db.js' import type * as db_Schema from '../Schema.js' import * as Memberships from './memberships.js' /** Columns of the `invitations` table, derived from `Schema.Invitation`. */ export type Table = db_Schema.Invitation /** A stored invitation row. */ export type Record = Selectable /** * Inserts an invitation and returns the stored record. Emails are stored * lowercase so matching against verified emails is case-insensitive. Re-inviting * a live (pending) (org, email) refreshes that row — role, inviter, expiry — * rather than accumulating duplicates, matching the `invitations_pending_unique` * partial index. * * @param db - The database. * @param input - The invitation 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('invitations') .values({ acceptedAt: null, createdAt: now, email: input.email.toLowerCase(), expiresAt: input.expiresAt, // Keep the legacy column inert until it can be removed safely. grantsEarlyAccess: false, id: Id.generate('inv'), invitedBy: input.invitedBy, orgId: input.orgId, revokedAt: null, role: input.role, }) .onConflict((oc) => oc .columns(['orgId', 'email']) .where('acceptedAt', 'is', null) .where('revokedAt', 'is', null) .doUpdateSet({ createdAt: now, expiresAt: input.expiresAt, grantsEarlyAccess: false, invitedBy: input.invitedBy, role: input.role, }), ) .returningAll() .executeTakeFirstOrThrow() } export declare namespace create { /** Fields accepted when inserting an invitation. */ type Input = { /** Invitee email. */ email: string /** When the invitation expires (ISO 8601). */ expiresAt: string /** User or API key id creating the invitation. */ invitedBy: string /** Organization id (`org_…`) the invitation joins. */ orgId: string /** Role granted on accept. */ role: Memberships.Role } } /** * Reads an invitation by id. * * @param db - The database. * @param id - The invitation id (`inv_…`). * @returns The record, or `undefined` when absent. */ export function get(db: Db.Db, id: string): Promise { return db.kysely.selectFrom('invitations').selectAll().where('id', '=', id).executeTakeFirst() } /** * Lists an organization's pending invitations (unaccepted, unrevoked, * unexpired), newest first. * * @param db - The database. * @param orgId - The organization id (`org_…`). * @returns The records. */ export function listByOrg(db: Db.Db, orgId: string): Promise { return pending(db).where('orgId', '=', orgId).orderBy('createdAt', 'desc').execute() } /** * Lists pending invitations addressed to an email, joined with the inviting * organization's name, newest first. * * @param db - The database. * @param email - The invitee email (matched lowercase). * @returns The records with `orgName` from the organization row. */ export function listByEmail(db: Db.Db, email: string): Promise<(Record & { orgName: string })[]> { return db.kysely .selectFrom('invitations') .innerJoin('organizations', 'organizations.id', 'invitations.orgId') .where('invitations.acceptedAt', 'is', null) .where('invitations.revokedAt', 'is', null) .where('invitations.expiresAt', '>', new Date().toISOString()) .where('invitations.email', '=', email.toLowerCase()) .selectAll('invitations') .select('organizations.name as orgName') .orderBy('invitations.createdAt', 'desc') .execute() } /** * Revokes a pending invitation. * * @param db - The database. * @param id - The invitation id (`inv_…`). * @returns The revoked record, or `undefined` when absent or already settled. */ export function revoke(db: Db.Db, id: string): Promise { const now = new Date().toISOString() return db.kysely .updateTable('invitations') .set({ revokedAt: now }) .where('id', '=', id) .where('acceptedAt', 'is', null) .where('revokedAt', 'is', null) .returningAll() .executeTakeFirst() } /** * Accepts a pending invitation into a membership, atomically: the conditional * update settles the invitation exactly once (double accepts lose the race). * A new member is inserted at the invited role; an existing member is only ever * elevated to it — accepting never demotes (which could strip the last owner). * * @param db - The database. * @param id - The invitation id (`inv_…`). * @param userId - The accepting user id (`usr_…`). * @returns The accepted record, or `undefined` when absent, settled, or expired. */ export function accept(db: Db.Db, id: string, userId: string): Promise { const now = new Date().toISOString() return db.kysely.transaction().execute(async (trx) => { const invitation = await trx .updateTable('invitations') .set({ acceptedAt: now }) .where('id', '=', id) .where('acceptedAt', 'is', null) .where('revokedAt', 'is', null) .where('expiresAt', '>', now) .returningAll() .executeTakeFirst() if (!invitation) return undefined const existing = await trx .selectFrom('memberships') .selectAll() .where('orgId', '=', invitation.orgId) .where('userId', '=', userId) .executeTakeFirst() if (!existing) await trx .insertInto('memberships') .values({ createdAt: now, orgId: invitation.orgId, role: invitation.role, updatedAt: now, userId, }) // A concurrent accept/join could insert first; keep that membership. .onConflict((oc) => oc.columns(['orgId', 'userId']).doNothing()) .execute() else if (Memberships.rank[invitation.role] > Memberships.rank[existing.role]) await trx .updateTable('memberships') .set({ role: invitation.role, updatedAt: now }) .where('orgId', '=', invitation.orgId) .where('userId', '=', userId) .execute() return invitation }) } /** Base query selecting pending invitations (unaccepted, unrevoked, unexpired). */ function pending(db: Db.Db) { return db.kysely .selectFrom('invitations') .selectAll() .where('acceptedAt', 'is', null) .where('revokedAt', 'is', null) .where('expiresAt', '>', new Date().toISOString()) }