import type { JSONColumnType, 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' /** Columns of the `invite_links` table. */ export type Table = Omit & { /** Lowercase email domains allowed to redeem, or null for unrestricted. */ allowedEmailDomains: JSONColumnType< db_Schema.InviteLink['allowedEmailDomains'], string | null, string | null > } /** Columns of the `invite_link_redemptions` table. */ export type RedemptionTable = db_Schema.InviteLinkRedemption /** A stored invite link. */ export type Record = Selectable /** A stored redemption. */ export type Redemption = Selectable /** Derived link availability. */ export type Status = 'active' | 'deleted' | 'disabled' | 'exhausted' | 'expired' /** Derives current availability from persisted controls and count. */ export function status(record: Record, now = new Date().toISOString()): Status { if (record.deletedAt) return 'deleted' if (!record.enabled) return 'disabled' if (record.expiresAt && record.expiresAt <= now) return 'expired' if (record.maxUses !== null && record.useCount >= record.maxUses) return 'exhausted' return 'active' } /** Creates a reusable invite link. */ export function create( db: Db.Db, input: { allowedEmailDomains: readonly string[] | null createdBy: string expiresAt: string | null maxUses: number | null name?: string | undefined orgId: string role: 'member' }, ): Promise { const now = new Date().toISOString() return db.kysely .insertInto('invite_links') .values({ allowedEmailDomains: input.allowedEmailDomains === null ? null : JSON.stringify(input.allowedEmailDomains), createdAt: now, createdBy: input.createdBy, deletedAt: null, enabled: true, expiresAt: input.expiresAt, id: Id.generate('iln'), lastUsedAt: null, maxUses: input.maxUses, name: input.name ?? 'Untitled link', orgId: input.orgId, role: input.role, token: Id.generate('lnk'), updatedAt: now, useCount: 0, }) .returningAll() .executeTakeFirstOrThrow() } /** Lists non-deleted links in an organization. */ export function list(db: Db.Db, orgId: string): Promise { return db.kysely .selectFrom('invite_links') .selectAll() .where('orgId', '=', orgId) .where('deletedAt', 'is', null) .orderBy('createdAt', 'desc') .execute() } /** Gets an invite link by its management id, including deleted links. */ export function get(db: Db.Db, id: string): Promise { return db.kysely.selectFrom('invite_links').selectAll().where('id', '=', id).executeTakeFirst() } /** Gets an invite link by its bearer token, including deleted links. */ export function getByToken(db: Db.Db, token: string): Promise { return db.kysely .selectFrom('invite_links') .selectAll() .where('token', '=', token) .executeTakeFirst() } /** Updates an org-scoped non-deleted link. */ export function update( db: Db.Db, orgId: string, id: string, enabled: boolean, ): Promise { return db.kysely .updateTable('invite_links') .set({ enabled, updatedAt: new Date().toISOString() }) .where('id', '=', id) .where('orgId', '=', orgId) .where('deletedAt', 'is', null) .returningAll() .executeTakeFirst() } /** Soft deletes an org-scoped link. */ export function remove(db: Db.Db, orgId: string, id: string): Promise { const now = new Date().toISOString() return db.kysely .updateTable('invite_links') .set({ deletedAt: now, updatedAt: now }) .where('id', '=', id) .where('orgId', '=', orgId) .where('deletedAt', 'is', null) .returningAll() .executeTakeFirst() } /** Lists redemption audit records, newest first. */ export function listRedemptions( db: Db.Db, orgId: string, inviteLinkId: string, ): Promise { return db.kysely .selectFrom('invite_link_redemptions') .selectAll() .where('orgId', '=', orgId) .where('inviteLinkId', '=', inviteLinkId) .orderBy('createdAt', 'desc') .execute() } /** Atomically joins through an active link, consuming only a newly inserted membership. */ export function accept( db: Db.Db, token: string, userId: string, email: string, ): Promise<{ link: Record; consumed: boolean } | undefined> { return db.kysely.transaction().execute(async (trx) => { const now = new Date().toISOString() const link = await trx .selectFrom('invite_links') .selectAll() .where('token', '=', token) .forUpdate() .executeTakeFirst() if (!link) return undefined // Existing membership is already the desired end state, so retries remain // successful after the link becomes unavailable and never consume capacity. const existing = await trx .selectFrom('memberships') .select('userId') .where('orgId', '=', link.orgId) .where('userId', '=', userId) .executeTakeFirst() if (existing) return { consumed: false, link } if (status(link, now) !== 'active') return undefined const normalizedEmail = email.trim().toLowerCase() const domain = normalizedEmail.slice(normalizedEmail.lastIndexOf('@') + 1) if (link.allowedEmailDomains !== null && !link.allowedEmailDomains.includes(domain)) throw new EmailDomainForbiddenError() const inserted = await trx .insertInto('memberships') .values({ createdAt: now, orgId: link.orgId, role: 'member', updatedAt: now, userId }) .onConflict((oc) => oc.columns(['orgId', 'userId']).doNothing()) .returning('userId') .executeTakeFirst() if (!inserted) return { consumed: false, link } await trx .updateTable('invite_links') .set({ lastUsedAt: now, updatedAt: now, useCount: link.useCount + 1 }) .where('id', '=', link.id) .execute() await trx .insertInto('invite_link_redemptions') .values({ createdAt: now, email: normalizedEmail, id: Id.generate('ilr'), inviteLinkId: link.id, inviteLinkName: link.name, orgId: link.orgId, userId, }) .execute() return { consumed: true, link: { ...link, lastUsedAt: now, updatedAt: now, useCount: link.useCount + 1 }, } }) } /** The verified email domain is not allowed by the invite link. */ export class EmailDomainForbiddenError extends Error { override name = 'InviteLinks.EmailDomainForbiddenError' }