import { sql, type ExpressionBuilder, type Selectable } from 'kysely' import * as Id from '../../internal/Id.js' import * as Viem from '../../internal/Viem.js' import * as SponsoredTransactions from './sponsoredTransactions.js' import type * as Db from '../Db.js' import type * as db_Schema from '../Schema.js' import type * as EnabledBillingSources from './enabledBillingSources.js' import type * as Memberships from './memberships.js' /** Columns of the `organizations` table, derived from `Schema.Organization`. */ export type Table = db_Schema.Organization /** A stored organization row. */ export type Record = Selectable /** * Inserts an organization and returns the stored record. The `id` defaults to * a generated `org_…` handle when omitted. * * @param db - The database. * @param input - The organization 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('organizations') .values({ createdAt: now, createdBy: input.createdBy ?? null, id: input.id ?? Id.generate('org'), name: input.name, sponsorshipSubsidyDurationDays: null, sponsorshipSubsidyProjectSpendLimit: null, updatedAt: now, userId: input.userId ?? null, }) .returningAll() .executeTakeFirstOrThrow() } export declare namespace create { /** Fields accepted when inserting an organization. */ type Input = { /** Identity creating the organization (e.g. admin email), recorded for audit. */ createdBy?: string | undefined /** Explicit id; a generated `org_…` handle is used when omitted. */ id?: string | undefined /** Human-readable organization name. */ name: string /** Owning user id (`usr_…`); null for organizations created by the super admin. */ userId?: string | undefined } } /** * Inserts an organization, its owner membership, and initial billing sources * atomically. * * @param db - The database. * @param input - The organization to insert. * @returns The stored record. */ export function createOwned(db: Db.Db, input: createOwned.Input): Promise { const now = new Date().toISOString() return db.kysely.transaction().execute(async (trx) => { const record = await trx .insertInto('organizations') .values({ createdAt: now, createdBy: input.userId, id: Id.generate('org'), name: input.name, sponsorshipSubsidyDurationDays: null, sponsorshipSubsidyProjectSpendLimit: null, updatedAt: now, userId: input.userId, }) .returningAll() .executeTakeFirstOrThrow() await trx .insertInto('memberships') .values({ createdAt: now, orgId: record.id, role: 'owner', updatedAt: now, userId: input.userId, }) .execute() if (input.enabledBillingSources?.length) await trx .insertInto('enabled_billing_sources') .values( input.enabledBillingSources.map((source) => ({ createdAt: now, createdBy: input.userId, orgId: record.id, source, })), ) .execute() return record }) } export declare namespace createOwned { /** Fields accepted when inserting a user-owned organization. */ type Input = { /** Billing sources enabled when the organization is created. */ enabledBillingSources?: readonly EnabledBillingSources.Source[] | undefined /** Human-readable organization name. */ name: string /** Owning user id (`usr_…`), granted the `owner` membership. */ userId: string } } /** * Deletes an organization and its owned rows atomically. * * @param db - The database. * @param id - The organization id (`org_…`). * @returns Whether the organization existed and was deleted. */ export function deleteOrganization(db: Db.Db, id: string): Promise { return db.kysely.transaction().execute(async (trx) => { const failedSince = new Date( Date.now() - SponsoredTransactions.failedIntentRecoveryTtlMs, ).toISOString() const unreported = await trx .selectFrom('sponsored_transactions') .select('id') .where('orgId', '=', id) .where('billable', '=', true) .where('chainId', '=', Viem.chainId.mainnet) .where('meterReportedAt', 'is', null) .where(unreportedSponsorshipStatus(failedSince)) .limit(1) .executeTakeFirst() if (unreported) throw new UnreportedSponsorshipsError() await trx .deleteFrom('webhook_subscriptions') .where('ownerType', '=', 'api_key') .where('ownerId', '=', id) .execute() await trx.deleteFrom('billing_settings').where('orgId', '=', id).execute() await trx.deleteFrom('enabled_billing_sources').where('orgId', '=', id).execute() await trx.deleteFrom('invitations').where('orgId', '=', id).execute() await trx.deleteFrom('invite_link_redemptions').where('orgId', '=', id).execute() await trx.deleteFrom('invite_links').where('orgId', '=', id).execute() await trx.deleteFrom('memberships').where('orgId', '=', id).execute() await trx.deleteFrom('projects').where('orgId', '=', id).execute() await trx.deleteFrom('request_usage_buckets').where('orgId', '=', id).execute() await trx.deleteFrom('request_usage_meter_events').where('orgId', '=', id).execute() await trx.deleteFrom('stripe_customers').where('orgId', '=', id).execute() const result = await trx.deleteFrom('organizations').where('id', '=', id).executeTakeFirst() return result.numDeletedRows > 0n }) } /** Whether an organization has billable sponsorships awaiting settlement. */ export async function hasUnreportedSponsorships(db: Db.Db, id: string): Promise { const failedSince = new Date( Date.now() - SponsoredTransactions.failedIntentRecoveryTtlMs, ).toISOString() const record = await db.kysely .selectFrom('sponsored_transactions') .select('id') .where('orgId', '=', id) .where('billable', '=', true) .where('chainId', '=', Viem.chainId.mainnet) .where('meterReportedAt', 'is', null) .where(unreportedSponsorshipStatus(failedSince)) .limit(1) .executeTakeFirst() return record !== undefined } /** * Reads an organization by id. * * @param db - The database. * @param id - The organization id (`org_…`). * @returns The record, or `undefined` when absent. */ export function get(db: Db.Db, id: string): Promise { return db.kysely.selectFrom('organizations').selectAll().where('id', '=', id).executeTakeFirst() } /** * Reads organizations by id for operational attribution displays. * * @param db - The database. * @param ids - Organization ids to read. * @returns Matching organization records. */ export function listByIds(db: Db.Db, ids: readonly string[]): Promise { if (ids.length === 0) return Promise.resolve([]) return db.kysely .selectFrom('organizations') .selectAll() .where('id', 'in', [...ids]) .execute() } /** * Reads an organization with current member and project counts. * * @param db - The database. * @param id - The organization id. * @returns The organization summary, or `undefined` when absent. */ export async function getSummary(db: Db.Db, id: string): Promise { const record = await db.kysely .selectFrom('organizations') .selectAll('organizations') .select((eb) => [ eb .selectFrom('memberships') .select((builder) => builder.fn.countAll().as('count')) .whereRef('memberships.orgId', '=', 'organizations.id') .as('memberCount'), eb .selectFrom('projects') .select((builder) => builder.fn.countAll().as('count')) .whereRef('projects.orgId', '=', 'organizations.id') .as('projectCount'), ]) .where('organizations.id', '=', id) .executeTakeFirst() if (!record) return undefined return { ...record, memberCount: Number(record.memberCount), projectCount: Number(record.projectCount), } } /** * Lists organizations, newest first. * * @param db - The database. * @returns The records. */ export function list(db: Db.Db): Promise { return db.kysely.selectFrom('organizations').selectAll().orderBy('createdAt', 'desc').execute() } /** * Searches organizations for the admin lookup surface, newest first. Results * include member and project counts and use a stable `(createdAt, id)` keyset. * * @param db - The database. * @param options - Search, keyset, and page-size options. * @returns At most `limit + 1` rows so the caller can derive a next cursor. */ export async function search(db: Db.Db, options: search.Options): Promise { const { cursor, limit, query: searchQuery } = options let query = db.kysely .selectFrom('organizations') .selectAll('organizations') .select((eb) => [ eb .selectFrom('memberships') .select((builder) => builder.fn.countAll().as('count')) .whereRef('memberships.orgId', '=', 'organizations.id') .as('memberCount'), eb .selectFrom('projects') .select((builder) => builder.fn.countAll().as('count')) .whereRef('projects.orgId', '=', 'organizations.id') .as('projectCount'), ]) if (searchQuery?.startsWith('org_')) query = query.where('organizations.id', '=', searchQuery) else if (searchQuery?.startsWith('usr_')) query = query.where((eb) => eb.or([ eb('organizations.userId', '=', searchQuery), eb( 'organizations.id', 'in', eb .selectFrom('memberships') .select('memberships.orgId') .where('memberships.userId', '=', searchQuery), ), ]), ) else if (searchQuery) { const pattern = `${searchQuery .toLowerCase() .replaceAll('\\', '\\\\') .replaceAll('%', '\\%') .replaceAll('_', '\\_')}%` query = query.where((eb) => eb.or([ sql`lower(${sql.ref('organizations.name')}) LIKE ${pattern} ESCAPE '\\'`, eb( 'organizations.id', 'in', eb .selectFrom('memberships') .innerJoin('users', 'users.id', 'memberships.userId') .select('memberships.orgId') .where(sql`lower(${sql.ref('users.email')}) LIKE ${pattern} ESCAPE '\\'`), ), ]), ) } if (cursor) query = query.where((eb) => eb.or([ eb('organizations.createdAt', '<', cursor.createdAt), eb.and([ eb('organizations.createdAt', '=', cursor.createdAt), eb('organizations.id', '<', cursor.id), ]), ]), ) const records = await query .orderBy('organizations.createdAt', 'desc') .orderBy('organizations.id', 'desc') .limit(limit + 1) .execute() return records.map((record) => ({ ...record, memberCount: Number(record.memberCount), projectCount: Number(record.projectCount), })) } export declare namespace search { /** Cursor fields for the last organization returned by the previous page. */ type Cursor = { /** Organization creation time. */ createdAt: string /** Organization id, used as a deterministic tie-breaker. */ id: string } /** Options for {@link search}. */ type Options = { /** Last organization returned by the previous page. */ cursor?: Cursor | undefined /** Requested page size. */ limit: number /** Exact resource id or case-insensitive name/email prefix search. */ query?: string | undefined } /** Organization summary returned by {@link search}. */ type Result = Record & { /** Number of current organization members. */ memberCount: number /** Number of projects owned by the organization. */ projectCount: number } } /** * Lists organizations owned by `userId`, newest first. * * @param db - The database. * @param userId - The owning user id (`usr_…`). * @returns The records. */ export function listByUser(db: Db.Db, userId: string): Promise { return db.kysely .selectFrom('organizations') .selectAll() .where('userId', '=', userId) .orderBy('createdAt', 'desc') .execute() } /** * Lists organizations `userId` is a member of (any role), newest first. Each * row carries the caller's membership `role`. * * @param db - The database. * @param userId - The member user id (`usr_…`). * @returns The records. */ export function listByMember( db: Db.Db, userId: string, ): Promise<(Record & { role: Memberships.Role })[]> { return db.kysely .selectFrom('organizations') .innerJoin('memberships', 'memberships.orgId', 'organizations.id') .where('memberships.userId', '=', userId) .selectAll('organizations') .select('memberships.role') .orderBy('organizations.createdAt', 'desc') .execute() } /** * Renames an organization, bumping `updatedAt`. * * @param db - The database. * @param id - The organization id (`org_…`). * @param input - The fields to update. * @returns The updated record, or `undefined` when absent. */ export function update(db: Db.Db, id: string, input: update.Input): Promise { return db.kysely .updateTable('organizations') .set({ name: input.name, updatedAt: new Date().toISOString() }) .where('id', '=', id) .returningAll() .executeTakeFirst() } export declare namespace update { /** Mutable organization fields. */ type Input = { /** New human-readable organization name. */ name: string } } /** * Replaces the organization's sponsorship subsidy policy. A null duration * disables the program; an enabled policy may omit its per-project spend cap. * * @param db - The database. * @param id - The organization id (`org_…`). * @param input - The replacement subsidy policy. * @returns The updated organization, or `undefined` when absent. */ export function setSponsorshipSubsidy( db: Db.Db, id: string, input: setSponsorshipSubsidy.Input, ): Promise { return db.kysely .updateTable('organizations') .set({ sponsorshipSubsidyDurationDays: input.durationDays, sponsorshipSubsidyProjectSpendLimit: input.projectSpendLimit, updatedAt: new Date().toISOString(), }) .where('id', '=', id) .returningAll() .executeTakeFirst() } export declare namespace setSponsorshipSubsidy { /** Replacement organization subsidy policy. */ type Input = | { /** Fixed promotion duration in days. */ durationDays: 90 /** Optional decimal USD spend cap applied independently to each project. */ projectSpendLimit: string | null } | { /** Null disables the promotion. */ durationDays: null /** Null disables the promotion. */ projectSpendLimit: null } } /** * Inserts an organization when absent (`ON CONFLICT DO NOTHING`) — the * mint/backfill path, where a key's `orgId` must resolve to a real row. The * `name` defaults to the id for degenerate orgs created by the mint fallback. * * @param db - The database. * @param input - The organization to ensure. */ export async function upsert(db: Db.Db, input: upsert.Input): Promise { const now = new Date().toISOString() await db.kysely .insertInto('organizations') .values({ createdAt: now, createdBy: input.createdBy ?? null, id: input.id, name: input.name ?? input.id, sponsorshipSubsidyDurationDays: null, sponsorshipSubsidyProjectSpendLimit: null, updatedAt: now, }) .onConflict((oc) => oc.column('id').doNothing()) .execute() } export declare namespace upsert { /** Fields accepted when ensuring an organization exists. */ type Input = { /** Identity creating the organization, recorded for audit. */ createdBy?: string | undefined /** The organization id (`org_…`, or a mint-fallback key id). */ id: string /** Human-readable organization name; defaults to the id. */ name?: string | undefined } } /** Blocks rows that can still settle or recover into billable mainnet spend. */ function unreportedSponsorshipStatus(failedSince: string) { return (eb: ExpressionBuilder) => eb.or([ eb('status', '!=', 'failed'), eb.and([ eb('status', '=', 'failed'), eb('transactionHash', 'is', null), eb('finalizedAt', '>=', failedSince), ]), ]) } /** Organization deletion is blocked until billable sponsorships settle. */ export class UnreportedSponsorshipsError extends Error { override name = 'Organizations.UnreportedSponsorshipsError' }