diff --git a/.agents/skills/prisma-database-setup/SKILL.md b/.agents/skills/prisma-database-setup/SKILL.md deleted file mode 100644 index 51643e744..000000000 --- a/.agents/skills/prisma-database-setup/SKILL.md +++ /dev/null @@ -1,192 +0,0 @@ ---- -name: prisma-database-setup -description: Guides for configuring Prisma with different database providers (PostgreSQL, MySQL, SQLite, MongoDB, etc.). Use when setting up a new project, changing databases, or troubleshooting connection issues. Triggers on "configure postgres", "connect to mysql", "setup mongodb", "sqlite setup". -license: MIT -metadata: - author: prisma - version: "7.6.0" ---- - -# Prisma Database Setup - -Comprehensive guides for configuring Prisma ORM with various database providers. - -## When to Apply - -Reference this skill when: -- Initializing a new Prisma project -- Switching database providers -- Configuring connection strings and environment variables -- Troubleshooting database connection issues -- Setting up database-specific features -- Generating and instantiating Prisma Client - -## Rule Categories by Priority - -| Priority | Category | Impact | Prefix | -|----------|----------|--------|--------| -| 1 | Provider Guides | CRITICAL | provider names | -| 2 | Prisma Postgres | HIGH | `prisma-postgres` | -| 3 | Client Setup | CRITICAL | `prisma-client-setup` | - -## System Prerequisites - -- **Node.js 20.19.0+** -- **TypeScript 5.4.0+** - -## Bun Runtime - -If you're using Bun, run Prisma CLI commands with `bunx --bun prisma ...` so Prisma uses the Bun runtime instead of falling back to Node.js. - -## Supported Databases - -| Database | Provider String | Notes | -|----------|-----------------|-------| -| PostgreSQL | `postgresql` | Default, full feature support | -| MySQL | `mysql` | Widespread support, some JSON diffs | -| SQLite | `sqlite` | Local file-based, no enum/scalar lists | -| MongoDB | `mongodb` | Mongo-specific workflow; do not apply SQL driver-adapter guidance | -| SQL Server | `sqlserver` | Microsoft ecosystem | -| CockroachDB | `cockroachdb` | Distributed SQL, Postgres-compatible | -| Prisma Postgres | `postgresql` | Managed serverless database | - -## Configuration Files - -Your configuration shape depends on the provider and Prisma major version: - -1. **All providers** use **`prisma/schema.prisma`**. -2. **Prisma 7 SQL setups** typically use **`prisma.config.ts`** for datasource URLs. -3. **MongoDB projects should stay on Prisma 6.x**, keep `url = env("DATABASE_URL")` in the schema, and continue using the classic MongoDB setup. - -## Driver Adapters - -The standard SQL workflow uses a driver adapter. Choose the adapter and driver for your database and pass the adapter to `PrismaClient`. - -| Database | Adapter | JS Driver | -|----------|---------|-----------| -| PostgreSQL | `@prisma/adapter-pg` | `pg` | -| CockroachDB | `@prisma/adapter-pg` | `pg` | -| Prisma Postgres (Node.js) | `@prisma/adapter-pg` | `pg` | -| Prisma Postgres (edge/serverless) | `@prisma/adapter-ppg` | `@prisma/ppg` | -| MySQL / MariaDB | `@prisma/adapter-mariadb` | `mariadb` | -| SQLite | `@prisma/adapter-better-sqlite3` | `better-sqlite3` | -| SQLite (Turso/LibSQL) | `@prisma/adapter-libsql` | `@libsql/client` | -| SQL Server | `@prisma/adapter-mssql` | `node-mssql` | - -MongoDB should not follow the Prisma 7 SQL adapter workflow. Use the latest Prisma 6.x release for MongoDB projects and do not install a SQL `@prisma/adapter-*` package for it. - -Example (PostgreSQL): - -```ts -import 'dotenv/config' -import { PrismaClient } from '../generated/client' -import { PrismaPg } from '@prisma/adapter-pg' - -const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }) -const prisma = new PrismaClient({ adapter }) -``` - -## Prisma Client Setup (Required) - -Prisma Client must be installed and generated for any database. - -1. Install Prisma CLI and Prisma Client: - ```bash - npm install prisma --save-dev - npm install @prisma/client - ``` - -1. Add a generator block (`prisma-client` requires an explicit output path): - ```prisma - generator client { - provider = "prisma-client" - output = "../generated" - } - ``` - -1. Generate Prisma Client: - ```bash - npx prisma generate - ``` - -1. For SQL providers, instantiate Prisma Client with the database-specific driver adapter: - ```typescript - import { PrismaClient } from '../generated/client' - import { PrismaPg } from '@prisma/adapter-pg' - - const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }) - const prisma = new PrismaClient({ adapter }) - ``` - -1. Re-run `prisma generate` after every schema change. - -## Quick Reference - -### PostgreSQL -```prisma -datasource db { - provider = "postgresql" -} - -generator client { - provider = "prisma-client" - output = "../generated" -} -``` - -### MySQL -```prisma -datasource db { - provider = "mysql" -} - -generator client { - provider = "prisma-client" - output = "../generated" -} -``` - -### SQLite -```prisma -datasource db { - provider = "sqlite" -} - -generator client { - provider = "prisma-client" - output = "../generated" -} -``` - -### MongoDB -```prisma -datasource db { - provider = "mongodb" - url = env("DATABASE_URL") -} - -generator client { - provider = "prisma-client-js" -} -``` - -For MongoDB, stay on the latest Prisma 6.x line and keep the connection URL in `schema.prisma`. Do not move a MongoDB project to the Prisma 7 SQL adapter setup. If a MongoDB project asks about upgrading Prisma versions, route to the `prisma-mongodb-upgrade` skill (stay-on-v6 vs Prisma Next is the real decision; Prisma 7 is not an option). - -## Rule Files - -See individual rule files for detailed setup instructions: - -``` -references/postgresql.md -references/mysql.md -references/sqlite.md -references/mongodb.md -references/sqlserver.md -references/cockroachdb.md -references/prisma-postgres.md -references/prisma-client-setup.md -``` - -## How to Use - -Choose the provider reference file for your database, then apply `references/prisma-client-setup.md` to complete client generation and adapter setup. For MongoDB, use `references/mongodb.md` instead of copying the SQL adapter examples or Prisma 7 config pattern. diff --git a/.agents/skills/prisma-database-setup/references/cockroachdb.md b/.agents/skills/prisma-database-setup/references/cockroachdb.md deleted file mode 100644 index 38d38597a..000000000 --- a/.agents/skills/prisma-database-setup/references/cockroachdb.md +++ /dev/null @@ -1,89 +0,0 @@ -# CockroachDB Setup - -Configure Prisma with CockroachDB. - -## Prerequisites - -- CockroachDB cluster - -## 1. Schema Configuration - -In `prisma/schema.prisma`: - -```prisma -datasource db { - provider = "cockroachdb" -} - -generator client { - provider = "prisma-client" - output = "../generated" -} -``` - -## 2. Config Configuration - -In `prisma.config.ts`: - -```typescript -import { defineConfig, env } from 'prisma/config' - -export default defineConfig({ - schema: 'prisma/schema.prisma', - datasource: { - url: env('DATABASE_URL'), - }, -}) -``` - -## 3. Environment Variable - -In `.env`: - -```env -DATABASE_URL="postgresql://user:password@host:26257/db?sslmode=verify-full" -``` - -Note: CockroachDB uses the PostgreSQL wire protocol, so the URL often looks like postgresql, but the provider **MUST** be `cockroachdb` in the schema to handle specific CRDB features correctly. - -## Driver Adapter - -Use a driver adapter for the standard SQL workflow. CockroachDB is PostgreSQL-compatible, so use the PostgreSQL adapter. - -1. Install adapter and driver: - ```bash - npm install @prisma/adapter-pg pg - ``` - -2. Instantiate Prisma Client with the adapter: - ```typescript - import 'dotenv/config' - import { PrismaClient } from '../generated/client' - import { PrismaPg } from '@prisma/adapter-pg' - - const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }) - const prisma = new PrismaClient({ adapter }) - ``` - -## ID Generation - -CockroachDB uses `BigInt` or `UUID` for IDs efficiently. - -```prisma -model User { - id BigInt @id @default(autoincrement()) // Uses unique_rowid() -} -``` - -Or using string UUIDs: - -```prisma -model User { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid -} -``` - -## Common Issues - -### Schema Introspection -Always use `provider = "cockroachdb"` to ensure correct type mapping during `db pull`. diff --git a/.agents/skills/prisma-database-setup/references/mongodb.md b/.agents/skills/prisma-database-setup/references/mongodb.md deleted file mode 100644 index 29dc2a0e9..000000000 --- a/.agents/skills/prisma-database-setup/references/mongodb.md +++ /dev/null @@ -1,90 +0,0 @@ -# MongoDB Setup - -MongoDB projects should stay on the latest Prisma 6.x release. Do not upgrade a MongoDB app to Prisma 7's SQL client path. - -## Prerequisites - -- MongoDB 4.2+ -- Replica Set configured (required for transactions) -- Latest Prisma 6.x release, or your team's pinned Prisma 6 version -- Node.js 20.19.0+ -- TypeScript 5.4.0+ - -## 1. Schema Configuration - -Use the standard Prisma 6 MongoDB setup with `prisma-client-js`. - -In `prisma/schema.prisma`: - -```prisma -datasource db { - provider = "mongodb" - url = env("DATABASE_URL") -} - -generator client { - provider = "prisma-client-js" -} -``` - -### Driver Adapters - -Do **not** apply the Prisma 7 SQL adapter setup here. MongoDB does not use a SQL `@prisma/adapter-*` package. - -### ID Field Requirement - -MongoDB models **must** have a mapped `_id` field using `@id` and `@map("_id")`, usually of type `String` with `auto()` and `db.ObjectId`. - -```prisma -model User { - id String @id @default(auto()) @map("_id") @db.ObjectId - email String @unique - name String? -} -``` - -### Relations - -Relations in MongoDB expect IDs to be `db.ObjectId` type. - -```prisma -model Post { - id String @id @default(auto()) @map("_id") @db.ObjectId - author User @relation(fields: [authorId], references: [id]) - authorId String @db.ObjectId -} -``` - -## 2. Environment Variable - -In `.env`: - -```env -DATABASE_URL="mongodb+srv://user:password@cluster.mongodb.net/mydb?retryWrites=true&w=majority" -``` - -## Migrations vs Introspection - -- **No Migrations**: MongoDB is schema-less. `prisma migrate` commands **do not work**. -- **db push**: Use `prisma db push` to sync indexes and constraints. -- **db pull**: Use `prisma db pull` to generate schema from existing data (sampling). - -## Current Verification Notes - -- `prisma init --datasource-provider mongodb` is still implemented in Prisma's CLI source. -- Prisma's upstream repo still contains MongoDB fixtures and tests. -- Local verification shows Prisma 7 can still recognize MongoDB inputs, but the generated client path does not provide a supported MongoDB upgrade path. -- Local verification shows Prisma 6.x works end to end with `prisma-client-js`, `prisma db push`, and `new PrismaClient()` against a MongoDB replica set. - -## Version Guidance - -- For MongoDB, stay on the latest available Prisma 6.x release. -- Treat Prisma 7 MongoDB migration attempts as unsupported until Prisma ships a real MongoDB upgrade path. - -## Common Issues - -### "Transactions not supported" -Ensure your MongoDB instance is a **Replica Set**. Standalone instances do not support transactions. Atlas clusters are replica sets by default. - -### "Invalid ObjectID" -Ensure fields referencing IDs are decorated with `@db.ObjectId` if the target is an ObjectID. diff --git a/.agents/skills/prisma-database-setup/references/mysql.md b/.agents/skills/prisma-database-setup/references/mysql.md deleted file mode 100644 index eb67b186c..000000000 --- a/.agents/skills/prisma-database-setup/references/mysql.md +++ /dev/null @@ -1,126 +0,0 @@ -# MySQL Setup - -Configure Prisma with MySQL (or MariaDB). - -## Prerequisites - -- MySQL or MariaDB database -- Connection string - -## 1. Schema Configuration - -In `prisma/schema.prisma`: - -```prisma -datasource db { - provider = "mysql" -} - -generator client { - provider = "prisma-client" - output = "../generated" -} -``` - -## 2. Config Configuration - -In `prisma.config.ts`: - -```typescript -import { defineConfig, env } from 'prisma/config' - -export default defineConfig({ - schema: 'prisma/schema.prisma', - datasource: { - url: env('DATABASE_URL'), - }, -}) -``` - -## 3. Environment Variable - -In `.env`: - -```env -DATABASE_URL="mysql://user:password@localhost:3306/mydb" -``` - -### Connection String Format - -``` -mysql://USER:PASSWORD@HOST:PORT/DATABASE -``` - -- **USER**: Database user -- **PASSWORD**: Password -- **HOST**: Hostname -- **PORT**: Port (default 3306) -- **DATABASE**: Database name - -## Driver Adapter - -Use a driver adapter for the standard SQL workflow. - -1. Install adapter and driver: - ```bash - npm install @prisma/adapter-mariadb mariadb - ``` - -2. Instantiate Prisma Client with the adapter: - ```typescript - import 'dotenv/config' - import { PrismaClient } from '../generated/client' - import { PrismaMariaDb } from '@prisma/adapter-mariadb' - - const adapter = new PrismaMariaDb({ - host: 'localhost', - port: 3306, - connectionLimit: 5, - user: process.env.MYSQL_USER, - password: process.env.MYSQL_PASSWORD, - database: process.env.MYSQL_DATABASE, - }) - - const prisma = new PrismaClient({ adapter }) - ``` - -### Text protocol option - -If you need the MariaDB driver's text protocol instead of the default binary `execute()` path, enable `useTextProtocol` explicitly: - -```typescript -import { PrismaClient } from '../generated/client' -import { PrismaMariaDb } from '@prisma/adapter-mariadb' - -const adapter = new PrismaMariaDb(process.env.DATABASE_URL!, { - useTextProtocol: true, -}) - -const prisma = new PrismaClient({ adapter }) -``` - -Use this only when you specifically need text-protocol compatibility for your MariaDB setup. - -## PlanetScale Setup - -PlanetScale uses MySQL but requires specific settings because it doesn't support foreign key constraints. - -In `prisma/schema.prisma`: - -```prisma -datasource db { - provider = "mysql" - relationMode = "prisma" // Emulate foreign keys in Prisma -} -``` - -## Common Issues - -### "Too many connections" -MySQL has a connection limit. Adjust connection pool size in URL: -```env -DATABASE_URL="mysql://...?connection_limit=5" -``` - -### JSON Support -MySQL 5.7+ supports JSON. MariaDB 10.2+ supports JSON (as an alias for LONGTEXT with check constraints). Prisma handles this, but verify your version. diff --git a/.agents/skills/prisma-database-setup/references/postgresql.md b/.agents/skills/prisma-database-setup/references/postgresql.md deleted file mode 100644 index ea575f683..000000000 --- a/.agents/skills/prisma-database-setup/references/postgresql.md +++ /dev/null @@ -1,92 +0,0 @@ -# PostgreSQL Setup - -Configure Prisma with PostgreSQL. - -## Prerequisites - -- PostgreSQL database (local or cloud) -- Connection string - -## 1. Schema Configuration - -In `prisma/schema.prisma`: - -```prisma -datasource db { - provider = "postgresql" -} - -generator client { - provider = "prisma-client" - output = "../generated" -} -``` - -## 2. Config Configuration - -In `prisma.config.ts`: - -```typescript -import { defineConfig, env } from 'prisma/config' - -export default defineConfig({ - schema: 'prisma/schema.prisma', - datasource: { - url: env('DATABASE_URL'), - }, -}) -``` - -## 3. Environment Variable - -In `.env`: - -```env -DATABASE_URL="postgresql://user:password@localhost:5432/mydb?schema=public" -``` - -### Connection String Format - -``` -postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=SCHEMA -``` - -- **USER**: Database user -- **PASSWORD**: Password (URL encoded if special chars) -- **HOST**: Hostname (localhost, IP, or domain) -- **PORT**: Port (default 5432) -- **DATABASE**: Database name -- **SCHEMA**: Schema name (default `public`) - -## Driver Adapter - -Use a driver adapter for the standard SQL workflow. - -1. Install adapter and driver: - ```bash - npm install @prisma/adapter-pg pg - ``` - -2. Instantiate Prisma Client with the adapter: - ```typescript - import 'dotenv/config' - import { PrismaClient } from '../generated/client' - import { PrismaPg } from '@prisma/adapter-pg' - - const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }) - const prisma = new PrismaClient({ adapter }) - ``` - -## Common Issues - -### "Can't reach database server" -- Check host and port -- Check firewall settings -- Ensure database is running - -### "Authentication failed" -- Check user/password -- Special characters in password must be URL-encoded - -### "Schema does not exist" -- Ensure `?schema=public` (or your schema) is in the URL diff --git a/.agents/skills/prisma-database-setup/references/prisma-client-setup.md b/.agents/skills/prisma-database-setup/references/prisma-client-setup.md deleted file mode 100644 index bb3693223..000000000 --- a/.agents/skills/prisma-database-setup/references/prisma-client-setup.md +++ /dev/null @@ -1,47 +0,0 @@ -# Prisma Client Setup - -Generate and instantiate Prisma Client for Prisma's standard SQL provider workflow. For MongoDB, follow the provider-specific notes in `references/mongodb.md` instead of copying the SQL adapter example below. - -## 1. Install dependencies - -```bash -npm install prisma --save-dev -npm install @prisma/client -``` - -## 2. Add generator block - -In `prisma/schema.prisma`: - -```prisma -generator client { - provider = "prisma-client" - output = "../generated" -} -``` - -`prisma-client` requires an explicit `output` path and does not generate into `node_modules` by default. - -## 3. Generate Prisma Client - -```bash -npx prisma generate -``` - -Re-run `prisma generate` after every schema change to keep the client in sync. - -## 4. Instantiate Prisma Client - -```typescript -import { PrismaClient } from '../generated/client' -import { PrismaPg } from '@prisma/adapter-pg' - -const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }) -const prisma = new PrismaClient({ adapter }) -``` - -If you change the generator `output`, update the import path to match. For the SQL provider workflow, replace `PrismaPg` with the adapter for your database. - -## 5. Use a single instance - -Each `PrismaClient` instance creates a connection pool. Reuse a single instance per app process to avoid exhausting database connections. diff --git a/.agents/skills/prisma-database-setup/references/prisma-postgres.md b/.agents/skills/prisma-database-setup/references/prisma-postgres.md deleted file mode 100644 index dc939e5d9..000000000 --- a/.agents/skills/prisma-database-setup/references/prisma-postgres.md +++ /dev/null @@ -1,130 +0,0 @@ -# Prisma Postgres Setup - -Configure Prisma with Prisma Postgres (Managed). - -## Overview - -Prisma Postgres is a serverless, managed PostgreSQL database optimized for Prisma. - -## Setup via CLI - -You can provision a Prisma Postgres instance directly via the CLI: - -```bash -prisma init --db -``` - -This will: -1. Log you into Prisma Data Platform. -2. Create a new project and database instance. -3. Update your `.env` with the connection string. - -## Connection String - -For Prisma CLI flows and Accelerate-style usage, you may see a `prisma+postgres://` URL. - -For Prisma Client with a driver adapter in Node.js, prefer the direct TCP connection string from the Prisma Postgres dashboard: - -```env -DATABASE_URL="postgres://identifier:key@db.prisma.io:5432/postgres?sslmode=require" -``` - -## 1. Schema Configuration - -In `prisma/schema.prisma`: - -```prisma -datasource db { - provider = "postgresql" // Use postgresql provider -} - -generator client { - provider = "prisma-client" - output = "../generated" -} -``` - -## 2. Config Configuration - -In `prisma.config.ts`: - -```typescript -import { defineConfig, env } from 'prisma/config' - -export default defineConfig({ - schema: 'prisma/schema.prisma', - datasource: { - url: env('DATABASE_URL'), - }, -}) -``` - -## Driver Adapter - -Use a driver adapter for Prisma Postgres in the standard SQL workflow. - -### Recommended for standard Node.js apps - -1. Install adapter and driver: - ```bash - npm install @prisma/adapter-pg pg - ``` - -2. Use the direct TCP connection string from Prisma Console: - ```typescript - import 'dotenv/config' - import { PrismaClient } from '../generated/client' - import { PrismaPg } from '@prisma/adapter-pg' - - const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }) - const prisma = new PrismaClient({ adapter }) - ``` - -`PrismaPg` also accepts the connection string directly: - -```typescript -const adapter = new PrismaPg(process.env.DATABASE_URL!) -const prisma = new PrismaClient({ adapter }) -``` - -For PostgreSQL prepared statement naming, pass adapter options as the second argument: - -```typescript -import { createHash } from 'node:crypto' - -const adapter = new PrismaPg(process.env.DATABASE_URL!, { - statementNameGenerator: ({ sql }) => - `prisma_${createHash('sha1').update(sql).digest('hex').slice(0, 16)}`, -}) -``` - -### Edge/serverless option - -Use the Prisma Postgres serverless driver only when you need HTTP/WebSocket transport in environments like Workers or Edge Functions: - -```bash -npm install @prisma/adapter-ppg @prisma/ppg -``` - -```typescript -import { PrismaClient } from '../generated/client' -import { PrismaPostgresAdapter } from '@prisma/adapter-ppg' - -const prisma = new PrismaClient({ - adapter: new PrismaPostgresAdapter({ - connectionString: process.env.PRISMA_DIRECT_TCP_URL, - }), -}) -``` - -This serverless driver is the specialized path for HTTP/WebSocket-based edge and serverless runtimes, not the default recommendation for standard Node.js apps. - -## Features - -- **Serverless**: Scales to zero. -- **Caching**: Integrated query caching (Accelerate). -- **Real-time**: Database events (Pulse). - -## Using with Prisma Client - -Use the Prisma Postgres adapter shown above when instantiating Prisma Client. diff --git a/.agents/skills/prisma-database-setup/references/sqlite.md b/.agents/skills/prisma-database-setup/references/sqlite.md deleted file mode 100644 index 30e3e0040..000000000 --- a/.agents/skills/prisma-database-setup/references/sqlite.md +++ /dev/null @@ -1,106 +0,0 @@ -# SQLite Setup - -Configure Prisma with SQLite. - -## Prerequisites - -- None (file-based) - -## 1. Schema Configuration - -In `prisma/schema.prisma`: - -```prisma -datasource db { - provider = "sqlite" -} - -generator client { - provider = "prisma-client" - output = "../generated" -} -``` - -## 2. Config Configuration - -In `prisma.config.ts`: - -```typescript -import { defineConfig, env } from 'prisma/config' - -export default defineConfig({ - schema: 'prisma/schema.prisma', - datasource: { - url: env('DATABASE_URL'), - }, -}) -``` - -## 3. Environment Variable - -In `.env`: - -```env -DATABASE_URL="file:./dev.db" -``` - -### Connection String Format - -``` -file:PATH -``` - -- **PATH**: Relative path to the database file. Check `prisma.config.ts` if you need to confirm how your app resolves it. - -## Driver Adapter - -Use a driver adapter for the standard SQL workflow. - -1. Install adapter and driver: - ```bash - npm install @prisma/adapter-better-sqlite3 better-sqlite3 - ``` - -2. Instantiate Prisma Client with the adapter: - ```typescript - import { PrismaClient } from '../generated/client' - import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3' - - const adapter = new PrismaBetterSqlite3({ - url: process.env.DATABASE_URL ?? 'file:./dev.db', - }) - - const prisma = new PrismaClient({ adapter }) - ``` - -## Using Driver Adapter (LibSQL / Turso) - -For edge compatibility or Turso: - -1. Install: - ```bash - npm install @prisma/adapter-libsql @libsql/client - ``` - -2. Instantiate: - ```typescript - import { PrismaClient } from '../generated/client' - import { PrismaLibSql } from '@prisma/adapter-libsql' - - const adapter = new PrismaLibSql({ - url: process.env.TURSO_DATABASE_URL, - authToken: process.env.TURSO_AUTH_TOKEN, - }) - const prisma = new PrismaClient({ adapter }) - ``` - -## Limitations - -- **No Enums**: SQLite doesn't support enums (Prisma polyfills them or treats as String). -- **No Scalar Lists**: `String[]` is not supported directly. -- **Concurrency**: Write operations lock the file. - -## Common Issues - -### "Database file not found" -Ensure the path in `DATABASE_URL` is correct relative to where Prisma is running or the schema file. `file:./dev.db` creates it next to schema. diff --git a/.agents/skills/prisma-database-setup/references/sqlserver.md b/.agents/skills/prisma-database-setup/references/sqlserver.md deleted file mode 100644 index b519d95b1..000000000 --- a/.agents/skills/prisma-database-setup/references/sqlserver.md +++ /dev/null @@ -1,94 +0,0 @@ -# SQL Server Setup - -Configure Prisma with Microsoft SQL Server. - -## Prerequisites - -- SQL Server 2017, 2019, 2022, or Azure SQL -- TCP/IP enabled - -## 1. Schema Configuration - -In `prisma/schema.prisma`: - -```prisma -datasource db { - provider = "sqlserver" -} - -generator client { - provider = "prisma-client" - output = "../generated" -} -``` - -## 2. Config Configuration - -In `prisma.config.ts`: - -```typescript -import { defineConfig, env } from 'prisma/config' - -export default defineConfig({ - schema: 'prisma/schema.prisma', - datasource: { - url: env('DATABASE_URL'), - }, -}) -``` - -## 3. Environment Variable - -In `.env`: - -```env -DATABASE_URL="sqlserver://localhost:1433;database=mydb;user=sa;password=Password123;encrypt=true;trustServerCertificate=true" -``` - -### Connection String Format - -``` -sqlserver://HOST:PORT;database=DB;user=USER;password=PASS;encrypt=true;trustServerCertificate=true -``` - -- **encrypt**: Required for Azure (true). -- **trustServerCertificate**: True for self-signed certs (local dev). - -## Driver Adapter - -Use a driver adapter for the standard SQL workflow. - -1. Install adapter and driver: - ```bash - npm install @prisma/adapter-mssql mssql - ``` - -2. Instantiate Prisma Client with the adapter: - ```typescript - import 'dotenv/config' - import { PrismaClient } from '../generated/client' - import { PrismaMssql } from '@prisma/adapter-mssql' - - const adapter = new PrismaMssql({ - server: 'localhost', - port: 1433, - database: 'mydb', - user: process.env.SQLSERVER_USER, - password: process.env.SQLSERVER_PASSWORD, - options: { - encrypt: true, - trustServerCertificate: true, - }, - }) - - const prisma = new PrismaClient({ adapter }) - ``` - -## Common Issues - -### "Login failed for user" -- SQL Server auth vs Windows auth. Prisma typically uses SQL Server authentication (username/password). -- Ensure TCP/IP is enabled in SQL Server Configuration Manager. - -### "Table not found" (dbo schema) -Prisma assumes `dbo` schema by default. If using another schema, update the model or connection string? SQL Server provider mostly sticks to default schema. diff --git a/.env.example b/.env.example index 800659842..bb015916e 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,8 @@ # Postgres DATABASE_URL="postgresql://postgres:postgres@localhost:5432/crm?schema=public" -# A direct, unpooled connection to that same database, used by -# `prisma migrate deploy` when the API is built on Vercel. Set it only if +# A direct, unpooled connection to that same database, used by the Drizzle +# migrations (`bun run db:deploy`) when the API is built on Vercel. Set it only if # DATABASE_URL points at a pooler; POSTGRES_URL_NON_POOLING is read as a # fallback. Locally there is nothing to set. # DIRECT_DATABASE_URL="" @@ -179,10 +179,10 @@ GOOGLE_CLIENT_SECRET="" # least 16 characters. # CRON_SECRET="" -# Log every SQL statement Prisma runs, at debug level, without bound +# Log every SQL statement Drizzle runs, at debug level, without bound # parameters. Off by default: it buries every other line under a wall of # SELECTs. -# PRISMA_LOG_QUERIES="true" +# DB_LOG_QUERIES="true" # ── Anonymous usage telemetry ──────────────────────────────────────────────── diff --git a/AGENTS.md b/AGENTS.md index 28489adf7..a1fa3d2f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ context until you read them, and the rules in them are not optional. | `.github/workflows`, versions, changelog, how a change reaches `release` | `CONTRIBUTING.md` | Also check `.agents/skills/` for a relevant skill before starting — better-auth, -prisma, nestjs-trpc, eve, shadcn, nuqs and others have one. Tell the user which +nestjs-trpc, eve, shadcn, nuqs and others have one. Tell the user which rules and skills you read. ## Always true @@ -92,7 +92,7 @@ Rules for the list: ## A server page computes. A client component renders. A client component must never import a server package. `@crm/auth` and `@crm/db` -are server packages: their barrels reach Prisma, which reaches `pg`, which +are server packages: their barrels reach Drizzle, which reaches `pg`, which reaches `dns`. The bundler follows that chain into the browser and the build fails with `Module not found: Can't resolve 'dns'`. @@ -183,7 +183,7 @@ export const DISPATCH = { ## Parse at the boundary, never pass `Record` around -Untyped data — a Prisma `Json` column, a webhook body, an API response — is +Untyped data — a `jsonb` column, a webhook body, an API response — is parsed into a **domain type at the moment it enters the process**, with Zod, in a module that owns that shape. Every consumer downstream receives the parsed type and nothing else. `Record`, `unknown` casts and one-off diff --git a/README.md b/README.md index 1fc750a2b..b1e342f77 100644 --- a/README.md +++ b/README.md @@ -174,13 +174,13 @@ A [Turborepo](https://turborepo.dev) monorepo on [Bun](https://bun.com), deploye | **Sandbox** | [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox) in production, Docker or microsandbox locally | | **Front end** | [Next.js](https://nextjs.org) App Router · [shadcn/ui](https://ui.shadcn.com) · [nuqs](https://nuqs.dev) for URL state | | **API** | [NestJS](https://nestjs.com) with [nestjs-trpc](https://nestjs-trpc.io) — HTTP, auth, tRPC, mailbox sync | -| **Data** | [Prisma](https://prisma.io) · Postgres ([Neon](https://neon.tech)) · optional Redis ([Upstash](https://upstash.com)) | +| **Data** | [Drizzle](https://orm.drizzle.team) · Postgres ([Neon](https://neon.tech)) · optional Redis ([Upstash](https://upstash.com)) | | **Auth** | [Better Auth](https://better-auth.com) — Google, Microsoft, or your own IdP; one allow-list | | **Files** | [Vercel Blob](https://vercel.com/docs/vercel-blob) — mirrors profile pictures so they survive the source going away | | **Tooling** | [Biome](https://biomejs.dev) · TypeScript everywhere | The app talks to the API over **tRPC**, and the router type is generated from the -NestJS routers — so the front end is type-safe from the Prisma row to the table cell. +NestJS routers — so the front end is type-safe from the Drizzle row to the table cell. List state (filters, sort, page) lives in the URL, so copying the address bar reproduces the view. @@ -191,7 +191,7 @@ reproduces the view. | `apps/agent` | The research agent — tools, skills, schedules, sandbox | | `apps/app` | Next.js front end · :3000 | | `apps/api` | NestJS API — HTTP, auth, tRPC, mailbox sync · :3001 | -| `packages/db` | Prisma schema, migrations, shared Postgres client | +| `packages/db` | Drizzle schema, migrations, shared Postgres client | | `packages/auth` | Better Auth config and the sign-in allow-list | | `packages/ui` | shadcn/ui components, the Tailwind theme | | `packages/env` | Finds and loads the root `.env` | @@ -351,7 +351,7 @@ short version: | `bun run lint` / `format` | [Biome](https://biomejs.dev) | | `bun run db:migrate` | Create and apply a migration | | `bun run db:seed` | Top up the demo pipeline (idempotent) | -| `bun run db:studio` | Prisma Studio | +| `bun run db:studio` | Drizzle Studio | | `bun run --filter=api trpc:generate` | Regenerate the `AppRouter` type | | `bun run --filter=api dev:session` | Print a one-click local sign-in link | diff --git a/apps/agent/.scratch/skip.ts b/apps/agent/.scratch/skip.ts index da180db3b..d1c2ae511 100644 --- a/apps/agent/.scratch/skip.ts +++ b/apps/agent/.scratch/skip.ts @@ -1,23 +1,24 @@ -import { db, EnrichmentStatus } from "@crm/db"; +import { + company as companyTable, + db, + EnrichmentStatus, + firstRow, + requireRow, + whereEq, +} from "@crm/db"; import { settle } from "../agent/lib/enrichment"; -const company = await db.company.create({ - data: { +const company = await requireRow(db.insert(companyTable).values({ name: "Settle Probe", domain: "settle-probe.test", enrichmentStatus: EnrichmentStatus.PENDING, - }, - select: { id: true, enrichmentStatus: true }, -}); + }).returning({ id: companyTable.id, enrichmentStatus: companyTable.enrichmentStatus })); console.log("created ", company.enrichmentStatus); // Exactly what runBrand does when there is no key: settle before anything marks it RUNNING. await settle({ companyId: company.id }, EnrichmentStatus.SKIPPED, "no key"); -const after = await db.company.findUnique({ - where: { id: company.id }, - select: { enrichmentStatus: true }, -}); +const after = await firstRow(db.select({ enrichmentStatus: companyTable.enrichmentStatus }).from(companyTable).where(whereEq(companyTable.id, company.id)).limit(1)); console.log("after settle(SKIP) ", after?.enrichmentStatus); console.log( "swept again? ", @@ -26,5 +27,5 @@ console.log( : "NO — stuck", ); -await db.company.delete({ where: { id: company.id } }); -await db.$disconnect(); +await requireRow(db.delete(companyTable).where(whereEq(companyTable.id, company.id)).returning()); +await db.$client.end(); diff --git a/apps/agent/agent/channels/crm.ts b/apps/agent/agent/channels/crm.ts index 593d0039e..50fd2faa6 100644 --- a/apps/agent/agent/channels/crm.ts +++ b/apps/agent/agent/channels/crm.ts @@ -1,5 +1,19 @@ import { timingSafeEqual } from "node:crypto"; -import { EnrichmentStatus, Prisma } from "@crm/db"; +import { + agentConversation, + agentRun, + agentTask, + and, + EnrichmentStatus, + eq, + firstRow, + isNull, + lt, + lte, + or, + sql, + whereEq, +} from "@crm/db"; import { MAX_ATTEMPTS } from "@crm/db/agent-tasks"; import { schemas } from "@crm/validation"; import { eveTurnFailure } from "@crm/validation/eve-stream"; @@ -143,14 +157,15 @@ export default defineChannel({ const health = dispatchHealth(); const { db } = await import("@crm/db"); const now = new Date(); - const overdue = await db.agentTask.count({ - where: { - finishedAt: null, - dueAt: { lte: new Date(now.getTime() - STALE_QUEUE_MS) }, - attempts: { lt: MAX_ATTEMPTS }, - OR: [{ leasedUntil: null }, { leasedUntil: { lt: now } }], - }, - }); + const overdue = await db.$count( + agentTask, + and( + isNull(agentTask.finishedAt), + lte(agentTask.dueAt, new Date(now.getTime() - STALE_QUEUE_MS)), + lt(agentTask.attempts, MAX_ATTEMPTS), + or(isNull(agentTask.leasedUntil), lt(agentTask.leasedUntil, now)), + ), + ); const wedged = health.stalledMs > DRAIN_TIMEOUT_MS; return Response.json( @@ -285,14 +300,19 @@ export default defineChannel({ if (!conversationId || !data.message?.trim()) return; await import("@crm/db").then(({ db }) => - db.agentConversation.updateMany({ - where: { id: conversationId, kind: "BUILDER" }, - data: { + db + .update(agentConversation) + .set({ lastAssistantAt: new Date(), lastMessageAt: new Date(), - messageCount: { increment: 1 }, - }, - }), + messageCount: sql`${agentConversation.messageCount} + 1`, + }) + .where( + and( + whereEq(agentConversation.id, conversationId), + eq(agentConversation.kind, "BUILDER"), + ), + ), ); }, @@ -303,10 +323,15 @@ export default defineChannel({ if (!conversationId) return; await import("@crm/db").then(({ db }) => - db.agentConversation.updateMany({ - where: { id: conversationId, kind: "BUILDER" }, - data: { continuationToken: builderToken(conversationId) }, - }), + db + .update(agentConversation) + .set({ continuationToken: builderToken(conversationId) }) + .where( + and( + whereEq(agentConversation.id, conversationId), + eq(agentConversation.kind, "BUILDER"), + ), + ), ); }, @@ -324,13 +349,18 @@ export default defineChannel({ const conversationId = builderIdFromToken(channel.continuation?.token); if (conversationId) { const { db } = await import("@crm/db"); - await db.agentConversation.updateMany({ - where: { id: conversationId, kind: "BUILDER" }, - data: { + await db + .update(agentConversation) + .set({ continuationToken: builderToken(conversationId), - pendingInputRequests: Prisma.DbNull, - }, - }); + pendingInputRequests: null, + }) + .where( + and( + whereEq(agentConversation.id, conversationId), + eq(agentConversation.kind, "BUILDER"), + ), + ); return; } @@ -344,10 +374,15 @@ export default defineChannel({ const conversationId = builderIdFromToken(channel.continuation?.token); if (conversationId) { const { db } = await import("@crm/db"); - await db.agentConversation.updateMany({ - where: { id: conversationId, kind: "BUILDER" }, - data: { pendingInputRequests: Prisma.DbNull }, - }); + await db + .update(agentConversation) + .set({ pendingInputRequests: null }) + .where( + and( + whereEq(agentConversation.id, conversationId), + eq(agentConversation.kind, "BUILDER"), + ), + ); return; } @@ -355,10 +390,17 @@ export default defineChannel({ if (!runId) return; const { db } = await import("@crm/db"); - const run = await db.agentRun.findUnique({ - where: { id: runId }, - select: { status: true, summary: true, result: true }, - }); + const run = await firstRow( + db + .select({ + status: agentRun.status, + summary: agentRun.summary, + result: agentRun.result, + }) + .from(agentRun) + .where(whereEq(agentRun.id, runId)) + .limit(1), + ); if (run?.status !== "RUNNING") return; try { @@ -389,13 +431,18 @@ export default defineChannel({ const conversationId = builderIdFromToken(channel.continuation?.token); if (conversationId) { const { db } = await import("@crm/db"); - await db.agentConversation.updateMany({ - where: { id: conversationId, kind: "BUILDER" }, - data: { + await db + .update(agentConversation) + .set({ continuationToken: builderToken(conversationId), - pendingInputRequests: Prisma.DbNull, - }, - }); + pendingInputRequests: null, + }) + .where( + and( + whereEq(agentConversation.id, conversationId), + eq(agentConversation.kind, "BUILDER"), + ), + ); return; } @@ -413,15 +460,20 @@ export default defineChannel({ const conversationId = builderIdFromToken(channel.continuation?.token); if (conversationId) { const { db } = await import("@crm/db"); - await db.agentConversation.updateMany({ - where: { id: conversationId, kind: "BUILDER" }, - data: { + await db + .update(agentConversation) + .set({ continuationToken: builderToken(conversationId), - pendingInputRequests: Prisma.DbNull, + pendingInputRequests: null, lastAssistantAt: new Date(), lastMessageAt: new Date(), - }, - }); + }) + .where( + and( + whereEq(agentConversation.id, conversationId), + eq(agentConversation.kind, "BUILDER"), + ), + ); return; } diff --git a/apps/agent/agent/hooks/audit.ts b/apps/agent/agent/hooks/audit.ts index 19fee331a..4a1d299a5 100644 --- a/apps/agent/agent/hooks/audit.ts +++ b/apps/agent/agent/hooks/audit.ts @@ -1,4 +1,26 @@ -import { db, type Prisma } from "@crm/db"; +import { + agentAction, + agentConversation, + agentConversationSubmission, + agentEvent, + agentRun, + agentRunEvent, + and, + Decimal, + db, + type Executor, + eq, + firstRow, + hasValues, + isNull, + type JsonObject, + mcpConnection, + not, + requireRow, + sql, + startsWith, + whereEq, +} from "@crm/db"; import { WORKSPACE_ID } from "@crm/db/workspace"; import { publicEveEventData } from "@crm/validation/eve-public-event"; import { defineHook } from "eve/hooks"; @@ -39,7 +61,7 @@ export default defineHook({ try { const archivedData = capMcpArchivedResult( event.type, - ("data" in event ? (event.data ?? {}) : {}) as Prisma.InputJsonObject, + ("data" in event ? (event.data ?? {}) : {}) as JsonObject, ); const emittedAt = event.meta?.at ? new Date(event.meta.at) : new Date(); const purpose = purposeOf(ctx); @@ -55,11 +77,14 @@ export default defineHook({ ...publicEveEventData(event.type, archivedData), encryptedData: null, }; - await db.$transaction(async (tx) => { - const existing = await tx.agentEvent.findUnique({ - where: { id }, - select: { id: true }, - }); + await db.transaction(async (tx) => { + const existing = await firstRow( + tx + .select({ id: agentEvent.id }) + .from(agentEvent) + .where(whereEq(agentEvent.id, id)) + .limit(1), + ); if (existing) return; const streamIndex = @@ -72,20 +97,23 @@ export default defineHook({ ) : null; - await tx.agentEvent.create({ - data: { - id, - sessionId: ctx.session.id, - streamIndex, - deliveryIds: [...(event.meta?.deliveryIds ?? [])], - contactId: currentFocus().contactId, - conversationId, - type: storedEvent.type, - data: storedEvent.data, - encryptedData: storedEvent.encryptedData, - emittedAt, - }, - }); + await requireRow( + tx + .insert(agentEvent) + .values({ + id, + sessionId: ctx.session.id, + streamIndex, + deliveryIds: [...(event.meta?.deliveryIds ?? [])], + contactId: currentFocus().contactId, + conversationId, + type: storedEvent.type, + data: storedEvent.data, + encryptedData: storedEvent.encryptedData, + emittedAt, + }) + .returning(), + ); if (purpose === "builder") { await persistBuilderLifecycle(tx, event, ctx.session.id, ctx); @@ -112,7 +140,7 @@ export default defineHook({ }); async function reserveBuilderStreamIndex( - tx: Prisma.TransactionClient, + tx: Executor, conversationId: string, sessionId: string, eventType: string, @@ -126,33 +154,37 @@ async function reserveBuilderStreamIndex( return null; } if (conversation.sessionId === sessionId) { - const legacyEvents = await tx.agentEvent.count({ - where: { - conversationId, - sessionId, - streamIndex: null, - NOT: { id: { startsWith: BUILDER_INPUT_EVENT_PREFIX } }, - }, - }); + const legacyEvents = await tx.$count( + agentEvent, + and( + whereEq(agentEvent.conversationId, conversationId), + whereEq(agentEvent.sessionId, sessionId), + isNull(agentEvent.streamIndex), + not(startsWith(agentEvent.id, BUILDER_INPUT_EVENT_PREFIX)), + ), + ); if (legacyEvents > 0) return null; } const streamIndex = conversation.streamIndex; - await tx.agentConversation.update({ - where: { id: conversationId }, - data: { - sessionId: - conversation.sessionId === null && eventType === "session.started" - ? sessionId - : undefined, - streamIndex: { increment: 1 }, - }, - }); + await requireRow( + tx + .update(agentConversation) + .set({ + sessionId: + conversation.sessionId === null && eventType === "session.started" + ? sessionId + : undefined, + streamIndex: sql`${agentConversation.streamIndex} + 1`, + }) + .where(whereEq(agentConversation.id, conversationId)) + .returning(), + ); return streamIndex; } async function persistBuilderLifecycle( - tx: Prisma.TransactionClient, + tx: Executor, event: { type: string }, sessionId: string, ctx: Parameters[0], @@ -161,28 +193,38 @@ async function persistBuilderLifecycle( if (!conversationId) return; if (event.type === "session.started" && isRootSession(ctx)) { - await tx.agentConversation.updateMany({ - where: { id: conversationId, kind: "BUILDER" }, - data: { sessionId, continuationToken: null }, - }); + await tx + .update(agentConversation) + .set({ sessionId, continuationToken: null }) + .where( + and( + whereEq(agentConversation.id, conversationId), + eq(agentConversation.kind, "BUILDER"), + ), + ); } if (event.type === "message.received") { const submissionId = attribute(ctx, "submissionId"); if (submissionId) { - await tx.agentConversationSubmission.updateMany({ - where: { id: submissionId, conversationId }, - data: { status: "ACCEPTED", acceptedAt: new Date() }, - }); + await tx + .update(agentConversationSubmission) + .set({ status: "ACCEPTED", acceptedAt: new Date() }) + .where( + and( + whereEq(agentConversationSubmission.id, submissionId), + whereEq(agentConversationSubmission.conversationId, conversationId), + ), + ); } } } async function persistRunEvent( - tx: Prisma.TransactionClient, + tx: Executor, eventId: string, type: string, - data: Prisma.InputJsonObject, + data: JsonObject, emittedAt: Date, ctx: Parameters[0] & { session: { id: string } }, ) { @@ -197,10 +239,13 @@ async function persistRunEvent( ) { return; } - const existing = await tx.agentRunEvent.findUnique({ - where: { id: eventId }, - select: { id: true }, - }); + const existing = await firstRow( + tx + .select({ id: agentRunEvent.id }) + .from(agentRunEvent) + .where(whereEq(agentRunEvent.id, eventId)) + .limit(1), + ); if (existing) return; const sequence = run.nextEventSequence + 1; @@ -208,68 +253,85 @@ async function persistRunEvent( type === "session.started" && isRootSession(ctx) && (run.status === "QUEUED" || run.status === "RUNNING"); - await tx.agentRun.update({ - where: { id: run.id }, - data: { - nextEventSequence: sequence, - sessionId: mayStart ? ctx.session.id : undefined, - status: mayStart ? "RUNNING" : undefined, - startedAt: mayStart ? (run.startedAt ?? new Date()) : undefined, - }, - }); + await requireRow( + tx + .update(agentRun) + .set({ + nextEventSequence: sequence, + sessionId: mayStart ? ctx.session.id : undefined, + status: mayStart ? "RUNNING" : undefined, + startedAt: mayStart ? (run.startedAt ?? new Date()) : undefined, + }) + .where(whereEq(agentRun.id, run.id)) + .returning(), + ); - await tx.agentRunEvent.create({ - data: { - id: eventId, - runId, - sequence, - type, - data, - emittedAt, - }, - }); + await requireRow( + tx + .insert(agentRunEvent) + .values({ + id: eventId, + runId, + sequence, + type, + data, + emittedAt, + }) + .returning(), + ); if (type === "action.result") { await persistMcpActionResult(tx, runId, data, emittedAt); } if (type === "step.completed") { const { inputTokens, outputTokens, costUsd } = stepUsage.parse(data).usage; - const current = await tx.agentRun.findUniqueOrThrow({ - where: { id: runId }, - select: { inputTokens: true, outputTokens: true, costUsd: true }, - }); - await tx.agentRun.update({ - where: { id: runId }, - data: { - inputTokens: - inputTokens === null - ? undefined - : (current.inputTokens ?? 0) + inputTokens, - outputTokens: - outputTokens === null - ? undefined - : (current.outputTokens ?? 0) + outputTokens, - costUsd: - costUsd === null ? undefined : Number(current.costUsd ?? 0) + costUsd, - }, - }); + const current = await requireRow( + tx + .select({ + inputTokens: agentRun.inputTokens, + outputTokens: agentRun.outputTokens, + costUsd: agentRun.costUsd, + }) + .from(agentRun) + .where(whereEq(agentRun.id, runId)) + .limit(1), + ); + const usage = { + inputTokens: + inputTokens === null + ? undefined + : (current.inputTokens ?? 0) + inputTokens, + outputTokens: + outputTokens === null + ? undefined + : (current.outputTokens ?? 0) + outputTokens, + costUsd: + costUsd === null + ? undefined + : new Decimal(current.costUsd ?? 0).plus(costUsd), + }; + if (hasValues(usage)) { + await tx.update(agentRun).set(usage).where(whereEq(agentRun.id, runId)); + } } if (type === "message.completed") { const { message } = completedMessage.parse(data); if (message?.trim()) { - await tx.agentRun.updateMany({ - where: { id: runId, status: "RUNNING" }, - data: { summary: message.slice(0, 1000) }, - }); + await tx + .update(agentRun) + .set({ summary: message.slice(0, 1000) }) + .where( + and(whereEq(agentRun.id, runId), eq(agentRun.status, "RUNNING")), + ); } } } async function persistMcpActionResult( - tx: Prisma.TransactionClient, + tx: Executor, runId: string, - data: Prisma.InputJsonObject, + data: JsonObject, emittedAt: Date, ): Promise { const parsed = mcpActionResultEvent.safeParse(data); @@ -278,28 +340,38 @@ async function persistMcpActionResult( if (separator <= 0) return; const platformSlug = parsed.data.result.toolName.slice(0, separator); const success = parsed.data.status === "completed"; - const updated = await tx.agentAction.updateMany({ - where: { - runId, - idempotencyKey: `${runId}:${parsed.data.result.callId}`, - provider: platformSlug, - type: "mcp.tool.call", - status: "RUNNING", - }, - data: { + const updated = await tx + .update(agentAction) + .set({ status: success ? "SUCCEEDED" : "FAILED", errorCode: success ? null : "MCP_CALL_FAILED", errorMessage: success ? null : (parsed.data.error?.message ?? "The MCP tool call failed."), completedAt: emittedAt, - }, - }); - if (success && updated.count === 1) { - await tx.mcpConnection.updateMany({ - where: { organizationId: WORKSPACE_ID, platformSlug }, - data: { lastSuccessfulCallAt: emittedAt, lastErrorCode: null }, - }); + }) + .where( + and( + whereEq(agentAction.runId, runId), + whereEq( + agentAction.idempotencyKey, + `${runId}:${parsed.data.result.callId}`, + ), + whereEq(agentAction.provider, platformSlug), + eq(agentAction.type, "mcp.tool.call"), + eq(agentAction.status, "RUNNING"), + ), + ); + if (success && updated.rowCount === 1) { + await tx + .update(mcpConnection) + .set({ lastSuccessfulCallAt: emittedAt, lastErrorCode: null }) + .where( + and( + eq(mcpConnection.organizationId, WORKSPACE_ID), + whereEq(mcpConnection.platformSlug, platformSlug), + ), + ); } } diff --git a/apps/agent/agent/lib/accounts.ts b/apps/agent/agent/lib/accounts.ts index 742d37732..a8d253370 100644 --- a/apps/agent/agent/lib/accounts.ts +++ b/apps/agent/agent/lib/accounts.ts @@ -1,4 +1,26 @@ -import { ActivityType, db, EmailDirection } from "@crm/db"; +import { + ActivityType, + activity, + and, + asc, + calendarAttendee, + calendarEvent, + company as companyTable, + contact, + db, + deal as dealTable, + desc, + EmailDirection, + emailMessage, + emailThread, + eq, + firstRow, + inArray, + or, + orNull, + sql, + whereEq, +} from "@crm/db"; import { z } from "zod"; import { isDerivedName } from "./names"; @@ -102,59 +124,73 @@ export async function readCompanyHistory( includeCalendar?: boolean; } = {}, ): Promise { - const company = await db.company.findUnique({ - where: { id: companyId }, - select: { - id: true, - name: true, - domain: true, - website: true, - industry: true, - subIndustry: true, - city: true, - country: true, - description: true, - linkedinUrl: true, - enrichmentStatus: true, - }, - }); + const company = await firstRow( + db + .select({ + id: companyTable.id, + name: companyTable.name, + domain: companyTable.domain, + website: companyTable.website, + industry: companyTable.industry, + subIndustry: companyTable.subIndustry, + city: companyTable.city, + country: companyTable.country, + description: companyTable.description, + linkedinUrl: companyTable.linkedinUrl, + enrichmentStatus: companyTable.enrichmentStatus, + }) + .from(companyTable) + .where(whereEq(companyTable.id, companyId)) + .limit(1), + ); if (!company) return null; const includeEmail = options.includeEmail ?? true; const includeCalendar = options.includeCalendar ?? true; - const belongsToCompany = { OR: [{ companyId }, { contact: { companyId } }] }; + const companyContacts = db + .select({ id: contact.id }) + .from(contact) + .where(eq(contact.companyId, companyId)); + const belongsToCompany = or( + eq(emailThread.companyId, companyId), + inArray(emailThread.contactId, companyContacts), + ); + const companyThreads = db + .select({ id: emailThread.id }) + .from(emailThread) + .where(belongsToCompany); const [people, deals, threads, meetings, notes, lastInbound, counts] = await Promise.all([ - db.contact.findMany({ - where: { companyId }, - orderBy: [{ lastActivityAt: "desc" }, { createdAt: "asc" }], - take: options.people ?? 25, - select: { - id: true, - firstName: true, - lastName: true, - title: true, - email: true, - linkedinUrl: true, - lastActivityAt: true, - _count: - includeEmail || includeCalendar - ? { - select: { - emailThreads: includeEmail, - calendarEvents: includeCalendar, - }, - } - : false, - }, - }), - db.deal.findMany({ - where: { companyId }, - orderBy: [{ lastActivityAt: "desc" }, { createdAt: "desc" }], - take: 20, - select: { + db + .select({ + id: contact.id, + firstName: contact.firstName, + lastName: contact.lastName, + title: contact.title, + email: contact.email, + linkedinUrl: contact.linkedinUrl, + lastActivityAt: contact.lastActivityAt, + threads: includeEmail + ? db.$count(emailThread, eq(emailThread.contactId, contact.id)) + : sql`0`.mapWith(Number), + meetings: includeCalendar + ? db.$count(calendarEvent, eq(calendarEvent.contactId, contact.id)) + : sql`0`.mapWith(Number), + }) + .from(contact) + .where(eq(contact.companyId, companyId)) + .orderBy( + sql`${contact.lastActivityAt} desc nulls first`, + asc(contact.createdAt), + ) + .limit(options.people ?? 25), + db.query.deal.findMany({ + where: whereEq(dealTable.companyId, companyId), + orderBy: [desc(dealTable.lastActivityAt), desc(dealTable.createdAt)], + limit: 20, + columns: { id: true, name: true, stage: true, @@ -162,32 +198,36 @@ export async function readCompanyHistory( currency: true, expectedCloseDate: true, lastActivityAt: true, + }, + with: { contacts: { - select: { - role: true, + columns: { role: true }, + with: { contact: { - select: { id: true, firstName: true, lastName: true }, + columns: { id: true, firstName: true, lastName: true }, }, }, }, }, }), includeEmail - ? db.emailThread.findMany({ + ? db.query.emailThread.findMany({ where: belongsToCompany, - orderBy: { lastMessageAt: "desc" }, - take: options.threads ?? 5, - select: { + orderBy: [desc(emailThread.lastMessageAt)], + limit: options.threads ?? 5, + columns: { subject: true, messageCount: true, lastMessageAt: true, + }, + with: { contact: { - select: { id: true, firstName: true, lastName: true }, + columns: { id: true, firstName: true, lastName: true }, }, messages: { - orderBy: { sentAt: "desc" }, - take: options.messagesPerThread ?? 4, - select: { + orderBy: [desc(emailMessage.sentAt)], + limit: options.messagesPerThread ?? 4, + columns: { direction: true, fromEmail: true, fromName: true, @@ -200,45 +240,60 @@ export async function readCompanyHistory( }) : Promise.resolve([]), includeCalendar - ? db.calendarEvent.findMany({ - where: { - OR: [ - { companyId }, - { contact: { companyId } }, - { attendees: { some: { contact: { companyId } } } }, - ], - }, - orderBy: { startsAt: "desc" }, - take: 10, - select: { - title: true, - startsAt: true, - attendees: { select: { email: true, name: true } }, - }, + ? db.query.calendarEvent.findMany({ + where: or( + eq(calendarEvent.companyId, companyId), + inArray(calendarEvent.contactId, companyContacts), + inArray( + calendarEvent.id, + db + .select({ id: calendarAttendee.eventId }) + .from(calendarAttendee) + .where(inArray(calendarAttendee.contactId, companyContacts)), + ), + ), + orderBy: [desc(calendarEvent.startsAt)], + limit: 10, + columns: { title: true, startsAt: true }, + with: { attendees: { columns: { email: true, name: true } } }, }) : Promise.resolve([]), recentNotes({ companyId }), includeEmail - ? db.emailMessage.findFirst({ - where: { - direction: EmailDirection.INBOUND, - thread: belongsToCompany, - }, - orderBy: { sentAt: "desc" }, - select: { sentAt: true, fromEmail: true, fromName: true }, - }) + ? firstRow( + db + .select({ + sentAt: emailMessage.sentAt, + fromEmail: emailMessage.fromEmail, + fromName: emailMessage.fromName, + }) + .from(emailMessage) + .where( + and( + eq(emailMessage.direction, EmailDirection.INBOUND), + inArray(emailMessage.threadId, companyThreads), + ), + ) + .orderBy(desc(emailMessage.sentAt)) + .limit(1), + ) : Promise.resolve(null), Promise.all([ - db.contact.count({ where: { companyId } }), + db.$count(contact, whereEq(contact.companyId, companyId)), includeEmail - ? db.emailMessage.count({ where: { thread: belongsToCompany } }) + ? db.$count( + emailMessage, + inArray(emailMessage.threadId, companyThreads), + ) : Promise.resolve(0), includeCalendar - ? db.calendarEvent.count({ - where: { - OR: [{ companyId }, { contact: { companyId } }], - }, - }) + ? db.$count( + calendarEvent, + or( + eq(calendarEvent.companyId, companyId), + inArray(calendarEvent.contactId, companyContacts), + ), + ) : Promise.resolve(0), ]), ]); @@ -267,8 +322,8 @@ export async function readCompanyHistory( email: person.email, linkedinUrl: person.linkedinUrl, lastActivityAt: person.lastActivityAt?.toISOString() ?? null, - threads: person._count?.emailThreads ?? 0, - meetings: person._count?.calendarEvents ?? 0, + threads: person.threads, + meetings: person.meetings, needsIdentity: isDerivedName( person.email, person.firstName, @@ -346,39 +401,43 @@ export async function readDealHistory( includeCalendar?: boolean; } = {}, ): Promise { - const deal = await db.deal.findUnique({ - where: { id: dealId }, - select: { - id: true, - name: true, - description: true, - stage: true, - stageChangedAt: true, - amount: true, - currency: true, - expectedCloseDate: true, - closedAt: true, - closedReason: true, - lastActivityAt: true, - createdAt: true, - owner: { select: { name: true, email: true } }, - company: { select: { id: true, name: true, domain: true } }, - contacts: { - select: { - role: true, - contact: { - select: { - id: true, - firstName: true, - lastName: true, - title: true, - email: true, + const deal = await db.query.deal + .findFirst({ + where: whereEq(dealTable.id, dealId), + columns: { + id: true, + name: true, + description: true, + stage: true, + stageChangedAt: true, + amount: true, + currency: true, + expectedCloseDate: true, + closedAt: true, + closedReason: true, + lastActivityAt: true, + createdAt: true, + }, + with: { + owner: { columns: { name: true, email: true } }, + company: { columns: { id: true, name: true, domain: true } }, + contacts: { + columns: { role: true }, + with: { + contact: { + columns: { + id: true, + firstName: true, + lastName: true, + title: true, + email: true, + }, }, }, }, }, - }, - }); + }) + .then(orNull); if (!deal) return null; const includeEmail = options.includeEmail ?? true; @@ -388,38 +447,43 @@ export async function readDealHistory( const relatedThreads = contactIds.length > 0 - ? { - OR: [ - { contactId: { in: contactIds } }, - { companyId: deal.company.id }, - ], - } - : { companyId: deal.company.id }; + ? or( + inArray(emailThread.contactId, contactIds), + eq(emailThread.companyId, deal.company.id), + ) + : eq(emailThread.companyId, deal.company.id); const [stageChanges, threads, meetings, notes, lastInbound] = await Promise.all([ - db.activity.findMany({ - where: { dealId, type: ActivityType.STAGE_CHANGE }, - orderBy: { createdAt: "asc" }, - take: 25, - select: { meta: true, createdAt: true }, - }), + db + .select({ meta: activity.meta, createdAt: activity.createdAt }) + .from(activity) + .where( + and( + whereEq(activity.dealId, dealId), + eq(activity.type, ActivityType.STAGE_CHANGE), + ), + ) + .orderBy(asc(activity.createdAt)) + .limit(25), includeEmail - ? db.emailThread.findMany({ + ? db.query.emailThread.findMany({ where: relatedThreads, - orderBy: { lastMessageAt: "desc" }, - take: options.threads ?? 5, - select: { + orderBy: [desc(emailThread.lastMessageAt)], + limit: options.threads ?? 5, + columns: { subject: true, messageCount: true, lastMessageAt: true, + }, + with: { contact: { - select: { id: true, firstName: true, lastName: true }, + columns: { id: true, firstName: true, lastName: true }, }, messages: { - orderBy: { sentAt: "desc" }, - take: options.messagesPerThread ?? 4, - select: { + orderBy: [desc(emailMessage.sentAt)], + limit: options.messagesPerThread ?? 4, + columns: { direction: true, fromEmail: true, fromName: true, @@ -432,38 +496,52 @@ export async function readDealHistory( }) : Promise.resolve([]), includeCalendar - ? db.calendarEvent.findMany({ + ? db.query.calendarEvent.findMany({ where: contactIds.length > 0 - ? { - OR: [ - { contactId: { in: contactIds } }, - { - attendees: { some: { contactId: { in: contactIds } } }, - }, - { companyId: deal.company.id }, - ], - } - : { companyId: deal.company.id }, - orderBy: { startsAt: "desc" }, - take: 10, - select: { - title: true, - startsAt: true, - attendees: { select: { email: true, name: true } }, - }, + ? or( + inArray(calendarEvent.contactId, contactIds), + inArray( + calendarEvent.id, + db + .select({ id: calendarAttendee.eventId }) + .from(calendarAttendee) + .where(inArray(calendarAttendee.contactId, contactIds)), + ), + eq(calendarEvent.companyId, deal.company.id), + ) + : eq(calendarEvent.companyId, deal.company.id), + orderBy: [desc(calendarEvent.startsAt)], + limit: 10, + columns: { title: true, startsAt: true }, + with: { attendees: { columns: { email: true, name: true } } }, }) : Promise.resolve([]), recentNotes({ dealId }), includeEmail - ? db.emailMessage.findFirst({ - where: { - direction: EmailDirection.INBOUND, - thread: relatedThreads, - }, - orderBy: { sentAt: "desc" }, - select: { sentAt: true, fromEmail: true, fromName: true }, - }) + ? firstRow( + db + .select({ + sentAt: emailMessage.sentAt, + fromEmail: emailMessage.fromEmail, + fromName: emailMessage.fromName, + }) + .from(emailMessage) + .where( + and( + eq(emailMessage.direction, EmailDirection.INBOUND), + inArray( + emailMessage.threadId, + db + .select({ id: emailThread.id }) + .from(emailThread) + .where(relatedThreads), + ), + ), + ) + .orderBy(desc(emailMessage.sentAt)) + .limit(1), + ) : Promise.resolve(null), ]); @@ -540,28 +618,30 @@ function isOpen(stage: string): boolean { async function recentNotes( where: { companyId: string } | { dealId: string }, ): Promise { - const rows = await db.activity.findMany({ - where: { - ...where, - type: { - in: [ + const rows = await db + .select({ + type: activity.type, + subject: activity.subject, + body: activity.body, + occurredAt: activity.occurredAt, + createdAt: activity.createdAt, + }) + .from(activity) + .where( + and( + "companyId" in where + ? eq(activity.companyId, where.companyId) + : eq(activity.dealId, where.dealId), + inArray(activity.type, [ ActivityType.NOTE, ActivityType.CALL, ActivityType.TASK, ActivityType.ENRICHMENT, - ], - }, - }, - orderBy: { createdAt: "desc" }, - take: 10, - select: { - type: true, - subject: true, - body: true, - occurredAt: true, - createdAt: true, - }, - }); + ]), + ), + ) + .orderBy(desc(activity.createdAt)) + .limit(10); return rows.map((row) => ({ type: row.type, diff --git a/apps/agent/agent/lib/blank-facts.ts b/apps/agent/agent/lib/blank-facts.ts index 1923c0a93..2262b449a 100644 --- a/apps/agent/agent/lib/blank-facts.ts +++ b/apps/agent/agent/lib/blank-facts.ts @@ -1,4 +1,16 @@ -import { db, FactStatus } from "@crm/db"; +import { + and, + contactFact, + contact as contactTable, + db, + desc, + eq, + FactStatus, + inArray, + ne, + requireRow, + whereEq, +} from "@crm/db"; import { canonicalValue, type FactField, @@ -47,19 +59,19 @@ export async function sweepBlankFacts( options: { dry?: boolean } = {}, ): Promise { const [pending, proposals] = await Promise.all([ - db.contactFact.count({ where: { status: FactStatus.PROPOSED } }), - db.contactFact.findMany({ - where: { status: FactStatus.PROPOSED }, - select: { + db.$count(contactFact, eq(contactFact.status, FactStatus.PROPOSED)), + db.query.contactFact.findMany({ + where: eq(contactFact.status, FactStatus.PROPOSED), + columns: { id: true, contactId: true, field: true, value: true, score: true, - contact: { select: CONTACT_SELECT }, }, - orderBy: [{ score: "desc" }, { observedAt: "desc" }], - take: SCAN, + with: { contact: { columns: CONTACT_SELECT } }, + orderBy: [desc(contactFact.score), desc(contactFact.observedAt)], + limit: SCAN, }), ]); @@ -93,10 +105,15 @@ export async function sweepBlankFacts( if (stale.length === 0) continue; if (!options.dry) { - await db.contactFact.updateMany({ - where: { id: { in: stale.map((row) => row.id) } }, - data: { status: FactStatus.SUPERSEDED, supersededAt: new Date() }, - }); + await db + .update(contactFact) + .set({ status: FactStatus.SUPERSEDED, supersededAt: new Date() }) + .where( + inArray( + contactFact.id, + stale.map((row) => row.id), + ), + ); } sweep.settled += stale.length; @@ -153,13 +170,19 @@ async function appliedValues( const values = new Map(); for (let start = 0; start < contactIds.length; start += 1000) { - const rows = await db.contactFact.findMany({ - where: { - status: FactStatus.APPLIED, - contactId: { in: contactIds.slice(start, start + 1000) }, - }, - select: { contactId: true, field: true, value: true }, - }); + const rows = await db + .select({ + contactId: contactFact.contactId, + field: contactFact.field, + value: contactFact.value, + }) + .from(contactFact) + .where( + and( + eq(contactFact.status, FactStatus.APPLIED), + inArray(contactFact.contactId, contactIds.slice(start, start + 1000)), + ), + ); for (const row of rows) values.set(key(row.contactId, row.field), row.value); @@ -202,36 +225,50 @@ async function fill( value: string, column: string | null, ): Promise { - await db.$transaction(async (tx) => { - await tx.contactFact.updateMany({ - where: { - contactId, - field, - id: { not: factId }, - status: { in: [FactStatus.APPLIED, FactStatus.PROPOSED] }, - }, - data: { status: FactStatus.SUPERSEDED, supersededAt: new Date() }, - }); - - await tx.contactFact.update({ - where: { id: factId }, - data: { status: FactStatus.APPLIED }, - }); + await db.transaction(async (tx) => { + await tx + .update(contactFact) + .set({ status: FactStatus.SUPERSEDED, supersededAt: new Date() }) + .where( + and( + whereEq(contactFact.contactId, contactId), + whereEq(contactFact.field, field), + ne(contactFact.id, factId), + inArray(contactFact.status, [ + FactStatus.APPLIED, + FactStatus.PROPOSED, + ]), + ), + ); + + await requireRow( + tx + .update(contactFact) + .set({ status: FactStatus.APPLIED }) + .where(whereEq(contactFact.id, factId)) + .returning(), + ); if (column) { - await tx.contact.update({ - where: { id: contactId }, - data: { [column]: value }, - }); + await requireRow( + tx + .update(contactTable) + .set({ [column]: value }) + .where(whereEq(contactTable.id, contactId)) + .returning(), + ); } if (field === "name") { const split = splitName(value); if (split) { - await tx.contact.update({ - where: { id: contactId }, - data: { firstName: split.firstName, lastName: split.lastName }, - }); + await requireRow( + tx + .update(contactTable) + .set({ firstName: split.firstName, lastName: split.lastName }) + .where(whereEq(contactTable.id, contactId)) + .returning(), + ); } } }); diff --git a/apps/agent/agent/lib/brand-images.ts b/apps/agent/agent/lib/brand-images.ts index df017e752..6fb620bff 100644 --- a/apps/agent/agent/lib/brand-images.ts +++ b/apps/agent/agent/lib/brand-images.ts @@ -1,4 +1,4 @@ -import type { Prisma } from "@crm/db"; +import type { NewCompany } from "@crm/db"; import { blobEnabled, mirror } from "@crm/db/blob"; import { COMPANY_IMAGE_FIELDS } from "@crm/db/images"; import { z } from "zod"; @@ -7,8 +7,8 @@ const mirrorableUrl = z.string().regex(/\S/).nullable().catch(null); export async function mirrorBrandImages( companyId: string, - update: Prisma.CompanyUpdateInput, -): Promise<{ update: Prisma.CompanyUpdateInput; mirrored: string[] }> { + update: Partial, +): Promise<{ update: Partial; mirrored: string[] }> { if (!blobEnabled()) return { update, mirrored: [] }; const mirrored: string[] = []; diff --git a/apps/agent/agent/lib/brand-mapping.ts b/apps/agent/agent/lib/brand-mapping.ts index 0c92ef8fb..beb3a2cd5 100644 --- a/apps/agent/agent/lib/brand-mapping.ts +++ b/apps/agent/agent/lib/brand-mapping.ts @@ -1,7 +1,7 @@ -import type { Prisma } from "@crm/db"; +import type { NewCompany } from "@crm/db"; import type { Brand } from "./context-dev"; -export type BrandUpdate = Prisma.CompanyUpdateInput; +export type BrandUpdate = Partial; export type CompanySnapshot = { name: string; diff --git a/apps/agent/agent/lib/brand.ts b/apps/agent/agent/lib/brand.ts index fdd7740bd..83b99191e 100644 --- a/apps/agent/agent/lib/brand.ts +++ b/apps/agent/agent/lib/brand.ts @@ -1,8 +1,18 @@ -import { db, EnrichmentStatus } from "@crm/db"; +import { + and, + companyEnrichment, + company as companyTable, + db, + EnrichmentStatus, + eq, + orNull, + requireRow, + whereEq, +} from "@crm/db"; import { mirrorBrandImages } from "./brand-images"; import { brandToUpdate, filledFields, stillFillable } from "./brand-mapping"; import { brandByDomain, contextDevEnabled } from "./context-dev"; -import { UNLESS_COMPLETE } from "./enrichment"; +import { guardSql, UNLESS_COMPLETE } from "./enrichment"; export type BrandResult = { enriched: boolean; @@ -51,10 +61,12 @@ export async function runBrand({ fresh?: boolean; spend?: Spend; }): Promise { - const company = await db.company.findUnique({ - where: { id: companyId }, - select: COMPANY_FIELDS, - }); + const company = await db.query.company + .findFirst({ + where: eq(companyTable.id, companyId), + columns: COMPANY_FIELDS, + }) + .then(orNull); if (!company) return { enriched: false, reason: "No such company." }; @@ -78,13 +90,18 @@ export async function runBrand({ const charge = spend(2); if (!charge.ok) return { enriched: false, reason: charge.reason }; - await db.company.updateMany({ - where: { id: companyId, ...UNLESS_COMPLETE }, - data: { + await db + .update(companyTable) + .set({ enrichmentStatus: EnrichmentStatus.RUNNING, enrichmentError: null, - }, - }); + }) + .where( + and( + eq(companyTable.id, companyId), + guardSql(companyTable, UNLESS_COMPLETE), + ), + ); const result = await brandByDomain(company.domain, fresh ? 0 : undefined); @@ -106,31 +123,41 @@ export async function runBrand({ const { mirrored } = await mirrorBrandImages(companyId, update); - const filled = await db.$transaction(async (tx) => { - const current = await tx.company.findUnique({ - where: { id: companyId }, - select: COMPANY_FIELDS, - }); + const filled = await db.transaction(async (tx) => { + const current = await tx.query.company + .findFirst({ + where: eq(companyTable.id, companyId), + columns: COMPANY_FIELDS, + }) + .then(orNull); if (!current) return null; const data = stillFillable(update, snapshot(current)); - await tx.company.update({ - where: { id: companyId }, - data: { - ...data, - enrichmentStatus: EnrichmentStatus.COMPLETE, - enrichedAt: new Date(), - enrichmentError: null, - }, - }); - - await tx.companyEnrichment.upsert({ - where: { companyId }, - create: { companyId, raw: result.raw as object }, - update: { raw: result.raw as object, fetchedAt: new Date() }, - }); + await requireRow( + tx + .update(companyTable) + .set({ + ...data, + enrichmentStatus: EnrichmentStatus.COMPLETE, + enrichedAt: new Date(), + enrichmentError: null, + }) + .where(whereEq(companyTable.id, companyId)) + .returning(), + ); + + await requireRow( + tx + .insert(companyEnrichment) + .values({ companyId, raw: result.raw as object }) + .onConflictDoUpdate({ + target: companyEnrichment.companyId, + set: { raw: result.raw as object, fetchedAt: new Date() }, + }) + .returning(), + ); return filledFields(data); }); @@ -173,8 +200,8 @@ async function settle( error: string, guard: SettleGuard = { enrichmentStatus: EnrichmentStatus.RUNNING }, ): Promise { - await db.company.updateMany({ - where: { id: companyId, ...guard }, - data: { enrichmentStatus: status, enrichmentError: error }, - }); + await db + .update(companyTable) + .set({ enrichmentStatus: status, enrichmentError: error }) + .where(and(eq(companyTable.id, companyId), guardSql(companyTable, guard))); } diff --git a/apps/agent/agent/lib/builder-event-storage.ts b/apps/agent/agent/lib/builder-event-storage.ts index b2eec6b1c..c1d0d8f9e 100644 --- a/apps/agent/agent/lib/builder-event-storage.ts +++ b/apps/agent/agent/lib/builder-event-storage.ts @@ -2,7 +2,7 @@ import { chatEventPurpose, createChatEventSecretBox, } from "@crm/auth/chat-event-crypto"; -import type { Prisma } from "@crm/db"; +import type { JsonValue } from "@crm/db"; import { publicEveEventData, publicEveEventInput, @@ -14,23 +14,23 @@ type BuilderEventIdentity = { }; type StoredBuilderEvent = { - data: Prisma.InputJsonValue; + data: JsonValue; encryptedData: string | null; }; type BuilderEventRecord = { - data: Prisma.JsonValue; + data: JsonValue; encryptedData: string | null; }; type RuntimeEventValue = - | Prisma.InputJsonValue + | JsonValue | undefined | RuntimeEventValue[] | { [key: string]: RuntimeEventValue }; export function storeBuilderEvent( - value: Prisma.InputJsonValue, + value: JsonValue, identity: BuilderEventIdentity, ): StoredBuilderEvent { const box = createChatEventSecretBox(); @@ -56,7 +56,7 @@ export function prepareBuilderEventForStorage( export function readBuilderEvent( record: BuilderEventRecord, identity: BuilderEventIdentity, -): Prisma.JsonValue | null { +): JsonValue | null { if (!record.encryptedData) return record.data; const box = createChatEventSecretBox(); if (!box) return null; diff --git a/apps/agent/agent/lib/builder-input.ts b/apps/agent/agent/lib/builder-input.ts index b6ddf5615..1e31faf4c 100644 --- a/apps/agent/agent/lib/builder-input.ts +++ b/apps/agent/agent/lib/builder-input.ts @@ -1,4 +1,15 @@ -import { db, type Prisma } from "@crm/db"; +import { + agentConversation, + agentEvent, + and, + db, + desc, + firstRow, + type JsonValue, + requireRow, + startsWith, + whereEq, +} from "@crm/db"; import { lockIdempotencyKey } from "@crm/db/idempotency"; import { type InputRequested, parse, schemas } from "@crm/validation"; import type { ChannelEvents } from "eve/channels"; @@ -39,13 +50,16 @@ export async function persistBuilderInputRequest( const eventId = `${eventPrefix(conversationId)}${event.turnId}:${event.sequence}:${event.stepIndex}`; - return db.$transaction(async (tx) => { + return db.transaction(async (tx) => { await lockIdempotencyKey(tx, eventId); - const replay = await tx.agentEvent.findUnique({ - where: { id: eventId }, - select: { id: true }, - }); + const replay = await firstRow( + tx + .select({ id: agentEvent.id }) + .from(agentEvent) + .where(whereEq(agentEvent.id, eventId)) + .limit(1), + ); if (replay) return false; const conversation = await lockBuilderConversation(tx, conversationId); @@ -53,14 +67,23 @@ export async function persistBuilderInputRequest( return false; } - const recorded = await tx.agentEvent.findFirst({ - where: { - conversationId: conversation.id, - id: { startsWith: eventPrefix(conversation.id) }, - }, - orderBy: [{ emittedAt: "desc" }, { id: "desc" }], - select: { id: true, data: true, encryptedData: true }, - }); + const recorded = await firstRow( + tx + .select({ + id: agentEvent.id, + data: agentEvent.data, + encryptedData: agentEvent.encryptedData, + }) + .from(agentEvent) + .where( + and( + whereEq(agentEvent.conversationId, conversation.id), + startsWith(agentEvent.id, eventPrefix(conversation.id)), + ), + ) + .orderBy(desc(agentEvent.emittedAt), desc(agentEvent.id)) + .limit(1), + ); const recordedData = recorded ? readBuilderEvent(recorded, { conversationId: conversation.id, @@ -79,30 +102,36 @@ export async function persistBuilderInputRequest( } if (recorded && !recordedData) return false; - const storedEvent = storeBuilderEvent(event as Prisma.InputJsonValue, { + const storedEvent = storeBuilderEvent(event as JsonValue, { conversationId: conversation.id, eventId, }); - await tx.agentEvent.create({ - data: { - id: eventId, - sessionId: conversation.sessionId, - conversationId: conversation.id, - type: BUILDER_INPUT.eventType, - data: storedEvent.data, - encryptedData: storedEvent.encryptedData, - emittedAt: new Date(), - }, - }); - - await tx.agentConversation.update({ - where: { id: conversation.id }, - data: { - continuationToken: builderToken(conversation.id), - pendingInputRequests: event.requests as Prisma.InputJsonValue, - }, - }); + await requireRow( + tx + .insert(agentEvent) + .values({ + id: eventId, + sessionId: conversation.sessionId, + conversationId: conversation.id, + type: BUILDER_INPUT.eventType, + data: storedEvent.data, + encryptedData: storedEvent.encryptedData, + emittedAt: new Date(), + }) + .returning(), + ); + + await requireRow( + tx + .update(agentConversation) + .set({ + continuationToken: builderToken(conversation.id), + pendingInputRequests: event.requests as JsonValue, + }) + .where(whereEq(agentConversation.id, conversation.id)) + .returning(), + ); return true; }); diff --git a/apps/agent/agent/lib/builder-runtime.ts b/apps/agent/agent/lib/builder-runtime.ts index e1dcfedad..7b437c47d 100644 --- a/apps/agent/agent/lib/builder-runtime.ts +++ b/apps/agent/agent/lib/builder-runtime.ts @@ -1,5 +1,37 @@ import { isDeepStrictEqual } from "node:util"; -import { db, type Prisma } from "@crm/db"; +import { + account as accountTable, + agentAuditEvent, + agentBuilderArtifact, + agentConversation, + agentConversationSubmission, + agentDefinition, + agentTrigger, + agentVersion, + and, + asc, + company, + contact, + db, + deal, + desc, + type Executor, + eq, + firstRow, + isNotNull, + type JsonValue, + mcpConnection, + mcpToolSnapshot, + member, + orNull, + queryRows, + requireRow, + slackChannel, + slackMemberMatch, + sql, + user, + whereEq, +} from "@crm/db"; import { CRM_EVENT_CATALOG, CRM_EVENT_TYPES, @@ -104,77 +136,100 @@ export async function writeBuilderArtifact( ) { assertSafeArtifact(content); - return db.$transaction(async (tx) => { - const [conversation] = await tx.$queryRaw>` + return db.transaction(async (tx) => { + const [conversation] = await queryRows<{ id: string }>( + tx, + sql` SELECT id FROM "agentConversation" WHERE id = ${conversationId} AND "userId" = ${userId} AND kind = 'BUILDER' FOR UPDATE - `; + `, + ); if (!conversation) { throw new Error("This builder conversation is unavailable."); } - const latest = await tx.agentBuilderArtifact.findFirst({ - where: { conversationId, path }, - orderBy: { revision: "desc" }, - select: { id: true, revision: true, content: true, status: true }, - }); + const latest = await firstRow( + tx + .select({ + id: agentBuilderArtifact.id, + revision: agentBuilderArtifact.revision, + content: agentBuilderArtifact.content, + status: agentBuilderArtifact.status, + }) + .from(agentBuilderArtifact) + .where( + and( + whereEq(agentBuilderArtifact.conversationId, conversationId), + whereEq(agentBuilderArtifact.path, path), + ), + ) + .orderBy(desc(agentBuilderArtifact.revision)) + .limit(1), + ); if (latest?.content === content) { return { saved: true as const, id: latest.id, revision: latest.revision }; } - const artifact = await tx.agentBuilderArtifact.create({ - data: { - conversationId, - path, - language: ARTIFACT_LANGUAGES[path], - content, - previousContent: latest?.content ?? null, - revision: (latest?.revision ?? 0) + 1, - status: "WRITING", - }, - select: { id: true, revision: true }, - }); + const artifact = await requireRow( + tx + .insert(agentBuilderArtifact) + .values({ + conversationId, + path, + language: ARTIFACT_LANGUAGES[path], + content, + previousContent: latest?.content ?? null, + revision: (latest?.revision ?? 0) + 1, + status: "WRITING", + }) + .returning({ + id: agentBuilderArtifact.id, + revision: agentBuilderArtifact.revision, + }), + ); return { saved: true as const, ...artifact }; }); } export async function builderContext(conversationId: string, userId: string) { - const conversation = await db.agentConversation.findFirst({ - where: { id: conversationId, userId, kind: "BUILDER" }, - select: { - id: true, - title: true, - agent: { - select: { - id: true, - name: true, - description: true, - status: true, - versions: { - orderBy: { number: "desc" }, - take: 1, - select: { - id: true, - number: true, - status: true, - manifest: true, - instructions: true, + const conversation = await db.query.agentConversation + .findFirst({ + where: and( + whereEq(agentConversation.id, conversationId), + whereEq(agentConversation.userId, userId), + eq(agentConversation.kind, "BUILDER"), + ), + columns: { id: true, title: true }, + with: { + agent: { + columns: { id: true, name: true, description: true, status: true }, + with: { + versions: { + orderBy: [desc(agentVersion.number)], + limit: 1, + columns: { + id: true, + number: true, + status: true, + manifest: true, + instructions: true, + }, }, }, }, + submissions: { + orderBy: [asc(agentConversationSubmission.createdAt)], + columns: { id: true, message: true, createdAt: true }, + }, }, - submissions: { - orderBy: { createdAt: "asc" }, - select: { id: true, message: true, createdAt: true }, - }, - }, - }); + }) + .then(orNull); if (!conversation) throw new Error("This builder conversation is unavailable."); @@ -206,13 +261,17 @@ export async function saveBuilderDraft( userId: string, input: DraftAgentInput, ) { - const conversation = await db.agentConversation.findFirst({ - where: { id: conversationId, userId, kind: "BUILDER" }, - select: { - id: true, - submissions: { select: { message: true } }, - }, - }); + const conversation = await db.query.agentConversation + .findFirst({ + where: and( + whereEq(agentConversation.id, conversationId), + whereEq(agentConversation.userId, userId), + eq(agentConversation.kind, "BUILDER"), + ), + columns: { id: true }, + with: { submissions: { columns: { message: true } } }, + }) + .then(orNull); if (!conversation) throw new Error("This builder conversation is unavailable."); @@ -270,17 +329,21 @@ export async function saveBuilderDraft( const files = artifactFiles(input, JSON.stringify(manifest, null, 2)); for (const file of files) assertSafeArtifact(file.content); - return db.$transaction(async (tx) => { - const [lockedConversation] = await tx.$queryRaw< - Array<{ id: string; agentId: string | null }> - >` + return db.transaction(async (tx) => { + const [lockedConversation] = await queryRows<{ + id: string; + agentId: string | null; + }>( + tx, + sql` SELECT id, "agentId" FROM "agentConversation" WHERE id = ${conversationId} AND "userId" = ${userId} AND kind = 'BUILDER' FOR UPDATE - `; + `, + ); if (!lockedConversation) { throw new Error("This builder conversation is unavailable."); } @@ -289,56 +352,68 @@ export async function saveBuilderDraft( let created = false; if (!agentId) { - const agent = await tx.agentDefinition.create({ - data: { - name: input.name, - description: input.description, - createdById: userId, - }, - select: { id: true }, - }); + const agent = await requireRow( + tx + .insert(agentDefinition) + .values({ + name: input.name, + description: input.description, + createdById: userId, + }) + .returning({ id: agentDefinition.id }), + ); agentId = agent.id; created = true; - await tx.agentConversation.update({ - where: { id: conversationId }, - data: { agentId, title: input.name }, - }); + await requireRow( + tx + .update(agentConversation) + .set({ agentId, title: input.name }) + .where(whereEq(agentConversation.id, conversationId)) + .returning(), + ); } else { - const [agent] = await tx.$queryRaw< - Array<{ - status: "DRAFT" | "LIVE" | "PAUSED" | "ARCHIVED" | "DELETED"; - }> - >` + const [agent] = await queryRows<{ + status: "DRAFT" | "LIVE" | "PAUSED" | "ARCHIVED" | "DELETED"; + }>( + tx, + sql` SELECT status FROM "agentDefinition" WHERE id = ${agentId} FOR UPDATE - `; + `, + ); if (!agent || agent.status === "DELETED") { throw new Error("This agent is unavailable."); } if (agent.status === "DRAFT") { - await tx.agentDefinition.update({ - where: { id: agentId }, - data: { name: input.name, description: input.description }, - }); + await requireRow( + tx + .update(agentDefinition) + .set({ name: input.name, description: input.description }) + .where(whereEq(agentDefinition.id, agentId)) + .returning(), + ); } } - const latest = await tx.agentVersion.findFirst({ - where: { agentId }, - orderBy: { number: "desc" }, - select: { - id: true, - number: true, - status: true, - instructions: true, - manifest: true, - modelId: true, - modelContextWindowTokens: true, - }, - }); + const latest = await firstRow( + tx + .select({ + id: agentVersion.id, + number: agentVersion.number, + status: agentVersion.status, + instructions: agentVersion.instructions, + manifest: agentVersion.manifest, + modelId: agentVersion.modelId, + modelContextWindowTokens: agentVersion.modelContextWindowTokens, + }) + .from(agentVersion) + .where(whereEq(agentVersion.agentId, agentId)) + .orderBy(desc(agentVersion.number)) + .limit(1), + ); if ( latest?.status === "READY" && latest.instructions === input.instructions && @@ -357,68 +432,80 @@ export async function saveBuilderDraft( } const number = (latest?.number ?? 0) + 1; - const version = await tx.agentVersion.create({ - data: { - agentId, - number, - status: "READY", - instructions: input.instructions, - manifest: manifest as Prisma.InputJsonValue, - modelId: model.id, - modelContextWindowTokens: model.contextWindowTokens, - sandboxPolicy, - validation: { - status: "passed", - checkedAt: now.toISOString(), - capabilities: validation.capabilities, - }, - sourceConversationId: conversationId, - createdById: userId, - }, - select: { id: true, number: true, status: true }, - }); + const version = await requireRow( + tx + .insert(agentVersion) + .values({ + agentId, + number, + status: "READY", + instructions: input.instructions, + manifest: manifest as JsonValue, + modelId: model.id, + modelContextWindowTokens: model.contextWindowTokens, + sandboxPolicy, + validation: { + status: "passed", + checkedAt: now.toISOString(), + capabilities: validation.capabilities, + }, + sourceConversationId: conversationId, + createdById: userId, + }) + .returning({ + id: agentVersion.id, + number: agentVersion.number, + status: agentVersion.status, + }), + ); await persistArtifactSnapshots(tx, conversationId, version.id, files); - await tx.agentTrigger.createMany({ - data: input.triggers.map((trigger, index) => ({ + await tx.insert(agentTrigger).values( + input.triggers.map((trigger, index) => ({ agentId, versionId: version.id, type: trigger.type, name: trigger.name, - config: manifestTriggers[index]?.config as Prisma.InputJsonValue, + config: manifestTriggers[index]?.config as JsonValue, createdById: userId, nextRunAt: scheduleDate(trigger, now), })), - }); + ); if (created) { - await tx.agentAuditEvent.create({ - data: { + await requireRow( + tx + .insert(agentAuditEvent) + .values({ + agentId, + actorUserId: userId, + actorType: "USER", + actorId: userId, + type: "agent.created", + summary: "Created a draft agent from a private builder chat", + requestId: `builder:${conversationId}`, + }) + .returning(), + ); + } + + await requireRow( + tx + .insert(agentAuditEvent) + .values({ agentId, + versionId: version.id, actorUserId: userId, actorType: "USER", actorId: userId, - type: "agent.created", - summary: "Created a draft agent from a private builder chat", - requestId: `builder:${conversationId}`, - }, - }); - } - - await tx.agentAuditEvent.create({ - data: { - agentId, - versionId: version.id, - actorUserId: userId, - actorType: "USER", - actorId: userId, - type: "version.created", - summary: `Prepared version ${number} for review`, - requestId: version.id, - after: { status: "READY", validation: "passed" }, - }, - }); + type: "version.created", + summary: `Prepared version ${number} for review`, + requestId: version.id, + after: { status: "READY", validation: "passed" }, + }) + .returning(), + ); return { saved: true as const, @@ -431,23 +518,31 @@ export async function saveBuilderDraft( } async function persistArtifactSnapshots( - tx: Prisma.TransactionClient, + tx: Executor, conversationId: string, versionId: string, files: ReturnType, ) { for (const file of files) { - const latestArtifact = await tx.agentBuilderArtifact.findFirst({ - where: { conversationId, path: file.path }, - orderBy: { revision: "desc" }, - select: { - id: true, - versionId: true, - revision: true, - content: true, - status: true, - }, - }); + const latestArtifact = await firstRow( + tx + .select({ + id: agentBuilderArtifact.id, + versionId: agentBuilderArtifact.versionId, + revision: agentBuilderArtifact.revision, + content: agentBuilderArtifact.content, + status: agentBuilderArtifact.status, + }) + .from(agentBuilderArtifact) + .where( + and( + whereEq(agentBuilderArtifact.conversationId, conversationId), + whereEq(agentBuilderArtifact.path, file.path), + ), + ) + .orderBy(desc(agentBuilderArtifact.revision)) + .limit(1), + ); if ( latestArtifact?.content === file.content && latestArtifact.versionId === versionId && @@ -460,25 +555,31 @@ async function persistArtifactSnapshots( latestArtifact.status === "WRITING" && latestArtifact.versionId === null ) { - await tx.agentBuilderArtifact.update({ - where: { id: latestArtifact.id }, - data: { versionId, status: "READY" }, - }); + await requireRow( + tx + .update(agentBuilderArtifact) + .set({ versionId, status: "READY" }) + .where(whereEq(agentBuilderArtifact.id, latestArtifact.id)) + .returning(), + ); continue; } - await tx.agentBuilderArtifact.create({ - data: { - conversationId, - versionId, - path: file.path, - language: file.language, - content: file.content, - previousContent: latestArtifact?.content ?? null, - revision: (latestArtifact?.revision ?? 0) + 1, - status: "READY", - }, - }); + await requireRow( + tx + .insert(agentBuilderArtifact) + .values({ + conversationId, + versionId, + path: file.path, + language: file.language, + content: file.content, + previousContent: latestArtifact?.content ?? null, + revision: (latestArtifact?.revision ?? 0) + 1, + status: "READY", + }) + .returning(), + ); } } @@ -670,42 +771,70 @@ function integrationResourceStatus( async function connectionStatus(userId: string) { const [googleAccounts, slackAccount, workspaceMembers, mcpConnections] = await Promise.all([ - db.account.findMany({ - where: { userId, providerId: "google" }, - select: { providerId: true, scope: true }, - }), - db.account.findFirst({ - where: { providerId: "slack", accessToken: { not: null } }, - orderBy: { updatedAt: "desc" }, - select: { id: true }, - }), - db.member.findMany({ - where: { organizationId: WORKSPACE_ID }, - orderBy: { user: { name: "asc" } }, - select: { - user: { - select: { - name: true, - email: true, - slackMemberMatch: { - select: { - slackUserId: true, - slackHandle: true, - slackEmail: true, - }, - }, - }, + db + .select({ + providerId: accountTable.providerId, + scope: accountTable.scope, + }) + .from(accountTable) + .where( + and( + whereEq(accountTable.userId, userId), + eq(accountTable.providerId, "google"), + ), + ), + firstRow( + db + .select({ id: accountTable.id }) + .from(accountTable) + .where( + and( + eq(accountTable.providerId, "slack"), + isNotNull(accountTable.accessToken), + ), + ) + .orderBy(desc(accountTable.updatedAt)) + .limit(1), + ), + db + .select({ + user: { name: user.name, email: user.email }, + match: { + id: slackMemberMatch.id, + slackUserId: slackMemberMatch.slackUserId, + slackHandle: slackMemberMatch.slackHandle, + slackEmail: slackMemberMatch.slackEmail, }, - }, - }), - db.mcpConnection.findMany({ - where: { organizationId: WORKSPACE_ID, status: "CONNECTED" }, - select: { - platformSlug: true, - accessMode: true, + }) + .from(member) + .innerJoin(user, eq(user.id, member.userId)) + .leftJoin(slackMemberMatch, eq(slackMemberMatch.crmUserId, user.id)) + .where(eq(member.organizationId, WORKSPACE_ID)) + .orderBy(asc(user.name)) + .then((rows) => + rows.map((row) => ({ + user: { + ...row.user, + slackMemberMatch: row.match + ? { + slackUserId: row.match.slackUserId, + slackHandle: row.match.slackHandle, + slackEmail: row.match.slackEmail, + } + : null, + }, + })), + ), + db.query.mcpConnection.findMany({ + where: and( + eq(mcpConnection.organizationId, WORKSPACE_ID), + eq(mcpConnection.status, "CONNECTED"), + ), + columns: { platformSlug: true, accessMode: true }, + with: { toolSnapshots: { - orderBy: { createdAt: "desc" }, - select: { + orderBy: [desc(mcpToolSnapshot.createdAt)], + columns: { name: true, description: true, fingerprint: true, @@ -718,12 +847,16 @@ async function connectionStatus(userId: string) { ]); if (slackAccount) void requestStaleSlackInventorySync(); - const slackChannels = await db.slackChannel.findMany({ - where: { available: true }, - orderBy: { name: "asc" }, - take: 100, - select: { id: true, name: true, memberCount: true }, - }); + const slackChannels = await db + .select({ + id: slackChannel.id, + name: slackChannel.name, + memberCount: slackChannel.memberCount, + }) + .from(slackChannel) + .where(eq(slackChannel.available, true)) + .orderBy(asc(slackChannel.name)) + .limit(100); const scopes = new Set( googleAccounts.flatMap((account) => (account.scope ?? "").split(/[,\s]+/)), ); @@ -889,38 +1022,50 @@ async function describeResources(resources: BuilderResource[]) { return Promise.all( resources.map(async (resource) => { if (resource.kind === "company") { - const row = await db.company.findUnique({ - where: { id: resource.id }, - select: { id: true, name: true, domain: true, industry: true }, - }); + const row = await firstRow( + db + .select({ + id: company.id, + name: company.name, + domain: company.domain, + industry: company.industry, + }) + .from(company) + .where(whereEq(company.id, resource.id)) + .limit(1), + ); return { ...resource, record: row }; } if (resource.kind === "contact") { - const row = await db.contact.findUnique({ - where: { id: resource.id }, - select: { - id: true, - firstName: true, - lastName: true, - email: true, - title: true, - company: { select: { id: true, name: true } }, - }, - }); + const row = await db.query.contact + .findFirst({ + where: whereEq(contact.id, resource.id), + columns: { + id: true, + firstName: true, + lastName: true, + email: true, + title: true, + }, + with: { company: { columns: { id: true, name: true } } }, + }) + .then(orNull); return { ...resource, record: row }; } if (resource.kind === "deal") { - const row = await db.deal.findUnique({ - where: { id: resource.id }, - select: { - id: true, - name: true, - stage: true, - amount: true, - currency: true, - company: { select: { id: true, name: true } }, - }, - }); + const row = await db.query.deal + .findFirst({ + where: whereEq(deal.id, resource.id), + columns: { + id: true, + name: true, + stage: true, + amount: true, + currency: true, + }, + with: { company: { columns: { id: true, name: true } } }, + }) + .then(orNull); return { ...resource, record: row @@ -943,7 +1088,7 @@ async function missingResourceIds(resources: BuilderResource[]) { return described.filter((resource) => !resource.record); } -function resourcesOf(value: Prisma.JsonValue): BuilderResource[] { +function resourcesOf(value: JsonValue): BuilderResource[] { return taggedResources .parse(value) .resources.filter((resource) => resource !== null); diff --git a/apps/agent/agent/lib/chat-mcp.ts b/apps/agent/agent/lib/chat-mcp.ts index cb384e561..75f062c13 100644 --- a/apps/agent/agent/lib/chat-mcp.ts +++ b/apps/agent/agent/lib/chat-mcp.ts @@ -1,4 +1,13 @@ -import { db } from "@crm/db"; +import { + and, + db, + eq, + firstRow, + mcpConnection, + mcpToolSnapshot, + orNull, + whereEq, +} from "@crm/db"; import { WORKSPACE_ID } from "@crm/db/workspace"; import { vendorAccess } from "@crm/mcp-integrations/catalog"; import type { Vendor } from "@crm/mcp-integrations/vendor"; @@ -80,17 +89,19 @@ export function chatCallApprovalStatus( } export async function loadChatMcpConnections(): Promise { - const connections = await db.mcpConnection.findMany({ - where: { organizationId: WORKSPACE_ID }, - select: { + const connections = await db.query.mcpConnection.findMany({ + where: eq(mcpConnection.organizationId, WORKSPACE_ID), + columns: { id: true, platformSlug: true, status: true, accessMode: true, lastErrorCode: true, + }, + with: { toolSnapshots: { - where: { reviewState: "REVIEWED" }, - select: { name: true, callKind: true }, + where: eq(mcpToolSnapshot.reviewState, "REVIEWED"), + columns: { name: true, callKind: true }, }, }, }); @@ -164,10 +175,13 @@ async function chatMcpToken( try { return await mcpConnectionToken(connection.id); } catch { - const current = await db.mcpConnection.findUnique({ - where: { id: connection.id }, - select: { status: true }, - }); + const current = await firstRow( + db + .select({ status: mcpConnection.status }) + .from(mcpConnection) + .where(whereEq(mcpConnection.id, connection.id)) + .limit(1), + ); if (current?.status !== "CONNECTED") { throw new ConnectionAuthorizationRequiredError(vendor.slug, { message: `${vendor.name} needs authorization. Use the connection card.`, @@ -230,18 +244,22 @@ async function authorizeChatMcpTool( if (!toolName) { return { type: "denied", reason: "The MCP tool name is invalid." }; } - const current = await db.mcpConnection.findUnique({ - where: { id: connection.id }, - select: { - status: true, - accessMode: true, - toolSnapshots: { - where: { name: toolName, reviewState: "REVIEWED" }, - select: { callKind: true }, - take: 1, + const current = await db.query.mcpConnection + .findFirst({ + where: whereEq(mcpConnection.id, connection.id), + columns: { status: true, accessMode: true }, + with: { + toolSnapshots: { + where: and( + whereEq(mcpToolSnapshot.name, toolName), + eq(mcpToolSnapshot.reviewState, "REVIEWED"), + ), + limit: 1, + columns: { callKind: true }, + }, }, - }, - }); + }) + .then(orNull); if (current?.status !== "CONNECTED") { return { type: "denied", reason: "The MCP platform is disconnected." }; } diff --git a/apps/agent/agent/lib/conversation-title.ts b/apps/agent/agent/lib/conversation-title.ts index 5eb2efbba..824d7f461 100644 --- a/apps/agent/agent/lib/conversation-title.ts +++ b/apps/agent/agent/lib/conversation-title.ts @@ -1,4 +1,12 @@ -import { db } from "@crm/db"; +import { + agentConversation, + and, + db, + eq, + firstRow, + isNull, + whereEq, +} from "@crm/db"; export const BUILDER_CONVERSATION_TITLE_MAX_LENGTH = 60; @@ -16,21 +24,32 @@ export async function setBuilderConversationTitle( if (!normalized) throw new Error("A chat title cannot be empty."); - const updated = await db.agentConversation.updateMany({ - where: { - id: conversationId, - userId, - kind: "BUILDER", - title: null, - }, - data: { title: normalized }, - }); + const updated = await db + .update(agentConversation) + .set({ title: normalized }) + .where( + and( + whereEq(agentConversation.id, conversationId), + whereEq(agentConversation.userId, userId), + eq(agentConversation.kind, "BUILDER"), + isNull(agentConversation.title), + ), + ); - if (updated.count === 0) { - const conversation = await db.agentConversation.findFirst({ - where: { id: conversationId, userId, kind: "BUILDER" }, - select: { title: true }, - }); + if (updated.rowCount === 0) { + const conversation = await firstRow( + db + .select({ title: agentConversation.title }) + .from(agentConversation) + .where( + and( + whereEq(agentConversation.id, conversationId), + whereEq(agentConversation.userId, userId), + eq(agentConversation.kind, "BUILDER"), + ), + ) + .limit(1), + ); if (!conversation) throw new Error("This builder conversation is unavailable."); return { saved: false as const, title: conversation.title }; diff --git a/apps/agent/agent/lib/crm.ts b/apps/agent/agent/lib/crm.ts index 0c0e5e82e..2189d0f10 100644 --- a/apps/agent/agent/lib/crm.ts +++ b/apps/agent/agent/lib/crm.ts @@ -1,4 +1,32 @@ -import { db, EnrichmentStatus, type Prisma } from "@crm/db"; +import { + activity as activityTable, + and, + asc, + calendarAttendee, + calendarEvent, + company, + contactBrief, + contact as contactTable, + db, + deal, + dealContact, + desc, + EnrichmentStatus, + emailMessage, + emailThread, + eq, + firstRow, + inArray, + isNotNull, + isNull, + type JsonObject, + ne, + or, + orNull, + requireRow, + user, + whereEq, +} from "@crm/db"; import { domainOf, isDerivedName } from "./names"; import type { Person } from "./socials"; @@ -18,28 +46,30 @@ export type WorkItem = { }; export async function contactsNeedingWork(limit: number): Promise { - const rows = await db.contact.findMany({ - where: { - OR: [ - { brief: { is: null } }, - { socialsCheckedAt: null }, - { AND: [{ email: { not: null } }, { lastName: null }] }, - ], - }, - select: { - id: true, - email: true, - firstName: true, - lastName: true, - title: true, - linkedinUrl: true, - socialsCheckedAt: true, - company: { select: { name: true, domain: true } }, - brief: { select: { contactId: true } }, - }, - orderBy: { createdAt: "asc" }, - take: limit, - }); + const rows = await db + .select({ + id: contactTable.id, + email: contactTable.email, + firstName: contactTable.firstName, + lastName: contactTable.lastName, + title: contactTable.title, + linkedinUrl: contactTable.linkedinUrl, + socialsCheckedAt: contactTable.socialsCheckedAt, + company: { name: company.name, domain: company.domain }, + brief: { contactId: contactBrief.contactId }, + }) + .from(contactTable) + .leftJoin(company, eq(company.id, contactTable.companyId)) + .leftJoin(contactBrief, eq(contactBrief.contactId, contactTable.id)) + .where( + or( + isNull(contactBrief.contactId), + isNull(contactTable.socialsCheckedAt), + and(isNotNull(contactTable.email), isNull(contactTable.lastName)), + ), + ) + .orderBy(asc(contactTable.createdAt)) + .limit(limit); return rows.map((row) => ({ id: row.id, @@ -61,16 +91,13 @@ export async function contactsNeedingWork(limit: number): Promise { export async function personForVerification( contactId: string, ): Promise { - const contact = await db.contact.findUnique({ - where: { id: contactId }, - select: { - firstName: true, - lastName: true, - title: true, - email: true, - company: { select: { name: true, domain: true } }, - }, - }); + const contact = await db.query.contact + .findFirst({ + where: whereEq(contactTable.id, contactId), + columns: { firstName: true, lastName: true, title: true, email: true }, + with: { company: { columns: { name: true, domain: true } } }, + }) + .then(orNull); if (!contact) return null; @@ -89,10 +116,13 @@ export async function personForVerification( export async function contactProfileSlug( contactId: string, ): Promise<{ slug: string; profileUrl: string } | null> { - const contact = await db.contact.findUnique({ - where: { id: contactId }, - select: { linkedinUrl: true }, - }); + const contact = await firstRow( + db + .select({ linkedinUrl: contactTable.linkedinUrl }) + .from(contactTable) + .where(whereEq(contactTable.id, contactId)) + .limit(1), + ); const slug = linkedinSlug(contact?.linkedinUrl ?? null); return slug @@ -165,36 +195,48 @@ export async function readCrmHistory( includeCalendar?: boolean; } = {}, ): Promise { - const contact = await db.contact.findUnique({ - where: { id: contactId }, - select: { - id: true, - firstName: true, - lastName: true, - email: true, - title: true, - companyId: true, - company: { - select: { id: true, name: true, domain: true, industry: true }, - }, - deals: { - orderBy: { deal: { lastActivityAt: "desc" } }, - select: { - role: true, - deal: { - select: { - id: true, - name: true, - stage: true, - amount: true, - currency: true, - expectedCloseDate: true, - }, - }, + const found = await firstRow( + db + .select({ + id: contactTable.id, + firstName: contactTable.firstName, + lastName: contactTable.lastName, + email: contactTable.email, + title: contactTable.title, + companyId: contactTable.companyId, + company: { + id: company.id, + name: company.name, + domain: company.domain, + industry: company.industry, }, - }, - }, - }); + }) + .from(contactTable) + .leftJoin(company, eq(company.id, contactTable.companyId)) + .where(eq(contactTable.id, contactId)) + .limit(1), + ); + const contact = found + ? { + ...found, + deals: await db + .select({ + role: dealContact.role, + deal: { + id: deal.id, + name: deal.name, + stage: deal.stage, + amount: deal.amount, + currency: deal.currency, + expectedCloseDate: deal.expectedCloseDate, + }, + }) + .from(dealContact) + .innerJoin(deal, eq(deal.id, dealContact.dealId)) + .where(eq(dealContact.contactId, contactId)) + .orderBy(desc(deal.lastActivityAt)), + } + : null; if (!contact) return null; const includeEmail = options.includeEmail ?? true; @@ -202,18 +244,16 @@ export async function readCrmHistory( const [threads, meetings, colleagues] = await Promise.all([ includeEmail - ? db.emailThread.findMany({ - where: { contactId }, - orderBy: { lastMessageAt: "desc" }, - take: options.threads ?? 5, - select: { - subject: true, - messageCount: true, - lastMessageAt: true, + ? db.query.emailThread.findMany({ + where: whereEq(emailThread.contactId, contactId), + orderBy: [desc(emailThread.lastMessageAt)], + limit: options.threads ?? 5, + columns: { subject: true, messageCount: true, lastMessageAt: true }, + with: { messages: { - orderBy: { sentAt: "desc" }, - take: options.messagesPerThread ?? 6, - select: { + orderBy: [desc(emailMessage.sentAt)], + limit: options.messagesPerThread ?? 6, + columns: { direction: true, fromEmail: true, fromName: true, @@ -226,17 +266,23 @@ export async function readCrmHistory( }) : Promise.resolve([]), includeCalendar - ? db.calendarEvent.findMany({ - where: { - OR: [{ contactId }, { attendees: { some: { contactId } } }], - }, - orderBy: { startsAt: "desc" }, - take: 10, - select: { - title: true, - startsAt: true, + ? db.query.calendarEvent.findMany({ + where: or( + whereEq(calendarEvent.contactId, contactId), + inArray( + calendarEvent.id, + db + .select({ id: calendarAttendee.eventId }) + .from(calendarAttendee) + .where(whereEq(calendarAttendee.contactId, contactId)), + ), + ), + orderBy: [desc(calendarEvent.startsAt)], + limit: 10, + columns: { title: true, startsAt: true }, + with: { attendees: { - select: { + columns: { email: true, name: true, contactId: true, @@ -247,12 +293,22 @@ export async function readCrmHistory( }) : Promise.resolve([]), contact.companyId - ? db.contact.findMany({ - where: { companyId: contact.companyId, id: { not: contactId } }, - select: { id: true, firstName: true, lastName: true, title: true }, - take: 8, - orderBy: { lastActivityAt: "desc" }, - }) + ? db + .select({ + id: contactTable.id, + firstName: contactTable.firstName, + lastName: contactTable.lastName, + title: contactTable.title, + }) + .from(contactTable) + .where( + and( + eq(contactTable.companyId, contact.companyId), + ne(contactTable.id, contactId), + ), + ) + .orderBy(desc(contactTable.lastActivityAt)) + .limit(8) : Promise.resolve([]), ]); @@ -324,10 +380,13 @@ export async function readCrmHistory( } export async function stampSocialsChecked(contactId: string): Promise { - await db.contact.update({ - where: { id: contactId }, - data: { socialsCheckedAt: new Date() }, - }); + await requireRow( + db + .update(contactTable) + .set({ socialsCheckedAt: new Date() }) + .where(whereEq(contactTable.id, contactId)) + .returning(), + ); } export async function setEnrichmentStatus( @@ -335,47 +394,59 @@ export async function setEnrichmentStatus( status: EnrichmentStatus, error?: string, ): Promise { - await db.contact.update({ - where: { id: contactId }, - data: { - enrichmentStatus: status, - enrichmentError: error ?? null, - enrichedAt: status === EnrichmentStatus.COMPLETE ? new Date() : undefined, - }, - }); + await requireRow( + db + .update(contactTable) + .set({ + enrichmentStatus: status, + enrichmentError: error ?? null, + enrichedAt: + status === EnrichmentStatus.COMPLETE ? new Date() : undefined, + }) + .where(whereEq(contactTable.id, contactId)) + .returning(), + ); } export async function writeTimelineNote( contactId: string, subject: string, body: string, - meta: Prisma.InputJsonObject = {}, + meta: JsonObject = {}, ): Promise { - const contact = await db.contact.findUnique({ - where: { id: contactId }, - select: { companyId: true, ownerId: true }, - }); + const contact = await firstRow( + db + .select({ + companyId: contactTable.companyId, + ownerId: contactTable.ownerId, + }) + .from(contactTable) + .where(whereEq(contactTable.id, contactId)) + .limit(1), + ); if (!contact) return null; const author = contact.ownerId ?? - (await db.user.findFirst({ select: { id: true } }))?.id ?? + (await firstRow(db.select({ id: user.id }).from(user).limit(1)))?.id ?? null; if (!author) return null; - const activity = await db.activity.create({ - data: { - type: "NOTE", - subject, - body, - occurredAt: new Date(), - contactId, - companyId: contact.companyId, - createdById: author, - meta: { ...meta, agent: "people-research" }, - }, - select: { id: true }, - }); + const activity = await requireRow( + db + .insert(activityTable) + .values({ + type: "NOTE", + subject, + body, + occurredAt: new Date(), + contactId, + companyId: contact.companyId, + createdById: author, + meta: { ...meta, agent: "people-research" }, + }) + .returning({ id: activityTable.id }), + ); return activity.id; } diff --git a/apps/agent/agent/lib/custom-agent-dispatch.ts b/apps/agent/agent/lib/custom-agent-dispatch.ts index a5c8e8c9a..0385104e0 100644 --- a/apps/agent/agent/lib/custom-agent-dispatch.ts +++ b/apps/agent/agent/lib/custom-agent-dispatch.ts @@ -1,4 +1,37 @@ -import { db, type Prisma } from "@crm/db"; +import { + agentAction, + agentAuditEvent, + agentConversation, + agentConversationAttachment, + agentConversationSubmission, + agentDefinition, + agentRun, + agentRunEvent, + agentTrigger, + and, + asc, + db, + type Executor, + eq, + firstRow, + inArray, + isNotNull, + isNull, + type JsonObject, + type JsonValue, + lt, + lte, + type NewAgentRun, + ne, + notExists, + or, + orNull, + orThrow, + queryRows, + requireRow, + sql, + whereEq, +} from "@crm/db"; import { CRM_EVENT_CATALOG } from "@crm/db/crm-events"; import { lockIdempotencyKey } from "@crm/db/idempotency"; import { crmEventTask } from "@crm/validation/agent-events"; @@ -69,18 +102,37 @@ const builderSubmissionMessage = z export async function pendingBuilderSubmissionIds(): Promise { await recoverBuilderSubmissions(); - const rows = await db.agentConversationSubmission.findMany({ - where: { - status: "PENDING", - conversation: { - kind: "BUILDER", - OR: [{ sessionId: null }, { continuationToken: { not: null } }], - }, - }, - orderBy: [{ createdAt: "asc" }, { id: "asc" }], - take: BUILDER_BATCH * 3, - select: { id: true, conversationId: true }, - }); + const rows = await db + .select({ + id: agentConversationSubmission.id, + conversationId: agentConversationSubmission.conversationId, + }) + .from(agentConversationSubmission) + .where( + and( + eq(agentConversationSubmission.status, "PENDING"), + inArray( + agentConversationSubmission.conversationId, + db + .select({ id: agentConversation.id }) + .from(agentConversation) + .where( + and( + eq(agentConversation.kind, "BUILDER"), + or( + isNull(agentConversation.sessionId), + isNotNull(agentConversation.continuationToken), + ), + ), + ), + ), + ), + ) + .orderBy( + asc(agentConversationSubmission.createdAt), + asc(agentConversationSubmission.id), + ) + .limit(BUILDER_BATCH * 3); const seen = new Set(); return rows @@ -102,11 +154,14 @@ export async function dispatchBuilderSubmission( submissionId: string, send: SendFn, ) { - const submission = await db.$transaction(async (tx) => { - const seed = await tx.agentConversationSubmission.findUnique({ - where: { id: submissionId }, - select: { conversationId: true }, - }); + const submission = await db.transaction(async (tx) => { + const seed = await firstRow( + tx + .select({ conversationId: agentConversationSubmission.conversationId }) + .from(agentConversationSubmission) + .where(whereEq(agentConversationSubmission.id, submissionId)) + .limit(1), + ); if (!seed) throw new Error("Builder submission is unavailable."); const conversation = await lockBuilderConversation(tx, seed.conversationId); @@ -118,15 +173,40 @@ export async function dispatchBuilderSubmission( } const [active, firstPending] = await Promise.all([ - tx.agentConversationSubmission.findFirst({ - where: { conversationId: conversation.id, status: "SENDING" }, - select: { id: true }, - }), - tx.agentConversationSubmission.findFirst({ - where: { conversationId: conversation.id, status: "PENDING" }, - orderBy: [{ createdAt: "asc" }, { id: "asc" }], - select: { id: true }, - }), + firstRow( + tx + .select({ id: agentConversationSubmission.id }) + .from(agentConversationSubmission) + .where( + and( + whereEq( + agentConversationSubmission.conversationId, + conversation.id, + ), + eq(agentConversationSubmission.status, "SENDING"), + ), + ) + .limit(1), + ), + firstRow( + tx + .select({ id: agentConversationSubmission.id }) + .from(agentConversationSubmission) + .where( + and( + whereEq( + agentConversationSubmission.conversationId, + conversation.id, + ), + eq(agentConversationSubmission.status, "PENDING"), + ), + ) + .orderBy( + asc(agentConversationSubmission.createdAt), + asc(agentConversationSubmission.id), + ) + .limit(1), + ), ]); if (active || firstPending?.id !== submissionId) { throw new Error( @@ -134,47 +214,53 @@ export async function dispatchBuilderSubmission( ); } - await tx.agentConversationSubmission.update({ - where: { id: submissionId }, - data: { - status: "SENDING", - attemptCount: { increment: 1 }, - sentAt: new Date(), - errorCode: null, - errorMessage: null, - }, - }); - await tx.agentConversation.update({ - where: { id: conversation.id }, - data: { continuationToken: null }, - }); + await requireRow( + tx + .update(agentConversationSubmission) + .set({ + status: "SENDING", + attemptCount: sql`${agentConversationSubmission.attemptCount} + 1`, + sentAt: new Date(), + errorCode: null, + errorMessage: null, + }) + .where(whereEq(agentConversationSubmission.id, submissionId)) + .returning(), + ); + await requireRow( + tx + .update(agentConversation) + .set({ continuationToken: null }) + .where(whereEq(agentConversation.id, conversation.id)) + .returning(), + ); - return tx.agentConversationSubmission.findUniqueOrThrow({ - where: { id: submissionId }, - select: { - id: true, - commandType: true, - message: true, - attemptCount: true, - attachments: { - orderBy: { position: "asc" }, - select: { - name: true, - mediaType: true, - content: true, - }, + return tx.query.agentConversationSubmission + .findFirst({ + where: whereEq(agentConversationSubmission.id, submissionId), + columns: { + id: true, + commandType: true, + message: true, + attemptCount: true, }, - conversation: { - select: { - id: true, - title: true, - userId: true, - kind: true, - permissionMode: true, + with: { + attachments: { + orderBy: [asc(agentConversationAttachment.position)], + columns: { name: true, mediaType: true, content: true }, + }, + conversation: { + columns: { + id: true, + title: true, + userId: true, + kind: true, + permissionMode: true, + }, }, }, - }, - }); + }) + .then(orThrow); }); const conversationId = submission.conversation.id; @@ -210,63 +296,83 @@ export async function dispatchBuilderSubmission( }, ); - await db.$transaction(async (tx) => { + await db.transaction(async (tx) => { const conversation = await lockBuilderConversation(tx, conversationId); if (!conversation) return; - await tx.agentConversationSubmission.update({ - where: { id: submission.id }, - data: { status: "ACCEPTED", acceptedAt: new Date() }, - }); - await tx.agentConversation.update({ - where: { id: conversationId }, - data: { - sessionId: session.id, - }, - }); + await requireRow( + tx + .update(agentConversationSubmission) + .set({ status: "ACCEPTED", acceptedAt: new Date() }) + .where(whereEq(agentConversationSubmission.id, submission.id)) + .returning(), + ); + await requireRow( + tx + .update(agentConversation) + .set({ + sessionId: session.id, + }) + .where(whereEq(agentConversation.id, conversationId)) + .returning(), + ); }); return session; } catch (error) { const message = error instanceof Error ? error.message : String(error); const retry = submission.attemptCount < MAX_BUILDER_ATTEMPTS; - await db.$transaction(async (tx) => { + await db.transaction(async (tx) => { const conversation = await lockBuilderConversation(tx, conversationId); if (!conversation) return; - await tx.agentConversationSubmission.update({ - where: { id: submission.id }, - data: { - status: retry ? "PENDING" : "FAILED", - errorCode: "DELIVERY_FAILED", - errorMessage: message, - }, - }); - await tx.agentConversation.update({ - where: { id: conversationId }, - data: { continuationToken: builderToken(conversationId) }, - }); + await requireRow( + tx + .update(agentConversationSubmission) + .set({ + status: retry ? "PENDING" : "FAILED", + errorCode: "DELIVERY_FAILED", + errorMessage: message, + }) + .where(whereEq(agentConversationSubmission.id, submission.id)) + .returning(), + ); + await requireRow( + tx + .update(agentConversation) + .set({ continuationToken: builderToken(conversationId) }) + .where(whereEq(agentConversation.id, conversationId)) + .returning(), + ); }); throw error; } } export async function queueDueAgentRuns(now = new Date()): Promise { - const triggers = await db.agentTrigger.findMany({ - where: { - enabled: true, - type: "SCHEDULE", - nextRunAt: { lte: now }, - agent: { status: "LIVE" }, - }, - orderBy: [{ nextRunAt: "asc" }, { id: "asc" }], - take: RUN_BATCH, - select: { - id: true, - agentId: true, - versionId: true, - nextRunAt: true, - config: true, - }, - }); + const triggers = await db + .select({ + id: agentTrigger.id, + agentId: agentTrigger.agentId, + versionId: agentTrigger.versionId, + nextRunAt: agentTrigger.nextRunAt, + config: agentTrigger.config, + }) + .from(agentTrigger) + .where( + and( + eq(agentTrigger.enabled, true), + eq(agentTrigger.type, "SCHEDULE"), + lte(agentTrigger.nextRunAt, now), + inArray( + agentTrigger.agentId, + db + .select({ id: agentDefinition.id }) + .from(agentDefinition) + .where(eq(agentDefinition.status, "LIVE")), + ), + ), + ) + .orderBy(asc(agentTrigger.nextRunAt), asc(agentTrigger.id)) + .limit(RUN_BATCH); let queued = 0; for (const trigger of triggers) { @@ -277,28 +383,33 @@ export async function queueDueAgentRuns(now = new Date()): Promise { ).intervalMinutes; const nextRunAt = advance(scheduledAt, intervalMinutes, now); const idempotencyKey = `${trigger.id}:${scheduledAt.toISOString()}`; - const claimed = await db.$transaction(async (tx) => { - const [agent] = await tx.$queryRaw>` + const claimed = await db.transaction(async (tx) => { + const [agent] = await queryRows<{ id: string; status: string }>( + tx, + sql` SELECT id, status FROM "agentDefinition" WHERE id = ${trigger.agentId} FOR UPDATE - `; + `, + ); if (agent?.status !== "LIVE") return false; - const updated = await tx.agentTrigger.updateMany({ - where: { - id: trigger.id, - nextRunAt: scheduledAt, - enabled: true, - }, - data: { nextRunAt, lastRunAt: scheduledAt }, - }); - if (updated.count === 0) return false; - - await tx.agentRun.upsert({ - where: { idempotencyKey }, - create: { + const updated = await tx + .update(agentTrigger) + .set({ nextRunAt, lastRunAt: scheduledAt }) + .where( + and( + whereEq(agentTrigger.id, trigger.id), + whereEq(agentTrigger.nextRunAt, scheduledAt), + eq(agentTrigger.enabled, true), + ), + ); + if (updated.rowCount === 0) return false; + + await queueRun( + tx, + { agentId: trigger.agentId, versionId: trigger.versionId, triggerId: trigger.id, @@ -306,12 +417,9 @@ export async function queueDueAgentRuns(now = new Date()): Promise { idempotencyKey, correlationId: crypto.randomUUID(), input: { scheduledFor: scheduledAt.toISOString() }, - events: { - create: { sequence: 0, type: "run.queued", data: {} }, - }, }, - update: {}, - }); + {}, + ); return true; }); if (claimed) queued += 1; @@ -345,42 +453,61 @@ export async function queueEventAgentRuns( throw new Error("The queued agent event is invalid."); } - const triggers = await db.agentTrigger.findMany({ - where: { - enabled: true, - type: "EVENT", - agent: { status: "LIVE" }, - }, - orderBy: { id: "asc" }, - select: { - id: true, - agentId: true, - versionId: true, - config: true, - }, - }); + const triggers = await db + .select({ + id: agentTrigger.id, + agentId: agentTrigger.agentId, + versionId: agentTrigger.versionId, + config: agentTrigger.config, + }) + .from(agentTrigger) + .where( + and( + eq(agentTrigger.enabled, true), + eq(agentTrigger.type, "EVENT"), + inArray( + agentTrigger.agentId, + db + .select({ id: agentDefinition.id }) + .from(agentDefinition) + .where(eq(agentDefinition.status, "LIVE")), + ), + ), + ) + .orderBy(asc(agentTrigger.id)); let matched = 0; for (const trigger of triggers) { if (readAgentTriggerConfig(trigger.config).event !== eventType) continue; const idempotencyKey = `event:${task.id}:trigger:${trigger.id}`; - const queued = await db.$transaction(async (tx) => { + const queued = await db.transaction(async (tx) => { await lockIdempotencyKey(tx, idempotencyKey); - const eligible = await tx.agentTrigger.findFirst({ - where: { - id: trigger.id, - enabled: true, - type: "EVENT", - agent: { status: "LIVE" }, - }, - select: { id: true }, - }); + const eligible = await firstRow( + tx + .select({ id: agentTrigger.id }) + .from(agentTrigger) + .where( + and( + whereEq(agentTrigger.id, trigger.id), + eq(agentTrigger.enabled, true), + eq(agentTrigger.type, "EVENT"), + inArray( + agentTrigger.agentId, + db + .select({ id: agentDefinition.id }) + .from(agentDefinition) + .where(eq(agentDefinition.status, "LIVE")), + ), + ), + ) + .limit(1), + ); if (!eligible) return false; - await tx.agentRun.upsert({ - where: { idempotencyKey }, - create: { + await queueRun( + tx, + { agentId: trigger.agentId, versionId: trigger.versionId, triggerId: trigger.id, @@ -391,20 +518,18 @@ export async function queueEventAgentRuns( event: { type: eventType, occurredAt, data }, record: { kind: recordKind, id: recordId }, }, - events: { - create: { - sequence: 0, - type: "run.queued", - data: { eventType, taskId: task.id }, - }, - }, }, - update: {}, - }); - await tx.agentTrigger.updateMany({ - where: { id: trigger.id, enabled: true }, - data: { lastRunAt: occurredAtDate }, - }); + { eventType, taskId: task.id }, + ); + await tx + .update(agentTrigger) + .set({ lastRunAt: occurredAtDate }) + .where( + and( + whereEq(agentTrigger.id, trigger.id), + eq(agentTrigger.enabled, true), + ), + ); return true; }); if (queued) matched += 1; @@ -415,20 +540,45 @@ export async function queueEventAgentRuns( export async function pendingAgentRunIds(): Promise { await recoverAgentRuns(); - const rows = await db.agentRun.findMany({ - where: { - status: "QUEUED", - agent: { - status: "LIVE", - runs: { - none: { status: { in: ["RUNNING", "WAITING_FOR_APPROVAL"] } }, - }, - }, - }, - orderBy: [{ createdAt: "asc" }, { id: "asc" }], - take: RUN_BATCH * 4, - select: { id: true, agentId: true, versionId: true }, - }); + const rows = await db + .select({ + id: agentRun.id, + agentId: agentRun.agentId, + versionId: agentRun.versionId, + }) + .from(agentRun) + .where( + and( + eq(agentRun.status, "QUEUED"), + inArray( + agentRun.agentId, + db + .select({ id: agentDefinition.id }) + .from(agentDefinition) + .where( + and( + eq(agentDefinition.status, "LIVE"), + notExists( + db + .select({ id: agentRun.agentId }) + .from(agentRun) + .where( + and( + eq(agentRun.agentId, agentDefinition.id), + inArray(agentRun.status, [ + "RUNNING", + "WAITING_FOR_APPROVAL", + ]), + ), + ), + ), + ), + ), + ), + ), + ) + .orderBy(asc(agentRun.createdAt), asc(agentRun.id)) + .limit(RUN_BATCH * 4); const runnable: string[] = []; const selectedAgents = new Set(); @@ -477,52 +627,62 @@ export async function drainAgentRuns(send: SendFn): Promise { } export async function dispatchAgentRun(runId: string, send: SendFn) { - const run = await db.agentRun.findUnique({ - where: { id: runId }, - select: { - id: true, - status: true, - agentId: true, - versionId: true, - initiatedById: true, - agent: { - select: { name: true, createdById: true, status: true }, + const run = await db.query.agentRun + .findFirst({ + where: whereEq(agentRun.id, runId), + columns: { + id: true, + status: true, + agentId: true, + versionId: true, + initiatedById: true, }, - version: { select: { modelId: true } }, - }, - }); + with: { + agent: { columns: { name: true, createdById: true, status: true } }, + version: { columns: { modelId: true } }, + }, + }) + .then(orNull); if (run?.status !== "QUEUED" || run.agent.status !== "LIVE") { throw new Error("Agent run was already claimed or is not live."); } - const claim = await db.$transaction(async (tx) => { - const [agent] = await tx.$queryRaw>` + const claim = await db.transaction(async (tx) => { + const [agent] = await queryRows<{ id: string; status: string }>( + tx, + sql` SELECT id, status FROM "agentDefinition" WHERE id = ${run.agentId} FOR UPDATE - `; + `, + ); if (agent?.status !== "LIVE") return "unavailable" as const; - const active = await tx.agentRun.findFirst({ - where: { - agentId: run.agentId, - id: { not: run.id }, - status: { in: ["RUNNING", "WAITING_FOR_APPROVAL"] }, - }, - select: { id: true }, - }); + const active = await firstRow( + tx + .select({ id: agentRun.id }) + .from(agentRun) + .where( + and( + whereEq(agentRun.agentId, run.agentId), + ne(agentRun.id, run.id), + inArray(agentRun.status, ["RUNNING", "WAITING_FOR_APPROVAL"]), + ), + ) + .limit(1), + ); if (active) return "deferred" as const; - const updated = await tx.agentRun.updateMany({ - where: { id: runId, status: "QUEUED" }, - data: { + const updated = await tx + .update(agentRun) + .set({ status: "RUNNING", startedAt: new Date(), modelId: run.version.modelId, - }, - }); - return updated.count === 1 + }) + .where(and(whereEq(agentRun.id, runId), eq(agentRun.status, "QUEUED"))); + return updated.rowCount === 1 ? ("claimed" as const) : ("unavailable" as const); }); @@ -553,10 +713,10 @@ export async function dispatchAgentRun(runId: string, send: SendFn) { title: `${run.agent.name} run`, }); - await db.agentRun.updateMany({ - where: { id: run.id, status: "RUNNING" }, - data: { sessionId: session.id }, - }); + await db + .update(agentRun) + .set({ sessionId: session.id }) + .where(and(whereEq(agentRun.id, run.id), eq(agentRun.status, "RUNNING"))); return session; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -566,7 +726,7 @@ export async function dispatchAgentRun(runId: string, send: SendFn) { } export async function failRun(runId: string, code: string, message: string) { - return db.$transaction(async (tx) => { + return db.transaction(async (tx) => { const run = await lockAgentRun(tx, runId); if (run.status === "FAILED") { return { id: run.id, status: "FAILED" as const }; @@ -577,52 +737,61 @@ export async function failRun(runId: string, code: string, message: string) { const sequence = run.nextEventSequence + 1; const finishedAt = new Date(); - await tx.agentRun.update({ - where: { id: runId }, - data: { - status: "FAILED", - errorCode: code, - errorMessage: message, - finishedAt, - nextEventSequence: sequence, - }, - }); - await tx.agentRunEvent.create({ - data: { - id: runTerminalEventId(run.id, "failed"), - runId: run.id, - sequence, - type: "run.failed", - data: { code, message }, - emittedAt: finishedAt, - }, - }); - await tx.agentAuditEvent.upsert({ - where: { - agentId_type_requestId: { + await requireRow( + tx + .update(agentRun) + .set({ + status: "FAILED", + errorCode: code, + errorMessage: message, + finishedAt, + nextEventSequence: sequence, + }) + .where(whereEq(agentRun.id, runId)) + .returning(), + ); + await requireRow( + tx + .insert(agentRunEvent) + .values({ + id: runTerminalEventId(run.id, "failed"), + runId: run.id, + sequence, + type: "run.failed", + data: { code, message }, + emittedAt: finishedAt, + }) + .returning(), + ); + await requireRow( + tx + .insert(agentAuditEvent) + .values({ agentId: run.agentId, + versionId: run.versionId, + actorType: "AGENT", + actorId: run.id, type: "run.failed", + summary: message, requestId: run.id, - }, - }, - create: { - agentId: run.agentId, - versionId: run.versionId, - actorType: "AGENT", - actorId: run.id, - type: "run.failed", - summary: message, - requestId: run.id, - }, - update: {}, - }); + }) + .onConflictDoUpdate({ + target: [ + agentAuditEvent.agentId, + agentAuditEvent.type, + agentAuditEvent.requestId, + ], + set: { agentId: sql`excluded.${sql.identifier("agentId")}` }, + }) + .returning(), + ); return { id: run.id, status: "FAILED" as const }; }); } export async function cancelRun(runId: string, code: string, message: string) { - return db.$transaction(async (tx) => { + return db.transaction(async (tx) => { const run = await lockAgentRun(tx, runId); if (isTerminalRunStatus(run.status)) { return { id: run.id, status: run.status, settled: false }; @@ -630,54 +799,68 @@ export async function cancelRun(runId: string, code: string, message: string) { const sequence = run.nextEventSequence + 1; const finishedAt = new Date(); - await tx.agentRun.update({ - where: { id: runId }, - data: { - status: "CANCELLED", - errorCode: code, - errorMessage: message, - finishedAt, - nextEventSequence: sequence, - }, - }); - await tx.agentAction.updateMany({ - where: { runId: run.id, status: { in: ["PLANNED", "RUNNING"] } }, - data: { + await requireRow( + tx + .update(agentRun) + .set({ + status: "CANCELLED", + errorCode: code, + errorMessage: message, + finishedAt, + nextEventSequence: sequence, + }) + .where(whereEq(agentRun.id, runId)) + .returning(), + ); + await tx + .update(agentAction) + .set({ status: "CANCELLED", errorCode: code, errorMessage: message, completedAt: finishedAt, - }, - }); - await tx.agentRunEvent.create({ - data: { - id: runTerminalEventId(run.id, "cancelled"), - runId: run.id, - sequence, - type: "run.cancelled", - data: { code, message }, - emittedAt: finishedAt, - }, - }); - await tx.agentAuditEvent.upsert({ - where: { - agentId_type_requestId: { + }) + .where( + and( + whereEq(agentAction.runId, run.id), + inArray(agentAction.status, ["PLANNED", "RUNNING"]), + ), + ); + await requireRow( + tx + .insert(agentRunEvent) + .values({ + id: runTerminalEventId(run.id, "cancelled"), + runId: run.id, + sequence, + type: "run.cancelled", + data: { code, message }, + emittedAt: finishedAt, + }) + .returning(), + ); + await requireRow( + tx + .insert(agentAuditEvent) + .values({ agentId: run.agentId, + versionId: run.versionId, + actorType: "AGENT", + actorId: run.id, type: "run.cancelled", + summary: message, requestId: run.id, - }, - }, - create: { - agentId: run.agentId, - versionId: run.versionId, - actorType: "AGENT", - actorId: run.id, - type: "run.cancelled", - summary: message, - requestId: run.id, - }, - update: {}, - }); + }) + .onConflictDoUpdate({ + target: [ + agentAuditEvent.agentId, + agentAuditEvent.type, + agentAuditEvent.requestId, + ], + set: { agentId: sql`excluded.${sql.identifier("agentId")}` }, + }) + .returning(), + ); return { id: run.id, status: "CANCELLED" as const, settled: true }; }); @@ -701,26 +884,35 @@ export function runIdFromToken(token: string | undefined): string | null { async function recoverBuilderSubmissions() { const stale = new Date(Date.now() - BUILDER_LEASE_MS); - const rows = await db.agentConversationSubmission.findMany({ - where: { - status: "SENDING", - sentAt: { lt: stale }, - }, - orderBy: [{ sentAt: "asc" }, { id: "asc" }], - take: BUILDER_BATCH * 3, - select: { id: true, conversationId: true, attemptCount: true }, - }); + const rows = await db + .select({ + id: agentConversationSubmission.id, + conversationId: agentConversationSubmission.conversationId, + attemptCount: agentConversationSubmission.attemptCount, + }) + .from(agentConversationSubmission) + .where( + and( + eq(agentConversationSubmission.status, "SENDING"), + lt(agentConversationSubmission.sentAt, stale), + ), + ) + .orderBy( + asc(agentConversationSubmission.sentAt), + asc(agentConversationSubmission.id), + ) + .limit(BUILDER_BATCH * 3); for (const row of rows) { - await db.$transaction(async (tx) => { + await db.transaction(async (tx) => { const conversation = await lockBuilderConversation( tx, row.conversationId, ); if (conversation?.kind !== "BUILDER") return; - const claimed = await tx.agentConversationSubmission.updateMany({ - where: { id: row.id, status: "SENDING", sentAt: { lt: stale } }, - data: + const claimed = await tx + .update(agentConversationSubmission) + .set( row.attemptCount < MAX_BUILDER_ATTEMPTS ? { status: "PENDING" } : { @@ -729,13 +921,25 @@ async function recoverBuilderSubmissions() { errorMessage: "The builder could not accept this message after three attempts.", }, - }); - if (claimed.count === 0) return; - - await tx.agentConversation.updateMany({ - where: { id: row.conversationId, kind: "BUILDER" }, - data: { continuationToken: builderToken(row.conversationId) }, - }); + ) + .where( + and( + whereEq(agentConversationSubmission.id, row.id), + eq(agentConversationSubmission.status, "SENDING"), + lt(agentConversationSubmission.sentAt, stale), + ), + ); + if (claimed.rowCount === 0) return; + + await tx + .update(agentConversation) + .set({ continuationToken: builderToken(row.conversationId) }) + .where( + and( + whereEq(agentConversation.id, row.conversationId), + eq(agentConversation.kind, "BUILDER"), + ), + ); }); } } @@ -749,15 +953,18 @@ export type LockedBuilderConversation = { }; export async function lockBuilderConversation( - tx: Prisma.TransactionClient, + tx: Executor, conversationId: string, ): Promise { - const [conversation] = await tx.$queryRaw` + const [conversation] = await queryRows( + tx, + sql` SELECT id, kind, "sessionId", "continuationToken", "streamIndex" FROM "agentConversation" WHERE id = ${conversationId} FOR UPDATE - `; + `, + ); return conversation ?? null; } @@ -765,16 +972,18 @@ export const RUN_TIMED_OUT = "RUN_TIMED_OUT"; async function timeOutOverrunningRuns() { const overrun = new Date(Date.now() - DISPATCH.run.executionTimeoutMs); - const rows = await db.agentRun.findMany({ - where: { - status: "RUNNING", - sessionId: { not: null }, - startedAt: { lt: overrun }, - }, - orderBy: [{ startedAt: "asc" }, { id: "asc" }], - take: RUN_BATCH * 3, - select: { id: true }, - }); + const rows = await db + .select({ id: agentRun.id }) + .from(agentRun) + .where( + and( + eq(agentRun.status, "RUNNING"), + isNotNull(agentRun.sessionId), + lt(agentRun.startedAt, overrun), + ), + ) + .orderBy(asc(agentRun.startedAt), asc(agentRun.id)) + .limit(RUN_BATCH * 3); const minutes = Math.round(DISPATCH.run.executionTimeoutMs / 60_000); for (const row of rows) { @@ -790,25 +999,30 @@ async function recoverAgentRuns() { await timeOutOverrunningRuns(); const stale = new Date(Date.now() - RUN_DELIVERY_LEASE_MS); - const rows = await db.agentRun.findMany({ - where: { - status: "RUNNING", - sessionId: null, - startedAt: { lt: stale }, - }, - orderBy: [{ startedAt: "asc" }, { id: "asc" }], - take: RUN_BATCH * 3, - select: { id: true, agentId: true }, - }); + const rows = await db + .select({ id: agentRun.id, agentId: agentRun.agentId }) + .from(agentRun) + .where( + and( + eq(agentRun.status, "RUNNING"), + isNull(agentRun.sessionId), + lt(agentRun.startedAt, stale), + ), + ) + .orderBy(asc(agentRun.startedAt), asc(agentRun.id)) + .limit(RUN_BATCH * 3); for (const row of rows) { - await db.$transaction(async (tx) => { - const [agent] = await tx.$queryRaw>` + await db.transaction(async (tx) => { + const [agent] = await queryRows<{ status: string }>( + tx, + sql` SELECT status FROM "agentDefinition" WHERE id = ${row.agentId} FOR UPDATE - `; + `, + ); const run = await lockAgentRun(tx, row.id); if ( run.status !== "RUNNING" || @@ -821,42 +1035,50 @@ async function recoverAgentRuns() { const sequence = run.nextEventSequence + 1; const cancelled = agent?.status !== "LIVE" && agent?.status !== "PAUSED"; - await tx.agentRun.update({ - where: { id: run.id }, - data: cancelled - ? { - status: "CANCELLED", - errorCode: "AGENT_UNAVAILABLE", - errorMessage: - "The agent was unavailable when delivery recovery ran.", - finishedAt: new Date(), - nextEventSequence: sequence, - } - : { - status: "QUEUED", - startedAt: null, - errorCode: null, - errorMessage: null, - finishedAt: null, - nextEventSequence: sequence, - }, - }); - await tx.agentRunEvent.create({ - data: { - id: `run-delivery-${cancelled ? "cancelled" : "recovered"}:${run.id}:${run.startedAt.toISOString()}`, - runId: run.id, - sequence, - type: cancelled ? "run.cancelled" : "run.delivery_recovered", - data: cancelled ? { reason: "agent.unavailable" } : {}, - }, - }); + await requireRow( + tx + .update(agentRun) + .set( + cancelled + ? { + status: "CANCELLED", + errorCode: "AGENT_UNAVAILABLE", + errorMessage: + "The agent was unavailable when delivery recovery ran.", + finishedAt: new Date(), + nextEventSequence: sequence, + } + : { + status: "QUEUED", + startedAt: null, + errorCode: null, + errorMessage: null, + finishedAt: null, + nextEventSequence: sequence, + }, + ) + .where(whereEq(agentRun.id, run.id)) + .returning(), + ); + await requireRow( + tx + .insert(agentRunEvent) + .values({ + id: `run-delivery-${cancelled ? "cancelled" : "recovered"}:${run.id}:${run.startedAt.toISOString()}`, + runId: run.id, + sequence, + type: cancelled ? "run.cancelled" : "run.delivery_recovered", + data: cancelled ? { reason: "agent.unavailable" } : {}, + }) + .returning(), + ); }); } } export function builderDeliveryMessage( submissionId: string, - value: Prisma.JsonValue, + value: JsonValue, attachments: readonly BuilderDeliveryAttachment[] = [], ): Parameters[0] { const message = builderSubmissionMessage.parse(value); @@ -901,7 +1123,7 @@ export function builderDeliveryMessage( export function builderCommandType( commandType: string, - value: Prisma.JsonValue, + value: JsonValue, ): string { const { requestId, optionId, text } = builderSubmissionMessage.parse(value).inputResponse; @@ -936,3 +1158,19 @@ function permissionModeAttribute(mode: string): string { if (mode === "BYPASS") return "bypass"; return "manual"; } + +async function queueRun( + tx: Executor, + run: NewAgentRun, + data: JsonObject, +): Promise { + const [created] = await tx + .insert(agentRun) + .values(run) + .onConflictDoNothing({ target: agentRun.idempotencyKey }) + .returning({ id: agentRun.id }); + if (!created) return; + await tx + .insert(agentRunEvent) + .values({ runId: created.id, sequence: 0, type: "run.queued", data }); +} diff --git a/apps/agent/agent/lib/enrichment.ts b/apps/agent/agent/lib/enrichment.ts index 5155f75e8..2ea61c760 100644 --- a/apps/agent/agent/lib/enrichment.ts +++ b/apps/agent/agent/lib/enrichment.ts @@ -1,4 +1,21 @@ -import { db, EnrichmentStatus, type Prisma } from "@crm/db"; +import { + agentTask, + and, + company, + contact, + db, + EnrichmentStatus, + eq, + firstRow, + inArray, + isNull, + lt, + ne, + or, + type PgColumn, + type SQL, + whereEq, +} from "@crm/db"; import { ownsCompanyStatus, ownsContactStatus } from "@crm/db/agent-tasks"; import type { TaskSubject } from "./tasks"; @@ -7,7 +24,7 @@ type StatusGuard = | { not: EnrichmentStatus } | { in: EnrichmentStatus[] }; -type SettleGuard = { +export type SettleGuard = { enrichmentStatus?: StatusGuard; OR?: Array<{ enrichmentStatus: EnrichmentStatus; @@ -24,6 +41,43 @@ export const UNLESS_COMPLETE = { enrichmentStatus: { not: EnrichmentStatus.COMPLETE }, } as const; +type GuardColumns = { enrichmentStatus: PgColumn; updatedAt: PgColumn }; + +const STATUSES = new Set(Object.values(EnrichmentStatus)); + +function isStatus(guard: StatusGuard): guard is EnrichmentStatus { + return STATUSES.has(guard); +} + +function statusSql(column: PgColumn, guard: StatusGuard): SQL { + if (isStatus(guard)) return eq(column, guard); + if ("not" in guard) return ne(column, guard.not); + return inArray(column, guard.in); +} + +export function guardSql( + columns: GuardColumns, + guard: SettleGuard, +): SQL | undefined { + return and( + guard.enrichmentStatus === undefined + ? undefined + : statusSql(columns.enrichmentStatus, guard.enrichmentStatus), + guard.OR === undefined + ? undefined + : or( + ...guard.OR.map((branch) => + and( + eq(columns.enrichmentStatus, branch.enrichmentStatus), + branch.updatedAt === undefined + ? undefined + : lt(columns.updatedAt, branch.updatedAt.lt), + ), + ), + ), + ); +} + function ownedColumns(subject: TaskSubject): OwnedColumns { return { contactId: @@ -69,17 +123,17 @@ async function write( }; if (owned.contactId) { - await db.contact.updateMany({ - where: { id: owned.contactId, ...guard }, - data, - }); + await db + .update(contact) + .set(data) + .where(and(eq(contact.id, owned.contactId), guardSql(contact, guard))); } if (owned.companyId) { - await db.company.updateMany({ - where: { id: owned.companyId, ...guard }, - data, - }); + await db + .update(company) + .set(data) + .where(and(eq(company.id, owned.companyId), guardSql(company, guard))); } } @@ -130,24 +184,38 @@ async function settleable( } async function taskEndedAt(taskId: string): Promise { - const task = await db.agentTask.findUnique({ - where: { id: taskId }, - select: { finishedAt: true }, - }); + const task = await firstRow( + db + .select({ finishedAt: agentTask.finishedAt }) + .from(agentTask) + .where(whereEq(agentTask.id, taskId)) + .limit(1), + ); return task?.finishedAt ?? null; } async function hasOpenRequest(subject: TaskSubject): Promise { - const owners: Prisma.AgentTaskWhereInput[] = []; - if (subject.contactId) owners.push({ contactId: subject.contactId }); - if (subject.companyId) owners.push({ companyId: subject.companyId }); + const owners: SQL[] = []; + if (subject.contactId) + owners.push(eq(agentTask.contactId, subject.contactId)); + if (subject.companyId) + owners.push(eq(agentTask.companyId, subject.companyId)); if (owners.length === 0) return false; - const open = await db.agentTask.findFirst({ - where: { id: { not: subject.id }, finishedAt: null, OR: owners }, - select: { id: true }, - }); + const open = await firstRow( + db + .select({ id: agentTask.id }) + .from(agentTask) + .where( + and( + ne(agentTask.id, subject.id), + isNull(agentTask.finishedAt), + or(...owners), + ), + ) + .limit(1), + ); return open !== null; } diff --git a/apps/agent/agent/lib/facts.ts b/apps/agent/agent/lib/facts.ts index ff92b1a30..73d0b41d0 100644 --- a/apps/agent/agent/lib/facts.ts +++ b/apps/agent/agent/lib/facts.ts @@ -1,4 +1,19 @@ -import { db, FactBand, FactStatus, type Prisma } from "@crm/db"; +import { + and, + contactBrief, + contactFact, + contact as contactTable, + db, + desc, + eq, + FactBand, + FactStatus, + firstRow, + inArray, + type JsonValue, + requireRow, + whereEq, +} from "@crm/db"; import { type Evidence, scoreEvidence } from "./evidence"; import { currentFocus } from "./focus"; import { isDerivedName, splitName } from "./names"; @@ -88,21 +103,24 @@ export async function recordFact( }; } - const contact = await db.contact.findUnique({ - where: { id: contactId }, - select: { - id: true, - email: true, - firstName: true, - lastName: true, - title: true, - seniority: true, - function: true, - linkedinUrl: true, - twitterUrl: true, - githubUrl: true, - }, - }); + const contact = await firstRow( + db + .select({ + id: contactTable.id, + email: contactTable.email, + firstName: contactTable.firstName, + lastName: contactTable.lastName, + title: contactTable.title, + seniority: contactTable.seniority, + function: contactTable.function, + linkedinUrl: contactTable.linkedinUrl, + twitterUrl: contactTable.twitterUrl, + githubUrl: contactTable.githubUrl, + }) + .from(contactTable) + .where(whereEq(contactTable.id, contactId)) + .limit(1), + ); if (!contact) { return { @@ -113,10 +131,19 @@ export async function recordFact( }; } - const existing = await db.contactFact.findMany({ - where: { contactId, field }, - select: { id: true, value: true, status: true }, - }); + const existing = await db + .select({ + id: contactFact.id, + value: contactFact.value, + status: contactFact.status, + }) + .from(contactFact) + .where( + and( + whereEq(contactFact.contactId, contactId), + whereEq(contactFact.field, field), + ), + ); if ( existing.some( @@ -180,49 +207,63 @@ export async function recordFact( const sessionId = currentFocus().sessionId; - await db.$transaction(async (tx) => { + await db.transaction(async (tx) => { if (applies) { - await tx.contactFact.updateMany({ - where: { - contactId, - field, - status: { in: [FactStatus.APPLIED, FactStatus.PROPOSED] }, - }, - data: { status: FactStatus.SUPERSEDED, supersededAt: new Date() }, - }); + await tx + .update(contactFact) + .set({ status: FactStatus.SUPERSEDED, supersededAt: new Date() }) + .where( + and( + whereEq(contactFact.contactId, contactId), + whereEq(contactFact.field, field), + inArray(contactFact.status, [ + FactStatus.APPLIED, + FactStatus.PROPOSED, + ]), + ), + ); } - await tx.contactFact.create({ - data: { - contactId, - field, - value: trimmed, - score: scored.score, - band: scored.band as FactBand, - evidence: input.evidence as Prisma.InputJsonValue, - method: input.method, - sourceUrl: input.sourceUrl ?? null, - sessionId, - status: applies ? FactStatus.APPLIED : FactStatus.PROPOSED, - }, - }); + await requireRow( + tx + .insert(contactFact) + .values({ + contactId, + field, + value: trimmed, + score: scored.score, + band: scored.band as FactBand, + evidence: input.evidence as JsonValue, + method: input.method, + sourceUrl: input.sourceUrl ?? null, + sessionId, + status: applies ? FactStatus.APPLIED : FactStatus.PROPOSED, + }) + .returning(), + ); if (!applies) return; if (column) { - await tx.contact.update({ - where: { id: contactId }, - data: { [column]: trimmed }, - }); + await requireRow( + tx + .update(contactTable) + .set({ [column]: trimmed }) + .where(whereEq(contactTable.id, contactId)) + .returning(), + ); } if (field === "name") { const split = splitName(trimmed); if (split) { - await tx.contact.update({ - where: { id: contactId }, - data: { firstName: split.firstName, lastName: split.lastName }, - }); + await requireRow( + tx + .update(contactTable) + .set({ firstName: split.firstName, lastName: split.lastName }) + .where(whereEq(contactTable.id, contactId)) + .returning(), + ); } } }); @@ -239,16 +280,41 @@ export async function recordFact( export async function lastEmployerChange(contactId: string) { const [previous, current] = await Promise.all([ - db.contactFact.findFirst({ - where: { contactId, field: "employer", status: FactStatus.SUPERSEDED }, - orderBy: { supersededAt: "desc" }, - select: { value: true, supersededAt: true }, - }), - db.contactFact.findFirst({ - where: { contactId, field: "employer", status: FactStatus.APPLIED }, - orderBy: { observedAt: "desc" }, - select: { value: true, observedAt: true, sourceUrl: true }, - }), + firstRow( + db + .select({ + value: contactFact.value, + supersededAt: contactFact.supersededAt, + }) + .from(contactFact) + .where( + and( + whereEq(contactFact.contactId, contactId), + eq(contactFact.field, "employer"), + eq(contactFact.status, FactStatus.SUPERSEDED), + ), + ) + .orderBy(desc(contactFact.supersededAt)) + .limit(1), + ), + firstRow( + db + .select({ + value: contactFact.value, + observedAt: contactFact.observedAt, + sourceUrl: contactFact.sourceUrl, + }) + .from(contactFact) + .where( + and( + whereEq(contactFact.contactId, contactId), + eq(contactFact.field, "employer"), + eq(contactFact.status, FactStatus.APPLIED), + ), + ) + .orderBy(desc(contactFact.observedAt)) + .limit(1), + ), ]); if (!previous || !current || sameValue(previous.value, current.value)) { @@ -291,18 +357,20 @@ export async function writeBrief(input: { const data = { narrative: input.narrative.trim(), - sections: input.sections as Prisma.InputJsonValue, + sections: input.sections as JsonValue, score: scored.score, sourceUrl: input.sourceUrl ?? null, sessionId: currentFocus().sessionId, refreshedAt: new Date(), }; - await db.contactBrief.upsert({ - where: { contactId: input.contactId }, - create: { contactId: input.contactId, ...data }, - update: data, - }); + await requireRow( + db + .insert(contactBrief) + .values({ contactId: input.contactId, ...data }) + .onConflictDoUpdate({ target: contactBrief.contactId, set: data }) + .returning(), + ); return { written: true, score: scored.score }; } diff --git a/apps/agent/agent/lib/fields.ts b/apps/agent/agent/lib/fields.ts index 8f0b45f7d..3b3881e0b 100644 --- a/apps/agent/agent/lib/fields.ts +++ b/apps/agent/agent/lib/fields.ts @@ -1,4 +1,18 @@ -import { db } from "@crm/db"; +import { + and, + asc, + db, + desc, + type Executor, + eq, + fieldDefinition, + fieldOption, + fieldValue, + firstRow, + isNull, + requireRow, + whereEq, +} from "@crm/db"; import type { FieldEntity, FieldType } from "@crm/db/enums"; import { attachValues, @@ -16,18 +30,33 @@ import { import { lockIdempotencyKey } from "@crm/db/idempotency"; import { currentFocus } from "./focus"; -const WITH_OPTIONS = { options: { orderBy: { position: "asc" } } } as const; +const WITH_OPTIONS = { options: { orderBy: [asc(fieldOption.position)] } }; export type { FieldEntity, FieldType, RecordField, SerializedField }; async function definitionsFor( entity: FieldEntity, ): Promise { - return db.fieldDefinition.findMany({ - where: { entity, archivedAt: null }, - include: WITH_OPTIONS, - orderBy: { position: "asc" }, + return db.query.fieldDefinition.findMany({ + where: and( + eq(fieldDefinition.entity, entity), + isNull(fieldDefinition.archivedAt), + ), + with: WITH_OPTIONS, + orderBy: [asc(fieldDefinition.position)], + }); +} + +async function definitionWithOptions( + executor: Executor, + id: string, +): Promise { + const definition = await executor.query.fieldDefinition.findFirst({ + where: eq(fieldDefinition.id, id), + with: WITH_OPTIONS, }); + if (!definition) throw new Error(`No field definition with id ${id}.`); + return definition; } export async function listFields( @@ -45,7 +74,7 @@ export async function readFields( const [definitions, rows] = await Promise.all([ definitionsFor(entity), - db.fieldValue.findMany({ where: { [column]: recordId } }), + db.select().from(fieldValue).where(eq(fieldValue[column], recordId)), ]); return attachValues(definitions, rows); @@ -81,7 +110,7 @@ export async function writeField(input: { const isBackfill = currentFocus().taskKind === "field-backfill"; try { - return await db.$transaction(async (tx) => { + return await db.transaction(async (tx) => { if (isBackfill) { const column = recordColumn(input.entity); await lockIdempotencyKey( @@ -89,9 +118,18 @@ export async function writeField(input: { `field-value:${definition.id}:${input.recordId}`, ); - const row = await tx.fieldValue.findFirst({ - where: { fieldId: definition.id, [column]: input.recordId }, - }); + const row = await firstRow( + tx + .select() + .from(fieldValue) + .where( + and( + eq(fieldValue.fieldId, definition.id), + eq(fieldValue[column], input.recordId), + ), + ) + .limit(1), + ); if (readValue(definition, row ?? undefined) !== null) { return { @@ -129,10 +167,18 @@ export async function createField(input: { return { created: false, reason: "That label does not make a usable key." }; } - const taken = await db.fieldDefinition.findUnique({ - where: { entity_key: { entity: input.entity, key } }, - select: { id: true }, - }); + const taken = await firstRow( + db + .select({ id: fieldDefinition.id }) + .from(fieldDefinition) + .where( + and( + whereEq(fieldDefinition.entity, input.entity), + whereEq(fieldDefinition.key, key), + ), + ) + .limit(1), + ); if (taken) { return { @@ -145,30 +191,40 @@ export async function createField(input: { return { created: false, reason: "A select needs at least one option." }; } - const last = await db.fieldDefinition.findFirst({ - where: { entity: input.entity }, - orderBy: { position: "desc" }, - select: { position: true }, - }); - - const definition = await db.fieldDefinition.create({ - data: { - entity: input.entity, - key, - label: input.label, - type: input.type, - agentBrief: input.agentBrief ?? null, - position: (last?.position ?? -1) + 1, - options: usesOptions(input.type) - ? { - create: (input.options ?? []).map((label, index) => ({ - label, - position: index, - })), - } - : undefined, - }, - include: WITH_OPTIONS, + const last = await firstRow( + db + .select({ position: fieldDefinition.position }) + .from(fieldDefinition) + .where(whereEq(fieldDefinition.entity, input.entity)) + .orderBy(desc(fieldDefinition.position)) + .limit(1), + ); + + const definition = await db.transaction(async (tx) => { + const created = await requireRow( + tx + .insert(fieldDefinition) + .values({ + entity: input.entity, + key, + label: input.label, + type: input.type, + agentBrief: input.agentBrief ?? null, + position: (last?.position ?? -1) + 1, + }) + .returning({ id: fieldDefinition.id }), + ); + const options = usesOptions(input.type) ? (input.options ?? []) : []; + if (options.length > 0) { + await tx.insert(fieldOption).values( + options.map((label, index) => ({ + fieldId: created.id, + label, + position: index, + })), + ); + } + return definitionWithOptions(tx, created.id); }); return serializeField(definition); @@ -180,10 +236,18 @@ export async function updateFieldBrief(input: { agentBrief: string | null; agentFilled?: boolean; }): Promise { - const existing = await db.fieldDefinition.findUnique({ - where: { entity_key: { entity: input.entity, key: input.key } }, - select: { id: true }, - }); + const existing = await firstRow( + db + .select({ id: fieldDefinition.id }) + .from(fieldDefinition) + .where( + and( + whereEq(fieldDefinition.entity, input.entity), + whereEq(fieldDefinition.key, input.key), + ), + ) + .limit(1), + ); if (!existing) { return { @@ -192,11 +256,11 @@ export async function updateFieldBrief(input: { }; } - const definition = await db.fieldDefinition.update({ - where: { id: existing.id }, - data: { agentBrief: input.agentBrief, agentFilled: input.agentFilled }, - include: WITH_OPTIONS, - }); + await db + .update(fieldDefinition) + .set({ agentBrief: input.agentBrief, agentFilled: input.agentFilled }) + .where(eq(fieldDefinition.id, existing.id)); + const definition = await definitionWithOptions(db, existing.id); return serializeField(definition); } @@ -205,10 +269,18 @@ export async function archiveField(input: { entity: FieldEntity; key: string; }): Promise<{ archived: boolean; reason?: string }> { - const existing = await db.fieldDefinition.findUnique({ - where: { entity_key: { entity: input.entity, key: input.key } }, - select: { id: true }, - }); + const existing = await firstRow( + db + .select({ id: fieldDefinition.id }) + .from(fieldDefinition) + .where( + and( + whereEq(fieldDefinition.entity, input.entity), + whereEq(fieldDefinition.key, input.key), + ), + ) + .limit(1), + ); if (!existing) { return { @@ -217,10 +289,13 @@ export async function archiveField(input: { }; } - await db.fieldDefinition.update({ - where: { id: existing.id }, - data: { archivedAt: new Date() }, - }); + await requireRow( + db + .update(fieldDefinition) + .set({ archivedAt: new Date() }) + .where(whereEq(fieldDefinition.id, existing.id)) + .returning(), + ); return { archived: true }; } diff --git a/apps/agent/agent/lib/lookup.ts b/apps/agent/agent/lib/lookup.ts index 8f8385e73..f8b33de43 100644 --- a/apps/agent/agent/lib/lookup.ts +++ b/apps/agent/agent/lib/lookup.ts @@ -1,4 +1,26 @@ -import { DealStage, db } from "@crm/db"; +import { + and, + asc, + company, + contact, + contains, + DealStage, + db, + deal, + desc, + eq, + equalsInsensitive, + firstRow, + gt, + inArray, + isNotNull, + isNull, + lte, + or, + type SQL, + sql, + user, +} from "@crm/db"; import { LOSING_DEAL_STAGES, OPEN_DEAL_STAGES } from "@crm/db/deal-stage"; import { domainOf, normalise } from "./names"; @@ -75,49 +97,55 @@ export async function listDeals(options: DealListOptions = {}) { ? [...LOSING_DEAL_STAGES] : null; - const rows = await db.deal.findMany({ - where: { - stage: stages ? { in: stages } : undefined, - companyId: options.companyId ?? undefined, - ownerId: options.ownerId ?? undefined, - OR: cutoff - ? [ - { lastActivityAt: { lte: cutoff } }, - { lastActivityAt: null, createdAt: { lte: cutoff } }, - ] - : undefined, - }, - orderBy: [ - { lastActivityAt: { sort: "asc", nulls: "first" } }, - { createdAt: "asc" }, - { id: "asc" }, - ], - cursor: options.cursor ? { id: options.cursor } : undefined, - skip: options.cursor ? 1 : undefined, - take: limit + 1, - select: { - id: true, - name: true, - stage: true, - amount: true, - currency: true, - createdAt: true, - lastActivityAt: true, - expectedCloseDate: true, + const rows = await db + .select({ + id: deal.id, + name: deal.name, + stage: deal.stage, + amount: deal.amount, + currency: deal.currency, + createdAt: deal.createdAt, + lastActivityAt: deal.lastActivityAt, + expectedCloseDate: deal.expectedCloseDate, company: { - select: { - id: true, - name: true, - domain: true, - iconUrl: true, - iconDarkUrl: true, - iconTone: true, - logoUrl: true, - }, + id: company.id, + name: company.name, + domain: company.domain, + iconUrl: company.iconUrl, + iconDarkUrl: company.iconDarkUrl, + iconTone: company.iconTone, + logoUrl: company.logoUrl, }, - owner: { select: { id: true, name: true, email: true, image: true } }, - }, - }); + owner: { + id: user.id, + name: user.name, + email: user.email, + image: user.image, + }, + }) + .from(deal) + .innerJoin(company, eq(company.id, deal.companyId)) + .leftJoin(user, eq(user.id, deal.ownerId)) + .where( + and( + stages ? inArray(deal.stage, stages) : undefined, + options.companyId ? eq(deal.companyId, options.companyId) : undefined, + options.ownerId ? eq(deal.ownerId, options.ownerId) : undefined, + cutoff + ? or( + lte(deal.lastActivityAt, cutoff), + and(isNull(deal.lastActivityAt), lte(deal.createdAt, cutoff)), + ) + : undefined, + options.cursor ? await dealsAfter(options.cursor) : undefined, + ), + ) + .orderBy( + sql`${deal.lastActivityAt} asc nulls first`, + asc(deal.createdAt), + asc(deal.id), + ) + .limit(limit + 1); const hasMore = rows.length > limit; const page = rows.slice(0, limit); @@ -154,6 +182,38 @@ export async function listDeals(options: DealListOptions = {}) { }; } +async function dealsAfter(cursorId: string): Promise { + const cursor = await firstRow( + db + .select({ + id: deal.id, + createdAt: deal.createdAt, + lastActivityAt: deal.lastActivityAt, + }) + .from(deal) + .where(eq(deal.id, cursorId)) + .limit(1), + ); + if (!cursor) return sql`false`; + + const tail = or( + gt(deal.createdAt, cursor.createdAt), + and(eq(deal.createdAt, cursor.createdAt), gt(deal.id, cursor.id)), + ); + + if (cursor.lastActivityAt === null) { + return or( + isNotNull(deal.lastActivityAt), + and(isNull(deal.lastActivityAt), tail), + ); + } + + return or( + gt(deal.lastActivityAt, cursor.lastActivityAt), + and(eq(deal.lastActivityAt, cursor.lastActivityAt), tail), + ); +} + export async function searchCrm( query: string, options: { kinds?: RecordKind[]; limit?: number } = {}, @@ -192,34 +252,33 @@ async function searchContacts( email: string | null, limit: number, ): Promise { - const contains = words.flatMap((word) => [ - { firstName: { contains: word, mode: "insensitive" as const } }, - { lastName: { contains: word, mode: "insensitive" as const } }, - { email: { contains: word, mode: "insensitive" as const } }, + const matches = words.flatMap((word) => [ + contains(contact.firstName, word, { insensitive: true }), + contains(contact.lastName, word, { insensitive: true }), + contains(contact.email, word, { insensitive: true }), ]); - const rows = await db.contact.findMany({ - where: { - OR: [ - ...(email - ? [{ email: { equals: email, mode: "insensitive" as const } }] - : []), - ...contains, - { company: { name: { contains: term, mode: "insensitive" as const } } }, - ], - }, - orderBy: [{ lastActivityAt: "desc" }, { createdAt: "asc" }], - take: limit * 3, - select: { - id: true, - firstName: true, - lastName: true, - title: true, - email: true, - lastActivityAt: true, - company: { select: { id: true, name: true } }, - }, - }); + const rows = await db + .select({ + id: contact.id, + firstName: contact.firstName, + lastName: contact.lastName, + title: contact.title, + email: contact.email, + lastActivityAt: contact.lastActivityAt, + company: { id: company.id, name: company.name }, + }) + .from(contact) + .leftJoin(company, eq(company.id, contact.companyId)) + .where( + or( + email ? equalsInsensitive(contact.email, email) : undefined, + ...matches, + contains(company.name, term, { insensitive: true }), + ), + ) + .orderBy(desc(contact.lastActivityAt), asc(contact.createdAt)) + .limit(limit * 3); return rows .map((row) => { @@ -248,28 +307,29 @@ async function searchCompanies( domain: string | null, limit: number, ): Promise { - const rows = await db.company.findMany({ - where: { - OR: [ - { name: { contains: term, mode: "insensitive" } }, - ...(domain - ? [{ domain: { contains: domain, mode: "insensitive" as const } }] - : []), - ...words.map((word) => ({ - name: { contains: word, mode: "insensitive" as const }, - })), - ], - }, - orderBy: [{ lastActivityAt: "desc" }, { name: "asc" }], - take: limit * 3, - select: { - id: true, - name: true, - domain: true, - industry: true, - _count: { select: { contacts: true, deals: true } }, - }, - }); + const rows = await db + .select({ + id: company.id, + name: company.name, + domain: company.domain, + industry: company.industry, + contacts: db.$count(contact, eq(contact.companyId, company.id)), + deals: db.$count(deal, eq(deal.companyId, company.id)), + }) + .from(company) + .where( + or( + contains(company.name, term, { insensitive: true }), + domain + ? contains(company.domain, domain, { insensitive: true }) + : undefined, + ...words.map((word) => + contains(company.name, word, { insensitive: true }), + ), + ), + ) + .orderBy(desc(company.lastActivityAt), asc(company.name)) + .limit(limit * 3); return rows .map((row) => ({ @@ -280,8 +340,8 @@ async function searchCompanies( name: row.name, domain: row.domain, industry: row.industry, - contacts: row._count.contacts, - deals: row._count.deals, + contacts: row.contacts, + deals: row.deals, }, })) .sort((a, b) => b.score - a.score) @@ -294,27 +354,28 @@ async function searchDeals( words: string[], limit: number, ): Promise { - const rows = await db.deal.findMany({ - where: { - OR: [ - { name: { contains: term, mode: "insensitive" } }, - ...words.map((word) => ({ - name: { contains: word, mode: "insensitive" as const }, - })), - { company: { name: { contains: term, mode: "insensitive" } } }, - ], - }, - orderBy: [{ lastActivityAt: "desc" }, { createdAt: "desc" }], - take: limit * 3, - select: { - id: true, - name: true, - stage: true, - amount: true, - currency: true, - company: { select: { id: true, name: true } }, - }, - }); + const rows = await db + .select({ + id: deal.id, + name: deal.name, + stage: deal.stage, + amount: deal.amount, + currency: deal.currency, + company: { id: company.id, name: company.name }, + }) + .from(deal) + .innerJoin(company, eq(company.id, deal.companyId)) + .where( + or( + contains(deal.name, term, { insensitive: true }), + ...words.map((word) => + contains(deal.name, word, { insensitive: true }), + ), + contains(company.name, term, { insensitive: true }), + ), + ) + .orderBy(desc(deal.lastActivityAt), desc(deal.createdAt)) + .limit(limit * 3); return rows .map((row) => ({ diff --git a/apps/agent/agent/lib/mcp-discovery.ts b/apps/agent/agent/lib/mcp-discovery.ts index 68c768446..4f93ced19 100644 --- a/apps/agent/agent/lib/mcp-discovery.ts +++ b/apps/agent/agent/lib/mcp-discovery.ts @@ -1,4 +1,16 @@ -import { db, type Prisma } from "@crm/db"; +import { + and, + db, + desc, + firstRow, + type JsonValue, + mcpConnection, + mcpToolSnapshot, + ne, + notInArray, + requireRow, + whereEq, +} from "@crm/db"; import { vendorAccess, vendorBySlug } from "@crm/mcp-integrations/catalog"; import { classifyTool, @@ -21,16 +33,24 @@ const toolsReply = z.object({ }); export async function runMcpToolDiscovery( - payload: Prisma.JsonValue | null, + payload: JsonValue | null, ): Promise { const input = discoveryPayload.parse(payload); const vendor = vendorBySlug(input.platformSlug); if (!vendor) throw new Error("The MCP platform is unavailable."); - const connection = await db.mcpConnection.findUnique({ - where: { id: input.connectionId }, - select: { id: true, platformSlug: true, status: true, accessMode: true }, - }); + const connection = await firstRow( + db + .select({ + id: mcpConnection.id, + platformSlug: mcpConnection.platformSlug, + status: mcpConnection.status, + accessMode: mcpConnection.accessMode, + }) + .from(mcpConnection) + .where(whereEq(mcpConnection.id, input.connectionId)) + .limit(1), + ); if ( !connection || connection.platformSlug !== vendor.slug || @@ -45,21 +65,27 @@ export async function runMcpToolDiscovery( connection.accessMode === "READ_ONLY" ? "read_only" : "read_write"; const tools = await listTools(vendorAccess(vendor, mode).url, token.token); await storeTools(connection.id, vendor, tools); - await db.mcpConnection.update({ - where: { id: connection.id }, - data: { - status: "CONNECTED", - lastSuccessfulCallAt: new Date(), - lastErrorCode: null, - }, - }); + await requireRow( + db + .update(mcpConnection) + .set({ + status: "CONNECTED", + lastSuccessfulCallAt: new Date(), + lastErrorCode: null, + }) + .where(whereEq(mcpConnection.id, connection.id)) + .returning(), + ); return `Discovered ${tools.length} ${vendor.name} MCP tools.`; } catch (error) { const message = error instanceof Error ? error.message : "discovery_failed"; - await db.mcpConnection.update({ - where: { id: connection.id }, - data: { status: "ERROR", lastErrorCode: message.slice(0, 120) }, - }); + await requireRow( + db + .update(mcpConnection) + .set({ status: "ERROR", lastErrorCode: message.slice(0, 120) }) + .where(whereEq(mcpConnection.id, connection.id)) + .returning(), + ); throw error; } } @@ -85,10 +111,11 @@ async function storeTools( vendor: NonNullable>, tools: z.infer["tools"], ): Promise { - const existing = await db.mcpToolSnapshot.findMany({ - where: { connectionId }, - orderBy: { createdAt: "desc" }, - }); + const existing = await db + .select() + .from(mcpToolSnapshot) + .where(whereEq(mcpToolSnapshot.connectionId, connectionId)) + .orderBy(desc(mcpToolSnapshot.createdAt)); const latest = new Map(); for (const snapshot of existing) { if (!latest.has(snapshot.name)) latest.set(snapshot.name, snapshot); @@ -96,67 +123,77 @@ async function storeTools( const seen = new Set(tools.map((tool) => tool.name)); const now = new Date(); - await db.$transaction(async (tx) => { + await db.transaction(async (tx) => { for (const tool of tools) { const fingerprint = toolFingerprint(tool); const previous = latest.get(tool.name); if (previous?.fingerprint === fingerprint) { - await tx.mcpToolSnapshot.update({ - where: { id: previous.id }, - data: { - lastSeenAt: now, - reviewState: - previous.reviewState === "REMOVED" ? "PENDING" : undefined, - }, - }); + await requireRow( + tx + .update(mcpToolSnapshot) + .set({ + lastSeenAt: now, + reviewState: + previous.reviewState === "REMOVED" ? "PENDING" : undefined, + }) + .where(whereEq(mcpToolSnapshot.id, previous.id)) + .returning(), + ); continue; } - await tx.mcpToolSnapshot.updateMany({ - where: { - connectionId, - name: tool.name, - reviewState: { not: "REMOVED" }, - }, - data: { reviewState: "CHANGED" }, - }); - await tx.mcpToolSnapshot.upsert({ - where: { - connectionId_name_fingerprint: { + await tx + .update(mcpToolSnapshot) + .set({ reviewState: "CHANGED" }) + .where( + and( + whereEq(mcpToolSnapshot.connectionId, connectionId), + whereEq(mcpToolSnapshot.name, tool.name), + ne(mcpToolSnapshot.reviewState, "REMOVED"), + ), + ); + await requireRow( + tx + .insert(mcpToolSnapshot) + .values({ connectionId, name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + annotations: tool.annotations, fingerprint, - }, - }, - create: { - connectionId, - name: tool.name, - description: tool.description, - inputSchema: tool.inputSchema, - annotations: tool.annotations, - fingerprint, - callKind: classifyTool(vendor, tool), - reviewState: "PENDING", - lastSeenAt: now, - }, - update: { - description: tool.description, - inputSchema: tool.inputSchema, - annotations: tool.annotations, - callKind: classifyTool(vendor, tool), - reviewState: "PENDING", - lastSeenAt: now, - }, - }); + callKind: classifyTool(vendor, tool), + reviewState: "PENDING", + lastSeenAt: now, + }) + .onConflictDoUpdate({ + target: [ + mcpToolSnapshot.connectionId, + mcpToolSnapshot.name, + mcpToolSnapshot.fingerprint, + ], + set: { + description: tool.description, + inputSchema: tool.inputSchema, + annotations: tool.annotations, + callKind: classifyTool(vendor, tool), + reviewState: "PENDING", + lastSeenAt: now, + }, + }) + .returning(), + ); } - await tx.mcpToolSnapshot.updateMany({ - where: { - connectionId, - name: { notIn: [...seen] }, - reviewState: { not: "REMOVED" }, - }, - data: { reviewState: "REMOVED" }, - }); + await tx + .update(mcpToolSnapshot) + .set({ reviewState: "REMOVED" }) + .where( + and( + whereEq(mcpToolSnapshot.connectionId, connectionId), + notInArray(mcpToolSnapshot.name, [...seen]), + ne(mcpToolSnapshot.reviewState, "REMOVED"), + ), + ); }); } diff --git a/apps/agent/agent/lib/mcp-runtime.ts b/apps/agent/agent/lib/mcp-runtime.ts index 9406e021e..f2bfaff5b 100644 --- a/apps/agent/agent/lib/mcp-runtime.ts +++ b/apps/agent/agent/lib/mcp-runtime.ts @@ -1,5 +1,19 @@ import { createHash } from "node:crypto"; -import { db, type Prisma } from "@crm/db"; +import { + agentAction, + agentRun, + and, + db, + eq, + firstRow, + type JsonObject, + type JsonValue, + mcpConnection, + mcpToolSnapshot, + orNull, + requireRow, + whereEq, +} from "@crm/db"; import { WORKSPACE_ID } from "@crm/db/workspace"; import { vendorAccess, vendorBySlug } from "@crm/mcp-integrations/catalog"; import { parseAgentManifest } from "@crm/validation/agent-manifest"; @@ -29,8 +43,8 @@ export const mcpActionResultEvent = z.object({ export function capMcpArchivedResult( type: string, - data: Prisma.InputJsonObject, -): Prisma.InputJsonObject { + data: JsonObject, +): JsonObject { if (type !== "action.result") return data; const parsed = mcpActionResultEvent.safeParse(data); if (!parsed.success || !parsed.data.result.toolName.includes("__")) @@ -58,7 +72,7 @@ export function capMcpArchivedResult( }; } -export function mcpModelOutput(output: Prisma.JsonValue) { +export function mcpModelOutput(output: JsonValue) { const serialized = JSON.stringify(output); const originalBytes = Buffer.byteLength(serialized); if (originalBytes <= MCP.result.modelMaxBytes) { @@ -85,13 +99,19 @@ export async function callMcpTool(input: { platformSlug: string; toolName: string; arguments: z.infer; -}): Promise { +}): Promise { const vendor = vendorBySlug(input.platformSlug); if (!vendor) throw new Error("The MCP platform is unavailable."); - const connection = await db.mcpConnection.findUnique({ - where: { id: input.connectionId }, - select: { status: true, accessMode: true }, - }); + const connection = await firstRow( + db + .select({ + status: mcpConnection.status, + accessMode: mcpConnection.accessMode, + }) + .from(mcpConnection) + .where(whereEq(mcpConnection.id, input.connectionId)) + .limit(1), + ); if (connection?.status !== "CONNECTED") { throw new Error("The MCP platform is disconnected."); } @@ -137,13 +157,13 @@ export async function authorizeMcpTool( if (!toolName) return { type: "denied", reason: "The MCP tool name is invalid." }; - const run = await db.agentRun.findUnique({ - where: { id: runId }, - select: { - agentId: true, - version: { select: { manifest: true } }, - }, - }); + const run = await db.query.agentRun + .findFirst({ + where: whereEq(agentRun.id, runId), + columns: { agentId: true }, + with: { version: { columns: { manifest: true } } }, + }) + .then(orNull); if (!run) return { type: "denied", reason: "The agent run is unavailable." }; const manifest = parseAgentManifest(run.version.manifest); const grant = manifest.mcp.find( @@ -153,28 +173,26 @@ export async function authorizeMcpTool( if (!tool) return { type: "denied", reason: "This agent lacks that MCP tool grant." }; - const connection = await db.mcpConnection.findUnique({ - where: { - organizationId_platformSlug: { - organizationId: WORKSPACE_ID, - platformSlug, - }, - }, - select: { - id: true, - status: true, - accessMode: true, - toolSnapshots: { - where: { - name: toolName, - fingerprint: tool.fingerprint, - reviewState: "REVIEWED", + const connection = await db.query.mcpConnection + .findFirst({ + where: and( + eq(mcpConnection.organizationId, WORKSPACE_ID), + whereEq(mcpConnection.platformSlug, platformSlug), + ), + columns: { id: true, status: true, accessMode: true }, + with: { + toolSnapshots: { + where: and( + whereEq(mcpToolSnapshot.name, toolName), + whereEq(mcpToolSnapshot.fingerprint, tool.fingerprint), + eq(mcpToolSnapshot.reviewState, "REVIEWED"), + ), + limit: 1, + columns: { id: true }, }, - select: { id: true }, - take: 1, }, - }, - }); + }) + .then(orNull); if (connection?.status !== "CONNECTED") { return { type: "denied", reason: "The MCP platform is disconnected." }; } @@ -194,37 +212,43 @@ export async function authorizeMcpTool( if (tool.callKind === "read") return "approved"; const idempotencyKey = `${runId}:${context.callId}`; - const existing = await db.agentAction.findUnique({ - where: { idempotencyKey }, - select: { id: true }, - }); + const existing = await firstRow( + db + .select({ id: agentAction.id }) + .from(agentAction) + .where(whereEq(agentAction.idempotencyKey, idempotencyKey)) + .limit(1), + ); if (existing) { return { type: "denied", reason: "This MCP change already ran." }; } - await db.agentAction.create({ - data: { - agentId: run.agentId, - runId, - type: "mcp.tool.call", - provider: platformSlug, - targetType: "mcp-tool", - targetId: tool.name, - targetLabel: tool.name, - summary: tool.summary, - metadata: { - toolName: tool.name, - callKind: tool.callKind, - fingerprint: tool.fingerprint, - }, - status: "RUNNING", - idempotencyKey, - requestHash: createHash("sha256") - .update(`${platformSlug}:${tool.name}:${context.callId}`) - .digest("hex"), - attemptCount: 1, - startedAt: new Date(), - }, - }); + await requireRow( + db + .insert(agentAction) + .values({ + agentId: run.agentId, + runId, + type: "mcp.tool.call", + provider: platformSlug, + targetType: "mcp-tool", + targetId: tool.name, + targetLabel: tool.name, + summary: tool.summary, + metadata: { + toolName: tool.name, + callKind: tool.callKind, + fingerprint: tool.fingerprint, + }, + status: "RUNNING", + idempotencyKey, + requestHash: createHash("sha256") + .update(`${platformSlug}:${tool.name}:${context.callId}`) + .digest("hex"), + attemptCount: 1, + startedAt: new Date(), + }) + .returning(), + ); return "approved"; } diff --git a/apps/agent/agent/lib/portrait.ts b/apps/agent/agent/lib/portrait.ts index fc5f4009a..3be31107a 100644 --- a/apps/agent/agent/lib/portrait.ts +++ b/apps/agent/agent/lib/portrait.ts @@ -1,4 +1,11 @@ -import { db } from "@crm/db"; +import { + contact as contactTable, + db, + firstRow, + orNull, + requireRow, + whereEq, +} from "@crm/db"; import { blobEnabled, isMirrored, mirror } from "@crm/db/blob"; import { CONTEXT_DEV_PEOPLE, enabled } from "./capabilities"; import { findPortrait, type PortraitSource } from "./portrait-sources"; @@ -48,10 +55,13 @@ export async function storePortrait({ }; } - const contact = await db.contact.findUnique({ - where: { id: contactId }, - select: { imageUrl: true }, - }); + const contact = await firstRow( + db + .select({ imageUrl: contactTable.imageUrl }) + .from(contactTable) + .where(whereEq(contactTable.id, contactId)) + .limit(1), + ); if (!contact) { return { stored: false, imageUrl: null, reason: "No such contact." }; @@ -79,10 +89,13 @@ export async function storePortrait({ return { stored: false, imageUrl: stored, reason: "Unchanged." }; } - await db.contact.update({ - where: { id: contactId }, - data: { imageUrl: stored }, - }); + await requireRow( + db + .update(contactTable) + .set({ imageUrl: stored }) + .where(whereEq(contactTable.id, contactId)) + .returning(), + ); return { stored: true, imageUrl: stored }; } @@ -105,18 +118,20 @@ export async function runPortrait({ }; } - const contact = await db.contact.findUnique({ - where: { id: contactId }, - select: { - id: true, - firstName: true, - lastName: true, - imageUrl: true, - linkedinUrl: true, - githubUrl: true, - company: { select: { name: true, domain: true } }, - }, - }); + const contact = await db.query.contact + .findFirst({ + where: whereEq(contactTable.id, contactId), + columns: { + id: true, + firstName: true, + lastName: true, + imageUrl: true, + linkedinUrl: true, + githubUrl: true, + }, + with: { company: { columns: { name: true, domain: true } } }, + }) + .then(orNull); if (!contact) { return { stored: false, imageUrl: null, reason: "No such contact." }; diff --git a/apps/agent/agent/lib/preamble.ts b/apps/agent/agent/lib/preamble.ts index 1b93f383d..6d8c2aa53 100644 --- a/apps/agent/agent/lib/preamble.ts +++ b/apps/agent/agent/lib/preamble.ts @@ -1,4 +1,20 @@ -import { db } from "@crm/db"; +import { + asc, + calendarEvent, + company as companyTable, + contactBrief, + contact as contactTable, + db, + dealContact, + deal as dealTable, + desc, + emailThread, + eq, + firstRow, + orNull, + relatedCount, + whereEq, +} from "@crm/db"; import { websiteUrl } from "@crm/db/workspace"; import { capabilitiesMarkdown } from "./capabilities"; import { identity, usMarkdown, type WorkspaceIdentity } from "./workspace"; @@ -78,26 +94,53 @@ export async function contactPreamble( contactId: string, opened: Opened, ): Promise { - const contact = await db.contact.findUnique({ - where: { id: contactId }, - select: { - firstName: true, - lastName: true, - email: true, - title: true, - company: { select: { id: true, name: true, domain: true } }, - brief: { select: { refreshedAt: true } }, - deals: { - orderBy: { deal: { lastActivityAt: "desc" } }, - take: 5, - select: { - role: true, - deal: { select: { id: true, name: true, stage: true } }, + const found = await firstRow( + db + .select({ + firstName: contactTable.firstName, + lastName: contactTable.lastName, + email: contactTable.email, + title: contactTable.title, + company: { + id: companyTable.id, + name: companyTable.name, + domain: companyTable.domain, }, - }, - _count: { select: { emailThreads: true, calendarEvents: true } }, - }, - }); + brief: { refreshedAt: contactBrief.refreshedAt }, + emailThreads: db.$count( + emailThread, + eq(emailThread.contactId, contactTable.id), + ), + calendarEvents: db.$count( + calendarEvent, + eq(calendarEvent.contactId, contactTable.id), + ), + }) + .from(contactTable) + .leftJoin(companyTable, eq(companyTable.id, contactTable.companyId)) + .leftJoin(contactBrief, eq(contactBrief.contactId, contactTable.id)) + .where(eq(contactTable.id, contactId)) + .limit(1), + ); + const contact = found + ? { + ...found, + deals: await db + .select({ + role: dealContact.role, + deal: { + id: dealTable.id, + name: dealTable.name, + stage: dealTable.stage, + }, + }) + .from(dealContact) + .innerJoin(dealTable, eq(dealTable.id, dealContact.dealId)) + .where(eq(dealContact.contactId, contactId)) + .orderBy(desc(dealTable.lastActivityAt)) + .limit(5), + } + : null; if (!contact) { return { markdown: await closing(), focus: { contactId } }; @@ -106,8 +149,8 @@ export async function contactPreamble( const name = [contact.firstName, contact.lastName].filter(Boolean).join(" "); const known = - contact._count.emailThreads > 0 || contact._count.calendarEvents > 0 - ? `We have ${contact._count.emailThreads} thread(s) and ${contact._count.calendarEvents} meeting(s) with them — read those first.` + contact.emailThreads > 0 || contact.calendarEvents > 0 + ? `We have ${contact.emailThreads} thread(s) and ${contact.calendarEvents} meeting(s) with them — read those first.` : "We have never corresponded with them, so there is nothing internal to go on."; const deals = contact.deals @@ -164,26 +207,34 @@ export async function companyPreamble( companyId: string, opened: Opened, ): Promise { - const company = await db.company.findUnique({ - where: { id: companyId }, - select: { - name: true, - domain: true, - industry: true, - description: true, - contacts: { - orderBy: [{ lastActivityAt: "desc" }, { createdAt: "asc" }], - take: 12, - select: { id: true, firstName: true, lastName: true, title: true }, - }, - deals: { - orderBy: [{ lastActivityAt: "desc" }, { createdAt: "desc" }], - take: 8, - select: { id: true, name: true, stage: true }, + const company = await db.query.company + .findFirst({ + where: eq(companyTable.id, companyId), + columns: { name: true, domain: true, industry: true, description: true }, + extras: (row: { id: typeof companyTable.id }) => ({ + contactCount: relatedCount( + contactTable, + contactTable.companyId, + row.id, + ).as("contactCount"), + }), + with: { + contacts: { + orderBy: [ + desc(contactTable.lastActivityAt), + asc(contactTable.createdAt), + ], + limit: 12, + columns: { id: true, firstName: true, lastName: true, title: true }, + }, + deals: { + orderBy: [desc(dealTable.lastActivityAt), desc(dealTable.createdAt)], + limit: 8, + columns: { id: true, name: true, stage: true }, + }, }, - _count: { select: { contacts: true } }, - }, - }); + }) + .then(orNull); if (!company) { return { markdown: await closing(), focus: { companyId } }; @@ -199,8 +250,8 @@ export async function companyPreamble( .join("\n"); const more = - company._count.contacts > company.contacts.length - ? `\n- …and ${company._count.contacts - company.contacts.length} more; \`read_company_history\` lists them all.` + company.contactCount > company.contacts.length + ? `\n- …and ${company.contactCount - company.contacts.length} more; \`read_company_history\` lists them all.` : ""; const deals = company.deals @@ -221,7 +272,7 @@ export async function companyPreamble( ), "", people - ? `### Who we know there (${company._count.contacts})\n\n${people}${more}\n\nThose are contact ids. Use them directly — with \`read_crm_history\`, \`identify_contact\` or \`record_fact\`. Never ask a rep which contact they mean without naming these first.` + ? `### Who we know there (${company.contactCount})\n\n${people}${more}\n\nThose are contact ids. Use them directly — with \`read_crm_history\`, \`identify_contact\` or \`record_fact\`. Never ask a rep which contact they mean without naming these first.` : "We have no contacts on file here yet.", "", deals ? `Deals: ${deals}.` : "There are no deals here.", @@ -243,27 +294,36 @@ export async function dealPreamble( dealId: string, opened: Opened, ): Promise { - const deal = await db.deal.findUnique({ - where: { id: dealId }, - select: { - name: true, - description: true, - stage: true, - amount: true, - currency: true, - expectedCloseDate: true, - lastActivityAt: true, - company: { select: { id: true, name: true } }, - contacts: { - select: { - role: true, - contact: { - select: { id: true, firstName: true, lastName: true, title: true }, + const deal = await db.query.deal + .findFirst({ + where: whereEq(dealTable.id, dealId), + columns: { + name: true, + description: true, + stage: true, + amount: true, + currency: true, + expectedCloseDate: true, + lastActivityAt: true, + }, + with: { + company: { columns: { id: true, name: true } }, + contacts: { + columns: { role: true }, + with: { + contact: { + columns: { + id: true, + firstName: true, + lastName: true, + title: true, + }, + }, }, }, }, - }, - }); + }) + .then(orNull); if (!deal) return { markdown: await closing(), focus: {} }; diff --git a/apps/agent/agent/lib/run-preflight.ts b/apps/agent/agent/lib/run-preflight.ts index 4f9d4dac5..d04a8ddac 100644 --- a/apps/agent/agent/lib/run-preflight.ts +++ b/apps/agent/agent/lib/run-preflight.ts @@ -1,4 +1,15 @@ -import { db } from "@crm/db"; +import { + agentVersion, + and, + db, + eq, + firstRow, + mcpConnection, + mcpToolSnapshot, + or, + orNull, + whereEq, +} from "@crm/db"; import { WORKSPACE_ID } from "@crm/db/workspace"; import { type AgentManifest, @@ -32,28 +43,29 @@ export async function missingRunDependencies( if (!(await CHECKS[id]())) missing.push(fix); } for (const grant of manifest.mcp) { - const connection = await db.mcpConnection.findUnique({ - where: { - organizationId_platformSlug: { - organizationId: WORKSPACE_ID, - platformSlug: grant.platformSlug, - }, - }, - select: { - status: true, - accessMode: true, - toolSnapshots: { - where: { - OR: grant.tools.map((tool) => ({ - name: tool.name, - fingerprint: tool.fingerprint, - reviewState: "REVIEWED" as const, - })), + const connection = await db.query.mcpConnection + .findFirst({ + where: and( + eq(mcpConnection.organizationId, WORKSPACE_ID), + eq(mcpConnection.platformSlug, grant.platformSlug), + ), + columns: { status: true, accessMode: true }, + with: { + toolSnapshots: { + where: or( + ...grant.tools.map((tool) => + and( + eq(mcpToolSnapshot.name, tool.name), + eq(mcpToolSnapshot.fingerprint, tool.fingerprint), + eq(mcpToolSnapshot.reviewState, "REVIEWED"), + ), + ), + ), + columns: { name: true, fingerprint: true }, }, - select: { name: true, fingerprint: true }, }, - }, - }); + }) + .then(orNull); const current = new Set( connection?.toolSnapshots.map( (tool) => `${tool.name}:${tool.fingerprint}`, @@ -79,10 +91,13 @@ export async function missingRunDependencies( export async function runDependencyFailure( versionId: string, ): Promise { - const version = await db.agentVersion.findUnique({ - where: { id: versionId }, - select: { manifest: true }, - }); + const version = await firstRow( + db + .select({ manifest: agentVersion.manifest }) + .from(agentVersion) + .where(whereEq(agentVersion.id, versionId)) + .limit(1), + ); if (!version) return null; let manifest: AgentManifest; diff --git a/apps/agent/agent/lib/run-runtime.ts b/apps/agent/agent/lib/run-runtime.ts index bc9d9c2f8..949cf1cf3 100644 --- a/apps/agent/agent/lib/run-runtime.ts +++ b/apps/agent/agent/lib/run-runtime.ts @@ -1,5 +1,33 @@ import { createHash, randomUUID } from "node:crypto"; -import { ActivityType, db, type Prisma } from "@crm/db"; +import { + ActivityType, + activity, + agentAction, + agentAuditEvent, + agentRun, + agentRunEvent, + agentVersion, + and, + company as companyTable, + contact as contactTable, + db, + deal as dealTable, + desc, + type Executor, + eq, + firstRow, + inArray, + isNull, + type JsonValue, + lt, + type NewAgentAction, + ne, + or, + orNull, + requireRow, + sql, + whereEq, +} from "@crm/db"; import type { AgentActionStatus, AgentTriggerType } from "@crm/db/enums"; import { lockIdempotencyKey } from "@crm/db/idempotency"; import { @@ -89,7 +117,7 @@ type RunActionRow = { status: AgentActionStatus; externalId: string | null; requestHash: string | null; - metadata: Prisma.JsonValue; + metadata: JsonValue; }; type RunActionClaim = @@ -98,25 +126,25 @@ type RunActionClaim = claimed: true; actionId: string; claimedAt: Date; - metadata: Prisma.JsonValue; + metadata: JsonValue; }; const RUN_ACTION_FIELDS = { - id: true, - status: true, - externalId: true, - requestHash: true, - metadata: true, -} as const; + id: agentAction.id, + status: agentAction.status, + externalId: agentAction.externalId, + requestHash: agentAction.requestHash, + metadata: agentAction.metadata, +}; export async function approvedRunInstructions(runId: string): Promise { - const run = await db.agentRun.findUnique({ - where: { id: runId }, - select: { - status: true, - version: { select: { instructions: true } }, - }, - }); + const run = await db.query.agentRun + .findFirst({ + where: whereEq(agentRun.id, runId), + columns: { status: true }, + with: { version: { columns: { instructions: true } } }, + }) + .then(orNull); if (!run) throw new Error("This agent run is unavailable."); if (run.status !== "RUNNING") { @@ -126,26 +154,27 @@ export async function approvedRunInstructions(runId: string): Promise { } export async function runContext(runId: string) { - const run = await db.agentRun.findUnique({ - where: { id: runId }, - select: { - id: true, - status: true, - triggerType: true, - input: true, - agent: { select: { id: true, name: true, description: true } }, - version: { - select: { - id: true, - number: true, - manifest: true, - modelId: true, - sandboxPolicy: true, + const run = await db.query.agentRun + .findFirst({ + where: whereEq(agentRun.id, runId), + columns: { id: true, status: true, triggerType: true, input: true }, + with: { + agent: { columns: { id: true, name: true, description: true } }, + version: { + columns: { + id: true, + number: true, + manifest: true, + modelId: true, + sandboxPolicy: true, + }, + }, + trigger: { + columns: { id: true, name: true, type: true, config: true }, }, }, - trigger: { select: { id: true, name: true, type: true, config: true } }, - }, - }); + }) + .then(orNull); if (!run) throw new Error("This agent run is unavailable."); if (run.status !== "RUNNING") { @@ -240,17 +269,16 @@ export async function createRunActivity( dueAt?: string | null; }, ) { - const run = await db.agentRun.findUnique({ - where: { id: runId }, - select: { - id: true, - status: true, - agentId: true, - initiatedById: true, - agent: { select: { createdById: true } }, - version: { select: { manifest: true } }, - }, - }); + const run = await db.query.agentRun + .findFirst({ + where: whereEq(agentRun.id, runId), + columns: { id: true, status: true, agentId: true, initiatedById: true }, + with: { + agent: { columns: { createdById: true } }, + version: { columns: { manifest: true } }, + }, + }) + .then(orNull); if (!run) throw new Error("This agent run is unavailable."); assertActivityAllowed(run.version.manifest, input.type); @@ -310,61 +338,78 @@ export async function createRunActivity( const activityId = `agent-action-${claim.actionId}`; const now = new Date(); - await db.$transaction(async (tx) => { + await db.transaction(async (tx) => { const activeRun = await lockAgentRun(tx, runId); if (activeRun.status !== "RUNNING") { throw new Error("This agent run is not active."); } - await tx.activity.upsert({ - where: { id: activityId }, - create: { - id: activityId, - type: input.type === "TASK" ? ActivityType.TASK : ActivityType.NOTE, - subject: input.subject?.trim() || null, - body: input.body?.trim() || null, - occurredAt: now, - dueAt: input.type === "TASK" ? dueAt : null, - companyId: target.companyId, - contactId: target.contactId, - dealId: target.dealId, - createdById: run.initiatedById ?? run.agent.createdById, - meta: { - source: "agent", - agentId: run.agentId, - runId, - actionId: claim.actionId, - }, - }, - update: {}, - }); + await requireRow( + tx + .insert(activity) + .values({ + id: activityId, + type: input.type === "TASK" ? ActivityType.TASK : ActivityType.NOTE, + subject: input.subject?.trim() || null, + body: input.body?.trim() || null, + occurredAt: now, + dueAt: input.type === "TASK" ? dueAt : null, + companyId: target.companyId, + contactId: target.contactId, + dealId: target.dealId, + createdById: run.initiatedById ?? run.agent.createdById, + meta: { + source: "agent", + agentId: run.agentId, + runId, + actionId: claim.actionId, + }, + }) + .onConflictDoUpdate({ + target: activity.id, + set: { id: sql`excluded.${sql.identifier("id")}` }, + }) + .returning(), + ); if (target.companyId) { - await tx.company.update({ - where: { id: target.companyId }, - data: { lastActivityAt: now }, - }); + await requireRow( + tx + .update(companyTable) + .set({ lastActivityAt: now }) + .where(whereEq(companyTable.id, target.companyId)) + .returning(), + ); } if (target.contactId) { - await tx.contact.update({ - where: { id: target.contactId }, - data: { lastActivityAt: now }, - }); + await requireRow( + tx + .update(contactTable) + .set({ lastActivityAt: now }) + .where(whereEq(contactTable.id, target.contactId)) + .returning(), + ); } if (target.dealId) { - await tx.deal.update({ - where: { id: target.dealId }, - data: { lastActivityAt: now }, - }); + await requireRow( + tx + .update(dealTable) + .set({ lastActivityAt: now }) + .where(whereEq(dealTable.id, target.dealId)) + .returning(), + ); } - await tx.agentAction.update({ - where: { id: claim.actionId }, - data: { - status: "SUCCEEDED", - externalId: activityId, - completedAt: now, - }, - }); + await requireRow( + tx + .update(agentAction) + .set({ + status: "SUCCEEDED", + externalId: activityId, + completedAt: now, + }) + .where(whereEq(agentAction.id, claim.actionId)) + .returning(), + ); }); return { actionId: claim.actionId, activityId, replayed: false }; @@ -381,15 +426,13 @@ export async function postRunSlackMessage( input: { text: string }, abortSignal?: AbortSignal, ) { - const run = await db.agentRun.findUnique({ - where: { id: runId }, - select: { - id: true, - status: true, - agentId: true, - version: { select: { manifest: true } }, - }, - }); + const run = await db.query.agentRun + .findFirst({ + where: whereEq(agentRun.id, runId), + columns: { id: true, status: true, agentId: true }, + with: { version: { columns: { manifest: true } } }, + }) + .then(orNull); if (!run) throw new Error("This agent run is unavailable."); const destination = approvedSlackDestination(run.version.manifest); @@ -451,15 +494,21 @@ export async function postRunSlackMessage( }, ); const messageId = `${posted.channel}:${posted.ts}`; - const completed = await db.agentAction.updateMany({ - where: { id: actionId, status: "RUNNING", startedAt: claimedAt }, - data: { + const completed = await db + .update(agentAction) + .set({ status: "SUCCEEDED", externalId: messageId, completedAt: new Date(), - }, - }); - if (completed.count === 0) { + }) + .where( + and( + whereEq(agentAction.id, actionId), + eq(agentAction.status, "RUNNING"), + whereEq(agentAction.startedAt, claimedAt), + ), + ); + if (completed.rowCount === 0) { await recordDeliveryOutsideClaim(actionId, messageId); throw new Error( "This agent run stopped while Slack was accepting the message.", @@ -483,10 +532,13 @@ async function findRunAction( idempotencyKey: string, requestHash: string, ): Promise { - const existing = await db.agentAction.findUnique({ - where: { idempotencyKey }, - select: RUN_ACTION_FIELDS, - }); + const existing = await firstRow( + db + .select(RUN_ACTION_FIELDS) + .from(agentAction) + .where(eq(agentAction.idempotencyKey, idempotencyKey)) + .limit(1), + ); if (existing) assertActionRequestMatches(existing.requestHash, requestHash); return existing; } @@ -495,28 +547,30 @@ async function claimRunAction( existing: RunActionRow | null, idempotencyKey: string, requestHash: string, - data: Omit< - Prisma.AgentActionUncheckedCreateInput, - "idempotencyKey" | "requestHash" - >, + data: Omit, ): Promise { const action = existing ?? - (await db.$transaction(async (tx) => { + (await db.transaction(async (tx) => { await lockIdempotencyKey(tx, idempotencyKey); - const winner = await tx.agentAction.findUnique({ - where: { idempotencyKey }, - select: RUN_ACTION_FIELDS, - }); + const winner = await firstRow( + tx + .select(RUN_ACTION_FIELDS) + .from(agentAction) + .where(eq(agentAction.idempotencyKey, idempotencyKey)) + .limit(1), + ); if (winner) { assertActionRequestMatches(winner.requestHash, requestHash); return winner; } - return tx.agentAction.create({ - data: { ...data, idempotencyKey, requestHash }, - select: RUN_ACTION_FIELDS, - }); + return requireRow( + tx + .insert(agentAction) + .values({ ...data, idempotencyKey, requestHash }) + .returning(RUN_ACTION_FIELDS), + ); })); if (action.status === "SUCCEEDED") { return { @@ -527,31 +581,42 @@ async function claimRunAction( } const claimedAt = new Date(); - const claimed = await db.agentAction.updateMany({ - where: { - id: action.id, - OR: [ - { status: { in: ["PLANNED", "FAILED"] } }, - { - status: "RUNNING", - startedAt: { lt: new Date(claimedAt.getTime() - ACTION_LEASE_MS) }, - }, - ], - }, - data: { + const claimed = await db + .update(agentAction) + .set({ status: "RUNNING", startedAt: claimedAt, completedAt: null, - attemptCount: { increment: 1 }, + attemptCount: sql`${agentAction.attemptCount} + 1`, errorCode: null, errorMessage: null, - }, - }); - if (claimed.count === 0) { - const current = await db.agentAction.findUnique({ - where: { id: action.id }, - select: { status: true, externalId: true }, - }); + }) + .where( + and( + whereEq(agentAction.id, action.id), + or( + inArray(agentAction.status, ["PLANNED", "FAILED"]), + and( + eq(agentAction.status, "RUNNING"), + lt( + agentAction.startedAt, + new Date(claimedAt.getTime() - ACTION_LEASE_MS), + ), + ), + ), + ), + ); + if (claimed.rowCount === 0) { + const current = await firstRow( + db + .select({ + status: agentAction.status, + externalId: agentAction.externalId, + }) + .from(agentAction) + .where(whereEq(agentAction.id, action.id)) + .limit(1), + ); if (current?.status === "SUCCEEDED") { return { claimed: false, @@ -575,19 +640,21 @@ async function failRunAction( code: string, message: string, ): Promise { - await db.agentAction.updateMany({ - where: { - id: claim.actionId, - status: "RUNNING", - startedAt: claim.claimedAt, - }, - data: { + await db + .update(agentAction) + .set({ status: "FAILED", errorCode: code, errorMessage: message, completedAt: new Date(), - }, - }); + }) + .where( + and( + whereEq(agentAction.id, claim.actionId), + eq(agentAction.status, "RUNNING"), + whereEq(agentAction.startedAt, claim.claimedAt), + ), + ); } export async function sendSlackMessage( @@ -641,10 +708,13 @@ export async function sendSlackMessage( } async function assertRunActive(runId: string): Promise { - const run = await db.agentRun.findUnique({ - where: { id: runId }, - select: { status: true }, - }); + const run = await firstRow( + db + .select({ status: agentRun.status }) + .from(agentRun) + .where(whereEq(agentRun.id, runId)) + .limit(1), + ); if (run?.status !== "RUNNING") { throw new Error("This agent run is not active."); } @@ -655,14 +725,19 @@ async function holdRunActionClaim( actionId: string, claimedAt: Date, ): Promise { - await db.$transaction(async (tx) => { + await db.transaction(async (tx) => { const run = await lockAgentRun(tx, runId); if (run.status !== "RUNNING") { throw new Error("This agent run is not active."); } - const held = await tx.agentAction.count({ - where: { id: actionId, status: "RUNNING", startedAt: claimedAt }, - }); + const held = await tx.$count( + agentAction, + and( + whereEq(agentAction.id, actionId), + eq(agentAction.status, "RUNNING"), + whereEq(agentAction.startedAt, claimedAt), + ), + ); if (held === 0) { throw new Error("This agent action is no longer held by this run."); } @@ -675,21 +750,34 @@ async function recordDeliveryOutsideClaim( ): Promise { const delivered = "Slack accepted this message before the run stopped, and it cannot be withdrawn."; - const current = await db.agentAction.findUnique({ - where: { id: actionId }, - select: { status: true, externalId: true, errorMessage: true }, - }); + const current = await firstRow( + db + .select({ + status: agentAction.status, + externalId: agentAction.externalId, + errorMessage: agentAction.errorMessage, + }) + .from(agentAction) + .where(whereEq(agentAction.id, actionId)) + .limit(1), + ); if (!current || current.status === "SUCCEEDED" || current.externalId) return; - await db.agentAction.updateMany({ - where: { id: actionId, status: { not: "SUCCEEDED" }, externalId: null }, - data: { + await db + .update(agentAction) + .set({ externalId: messageId, errorMessage: current.errorMessage ? `${current.errorMessage} ${delivered}` : delivered, - }, - }); + }) + .where( + and( + whereEq(agentAction.id, actionId), + ne(agentAction.status, "SUCCEEDED"), + isNull(agentAction.externalId), + ), + ); } async function slackApiRequest( @@ -744,7 +832,7 @@ export async function stageRunResult( noActionNeeded?: { reason: string } | null; }, ) { - return db.$transaction(async (tx) => { + return db.transaction(async (tx) => { const run = await lockAgentRun(tx, runId); if (run.status !== "RUNNING") { throw new Error(`This agent run already ended with ${run.status}.`); @@ -759,19 +847,22 @@ export async function stageRunResult( result.noActionNeeded = input.noActionNeeded.reason; } - await tx.agentRun.update({ - where: { id: runId }, - data: { - summary: input.summary, - result: result as Prisma.InputJsonValue, - }, - }); + await requireRow( + tx + .update(agentRun) + .set({ + summary: input.summary, + result: result as JsonValue, + }) + .where(whereEq(agentRun.id, runId)) + .returning(), + ); return { id: run.id, status: "RUNNING" as const }; }); } -export function runResultOf(value: Prisma.JsonValue): RunResult { +export function runResultOf(value: JsonValue): RunResult { return storedRunResult.parse(value); } @@ -785,7 +876,7 @@ export async function finishRun( runId: string, input: { summary: string; result?: RunResult | null }, ) { - return db.$transaction(async (tx) => { + return db.transaction(async (tx) => { const run = await lockAgentRun(tx, runId); if (run.status === "SUCCEEDED") { return { id: run.id, status: "SUCCEEDED" as const }; @@ -805,88 +896,106 @@ export async function finishRun( const sequence = run.nextEventSequence + 1; const finishedAt = new Date(); - await tx.agentRun.update({ - where: { id: runId }, - data: { - status: "SUCCEEDED", - summary: input.summary, - result: (input.result ?? {}) as Prisma.InputJsonValue, - finishedAt, - nextEventSequence: sequence, - }, - }); - await tx.agentRunEvent.create({ - data: { - id: runTerminalEventId(run.id, "completed"), - runId: run.id, - sequence, - type: "run.completed", - data: { summary: input.summary }, - emittedAt: finishedAt, - }, - }); - await tx.agentAuditEvent.upsert({ - where: { - agentId_type_requestId: { + await requireRow( + tx + .update(agentRun) + .set({ + status: "SUCCEEDED", + summary: input.summary, + result: (input.result ?? {}) as JsonValue, + finishedAt, + nextEventSequence: sequence, + }) + .where(whereEq(agentRun.id, runId)) + .returning(), + ); + await requireRow( + tx + .insert(agentRunEvent) + .values({ + id: runTerminalEventId(run.id, "completed"), + runId: run.id, + sequence, + type: "run.completed", + data: { summary: input.summary }, + emittedAt: finishedAt, + }) + .returning(), + ); + await requireRow( + tx + .insert(agentAuditEvent) + .values({ agentId: run.agentId, + versionId: run.versionId, + actorType: "AGENT", + actorId: run.id, type: "run.completed", + summary: input.summary, requestId: run.id, - }, - }, - create: { - agentId: run.agentId, - versionId: run.versionId, - actorType: "AGENT", - actorId: run.id, - type: "run.completed", - summary: input.summary, - requestId: run.id, - }, - update: {}, - }); + }) + .onConflictDoUpdate({ + target: [ + agentAuditEvent.agentId, + agentAuditEvent.type, + agentAuditEvent.requestId, + ], + set: { agentId: sql`excluded.${sql.identifier("agentId")}` }, + }) + .returning(), + ); return { id: run.id, status: "SUCCEEDED" as const }; }); } async function noActionNeededRefusal( - tx: Prisma.TransactionClient, + tx: Executor, run: LockedAgentRun, ): Promise { - const { triggerType } = await tx.agentRun.findUniqueOrThrow({ - where: { id: run.id }, - select: { triggerType: true }, - }); + const { triggerType } = await requireRow( + tx + .select({ triggerType: agentRun.triggerType }) + .from(agentRun) + .where(whereEq(agentRun.id, run.id)) + .limit(1), + ); if (NO_ACTION_TRIGGER_TYPES.has(triggerType)) return null; - const version = await tx.agentVersion.findUniqueOrThrow({ - where: { id: run.versionId }, - select: { manifest: true }, - }); + const version = await requireRow( + tx + .select({ manifest: agentVersion.manifest }) + .from(agentVersion) + .where(whereEq(agentVersion.id, run.versionId)) + .limit(1), + ); if (externalManifestActions(version.manifest).length === 0) return null; return `This ${triggerType.toLowerCase()} run cannot end with no action needed, because its agent declares an action. Perform the declared action, or report why it failed.`; } async function requiredActionFailure( - tx: Prisma.TransactionClient, + tx: Executor, run: LockedAgentRun, ): Promise<{ code: string; message: string } | null> { - const version = await tx.agentVersion.findUniqueOrThrow({ - where: { id: run.versionId }, - select: { manifest: true }, - }); + const version = await requireRow( + tx + .select({ manifest: agentVersion.manifest }) + .from(agentVersion) + .where(whereEq(agentVersion.id, run.versionId)) + .limit(1), + ); const external = externalManifestActions(version.manifest); - const recorded = await tx.agentAction.findMany({ - where: { runId: run.id }, - orderBy: [{ completedAt: "desc" }, { plannedAt: "desc" }], - select: { - type: true, - status: true, - errorCode: true, - errorMessage: true, - }, - }); + const recorded = await tx + .select({ + type: agentAction.type, + status: agentAction.status, + errorCode: agentAction.errorCode, + errorMessage: agentAction.errorMessage, + }) + .from(agentAction) + .where(whereEq(agentAction.runId, run.id)) + .orderBy(desc(agentAction.completedAt), desc(agentAction.plannedAt)); for (const action of external) { const type = action.type; @@ -905,22 +1014,25 @@ async function requiredActionFailure( : `The declared ${type} action has no executor.`; if (rows.length === 0) { - await tx.agentAction.create({ - data: { - agentId: run.agentId, - runId: run.id, - type, - provider: - type === AGENT_ACTION_TYPES.SLACK_MESSAGE_POST ? "slack" : "crm", - summary: action.summary, - status: "FAILED", - idempotencyKey: `run:${run.id}:required:${type}`, - requestHash: hashRequest({ type, required: true }), - errorCode: code, - errorMessage: message, - completedAt: new Date(), - }, - }); + await requireRow( + tx + .insert(agentAction) + .values({ + agentId: run.agentId, + runId: run.id, + type, + provider: + type === AGENT_ACTION_TYPES.SLACK_MESSAGE_POST ? "slack" : "crm", + summary: action.summary, + status: "FAILED", + idempotencyKey: `run:${run.id}:required:${type}`, + requestHash: hashRequest({ type, required: true }), + errorCode: code, + errorMessage: message, + completedAt: new Date(), + }) + .returning(), + ); } return { code, message }; @@ -930,57 +1042,66 @@ async function requiredActionFailure( } async function failLockedRun( - tx: Prisma.TransactionClient, + tx: Executor, run: LockedAgentRun, code: string, message: string, ) { const sequence = run.nextEventSequence + 1; const finishedAt = new Date(); - await tx.agentRun.update({ - where: { id: run.id }, - data: { - status: "FAILED", - errorCode: code, - errorMessage: message, - finishedAt, - nextEventSequence: sequence, - }, - }); - await tx.agentRunEvent.create({ - data: { - id: runTerminalEventId(run.id, "failed"), - runId: run.id, - sequence, - type: "run.failed", - data: { code, message }, - emittedAt: finishedAt, - }, - }); - await tx.agentAuditEvent.upsert({ - where: { - agentId_type_requestId: { + await requireRow( + tx + .update(agentRun) + .set({ + status: "FAILED", + errorCode: code, + errorMessage: message, + finishedAt, + nextEventSequence: sequence, + }) + .where(whereEq(agentRun.id, run.id)) + .returning(), + ); + await requireRow( + tx + .insert(agentRunEvent) + .values({ + id: runTerminalEventId(run.id, "failed"), + runId: run.id, + sequence, + type: "run.failed", + data: { code, message }, + emittedAt: finishedAt, + }) + .returning(), + ); + await requireRow( + tx + .insert(agentAuditEvent) + .values({ agentId: run.agentId, + versionId: run.versionId, + actorType: "AGENT", + actorId: run.id, type: "run.failed", + summary: message, requestId: run.id, - }, - }, - create: { - agentId: run.agentId, - versionId: run.versionId, - actorType: "AGENT", - actorId: run.id, - type: "run.failed", - summary: message, - requestId: run.id, - }, - update: {}, - }); + }) + .onConflictDoUpdate({ + target: [ + agentAuditEvent.agentId, + agentAuditEvent.type, + agentAuditEvent.requestId, + ], + set: { agentId: sql`excluded.${sql.identifier("agentId")}` }, + }) + .returning(), + ); return { id: run.id, status: "FAILED" as const }; } -function manifestDataScope(value: Prisma.JsonValue): RunDataScope { +function manifestDataScope(value: JsonValue): RunDataScope { const scope = parseAgentManifest(value).dataScope; const resources = scope.resources; const records = resources.filter( @@ -995,18 +1116,18 @@ function manifestDataScope(value: Prisma.JsonValue): RunDataScope { return { mode: scope.mode, resources }; } -function manifestActions(value: Prisma.JsonValue) { +function manifestActions(value: JsonValue) { return parseAgentManifest(value).actions; } -function externalManifestActions(value: Prisma.JsonValue) { +function externalManifestActions(value: JsonValue) { return manifestActions(value).filter( (action) => action.type !== AGENT_ACTION_TYPES.RUN_SUMMARY, ); } function assertActivityAllowed( - manifest: Prisma.JsonValue, + manifest: JsonValue, activityType: "NOTE" | "TASK", ) { const allowed = manifestActions(manifest).some( @@ -1022,7 +1143,7 @@ function assertActivityAllowed( } export function approvedSlackDestination( - manifest: Prisma.JsonValue, + manifest: JsonValue, ): SlackRunDestination { const scope = manifestDataScope(manifest); if ( @@ -1092,10 +1213,13 @@ export function allowedHistorySources( async function targetRecord(kind: "company" | "contact" | "deal", id: string) { if (kind === "company") { - const company = await db.company.findUnique({ - where: { id }, - select: { id: true, name: true }, - }); + const company = await firstRow( + db + .select({ id: companyTable.id, name: companyTable.name }) + .from(companyTable) + .where(whereEq(companyTable.id, id)) + .limit(1), + ); return company ? { label: company.name, @@ -1106,10 +1230,18 @@ async function targetRecord(kind: "company" | "contact" | "deal", id: string) { : null; } if (kind === "contact") { - const contact = await db.contact.findUnique({ - where: { id }, - select: { id: true, firstName: true, lastName: true, companyId: true }, - }); + const contact = await firstRow( + db + .select({ + id: contactTable.id, + firstName: contactTable.firstName, + lastName: contactTable.lastName, + companyId: contactTable.companyId, + }) + .from(contactTable) + .where(whereEq(contactTable.id, id)) + .limit(1), + ); return contact ? { label: [contact.firstName, contact.lastName] @@ -1122,10 +1254,17 @@ async function targetRecord(kind: "company" | "contact" | "deal", id: string) { : null; } - const deal = await db.deal.findUnique({ - where: { id }, - select: { id: true, name: true, companyId: true }, - }); + const deal = await firstRow( + db + .select({ + id: dealTable.id, + name: dealTable.name, + companyId: dealTable.companyId, + }) + .from(dealTable) + .where(whereEq(dealTable.id, id)) + .limit(1), + ); return deal ? { label: deal.name, diff --git a/apps/agent/agent/lib/run-state.ts b/apps/agent/agent/lib/run-state.ts index c98fcb359..a8f6574e1 100644 --- a/apps/agent/agent/lib/run-state.ts +++ b/apps/agent/agent/lib/run-state.ts @@ -1,4 +1,4 @@ -import type { Prisma } from "@crm/db"; +import { type Executor, queryRows, sql } from "@crm/db"; import type { AgentRunStatus } from "@crm/db/enums"; export type LockedAgentRun = { @@ -12,15 +12,18 @@ export type LockedAgentRun = { }; export async function lockAgentRun( - tx: Prisma.TransactionClient, + tx: Executor, runId: string, ): Promise { - const [run] = await tx.$queryRaw` + const [run] = await queryRows( + tx, + sql` SELECT id, "agentId", "versionId", status, "sessionId", "startedAt", "nextEventSequence" FROM "agentRun" WHERE id = ${runId} FOR UPDATE - `; + `, + ); if (!run) throw new Error("This agent run is unavailable."); return run; } diff --git a/apps/agent/agent/lib/slack-connection.ts b/apps/agent/agent/lib/slack-connection.ts index 41b267c90..e78317620 100644 --- a/apps/agent/agent/lib/slack-connection.ts +++ b/apps/agent/agent/lib/slack-connection.ts @@ -1,11 +1,28 @@ -import { db } from "@crm/db"; +import { + account as accountTable, + and, + db, + desc, + eq, + firstRow, + isNotNull, + slackWorkspaceGrant, +} from "@crm/db"; export async function slackAccessToken(): Promise { - const account = await db.account.findFirst({ - where: { providerId: "slack", accessToken: { not: null } }, - orderBy: { updatedAt: "desc" }, - select: { accessToken: true }, - }); + const account = await firstRow( + db + .select({ accessToken: accountTable.accessToken }) + .from(accountTable) + .where( + and( + eq(accountTable.providerId, "slack"), + isNotNull(accountTable.accessToken), + ), + ) + .orderBy(desc(accountTable.updatedAt)) + .limit(1), + ); return account?.accessToken ?? null; } @@ -15,10 +32,13 @@ export async function slackConnected(): Promise { } export async function slackUserToken(): Promise { - const grant = await db.slackWorkspaceGrant.findFirst({ - orderBy: { updatedAt: "desc" }, - select: { userToken: true }, - }); + const grant = await firstRow( + db + .select({ userToken: slackWorkspaceGrant.userToken }) + .from(slackWorkspaceGrant) + .orderBy(desc(slackWorkspaceGrant.updatedAt)) + .limit(1), + ); return grant?.userToken ?? null; } diff --git a/apps/agent/agent/lib/slack-join-task.ts b/apps/agent/agent/lib/slack-join-task.ts index 27d29c653..6d0d25e0e 100644 --- a/apps/agent/agent/lib/slack-join-task.ts +++ b/apps/agent/agent/lib/slack-join-task.ts @@ -1,10 +1,8 @@ -import type { Prisma } from "@crm/db"; +import type { JsonValue } from "@crm/db"; import { parse, schemas } from "@crm/validation"; import { joinSlackChannel } from "./slack-membership"; -export async function runSlackChannelJoin( - value: Prisma.JsonValue, -): Promise { +export async function runSlackChannelJoin(value: JsonValue): Promise { const { channelId, channelName } = parse( schemas.slack.joinPayload, value, diff --git a/apps/agent/agent/lib/slack-membership.ts b/apps/agent/agent/lib/slack-membership.ts index c776d5c3d..bcbf3c49b 100644 --- a/apps/agent/agent/lib/slack-membership.ts +++ b/apps/agent/agent/lib/slack-membership.ts @@ -1,4 +1,4 @@ -import { db } from "@crm/db"; +import { db, eq, firstRow, requireRow, slackChannel, whereEq } from "@crm/db"; import { schemas } from "@crm/validation"; import { z } from "zod"; import { SLACK } from "./slack-config"; @@ -113,11 +113,10 @@ async function classifyChannel( return live; } - await db.slackChannel - .update({ - where: { id: channelId }, - data: { ...live, classifiedAt: new Date() }, - }) + await db + .update(slackChannel) + .set({ ...live, classifiedAt: new Date() }) + .where(eq(slackChannel.id, channelId)) .catch(() => null); await requestSlackInventorySync(); @@ -127,10 +126,17 @@ async function classifyChannel( export async function joinSlackChannel( channelId: string, ): Promise { - const channel = await db.slackChannel.findUnique({ - where: { id: channelId }, - select: { id: true, isPrivate: true, isMember: true }, - }); + const channel = await firstRow( + db + .select({ + id: slackChannel.id, + isPrivate: slackChannel.isPrivate, + isMember: slackChannel.isMember, + }) + .from(slackChannel) + .where(whereEq(slackChannel.id, channelId)) + .limit(1), + ); if (!channel) { return { joined: false, reason: "No such channel.", needsHuman: false }; @@ -164,15 +170,18 @@ export async function joinSlackChannel( }; } - await db.slackChannel.update({ - where: { id: channelId }, - data: { - isMember: true, - available: true, - inviteRequestedAt: null, - classifiedAt: new Date(), - }, - }); + await requireRow( + db + .update(slackChannel) + .set({ + isMember: true, + available: true, + inviteRequestedAt: null, + classifiedAt: new Date(), + }) + .where(whereEq(slackChannel.id, channelId)) + .returning(), + ); return { joined: true, already: outcome.error === ALREADY_IN_CHANNEL }; } @@ -258,23 +267,31 @@ export async function createSlackChannel( const channel = parsed.data.channel; - await db.slackChannel.upsert({ - where: { id: channel.id }, - create: { - id: channel.id, - name: channel.name, - isPrivate, - isMember: !isPrivate && token === bot, - available: true, - classifiedAt: new Date(), - }, - update: { - name: channel.name, - isPrivate, - available: true, - classifiedAt: new Date(), - }, - }); + await requireRow( + db + .insert(slackChannel) + .values({ + id: channel.id, + name: channel.name, + isPrivate, + isMember: !isPrivate && token === bot, + available: true, + classifiedAt: new Date(), + }) + .onConflictDoUpdate({ + target: slackChannel.id, + set: { + ...{ + name: channel.name, + isPrivate, + available: true, + classifiedAt: new Date(), + }, + updatedAt: new Date(), + }, + }) + .returning(), + ); if (token === user) await joinSlackChannel(channel.id); diff --git a/apps/agent/agent/lib/slack-people.ts b/apps/agent/agent/lib/slack-people.ts index b73d14e12..a04731faf 100644 --- a/apps/agent/agent/lib/slack-people.ts +++ b/apps/agent/agent/lib/slack-people.ts @@ -1,4 +1,16 @@ -import { db } from "@crm/db"; +import { + db, + desc, + eq, + firstRow, + member as memberTable, + notInArray, + queryRows, + requireRow, + slackChannel as slackChannelTable, + slackMemberMatch, + sql, +} from "@crm/db"; import { queueSlackInventorySync } from "@crm/db/slack-inventory"; import { WORKSPACE_ID } from "@crm/db/workspace"; import { parse, schemas } from "@crm/validation"; @@ -54,10 +66,13 @@ export async function requestSlackInventorySync(): Promise { export async function requestStaleSlackInventorySync(): Promise { try { - const newest = await db.slackChannel.findFirst({ - orderBy: { updatedAt: "desc" }, - select: { updatedAt: true }, - }); + const newest = await firstRow( + db + .select({ updatedAt: slackChannelTable.updatedAt }) + .from(slackChannelTable) + .orderBy(desc(slackChannelTable.updatedAt)) + .limit(1), + ); const fresh = newest !== null && Date.now() - newest.updatedAt.getTime() < SLACK.inventory.staleMs; @@ -87,36 +102,38 @@ export async function runSlackPeopleMatch(): Promise { return email ? [[email, member] as const] : []; }), ); - const crmMembers = await db.member.findMany({ - where: { organizationId: WORKSPACE_ID }, - select: { - user: { - select: { - id: true, - email: true, - }, - }, - }, + const crmMembers = await db.query.member.findMany({ + where: eq(memberTable.organizationId, WORKSPACE_ID), + columns: {}, + with: { user: { columns: { id: true, email: true } } }, }); let matched = 0; for (const { user } of crmMembers) { const slack = byEmail.get(user.email.trim().toLowerCase()); const slackHandle = slack ? `@${slack.name ?? slack.id}` : null; - await db.slackMemberMatch.upsert({ - where: { crmUserId: user.id }, - create: { - crmUserId: user.id, - slackUserId: slack?.id, - slackHandle, - slackEmail: slack?.profile?.email, - }, - update: { - slackUserId: slack?.id ?? null, - slackHandle, - slackEmail: slack?.profile?.email ?? null, - }, - }); + await requireRow( + db + .insert(slackMemberMatch) + .values({ + crmUserId: user.id, + slackUserId: slack?.id, + slackHandle, + slackEmail: slack?.profile?.email, + }) + .onConflictDoUpdate({ + target: slackMemberMatch.crmUserId, + set: { + ...{ + slackUserId: slack?.id ?? null, + slackHandle, + slackEmail: slack?.profile?.email ?? null, + }, + updatedAt: new Date(), + }, + }) + .returning(), + ); if (slack) matched += 1; } @@ -173,33 +190,36 @@ export async function persistSlackChannels( ).values(), ]; - return db.$transaction(async (tx) => { - const [account] = await tx.$queryRaw>` + return db.transaction(async (tx) => { + const [account] = await queryRows<{ id: string }>( + tx, + sql` SELECT id FROM "account" WHERE "providerId" = 'slack' AND "accessToken" IS NOT NULL ORDER BY "updatedAt" DESC LIMIT 1 FOR UPDATE - `; + `, + ); if (!account) return 0; const ids = available.map((channel) => channel.id); - await tx.slackChannel.updateMany({ - where: { id: { notIn: ids } }, - data: { available: false }, - }); + await tx + .update(slackChannelTable) + .set({ available: false }) + .where(notInArray(slackChannelTable.id, ids)); if (ids.length === 0) return 0; - await tx.$executeRaw` + await tx.execute(sql` INSERT INTO "slackChannel" (id, name, "memberCount", "isPrivate", "isMember", available, "classifiedAt", "createdAt", "updatedAt") SELECT id, name, "memberCount", "isPrivate", "isMember", true, NOW(), NOW(), NOW() FROM UNNEST( - ${ids}::text[], - ${available.map((channel) => channel.name)}::text[], - ${available.map((channel) => channel.num_members ?? null)}::int[], - ${available.map((channel) => channel.is_private ?? false)}::boolean[], - ${available.map((channel) => channel.is_member ?? false)}::boolean[] + ${sql.param(ids)}::text[], + ${sql.param(available.map((channel) => channel.name))}::text[], + ${sql.param(available.map((channel) => channel.num_members ?? null))}::int[], + ${sql.param(available.map((channel) => channel.is_private ?? false))}::boolean[], + ${sql.param(available.map((channel) => channel.is_member ?? false))}::boolean[] ) AS incoming(id, name, "memberCount", "isPrivate", "isMember") ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, @@ -209,7 +229,7 @@ export async function persistSlackChannels( available = true, "classifiedAt" = NOW(), "updatedAt" = NOW() - `; + `); return ids.length; }); diff --git a/apps/agent/agent/lib/stale-tasks.ts b/apps/agent/agent/lib/stale-tasks.ts index c85d51fcc..d1b76cf21 100644 --- a/apps/agent/agent/lib/stale-tasks.ts +++ b/apps/agent/agent/lib/stale-tasks.ts @@ -1,4 +1,19 @@ -import { db, EnrichmentStatus, type Prisma } from "@crm/db"; +import { + agentTask, + and, + asc, + company, + contact, + db, + EnrichmentStatus, + eq, + inArray, + isNotNull, + isNull, + lt, + lte, + or, +} from "@crm/db"; import { MAX_ATTEMPTS, ownsCompanyStatus, @@ -87,29 +102,29 @@ export async function reconcileStaleTasks(): Promise { async function runSweep(sweep: StaleTaskSweep): Promise { const now = new Date(); - const where: Prisma.AgentTaskWhereInput = { - finishedAt: null, - dueAt: { lte: now }, - OR: [{ leasedUntil: null }, { leasedUntil: { lt: now } }], - }; + const where = and( + isNull(agentTask.finishedAt), + lte(agentTask.dueAt, now), + or(isNull(agentTask.leasedUntil), lt(agentTask.leasedUntil, now)), + ); const [open, tasks] = await Promise.all([ - db.agentTask.count({ where }), - db.agentTask.findMany({ - where, - orderBy: [{ dueAt: "asc" }], - take: SCAN, - select: { - id: true, - kind: true, - contactId: true, - companyId: true, - dealId: true, - attempts: true, - leasedUntil: true, - startedAt: true, - }, - }), + db.$count(agentTask, where), + db + .select({ + id: agentTask.id, + kind: agentTask.kind, + contactId: agentTask.contactId, + companyId: agentTask.companyId, + dealId: agentTask.dealId, + attempts: agentTask.attempts, + leasedUntil: agentTask.leasedUntil, + startedAt: agentTask.startedAt, + }) + .from(agentTask) + .where(where) + .orderBy(asc(agentTask.dueAt)) + .limit(SCAN), ]); sweep.scanned = tasks.length; @@ -139,40 +154,50 @@ async function runSweep(sweep: StaleTaskSweep): Promise { } if (landed.length > 0) { - const { count } = await db.agentTask.updateMany({ - where: { - id: { in: landed }, - finishedAt: null, - OR: [{ leasedUntil: null }, { leasedUntil: { lt: now } }], - }, - data: { finishedAt: now, outcome: LANDED_OUTCOME }, - }); - - sweep.closed = count; + const { rowCount: count } = await db + .update(agentTask) + .set({ finishedAt: now, outcome: LANDED_OUTCOME }) + .where( + and( + inArray(agentTask.id, landed), + isNull(agentTask.finishedAt), + or(isNull(agentTask.leasedUntil), lt(agentTask.leasedUntil, now)), + ), + ); + + sweep.closed = count ?? 0; } if (untargeted.length > 0) { - const { count } = await db.agentTask.updateMany({ - where: { - id: { in: untargeted }, - finishedAt: null, - OR: [{ leasedUntil: null }, { leasedUntil: { lt: now } }], - }, - data: { finishedAt: now, outcome: UNTARGETED_OUTCOME }, - }); - - sweep.closed += count; + const { rowCount: count } = await db + .update(agentTask) + .set({ finishedAt: now, outcome: UNTARGETED_OUTCOME }) + .where( + and( + inArray(agentTask.id, untargeted), + isNull(agentTask.finishedAt), + or(isNull(agentTask.leasedUntil), lt(agentTask.leasedUntil, now)), + ), + ); + + sweep.closed += count ?? 0; } sweep.retired = (await retireAbandoned()).length; if (dead.length > 0) { - const { count } = await db.agentTask.updateMany({ - where: { id: { in: dead }, finishedAt: null, leasedUntil: { lt: now } }, - data: { leasedUntil: null }, - }); - - sweep.released = count; + const { rowCount: count } = await db + .update(agentTask) + .set({ leasedUntil: null }) + .where( + and( + inArray(agentTask.id, dead), + isNull(agentTask.finishedAt), + lt(agentTask.leasedUntil, now), + ), + ); + + sweep.released = count ?? 0; } } @@ -185,24 +210,28 @@ async function completedSubjects( const [contacts, companies] = await Promise.all([ contactIds.length === 0 ? [] - : db.contact.findMany({ - where: { - id: { in: contactIds }, - enrichmentStatus: EnrichmentStatus.COMPLETE, - enrichedAt: { not: null }, - }, - select: { id: true, enrichedAt: true }, - }), + : db + .select({ id: contact.id, enrichedAt: contact.enrichedAt }) + .from(contact) + .where( + and( + inArray(contact.id, contactIds), + eq(contact.enrichmentStatus, EnrichmentStatus.COMPLETE), + isNotNull(contact.enrichedAt), + ), + ), companyIds.length === 0 ? [] - : db.company.findMany({ - where: { - id: { in: companyIds }, - enrichmentStatus: EnrichmentStatus.COMPLETE, - enrichedAt: { not: null }, - }, - select: { id: true, enrichedAt: true }, - }), + : db + .select({ id: company.id, enrichedAt: company.enrichedAt }) + .from(company) + .where( + and( + inArray(company.id, companyIds), + eq(company.enrichmentStatus, EnrichmentStatus.COMPLETE), + isNotNull(company.enrichedAt), + ), + ), ]); const completed = new Map(); diff --git a/apps/agent/agent/lib/tasks.ts b/apps/agent/agent/lib/tasks.ts index e3602a4c8..d7ba153c2 100644 --- a/apps/agent/agent/lib/tasks.ts +++ b/apps/agent/agent/lib/tasks.ts @@ -1,4 +1,16 @@ -import { db, type Prisma } from "@crm/db"; +import { + agentTask, + and, + db, + desc, + firstRow, + isNull, + type JsonValue, + queryRows, + requireRow, + sql, + whereEq, +} from "@crm/db"; import { MAX_ATTEMPTS, RETIRED_OUTCOME } from "@crm/db/agent-tasks"; import { DISPATCH } from "./dispatch-config"; @@ -9,7 +21,7 @@ export type LeasedTask = { dealId: string | null; kind: string; reason: string; - payload: Prisma.JsonValue | null; + payload: JsonValue | null; budget: number; attempts: number; priority: number; @@ -39,7 +51,9 @@ export async function claimDue( const onlyMode = "only" in kinds; - const claimed = await db.$queryRaw` + const claimed = await queryRows( + db, + sql` UPDATE "agentTask" AS t SET "leasedUntil" = ${until}, "startedAt" = COALESCE(t."startedAt", ${now}), @@ -51,8 +65,8 @@ export async function claimDue( AND (t2."leasedUntil" IS NULL OR t2."leasedUntil" < ${now}) AND t2."attempts" < ${MAX_ATTEMPTS} AND CASE - WHEN ${onlyMode}::boolean THEN t2.kind = ANY(${list}::text[]) - ELSE t2.kind <> ALL(${list}::text[]) + WHEN ${onlyMode}::boolean THEN t2.kind = ANY(${sql.param(list)}::text[]) + ELSE t2.kind <> ALL(${sql.param(list)}::text[]) END ORDER BY t2."priority" DESC, t2."dueAt" ASC LIMIT ${limit} @@ -61,7 +75,8 @@ export async function claimDue( WHERE t.id = due.id RETURNING t.id, t."contactId", t."companyId", t."dealId", t.kind, t.reason, t.payload, t.budget, t.attempts, t.priority, t."dueAt"; - `; + `, + ); return claimed.sort( (a, b) => b.priority - a.priority || a.dueAt.getTime() - b.dueAt.getTime(), @@ -73,7 +88,9 @@ export async function retireExhausted( ): Promise { const now = new Date(); - return db.$queryRaw` + return queryRows( + db, + sql` UPDATE "agentTask" AS t SET "finishedAt" = ${now}, "outcome" = ${RETIRED_OUTCOME} @@ -88,7 +105,8 @@ export async function retireExhausted( FOR UPDATE SKIP LOCKED ) RETURNING t.id, t."contactId", t."companyId", t."dealId", t.kind; - `; + `, + ); } export async function completeTask( @@ -96,50 +114,56 @@ export async function completeTask( outcome: string, sessionId?: string, ): Promise { - const { count } = await db.agentTask.updateMany({ - where: { id: taskId, finishedAt: null }, - data: { + const { rowCount: count } = await db + .update(agentTask) + .set({ finishedAt: new Date(), outcome: outcome.slice(0, 500), sessionId: sessionId || undefined, - }, - }); + }) + .where(and(whereEq(agentTask.id, taskId), isNull(agentTask.finishedAt))); if (count === 0) return null; - return db.agentTask.findUnique({ - where: { id: taskId }, - select: { - id: true, - contactId: true, - companyId: true, - dealId: true, - kind: true, - }, - }); + return firstRow( + db + .select({ + id: agentTask.id, + contactId: agentTask.contactId, + companyId: agentTask.companyId, + dealId: agentTask.dealId, + kind: agentTask.kind, + }) + .from(agentTask) + .where(whereEq(agentTask.id, taskId)) + .limit(1), + ); } export async function taskSubject(taskId: string): Promise { - return db.agentTask.findUnique({ - where: { id: taskId }, - select: { - id: true, - contactId: true, - companyId: true, - dealId: true, - kind: true, - }, - }); + return firstRow( + db + .select({ + id: agentTask.id, + contactId: agentTask.contactId, + companyId: agentTask.companyId, + dealId: agentTask.dealId, + kind: agentTask.kind, + }) + .from(agentTask) + .where(whereEq(agentTask.id, taskId)) + .limit(1), + ); } export async function noteSession( taskId: string, sessionId: string, ): Promise { - await db.agentTask.updateMany({ - where: { id: taskId, finishedAt: null }, - data: { sessionId }, - }); + await db + .update(agentTask) + .set({ sessionId }) + .where(and(whereEq(agentTask.id, taskId), isNull(agentTask.finishedAt))); } export async function scheduleTask(input: { @@ -148,58 +172,69 @@ export async function scheduleTask(input: { dealId?: string | null; kind: string; reason: string; - payload?: Prisma.InputJsonValue | null; + payload?: JsonValue | null; dueAt: Date; priority?: number; budget?: number; }): Promise<{ id: string }> { - const existing = await db.agentTask.findFirst({ - where: { - kind: input.kind, - finishedAt: null, - contactId: input.contactId ?? undefined, - companyId: input.companyId ?? undefined, - dealId: input.dealId ?? undefined, - }, - select: { id: true }, - }); + const existing = await firstRow( + db + .select({ id: agentTask.id }) + .from(agentTask) + .where( + and( + whereEq(agentTask.kind, input.kind), + isNull(agentTask.finishedAt), + whereEq(agentTask.contactId, input.contactId ?? undefined), + whereEq(agentTask.companyId, input.companyId ?? undefined), + whereEq(agentTask.dealId, input.dealId ?? undefined), + ), + ) + .limit(1), + ); if (existing) { - await db.agentTask.update({ - where: { id: existing.id }, - data: { dueAt: input.dueAt, reason: input.reason }, - }); + await requireRow( + db + .update(agentTask) + .set({ dueAt: input.dueAt, reason: input.reason }) + .where(whereEq(agentTask.id, existing.id)) + .returning(), + ); return existing; } - return db.agentTask.create({ - data: { - contactId: input.contactId ?? null, - companyId: input.companyId ?? null, - dealId: input.dealId ?? null, - kind: input.kind, - reason: input.reason, - payload: input.payload ?? undefined, - dueAt: input.dueAt, - priority: input.priority ?? 0, - budget: input.budget ?? 4, - }, - select: { id: true }, - }); + return requireRow( + db + .insert(agentTask) + .values({ + contactId: input.contactId ?? null, + companyId: input.companyId ?? null, + dealId: input.dealId ?? null, + kind: input.kind, + reason: input.reason, + payload: input.payload ?? undefined, + dueAt: input.dueAt, + priority: input.priority ?? 0, + budget: input.budget ?? 4, + }) + .returning({ id: agentTask.id }), + ); } export async function lastDecision(contactId: string) { - return db.agentTask.findFirst({ - where: { contactId }, - orderBy: { createdAt: "desc" }, - select: { - kind: true, - reason: true, - dueAt: true, - finishedAt: true, - outcome: true, - }, - }); + return firstRow( + db + .select({ + kind: agentTask.kind, + reason: agentTask.reason, + dueAt: agentTask.dueAt, + finishedAt: agentTask.finishedAt, + outcome: agentTask.outcome, + }) + .from(agentTask) + .where(whereEq(agentTask.contactId, contactId)) + .orderBy(desc(agentTask.createdAt)) + .limit(1), + ); } - -export type { Prisma }; diff --git a/apps/agent/agent/subagents/agent_runner/agent.ts b/apps/agent/agent/subagents/agent_runner/agent.ts index 5b6b65f3c..9f90e3809 100644 --- a/apps/agent/agent/subagents/agent_runner/agent.ts +++ b/apps/agent/agent/subagents/agent_runner/agent.ts @@ -1,4 +1,4 @@ -import { db } from "@crm/db"; +import { agentRun, db, orNull, whereEq } from "@crm/db"; import { DEFAULT_AGENT_MODEL } from "@crm/db/settings"; import { defineAgent, defineDynamic } from "eve"; import { attribute, purposeOf } from "../../lib/session-purpose"; @@ -23,14 +23,17 @@ export default defineAgent({ }; } - const run = await db.agentRun.findUnique({ - where: { id: runId }, - select: { - version: { - select: { modelId: true, modelContextWindowTokens: true }, + const run = await db.query.agentRun + .findFirst({ + where: whereEq(agentRun.id, runId), + columns: {}, + with: { + version: { + columns: { modelId: true, modelContextWindowTokens: true }, + }, }, - }, - }); + }) + .then(orNull); return run ? { model: run.version.modelId, diff --git a/apps/agent/agent/tools/mcp.ts b/apps/agent/agent/tools/mcp.ts index 88c51c9a6..4d311772b 100644 --- a/apps/agent/agent/tools/mcp.ts +++ b/apps/agent/agent/tools/mcp.ts @@ -1,4 +1,14 @@ -import { db } from "@crm/db"; +import { + agentRun, + and, + db, + eq, + inArray, + mcpConnection, + mcpToolSnapshot, + orNull, + whereEq, +} from "@crm/db"; import { WORKSPACE_ID } from "@crm/db/workspace"; import { parseAgentManifest } from "@crm/validation/agent-manifest"; import { defineDynamic, defineTool } from "eve/tools"; @@ -18,28 +28,30 @@ export default defineDynamic({ if (purposeOf(ctx) !== "team-agent") return null; const runId = attribute(ctx, "runId"); if (!runId) return null; - const run = await db.agentRun.findUnique({ - where: { id: runId }, - select: { version: { select: { manifest: true } } }, - }); + const run = await db.query.agentRun + .findFirst({ + where: whereEq(agentRun.id, runId), + columns: {}, + with: { version: { columns: { manifest: true } } }, + }) + .then(orNull); if (!run) return null; const manifest = parseAgentManifest(run.version.manifest); if (manifest.mcp.length === 0) return null; - const connections = await db.mcpConnection.findMany({ - where: { - organizationId: WORKSPACE_ID, - platformSlug: { - in: manifest.mcp.map((grant) => grant.platformSlug), - }, - status: "CONNECTED", - }, - select: { - id: true, - platformSlug: true, - accessMode: true, + const connections = await db.query.mcpConnection.findMany({ + where: and( + eq(mcpConnection.organizationId, WORKSPACE_ID), + inArray( + mcpConnection.platformSlug, + manifest.mcp.map((grant) => grant.platformSlug), + ), + eq(mcpConnection.status, "CONNECTED"), + ), + columns: { id: true, platformSlug: true, accessMode: true }, + with: { toolSnapshots: { - where: { reviewState: "REVIEWED" }, - select: { + where: eq(mcpToolSnapshot.reviewState, "REVIEWED"), + columns: { name: true, description: true, inputSchema: true, diff --git a/apps/agent/agent/tools/record_job_change.ts b/apps/agent/agent/tools/record_job_change.ts index d7fdbb726..b860af2f0 100644 --- a/apps/agent/agent/tools/record_job_change.ts +++ b/apps/agent/agent/tools/record_job_change.ts @@ -1,4 +1,10 @@ -import { db } from "@crm/db"; +import { + contact as contactTable, + db, + firstRow, + requireRow, + whereEq, +} from "@crm/db"; import { defineTool } from "eve/tools"; import { z } from "zod"; import { sensitiveWrite } from "../lib/approval"; @@ -34,15 +40,18 @@ export default defineTool({ }; } - const contact = await db.contact.findUnique({ - where: { id: contactId }, - select: { - firstName: true, - lastName: true, - ownerId: true, - companyId: true, - }, - }); + const contact = await firstRow( + db + .select({ + firstName: contactTable.firstName, + lastName: contactTable.lastName, + ownerId: contactTable.ownerId, + companyId: contactTable.companyId, + }) + .from(contactTable) + .where(whereEq(contactTable.id, contactId)) + .limit(1), + ); if (!contact) return { raised: false as const, reason: "No such contact." }; const name = [contact.firstName, contact.lastName] @@ -66,10 +75,13 @@ export default defineTool({ ); if (moveToCompanyId) { - await db.contact.update({ - where: { id: contactId }, - data: { companyId: moveToCompanyId }, - }); + await requireRow( + db + .update(contactTable) + .set({ companyId: moveToCompanyId }) + .where(whereEq(contactTable.id, contactId)) + .returning(), + ); } return { diff --git a/apps/agent/agent/tools/request_connection.ts b/apps/agent/agent/tools/request_connection.ts index 5fae5187a..0a8425c4a 100644 --- a/apps/agent/agent/tools/request_connection.ts +++ b/apps/agent/agent/tools/request_connection.ts @@ -1,4 +1,4 @@ -import { db } from "@crm/db"; +import { and, db, eq, firstRow, mcpConnection, whereEq } from "@crm/db"; import { WORKSPACE_ID } from "@crm/db/workspace"; import { vendors } from "@crm/mcp-integrations/catalog"; import { agentConnectionRequest } from "@crm/validation/agent-connection-request"; @@ -28,15 +28,21 @@ export default defineTool({ } const entry = vendors.find((candidate) => candidate.slug === vendor); if (!entry) throw new Error("The MCP platform is unavailable."); - const connection = await db.mcpConnection.findUnique({ - where: { - organizationId_platformSlug: { - organizationId: WORKSPACE_ID, - platformSlug: vendor, - }, - }, - select: { status: true, lastErrorCode: true }, - }); + const connection = await firstRow( + db + .select({ + status: mcpConnection.status, + lastErrorCode: mcpConnection.lastErrorCode, + }) + .from(mcpConnection) + .where( + and( + eq(mcpConnection.organizationId, WORKSPACE_ID), + whereEq(mcpConnection.platformSlug, vendor), + ), + ) + .limit(1), + ); return agentConnectionRequest.parse({ vendor: entry.slug, name: entry.name, diff --git a/apps/agent/agent/tools/research_company.ts b/apps/agent/agent/tools/research_company.ts index fd6ea10cb..c1aaeab6a 100644 --- a/apps/agent/agent/tools/research_company.ts +++ b/apps/agent/agent/tools/research_company.ts @@ -1,4 +1,13 @@ -import { ActivityType, db } from "@crm/db"; +import { + ActivityType, + activity as activityTable, + company as companyTable, + db, + firstRow, + requireRow, + user, + whereEq, +} from "@crm/db"; import { defineTool } from "eve/tools"; import { z } from "zod"; import { extract, type JsonSchema } from "../lib/context-dev"; @@ -75,16 +84,19 @@ export default defineTool({ companyId: z.string(), }), async execute({ companyId }) { - const company = await db.company.findUnique({ - where: { id: companyId }, - select: { - id: true, - name: true, - domain: true, - website: true, - ownerId: true, - }, - }); + const company = await firstRow( + db + .select({ + id: companyTable.id, + name: companyTable.name, + domain: companyTable.domain, + website: companyTable.website, + ownerId: companyTable.ownerId, + }) + .from(companyTable) + .where(whereEq(companyTable.id, companyId)) + .limit(1), + ); if (!company) return { written: false as const, reason: "No such company." }; @@ -110,38 +122,43 @@ export default defineTool({ const author = company.ownerId ?? - (await db.user.findFirst({ select: { id: true } }))?.id ?? + (await firstRow(db.select({ id: user.id }).from(user).limit(1)))?.id ?? null; if (!author) return { written: false as const, reason: "No user to attribute to." }; const scalar = briefScalar.parse(result.data); - const activity = await db.activity.create({ - data: { - type: ActivityType.ENRICHMENT, - subject: `Research brief — ${company.name}`, - body: - scalar === null - ? formatBrief(researchBrief.parse(result.data)) - : String(scalar), - occurredAt: new Date(), - companyId: company.id, - createdById: author, - meta: { - source: "context.dev", - endpoint: "web/extract", - creditCost: 10, - agent: "people-research", - }, - }, - select: { id: true }, - }); - - await db.company.update({ - where: { id: companyId }, - data: { lastActivityAt: new Date() }, - }); + const activity = await requireRow( + db + .insert(activityTable) + .values({ + type: ActivityType.ENRICHMENT, + subject: `Research brief — ${company.name}`, + body: + scalar === null + ? formatBrief(researchBrief.parse(result.data)) + : String(scalar), + occurredAt: new Date(), + companyId: company.id, + createdById: author, + meta: { + source: "context.dev", + endpoint: "web/extract", + creditCost: 10, + agent: "people-research", + }, + }) + .returning({ id: activityTable.id }), + ); + + await requireRow( + db + .update(companyTable) + .set({ lastActivityAt: new Date() }) + .where(whereEq(companyTable.id, companyId)) + .returning(), + ); return { written: true as const, activityId: activity.id }; }, diff --git a/apps/agent/evals/agent-builder.eval.ts b/apps/agent/evals/agent-builder.eval.ts index 6612ee907..48a01e96f 100644 --- a/apps/agent/evals/agent-builder.eval.ts +++ b/apps/agent/evals/agent-builder.eval.ts @@ -1,4 +1,22 @@ -import { db } from "@crm/db"; +import { + agentAuditEvent, + agentBuilderArtifact, + agentConversation, + agentConversationSubmission, + agentDefinition, + agentEvent, + agentTrigger, + agentVersion, + db, + desc, + eq, + firstRow, + inArray, + orThrow, + requireRow, + user, + whereEq, +} from "@crm/db"; import { agentManifest } from "@crm/validation/agent-manifest"; import { defineEval } from "eve/evals"; import { equals, satisfies } from "eve/evals/expect"; @@ -27,30 +45,33 @@ export default defineEval({ let sessionId: string | null = null; try { - await db.user.create({ - data: { - id: userId, - name: "Agent Builder Eval", - email: `${userId}@example.test`, - }, - }); - const conversation = await db.agentConversation.create({ - data: { - kind: "BUILDER", - userId, - submissions: { - create: { - submittedById: userId, - clientRequestId: crypto.randomUUID(), - commandType: "CREATE_AGENT", - message: { - text: "Build me an agent that gives me a snapshot of our CRM accounts.", - resources: [], - }, - }, + await requireRow( + db + .insert(user) + .values({ + id: userId, + name: "Agent Builder Eval", + email: `${userId}@example.test`, + }) + .returning(), + ); + const conversation = await requireRow( + db + .insert(agentConversation) + .values({ kind: "BUILDER", userId }) + .returning({ id: agentConversation.id }), + ); + await db.insert(agentConversationSubmission).values({ + conversationId: conversation.id, + ...{ + submittedById: userId, + clientRequestId: crypto.randomUUID(), + commandType: "CREATE_AGENT", + message: { + text: "Build me an agent that gives me a snapshot of our CRM accounts.", + resources: [], }, }, - select: { id: true }, }); conversationId = conversation.id; @@ -74,25 +95,28 @@ export default defineEval({ session.notCalledTool("record_fact"); session.notCalledTool("record_job_change"); - const saved = await db.agentConversation.findUniqueOrThrow({ - where: { id: conversation.id }, - select: { - agent: { - select: { - status: true, - versions: { - orderBy: { number: "desc" }, - take: 1, - select: { status: true, manifest: true }, + const saved = await db.query.agentConversation + .findFirst({ + where: whereEq(agentConversation.id, conversation.id), + columns: {}, + with: { + agent: { + columns: { status: true }, + with: { + versions: { + orderBy: [desc(agentVersion.number)], + limit: 1, + columns: { status: true, manifest: true }, + }, }, }, + builderArtifacts: { + where: eq(agentBuilderArtifact.status, "READY"), + columns: { path: true }, + }, }, - builderArtifacts: { - where: { status: "READY" }, - select: { path: true }, - }, - }, - }); + }) + .then(orThrow); t.check(saved.agent?.status, equals("DRAFT")); t.check(saved.agent?.versions[0]?.status, equals("READY")); t.check(saved.builderArtifacts.length, equals(3)); @@ -120,10 +144,13 @@ async function waitForBuilderSession( ): Promise { for (let attempt = 0; attempt < 600; attempt += 1) { if (signal.aborted) return null; - const conversation = await db.agentConversation.findUnique({ - where: { id: conversationId }, - select: { sessionId: true }, - }); + const conversation = await firstRow( + db + .select({ sessionId: agentConversation.sessionId }) + .from(agentConversation) + .where(whereEq(agentConversation.id, conversationId)) + .limit(1), + ); if (conversation?.sessionId) return conversation.sessionId; await new Promise((resolve) => setTimeout(resolve, 100)); } @@ -135,32 +162,51 @@ async function cleanupBuilderEval( conversationId: string | null, sessionId: string | null, ) { - const agents = await db.agentDefinition.findMany({ - where: { createdById: userId }, - select: { id: true }, - }); + const agents = await db + .select({ id: agentDefinition.id }) + .from(agentDefinition) + .where(whereEq(agentDefinition.createdById, userId)); const agentIds = agents.map((agent) => agent.id); - if (sessionId) await db.agentEvent.deleteMany({ where: { sessionId } }); + if (sessionId) + await db.delete(agentEvent).where(whereEq(agentEvent.sessionId, sessionId)); if (conversationId) { - await db.agentBuilderArtifact.deleteMany({ where: { conversationId } }); + await db + .delete(agentBuilderArtifact) + .where(whereEq(agentBuilderArtifact.conversationId, conversationId)); } if (agentIds.length > 0) { - await db.agentBuilderArtifact.deleteMany({ - where: { version: { agentId: { in: agentIds } } }, - }); - await db.agentAuditEvent.deleteMany({ - where: { agentId: { in: agentIds } }, - }); - await db.agentTrigger.deleteMany({ where: { agentId: { in: agentIds } } }); - await db.agentDefinition.updateMany({ - where: { id: { in: agentIds } }, - data: { currentVersionId: null }, - }); - await db.agentVersion.deleteMany({ where: { agentId: { in: agentIds } } }); - await db.agentDefinition.deleteMany({ where: { id: { in: agentIds } } }); + await db + .delete(agentBuilderArtifact) + .where( + inArray( + agentBuilderArtifact.versionId, + db + .select({ id: agentVersion.id }) + .from(agentVersion) + .where(inArray(agentVersion.agentId, agentIds)), + ), + ); + await db + .delete(agentAuditEvent) + .where(inArray(agentAuditEvent.agentId, agentIds)); + await db + .delete(agentTrigger) + .where(inArray(agentTrigger.agentId, agentIds)); + await db + .update(agentDefinition) + .set({ currentVersionId: null }) + .where(inArray(agentDefinition.id, agentIds)); + await db + .delete(agentVersion) + .where(inArray(agentVersion.agentId, agentIds)); + await db + .delete(agentDefinition) + .where(inArray(agentDefinition.id, agentIds)); } if (conversationId) { - await db.agentConversation.deleteMany({ where: { id: conversationId } }); + await db + .delete(agentConversation) + .where(whereEq(agentConversation.id, conversationId)); } - await db.user.deleteMany({ where: { id: userId } }); + await db.delete(user).where(whereEq(user.id, userId)); } diff --git a/apps/agent/scripts/backfill-brand-images.ts b/apps/agent/scripts/backfill-brand-images.ts index 8f5a9a3c7..9f1103f7b 100644 --- a/apps/agent/scripts/backfill-brand-images.ts +++ b/apps/agent/scripts/backfill-brand-images.ts @@ -1,4 +1,4 @@ -import { db } from "@crm/db"; +import { company, db, requireRow, whereEq } from "@crm/db"; import { blobEnabled, isMirrored, mirror } from "@crm/db/blob"; import { COMPANY_IMAGE_FIELDS, type CompanyImageField } from "@crm/db/images"; import { z } from "zod"; @@ -17,12 +17,11 @@ if (!blobEnabled()) { ); } -const rows = await db.companyEnrichment.findMany({ - select: { - companyId: true, - raw: true, +const rows = await db.query.companyEnrichment.findMany({ + columns: { companyId: true, raw: true }, + with: { company: { - select: { + columns: { name: true, description: true, logoUrl: true, @@ -81,7 +80,13 @@ for (const row of rows) { if (Object.keys(patch).length === 0) continue; - await db.company.update({ where: { id: row.companyId }, data: patch }); + await requireRow( + db + .update(company) + .set(patch) + .where(whereEq(company.id, row.companyId)) + .returning(), + ); updated += 1; console.log(`${row.company.name}: ${Object.keys(patch).join(", ")}`); } @@ -89,4 +94,4 @@ for (const row of rows) { console.log( `\nUpdated ${updated} of ${rows.length} enriched companies. Copied ${copied} images.`, ); -await db.$disconnect(); +await db.$client.end(); diff --git a/apps/agent/scripts/backfill-empty-facts.ts b/apps/agent/scripts/backfill-empty-facts.ts index 87bfa07a3..b1f9cbc32 100644 --- a/apps/agent/scripts/backfill-empty-facts.ts +++ b/apps/agent/scripts/backfill-empty-facts.ts @@ -26,7 +26,7 @@ if (sweep.unscanned > 0) { ); } -await db.$disconnect(); +await db.$client.end(); function count(n: number, noun: string): string { return `${n} ${noun}${n === 1 ? "" : "s"}`; diff --git a/apps/agent/test/accounts.integration.spec.ts b/apps/agent/test/accounts.integration.spec.ts index 536eaf0e7..276dd6877 100644 --- a/apps/agent/test/accounts.integration.spec.ts +++ b/apps/agent/test/accounts.integration.spec.ts @@ -1,5 +1,24 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { ActivityType, DealStage, db, EmailDirection } from "@crm/db"; +import { + ActivityType, + activity, + calendarAttendee, + calendarEvent, + company as companyTable, + contact, + DealStage, + Decimal, + db, + dealContact, + deal as dealTable, + EmailDirection, + emailMessage, + emailThread, + firstRow, + requireRow, + user as userTable, + whereEq, +} from "@crm/db"; import { readCompanyHistory, readDealHistory } from "../agent/lib/accounts"; const suffix = process.env.TEST_RUN_ID ?? "accounts-spec"; @@ -17,185 +36,212 @@ const daysAhead = (days: number) => new Date(Date.now() + days * 86_400_000); beforeAll(async () => { await cleanup(); - const user = await db.user.create({ - data: { - id: `user-${suffix}`, - name: "Rep One", - email: `rep.${suffix}@example.test`, - emailVerified: true, - }, - select: { id: true }, - }); + const user = await requireRow( + db + .insert(userTable) + .values({ + id: `user-${suffix}`, + name: "Rep One", + email: `rep.${suffix}@example.test`, + emailVerified: true, + }) + .returning({ id: userTable.id }), + ); userId = user.id; - const company = await db.company.create({ - data: { - name: `Fernhill Systems ${suffix}`, - domain, - industry: "Security software", - lastActivityAt: daysAgo(1), - }, - select: { id: true }, - }); + const company = await requireRow( + db + .insert(companyTable) + .values({ + name: `Fernhill Systems ${suffix}`, + domain, + industry: "Security software", + lastActivityAt: daysAgo(1), + }) + .returning({ id: companyTable.id }), + ); companyId = company.id; - const paula = await db.contact.create({ - data: { - firstName: "Paula", - lastName: "Marchetti", - title: "Growth Specialist", - email: `paula.marchetti@${domain}`, - companyId, - lastActivityAt: daysAgo(1), - }, - select: { id: true }, - }); + const paula = await requireRow( + db + .insert(contact) + .values({ + firstName: "Paula", + lastName: "Marchetti", + title: "Growth Specialist", + email: `paula.marchetti@${domain}`, + companyId, + lastActivityAt: daysAgo(1), + }) + .returning({ id: contact.id }), + ); paulaId = paula.id; - const placeholder = await db.contact.create({ - data: { - firstName: "Tsomerville", - lastName: null, - email: `tsomerville@${domain}`, - companyId, - lastActivityAt: daysAgo(30), - }, - select: { id: true }, - }); + const placeholder = await requireRow( + db + .insert(contact) + .values({ + firstName: "Tsomerville", + lastName: null, + email: `tsomerville@${domain}`, + companyId, + lastActivityAt: daysAgo(30), + }) + .returning({ id: contact.id }), + ); placeholderId = placeholder.id; - const deal = await db.deal.create({ - data: { - name: `Fernhill platform ${suffix}`, - companyId, - ownerId: userId, - stage: DealStage.CONTRACT_SENT, - stageChangedAt: daysAgo(42), - amount: 48_000, - currency: "USD", - expectedCloseDate: daysAhead(14), - lastActivityAt: daysAgo(3), - contacts: { create: [{ contactId: paulaId, role: "Champion" }] }, - }, - select: { id: true }, - }); - dealId = deal.id; - - await db.activity.createMany({ - data: [ - { - type: ActivityType.STAGE_CHANGE, - subject: "Stage changed", - companyId, - dealId, - createdById: userId, - createdAt: daysAgo(60), - meta: { from: "DEMO_BOOKED", to: "QUALIFIED_TO_BUY" }, - }, - { - type: ActivityType.STAGE_CHANGE, - subject: "Stage changed", - companyId, - dealId, - createdById: userId, - createdAt: daysAgo(42), - meta: { from: "QUALIFIED_TO_BUY", to: "CONTRACT_SENT" }, - }, - { - type: ActivityType.NOTE, - subject: "Pricing pushback", - body: "They want the security review done before signing.", - occurredAt: daysAgo(5), - companyId, - dealId, - createdById: userId, - }, - { - type: ActivityType.EMAIL, - subject: "Re: Contract", + const deal = await requireRow( + db + .insert(dealTable) + .values({ + name: `Fernhill platform ${suffix}`, companyId, - dealId, - createdById: userId, - }, - ], - }); + ownerId: userId, + stage: DealStage.CONTRACT_SENT, + stageChangedAt: daysAgo(42), + amount: new Decimal(48_000), + currency: "USD", + expectedCloseDate: daysAhead(14), + lastActivityAt: daysAgo(3), + }) + .returning({ id: dealTable.id }), + ); + await db + .insert(dealContact) + .values({ dealId: deal.id, contactId: paulaId, role: "Champion" }); + dealId = deal.id; - const thread = await db.emailThread.create({ - data: { - rootMessageId: ``, + await db.insert(activity).values([ + { + type: ActivityType.STAGE_CHANGE, + subject: "Stage changed", + companyId, + dealId, + createdById: userId, + createdAt: daysAgo(60), + meta: { from: "DEMO_BOOKED", to: "QUALIFIED_TO_BUY" }, + }, + { + type: ActivityType.STAGE_CHANGE, + subject: "Stage changed", + companyId, + dealId, + createdById: userId, + createdAt: daysAgo(42), + meta: { from: "QUALIFIED_TO_BUY", to: "CONTRACT_SENT" }, + }, + { + type: ActivityType.NOTE, + subject: "Pricing pushback", + body: "They want the security review done before signing.", + occurredAt: daysAgo(5), + companyId, + dealId, + createdById: userId, + }, + { + type: ActivityType.EMAIL, subject: "Re: Contract", companyId, - contactId: paulaId, - firstMessageAt: daysAgo(9), - lastMessageAt: daysAgo(3), - messageCount: 2, + dealId, + createdById: userId, }, - select: { id: true }, - }); + ]); - await db.emailMessage.createMany({ - data: [ - { - threadId: thread.id, - rfcMessageId: ``, - direction: EmailDirection.OUTBOUND, - fromEmail: `rep.${suffix}@example.test`, - recipients: [], - subject: "Contract", - body: "Sending the paperwork over.", - sentAt: daysAgo(9), - }, - { - threadId: thread.id, - rfcMessageId: ``, - direction: EmailDirection.INBOUND, - fromEmail: `paula.marchetti@${domain}`, - fromName: "Paula Marchetti", - recipients: [], + const thread = await requireRow( + db + .insert(emailThread) + .values({ + rootMessageId: ``, subject: "Re: Contract", - body: "Thanks — Paula Marchetti, Growth Specialist, Fernhill.", - sentAt: daysAgo(3), - }, - ], - }); - - await db.calendarEvent.create({ - data: { - iCalUid: `event.${suffix}@example.test`, - originalStartTime: daysAhead(4), - title: "Security review", - startsAt: daysAhead(4), - endsAt: daysAhead(4), - status: "confirmed", - companyId, - contactId: paulaId, - attendees: { - create: [ - { email: `paula.marchetti@${domain}`, name: "Paula Marchetti" }, - ], - }, + companyId, + contactId: paulaId, + firstMessageAt: daysAgo(9), + lastMessageAt: daysAgo(3), + messageCount: 2, + }) + .returning({ id: emailThread.id }), + ); + + await db.insert(emailMessage).values([ + { + threadId: thread.id, + rfcMessageId: ``, + direction: EmailDirection.OUTBOUND, + fromEmail: `rep.${suffix}@example.test`, + recipients: [], + subject: "Contract", + body: "Sending the paperwork over.", + sentAt: daysAgo(9), + }, + { + threadId: thread.id, + rfcMessageId: ``, + direction: EmailDirection.INBOUND, + fromEmail: `paula.marchetti@${domain}`, + fromName: "Paula Marchetti", + recipients: [], + subject: "Re: Contract", + body: "Thanks — Paula Marchetti, Growth Specialist, Fernhill.", + sentAt: daysAgo(3), }, + ]); + + const event = await requireRow( + db + .insert(calendarEvent) + .values({ + iCalUid: `event.${suffix}@example.test`, + originalStartTime: daysAhead(4), + title: "Security review", + startsAt: daysAhead(4), + endsAt: daysAhead(4), + status: "confirmed", + companyId, + contactId: paulaId, + }) + .returning(), + ); + await db.insert(calendarAttendee).values({ + eventId: event.id, + email: `paula.marchetti@${domain}`, + name: "Paula Marchetti", }); }); afterAll(cleanup); async function cleanup(): Promise { - const company = await db.company.findFirst({ - where: { domain }, - select: { id: true }, - }); + const company = await firstRow( + db + .select({ id: companyTable.id }) + .from(companyTable) + .where(whereEq(companyTable.domain, domain)) + .limit(1), + ); if (company) { - await db.activity.deleteMany({ where: { companyId: company.id } }); - await db.calendarEvent.deleteMany({ where: { companyId: company.id } }); - await db.emailThread.deleteMany({ where: { companyId: company.id } }); - await db.deal.deleteMany({ where: { companyId: company.id } }); - await db.contact.deleteMany({ where: { companyId: company.id } }); - await db.company.delete({ where: { id: company.id } }); + await db.delete(activity).where(whereEq(activity.companyId, company.id)); + await db + .delete(calendarEvent) + .where(whereEq(calendarEvent.companyId, company.id)); + await db + .delete(emailThread) + .where(whereEq(emailThread.companyId, company.id)); + await db.delete(dealTable).where(whereEq(dealTable.companyId, company.id)); + await db.delete(contact).where(whereEq(contact.companyId, company.id)); + await requireRow( + db + .delete(companyTable) + .where(whereEq(companyTable.id, company.id)) + .returning(), + ); } - await db.user.deleteMany({ where: { email: `rep.${suffix}@example.test` } }); + await db + .delete(userTable) + .where(whereEq(userTable.email, `rep.${suffix}@example.test`)); } describe("readCompanyHistory", () => { diff --git a/apps/agent/test/blank-facts.integration.spec.ts b/apps/agent/test/blank-facts.integration.spec.ts index 412677369..af841a771 100644 --- a/apps/agent/test/blank-facts.integration.spec.ts +++ b/apps/agent/test/blank-facts.integration.spec.ts @@ -1,5 +1,16 @@ import { afterAll, beforeEach, describe, expect, it } from "bun:test"; -import { db, FactBand, FactStatus } from "@crm/db"; +import { + and, + contactFact, + contact as contactTable, + db, + eq, + FactBand, + FactStatus, + firstRow, + requireRow, + whereEq, +} from "@crm/db"; import { sweepBlankFacts } from "../agent/lib/blank-facts"; const suffix = process.env.TEST_RUN_ID ?? "blank-facts-spec"; @@ -12,40 +23,46 @@ async function propose(input: { value: string; score: number; }): Promise { - const fact = await db.contactFact.create({ - data: { - contactId, - field: input.field, - value: input.value, - score: input.score, - band: input.score >= 0.55 ? FactBand.PROBABLE : FactBand.POSSIBLE, - evidence: [{ kind: "web.cited-claim", detail: "a page said so" }], - method: "web", - status: FactStatus.PROPOSED, - }, - select: { id: true }, - }); + const fact = await requireRow( + db + .insert(contactFact) + .values({ + contactId, + field: input.field, + value: input.value, + score: input.score, + band: input.score >= 0.55 ? FactBand.PROBABLE : FactBand.POSSIBLE, + evidence: [{ kind: "web.cited-claim", detail: "a page said so" }], + method: "web", + status: FactStatus.PROPOSED, + }) + .returning({ id: contactFact.id }), + ); return fact.id; } function statusOf(id: string) { - return db.contactFact - .findUnique({ where: { id }, select: { status: true } }) - .then((fact) => fact?.status); + return db + .select({ status: contactFact.status }) + .from(contactFact) + .where(eq(contactFact.id, id)) + .then(([fact]) => fact?.status); } beforeEach(async () => { - await db.contact.deleteMany({ where: { email } }); - const contact = await db.contact.create({ - data: { firstName: "Blank", lastName: "Subject", email }, - select: { id: true }, - }); + await db.delete(contactTable).where(whereEq(contactTable.email, email)); + const contact = await requireRow( + db + .insert(contactTable) + .values({ firstName: "Blank", lastName: "Subject", email }) + .returning({ id: contactTable.id }), + ); contactId = contact.id; }); afterAll(async () => { - await db.contact.deleteMany({ where: { email } }); + await db.delete(contactTable).where(whereEq(contactTable.email, email)); }); describe("sweepBlankFacts", () => { @@ -67,18 +84,24 @@ describe("sweepBlankFacts", () => { expect(await statusOf(strong)).toBe("APPLIED"); expect(await statusOf(weak)).toBe("SUPERSEDED"); - const contact = await db.contact.findUnique({ - where: { id: contactId }, - select: { linkedinUrl: true }, - }); + const contact = await firstRow( + db + .select({ linkedinUrl: contactTable.linkedinUrl }) + .from(contactTable) + .where(whereEq(contactTable.id, contactId)) + .limit(1), + ); expect(contact?.linkedinUrl).toBe("https://www.linkedin.com/in/subject"); }); it("leaves a suggestion that disagrees with what is already there", async () => { - await db.contact.update({ - where: { id: contactId }, - data: { title: "Head of Security" }, - }); + await requireRow( + db + .update(contactTable) + .set({ title: "Head of Security" }) + .where(whereEq(contactTable.id, contactId)) + .returning(), + ); const offer = await propose({ field: "title", @@ -94,10 +117,13 @@ describe("sweepBlankFacts", () => { }); it("clears a suggestion that only repeats the record", async () => { - await db.contact.update({ - where: { id: contactId }, - data: { title: "Head of Security" }, - }); + await requireRow( + db + .update(contactTable) + .set({ title: "Head of Security" }) + .where(whereEq(contactTable.id, contactId)) + .returning(), + ); const echo = await propose({ field: "title", @@ -113,10 +139,13 @@ describe("sweepBlankFacts", () => { }); it("clears a suggestion that differs only by a trailing slash", async () => { - await db.contact.update({ - where: { id: contactId }, - data: { linkedinUrl: "https://www.linkedin.com/in/pogrebs" }, - }); + await requireRow( + db + .update(contactTable) + .set({ linkedinUrl: "https://www.linkedin.com/in/pogrebs" }) + .where(whereEq(contactTable.id, contactId)) + .returning(), + ); const echo = await propose({ field: "linkedinUrl", @@ -132,19 +161,27 @@ describe("sweepBlankFacts", () => { }); it("keeps one of two suggestions offering the same value", async () => { - await db.contact.update({ - where: { id: contactId }, - data: { title: "Head of Security" }, - }); + await requireRow( + db + .update(contactTable) + .set({ title: "Head of Security" }) + .where(whereEq(contactTable.id, contactId)) + .returning(), + ); await propose({ field: "title", value: "VP Security", score: 0.61 }); await propose({ field: "title", value: "VP Security", score: 0.61 }); await sweepBlankFacts(); - const left = await db.contactFact.count({ - where: { contactId, field: "title", status: FactStatus.PROPOSED }, - }); + const left = await db.$count( + contactFact, + and( + whereEq(contactFact.contactId, contactId), + eq(contactFact.field, "title"), + eq(contactFact.status, FactStatus.PROPOSED), + ), + ); expect(left).toBe(1); }); @@ -160,10 +197,13 @@ describe("sweepBlankFacts", () => { expect(sweep.filled).toBe(1); expect(await statusOf(offer)).toBe("PROPOSED"); - const contact = await db.contact.findUnique({ - where: { id: contactId }, - select: { githubUrl: true }, - }); + const contact = await firstRow( + db + .select({ githubUrl: contactTable.githubUrl }) + .from(contactTable) + .where(whereEq(contactTable.id, contactId)) + .limit(1), + ); expect(contact?.githubUrl).toBeNull(); }); }); diff --git a/apps/agent/test/builder-runtime.integration.spec.ts b/apps/agent/test/builder-runtime.integration.spec.ts index 725777651..50e2a19b5 100644 --- a/apps/agent/test/builder-runtime.integration.spec.ts +++ b/apps/agent/test/builder-runtime.integration.spec.ts @@ -1,5 +1,24 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { db } from "@crm/db"; +import { + agentAuditEvent, + agentBuilderArtifact, + agentConversation, + agentDefinition, + agentEvent, + agentTrigger, + agentVersion, + and, + asc, + db, + desc, + eq, + firstRow, + inArray, + or, + requireRow, + user, + whereEq, +} from "@crm/db"; import { persistBuilderInputRequest } from "../agent/lib/builder-input"; import { saveBuilderDraft, @@ -15,62 +34,73 @@ let agentId = ""; const conversationIds: string[] = []; beforeAll(async () => { - await db.user.create({ - data: { - id: userId, - name: "Builder Runtime Test", - email: `${userId}@example.test`, - }, - }); - const conversation = await db.agentConversation.create({ - data: { - kind: "BUILDER", - userId, - sessionId: `builder-question-session-${suffix}`, - }, - select: { id: true }, - }); + await requireRow( + db + .insert(user) + .values({ + id: userId, + name: "Builder Runtime Test", + email: `${userId}@example.test`, + }) + .returning(), + ); + const conversation = await requireRow( + db + .insert(agentConversation) + .values({ + kind: "BUILDER", + userId, + sessionId: `builder-question-session-${suffix}`, + }) + .returning({ id: agentConversation.id }), + ); conversationId = conversation.id; conversationIds.push(conversation.id); }); afterAll(async () => { const agentIds = ( - await db.agentDefinition.findMany({ - where: { createdById: userId }, - select: { id: true }, - }) + await db + .select({ id: agentDefinition.id }) + .from(agentDefinition) + .where(whereEq(agentDefinition.createdById, userId)) ).map((agent) => agent.id); if (agentIds.length > 0) { - await db.agentBuilderArtifact.deleteMany({ - where: { - OR: [ - { conversationId: { in: conversationIds } }, - { version: { agentId: { in: agentIds } } }, - ], - }, - }); - await db.agentAuditEvent.deleteMany({ - where: { agentId: { in: agentIds } }, - }); - await db.agentTrigger.deleteMany({ - where: { agentId: { in: agentIds } }, - }); - await db.agentDefinition.updateMany({ - where: { id: { in: agentIds } }, - data: { currentVersionId: null }, - }); - await db.agentVersion.deleteMany({ - where: { agentId: { in: agentIds } }, - }); - await db.agentDefinition.deleteMany({ - where: { id: { in: agentIds } }, - }); + await db + .delete(agentBuilderArtifact) + .where( + or( + inArray(agentBuilderArtifact.conversationId, conversationIds), + inArray( + agentBuilderArtifact.versionId, + db + .select({ id: agentVersion.id }) + .from(agentVersion) + .where(inArray(agentVersion.agentId, agentIds)), + ), + ), + ); + await db + .delete(agentAuditEvent) + .where(inArray(agentAuditEvent.agentId, agentIds)); + await db + .delete(agentTrigger) + .where(inArray(agentTrigger.agentId, agentIds)); + await db + .update(agentDefinition) + .set({ currentVersionId: null }) + .where(inArray(agentDefinition.id, agentIds)); + await db + .delete(agentVersion) + .where(inArray(agentVersion.agentId, agentIds)); + await db + .delete(agentDefinition) + .where(inArray(agentDefinition.id, agentIds)); } - await db.agentConversation.deleteMany({ - where: { id: { in: conversationIds } }, - }); - await db.user.deleteMany({ where: { id: userId } }); + await db + .delete(agentConversation) + .where(inArray(agentConversation.id, conversationIds)); + await db.delete(user).where(whereEq(user.id, userId)); }); describe("builder persistence", () => { @@ -99,21 +129,37 @@ describe("builder persistence", () => { await persistBuilderInputRequest(event, undefined, conversationId), ).toBe(true); expect( - await db.agentConversation.findUnique({ - where: { id: conversationId }, - select: { continuationToken: true, pendingInputRequests: true }, - }), + await firstRow( + db + .select({ + continuationToken: agentConversation.continuationToken, + pendingInputRequests: agentConversation.pendingInputRequests, + }) + .from(agentConversation) + .where(whereEq(agentConversation.id, conversationId)) + .limit(1), + ), ).toEqual({ continuationToken: builderToken(conversationId), pendingInputRequests: [request], }); expect( - await db.agentEvent.findUnique({ - where: { - id: `builder-input:${conversationId}:${event.turnId}:${event.sequence}:${event.stepIndex}`, - }, - select: { conversationId: true, type: true, data: true }, - }), + await firstRow( + db + .select({ + conversationId: agentEvent.conversationId, + type: agentEvent.type, + data: agentEvent.data, + }) + .from(agentEvent) + .where( + whereEq( + agentEvent.id, + `builder-input:${conversationId}:${event.turnId}:${event.sequence}:${event.stepIndex}`, + ), + ) + .limit(1), + ), ).toEqual({ conversationId, type: "input.requested", @@ -151,29 +197,38 @@ describe("builder persistence", () => { ).toBe(false); expect( - await db.agentConversation.findUnique({ - where: { id: conversationId }, - select: { pendingInputRequests: true }, - }), + await firstRow( + db + .select({ + pendingInputRequests: agentConversation.pendingInputRequests, + }) + .from(agentConversation) + .where(whereEq(agentConversation.id, conversationId)) + .limit(1), + ), ).toEqual({ pendingInputRequests: newer.requests }); expect( - await db.agentEvent.count({ - where: { - id: `builder-input:${conversationId}:${event.turnId}:2:${event.stepIndex}`, - }, - }), + await db.$count( + agentEvent, + whereEq( + agentEvent.id, + `builder-input:${conversationId}:${event.turnId}:2:${event.stepIndex}`, + ), + ), ).toBe(0); }); it("persists every request from the same event", async () => { - const conversation = await db.agentConversation.create({ - data: { - kind: "BUILDER", - userId, - sessionId: `builder-approval-session-${suffix}`, - }, - select: { id: true }, - }); + const conversation = await requireRow( + db + .insert(agentConversation) + .values({ + kind: "BUILDER", + userId, + sessionId: `builder-approval-session-${suffix}`, + }) + .returning({ id: agentConversation.id }), + ); conversationIds.push(conversation.id); const approval = { kind: "tool-approval" as const, @@ -230,22 +285,29 @@ describe("builder persistence", () => { ), ).toBe(true); expect( - await db.agentConversation.findUnique({ - where: { id: conversation.id }, - select: { pendingInputRequests: true }, - }), + await firstRow( + db + .select({ + pendingInputRequests: agentConversation.pendingInputRequests, + }) + .from(agentConversation) + .where(whereEq(agentConversation.id, conversation.id)) + .limit(1), + ), ).toEqual({ pendingInputRequests: [question, approval, sessionLimit] }); }); it("ignores a delayed question from a turn the session has already left", async () => { - const conversation = await db.agentConversation.create({ - data: { - kind: "BUILDER", - userId, - sessionId: `builder-stale-turn-session-${suffix}`, - }, - select: { id: true }, - }); + const conversation = await requireRow( + db + .insert(agentConversation) + .values({ + kind: "BUILDER", + userId, + sessionId: `builder-stale-turn-session-${suffix}`, + }) + .returning({ id: agentConversation.id }), + ); conversationIds.push(conversation.id); const question = (requestId: string, prompt: string) => ({ @@ -289,25 +351,31 @@ describe("builder persistence", () => { ).toBe(false); expect( - await db.agentConversation.findUnique({ - where: { id: conversation.id }, - select: { pendingInputRequests: true }, - }), + await firstRow( + db + .select({ + pendingInputRequests: agentConversation.pendingInputRequests, + }) + .from(agentConversation) + .where(whereEq(agentConversation.id, conversation.id)) + .limit(1), + ), ).toEqual({ pendingInputRequests: [current] }); expect( - await db.agentEvent.count({ - where: { - id: `builder-input:${conversation.id}:turn_3:3:7`, - }, - }), + await db.$count( + agentEvent, + whereEq(agentEvent.id, `builder-input:${conversation.id}:turn_3:3:7`), + ), ).toBe(0); }); it("sets a concise model-authored title only once", async () => { - const conversation = await db.agentConversation.create({ - data: { kind: "BUILDER", userId }, - select: { id: true }, - }); + const conversation = await requireRow( + db + .insert(agentConversation) + .values({ kind: "BUILDER", userId }) + .returning({ id: agentConversation.id }), + ); conversationIds.push(conversation.id); expect( @@ -378,14 +446,30 @@ describe("builder persistence", () => { expect(new Set(saved.map((save) => save.agentId)).size).toBe(1); expect(new Set(saved.map((save) => save.versionId)).size).toBe(1); agentId = saved[0]?.agentId ?? ""; - expect(await db.agentDefinition.count({ where: { id: agentId } })).toBe(1); - expect(await db.agentVersion.count({ where: { agentId } })).toBe(1); - - const artifact = await db.agentBuilderArtifact.findFirstOrThrow({ - where: { conversationId, path: "agent/instructions.md" }, - orderBy: { revision: "desc" }, - select: { revision: true, status: true, versionId: true }, - }); + expect( + await db.$count(agentDefinition, whereEq(agentDefinition.id, agentId)), + ).toBe(1); + expect( + await db.$count(agentVersion, whereEq(agentVersion.agentId, agentId)), + ).toBe(1); + + const artifact = await requireRow( + db + .select({ + revision: agentBuilderArtifact.revision, + status: agentBuilderArtifact.status, + versionId: agentBuilderArtifact.versionId, + }) + .from(agentBuilderArtifact) + .where( + and( + whereEq(agentBuilderArtifact.conversationId, conversationId), + eq(agentBuilderArtifact.path, "agent/instructions.md"), + ), + ) + .orderBy(desc(agentBuilderArtifact.revision)) + .limit(1), + ); expect(artifact).toEqual({ revision: 1, status: "READY", @@ -394,10 +478,12 @@ describe("builder persistence", () => { }); it("assigns consecutive versions to distinct concurrent drafts", async () => { - const conversation = await db.agentConversation.create({ - data: { kind: "BUILDER", userId }, - select: { id: true }, - }); + const conversation = await requireRow( + db + .insert(agentConversation) + .values({ kind: "BUILDER", userId }) + .returning({ id: agentConversation.id }), + ); conversationIds.push(conversation.id); const base = { description: "Prepare a distinct CRM run summary.", @@ -436,17 +522,20 @@ describe("builder persistence", () => { 1, 2, 3, 4, ]); expect( - await db.agentVersion.count({ - where: { agentId: saved[0]?.agentId }, - }), + await db.$count( + agentVersion, + whereEq(agentVersion.agentId, saved[0]?.agentId), + ), ).toBe(4); }); it("persists every event trigger on one agent version", async () => { - const conversation = await db.agentConversation.create({ - data: { kind: "BUILDER", userId }, - select: { id: true }, - }); + const conversation = await requireRow( + db + .insert(agentConversation) + .values({ kind: "BUILDER", userId }) + .returning({ id: agentConversation.id }), + ); conversationIds.push(conversation.id); const saved = await saveBuilderDraft(conversation.id, userId, { @@ -488,21 +577,24 @@ describe("builder persistence", () => { if (!saved.saved) throw new Error("Lifecycle draft was not saved"); expect( - await db.agentTrigger.findMany({ - where: { versionId: saved.versionId }, - orderBy: { createdAt: "asc" }, - select: { type: true, config: true }, - }), + await db + .select({ type: agentTrigger.type, config: agentTrigger.config }) + .from(agentTrigger) + .where(whereEq(agentTrigger.versionId, saved.versionId)) + .orderBy(asc(agentTrigger.createdAt)), ).toEqual([ { type: "EVENT", config: { event: "deal.created" } }, { type: "EVENT", config: { event: "deal.opened" } }, { type: "EVENT", config: { event: "deal.closed" } }, ]); - const version = await db.agentVersion.findUniqueOrThrow({ - where: { id: saved.versionId }, - select: { manifest: true }, - }); + const version = await requireRow( + db + .select({ manifest: agentVersion.manifest }) + .from(agentVersion) + .where(whereEq(agentVersion.id, saved.versionId)) + .limit(1), + ); expect(version.manifest).toMatchObject({ triggers: [ { config: { event: "deal.created" } }, @@ -513,10 +605,12 @@ describe("builder persistence", () => { }); it("fails closed on unsupported integrations and ambiguous record scope", async () => { - const conversation = await db.agentConversation.create({ - data: { kind: "BUILDER", userId }, - select: { id: true }, - }); + const conversation = await requireRow( + db + .insert(agentConversation) + .values({ kind: "BUILDER", userId }) + .returning({ id: agentConversation.id }), + ); conversationIds.push(conversation.id); const base = { name: "Safe scope", @@ -593,10 +687,12 @@ describe("builder persistence", () => { }); it("keeps a live definition unchanged until a revised version is deployed", async () => { - const conversation = await db.agentConversation.create({ - data: { kind: "BUILDER", userId }, - select: { id: true }, - }); + const conversation = await requireRow( + db + .insert(agentConversation) + .values({ kind: "BUILDER", userId }) + .returning({ id: agentConversation.id }), + ); conversationIds.push(conversation.id); const original = { @@ -625,21 +721,27 @@ describe("builder persistence", () => { const first = await saveBuilderDraft(conversation.id, userId, original); if (!first.saved) throw new Error("Initial draft was not saved"); - await db.$transaction([ - db.agentVersion.update({ - where: { id: first.versionId }, - data: { status: "DEPLOYED" }, - }), - db.agentDefinition.update({ - where: { id: first.agentId }, - data: { - name: "Workspace pulse", - description: "Team-visible description", - status: "LIVE", - currentVersionId: first.versionId, - }, - }), - ]); + await db.transaction(async (tx) => { + await requireRow( + tx + .update(agentVersion) + .set({ status: "DEPLOYED" }) + .where(eq(agentVersion.id, first.versionId)) + .returning(), + ); + await requireRow( + tx + .update(agentDefinition) + .set({ + name: "Workspace pulse", + description: "Team-visible description", + status: "LIVE", + currentVersionId: first.versionId, + }) + .where(eq(agentDefinition.id, first.agentId)) + .returning(), + ); + }); const revised = { ...original, @@ -656,23 +758,41 @@ describe("builder persistence", () => { if (!second.saved) throw new Error("Revised draft was not saved"); const [definition, version, artifacts] = await Promise.all([ - db.agentDefinition.findUniqueOrThrow({ - where: { id: first.agentId }, - select: { - name: true, - description: true, - status: true, - currentVersionId: true, - }, - }), - db.agentVersion.findUniqueOrThrow({ - where: { id: second.versionId }, - select: { number: true, status: true, manifest: true }, - }), - db.agentBuilderArtifact.findMany({ - where: { conversationId: conversation.id, versionId: second.versionId }, - select: { path: true, status: true }, - }), + requireRow( + db + .select({ + name: agentDefinition.name, + description: agentDefinition.description, + status: agentDefinition.status, + currentVersionId: agentDefinition.currentVersionId, + }) + .from(agentDefinition) + .where(whereEq(agentDefinition.id, first.agentId)) + .limit(1), + ), + requireRow( + db + .select({ + number: agentVersion.number, + status: agentVersion.status, + manifest: agentVersion.manifest, + }) + .from(agentVersion) + .where(whereEq(agentVersion.id, second.versionId)) + .limit(1), + ), + db + .select({ + path: agentBuilderArtifact.path, + status: agentBuilderArtifact.status, + }) + .from(agentBuilderArtifact) + .where( + and( + whereEq(agentBuilderArtifact.conversationId, conversation.id), + whereEq(agentBuilderArtifact.versionId, second.versionId), + ), + ), ]); expect(second.versionId).not.toBe(first.versionId); @@ -695,9 +815,13 @@ describe("builder persistence", () => { true, ); expect( - await db.agentBuilderArtifact.count({ - where: { conversationId: conversation.id, status: "WRITING" }, - }), + await db.$count( + agentBuilderArtifact, + and( + whereEq(agentBuilderArtifact.conversationId, conversation.id), + eq(agentBuilderArtifact.status, "WRITING"), + ), + ), ).toBe(0); }); }); diff --git a/apps/agent/test/close-task.integration.spec.ts b/apps/agent/test/close-task.integration.spec.ts index 08ef60806..f6df47e48 100644 --- a/apps/agent/test/close-task.integration.spec.ts +++ b/apps/agent/test/close-task.integration.spec.ts @@ -1,5 +1,14 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { db, EnrichmentStatus } from "@crm/db"; +import { + agentTask, + contact as contactTable, + db, + EnrichmentStatus, + firstRow, + inArray, + requireRow, + whereEq, +} from "@crm/db"; import { closeTask, taskToken } from "../agent/channels/crm"; const kind = "identify"; @@ -9,36 +18,40 @@ const taskIds: string[] = []; async function clear() { if (taskIds.length > 0) { - await db.agentTask.deleteMany({ where: { id: { in: taskIds.splice(0) } } }); + await db.delete(agentTask).where(inArray(agentTask.id, taskIds.splice(0))); } - await db.contact.deleteMany({ where: { email } }); + await db.delete(contactTable).where(whereEq(contactTable.email, email)); } beforeEach(clear); afterEach(clear); async function running() { - const contact = await db.contact.create({ - data: { - firstName: "Close", - email, - enrichmentStatus: EnrichmentStatus.RUNNING, - }, - select: { id: true }, - }); - - const task = await db.agentTask.create({ - data: { - kind, - reason: "test", - dueAt: new Date(Date.now() - 1000), - budget: 4, - contactId: contact.id, - startedAt: new Date(), - attempts: 1, - }, - select: { id: true }, - }); + const contact = await requireRow( + db + .insert(contactTable) + .values({ + firstName: "Close", + email, + enrichmentStatus: EnrichmentStatus.RUNNING, + }) + .returning({ id: contactTable.id }), + ); + + const task = await requireRow( + db + .insert(agentTask) + .values({ + kind, + reason: "test", + dueAt: new Date(Date.now() - 1000), + budget: 4, + contactId: contact.id, + startedAt: new Date(), + attempts: 1, + }) + .returning({ id: agentTask.id }), + ); taskIds.push(task.id); @@ -51,11 +64,19 @@ describe("closeTask", () => { expect(await closeTask(taskToken(taskId), "ran")).toBe(true); - const task = await db.agentTask.findUnique({ where: { id: taskId } }); + const task = await firstRow( + db.select().from(agentTask).where(whereEq(agentTask.id, taskId)).limit(1), + ); expect(task?.finishedAt).not.toBeNull(); expect(task?.outcome).toBe("ran"); - const contact = await db.contact.findUnique({ where: { id: contactId } }); + const contact = await firstRow( + db + .select() + .from(contactTable) + .where(whereEq(contactTable.id, contactId)) + .limit(1), + ); expect(contact?.enrichmentStatus).toBe(EnrichmentStatus.COMPLETE); expect(contact?.enrichedAt).not.toBeNull(); }); @@ -67,7 +88,13 @@ describe("closeTask", () => { await closeTask(taskToken(taskId), "stopped", EnrichmentStatus.SKIPPED), ).toBe(true); - const contact = await db.contact.findUnique({ where: { id: contactId } }); + const contact = await firstRow( + db + .select() + .from(contactTable) + .where(whereEq(contactTable.id, contactId)) + .limit(1), + ); expect(contact?.enrichmentStatus).toBe(EnrichmentStatus.SKIPPED); }); @@ -77,7 +104,9 @@ describe("closeTask", () => { await closeTask(taskToken(taskId), "ran"); expect(await closeTask(taskToken(taskId), "ran again")).toBe(true); - const task = await db.agentTask.findUnique({ where: { id: taskId } }); + const task = await firstRow( + db.select().from(agentTask).where(whereEq(agentTask.id, taskId)).limit(1), + ); expect(task?.outcome).toBe("ran"); }); diff --git a/apps/agent/test/durable-agent-runtime.integration.spec.ts b/apps/agent/test/durable-agent-runtime.integration.spec.ts index b3551289e..795aca1d3 100644 --- a/apps/agent/test/durable-agent-runtime.integration.spec.ts +++ b/apps/agent/test/durable-agent-runtime.integration.spec.ts @@ -1,5 +1,32 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; -import { db } from "@crm/db"; +import { + activity, + agentAction, + agentAuditEvent, + agentConversation, + agentConversationAttachment, + agentConversationSubmission, + agentDefinition, + agentEvent, + agentRun, + agentRunEvent, + agentTrigger, + agentVersion, + and, + asc, + company, + db, + eq, + inArray, + type NewAgentConversation, + type NewAgentConversationAttachment, + type NewAgentConversationSubmission, + requireRow, + sql, + startsWith, + user, + whereEq, +} from "@crm/db"; import type { SendFn } from "eve/channels"; import { z } from "zod"; import audit from "../agent/hooks/audit"; @@ -33,174 +60,236 @@ let triggerId = ""; const builderConversationIds: string[] = []; beforeAll(async () => { - await db.user.create({ - data: { - id: userId, - name: "Durable Runtime Test", - email: `${userId}@example.test`, - }, - }); - const [company, otherCompany] = await Promise.all([ - db.company.create({ - data: { name: "Durable Runtime Company", domain }, - select: { id: true }, - }), - db.company.create({ - data: { name: "Out of Scope Company", domain: `other-${domain}` }, - select: { id: true }, - }), + await requireRow( + db + .insert(user) + .values({ + id: userId, + name: "Durable Runtime Test", + email: `${userId}@example.test`, + }) + .returning(), + ); + const [created, otherCreated] = await Promise.all([ + requireRow( + db + .insert(company) + .values({ name: "Durable Runtime Company", domain }) + .returning({ id: company.id }), + ), + requireRow( + db + .insert(company) + .values({ name: "Out of Scope Company", domain: `other-${domain}` }) + .returning({ id: company.id }), + ), ]); - companyId = company.id; - otherCompanyId = otherCompany.id; - - const agent = await db.agentDefinition.create({ - data: { - name: "Durable runtime", - status: "LIVE", - createdById: userId, - }, - select: { id: true }, - }); + companyId = created.id; + otherCompanyId = otherCreated.id; + + const agent = await requireRow( + db + .insert(agentDefinition) + .values({ + name: "Durable runtime", + status: "LIVE", + createdById: userId, + }) + .returning({ id: agentDefinition.id }), + ); agentId = agent.id; - const version = await db.agentVersion.create({ - data: { - agentId, - number: 1, - status: "DEPLOYED", - instructions: "Create one approved CRM activity.", - manifest: { - triggers: [ - { - type: "SCHEDULE", - name: "Every hour", - summary: "Run every hour", - config: { - nextRunAt: new Date().toISOString(), - intervalMinutes: 60, + const version = await requireRow( + db + .insert(agentVersion) + .values({ + agentId, + number: 1, + status: "DEPLOYED", + instructions: "Create one approved CRM activity.", + manifest: { + triggers: [ + { + type: "SCHEDULE", + name: "Every hour", + summary: "Run every hour", + config: { + nextRunAt: new Date().toISOString(), + intervalMinutes: 60, + }, }, + ], + dataScope: { + mode: "SELECTED", + summary: "Only the selected durable runtime company", + resources: [ + { + kind: "company", + id: companyId, + label: "Durable Runtime Company", + }, + ], }, - ], - dataScope: { - mode: "SELECTED", - summary: "Only the selected durable runtime company", - resources: [ + actions: [ + { + type: "crm.activity.create", + provider: "crm", + summary: "Create a CRM note", + activityTypes: ["NOTE"], + }, { - kind: "company", - id: companyId, - label: "Durable Runtime Company", + type: "run.summary", + provider: "crm", + summary: "Summarize the run", }, ], }, - actions: [ - { - type: "crm.activity.create", - provider: "crm", - summary: "Create a CRM note", - activityTypes: ["NOTE"], - }, - { - type: "run.summary", - provider: "crm", - summary: "Summarize the run", - }, - ], - }, - modelId: "test/model", - sandboxPolicy: {}, - createdById: userId, - approvedAt: new Date(), - deployedAt: new Date(), - }, - select: { id: true }, - }); + modelId: "test/model", + sandboxPolicy: {}, + createdById: userId, + approvedAt: new Date(), + deployedAt: new Date(), + }) + .returning({ id: agentVersion.id }), + ); versionId = version.id; - await db.agentDefinition.update({ - where: { id: agentId }, - data: { currentVersionId: versionId }, - }); - const trigger = await db.agentTrigger.create({ - data: { - agentId, - versionId, - type: "SCHEDULE", - name: "Every hour", - config: { intervalMinutes: 60 }, - createdById: userId, - enabled: true, - nextRunAt: new Date(Date.now() - 60_000), - }, - select: { id: true }, - }); + await requireRow( + db + .update(agentDefinition) + .set({ currentVersionId: versionId }) + .where(whereEq(agentDefinition.id, agentId)) + .returning(), + ); + const trigger = await requireRow( + db + .insert(agentTrigger) + .values({ + agentId, + versionId, + type: "SCHEDULE", + name: "Every hour", + config: { intervalMinutes: 60 }, + createdById: userId, + enabled: true, + nextRunAt: new Date(Date.now() - 60_000), + }) + .returning({ id: agentTrigger.id }), + ); triggerId = trigger.id; }); afterEach(async () => { if (!agentId) return; - await db.agentRun.updateMany({ - where: { - agentId, - status: { in: ["QUEUED", "RUNNING", "WAITING_FOR_APPROVAL"] }, - }, - data: { + await db + .update(agentRun) + .set({ status: "CANCELLED", errorCode: "TEST_CLEANUP", errorMessage: "Settled between tests.", finishedAt: new Date(), - }, - }); + }) + .where( + and( + whereEq(agentRun.agentId, agentId), + inArray(agentRun.status, ["QUEUED", "RUNNING", "WAITING_FOR_APPROVAL"]), + ), + ); }); afterAll(async () => { if (builderConversationIds.length > 0) { - await db.agentConversation.deleteMany({ - where: { id: { in: builderConversationIds } }, - }); + await db + .delete(agentConversation) + .where(inArray(agentConversation.id, builderConversationIds)); } if (agentId) { - await db.agentEvent.deleteMany({ - where: { sessionId: { startsWith: `durable-session-${suffix}` } }, - }); - await db.agentRunEvent.deleteMany({ where: { run: { agentId } } }); - await db.agentAction.deleteMany({ where: { agentId } }); - await db.activity.deleteMany({ - where: { meta: { path: ["agentId"], equals: agentId } }, - }); - await db.agentAuditEvent.deleteMany({ where: { agentId } }); - await db.agentRun.deleteMany({ where: { agentId } }); - await db.agentTrigger.deleteMany({ where: { agentId } }); - await db.agentDefinition.updateMany({ - where: { id: agentId }, - data: { currentVersionId: null }, - }); - await db.agentVersion.deleteMany({ where: { agentId } }); - await db.agentDefinition.deleteMany({ where: { id: agentId } }); + await db + .delete(agentEvent) + .where(startsWith(agentEvent.sessionId, `durable-session-${suffix}`)); + await db + .delete(agentRunEvent) + .where( + inArray( + agentRunEvent.runId, + db + .select({ id: agentRun.id }) + .from(agentRun) + .where(whereEq(agentRun.agentId, agentId)), + ), + ); + await db.delete(agentAction).where(whereEq(agentAction.agentId, agentId)); + await db + .delete(activity) + .where(sql`${activity.meta} #>> '{agentId}' = ${agentId}`); + await db + .delete(agentAuditEvent) + .where(whereEq(agentAuditEvent.agentId, agentId)); + await db.delete(agentRun).where(whereEq(agentRun.agentId, agentId)); + await db.delete(agentTrigger).where(whereEq(agentTrigger.agentId, agentId)); + await db + .update(agentDefinition) + .set({ currentVersionId: null }) + .where(whereEq(agentDefinition.id, agentId)); + await db.delete(agentVersion).where(whereEq(agentVersion.agentId, agentId)); + await db + .delete(agentDefinition) + .where(whereEq(agentDefinition.id, agentId)); } - await db.company.deleteMany({ - where: { id: { in: [companyId, otherCompanyId] } }, - }); - await db.user.deleteMany({ where: { id: userId } }); + await db + .delete(company) + .where(inArray(company.id, [companyId, otherCompanyId])); + await db.delete(user).where(whereEq(user.id, userId)); }); +async function builderConversation( + conversation: NewAgentConversation, + submission: Omit, + attachment?: Omit, +) { + const created = await requireRow( + db + .insert(agentConversation) + .values(conversation) + .returning({ id: agentConversation.id }), + ); + const submitted = await requireRow( + db + .insert(agentConversationSubmission) + .values({ ...submission, conversationId: created.id }) + .returning({ id: agentConversationSubmission.id }), + ); + if (attachment) { + await db + .insert(agentConversationAttachment) + .values({ ...attachment, submissionId: submitted.id }); + } + return { id: created.id, submissions: [submitted] }; +} + async function createRun( status: "QUEUED" | "RUNNING" = "RUNNING", startedAt: Date | null = new Date(), sessionId: string | null = null, triggerType: "MANUAL" | "EVENT" = "MANUAL", ) { - return db.agentRun.create({ - data: { - agentId, - versionId, - triggerType, - status, - startedAt, - sessionId, - idempotencyKey: `durable-run-${crypto.randomUUID()}`, - correlationId: crypto.randomUUID(), - events: { create: { sequence: 0, type: "run.queued", data: {} } }, - }, - select: { id: true }, - }); + const run = await requireRow( + db + .insert(agentRun) + .values({ + agentId, + versionId, + triggerType, + status, + startedAt, + sessionId, + idempotencyKey: `durable-run-${crypto.randomUUID()}`, + correlationId: crypto.randomUUID(), + }) + .returning({ id: agentRun.id }), + ); + await db + .insert(agentRunEvent) + .values({ runId: run.id, sequence: 0, type: "run.queued", data: {} }); + return run; } async function satisfyRequiredActivity(runId: string, callId: string) { @@ -215,18 +304,20 @@ async function satisfyRequiredActivity(runId: string, callId: string) { describe("durable custom-agent runtime", () => { it("queues one run per matching event trigger and event occurrence", async () => { - const trigger = await db.agentTrigger.create({ - data: { - agentId, - versionId, - type: "EVENT", - name: "When a deal closes", - config: { event: "deal.closed" }, - createdById: userId, - enabled: true, - }, - select: { id: true }, - }); + const trigger = await requireRow( + db + .insert(agentTrigger) + .values({ + agentId, + versionId, + type: "EVENT", + name: "When a deal closes", + config: { event: "deal.closed" }, + createdById: userId, + enabled: true, + }) + .returning({ id: agentTrigger.id }), + ); const occurredAt = new Date().toISOString(); const task = { id: `event-task-${suffix}`, @@ -247,10 +338,14 @@ describe("durable custom-agent runtime", () => { queueEventAgentRuns(task), ]); - const runs = await db.agentRun.findMany({ - where: { triggerId: trigger.id }, - select: { triggerType: true, status: true, input: true }, - }); + const runs = await db + .select({ + triggerType: agentRun.triggerType, + status: agentRun.status, + input: agentRun.input, + }) + .from(agentRun) + .where(whereEq(agentRun.triggerId, trigger.id)); expect(runs).toEqual([ { triggerType: "EVENT", @@ -275,11 +370,21 @@ describe("durable custom-agent runtime", () => { expect(results.reduce((total, count) => total + count, 0)).toBe(1); const [trigger, scheduledRuns] = await Promise.all([ - db.agentTrigger.findUniqueOrThrow({ where: { id: triggerId } }), - db.agentRun.findMany({ - where: { triggerId }, - select: { id: true, status: true, input: true }, - }), + requireRow( + db + .select() + .from(agentTrigger) + .where(whereEq(agentTrigger.id, triggerId)) + .limit(1), + ), + db + .select({ + id: agentRun.id, + status: agentRun.status, + input: agentRun.input, + }) + .from(agentRun) + .where(whereEq(agentRun.triggerId, triggerId)), ]); expect(trigger.lastRunAt).not.toBeNull(); expect(trigger.nextRunAt?.getTime()).toBeGreaterThan(now.getTime()); @@ -296,15 +401,31 @@ describe("durable custom-agent runtime", () => { const pending = await pendingAgentRunIds(); const [recovered, untouched] = await Promise.all([ - db.agentRun.findUniqueOrThrow({ where: { id: recoverable.id } }), - db.agentRun.findUniqueOrThrow({ where: { id: active.id } }), + requireRow( + db + .select() + .from(agentRun) + .where(whereEq(agentRun.id, recoverable.id)) + .limit(1), + ), + requireRow( + db + .select() + .from(agentRun) + .where(whereEq(agentRun.id, active.id)) + .limit(1), + ), ]); expect(pending).not.toContain(recoverable.id); expect(recovered).toMatchObject({ status: "QUEUED", startedAt: null }); expect( - await db.agentRunEvent.count({ - where: { runId: recoverable.id, type: "run.delivery_recovered" }, - }), + await db.$count( + agentRunEvent, + and( + whereEq(agentRunEvent.runId, recoverable.id), + eq(agentRunEvent.type, "run.delivery_recovered"), + ), + ), ).toBe(1); expect(untouched.status).toBe("RUNNING"); }); @@ -322,9 +443,9 @@ describe("durable custom-agent runtime", () => { dispatchAgentRun(run.id, send), dispatchAgentRun(run.id, send), ]); - const persisted = await db.agentRun.findUniqueOrThrow({ - where: { id: run.id }, - }); + const persisted = await requireRow( + db.select().from(agentRun).where(whereEq(agentRun.id, run.id)).limit(1), + ); expect( attempts.filter((attempt) => attempt.status === "fulfilled"), ).toHaveLength(1); @@ -361,10 +482,10 @@ describe("durable custom-agent runtime", () => { expect(deliveries).toHaveLength(1); expect( ( - await db.agentRun.findMany({ - where: { id: { in: [first.id, second.id] } }, - select: { status: true }, - }) + await db + .select({ status: agentRun.status }) + .from(agentRun) + .where(inArray(agentRun.id, [first.id, second.id])) ) .map((run) => run.status) .sort(), @@ -372,68 +493,84 @@ describe("durable custom-agent runtime", () => { }); it("restores a builder continuation when a delivery lease expires", async () => { - const conversation = await db.agentConversation.create({ - data: { + const conversation = await builderConversation( + { kind: "BUILDER", userId, sessionId: `durable-session-${suffix}-builder`, continuationToken: null, - submissions: { - create: { - submittedById: userId, - clientRequestId: crypto.randomUUID(), - message: { text: "Continue building" }, - status: "SENDING", - attemptCount: 1, - sentAt: new Date(Date.now() - 10 * 60_000), - }, - }, }, - select: { id: true, submissions: { select: { id: true } } }, - }); + { + submittedById: userId, + clientRequestId: crypto.randomUUID(), + message: { text: "Continue building" }, + status: "SENDING", + attemptCount: 1, + sentAt: new Date(Date.now() - 10 * 60_000), + }, + ); builderConversationIds.push(conversation.id); await pendingBuilderSubmissionIds(); const [submission, restored] = await Promise.all([ - db.agentConversationSubmission.findUniqueOrThrow({ - where: { id: conversation.submissions[0]?.id }, - }), - db.agentConversation.findUniqueOrThrow({ - where: { id: conversation.id }, - }), + requireRow( + db + .select() + .from(agentConversationSubmission) + .where( + whereEq( + agentConversationSubmission.id, + conversation.submissions[0]?.id, + ), + ) + .limit(1), + ), + requireRow( + db + .select() + .from(agentConversation) + .where(whereEq(agentConversation.id, conversation.id)) + .limit(1), + ), ]); expect(submission.status).toBe("PENDING"); expect(restored.continuationToken).toBe(builderToken(conversation.id)); }); it("keeps concurrent builder dispatches in conversation order", async () => { - const conversation = await db.agentConversation.create({ - data: { kind: "BUILDER", userId }, - select: { id: true }, - }); + const conversation = await requireRow( + db + .insert(agentConversation) + .values({ kind: "BUILDER", userId }) + .returning({ id: agentConversation.id }), + ); builderConversationIds.push(conversation.id); const firstCreatedAt = new Date(); const [first, second] = await Promise.all([ - db.agentConversationSubmission.create({ - data: { - conversationId: conversation.id, - submittedById: userId, - clientRequestId: crypto.randomUUID(), - message: { text: "First" }, - createdAt: firstCreatedAt, - }, - select: { id: true }, - }), - db.agentConversationSubmission.create({ - data: { - conversationId: conversation.id, - submittedById: userId, - clientRequestId: crypto.randomUUID(), - message: { text: "Second" }, - createdAt: new Date(firstCreatedAt.getTime() + 1), - }, - select: { id: true }, - }), + requireRow( + db + .insert(agentConversationSubmission) + .values({ + conversationId: conversation.id, + submittedById: userId, + clientRequestId: crypto.randomUUID(), + message: { text: "First" }, + createdAt: firstCreatedAt, + }) + .returning({ id: agentConversationSubmission.id }), + ), + requireRow( + db + .insert(agentConversationSubmission) + .values({ + conversationId: conversation.id, + submittedById: userId, + clientRequestId: crypto.randomUUID(), + message: { text: "Second" }, + createdAt: new Date(firstCreatedAt.getTime() + 1), + }) + .returning({ id: agentConversationSubmission.id }), + ), ]); const started = Promise.withResolvers(); const release = Promise.withResolvers(); @@ -456,11 +593,17 @@ describe("durable custom-agent runtime", () => { release.resolve(); await firstDispatch; - const submissions = await db.agentConversationSubmission.findMany({ - where: { id: { in: [first.id, second.id] } }, - orderBy: [{ createdAt: "asc" }, { id: "asc" }], - select: { id: true, status: true }, - }); + const submissions = await db + .select({ + id: agentConversationSubmission.id, + status: agentConversationSubmission.status, + }) + .from(agentConversationSubmission) + .where(inArray(agentConversationSubmission.id, [first.id, second.id])) + .orderBy( + asc(agentConversationSubmission.createdAt), + asc(agentConversationSubmission.id), + ); expect(deliveries).toBe(1); expect(secondError?.message).toContain( "already claimed or is out of order", @@ -473,40 +616,31 @@ describe("durable custom-agent runtime", () => { it("loads persisted attachment bytes into the Eve builder turn", async () => { const content = Buffer.from("durable attachment"); - const conversation = await db.agentConversation.create({ - data: { - kind: "BUILDER", - userId, - permissionMode: "AUTO", - submissions: { - create: { - submittedById: userId, - clientRequestId: crypto.randomUUID(), - message: { - text: "Read the attachment", - resources: [], - attachments: [ - { - name: "brief.txt", - type: "text/plain", - size: content.byteLength, - }, - ], - }, - attachments: { - create: { - position: 0, - name: "brief.txt", - mediaType: "text/plain", - size: content.byteLength, - content, - }, + const conversation = await builderConversation( + { kind: "BUILDER", userId, permissionMode: "AUTO" }, + { + submittedById: userId, + clientRequestId: crypto.randomUUID(), + message: { + text: "Read the attachment", + resources: [], + attachments: [ + { + name: "brief.txt", + type: "text/plain", + size: content.byteLength, }, - }, + ], }, }, - select: { id: true, submissions: { select: { id: true } } }, - }); + { + position: 0, + name: "brief.txt", + mediaType: "text/plain", + size: content.byteLength, + content, + }, + ); builderConversationIds.push(conversation.id); let delivered: Parameters[0] | null = null; let deliveredOptions: Parameters[1] | null = null; @@ -579,17 +713,21 @@ describe("durable custom-agent runtime", () => { await handler(event, context); await handler(event, context); - const persisted = await db.agentRun.findUniqueOrThrow({ - where: { id: run.id }, - }); + const persisted = await requireRow( + db.select().from(agentRun).where(whereEq(agentRun.id, run.id)).limit(1), + ); expect(persisted).toMatchObject({ inputTokens: 5, outputTokens: 3, nextEventSequence: 1, }); expect(Number(persisted.costUsd)).toBe(0.01); - expect(await db.agentRunEvent.count({ where: { id: eventId } })).toBe(1); - expect(await db.agentEvent.count({ where: { id: eventId } })).toBe(1); + expect( + await db.$count(agentRunEvent, whereEq(agentRunEvent.id, eventId)), + ).toBe(1); + expect(await db.$count(agentEvent, whereEq(agentEvent.id, eventId))).toBe( + 1, + ); }); it("keeps a nested subagent session from replacing the root run session", async () => { @@ -638,7 +776,9 @@ describe("durable custom-agent runtime", () => { ); expect( - await db.agentRun.findUniqueOrThrow({ where: { id: run.id } }), + await requireRow( + db.select().from(agentRun).where(whereEq(agentRun.id, run.id)).limit(1), + ), ).toMatchObject({ sessionId: rootSessionId }); }); @@ -659,8 +799,20 @@ describe("durable custom-agent runtime", () => { await failRun(failed.id, "SECOND_FAILURE", "This arrived late"); const [completedRow, failedRow] = await Promise.all([ - db.agentRun.findUniqueOrThrow({ where: { id: completed.id } }), - db.agentRun.findUniqueOrThrow({ where: { id: failed.id } }), + requireRow( + db + .select() + .from(agentRun) + .where(whereEq(agentRun.id, completed.id)) + .limit(1), + ), + requireRow( + db + .select() + .from(agentRun) + .where(whereEq(agentRun.id, failed.id)) + .limit(1), + ), ]); expect(completedRow).toMatchObject({ status: "SUCCEEDED", @@ -673,12 +825,13 @@ describe("durable custom-agent runtime", () => { }); expect(lateCompletionError?.message).toContain("already ended with FAILED"); expect( - await db.agentRunEvent.count({ - where: { - runId: { in: [completed.id, failed.id] }, - type: { in: ["run.completed", "run.failed"] }, - }, - }), + await db.$count( + agentRunEvent, + and( + inArray(agentRunEvent.runId, [completed.id, failed.id]), + inArray(agentRunEvent.type, ["run.completed", "run.failed"]), + ), + ), ).toBe(2); }); @@ -688,18 +841,22 @@ describe("durable custom-agent runtime", () => { summary: "Staged safely", result: { noted: 2 }, }); - const staged = await db.agentRun.findUniqueOrThrow({ - where: { id: run.id }, - }); + const staged = await requireRow( + db.select().from(agentRun).where(whereEq(agentRun.id, run.id)).limit(1), + ); expect(staged).toMatchObject({ status: "RUNNING", summary: "Staged safely", result: { noted: 2 }, }); expect( - await db.agentRunEvent.count({ - where: { runId: run.id, type: "run.completed" }, - }), + await db.$count( + agentRunEvent, + and( + whereEq(agentRunEvent.runId, run.id), + eq(agentRunEvent.type, "run.completed"), + ), + ), ).toBe(0); await satisfyRequiredActivity(run.id, "staged-success"); @@ -708,7 +865,9 @@ describe("durable custom-agent runtime", () => { result: runResultOf(staged.result), }); expect( - await db.agentRun.findUniqueOrThrow({ where: { id: run.id } }), + await requireRow( + db.select().from(agentRun).where(whereEq(agentRun.id, run.id)).limit(1), + ), ).toMatchObject({ status: "SUCCEEDED", summary: "Staged safely" }); }); @@ -720,17 +879,26 @@ describe("durable custom-agent runtime", () => { expect(finished).toEqual({ id: run.id, status: "FAILED" }); expect( - await db.agentRun.findUniqueOrThrow({ where: { id: run.id } }), + await requireRow( + db.select().from(agentRun).where(whereEq(agentRun.id, run.id)).limit(1), + ), ).toMatchObject({ status: "FAILED", errorCode: "ACTION_NOT_PERFORMED", }); expect( - await db.agentAction.findUniqueOrThrow({ - where: { - idempotencyKey: `run:${run.id}:required:crm.activity.create`, - }, - }), + await requireRow( + db + .select() + .from(agentAction) + .where( + whereEq( + agentAction.idempotencyKey, + `run:${run.id}:required:crm.activity.create`, + ), + ) + .limit(1), + ), ).toMatchObject({ status: "FAILED", errorCode: "ACTION_NOT_PERFORMED", @@ -758,12 +926,18 @@ describe("durable custom-agent runtime", () => { }); expect(finished).toEqual({ id: run.id, status: "FAILED" }); expect( - await db.agentRun.findUniqueOrThrow({ where: { id: run.id } }), + await requireRow( + db.select().from(agentRun).where(whereEq(agentRun.id, run.id)).limit(1), + ), ).toMatchObject({ status: "FAILED", errorCode: "ACTION_NOT_PERFORMED" }); expect( - await db.agentRunEvent.count({ - where: { runId: run.id, type: "run.completed" }, - }), + await db.$count( + agentRunEvent, + and( + whereEq(agentRunEvent.runId, run.id), + eq(agentRunEvent.type, "run.completed"), + ), + ), ).toBe(0); }); @@ -773,9 +947,9 @@ describe("durable custom-agent runtime", () => { summary: "The deal was already closed", noActionNeeded: { reason: "The condition was not met." }, }); - const staged = await db.agentRun.findUniqueOrThrow({ - where: { id: run.id }, - }); + const staged = await requireRow( + db.select().from(agentRun).where(whereEq(agentRun.id, run.id)).limit(1), + ); expect(staged.result).toMatchObject({ noActionNeeded: "The condition was not met.", }); @@ -785,7 +959,9 @@ describe("durable custom-agent runtime", () => { result: runResultOf(staged.result), }); expect(finished).toEqual({ id: run.id, status: "SUCCEEDED" }); - expect(await db.agentAction.count({ where: { runId: run.id } })).toBe(0); + expect( + await db.$count(agentAction, whereEq(agentAction.runId, run.id)), + ).toBe(0); }); it("claims an approved CRM action once and rejects scope before target access", async () => { @@ -818,14 +994,19 @@ describe("durable custom-agent runtime", () => { } expect(reusedCallError?.message).toContain("already used for other input"); expect( - await db.agentAction.count({ - where: { runId: run.id, idempotencyKey: `${run.id}:shared-call` }, - }), + await db.$count( + agentAction, + and( + whereEq(agentAction.runId, run.id), + whereEq(agentAction.idempotencyKey, `${run.id}:shared-call`), + ), + ), ).toBe(1); expect( - await db.activity.count({ - where: { meta: { path: ["runId"], equals: run.id } }, - }), + await db.$count( + activity, + sql`${activity.meta} #>> '{runId}' = ${run.id}`, + ), ).toBe(1); let scopeError: Error | null = null; @@ -839,9 +1020,10 @@ describe("durable custom-agent runtime", () => { } expect(scopeError?.message).toContain("outside this agent version"); expect( - await db.agentAction.count({ - where: { idempotencyKey: `${run.id}:out-of-scope` }, - }), + await db.$count( + agentAction, + whereEq(agentAction.idempotencyKey, `${run.id}:out-of-scope`), + ), ).toBe(0); let activityTypeError: Error | null = null; @@ -858,9 +1040,10 @@ describe("durable custom-agent runtime", () => { "does not allow CRM task activities", ); expect( - await db.agentAction.count({ - where: { idempotencyKey: `${run.id}:unapproved-task` }, - }), + await db.$count( + agentAction, + whereEq(agentAction.idempotencyKey, `${run.id}:unapproved-task`), + ), ).toBe(0); }); }); diff --git a/apps/agent/test/e2e/dispatch.e2e.ts b/apps/agent/test/e2e/dispatch.e2e.ts index 6ae373760..ba2f4557c 100644 --- a/apps/agent/test/e2e/dispatch.e2e.ts +++ b/apps/agent/test/e2e/dispatch.e2e.ts @@ -1,4 +1,23 @@ -import { db } from "@crm/db"; +import { + agentDefinition, + agentRun, + agentTask, + agentTrigger, + agentVersion, + and, + company as companyTable, + db, + deal as dealTable, + endsWith, + eq, + firstRow, + inArray, + isNull, + requireRow, + startsWith, + user, + whereEq, +} from "@crm/db"; import { PRIORITY } from "@crm/db/agent-tasks"; import { runVisibleLane } from "../../agent/lib/dispatch"; import { @@ -17,81 +36,99 @@ function record(name: string, ok: boolean, detail: string) { } async function seedAgent() { - const owner = await db.user.findFirstOrThrow({ select: { id: true } }); - const agent = await db.agentDefinition.create({ - data: { - name: `${E2E.dispatch.agentPrefix} ${Date.now()}`, - description: "Seeded by the dispatch E2E. Safe to delete.", - status: "LIVE", - createdById: owner.id, - }, - select: { id: true }, - }); - const version = await db.agentVersion.create({ - data: { - agentId: agent.id, - number: 1, - status: "DEPLOYED", - instructions: "Dispatch test agent. Does nothing.", - modelId: "test/model", - sandboxPolicy: {}, - createdById: owner.id, - manifest: { - description: "dispatch test", - triggers: [ - { - type: "EVENT", - name: "Deal created", - summary: "Fires on deal creation", - config: { event: "deal.created" }, - }, - ], - dataScope: { mode: "WORKSPACE", summary: "Workspace", resources: [] }, - actions: [ - { type: "run.summary", provider: "crm", summary: "Log the result" }, - ], - }, - }, - select: { id: true }, - }); - await db.agentDefinition.update({ - where: { id: agent.id }, - data: { currentVersionId: version.id }, - }); - await db.agentTrigger.create({ - data: { - agentId: agent.id, - versionId: version.id, - type: "EVENT", - name: "Deal created", - config: { event: "deal.created" }, - enabled: true, - createdById: owner.id, - }, - }); + const owner = await requireRow( + db.select({ id: user.id }).from(user).limit(1), + ); + const agent = await requireRow( + db + .insert(agentDefinition) + .values({ + name: `${E2E.dispatch.agentPrefix} ${Date.now()}`, + description: "Seeded by the dispatch E2E. Safe to delete.", + status: "LIVE", + createdById: owner.id, + }) + .returning({ id: agentDefinition.id }), + ); + const version = await requireRow( + db + .insert(agentVersion) + .values({ + agentId: agent.id, + number: 1, + status: "DEPLOYED", + instructions: "Dispatch test agent. Does nothing.", + modelId: "test/model", + sandboxPolicy: {}, + createdById: owner.id, + manifest: { + description: "dispatch test", + triggers: [ + { + type: "EVENT", + name: "Deal created", + summary: "Fires on deal creation", + config: { event: "deal.created" }, + }, + ], + dataScope: { mode: "WORKSPACE", summary: "Workspace", resources: [] }, + actions: [ + { type: "run.summary", provider: "crm", summary: "Log the result" }, + ], + }, + }) + .returning({ id: agentVersion.id }), + ); + await requireRow( + db + .update(agentDefinition) + .set({ currentVersionId: version.id }) + .where(whereEq(agentDefinition.id, agent.id)) + .returning(), + ); + await requireRow( + db + .insert(agentTrigger) + .values({ + agentId: agent.id, + versionId: version.id, + type: "EVENT", + name: "Deal created", + config: { event: "deal.created" }, + enabled: true, + createdById: owner.id, + }) + .returning(), + ); return { agentId: agent.id }; } async function seedDeal() { const stamp = Date.now(); - const company = await db.company.create({ - data: { - name: `${E2E.dispatch.companyPrefix} ${stamp}`, - domain: `${E2E.dispatch.domainPrefix}${stamp}${E2E.dispatch.domainSuffix}`, - }, - select: { id: true }, - }); - const owner = await db.user.findFirstOrThrow({ select: { id: true } }); - return db.deal.create({ - data: { - name: `E2E Deal ${Date.now()}`, - companyId: company.id, - ownerId: owner.id, - stage: "DEMO_BOOKED", - stageChangedAt: new Date(), - }, - select: { id: true, companyId: true }, - }); + const company = await requireRow( + db + .insert(companyTable) + .values({ + name: `${E2E.dispatch.companyPrefix} ${stamp}`, + domain: `${E2E.dispatch.domainPrefix}${stamp}${E2E.dispatch.domainSuffix}`, + }) + .returning({ id: companyTable.id }), + ); + const owner = await requireRow( + db.select({ id: user.id }).from(user).limit(1), + ); + return requireRow( + db + .insert(dealTable) + .values({ + name: `E2E Deal ${Date.now()}`, + companyId: company.id, + ownerId: owner.id, + stage: "DEMO_BOOKED", + stageChangedAt: new Date(), + }) + .returning({ id: dealTable.id, companyId: dealTable.companyId }), + ); } async function sweepLeftovers() { @@ -99,27 +136,37 @@ async function sweepLeftovers() { console.log(` removed leftover ${name}`); } - const stale = await db.company.findMany({ - where: { - domain: { - startsWith: E2E.dispatch.domainPrefix, - endsWith: E2E.dispatch.domainSuffix, - }, - name: { startsWith: E2E.dispatch.companyPrefix }, - }, - select: { id: true, name: true }, - }); + const stale = await db + .select({ id: companyTable.id, name: companyTable.name }) + .from(companyTable) + .where( + and( + and( + startsWith(companyTable.domain, E2E.dispatch.domainPrefix), + endsWith(companyTable.domain, E2E.dispatch.domainSuffix), + ), + startsWith(companyTable.name, E2E.dispatch.companyPrefix), + ), + ); for (const company of stale) { - const deals = await db.deal.findMany({ - where: { companyId: company.id }, - select: { id: true }, - }); - await db.agentTask.deleteMany({ - where: { dealId: { in: deals.map((deal) => deal.id) } }, - }); - await db.deal.deleteMany({ where: { companyId: company.id } }); - await db.company.delete({ where: { id: company.id } }); + const deals = await db + .select({ id: dealTable.id }) + .from(dealTable) + .where(whereEq(dealTable.companyId, company.id)); + await db.delete(agentTask).where( + inArray( + agentTask.dealId, + deals.map((deal) => deal.id), + ), + ); + await db.delete(dealTable).where(whereEq(dealTable.companyId, company.id)); + await requireRow( + db + .delete(companyTable) + .where(whereEq(companyTable.id, company.id)) + .returning(), + ); console.log(` removed leftover ${company.name}`); } } @@ -132,10 +179,16 @@ async function cleanUp( ) { await removeEventRuns(taskId ? [taskId] : []); if (agentId) await removeAgent(agentId); - if (dealId) await db.agentTask.deleteMany({ where: { dealId } }); + if (dealId) + await db.delete(agentTask).where(whereEq(agentTask.dealId, dealId)); if (!companyId) return; - await db.deal.deleteMany({ where: { companyId } }); - await db.company.delete({ where: { id: companyId } }); + await db.delete(dealTable).where(whereEq(dealTable.companyId, companyId)); + await requireRow( + db + .delete(companyTable) + .where(whereEq(companyTable.id, companyId)) + .returning(), + ); } async function main() { @@ -152,28 +205,30 @@ async function main() { const deal = await seedDeal(); companyId = deal.companyId; dealId = deal.id; - const task = await db.agentTask.create({ - data: { - dealId: deal.id, - kind: "agent-event", - reason: "deal.created", - payload: { - type: "deal.created", - record: { kind: "deal", id: deal.id }, - occurredAt: new Date().toISOString(), - data: { companyId: deal.companyId, stage: "DEMO_BOOKED" }, - }, - priority: PRIORITY.event, - budget: 1, - dueAt: new Date(), - }, - select: { id: true }, - }); + const task = await requireRow( + db + .insert(agentTask) + .values({ + dealId: deal.id, + kind: "agent-event", + reason: "deal.created", + payload: { + type: "deal.created", + record: { kind: "deal", id: deal.id }, + occurredAt: new Date().toISOString(), + data: { companyId: deal.companyId, stage: "DEMO_BOOKED" }, + }, + priority: PRIORITY.event, + budget: 1, + dueAt: new Date(), + }) + .returning({ id: agentTask.id }), + ); taskId = task.id; - const before = await db.agentRun.count(); + const before = await db.$count(agentRun); const handled = await runVisibleLane(); - const after = await db.agentRun.count(); + const after = await db.$count(agentRun); record( "drain claims a queued event", @@ -181,26 +236,36 @@ async function main() { `visible lane handled ${handled} task(s)`, ); - const settled = await db.agentTask.findUnique({ - where: { id: task.id }, - select: { finishedAt: true, outcome: true }, - }); + const settled = await firstRow( + db + .select({ + finishedAt: agentTask.finishedAt, + outcome: agentTask.outcome, + }) + .from(agentTask) + .where(whereEq(agentTask.id, task.id)) + .limit(1), + ); record( "drain settles the task", Boolean(settled?.finishedAt), settled?.outcome ?? "not settled", ); - const live = await db.agentDefinition.count({ where: { status: "LIVE" } }); + const live = await db.$count( + agentDefinition, + eq(agentDefinition.status, "LIVE"), + ); record( "event matched against live agents", after > before, `${after - before} run(s) queued from ${live} live agent(s)`, ); - const stuck = await db.agentTask.count({ - where: { kind: "agent-event", finishedAt: null }, - }); + const stuck = await db.$count( + agentTask, + and(eq(agentTask.kind, "agent-event"), isNull(agentTask.finishedAt)), + ); record("no agent-event backlog", stuck === 0, `${stuck} unclaimed`); } finally { const failure = await cleanUp(agentId, companyId, dealId, taskId).then( diff --git a/apps/agent/test/e2e/e2e-agents.ts b/apps/agent/test/e2e/e2e-agents.ts index c70427d34..a7dc440a2 100644 --- a/apps/agent/test/e2e/e2e-agents.ts +++ b/apps/agent/test/e2e/e2e-agents.ts @@ -1,24 +1,58 @@ -import { db } from "@crm/db"; +import { + agentAction, + agentAuditEvent, + agentDefinition, + agentRun, + agentRunEvent, + agentTrigger, + agentVersion, + db, + inArray, + or, + requireRow, + startsWith, + whereEq, +} from "@crm/db"; export async function removeAgent(agentId: string): Promise { - await db.agentRunEvent.deleteMany({ where: { run: { agentId } } }); - await db.agentAction.deleteMany({ where: { agentId } }); - await db.agentAuditEvent.deleteMany({ where: { agentId } }); - await db.agentRun.deleteMany({ where: { agentId } }); - await db.agentTrigger.deleteMany({ where: { agentId } }); - await db.agentDefinition.update({ - where: { id: agentId }, - data: { currentVersionId: null }, - }); - await db.agentVersion.deleteMany({ where: { agentId } }); - await db.agentDefinition.delete({ where: { id: agentId } }); + await db + .delete(agentRunEvent) + .where( + inArray( + agentRunEvent.runId, + db + .select({ id: agentRun.id }) + .from(agentRun) + .where(whereEq(agentRun.agentId, agentId)), + ), + ); + await db.delete(agentAction).where(whereEq(agentAction.agentId, agentId)); + await db + .delete(agentAuditEvent) + .where(whereEq(agentAuditEvent.agentId, agentId)); + await db.delete(agentRun).where(whereEq(agentRun.agentId, agentId)); + await db.delete(agentTrigger).where(whereEq(agentTrigger.agentId, agentId)); + await requireRow( + db + .update(agentDefinition) + .set({ currentVersionId: null }) + .where(whereEq(agentDefinition.id, agentId)) + .returning(), + ); + await db.delete(agentVersion).where(whereEq(agentVersion.agentId, agentId)); + await requireRow( + db + .delete(agentDefinition) + .where(whereEq(agentDefinition.id, agentId)) + .returning(), + ); } export async function removeAgentsNamed(prefix: string): Promise { - const stale = await db.agentDefinition.findMany({ - where: { name: { startsWith: prefix } }, - select: { id: true, name: true }, - }); + const stale = await db + .select({ id: agentDefinition.id, name: agentDefinition.name }) + .from(agentDefinition) + .where(startsWith(agentDefinition.name, prefix)); for (const agent of stale) await removeAgent(agent.id); @@ -30,21 +64,25 @@ export async function removeEventRuns( ): Promise { if (taskIds.length === 0) return 0; - const runs = await db.agentRun.findMany({ - where: { - OR: taskIds.map((taskId) => ({ - idempotencyKey: { startsWith: `event:${taskId}:` }, - })), - }, - select: { id: true }, - }); + const runs = await db + .select({ id: agentRun.id }) + .from(agentRun) + .where( + or( + ...taskIds.map((taskId) => + startsWith(agentRun.idempotencyKey, `event:${taskId}:`), + ), + ), + ); const runIds = runs.map((run) => run.id); if (runIds.length === 0) return 0; - await db.agentRunEvent.deleteMany({ where: { runId: { in: runIds } } }); - await db.agentAction.deleteMany({ where: { runId: { in: runIds } } }); - await db.agentAuditEvent.deleteMany({ where: { requestId: { in: runIds } } }); - await db.agentRun.deleteMany({ where: { id: { in: runIds } } }); + await db.delete(agentRunEvent).where(inArray(agentRunEvent.runId, runIds)); + await db.delete(agentAction).where(inArray(agentAction.runId, runIds)); + await db + .delete(agentAuditEvent) + .where(inArray(agentAuditEvent.requestId, runIds)); + await db.delete(agentRun).where(inArray(agentRun.id, runIds)); return runIds.length; } diff --git a/apps/agent/test/e2e/live-run.e2e.ts b/apps/agent/test/e2e/live-run.e2e.ts index c6f5484c1..3f6d821a3 100644 --- a/apps/agent/test/e2e/live-run.e2e.ts +++ b/apps/agent/test/e2e/live-run.e2e.ts @@ -1,4 +1,19 @@ -import { db } from "@crm/db"; +import { + agentAction, + agentDefinition, + agentRun, + agentRunEvent, + agentVersion, + and, + appSetting, + db, + eq, + firstRow, + requireRow, + slackChannel, + user, + whereEq, +} from "@crm/db"; import { reasonOf, removeAgent } from "./e2e-agents"; import { E2E } from "./e2e-config"; @@ -17,57 +32,66 @@ async function seedAgent( channelName: string, modelId: string, ) { - const owner = await db.user.findFirstOrThrow({ select: { id: true } }); - const agent = await db.agentDefinition.create({ - data: { - name: `${E2E.liveRun.agentPrefix} ${Date.now()}`, - description: "Seeded by the live run E2E. Safe to delete.", - status: "LIVE", - createdById: owner.id, - }, - select: { id: true }, - }); - const version = await db.agentVersion.create({ - data: { - agentId: agent.id, - number: 1, - status: "DEPLOYED", - instructions: `Post exactly one short Slack message to #${channelName} that says "E2E live run — ignore this message." Do nothing else.`, - modelId, - sandboxPolicy: {}, - createdById: owner.id, - manifest: { - description: "live run", - triggers: [ - { - type: "MANUAL", - name: "Run now", - summary: "Started by a person", - config: {}, - }, - ], - dataScope: { mode: "WORKSPACE", summary: "Workspace", resources: [] }, - actions: [ - { - type: "slack.message.post", - provider: "slack", - summary: `Post one message to #${channelName}`, - destination: { - kind: "channel", - resolution: "chosen", - id: channelId, - label: `#${channelName}`, + const owner = await requireRow( + db.select({ id: user.id }).from(user).limit(1), + ); + const agent = await requireRow( + db + .insert(agentDefinition) + .values({ + name: `${E2E.liveRun.agentPrefix} ${Date.now()}`, + description: "Seeded by the live run E2E. Safe to delete.", + status: "LIVE", + createdById: owner.id, + }) + .returning({ id: agentDefinition.id }), + ); + const version = await requireRow( + db + .insert(agentVersion) + .values({ + agentId: agent.id, + number: 1, + status: "DEPLOYED", + instructions: `Post exactly one short Slack message to #${channelName} that says "E2E live run — ignore this message." Do nothing else.`, + modelId, + sandboxPolicy: {}, + createdById: owner.id, + manifest: { + description: "live run", + triggers: [ + { + type: "MANUAL", + name: "Run now", + summary: "Started by a person", + config: {}, }, - }, - ], - }, - }, - select: { id: true }, - }); - await db.agentDefinition.update({ - where: { id: agent.id }, - data: { currentVersionId: version.id }, - }); + ], + dataScope: { mode: "WORKSPACE", summary: "Workspace", resources: [] }, + actions: [ + { + type: "slack.message.post", + provider: "slack", + summary: `Post one message to #${channelName}`, + destination: { + kind: "channel", + resolution: "chosen", + id: channelId, + label: `#${channelName}`, + }, + }, + ], + }, + }) + .returning({ id: agentVersion.id }), + ); + await requireRow( + db + .update(agentDefinition) + .set({ currentVersionId: version.id }) + .where(whereEq(agentDefinition.id, agent.id)) + .returning(), + ); return { agentId: agent.id, versionId: version.id, ownerId: owner.id }; } @@ -77,19 +101,27 @@ async function main() { process.exit(1); } - const channel = await db.slackChannel.findFirst({ - where: { available: true, name: "test" }, - select: { id: true, name: true }, - }); + const channel = await firstRow( + db + .select({ id: slackChannel.id, name: slackChannel.name }) + .from(slackChannel) + .where( + and(eq(slackChannel.available, true), eq(slackChannel.name, "test")), + ) + .limit(1), + ); if (!channel) { console.error("FAIL No available #test channel. Connect Slack first."); process.exit(1); } - const settings = await db.appSetting.findUnique({ - where: { id: "app" }, - select: { agentModelId: true }, - }); + const settings = await firstRow( + db + .select({ agentModelId: appSetting.agentModelId }) + .from(appSetting) + .where(eq(appSetting.id, "app")) + .limit(1), + ); if (!settings?.agentModelId) { console.error( "FAIL No model is configured. Pick one on the settings page first.", @@ -105,18 +137,22 @@ async function main() { let ok = false; try { - const run = await db.agentRun.create({ - data: { - agentId, - versionId, - initiatedById: ownerId, - triggerType: "MANUAL", - idempotencyKey: crypto.randomUUID(), - correlationId: crypto.randomUUID(), - events: { create: { sequence: 0, type: "run.queued", data: {} } }, - }, - select: { id: true }, - }); + const run = await requireRow( + db + .insert(agentRun) + .values({ + agentId, + versionId, + initiatedById: ownerId, + triggerType: "MANUAL", + idempotencyKey: crypto.randomUUID(), + correlationId: crypto.randomUUID(), + }) + .returning({ id: agentRun.id }), + ); + await db + .insert(agentRunEvent) + .values({ runId: run.id, sequence: 0, type: "run.queued", data: {} }); console.log(`Queued run ${run.id}. Dispatching…`); const dispatch = await fetch(`${AGENT_URL}/internal/crm/agent-dispatch`, { @@ -131,10 +167,17 @@ async function main() { while (Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, E2E.liveRun.pollMs)); - const current = await db.agentRun.findUniqueOrThrow({ - where: { id: run.id }, - select: { status: true, errorCode: true, errorMessage: true }, - }); + const current = await requireRow( + db + .select({ + status: agentRun.status, + errorCode: agentRun.errorCode, + errorMessage: agentRun.errorMessage, + }) + .from(agentRun) + .where(whereEq(agentRun.id, run.id)) + .limit(1), + ); if (["SUCCEEDED", "FAILED", "CANCELLED"].includes(current.status)) { final = current; break; @@ -146,16 +189,19 @@ async function main() { console.log(` still ${current.status}…`); } - const actions = await db.agentAction.findMany({ - where: { runId: run.id }, - select: { - type: true, - status: true, - externalId: true, - errorMessage: true, - }, - }); - const events = await db.agentRunEvent.count({ where: { runId: run.id } }); + const actions = await db + .select({ + type: agentAction.type, + status: agentAction.status, + externalId: agentAction.externalId, + errorMessage: agentAction.errorMessage, + }) + .from(agentAction) + .where(whereEq(agentAction.runId, run.id)); + const events = await db.$count( + agentRunEvent, + whereEq(agentRunEvent.runId, run.id), + ); console.log(`\n status ${final?.status ?? "never settled"}`); console.log(` events ${events}`); diff --git a/apps/agent/test/e2e/load.e2e.ts b/apps/agent/test/e2e/load.e2e.ts index 935c49913..20321ed45 100644 --- a/apps/agent/test/e2e/load.e2e.ts +++ b/apps/agent/test/e2e/load.e2e.ts @@ -1,4 +1,22 @@ -import { db } from "@crm/db"; +import { + agentDefinition, + agentRun, + agentTask, + agentTrigger, + agentVersion, + and, + company as companyTable, + db, + deal as dealTable, + endsWith, + eq, + inArray, + isNull, + requireRow, + startsWith, + user, + whereEq, +} from "@crm/db"; import { PRIORITY } from "@crm/db/agent-tasks"; import { runVisibleLane, VISIBLE_BATCH } from "../../agent/lib/dispatch"; import { @@ -13,58 +31,70 @@ const COUNT = Number(process.env.E2E_LOAD_COUNT ?? E2E.load.defaultCount); const DRAIN_PASSES = Math.ceil(COUNT / VISIBLE_BATCH) + E2E.load.drainPassSlack; async function seedAgent() { - const owner = await db.user.findFirstOrThrow({ select: { id: true } }); - const agent = await db.agentDefinition.create({ - data: { - name: `${E2E.load.agentPrefix} ${Date.now()}`, - description: "Seeded for load testing. Safe to delete.", - status: "LIVE", - createdById: owner.id, - }, - select: { id: true }, - }); - const version = await db.agentVersion.create({ - data: { - agentId: agent.id, - number: 1, - status: "DEPLOYED", - instructions: "Load test agent. Does nothing.", - modelId: "test/model", - sandboxPolicy: {}, - createdById: owner.id, - manifest: { - description: "load test", - triggers: [ - { - type: "EVENT", - name: "Deal created", - summary: "Fires on deal creation", - config: { event: "deal.created" }, - }, - ], - dataScope: { mode: "WORKSPACE", summary: "Workspace", resources: [] }, - actions: [ - { type: "run.summary", provider: "crm", summary: "Log the result" }, - ], - }, - }, - select: { id: true }, - }); - await db.agentDefinition.update({ - where: { id: agent.id }, - data: { currentVersionId: version.id }, - }); - await db.agentTrigger.create({ - data: { - agentId: agent.id, - versionId: version.id, - type: "EVENT", - name: "Deal created", - config: { event: "deal.created" }, - enabled: true, - createdById: owner.id, - }, - }); + const owner = await requireRow( + db.select({ id: user.id }).from(user).limit(1), + ); + const agent = await requireRow( + db + .insert(agentDefinition) + .values({ + name: `${E2E.load.agentPrefix} ${Date.now()}`, + description: "Seeded for load testing. Safe to delete.", + status: "LIVE", + createdById: owner.id, + }) + .returning({ id: agentDefinition.id }), + ); + const version = await requireRow( + db + .insert(agentVersion) + .values({ + agentId: agent.id, + number: 1, + status: "DEPLOYED", + instructions: "Load test agent. Does nothing.", + modelId: "test/model", + sandboxPolicy: {}, + createdById: owner.id, + manifest: { + description: "load test", + triggers: [ + { + type: "EVENT", + name: "Deal created", + summary: "Fires on deal creation", + config: { event: "deal.created" }, + }, + ], + dataScope: { mode: "WORKSPACE", summary: "Workspace", resources: [] }, + actions: [ + { type: "run.summary", provider: "crm", summary: "Log the result" }, + ], + }, + }) + .returning({ id: agentVersion.id }), + ); + await requireRow( + db + .update(agentDefinition) + .set({ currentVersionId: version.id }) + .where(whereEq(agentDefinition.id, agent.id)) + .returning(), + ); + await requireRow( + db + .insert(agentTrigger) + .values({ + agentId: agent.id, + versionId: version.id, + type: "EVENT", + name: "Deal created", + config: { event: "deal.created" }, + enabled: true, + createdById: owner.id, + }) + .returning(), + ); return { agentId: agent.id, versionId: version.id }; } @@ -73,27 +103,37 @@ async function sweepLeftovers() { console.log(` removed leftover ${name}`); } - const stale = await db.company.findMany({ - where: { - domain: { - startsWith: E2E.load.domainPrefix, - endsWith: E2E.load.domainSuffix, - }, - name: { startsWith: E2E.load.companyPrefix }, - }, - select: { id: true, name: true }, - }); + const stale = await db + .select({ id: companyTable.id, name: companyTable.name }) + .from(companyTable) + .where( + and( + and( + startsWith(companyTable.domain, E2E.load.domainPrefix), + endsWith(companyTable.domain, E2E.load.domainSuffix), + ), + startsWith(companyTable.name, E2E.load.companyPrefix), + ), + ); for (const company of stale) { - const deals = await db.deal.findMany({ - where: { companyId: company.id }, - select: { id: true }, - }); - await db.agentTask.deleteMany({ - where: { dealId: { in: deals.map((deal) => deal.id) } }, - }); - await db.deal.deleteMany({ where: { companyId: company.id } }); - await db.company.delete({ where: { id: company.id } }); + const deals = await db + .select({ id: dealTable.id }) + .from(dealTable) + .where(whereEq(dealTable.companyId, company.id)); + await db.delete(agentTask).where( + inArray( + agentTask.dealId, + deals.map((deal) => deal.id), + ), + ); + await db.delete(dealTable).where(whereEq(dealTable.companyId, company.id)); + await requireRow( + db + .delete(companyTable) + .where(whereEq(companyTable.id, company.id)) + .returning(), + ); console.log(` removed leftover ${company.name}`); } } @@ -107,11 +147,16 @@ async function cleanUp( await removeEventRuns(taskIds); if (agentId) await removeAgent(agentId); if (dealIds.length > 0) { - await db.agentTask.deleteMany({ where: { dealId: { in: dealIds } } }); + await db.delete(agentTask).where(inArray(agentTask.dealId, dealIds)); } if (!companyId) return; - await db.deal.deleteMany({ where: { companyId } }); - await db.company.delete({ where: { id: companyId } }); + await db.delete(dealTable).where(whereEq(dealTable.companyId, companyId)); + await requireRow( + db + .delete(companyTable) + .where(whereEq(companyTable.id, companyId)) + .returning(), + ); } async function main() { @@ -126,35 +171,37 @@ async function main() { try { const seededAgent = await seedAgent(); agentId = seededAgent.agentId; - const owner = await db.user.findFirstOrThrow({ select: { id: true } }); - const company = await db.company.create({ - data: { - name: `Load Co ${Date.now()}`, - domain: `load-${Date.now()}.test`, - }, - select: { id: true }, - }); + const owner = await requireRow( + db.select({ id: user.id }).from(user).limit(1), + ); + const company = await requireRow( + db + .insert(companyTable) + .values({ + name: `Load Co ${Date.now()}`, + domain: `load-${Date.now()}.test`, + }) + .returning({ id: companyTable.id }), + ); companyId = company.id; console.log(`Seeding ${COUNT} deal.created events…`); const seedStart = Date.now(); - const deals = await db.$transaction( - Array.from({ length: COUNT }, (_, index) => - db.deal.create({ - data: { - name: `Load Deal ${index}`, - companyId: company.id, - ownerId: owner.id, - stage: "DEMO_BOOKED", - stageChangedAt: new Date(), - }, - select: { id: true }, - }), - ), - ); + const deals = await db + .insert(dealTable) + .values( + Array.from({ length: COUNT }, (_, index) => ({ + name: `Load Deal ${index}`, + companyId: company.id, + ownerId: owner.id, + stage: "DEMO_BOOKED" as const, + stageChangedAt: new Date(), + })), + ) + .returning({ id: dealTable.id }); dealIds.push(...deals.map((deal) => deal.id)); - await db.agentTask.createMany({ - data: deals.map((deal) => ({ + await db.insert(agentTask).values( + deals.map((deal) => ({ dealId: deal.id, kind: "agent-event" as const, reason: "deal.created", @@ -168,11 +215,11 @@ async function main() { budget: 1, dueAt: new Date(), })), - }); - const seeded = await db.agentTask.findMany({ - where: { dealId: { in: dealIds } }, - select: { id: true }, - }); + ); + const seeded = await db + .select({ id: agentTask.id }) + .from(agentTask) + .where(inArray(agentTask.dealId, dealIds)); taskIds.push(...seeded.map((task) => task.id)); console.log(` seeded in ${Date.now() - seedStart}ms`); @@ -186,12 +233,14 @@ async function main() { } const drainMs = Date.now() - drainStart; - const queued = await db.agentRun.count({ - where: { agentId: seededAgent.agentId }, - }); - const leftover = await db.agentTask.count({ - where: { kind: "agent-event", finishedAt: null }, - }); + const queued = await db.$count( + agentRun, + whereEq(agentRun.agentId, seededAgent.agentId), + ); + const leftover = await db.$count( + agentTask, + and(eq(agentTask.kind, "agent-event"), isNull(agentTask.finishedAt)), + ); console.log(`\n tasks drained ${handled}`); console.log(` runs queued ${queued}`); diff --git a/apps/agent/test/e2e/retry.e2e.ts b/apps/agent/test/e2e/retry.e2e.ts index 652a6d715..4426cec7e 100644 --- a/apps/agent/test/e2e/retry.e2e.ts +++ b/apps/agent/test/e2e/retry.e2e.ts @@ -1,4 +1,18 @@ -import { db } from "@crm/db"; +import { + agentTask, + and, + company as companyTable, + db, + firstRow, + gte, + inArray, + isNull, + lt, + ne, + or, + requireRow, + whereEq, +} from "@crm/db"; import { MAX_ATTEMPTS, RETIRED_OUTCOME } from "@crm/db/agent-tasks"; import { retireAbandoned } from "../../agent/lib/stale-tasks"; import { claimDue } from "../../agent/lib/tasks"; @@ -23,30 +37,40 @@ async function claimMine() { } async function expireLease(taskId: string) { - await db.agentTask.update({ - where: { id: taskId }, - data: { leasedUntil: new Date(Date.now() - E2E.retry.expiredLeaseMs) }, - }); + await requireRow( + db + .update(agentTask) + .set({ leasedUntil: new Date(Date.now() - E2E.retry.expiredLeaseMs) }) + .where(whereEq(agentTask.id, taskId)) + .returning(), + ); } async function holdBackOtherExhausted(taskId: string): Promise { const now = new Date(); - const others = await db.agentTask.findMany({ - where: { - id: { not: taskId }, - finishedAt: null, - attempts: { gte: MAX_ATTEMPTS }, - OR: [{ leasedUntil: null }, { leasedUntil: { lt: now } }], - }, - select: { id: true, leasedUntil: true }, - }); + const others = await db + .select({ id: agentTask.id, leasedUntil: agentTask.leasedUntil }) + .from(agentTask) + .where( + and( + ne(agentTask.id, taskId), + isNull(agentTask.finishedAt), + gte(agentTask.attempts, MAX_ATTEMPTS), + or(isNull(agentTask.leasedUntil), lt(agentTask.leasedUntil, now)), + ), + ); if (others.length === 0) return []; - await db.agentTask.updateMany({ - where: { id: { in: others.map((row) => row.id) } }, - data: { leasedUntil: new Date(Date.now() + E2E.retry.holdBackMs) }, - }); + await db + .update(agentTask) + .set({ leasedUntil: new Date(Date.now() + E2E.retry.holdBackMs) }) + .where( + inArray( + agentTask.id, + others.map((row) => row.id), + ), + ); console.log(` held back ${others.length} exhausted task(s) owned by nobody`); return others; @@ -54,36 +78,40 @@ async function holdBackOtherExhausted(taskId: string): Promise { async function releaseHeldBack(held: readonly HeldTask[]) { for (const task of held) { - await db.agentTask.updateMany({ - where: { id: task.id }, - data: { leasedUntil: task.leasedUntil }, - }); + await db + .update(agentTask) + .set({ leasedUntil: task.leasedUntil }) + .where(whereEq(agentTask.id, task.id)); } } async function main() { const stamp = Date.now(); - const company = await db.company.create({ - data: { - name: `${E2E.retry.companyPrefix} ${stamp}`, - domain: `retry-${stamp}.test`, - }, - select: { id: true }, - }); + const company = await requireRow( + db + .insert(companyTable) + .values({ + name: `${E2E.retry.companyPrefix} ${stamp}`, + domain: `retry-${stamp}.test`, + }) + .returning({ id: companyTable.id }), + ); let held: HeldTask[] = []; try { - const task = await db.agentTask.create({ - data: { - companyId: company.id, - kind: E2E.retry.kind, - reason: E2E.retry.reason, - priority: E2E.retry.priority, - budget: 1, - dueAt: new Date(), - }, - select: { id: true }, - }); + const task = await requireRow( + db + .insert(agentTask) + .values({ + companyId: company.id, + kind: E2E.retry.kind, + reason: E2E.retry.reason, + priority: E2E.retry.priority, + budget: 1, + dueAt: new Date(), + }) + .returning({ id: agentTask.id }), + ); for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { const claimed = await claimMine(); @@ -106,20 +134,32 @@ async function main() { held = await holdBackOtherExhausted(task.id); await retireAbandoned(); - const retired = await db.agentTask.findUnique({ - where: { id: task.id }, - select: { finishedAt: true, outcome: true }, - }); + const retired = await firstRow( + db + .select({ + finishedAt: agentTask.finishedAt, + outcome: agentTask.outcome, + }) + .from(agentTask) + .where(whereEq(agentTask.id, task.id)) + .limit(1), + ); record( "exhausted task is retired, not left queued", Boolean(retired?.finishedAt) && retired?.outcome === RETIRED_OUTCOME, retired?.outcome ?? "still queued", ); - const enrichment = await db.company.findUnique({ - where: { id: company.id }, - select: { enrichmentStatus: true, enrichmentError: true }, - }); + const enrichment = await firstRow( + db + .select({ + enrichmentStatus: companyTable.enrichmentStatus, + enrichmentError: companyTable.enrichmentError, + }) + .from(companyTable) + .where(whereEq(companyTable.id, company.id)) + .limit(1), + ); record( "the record says why it gave up", enrichment?.enrichmentStatus === "FAILED", @@ -128,8 +168,15 @@ async function main() { } finally { try { await releaseHeldBack(held); - await db.agentTask.deleteMany({ where: { companyId: company.id } }); - await db.company.delete({ where: { id: company.id } }); + await db + .delete(agentTask) + .where(whereEq(agentTask.companyId, company.id)); + await requireRow( + db + .delete(companyTable) + .where(whereEq(companyTable.id, company.id)) + .returning(), + ); } catch (error) { record("cleanup leaves nothing behind", false, reasonOf(error)); } diff --git a/apps/agent/test/e2e/slack-delivery.e2e.ts b/apps/agent/test/e2e/slack-delivery.e2e.ts index fe7f62f99..ad1ebd2bc 100644 --- a/apps/agent/test/e2e/slack-delivery.e2e.ts +++ b/apps/agent/test/e2e/slack-delivery.e2e.ts @@ -1,4 +1,4 @@ -import { db } from "@crm/db"; +import { db, isNotNull, slackChannel, slackMemberMatch } from "@crm/db"; import { sendSlackMessage } from "../../agent/lib/run-runtime"; import { slackAccessToken } from "../../agent/lib/slack-connection"; @@ -17,13 +17,16 @@ async function main() { process.exit(1); } - const channels = await db.slackChannel.findMany({ - select: { id: true, name: true }, - }); - const people = await db.slackMemberMatch.findMany({ - where: { slackUserId: { not: null } }, - select: { slackUserId: true, slackHandle: true }, - }); + const channels = await db + .select({ id: slackChannel.id, name: slackChannel.name }) + .from(slackChannel); + const people = await db + .select({ + slackUserId: slackMemberMatch.slackUserId, + slackHandle: slackMemberMatch.slackHandle, + }) + .from(slackMemberMatch) + .where(isNotNull(slackMemberMatch.slackUserId)); const testChannel = channels.find((row) => row.name === "test"); const person = people[0]; diff --git a/apps/agent/test/e2e/slack-join.e2e.ts b/apps/agent/test/e2e/slack-join.e2e.ts index be17cc4ef..4bca055ca 100644 --- a/apps/agent/test/e2e/slack-join.e2e.ts +++ b/apps/agent/test/e2e/slack-join.e2e.ts @@ -1,4 +1,4 @@ -import { db } from "@crm/db"; +import { db, eq, slackChannel } from "@crm/db"; import { slackCanInviteItself, slackConnected, @@ -32,10 +32,15 @@ async function main() { console.log(await runSlackPeopleMatch()); - const channels = await db.slackChannel.findMany({ - where: { available: true }, - select: { id: true, name: true, isPrivate: true, isMember: true }, - }); + const channels = await db + .select({ + id: slackChannel.id, + name: slackChannel.name, + isPrivate: slackChannel.isPrivate, + isMember: slackChannel.isMember, + }) + .from(slackChannel) + .where(eq(slackChannel.available, true)); const canInvite = await slackCanInviteItself(); record( diff --git a/apps/agent/test/enrichment.integration.spec.ts b/apps/agent/test/enrichment.integration.spec.ts index 3db33938d..7d0cf2eb2 100644 --- a/apps/agent/test/enrichment.integration.spec.ts +++ b/apps/agent/test/enrichment.integration.spec.ts @@ -1,35 +1,50 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { db, EnrichmentStatus } from "@crm/db"; +import { + agentTask, + company as companyTable, + contact as contactTable, + db, + EnrichmentStatus, + eq, + firstRow, + requireRow, + startsWith, + whereEq, +} from "@crm/db"; import { markRunning, settle } from "../agent/lib/enrichment"; const domain = "lifecycle.example.test"; async function clear() { - await db.agentTask.deleteMany({ where: { reason: "lifecycle" } }); - await db.company.deleteMany({ where: { domain } }); - await db.contact.deleteMany({ - where: { email: { startsWith: "lifecycle-" } }, - }); + await db.delete(agentTask).where(eq(agentTask.reason, "lifecycle")); + await db.delete(companyTable).where(whereEq(companyTable.domain, domain)); + await db + .delete(contactTable) + .where(startsWith(contactTable.email, "lifecycle-")); } beforeEach(clear); afterEach(clear); async function company() { - return db.company.create({ - data: { name: "Lifecycle", domain }, - select: { id: true }, - }); + return requireRow( + db + .insert(companyTable) + .values({ name: "Lifecycle", domain }) + .returning({ id: companyTable.id }), + ); } async function contact() { - return db.contact.create({ - data: { - firstName: "Lifecycle", - email: `lifecycle-${crypto.randomUUID()}@example.test`, - }, - select: { id: true }, - }); + return requireRow( + db + .insert(contactTable) + .values({ + firstName: "Lifecycle", + email: `lifecycle-${crypto.randomUUID()}@example.test`, + }) + .returning({ id: contactTable.id }), + ); } function subjectOf(ids: { contactId?: string; companyId?: string }) { @@ -42,29 +57,40 @@ function subjectOf(ids: { contactId?: string; companyId?: string }) { } async function retiredTask(companyId: string) { - const row = await db.company.findUniqueOrThrow({ - where: { id: companyId }, - select: { updatedAt: true }, - }); - - return db.agentTask.create({ - data: { - companyId, - kind: "company-profile", - reason: "lifecycle", - attempts: 3, - dueAt: row.updatedAt, - finishedAt: new Date(row.updatedAt.getTime() + 1), - }, - select: { id: true }, - }); + const row = await requireRow( + db + .select({ updatedAt: companyTable.updatedAt }) + .from(companyTable) + .where(whereEq(companyTable.id, companyId)) + .limit(1), + ); + + return requireRow( + db + .insert(agentTask) + .values({ + companyId, + kind: "company-profile", + reason: "lifecycle", + attempts: 3, + dueAt: row.updatedAt, + finishedAt: new Date(row.updatedAt.getTime() + 1), + }) + .returning({ id: agentTask.id }), + ); } async function statusOfContact(id: string) { - const row = await db.contact.findUnique({ - where: { id }, - select: { enrichmentStatus: true, enrichedAt: true }, - }); + const row = await firstRow( + db + .select({ + enrichmentStatus: contactTable.enrichmentStatus, + enrichedAt: contactTable.enrichedAt, + }) + .from(contactTable) + .where(whereEq(contactTable.id, id)) + .limit(1), + ); return row; } @@ -95,10 +121,13 @@ describe("the record follows the task", () => { await markRunning(subject); await settle(subject, EnrichmentStatus.COMPLETE); - const row = await db.company.findUnique({ - where: { id: org.id }, - select: { enrichmentStatus: true }, - }); + const row = await firstRow( + db + .select({ enrichmentStatus: companyTable.enrichmentStatus }) + .from(companyTable) + .where(whereEq(companyTable.id, org.id)) + .limit(1), + ); expect(row?.enrichmentStatus).toBe("COMPLETE"); }); @@ -108,20 +137,29 @@ describe("the record follows the task", () => { await markRunning(subject); - await db.company.update({ - where: { id: org.id }, - data: { - enrichmentStatus: EnrichmentStatus.SKIPPED, - enrichmentError: "No domain to look up.", - }, - }); + await requireRow( + db + .update(companyTable) + .set({ + enrichmentStatus: EnrichmentStatus.SKIPPED, + enrichmentError: "No domain to look up.", + }) + .where(whereEq(companyTable.id, org.id)) + .returning(), + ); await settle(subject, EnrichmentStatus.COMPLETE); - const row = await db.company.findUnique({ - where: { id: org.id }, - select: { enrichmentStatus: true, enrichmentError: true }, - }); + const row = await firstRow( + db + .select({ + enrichmentStatus: companyTable.enrichmentStatus, + enrichmentError: companyTable.enrichmentError, + }) + .from(companyTable) + .where(whereEq(companyTable.id, org.id)) + .limit(1), + ); expect(row?.enrichmentStatus).toBe("SKIPPED"); expect(row?.enrichmentError).toBe("No domain to look up."); }); @@ -138,10 +176,13 @@ describe("the record follows the task", () => { const retried = await statusOfContact(person.id); expect(retried?.enrichmentStatus).toBe("RUNNING"); - const row = await db.contact.findUnique({ - where: { id: person.id }, - select: { enrichmentError: true }, - }); + const row = await firstRow( + db + .select({ enrichmentError: contactTable.enrichmentError }) + .from(contactTable) + .where(whereEq(contactTable.id, person.id)) + .limit(1), + ); expect(row?.enrichmentError).toBeNull(); }); @@ -155,10 +196,13 @@ describe("the record follows the task", () => { "Research was attempted several times and never completed.", ); - const row = await db.company.findUnique({ - where: { id: org.id }, - select: { enrichmentStatus: true }, - }); + const row = await firstRow( + db + .select({ enrichmentStatus: companyTable.enrichmentStatus }) + .from(companyTable) + .where(whereEq(companyTable.id, org.id)) + .limit(1), + ); expect(row?.enrichmentStatus).toBe("FAILED"); }); @@ -166,13 +210,16 @@ describe("the record follows the task", () => { const org = await company(); const task = await retiredTask(org.id); - await db.company.update({ - where: { id: org.id }, - data: { - enrichmentStatus: EnrichmentStatus.PENDING, - enrichmentError: null, - }, - }); + await requireRow( + db + .update(companyTable) + .set({ + enrichmentStatus: EnrichmentStatus.PENDING, + enrichmentError: null, + }) + .where(whereEq(companyTable.id, org.id)) + .returning(), + ); await settle( { ...subjectOf({ companyId: org.id }), id: task.id }, @@ -180,33 +227,44 @@ describe("the record follows the task", () => { "Research was attempted several times and never completed.", ); - const row = await db.company.findUnique({ - where: { id: org.id }, - select: { enrichmentStatus: true, enrichmentError: true }, - }); + const row = await firstRow( + db + .select({ + enrichmentStatus: companyTable.enrichmentStatus, + enrichmentError: companyTable.enrichmentError, + }) + .from(companyTable) + .where(whereEq(companyTable.id, org.id)) + .limit(1), + ); expect(row?.enrichmentStatus).toBe("PENDING"); expect(row?.enrichmentError).toBeNull(); }); it("leaves a queued record alone when a task that never ended fails late", async () => { const org = await company(); - const open = await db.agentTask.create({ - data: { - companyId: org.id, - kind: "company-profile", - reason: "lifecycle", - dueAt: new Date(), - }, - select: { id: true }, - }); - - await db.company.update({ - where: { id: org.id }, - data: { - enrichmentStatus: EnrichmentStatus.PENDING, - enrichmentError: null, - }, - }); + const open = await requireRow( + db + .insert(agentTask) + .values({ + companyId: org.id, + kind: "company-profile", + reason: "lifecycle", + dueAt: new Date(), + }) + .returning({ id: agentTask.id }), + ); + + await requireRow( + db + .update(companyTable) + .set({ + enrichmentStatus: EnrichmentStatus.PENDING, + enrichmentError: null, + }) + .where(whereEq(companyTable.id, org.id)) + .returning(), + ); await settle( { ...subjectOf({ companyId: org.id }), id: open.id }, @@ -214,10 +272,16 @@ describe("the record follows the task", () => { "The agent turn failed.", ); - const row = await db.company.findUnique({ - where: { id: org.id }, - select: { enrichmentStatus: true, enrichmentError: true }, - }); + const row = await firstRow( + db + .select({ + enrichmentStatus: companyTable.enrichmentStatus, + enrichmentError: companyTable.enrichmentError, + }) + .from(companyTable) + .where(whereEq(companyTable.id, org.id)) + .limit(1), + ); expect(row?.enrichmentStatus).toBe("PENDING"); expect(row?.enrichmentError).toBeNull(); }); @@ -225,15 +289,17 @@ describe("the record follows the task", () => { it("leaves the record to a newer request when an ended task settles late", async () => { const org = await company(); const task = await retiredTask(org.id); - await db.agentTask.create({ - data: { - companyId: org.id, - kind: "recheck", - reason: "lifecycle", - dueAt: new Date(), - }, - select: { id: true }, - }); + await requireRow( + db + .insert(agentTask) + .values({ + companyId: org.id, + kind: "recheck", + reason: "lifecycle", + dueAt: new Date(), + }) + .returning({ id: agentTask.id }), + ); await settle( { ...subjectOf({ companyId: org.id }), id: task.id }, @@ -241,10 +307,16 @@ describe("the record follows the task", () => { "Research was attempted several times and never completed.", ); - const row = await db.company.findUnique({ - where: { id: org.id }, - select: { enrichmentStatus: true, enrichmentError: true }, - }); + const row = await firstRow( + db + .select({ + enrichmentStatus: companyTable.enrichmentStatus, + enrichmentError: companyTable.enrichmentError, + }) + .from(companyTable) + .where(whereEq(companyTable.id, org.id)) + .limit(1), + ); expect(row?.enrichmentStatus).toBe("PENDING"); expect(row?.enrichmentError).toBeNull(); }); @@ -254,7 +326,12 @@ describe("the record follows the task", () => { const subject = subjectOf({ contactId: person.id }); await markRunning(subject); - await db.contact.delete({ where: { id: person.id } }); + await requireRow( + db + .delete(contactTable) + .where(whereEq(contactTable.id, person.id)) + .returning(), + ); await settle(subject, EnrichmentStatus.COMPLETE); }); @@ -273,10 +350,16 @@ describe("the record follows the task", () => { }; await markRunning(profile); - const row = await db.company.findUnique({ - where: { id: org.id }, - select: { enrichmentStatus: true, enrichedAt: true }, - }); + const row = await firstRow( + db + .select({ + enrichmentStatus: companyTable.enrichmentStatus, + enrichedAt: companyTable.enrichedAt, + }) + .from(companyTable) + .where(whereEq(companyTable.id, org.id)) + .limit(1), + ); expect(row?.enrichmentStatus).toBe("COMPLETE"); expect(row?.enrichedAt).not.toBeNull(); }); @@ -288,18 +371,27 @@ describe("the record follows the task", () => { await markRunning(identify); await settle(identify, EnrichmentStatus.COMPLETE); - const first = await db.contact.findUniqueOrThrow({ - where: { id: person.id }, - select: { enrichedAt: true }, - }); + const first = await requireRow( + db + .select({ enrichedAt: contactTable.enrichedAt }) + .from(contactTable) + .where(whereEq(contactTable.id, person.id)) + .limit(1), + ); const recheck = { ...identify, id: "recheck", kind: "recheck" }; await settle(recheck, EnrichmentStatus.COMPLETE); - const second = await db.contact.findUniqueOrThrow({ - where: { id: person.id }, - select: { enrichmentStatus: true, enrichedAt: true }, - }); + const second = await requireRow( + db + .select({ + enrichmentStatus: contactTable.enrichmentStatus, + enrichedAt: contactTable.enrichedAt, + }) + .from(contactTable) + .where(whereEq(contactTable.id, person.id)) + .limit(1), + ); expect(second.enrichmentStatus).toBe("COMPLETE"); expect(second.enrichedAt?.getTime()).toBeGreaterThanOrEqual( first.enrichedAt?.getTime() ?? 0, @@ -309,22 +401,27 @@ describe("the record follows the task", () => { it("lets a finished run land on the fresh look that asked for it", async () => { const person = await contact(); - const row = await db.contact.findUniqueOrThrow({ - where: { id: person.id }, - select: { updatedAt: true }, - }); + const row = await requireRow( + db + .select({ updatedAt: contactTable.updatedAt }) + .from(contactTable) + .where(whereEq(contactTable.id, person.id)) + .limit(1), + ); - const task = await db.agentTask.create({ - data: { - contactId: person.id, - kind: "identify", - reason: "lifecycle", - attempts: 1, - dueAt: row.updatedAt, - finishedAt: new Date(row.updatedAt.getTime() + 1), - }, - select: { id: true }, - }); + const task = await requireRow( + db + .insert(agentTask) + .values({ + contactId: person.id, + kind: "identify", + reason: "lifecycle", + attempts: 1, + dueAt: row.updatedAt, + finishedAt: new Date(row.updatedAt.getTime() + 1), + }) + .returning({ id: agentTask.id }), + ); await settle( { ...subjectOf({ contactId: person.id }), id: task.id }, @@ -339,32 +436,39 @@ describe("the record follows the task", () => { it("leaves a fresh look to the newer task a late run cannot satisfy", async () => { const person = await contact(); - const row = await db.contact.findUniqueOrThrow({ - where: { id: person.id }, - select: { updatedAt: true }, - }); + const row = await requireRow( + db + .select({ updatedAt: contactTable.updatedAt }) + .from(contactTable) + .where(whereEq(contactTable.id, person.id)) + .limit(1), + ); - const ended = await db.agentTask.create({ - data: { - contactId: person.id, - kind: "identify", - reason: "lifecycle", - attempts: 1, - dueAt: row.updatedAt, - finishedAt: new Date(row.updatedAt.getTime() + 1), - }, - select: { id: true }, - }); - - await db.agentTask.create({ - data: { - contactId: person.id, - kind: "recheck", - reason: "lifecycle", - dueAt: new Date(), - }, - select: { id: true }, - }); + const ended = await requireRow( + db + .insert(agentTask) + .values({ + contactId: person.id, + kind: "identify", + reason: "lifecycle", + attempts: 1, + dueAt: row.updatedAt, + finishedAt: new Date(row.updatedAt.getTime() + 1), + }) + .returning({ id: agentTask.id }), + ); + + await requireRow( + db + .insert(agentTask) + .values({ + contactId: person.id, + kind: "recheck", + reason: "lifecycle", + dueAt: new Date(), + }) + .returning({ id: agentTask.id }), + ); await settle( { ...subjectOf({ contactId: person.id }), id: ended.id }, @@ -389,10 +493,16 @@ describe("the record follows the task", () => { }; await settle(profile, EnrichmentStatus.FAILED, "the agent turn failed"); - const row = await db.company.findUnique({ - where: { id: org.id }, - select: { enrichmentStatus: true, enrichmentError: true }, - }); + const row = await firstRow( + db + .select({ + enrichmentStatus: companyTable.enrichmentStatus, + enrichmentError: companyTable.enrichmentError, + }) + .from(companyTable) + .where(whereEq(companyTable.id, org.id)) + .limit(1), + ); expect(row?.enrichmentStatus).toBe("RUNNING"); expect(row?.enrichmentError).toBeNull(); }); @@ -406,10 +516,16 @@ describe("the record follows the task", () => { const portrait = { ...identify, id: "portrait", kind: "portrait" }; await settle(portrait, EnrichmentStatus.FAILED, "the picture timed out"); - const row = await db.contact.findUnique({ - where: { id: person.id }, - select: { enrichmentStatus: true, enrichmentError: true }, - }); + const row = await firstRow( + db + .select({ + enrichmentStatus: contactTable.enrichmentStatus, + enrichmentError: contactTable.enrichmentError, + }) + .from(contactTable) + .where(whereEq(contactTable.id, person.id)) + .limit(1), + ); expect(row?.enrichmentStatus).toBe("RUNNING"); expect(row?.enrichmentError).toBeNull(); }); @@ -429,10 +545,16 @@ describe("the record follows the task", () => { await markRunning(profile); await settle(profile, EnrichmentStatus.FAILED, "the agent turn failed"); - const row = await db.company.findUnique({ - where: { id: org.id }, - select: { enrichmentStatus: true, enrichmentError: true }, - }); + const row = await firstRow( + db + .select({ + enrichmentStatus: companyTable.enrichmentStatus, + enrichmentError: companyTable.enrichmentError, + }) + .from(companyTable) + .where(whereEq(companyTable.id, org.id)) + .limit(1), + ); expect(row?.enrichmentStatus).toBe("COMPLETE"); expect(row?.enrichmentError).toBeNull(); }); diff --git a/apps/agent/test/facts.integration.spec.ts b/apps/agent/test/facts.integration.spec.ts index 4c9fc42e8..0da271744 100644 --- a/apps/agent/test/facts.integration.spec.ts +++ b/apps/agent/test/facts.integration.spec.ts @@ -1,5 +1,15 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { db } from "@crm/db"; +import { + and, + contactBrief, + contactFact, + contact as contactTable, + db, + eq, + firstRow, + requireRow, + whereEq, +} from "@crm/db"; import type { Evidence } from "../agent/lib/evidence"; import { recordFact, writeBrief } from "../agent/lib/facts"; @@ -14,16 +24,18 @@ const seen = (kind: Evidence["kind"], detail = "observed"): Evidence => ({ }); beforeAll(async () => { - await db.contact.deleteMany({ where: { email } }); - const contact = await db.contact.create({ - data: { firstName: "Subject", lastName: null, email }, - select: { id: true }, - }); + await db.delete(contactTable).where(whereEq(contactTable.email, email)); + const contact = await requireRow( + db + .insert(contactTable) + .values({ firstName: "Subject", lastName: null, email }) + .returning({ id: contactTable.id }), + ); contactId = contact.id; }); afterAll(async () => { - await db.contact.deleteMany({ where: { email } }); + await db.delete(contactTable).where(whereEq(contactTable.email, email)); }); describe("recordFact", () => { @@ -39,10 +51,13 @@ describe("recordFact", () => { expect(result.applied).toBe(true); expect(result.band).toBe("VERIFIED"); - const contact = await db.contact.findUnique({ - where: { id: contactId }, - select: { title: true }, - }); + const contact = await firstRow( + db + .select({ title: contactTable.title }) + .from(contactTable) + .where(whereEq(contactTable.id, contactId)) + .limit(1), + ); expect(contact?.title).toBe("Head of Security"); }); @@ -58,10 +73,13 @@ describe("recordFact", () => { expect(result.applied).toBe(true); expect(result.band).toBe("PROBABLE"); - const contact = await db.contact.findUnique({ - where: { id: contactId }, - select: { twitterUrl: true }, - }); + const contact = await firstRow( + db + .select({ twitterUrl: contactTable.twitterUrl }) + .from(contactTable) + .where(whereEq(contactTable.id, contactId)) + .limit(1), + ); expect(contact?.twitterUrl).toBe("https://x.com/subject"); }); @@ -91,10 +109,13 @@ describe("recordFact", () => { expect(result.applied).toBe(false); expect(result.reason).toContain("already carries a value"); - const contact = await db.contact.findUnique({ - where: { id: contactId }, - select: { twitterUrl: true }, - }); + const contact = await firstRow( + db + .select({ twitterUrl: contactTable.twitterUrl }) + .from(contactTable) + .where(whereEq(contactTable.id, contactId)) + .limit(1), + ); expect(contact?.twitterUrl).toBe("https://x.com/subject"); }); @@ -110,9 +131,14 @@ describe("recordFact", () => { expect(result.stored).toBe(false); expect(result.reason).toContain("already in front of a rep"); - const offers = await db.contactFact.count({ - where: { contactId, field: "twitterUrl", status: "PROPOSED" }, - }); + const offers = await db.$count( + contactFact, + and( + whereEq(contactFact.contactId, contactId), + eq(contactFact.field, "twitterUrl"), + eq(contactFact.status, "PROPOSED"), + ), + ); expect(offers).toBe(1); }); @@ -125,10 +151,15 @@ describe("recordFact", () => { method: "x.profile", }); - const facts = await db.contactFact.findMany({ - where: { contactId, field: "twitterUrl" }, - select: { value: true, status: true }, - }); + const facts = await db + .select({ value: contactFact.value, status: contactFact.status }) + .from(contactFact) + .where( + and( + whereEq(contactFact.contactId, contactId), + eq(contactFact.field, "twitterUrl"), + ), + ); expect(facts.filter((fact) => fact.status === "PROPOSED")).toHaveLength(0); expect( @@ -137,10 +168,13 @@ describe("recordFact", () => { }); it("never overwrites what a person typed", async () => { - await db.contact.update({ - where: { id: contactId }, - data: { githubUrl: "https://github.com/typed-by-a-human" }, - }); + await requireRow( + db + .update(contactTable) + .set({ githubUrl: "https://github.com/typed-by-a-human" }) + .where(whereEq(contactTable.id, contactId)) + .returning(), + ); const result = await recordFact({ contactId, @@ -153,10 +187,13 @@ describe("recordFact", () => { expect(result.applied).toBe(false); expect(result.reason).toContain("A person already filled in"); - const contact = await db.contact.findUnique({ - where: { id: contactId }, - select: { githubUrl: true }, - }); + const contact = await firstRow( + db + .select({ githubUrl: contactTable.githubUrl }) + .from(contactTable) + .where(whereEq(contactTable.id, contactId)) + .limit(1), + ); expect(contact?.githubUrl).toBe("https://github.com/typed-by-a-human"); }); @@ -170,14 +207,26 @@ describe("recordFact", () => { }); expect(offer.applied).toBe(false); - const proposal = await db.contactFact.findFirst({ - where: { contactId, field: "title", status: "PROPOSED" }, - select: { id: true }, - }); - await db.contactFact.update({ - where: { id: proposal?.id }, - data: { status: "DISMISSED", decidedAt: new Date() }, - }); + const proposal = await firstRow( + db + .select({ id: contactFact.id }) + .from(contactFact) + .where( + and( + whereEq(contactFact.contactId, contactId), + eq(contactFact.field, "title"), + eq(contactFact.status, "PROPOSED"), + ), + ) + .limit(1), + ); + await requireRow( + db + .update(contactFact) + .set({ status: "DISMISSED", decidedAt: new Date() }) + .where(whereEq(contactFact.id, proposal?.id)) + .returning(), + ); const result = await recordFact({ contactId, @@ -208,10 +257,15 @@ describe("recordFact", () => { method: "linkedin.profile", }); - const facts = await db.contactFact.findMany({ - where: { contactId, field: "employer" }, - select: { value: true, status: true }, - }); + const facts = await db + .select({ value: contactFact.value, status: contactFact.status }) + .from(contactFact) + .where( + and( + whereEq(contactFact.contactId, contactId), + eq(contactFact.field, "employer"), + ), + ); expect(facts).toHaveLength(2); expect(facts.find((f) => f.value === "Fleetio")?.status).toBe("SUPERSEDED"); @@ -239,10 +293,19 @@ describe("recordFact", () => { expect(result.stored).toBe(true); expect(result.applied).toBe(true); - const facts = await db.contactFact.findMany({ - where: { contactId, field: "employer" }, - select: { value: true, status: true, score: true }, - }); + const facts = await db + .select({ + value: contactFact.value, + status: contactFact.status, + score: contactFact.score, + }) + .from(contactFact) + .where( + and( + whereEq(contactFact.contactId, contactId), + eq(contactFact.field, "employer"), + ), + ); const live = facts.filter((fact) => fact.status === "APPLIED"); expect(live).toHaveLength(1); @@ -251,10 +314,22 @@ describe("recordFact", () => { }); it("stores the evidence, so the score can be explained later", async () => { - const fact = await db.contactFact.findFirst({ - where: { contactId, field: "title" }, - select: { evidence: true, score: true, sourceUrl: true }, - }); + const fact = await firstRow( + db + .select({ + evidence: contactFact.evidence, + score: contactFact.score, + sourceUrl: contactFact.sourceUrl, + }) + .from(contactFact) + .where( + and( + whereEq(contactFact.contactId, contactId), + eq(contactFact.field, "title"), + ), + ) + .limit(1), + ); expect(Array.isArray(fact?.evidence)).toBe(true); expect(fact?.score).toBeGreaterThan(0.85); @@ -278,7 +353,13 @@ describe("writeBrief", () => { evidence: [seen("linkedin.employer-and-name")], }); - const brief = await db.contactBrief.findUnique({ where: { contactId } }); + const brief = await firstRow( + db + .select() + .from(contactBrief) + .where(whereEq(contactBrief.contactId, contactId)) + .limit(1), + ); expect(brief?.narrative).toBe("Subject is now VP Security at Example."); }); diff --git a/apps/agent/test/keyless-brand.integration.spec.ts b/apps/agent/test/keyless-brand.integration.spec.ts index d6487b6b9..c64dbffa5 100644 --- a/apps/agent/test/keyless-brand.integration.spec.ts +++ b/apps/agent/test/keyless-brand.integration.spec.ts @@ -1,5 +1,15 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; -import { db, EnrichmentStatus } from "@crm/db"; +import { + agentTask, + company as companyTable, + db, + EnrichmentStatus, + firstRow, + inArray, + requireRow, + sql, + whereEq, +} from "@crm/db"; import { readContextDevKey, writeContextDevKey } from "@crm/db/settings"; import { runBrand } from "../agent/lib/brand"; import { settle } from "../agent/lib/enrichment"; @@ -22,21 +32,25 @@ const tasks: string[] = []; afterEach(async () => { if (tasks.length > 0) { - await db.agentTask.deleteMany({ where: { id: { in: tasks.splice(0) } } }); + await db.delete(agentTask).where(inArray(agentTask.id, tasks.splice(0))); } if (created.length === 0) return; - await db.company.deleteMany({ where: { id: { in: created.splice(0) } } }); + await db + .delete(companyTable) + .where(inArray(companyTable.id, created.splice(0))); }); async function company(status: EnrichmentStatus) { - const row = await db.company.create({ - data: { - name: "Keyless Probe", - domain: `keyless-${created.length}-${status}.test`.toLowerCase(), - enrichmentStatus: status, - }, - select: { id: true }, - }); + const row = await requireRow( + db + .insert(companyTable) + .values({ + name: "Keyless Probe", + domain: `keyless-${created.length}-${status}.test`.toLowerCase(), + enrichmentStatus: status, + }) + .returning({ id: companyTable.id }), + ); created.push(row.id); return row.id; @@ -51,23 +65,25 @@ const subjectOf = (companyId: string) => ({ }); async function retiredSubjectOf(companyId: string) { - await db.$executeRaw` + await db.execute(sql` UPDATE "company" SET "updatedAt" = NOW() - INTERVAL '1 second' WHERE id = ${companyId} - `; - - const row = await db.agentTask.create({ - data: { - companyId, - kind: "brand", - reason: "keyless", - attempts: 3, - dueAt: new Date(), - finishedAt: new Date(), - }, - select: { id: true }, - }); + `); + + const row = await requireRow( + db + .insert(agentTask) + .values({ + companyId, + kind: "brand", + reason: "keyless", + attempts: 3, + dueAt: new Date(), + finishedAt: new Date(), + }) + .returning({ id: agentTask.id }), + ); tasks.push(row.id); return { ...subjectOf(companyId), id: row.id }; @@ -75,10 +91,13 @@ async function retiredSubjectOf(companyId: string) { const statusOf = async (id: string) => ( - await db.company.findUnique({ - where: { id }, - select: { enrichmentStatus: true }, - }) + await firstRow( + db + .select({ enrichmentStatus: companyTable.enrichmentStatus }) + .from(companyTable) + .where(whereEq(companyTable.id, id)) + .limit(1), + ) )?.enrichmentStatus; describe("a brand task with no key", () => { @@ -136,13 +155,15 @@ describe("a brand task with no key", () => { }); async function domainlessCompany(status: EnrichmentStatus) { - const row = await db.company.create({ - data: { - name: `Keyless Probe ${created.length}`, - enrichmentStatus: status, - }, - select: { id: true }, - }); + const row = await requireRow( + db + .insert(companyTable) + .values({ + name: `Keyless Probe ${created.length}`, + enrichmentStatus: status, + }) + .returning({ id: companyTable.id }), + ); created.push(row.id); return row.id; diff --git a/apps/agent/test/lanes.integration.spec.ts b/apps/agent/test/lanes.integration.spec.ts index a2cde1ce9..9af64580c 100644 --- a/apps/agent/test/lanes.integration.spec.ts +++ b/apps/agent/test/lanes.integration.spec.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { db } from "@crm/db"; +import { agentTask, db, eq, requireRow } from "@crm/db"; import { DIRECT_KINDS, isDirectKind, PRIORITY } from "@crm/db/agent-tasks"; import { claimDue } from "../agent/lib/tasks"; @@ -10,23 +10,25 @@ const VISIBLE = { only: DIRECT_KINDS } as const; const RESEARCH = { except: DIRECT_KINDS } as const; async function clear() { - await db.agentTask.deleteMany({ where: { reason: REASON } }); + await db.delete(agentTask).where(eq(agentTask.reason, REASON)); } beforeEach(clear); afterEach(clear); async function queue(kind: string, priority: number) { - return db.agentTask.create({ - data: { - kind, - reason: REASON, - dueAt: new Date(Date.now() - 1000), - priority: TEST_PRIORITY_OFFSET + priority, - budget: 2, - }, - select: { id: true }, - }); + return requireRow( + db + .insert(agentTask) + .values({ + kind, + reason: REASON, + dueAt: new Date(Date.now() - 1000), + priority: TEST_PRIORITY_OFFSET + priority, + budget: 2, + }) + .returning({ id: agentTask.id }), + ); } describe("dispatch lanes", () => { diff --git a/apps/agent/test/lookup.integration.spec.ts b/apps/agent/test/lookup.integration.spec.ts index c903c5650..c02e6bf10 100644 --- a/apps/agent/test/lookup.integration.spec.ts +++ b/apps/agent/test/lookup.integration.spec.ts @@ -1,5 +1,17 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { DealStage, db } from "@crm/db"; +import { + activity, + company as companyTable, + contact, + DealStage, + Decimal, + db, + deal as dealTable, + inArray, + requireRow, + user as userTable, + whereEq, +} from "@crm/db"; import { listDeals, searchCrm } from "../agent/lib/lookup"; const suffix = process.env.TEST_RUN_ID ?? "lookup-spec"; @@ -17,116 +29,134 @@ let closedDealId: string; beforeAll(async () => { await cleanup(); - const user = await db.user.create({ - data: { - id: `user-${suffix}`, - name: "Rep One", - email: `rep.${suffix}@example.test`, - emailVerified: true, - image: "https://cdn.example.test/rep-one.png", - }, - select: { id: true }, - }); - - const northwind = await db.company.create({ - data: { - name: `Northwind ${suffix}`, - domain, - iconUrl: "https://cdn.example.test/northwind-icon.png", - iconDarkUrl: "https://cdn.example.test/northwind-icon-dark.png", - iconTone: "opaque", - logoUrl: "https://cdn.example.test/northwind-logo.svg", - }, - select: { id: true }, - }); + const user = await requireRow( + db + .insert(userTable) + .values({ + id: `user-${suffix}`, + name: "Rep One", + email: `rep.${suffix}@example.test`, + emailVerified: true, + image: "https://cdn.example.test/rep-one.png", + }) + .returning({ id: userTable.id }), + ); + + const northwind = await requireRow( + db + .insert(companyTable) + .values({ + name: `Northwind ${suffix}`, + domain, + iconUrl: "https://cdn.example.test/northwind-icon.png", + iconDarkUrl: "https://cdn.example.test/northwind-icon-dark.png", + iconTone: "opaque", + logoUrl: "https://cdn.example.test/northwind-logo.svg", + }) + .returning({ id: companyTable.id }), + ); northwindId = northwind.id; - const brightwater = await db.company.create({ - data: { name: `Brightwater ${suffix}`, domain: otherDomain }, - select: { id: true }, - }); + const brightwater = await requireRow( + db + .insert(companyTable) + .values({ name: `Brightwater ${suffix}`, domain: otherDomain }) + .returning({ id: companyTable.id }), + ); brightwaterId = brightwater.id; - const paula = await db.contact.create({ - data: { - firstName: "Paula", - lastName: "Marchetti", - title: "Growth Specialist", - email: `paula.marchetti@${domain}`, - companyId: northwindId, - lastActivityAt: new Date(), - }, - select: { id: true }, - }); + const paula = await requireRow( + db + .insert(contact) + .values({ + firstName: "Paula", + lastName: "Marchetti", + title: "Growth Specialist", + email: `paula.marchetti@${domain}`, + companyId: northwindId, + lastActivityAt: new Date(), + }) + .returning({ id: contact.id }), + ); paulaId = paula.id; - const peter = await db.contact.create({ - data: { - firstName: "Peter", - lastName: "Marchetti", - title: "Controller", - email: `peter.marchetti@${otherDomain}`, - companyId: brightwaterId, - }, - select: { id: true }, - }); + const peter = await requireRow( + db + .insert(contact) + .values({ + firstName: "Peter", + lastName: "Marchetti", + title: "Controller", + email: `peter.marchetti@${otherDomain}`, + companyId: brightwaterId, + }) + .returning({ id: contact.id }), + ); peterId = peter.id; - const deal = await db.deal.create({ - data: { - name: `Northwind renewal ${suffix}`, - companyId: northwindId, - ownerId: user.id, - stage: DealStage.QUALIFIED_TO_BUY, - amount: 12_000, - lastActivityAt: new Date("2026-06-01T12:00:00.000Z"), - }, - select: { id: true }, - }); + const deal = await requireRow( + db + .insert(dealTable) + .values({ + name: `Northwind renewal ${suffix}`, + companyId: northwindId, + ownerId: user.id, + stage: DealStage.QUALIFIED_TO_BUY, + amount: new Decimal(12_000), + lastActivityAt: new Date("2026-06-01T12:00:00.000Z"), + }) + .returning({ id: dealTable.id }), + ); dealId = deal.id; - const freshDeal = await db.deal.create({ - data: { - name: `Fresh expansion ${suffix}`, - companyId: northwindId, - ownerId: user.id, - stage: DealStage.CONTRACT_SENT, - lastActivityAt: new Date("2026-08-04T12:00:00.000Z"), - }, - select: { id: true }, - }); + const freshDeal = await requireRow( + db + .insert(dealTable) + .values({ + name: `Fresh expansion ${suffix}`, + companyId: northwindId, + ownerId: user.id, + stage: DealStage.CONTRACT_SENT, + lastActivityAt: new Date("2026-08-04T12:00:00.000Z"), + }) + .returning({ id: dealTable.id }), + ); freshDealId = freshDeal.id; - const closedDeal = await db.deal.create({ - data: { - name: `Closed renewal ${suffix}`, - companyId: brightwaterId, - ownerId: user.id, - stage: DealStage.CLOSED_LOST, - lastActivityAt: new Date("2026-05-01T12:00:00.000Z"), - }, - select: { id: true }, - }); + const closedDeal = await requireRow( + db + .insert(dealTable) + .values({ + name: `Closed renewal ${suffix}`, + companyId: brightwaterId, + ownerId: user.id, + stage: DealStage.CLOSED_LOST, + lastActivityAt: new Date("2026-05-01T12:00:00.000Z"), + }) + .returning({ id: dealTable.id }), + ); closedDealId = closedDeal.id; }); afterAll(cleanup); async function cleanup(): Promise { - const companies = await db.company.findMany({ - where: { domain: { in: [domain, otherDomain] } }, - select: { id: true }, - }); + const companies = await db + .select({ id: companyTable.id }) + .from(companyTable) + .where(inArray(companyTable.domain, [domain, otherDomain])); const ids = companies.map((company) => company.id); if (ids.length > 0) { - await db.activity.deleteMany({ where: { companyId: { in: ids } } }); - await db.deal.deleteMany({ where: { companyId: { in: ids } } }); - await db.contact.deleteMany({ where: { companyId: { in: ids } } }); - await db.company.deleteMany({ where: { id: { in: ids } } }); + await db.delete(activity).where(inArray(activity.companyId, ids)); + await db.delete(dealTable).where(inArray(dealTable.companyId, ids)); + await db.delete(contact).where(inArray(contact.companyId, ids)); + await db.delete(companyTable).where(inArray(companyTable.id, ids)); } - await db.user.deleteMany({ where: { email: `rep.${suffix}@example.test` } }); + await db + .delete(userTable) + .where(whereEq(userTable.email, `rep.${suffix}@example.test`)); } describe("searchCrm", () => { diff --git a/apps/agent/test/mcp-runtime.integration.spec.ts b/apps/agent/test/mcp-runtime.integration.spec.ts index 6fa53698c..61d5f8f26 100644 --- a/apps/agent/test/mcp-runtime.integration.spec.ts +++ b/apps/agent/test/mcp-runtime.integration.spec.ts @@ -1,5 +1,21 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { db } from "@crm/db"; +import { + agentAction, + agentDefinition, + agentRun, + agentVersion, + and, + db, + eq, + firstRow, + mcpClientRegistration, + mcpConnection, + mcpToolSnapshot, + organization, + requireRow, + user, + whereEq, +} from "@crm/db"; import { WORKSPACE_ID } from "@crm/db/workspace"; import type { ApprovalContext } from "eve/tools"; import { @@ -49,179 +65,208 @@ function context( } beforeAll(async () => { - const workspace = await db.organization.findUnique({ - where: { id: WORKSPACE_ID }, - select: { id: true }, - }); + const workspace = await firstRow( + db + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.id, WORKSPACE_ID)) + .limit(1), + ); if (!workspace) { - await db.organization.create({ - data: { - id: WORKSPACE_ID, - name: "Test Workspace", - slug: "workspace", - createdAt: new Date(), - }, - }); + await requireRow( + db + .insert(organization) + .values({ + id: WORKSPACE_ID, + name: "Test Workspace", + slug: "workspace", + createdAt: new Date(), + }) + .returning(), + ); createdWorkspace = true; } - await db.user.create({ - data: { - id: userId, - name: "MCP Runtime Test", - email: `${userId}@example.test`, - }, - }); - const agent = await db.agentDefinition.create({ - data: { name: "MCP runtime", status: "LIVE", createdById: userId }, - select: { id: true }, - }); + await requireRow( + db + .insert(user) + .values({ + id: userId, + name: "MCP Runtime Test", + email: `${userId}@example.test`, + }) + .returning(), + ); + const agent = await requireRow( + db + .insert(agentDefinition) + .values({ name: "MCP runtime", status: "LIVE", createdById: userId }) + .returning({ id: agentDefinition.id }), + ); agentId = agent.id; - const version = await db.agentVersion.create({ - data: { - agentId, - number: 1, - status: "DEPLOYED", - instructions: "Use approved Linear tools.", - manifest: { - triggers: [ - { - type: "MANUAL", - name: "Manual", - summary: "Run manually", - config: {}, + const version = await requireRow( + db + .insert(agentVersion) + .values({ + agentId, + number: 1, + status: "DEPLOYED", + instructions: "Use approved Linear tools.", + manifest: { + triggers: [ + { + type: "MANUAL", + name: "Manual", + summary: "Run manually", + config: {}, + }, + ], + dataScope: { + mode: "WORKSPACE", + summary: "Use the workspace", + resources: [], }, - ], - dataScope: { - mode: "WORKSPACE", - summary: "Use the workspace", - resources: [], + actions: [ + { + type: "run.summary", + provider: "crm", + summary: "Summarize the run", + }, + ], + mcp: [ + { + platformSlug, + summary: "Use Linear", + tools: [ + { + name: "list_issues", + fingerprint: readFingerprint, + callKind: "read", + summary: "List issues", + }, + { + name: "create_issue", + fingerprint: changeFingerprint, + callKind: "change", + summary: "Create an issue", + }, + { + name: "delete_issue", + fingerprint: riskyFingerprint, + callKind: "risky", + summary: "Delete an issue", + }, + ], + }, + ], }, - actions: [ - { - type: "run.summary", - provider: "crm", - summary: "Summarize the run", - }, - ], - mcp: [ - { - platformSlug, - summary: "Use Linear", - tools: [ - { - name: "list_issues", - fingerprint: readFingerprint, - callKind: "read", - summary: "List issues", - }, - { - name: "create_issue", - fingerprint: changeFingerprint, - callKind: "change", - summary: "Create an issue", - }, - { - name: "delete_issue", - fingerprint: riskyFingerprint, - callKind: "risky", - summary: "Delete an issue", - }, - ], - }, - ], - }, - modelId: "test/model", - sandboxPolicy: {}, - createdById: userId, - approvedAt: new Date(), - deployedAt: new Date(), - }, - select: { id: true }, - }); + modelId: "test/model", + sandboxPolicy: {}, + createdById: userId, + approvedAt: new Date(), + deployedAt: new Date(), + }) + .returning({ id: agentVersion.id }), + ); versionId = version.id; - await db.agentDefinition.update({ - where: { id: agentId }, - data: { currentVersionId: versionId }, - }); - const run = await db.agentRun.create({ - data: { - agentId, - versionId, - triggerType: "MANUAL", - status: "RUNNING", - idempotencyKey: `mcp-runtime-${suffix}`, - correlationId: `mcp-runtime-${suffix}`, - }, - select: { id: true }, - }); + await requireRow( + db + .update(agentDefinition) + .set({ currentVersionId: versionId }) + .where(whereEq(agentDefinition.id, agentId)) + .returning(), + ); + const run = await requireRow( + db + .insert(agentRun) + .values({ + agentId, + versionId, + triggerType: "MANUAL", + status: "RUNNING", + idempotencyKey: `mcp-runtime-${suffix}`, + correlationId: `mcp-runtime-${suffix}`, + }) + .returning({ id: agentRun.id }), + ); runId = run.id; - const registration = await db.mcpClientRegistration.create({ - data: { - platformSlug, - metadataFingerprint: suffix, - issuer: "https://mcp.linear.app", - clientId: `client-${suffix}`, - }, - select: { id: true }, - }); + const registration = await requireRow( + db + .insert(mcpClientRegistration) + .values({ + platformSlug, + metadataFingerprint: suffix, + issuer: "https://mcp.linear.app", + clientId: `client-${suffix}`, + }) + .returning({ id: mcpClientRegistration.id }), + ); registrationId = registration.id; - const connection = await db.mcpConnection.create({ - data: { - organizationId: WORKSPACE_ID, - platformSlug, - status: "CONNECTED", - accessMode: "READ_WRITE", - grantedScopes: ["read", "write"], - registrationId, - connectedById: userId, - toolSnapshots: { - create: [ - { - name: "list_issues", - description: "List issues", - inputSchema: {}, - fingerprint: readFingerprint, - callKind: "read", - reviewState: "REVIEWED", - }, - { - name: "create_issue", - description: "Create an issue", - inputSchema: {}, - fingerprint: changeFingerprint, - callKind: "change", - reviewState: "REVIEWED", - }, - { - name: "delete_issue", - description: "Delete an issue", - inputSchema: {}, - fingerprint: riskyFingerprint, - callKind: "risky", - reviewState: "REVIEWED", - }, - ], + const connection = await requireRow( + db + .insert(mcpConnection) + .values({ + organizationId: WORKSPACE_ID, + platformSlug, + status: "CONNECTED", + accessMode: "READ_WRITE", + grantedScopes: ["read", "write"], + registrationId, + connectedById: userId, + }) + .returning({ id: mcpConnection.id }), + ); + await db.insert(mcpToolSnapshot).values( + [ + { + name: "list_issues", + description: "List issues", + inputSchema: {}, + fingerprint: readFingerprint, + callKind: "read" as const, + reviewState: "REVIEWED" as const, }, - }, - select: { id: true }, - }); + { + name: "create_issue", + description: "Create an issue", + inputSchema: {}, + fingerprint: changeFingerprint, + callKind: "change" as const, + reviewState: "REVIEWED" as const, + }, + { + name: "delete_issue", + description: "Delete an issue", + inputSchema: {}, + fingerprint: riskyFingerprint, + callKind: "risky" as const, + reviewState: "REVIEWED" as const, + }, + ].map((tool) => ({ ...tool, connectionId: connection.id })), + ); connectionId = connection.id; }); afterAll(async () => { - await db.mcpConnection.deleteMany({ where: { platformSlug } }); - await db.mcpClientRegistration.deleteMany({ where: { id: registrationId } }); - await db.agentAction.deleteMany({ where: { runId } }); - await db.agentRun.deleteMany({ where: { id: runId } }); - await db.agentDefinition.update({ - where: { id: agentId }, - data: { currentVersionId: null }, - }); - await db.agentVersion.deleteMany({ where: { id: versionId } }); - await db.agentDefinition.deleteMany({ where: { id: agentId } }); - await db.user.deleteMany({ where: { id: userId } }); + await db + .delete(mcpConnection) + .where(whereEq(mcpConnection.platformSlug, platformSlug)); + await db + .delete(mcpClientRegistration) + .where(whereEq(mcpClientRegistration.id, registrationId)); + await db.delete(agentAction).where(whereEq(agentAction.runId, runId)); + await db.delete(agentRun).where(whereEq(agentRun.id, runId)); + await requireRow( + db + .update(agentDefinition) + .set({ currentVersionId: null }) + .where(whereEq(agentDefinition.id, agentId)) + .returning(), + ); + await db.delete(agentVersion).where(whereEq(agentVersion.id, versionId)); + await db.delete(agentDefinition).where(whereEq(agentDefinition.id, agentId)); + await db.delete(user).where(whereEq(user.id, userId)); if (createdWorkspace) { - await db.organization.deleteMany({ where: { id: WORKSPACE_ID } }); + await db.delete(organization).where(eq(organization.id, WORKSPACE_ID)); } }); @@ -292,9 +337,10 @@ describe("MCP runtime authorization", () => { ), ).toBe("user-approval"); expect( - await db.agentAction.count({ - where: { idempotencyKey: `${runId}:manual-change` }, - }), + await db.$count( + agentAction, + whereEq(agentAction.idempotencyKey, `${runId}:manual-change`), + ), ).toBe(0); }); @@ -306,9 +352,10 @@ describe("MCP runtime authorization", () => { ), ).toBe("approved"); expect( - await db.agentAction.count({ - where: { idempotencyKey: `${runId}:auto-change` }, - }), + await db.$count( + agentAction, + whereEq(agentAction.idempotencyKey, `${runId}:auto-change`), + ), ).toBe(1); }); @@ -343,10 +390,15 @@ describe("MCP runtime authorization", () => { }); it("denies a tool after its reviewed fingerprint changes", async () => { - await db.mcpToolSnapshot.updateMany({ - where: { connectionId, name: "list_issues" }, - data: { reviewState: "CHANGED" }, - }); + await db + .update(mcpToolSnapshot) + .set({ reviewState: "CHANGED" }) + .where( + and( + whereEq(mcpToolSnapshot.connectionId, connectionId), + eq(mcpToolSnapshot.name, "list_issues"), + ), + ); try { expect( await authorizeMcpTool( @@ -358,18 +410,26 @@ describe("MCP runtime authorization", () => { reason: "The MCP tool changed after deployment.", }); } finally { - await db.mcpToolSnapshot.updateMany({ - where: { connectionId, name: "list_issues" }, - data: { reviewState: "REVIEWED" }, - }); + await db + .update(mcpToolSnapshot) + .set({ reviewState: "REVIEWED" }) + .where( + and( + whereEq(mcpToolSnapshot.connectionId, connectionId), + eq(mcpToolSnapshot.name, "list_issues"), + ), + ); } }); it("denies a change through a read-only connection", async () => { - await db.mcpConnection.update({ - where: { id: connectionId }, - data: { accessMode: "READ_ONLY" }, - }); + await requireRow( + db + .update(mcpConnection) + .set({ accessMode: "READ_ONLY" }) + .where(whereEq(mcpConnection.id, connectionId)) + .returning(), + ); try { expect( await authorizeMcpTool( @@ -381,18 +441,24 @@ describe("MCP runtime authorization", () => { reason: "The MCP connection is read-only.", }); } finally { - await db.mcpConnection.update({ - where: { id: connectionId }, - data: { accessMode: "READ_WRITE" }, - }); + await requireRow( + db + .update(mcpConnection) + .set({ accessMode: "READ_WRITE" }) + .where(whereEq(mcpConnection.id, connectionId)) + .returning(), + ); } }); it("denies the next call after disconnect", async () => { - await db.mcpConnection.update({ - where: { id: connectionId }, - data: { status: "DISCONNECTED" }, - }); + await requireRow( + db + .update(mcpConnection) + .set({ status: "DISCONNECTED" }) + .where(whereEq(mcpConnection.id, connectionId)) + .returning(), + ); try { expect( await authorizeMcpTool( @@ -404,10 +470,13 @@ describe("MCP runtime authorization", () => { reason: "The MCP platform is disconnected.", }); } finally { - await db.mcpConnection.update({ - where: { id: connectionId }, - data: { status: "CONNECTED" }, - }); + await requireRow( + db + .update(mcpConnection) + .set({ status: "CONNECTED" }) + .where(whereEq(mcpConnection.id, connectionId)) + .returning(), + ); } }); @@ -422,9 +491,10 @@ describe("MCP runtime authorization", () => { reason: "This MCP change already ran.", }); expect( - await db.agentAction.count({ - where: { idempotencyKey: `${runId}:change-once` }, - }), + await db.$count( + agentAction, + whereEq(agentAction.idempotencyKey, `${runId}:change-once`), + ), ).toBe(1); }); }); diff --git a/apps/agent/test/model.integration.spec.ts b/apps/agent/test/model.integration.spec.ts index 1023cab51..813f99eba 100644 --- a/apps/agent/test/model.integration.spec.ts +++ b/apps/agent/test/model.integration.spec.ts @@ -7,7 +7,14 @@ import { expect, it, } from "bun:test"; -import { db, type Prisma } from "@crm/db"; +import { + appSetting, + db, + eq, + firstRow, + type NewAppSetting, + requireRow, +} from "@crm/db"; import { DEFAULT_AGENT_MODEL, readAgentModel, @@ -17,7 +24,7 @@ import { import { selectedModel } from "../agent/lib/model"; async function clear() { - await db.appSetting.deleteMany({ where: { id: SETTINGS_ID } }); + await db.delete(appSetting).where(eq(appSetting.id, SETTINGS_ID)); } /** @@ -25,17 +32,19 @@ async function clear() { * DATABASE_URL is somebody's working database. Deleting it and not putting it * back sends them through the research-key gate again with nothing saying why. */ -let saved: Prisma.AppSettingUncheckedCreateInput | null = null; +let saved: NewAppSetting | null = null; beforeAll(async () => { - saved = await db.appSetting.findUnique({ where: { id: SETTINGS_ID } }); + saved = await firstRow( + db.select().from(appSetting).where(eq(appSetting.id, SETTINGS_ID)).limit(1), + ); }); beforeEach(clear); afterEach(clear); afterAll(async () => { - if (saved) await db.appSetting.create({ data: saved }); + if (saved) await requireRow(db.insert(appSetting).values(saved).returning()); }); describe("the configured model", () => { @@ -81,7 +90,7 @@ describe("the configured model", () => { await writeAgentModel(db, { id: "openai/gpt-5.5", contextWindowTokens: 1 }); await writeAgentModel(db, { id: "zai/glm-5.2", contextWindowTokens: 2 }); - expect(await db.appSetting.count()).toBe(1); + expect(await db.$count(appSetting)).toBe(1); expect((await readAgentModel(db)).id).toBe("zai/glm-5.2"); }); }); diff --git a/apps/agent/test/preamble.integration.spec.ts b/apps/agent/test/preamble.integration.spec.ts index c47b71aa9..755332acd 100644 --- a/apps/agent/test/preamble.integration.spec.ts +++ b/apps/agent/test/preamble.integration.spec.ts @@ -1,5 +1,18 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { DealStage, db } from "@crm/db"; +import { + activity, + company as companyTable, + contact as contactTable, + DealStage, + Decimal, + db, + dealContact, + deal as dealTable, + firstRow, + requireRow, + user as userTable, + whereEq, +} from "@crm/db"; import { companyPreamble, composeClosing, @@ -24,81 +37,105 @@ const rep = { dispatched: false }; beforeAll(async () => { await cleanup(); - const user = await db.user.create({ - data: { - id: `user-${suffix}`, - name: "Rep One", - email: `rep.${suffix}@example.test`, - emailVerified: true, - }, - select: { id: true }, - }); - - const company = await db.company.create({ - data: { - name: `Fernhill Systems ${suffix}`, - domain, - industry: "Security software", - }, - select: { id: true }, - }); + const user = await requireRow( + db + .insert(userTable) + .values({ + id: `user-${suffix}`, + name: "Rep One", + email: `rep.${suffix}@example.test`, + emailVerified: true, + }) + .returning({ id: userTable.id }), + ); + + const company = await requireRow( + db + .insert(companyTable) + .values({ + name: `Fernhill Systems ${suffix}`, + domain, + industry: "Security software", + }) + .returning({ id: companyTable.id }), + ); companyId = company.id; - const paula = await db.contact.create({ - data: { - firstName: "Paula", - lastName: "Marchetti", - title: "Growth Specialist", - email: `paula.marchetti@${domain}`, - companyId, - lastActivityAt: new Date(), - }, - select: { id: true }, - }); + const paula = await requireRow( + db + .insert(contactTable) + .values({ + firstName: "Paula", + lastName: "Marchetti", + title: "Growth Specialist", + email: `paula.marchetti@${domain}`, + companyId, + lastActivityAt: new Date(), + }) + .returning({ id: contactTable.id }), + ); paulaId = paula.id; - const tomi = await db.contact.create({ - data: { - firstName: "Tomi", - lastName: "Okonkwo", - title: "Head of Security", - email: `tomi.okonkwo@${domain}`, - companyId, - }, - select: { id: true }, - }); + const tomi = await requireRow( + db + .insert(contactTable) + .values({ + firstName: "Tomi", + lastName: "Okonkwo", + title: "Head of Security", + email: `tomi.okonkwo@${domain}`, + companyId, + }) + .returning({ id: contactTable.id }), + ); tomiId = tomi.id; - const deal = await db.deal.create({ - data: { - name: `Fernhill platform ${suffix}`, - companyId, - ownerId: user.id, - stage: DealStage.CONTRACT_SENT, - amount: 48_000, - contacts: { create: [{ contactId: paulaId, role: "Champion" }] }, - }, - select: { id: true }, - }); + const deal = await requireRow( + db + .insert(dealTable) + .values({ + name: `Fernhill platform ${suffix}`, + companyId, + ownerId: user.id, + stage: DealStage.CONTRACT_SENT, + amount: new Decimal(48_000), + }) + .returning({ id: dealTable.id }), + ); + await db + .insert(dealContact) + .values({ dealId: deal.id, contactId: paulaId, role: "Champion" }); dealId = deal.id; }); afterAll(cleanup); async function cleanup(): Promise { - const company = await db.company.findFirst({ - where: { domain }, - select: { id: true }, - }); + const company = await firstRow( + db + .select({ id: companyTable.id }) + .from(companyTable) + .where(whereEq(companyTable.domain, domain)) + .limit(1), + ); if (company) { - await db.activity.deleteMany({ where: { companyId: company.id } }); - await db.deal.deleteMany({ where: { companyId: company.id } }); - await db.contact.deleteMany({ where: { companyId: company.id } }); - await db.company.delete({ where: { id: company.id } }); + await db.delete(activity).where(whereEq(activity.companyId, company.id)); + await db.delete(dealTable).where(whereEq(dealTable.companyId, company.id)); + await db + .delete(contactTable) + .where(whereEq(contactTable.companyId, company.id)); + await requireRow( + db + .delete(companyTable) + .where(whereEq(companyTable.id, company.id)) + .returning(), + ); } - await db.user.deleteMany({ where: { email: `rep.${suffix}@example.test` } }); + await db + .delete(userTable) + .where(whereEq(userTable.email, `rep.${suffix}@example.test`)); } describe("companyPreamble", () => { @@ -142,15 +179,22 @@ describe("contactPreamble", () => { }); it("offers a way out when they have no company", async () => { - const orphan = await db.contact.create({ - data: { firstName: "Nobody", email: `nobody.${suffix}@example.test` }, - select: { id: true }, - }); + const orphan = await requireRow( + db + .insert(contactTable) + .values({ firstName: "Nobody", email: `nobody.${suffix}@example.test` }) + .returning({ id: contactTable.id }), + ); const { markdown } = await contactPreamble(orphan.id, rep); expect(markdown).toContain("`search_crm` will find one"); - await db.contact.delete({ where: { id: orphan.id } }); + await requireRow( + db + .delete(contactTable) + .where(whereEq(contactTable.id, orphan.id)) + .returning(), + ); }); }); diff --git a/apps/agent/test/slack-membership.integration.spec.ts b/apps/agent/test/slack-membership.integration.spec.ts index b5796ad9d..8de5b253d 100644 --- a/apps/agent/test/slack-membership.integration.spec.ts +++ b/apps/agent/test/slack-membership.integration.spec.ts @@ -1,5 +1,18 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { db } from "@crm/db"; +import { + account, + agentTask, + and, + db, + eq, + firstRow, + notInArray, + requireRow, + slackChannel, + slackWorkspaceGrant, + sql, + user, +} from "@crm/db"; import { joinSlackChannel } from "../agent/lib/slack-membership"; const USER_ID = "slack-join-spec-user"; @@ -11,26 +24,36 @@ const INVENTORY_KIND = "slack-people-match"; const realFetch = globalThis.fetch; async function connect() { - await db.user.upsert({ - where: { id: USER_ID }, - create: { - id: USER_ID, - name: "Slack Join Spec", - email: `${USER_ID}@example.com`, - }, - update: {}, - }); - await db.account.upsert({ - where: { id: ACCOUNT_ID }, - create: { - id: ACCOUNT_ID, - accountId: "T-JOIN-SPEC", - providerId: "slack", - userId: USER_ID, - accessToken: "xoxb-join-spec", - }, - update: { accessToken: "xoxb-join-spec" }, - }); + await requireRow( + db + .insert(user) + .values({ + id: USER_ID, + name: "Slack Join Spec", + email: `${USER_ID}@example.com`, + }) + .onConflictDoUpdate({ + target: user.id, + set: { id: sql`excluded.${sql.identifier("id")}` }, + }) + .returning(), + ); + await requireRow( + db + .insert(account) + .values({ + id: ACCOUNT_ID, + accountId: "T-JOIN-SPEC", + providerId: "slack", + userId: USER_ID, + accessToken: "xoxb-join-spec", + }) + .onConflictDoUpdate({ + target: account.id, + set: { ...{ accessToken: "xoxb-join-spec" }, updatedAt: new Date() }, + }) + .returning(), + ); } function answers(error: string) { @@ -57,33 +80,43 @@ let inventoryTaskIds: string[] = []; beforeEach(async () => { requested.length = 0; inventoryTaskIds = ( - await db.agentTask.findMany({ - where: { kind: INVENTORY_KIND }, - select: { id: true }, - }) + await db + .select({ id: agentTask.id }) + .from(agentTask) + .where(eq(agentTask.kind, INVENTORY_KIND)) ).map((task) => task.id); - await db.slackChannel.deleteMany({ where: { id: CHANNEL_ID } }); + await db.delete(slackChannel).where(eq(slackChannel.id, CHANNEL_ID)); await connect(); - await db.slackChannel.create({ - data: { - id: CHANNEL_ID, - name: "join-spec", - isPrivate: false, - isMember: false, - available: true, - }, - }); + await requireRow( + db + .insert(slackChannel) + .values({ + id: CHANNEL_ID, + name: "join-spec", + isPrivate: false, + isMember: false, + available: true, + }) + .returning(), + ); }); afterEach(async () => { globalThis.fetch = realFetch; - await db.slackChannel.deleteMany({ where: { id: CHANNEL_ID } }); - await db.slackWorkspaceGrant.deleteMany({ where: { id: GRANT_ID } }); - await db.agentTask.deleteMany({ - where: { kind: INVENTORY_KIND, id: { notIn: inventoryTaskIds } }, - }); - await db.account.deleteMany({ where: { id: ACCOUNT_ID } }); - await db.user.deleteMany({ where: { id: USER_ID } }); + await db.delete(slackChannel).where(eq(slackChannel.id, CHANNEL_ID)); + await db + .delete(slackWorkspaceGrant) + .where(eq(slackWorkspaceGrant.id, GRANT_ID)); + await db + .delete(agentTask) + .where( + and( + eq(agentTask.kind, INVENTORY_KIND), + notInArray(agentTask.id, inventoryTaskIds), + ), + ); + await db.delete(account).where(eq(account.id, ACCOUNT_ID)); + await db.delete(user).where(eq(user.id, USER_ID)); }); describe("joining a Slack channel", () => { @@ -95,10 +128,13 @@ describe("joining a Slack channel", () => { expect(outcome.joined).toBe(false); expect(outcome).toMatchObject({ needsHuman: true }); - const row = await db.slackChannel.findUnique({ - where: { id: CHANNEL_ID }, - select: { isMember: true }, - }); + const row = await firstRow( + db + .select({ isMember: slackChannel.isMember }) + .from(slackChannel) + .where(eq(slackChannel.id, CHANNEL_ID)) + .limit(1), + ); expect(row?.isMember).toBe(false); }); @@ -116,27 +152,40 @@ describe("joining a Slack channel", () => { false, ); expect( - await db.slackChannel.findUnique({ - where: { id: CHANNEL_ID }, - select: { isPrivate: true, isMember: true }, - }), + await firstRow( + db + .select({ + isPrivate: slackChannel.isPrivate, + isMember: slackChannel.isMember, + }) + .from(slackChannel) + .where(eq(slackChannel.id, CHANNEL_ID)) + .limit(1), + ), ).toEqual({ isPrivate: true, isMember: true }); expect( - await db.agentTask.count({ - where: { kind: INVENTORY_KIND, id: { notIn: inventoryTaskIds } }, - }), + await db.$count( + agentTask, + and( + eq(agentTask.kind, INVENTORY_KIND), + notInArray(agentTask.id, inventoryTaskIds), + ), + ), ).toBe(1); }); it("invites itself to a private channel a stale row calls public", async () => { - await db.slackWorkspaceGrant.create({ - data: { - id: GRANT_ID, - teamId: "T-JOIN-SPEC", - userToken: "xoxp-join-spec", - userScopes: "groups:write", - }, - }); + await requireRow( + db + .insert(slackWorkspaceGrant) + .values({ + id: GRANT_ID, + teamId: "T-JOIN-SPEC", + userToken: "xoxp-join-spec", + userScopes: "groups:write", + }) + .returning(), + ); replies((url) => { if (url.includes("conversations.info")) { return { ok: false, error: "channel_not_found" }; @@ -155,10 +204,16 @@ describe("joining a Slack channel", () => { false, ); expect( - await db.slackChannel.findUnique({ - where: { id: CHANNEL_ID }, - select: { isPrivate: true, isMember: true }, - }), + await firstRow( + db + .select({ + isPrivate: slackChannel.isPrivate, + isMember: slackChannel.isMember, + }) + .from(slackChannel) + .where(eq(slackChannel.id, CHANNEL_ID)) + .limit(1), + ), ).toEqual({ isPrivate: true, isMember: true }); }); @@ -169,10 +224,13 @@ describe("joining a Slack channel", () => { expect(outcome).toEqual({ joined: true, already: true }); - const row = await db.slackChannel.findUnique({ - where: { id: CHANNEL_ID }, - select: { isMember: true }, - }); + const row = await firstRow( + db + .select({ isMember: slackChannel.isMember }) + .from(slackChannel) + .where(eq(slackChannel.id, CHANNEL_ID)) + .limit(1), + ); expect(row?.isMember).toBe(true); }); }); diff --git a/apps/agent/test/slack-people.integration.spec.ts b/apps/agent/test/slack-people.integration.spec.ts index 91fc768d7..26311a90d 100644 --- a/apps/agent/test/slack-people.integration.spec.ts +++ b/apps/agent/test/slack-people.integration.spec.ts @@ -1,5 +1,17 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { db } from "@crm/db"; +import { + account, + asc, + db, + eq, + firstRow, + requireRow, + slackChannel, + sql, + startsWith, + user, + whereEq, +} from "@crm/db"; import { persistSlackChannels, refreshSlackChannels, @@ -14,51 +26,61 @@ const realFetch = globalThis.fetch; let restore: Array<{ id: string; available: boolean }> = []; async function connect() { - await db.user.upsert({ - where: { id: USER_ID }, - create: { - id: USER_ID, - name: "Slack Spec", - email: `${USER_ID}@example.com`, - }, - update: {}, - }); - await db.account.upsert({ - where: { id: ACCOUNT_ID }, - create: { - id: ACCOUNT_ID, - accountId: "T-SPEC", - providerId: "slack", - userId: USER_ID, - accessToken: "xoxb-spec", - }, - update: { accessToken: "xoxb-spec" }, - }); + await requireRow( + db + .insert(user) + .values({ + id: USER_ID, + name: "Slack Spec", + email: `${USER_ID}@example.com`, + }) + .onConflictDoUpdate({ + target: user.id, + set: { id: sql`excluded.${sql.identifier("id")}` }, + }) + .returning(), + ); + await requireRow( + db + .insert(account) + .values({ + id: ACCOUNT_ID, + accountId: "T-SPEC", + providerId: "slack", + userId: USER_ID, + accessToken: "xoxb-spec", + }) + .onConflictDoUpdate({ + target: account.id, + set: { ...{ accessToken: "xoxb-spec" }, updatedAt: new Date() }, + }) + .returning(), + ); } async function disconnect() { - await db.account.deleteMany({ where: { providerId: "slack" } }); - await db.slackChannel.deleteMany({ where: { id: { startsWith: PREFIX } } }); + await db.delete(account).where(eq(account.providerId, "slack")); + await db.delete(slackChannel).where(startsWith(slackChannel.id, PREFIX)); } beforeEach(async () => { - restore = await db.slackChannel.findMany({ - select: { id: true, available: true }, - }); - await db.slackChannel.deleteMany({ where: { id: { startsWith: PREFIX } } }); + restore = await db + .select({ id: slackChannel.id, available: slackChannel.available }) + .from(slackChannel); + await db.delete(slackChannel).where(startsWith(slackChannel.id, PREFIX)); await connect(); }); afterEach(async () => { globalThis.fetch = realFetch; - await db.slackChannel.deleteMany({ where: { id: { startsWith: PREFIX } } }); - await db.account.deleteMany({ where: { id: ACCOUNT_ID } }); - await db.user.deleteMany({ where: { id: USER_ID } }); + await db.delete(slackChannel).where(startsWith(slackChannel.id, PREFIX)); + await db.delete(account).where(eq(account.id, ACCOUNT_ID)); + await db.delete(user).where(eq(user.id, USER_ID)); for (const row of restore) { - await db.slackChannel.updateMany({ - where: { id: row.id }, - data: { available: row.available }, - }); + await db + .update(slackChannel) + .set({ available: row.available }) + .where(whereEq(slackChannel.id, row.id)); } }); @@ -85,17 +107,23 @@ function slackReply(body: SlackListReply): Response { describe("persistSlackChannels", () => { it("creates, updates and retires the inventory in one pass", async () => { - await db.slackChannel.create({ - data: { - id: `${PREFIX}-keep`, - name: "old-name", - memberCount: 1, - available: false, - }, - }); - await db.slackChannel.create({ - data: { id: `${PREFIX}-gone`, name: "gone", available: true }, - }); + await requireRow( + db + .insert(slackChannel) + .values({ + id: `${PREFIX}-keep`, + name: "old-name", + memberCount: 1, + available: false, + }) + .returning(), + ); + await requireRow( + db + .insert(slackChannel) + .values({ id: `${PREFIX}-gone`, name: "gone", available: true }) + .returning(), + ); const written = await persistSlackChannels( [ @@ -120,18 +148,18 @@ describe("persistSlackChannels", () => { expect(written).toBe(3); - const rows = await db.slackChannel.findMany({ - where: { id: { startsWith: PREFIX } }, - orderBy: { id: "asc" }, - select: { - id: true, - name: true, - memberCount: true, - isPrivate: true, - isMember: true, - available: true, - }, - }); + const rows = await db + .select({ + id: slackChannel.id, + name: slackChannel.name, + memberCount: slackChannel.memberCount, + isPrivate: slackChannel.isPrivate, + isMember: slackChannel.isMember, + available: slackChannel.available, + }) + .from(slackChannel) + .where(startsWith(slackChannel.id, PREFIX)) + .orderBy(asc(slackChannel.id)); expect(rows).toEqual([ { @@ -170,16 +198,22 @@ describe("persistSlackChannels", () => { }); it("retires every channel when nothing is available", async () => { - await db.slackChannel.create({ - data: { id: `${PREFIX}-only`, name: "only", available: true }, - }); + await requireRow( + db + .insert(slackChannel) + .values({ id: `${PREFIX}-only`, name: "only", available: true }) + .returning(), + ); expect(await persistSlackChannels([], false)).toBe(0); - const row = await db.slackChannel.findUnique({ - where: { id: `${PREFIX}-only` }, - select: { available: true }, - }); + const row = await firstRow( + db + .select({ available: slackChannel.available }) + .from(slackChannel) + .where(whereEq(slackChannel.id, `${PREFIX}-only`)) + .limit(1), + ); expect(row?.available).toBe(false); }); @@ -193,7 +227,10 @@ describe("persistSlackChannels", () => { expect(written).toBe(0); expect( - await db.slackChannel.count({ where: { id: `${PREFIX}-ghost` } }), + await db.$count( + slackChannel, + whereEq(slackChannel.id, `${PREFIX}-ghost`), + ), ).toBe(0); }); }); @@ -250,7 +287,7 @@ describe("refreshSlackChannels", () => { expect(await refreshSlackChannels()).toBe(0); expect( - await db.slackChannel.count({ where: { id: { startsWith: PREFIX } } }), + await db.$count(slackChannel, startsWith(slackChannel.id, PREFIX)), ).toBe(0); }); diff --git a/apps/agent/test/stale-tasks.integration.spec.ts b/apps/agent/test/stale-tasks.integration.spec.ts index adc78db18..a897eb26b 100644 --- a/apps/agent/test/stale-tasks.integration.spec.ts +++ b/apps/agent/test/stale-tasks.integration.spec.ts @@ -1,5 +1,16 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { db, EnrichmentStatus } from "@crm/db"; +import { + agentTask, + company, + contact as contactTable, + db, + EnrichmentStatus, + eq, + firstRow, + requireRow, + startsWith, + whereEq, +} from "@crm/db"; import { MAX_ATTEMPTS, RETIRED_OUTCOME } from "@crm/db/agent-tasks"; import { reconcileStaleTasks } from "../agent/lib/stale-tasks"; @@ -10,35 +21,39 @@ const REASON = "stale-spec"; const MINUTE_MS = 60_000; async function clear() { - await db.agentTask.deleteMany({ where: { reason: REASON } }); - await db.contact.deleteMany({ where: { email: { startsWith: "stale-" } } }); - await db.company.deleteMany({ where: { name: { startsWith: "Stale Co " } } }); + await db.delete(agentTask).where(eq(agentTask.reason, REASON)); + await db.delete(contactTable).where(startsWith(contactTable.email, "stale-")); + await db.delete(company).where(startsWith(company.name, "Stale Co ")); } beforeEach(clear); afterEach(clear); async function someone(status: EnrichmentStatus, enrichedAt?: Date) { - return db.contact.create({ - data: { - firstName: "Stale", - email: `stale-${crypto.randomUUID()}@example.test`, - enrichmentStatus: status, - enrichedAt: enrichedAt ?? null, - }, - select: { id: true }, - }); + return requireRow( + db + .insert(contactTable) + .values({ + firstName: "Stale", + email: `stale-${crypto.randomUUID()}@example.test`, + enrichmentStatus: status, + enrichedAt: enrichedAt ?? null, + }) + .returning({ id: contactTable.id }), + ); } async function anAccount(status: EnrichmentStatus, enrichedAt?: Date) { - return db.company.create({ - data: { - name: `Stale Co ${crypto.randomUUID()}`, - enrichmentStatus: status, - enrichedAt: enrichedAt ?? null, - }, - select: { id: true }, - }); + return requireRow( + db + .insert(company) + .values({ + name: `Stale Co ${crypto.randomUUID()}`, + enrichmentStatus: status, + enrichedAt: enrichedAt ?? null, + }) + .returning({ id: company.id }), + ); } async function queue(overrides: { @@ -50,28 +65,37 @@ async function queue(overrides: { startedAt?: Date | null; leasedUntil?: Date | null; }) { - return db.agentTask.create({ - data: { - kind: overrides.kind ?? (overrides.companyId ? "brand" : kind), - reason: REASON, - dueAt: overrides.dueAt ?? new Date(Date.now() - MINUTE_MS), - priority: 0, - budget: 4, - contactId: overrides.contactId ?? null, - companyId: overrides.companyId ?? null, - attempts: overrides.attempts ?? 0, - startedAt: overrides.startedAt ?? null, - leasedUntil: overrides.leasedUntil ?? null, - }, - select: { id: true }, - }); + return requireRow( + db + .insert(agentTask) + .values({ + kind: overrides.kind ?? (overrides.companyId ? "brand" : kind), + reason: REASON, + dueAt: overrides.dueAt ?? new Date(Date.now() - MINUTE_MS), + priority: 0, + budget: 4, + contactId: overrides.contactId ?? null, + companyId: overrides.companyId ?? null, + attempts: overrides.attempts ?? 0, + startedAt: overrides.startedAt ?? null, + leasedUntil: overrides.leasedUntil ?? null, + }) + .returning({ id: agentTask.id }), + ); } async function row(id: string) { - return db.agentTask.findUnique({ - where: { id }, - select: { finishedAt: true, outcome: true, leasedUntil: true }, - }); + return firstRow( + db + .select({ + finishedAt: agentTask.finishedAt, + outcome: agentTask.outcome, + leasedUntil: agentTask.leasedUntil, + }) + .from(agentTask) + .where(whereEq(agentTask.id, id)) + .limit(1), + ); } describe("a task whose lease ran out", () => { @@ -90,10 +114,13 @@ describe("a task whose lease ran out", () => { expect(closed?.finishedAt).not.toBeNull(); expect(closed?.outcome).toBe(RETIRED_OUTCOME); - const settled = await db.contact.findUnique({ - where: { id: contact.id }, - select: { enrichmentStatus: true }, - }); + const settled = await firstRow( + db + .select({ enrichmentStatus: contactTable.enrichmentStatus }) + .from(contactTable) + .where(whereEq(contactTable.id, contact.id)) + .limit(1), + ); expect(settled?.enrichmentStatus).toBe(EnrichmentStatus.FAILED); }); @@ -240,21 +267,33 @@ describe("a task whose record is already done", () => { leasedUntil: new Date(Date.now() - MINUTE_MS), }); - const findMany = db.agentTask.findMany; - db.agentTask.findMany = async (...args) => { - const rows = await findMany.apply(db.agentTask, args); - await db.agentTask.update({ - where: { id: task.id }, - data: { leasedUntil: new Date(Date.now() + 10 * MINUTE_MS) }, - }); - - return rows; - }; + const select = db.select; + let renewed = false; + db.select = ((fields: Parameters[0]) => { + const builder = select.call(db, fields); + const from = builder.from.bind(builder); + builder.from = ((table: Parameters[0]) => { + const query = from(table); + if (table !== agentTask || renewed) return query; + renewed = true; + const execute = query.execute.bind(query); + query.execute = (async (...args: Parameters) => { + const rows = await execute(...args); + await db + .update(agentTask) + .set({ leasedUntil: new Date(Date.now() + 10 * MINUTE_MS) }) + .where(eq(agentTask.id, task.id)); + return rows; + }) as typeof query.execute; + return query; + }) as typeof builder.from; + return builder; + }) as typeof db.select; try { await reconcileStaleTasks(); } finally { - db.agentTask.findMany = findMany; + db.select = select; } expect((await row(task.id))?.finishedAt).toBeNull(); @@ -336,17 +375,17 @@ describe("running it twice", () => { }); it("reports a database failure instead of throwing", async () => { - const findMany = db.agentTask.findMany; - db.agentTask.findMany = () => { + const count = db.$count; + db.$count = (() => { throw new Error("the database is unreachable"); - }; + }) as typeof db.$count; try { const sweep = await reconcileStaleTasks(); expect(sweep.error).toBe("the database is unreachable"); expect(sweep.scanned).toBe(0); } finally { - db.agentTask.findMany = findMany; + db.$count = count; } expect((await reconcileStaleTasks()).error).toBeNull(); @@ -360,16 +399,16 @@ describe("running it twice", () => { leasedUntil: new Date(Date.now() - MINUTE_MS), }); - const updateMany = db.agentTask.updateMany; - db.agentTask.updateMany = () => { + const update = db.update; + db.update = (() => { throw new Error("the write failed"); - }; + }) as typeof db.update; let sweep: Awaited>; try { sweep = await reconcileStaleTasks(); } finally { - db.agentTask.updateMany = updateMany; + db.update = update; } expect(sweep.error).toBe("the write failed"); diff --git a/apps/agent/test/tasks.integration.spec.ts b/apps/agent/test/tasks.integration.spec.ts index e255cf7e0..376c6efc8 100644 --- a/apps/agent/test/tasks.integration.spec.ts +++ b/apps/agent/test/tasks.integration.spec.ts @@ -1,5 +1,16 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { db } from "@crm/db"; +import { + agentTask, + and, + contact as contactTable, + db, + firstRow, + inArray, + isNull, + requireRow, + startsWith, + whereEq, +} from "@crm/db"; import { MAX_ATTEMPTS } from "@crm/db/agent-tasks"; import { claimDue, @@ -13,8 +24,8 @@ const kind = "test-lease"; const TEST_TASKS = { only: [kind] } as const; async function clear() { - await db.agentTask.deleteMany({ where: { kind } }); - await db.contact.deleteMany({ where: { email: { startsWith: "lease-" } } }); + await db.delete(agentTask).where(whereEq(agentTask.kind, kind)); + await db.delete(contactTable).where(startsWith(contactTable.email, "lease-")); } beforeEach(clear); @@ -23,34 +34,41 @@ afterEach(clear); async function queue( overrides: { priority?: number; dueAt?: Date; contactId?: string } = {}, ) { - return db.agentTask.create({ - data: { - kind, - reason: "test", - dueAt: overrides.dueAt ?? new Date(Date.now() - 1000), - priority: overrides.priority ?? 0, - budget: 4, - contactId: overrides.contactId ?? null, - }, - select: { id: true }, - }); + return requireRow( + db + .insert(agentTask) + .values({ + kind, + reason: "test", + dueAt: overrides.dueAt ?? new Date(Date.now() - 1000), + priority: overrides.priority ?? 0, + budget: 4, + contactId: overrides.contactId ?? null, + }) + .returning({ id: agentTask.id }), + ); } async function expire(taskId: string) { - await db.agentTask.update({ - where: { id: taskId }, - data: { leasedUntil: new Date(Date.now() - 1000) }, - }); + await requireRow( + db + .update(agentTask) + .set({ leasedUntil: new Date(Date.now() - 1000) }) + .where(whereEq(agentTask.id, taskId)) + .returning(), + ); } async function someone() { - return db.contact.create({ - data: { - firstName: "Lease", - email: `lease-${crypto.randomUUID()}@example.test`, - }, - select: { id: true }, - }); + return requireRow( + db + .insert(contactTable) + .values({ + firstName: "Lease", + email: `lease-${crypto.randomUUID()}@example.test`, + }) + .returning({ id: contactTable.id }), + ); } describe("claimDue", () => { @@ -60,7 +78,13 @@ describe("claimDue", () => { const claimed = await claimDue(10, TEST_TASKS); expect(claimed.map((t) => t.id)).toContain(task.id); - const row = await db.agentTask.findUnique({ where: { id: task.id } }); + const row = await firstRow( + db + .select() + .from(agentTask) + .where(whereEq(agentTask.id, task.id)) + .limit(1), + ); expect(row?.leasedUntil).not.toBeNull(); expect(row?.startedAt).not.toBeNull(); }); @@ -99,10 +123,13 @@ describe("claimDue", () => { expect(await claimDue(10, TEST_TASKS)).toHaveLength(0); - await db.agentTask.update({ - where: { id: task.id }, - data: { leasedUntil: new Date(Date.now() - 1000) }, - }); + await requireRow( + db + .update(agentTask) + .set({ leasedUntil: new Date(Date.now() - 1000) }) + .where(whereEq(agentTask.id, task.id)) + .returning(), + ); expect((await claimDue(10, TEST_TASKS)).map((t) => t.id)).toContain( task.id, @@ -135,10 +162,13 @@ describe("claimDue", () => { await claimDue(10, TEST_TASKS); await completeTask(task.id, "ran"); - await db.agentTask.update({ - where: { id: task.id }, - data: { leasedUntil: null }, - }); + await requireRow( + db + .update(agentTask) + .set({ leasedUntil: null }) + .where(whereEq(agentTask.id, task.id)) + .returning(), + ); expect(await claimDue(10, TEST_TASKS)).toHaveLength(0); }); @@ -158,7 +188,13 @@ describe("retireExhausted", () => { expect(retired.map((t) => t.id)).toContain(task.id); expect(retired.find((t) => t.id === task.id)?.contactId).toBe(contact.id); - const row = await db.agentTask.findUnique({ where: { id: task.id } }); + const row = await firstRow( + db + .select() + .from(agentTask) + .where(whereEq(agentTask.id, task.id)) + .limit(1), + ); expect(row?.finishedAt).not.toBeNull(); expect(row?.outcome).toContain("Gave up"); }); @@ -194,9 +230,10 @@ describe("retireExhausted", () => { expect((await retireExhausted(2)).length).toBeLessThanOrEqual(2); } - const open = await db.agentTask.count({ - where: { id: { in: mine }, finishedAt: null }, - }); + const open = await db.$count( + agentTask, + and(inArray(agentTask.id, mine), isNull(agentTask.finishedAt)), + ); expect(open).toBe(0); }); }); @@ -211,7 +248,13 @@ describe("completeTask", () => { expect(subject?.contactId).toBe(contact.id); expect(await completeTask(task.id, "ran again")).toBeNull(); - const row = await db.agentTask.findUnique({ where: { id: task.id } }); + const row = await firstRow( + db + .select() + .from(agentTask) + .where(whereEq(agentTask.id, task.id)) + .limit(1), + ); expect(row?.outcome).toBe("ran"); }); }); @@ -225,7 +268,9 @@ describe("scheduleTask", () => { dueAt, }); - const row = await db.agentTask.findUnique({ where: { id } }); + const row = await firstRow( + db.select().from(agentTask).where(whereEq(agentTask.id, id)).limit(1), + ); expect(row?.reason).toContain("Acme"); }); @@ -237,6 +282,6 @@ describe("scheduleTask", () => { const second = await scheduleTask({ kind, reason: "second", dueAt: later }); expect(second.id).toBe(first.id); - expect(await db.agentTask.count({ where: { kind } })).toBe(1); + expect(await db.$count(agentTask, whereEq(agentTask.kind, kind))).toBe(1); }); }); diff --git a/apps/api/scripts/build-func.mjs b/apps/api/scripts/build-func.mjs index b06f0c017..fee0f9453 100644 --- a/apps/api/scripts/build-func.mjs +++ b/apps/api/scripts/build-func.mjs @@ -204,46 +204,35 @@ if (!process.env.VERCEL) { const dbDir = join(repoRoot, "packages/db"); const dbEnv = { ...process.env, DATABASE_URL: directDatabaseUrl }; - console.log("• applying migrations (prisma migrate deploy)..."); - execSync(`${bun} x prisma migrate deploy`, { + console.log("• applying migrations (drizzle)..."); + execSync(`${bun} scripts/migrate.ts`, { cwd: dbDir, stdio: "inherit", env: dbEnv, }); console.log("✓ migrations applied"); - console.log("• checking the deployed schema against schema.prisma..."); - const drift = spawnSync( - bun, - [ - "x", - "prisma", - "migrate", - "diff", - "--from-config-datasource", - "--to-schema", - join("prisma", "schema.prisma"), - "--exit-code", - ], - { cwd: dbDir, encoding: "utf8", env: dbEnv }, - ); + console.log("• checking src/schema/schema.ts against the migrations..."); + const drift = spawnSync(bun, ["scripts/check-drift.ts"], { + cwd: dbDir, + encoding: "utf8", + env: dbEnv, + }); if (drift.status === 0) { console.log("✓ schema matches"); - } else if (drift.status === 2) { + } else if (drift.status === 1) { console.log(""); - console.log("!! THE PRODUCTION SCHEMA DOES NOT MATCH schema.prisma !!"); console.log( - " Every migration is recorded as applied, so `migrate deploy` will keep reporting", + "!! src/schema/schema.ts HAS CHANGES THAT NO MIGRATION COVERS !!", ); console.log( - " nothing pending while queries fail on columns that are not there. Reconcile with", + " The deployed database has every migration, but queries read columns that no", ); console.log( - " `prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script`.", + " migration created. Run `bun run db:migrate` locally and commit the new migration.", ); console.log(""); - console.log(drift.stdout || ""); } else { console.log( `• could not compare the schema (${drift.stderr?.trim() || "unknown error"})`, diff --git a/apps/api/scripts/dev-session.ts b/apps/api/scripts/dev-session.ts index 4bfa0fe79..ce068523a 100644 --- a/apps/api/scripts/dev-session.ts +++ b/apps/api/scripts/dev-session.ts @@ -1,6 +1,6 @@ import { AUTH_COOKIE_PREFIX } from "@crm/auth/cookies"; import { ensureWorkspaceMembership } from "@crm/auth/organization"; -import { db } from "@crm/db"; +import { db, eq, session, user as userTable } from "@crm/db"; import { DEV_SESSION } from "./dev-session-config"; import { createDevSessionResponse, @@ -43,17 +43,22 @@ async function signCookieValue(value: string): Promise { return encodeURIComponent(`${value}.${base64}`); } -const user = await db.user.upsert({ - where: { email }, - create: { +await db + .insert(userTable) + .values({ id: `dev-${Buffer.from(email).toString("hex").slice(0, 20)}`, email, name, emailVerified: true, updatedAt: new Date(), - }, - update: {}, -}); + }) + .onConflictDoNothing({ target: userTable.email }); + +const [user] = await db + .select({ id: userTable.id }) + .from(userTable) + .where(eq(userTable.email, email)); +if (!user) throw new Error(`Could not find or create ${email}.`); const workspaceId = await ensureWorkspaceMembership(user.id); if (!workspaceId) { @@ -65,18 +70,20 @@ const expiresAt = new Date( Date.now() + DEV_SESSION.cookie.days * 24 * 60 * 60 * 1000, ); -await db.session.upsert({ - where: { token }, - create: { +await db + .insert(session) + .values({ id: token, token, userId: user.id, expiresAt, updatedAt: new Date(), activeOrganizationId: workspaceId, - }, - update: { expiresAt, activeOrganizationId: workspaceId }, -}); + }) + .onConflictDoUpdate({ + target: session.token, + set: { expiresAt, activeOrganizationId: workspaceId }, + }); const cookieValue = await signCookieValue(token); const loginPath = `/login/${crypto.randomUUID()}`; @@ -117,4 +124,4 @@ await new Promise((resolve) => { console.log("The link expires in 10 minutes."); }); -await db.$disconnect(); +await db.$client.end(); diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index 3b9a67a68..223634f07 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -1,4 +1,29 @@ -import { ActivityType, type Db, Prisma } from "@crm/db"; +import { + ActivityType, + activity, + and, + calendarAttendee, + calendarEvent, + company, + contact, + contains, + count, + type Db, + deal, + desc, + eq, + gte, + inArray, + isNotNull, + isNull, + lt, + not, + or, + relatedCount, + type SQL, + sql, + user, +} from "@crm/db"; import { activityMeta } from "@crm/validation/activity-meta"; import { BadRequestException, @@ -15,6 +40,8 @@ import { type OrderByColumns, paginate, resolveOrderBy, + sortBy, + sortNullsLast, } from "../trpc/list-input"; import type { ActivityCreateInput, @@ -36,44 +63,53 @@ import { taskWindowFilter, } from "./task-due-date"; -const AUTHOR_SELECT = { +const AUTHOR_COLUMNS = { id: true, name: true, email: true, image: true, } as const; -const ENTRY_SELECT = { - id: true, - type: true, - subject: true, - body: true, - occurredAt: true, - dueAt: true, - completedAt: true, - meta: true, - createdAt: true, - createdBy: { select: AUTHOR_SELECT }, - company: { select: { id: true, name: true } }, - contact: { select: { id: true, firstName: true, lastName: true } }, - deal: { select: { id: true, name: true } }, - - emailThread: { - select: { - id: true, - messageCount: true, - lastMessageAt: true, - }, +const ENTRY_QUERY = { + columns: { + id: true, + type: true, + subject: true, + body: true, + occurredAt: true, + dueAt: true, + completedAt: true, + meta: true, + createdAt: true, }, - calendarEvent: { - select: { - id: true, - startsAt: true, - endsAt: true, - isAllDay: true, - location: true, - conferenceUrl: true, - _count: { select: { attendees: true } }, + with: { + createdBy: { columns: AUTHOR_COLUMNS }, + company: { columns: { id: true, name: true } }, + contact: { columns: { id: true, firstName: true, lastName: true } }, + deal: { columns: { id: true, name: true } }, + emailThread: { + columns: { + id: true, + messageCount: true, + lastMessageAt: true, + }, + }, + calendarEvent: { + columns: { + id: true, + startsAt: true, + endsAt: true, + isAllDay: true, + location: true, + conferenceUrl: true, + }, + extras: (event: { id: typeof calendarEvent.id }) => ({ + attendeeCount: relatedCount( + calendarAttendee, + calendarAttendee.eventId, + event.id, + ).as("attendeeCount"), + }), }, }, } as const; @@ -85,54 +121,53 @@ const NOTE_TYPES = [ ActivityType.MEETING, ]; -const TASK_SELECT = { - id: true, - subject: true, - dueAt: true, - completedAt: true, - createdAt: true, - createdBy: { select: AUTHOR_SELECT }, +const TASK_FIELDS = { + id: activity.id, + subject: activity.subject, + dueAt: activity.dueAt, + completedAt: activity.completedAt, + createdAt: activity.createdAt, + createdBy: { + id: user.id, + name: user.name, + email: user.email, + image: user.image, + }, company: { - select: { - id: true, - name: true, - domain: true, - iconUrl: true, - iconDarkUrl: true, - iconTone: true, - logoUrl: true, - }, + id: company.id, + name: company.name, + domain: company.domain, + iconUrl: company.iconUrl, + iconDarkUrl: company.iconDarkUrl, + iconTone: company.iconTone, + logoUrl: company.logoUrl, }, - contact: { select: { id: true, firstName: true, lastName: true } }, - deal: { select: { id: true, name: true } }, -} as const; - -const TASK_SORTABLE: OrderByColumns = - { - subject: (dir) => [{ subject: { sort: dir, nulls: "last" } }], - dueAt: (dir) => [ - { dueAt: { sort: dir, nulls: "last" } }, - { createdAt: "desc" }, - ], - status: (dir) => [ - { - completedAt: { - sort: dir, - nulls: dir === "asc" ? "first" : "last", - }, - }, - { dueAt: { sort: "asc", nulls: "last" } }, - ], - company: (dir) => [ - { company: { name: dir } }, - { dueAt: { sort: "asc", nulls: "last" } }, - ], - createdBy: (dir) => [ - { createdBy: { name: dir } }, - { dueAt: { sort: "asc", nulls: "last" } }, - ], - createdAt: (dir) => [{ createdAt: dir }], - }; + contact: { + id: contact.id, + firstName: contact.firstName, + lastName: contact.lastName, + }, + deal: { id: deal.id, name: deal.name }, +}; + +const dueFirst = sql`${activity.dueAt} asc nulls last`; + +const TASK_SORTABLE: OrderByColumns = { + subject: (dir) => [sortNullsLast(activity.subject, dir)], + dueAt: (dir) => [ + sortNullsLast(activity.dueAt, dir), + desc(activity.createdAt), + ], + status: (dir) => [ + dir === "asc" + ? sql`${activity.completedAt} asc nulls first` + : sql`${activity.completedAt} desc nulls last`, + dueFirst, + ], + company: (dir) => [sortBy(company.name, dir), dueFirst], + createdBy: (dir) => [sortBy(user.name, dir), dueFirst], + createdAt: (dir) => [sortBy(activity.createdAt, dir)], +}; const taskAggregateRow = z.object({ open: z.coerce.number().int().nonnegative(), @@ -154,19 +189,17 @@ export class ActivitiesService { ) {} async timeline(input: TimelineInput): Promise { - const where = this.anchor(input); - Object.assign(where, filterClause(input.filter)); + const where = and( + this.anchor(input), + filterClause(input.filter), + input.cursor ? await this.after(input.cursor) : undefined, + ); - const rows = await this.db.activity.findMany({ + const rows = await this.db.query.activity.findMany({ + ...ENTRY_QUERY, where, - take: input.limit + 1, - cursor: input.cursor ? { id: input.cursor } : undefined, - skip: input.cursor ? 1 : undefined, - orderBy: [ - { occurredAt: { sort: "desc", nulls: "last" } }, - { id: "desc" }, - ], - select: ENTRY_SELECT, + limit: input.limit + 1, + orderBy: [sql`${activity.occurredAt} desc nulls last`, desc(activity.id)], }); const hasMore = rows.length > input.limit; @@ -184,20 +217,12 @@ export class ActivitiesService { const anchor = this.anchor(input); const [all, notes, upcoming, done, email, meetings] = await Promise.all([ - this.db.activity.count({ where: anchor }), - this.db.activity.count({ - where: { ...anchor, ...filterClause("notes") }, - }), - this.db.activity.count({ - where: { ...anchor, ...filterClause("upcoming") }, - }), - this.db.activity.count({ where: { ...anchor, ...filterClause("done") } }), - this.db.activity.count({ - where: { ...anchor, ...filterClause("email") }, - }), - this.db.activity.count({ - where: { ...anchor, ...filterClause("meetings") }, - }), + this.db.$count(activity, anchor), + this.db.$count(activity, and(anchor, filterClause("notes"))), + this.db.$count(activity, and(anchor, filterClause("upcoming"))), + this.db.$count(activity, and(anchor, filterClause("done"))), + this.db.$count(activity, and(anchor, filterClause("email"))), + this.db.$count(activity, and(anchor, filterClause("meetings"))), ]); return { all, notes, upcoming, done, email, meetings }; @@ -211,8 +236,9 @@ export class ActivitiesService { const isTask = input.type === ActivityType.TASK; - const activity = await this.db.activity.create({ - data: { + const [inserted] = await this.db + .insert(activity) + .values({ type: input.type, subject: blankToNull(input.subject ?? ""), body: blankToNull(input.body ?? ""), @@ -222,46 +248,46 @@ export class ActivitiesService { contactId: input.contactId ?? null, dealId: input.dealId ?? null, createdById: actingUserId, - }, - select: ENTRY_SELECT, - }); + }) + .returning({ id: activity.id }); + if (!inserted) throw new Error("The activity insert returned no row."); + + const entry = await this.entry(inserted.id); await this.stamp.touch( { companyId, contactId: input.contactId, dealId: input.dealId }, - activity.createdAt, + entry.createdAt, ); this.logger.log({ message: "Activity logged", - activityId: activity.id, - type: activity.type, + activityId: entry.id, + type: entry.type, }); - return serializeEntry(activity); + return serializeEntry(entry); } async update(input: ActivityUpdateInput): Promise { await this.requireTask(input.id, "Only tasks can be edited."); - const updated = await this.db.activity.update({ - where: { id: input.id }, - data: { subject: input.subject, dueAt: parseTaskDueDay(input.dueAt) }, - select: ENTRY_SELECT, - }); + await this.db + .update(activity) + .set({ subject: input.subject, dueAt: parseTaskDueDay(input.dueAt) }) + .where(eq(activity.id, input.id)); - return serializeEntry(updated); + return serializeEntry(await this.entry(input.id)); } async complete(id: string, completed: boolean): Promise { await this.requireTask(id, "Only tasks can be completed."); - const updated = await this.db.activity.update({ - where: { id }, - data: { completedAt: completed ? new Date() : null }, - select: ENTRY_SELECT, - }); + await this.db + .update(activity) + .set({ completedAt: completed ? new Date() : null }) + .where(eq(activity.id, id)); - return serializeEntry(updated); + return serializeEntry(await this.entry(id)); } async myTasks( @@ -269,23 +295,19 @@ export class ActivitiesService { actingUserId: string, ): Promise { const now = new Date(); - const where: Prisma.ActivityWhereInput = { - type: ActivityType.TASK, - completedAt: null, - createdById: actingUserId, - }; - - if (input.window === "overdue") where.dueAt = { lt: now }; - if (input.window === "upcoming") where.dueAt = { gte: now }; + const where = and( + eq(activity.type, ActivityType.TASK), + isNull(activity.completedAt), + eq(activity.createdById, actingUserId), + input.window === "overdue" ? lt(activity.dueAt, now) : undefined, + input.window === "upcoming" ? gte(activity.dueAt, now) : undefined, + ); - const tasks = await this.db.activity.findMany({ + const tasks = await this.db.query.activity.findMany({ + ...ENTRY_QUERY, where, - take: input.limit, - orderBy: [ - { dueAt: { sort: "asc", nulls: "last" } }, - { createdAt: "desc" }, - ], - select: ENTRY_SELECT, + limit: input.limit, + orderBy: [dueFirst, desc(activity.createdAt)], }); return tasks.map(serializeEntry); @@ -297,17 +319,17 @@ export class ActivitiesService { const { skip, take } = paginate(input); const [rows, total, facetCounts] = await Promise.all([ - this.db.activity.findMany({ - where, - skip, - take, - orderBy: resolveOrderBy(input, TASK_SORTABLE, [ - { dueAt: { sort: "asc", nulls: "last" } }, - { createdAt: "desc" }, - ]), - select: TASK_SELECT, - }), - this.db.activity.count({ where }), + selectTasks(this.db) + .where(where) + .orderBy( + ...resolveOrderBy(input, TASK_SORTABLE, [ + dueFirst, + desc(activity.createdAt), + ]), + ) + .limit(take) + .offset(skip), + this.db.$count(activity, where), this.taskFacetCounts(input.q, now), ]); @@ -322,48 +344,60 @@ export class ActivitiesService { }; } - private taskWhere( - input: TaskListInput, - now: Date, - ): Prisma.ActivityWhereInput { - const where = this.taskBaseWhere(input.q); - - if (input.status === "open") where.completedAt = null; - if (input.status === "done") where.completedAt = { not: null }; - if (input.createdBy.length > 0) { - where.createdById = { in: input.createdBy }; - } - if (input.due.length > 0) { - where.AND = [ - { OR: input.due.map((window) => taskWindowFilter(window, now)) }, - ]; - } - - return where; + private taskWhere(input: TaskListInput, now: Date): SQL | undefined { + return and( + this.taskBaseWhere(input.q), + input.status === "open" ? isNull(activity.completedAt) : undefined, + input.status === "done" ? isNotNull(activity.completedAt) : undefined, + input.createdBy.length > 0 + ? inArray(activity.createdById, input.createdBy) + : undefined, + input.due.length > 0 + ? or(...input.due.map((window) => taskWindowFilter(window, now))) + : undefined, + ); } - private taskBaseWhere(q: string): Prisma.ActivityWhereInput { + private taskBaseWhere(q: string): SQL | undefined { const term = q.trim(); - const where: Prisma.ActivityWhereInput = { type: ActivityType.TASK }; - if (!term) return where; - - where.OR = [ - { subject: { contains: term, mode: "insensitive" } }, - { body: { contains: term, mode: "insensitive" } }, - { company: { name: { contains: term, mode: "insensitive" } } }, - { deal: { name: { contains: term, mode: "insensitive" } } }, - { contact: { firstName: { contains: term, mode: "insensitive" } } }, - { contact: { lastName: { contains: term, mode: "insensitive" } } }, - ]; - - return where; + const isTask = eq(activity.type, ActivityType.TASK); + if (!term) return isTask; + + const matches = (column: Parameters[0]) => + contains(column, term, { insensitive: true }); + + return and( + isTask, + or( + matches(activity.subject), + matches(activity.body), + inArray( + activity.companyId, + this.db + .select({ id: company.id }) + .from(company) + .where(matches(company.name)), + ), + inArray( + activity.dealId, + this.db.select({ id: deal.id }).from(deal).where(matches(deal.name)), + ), + inArray( + activity.contactId, + this.db + .select({ id: contact.id }) + .from(contact) + .where(or(matches(contact.firstName), matches(contact.lastName))), + ), + ), + ); } private async taskFacetCounts(q: string, now: Date) { const where = this.taskBaseWhere(q); const term = q.trim(); const search = term - ? Prisma.sql`AND ( + ? sql`AND ( strpos(lower(COALESCE(activity."subject", '')), lower(${term})) > 0 OR strpos(lower(COALESCE(activity."body", '')), lower(${term})) > 0 OR strpos(lower(COALESCE(company."name", '')), lower(${term})) > 0 @@ -371,17 +405,17 @@ export class ActivitiesService { OR strpos(lower(COALESCE(contact."firstName", '')), lower(${term})) > 0 OR strpos(lower(COALESCE(contact."lastName", '')), lower(${term})) > 0 )` - : Prisma.empty; + : sql``; const { startOfToday, startOfTomorrow, startOfNextWeek } = taskDayBoundaries(now); const [authors, aggregateRows] = await Promise.all([ - this.db.activity.groupBy({ - by: ["createdById"], - where, - _count: { _all: true }, - }), - this.db.$queryRaw` + this.db + .select({ createdById: activity.createdById, count: count() }) + .from(activity) + .where(where) + .groupBy(activity.createdById), + this.db.execute(sql` SELECT COUNT(*) FILTER (WHERE activity."completedAt" IS NULL) AS "open", COUNT(*) FILTER (WHERE activity."completedAt" IS NOT NULL) AS "done", @@ -396,11 +430,11 @@ export class ActivitiesService { LEFT JOIN "deal" deal ON deal."id" = activity."dealId" WHERE activity."type" = ${ActivityType.TASK}::"ActivityType" ${search} - `, + `), ]); const aggregate = taskAggregateRow.parse( - z.array(z.unknown()).length(1).parse(aggregateRows)[0], + z.array(z.unknown()).length(1).parse(aggregateRows.rows)[0], ); return { @@ -417,26 +451,55 @@ export class ActivitiesService { } private async requireTask(id: string, wrongType: string) { - const activity = await this.db.activity.findUnique({ - where: { id }, - select: { type: true }, - }); + const [row] = await this.db + .select({ type: activity.type }) + .from(activity) + .where(eq(activity.id, id)) + .limit(1); - if (!activity) { + if (!row) { throw new NotFoundException(`No activity with id ${id}.`); } - if (activity.type !== ActivityType.TASK) { + if (row.type !== ActivityType.TASK) { throw new BadRequestException(wrongType); } } + private async entry(id: string): Promise { + const row = await findEntry(this.db, eq(activity.id, id)); + if (!row) throw new NotFoundException(`No activity with id ${id}.`); + return row; + } + + private async after(cursorId: string): Promise { + const [cursor] = await this.db + .select({ id: activity.id, occurredAt: activity.occurredAt }) + .from(activity) + .where(eq(activity.id, cursorId)) + .limit(1); + if (!cursor) return sql`false`; + + if (cursor.occurredAt === null) { + return and(isNull(activity.occurredAt), lt(activity.id, cursor.id)); + } + + return or( + lt(activity.occurredAt, cursor.occurredAt), + and( + eq(activity.occurredAt, cursor.occurredAt), + lt(activity.id, cursor.id), + ), + isNull(activity.occurredAt), + ); + } + private anchor( input: Pick, - ): Prisma.ActivityWhereInput { - if (input.dealId) return { dealId: input.dealId }; - if (input.contactId) return { contactId: input.contactId }; - if (input.companyId) return { companyId: input.companyId }; + ): SQL { + if (input.dealId) return eq(activity.dealId, input.dealId); + if (input.contactId) return eq(activity.contactId, input.contactId); + if (input.companyId) return eq(activity.companyId, input.companyId); throw new BadRequestException( "A timeline needs a company, a contact or a deal.", ); @@ -448,53 +511,81 @@ export class ActivitiesService { if (input.companyId) return input.companyId; if (input.dealId) { - const deal = await this.db.deal.findUnique({ - where: { id: input.dealId }, - select: { companyId: true }, - }); - if (!deal) { + const [row] = await this.db + .select({ companyId: deal.companyId }) + .from(deal) + .where(eq(deal.id, input.dealId)) + .limit(1); + if (!row) { throw new NotFoundException(`No deal with id ${input.dealId}.`); } - return deal.companyId; + return row.companyId; } if (input.contactId) { - const contact = await this.db.contact.findUnique({ - where: { id: input.contactId }, - select: { companyId: true }, - }); - if (!contact) { + const [row] = await this.db + .select({ companyId: contact.companyId }) + .from(contact) + .where(eq(contact.id, input.contactId)) + .limit(1); + if (!row) { throw new NotFoundException(`No contact with id ${input.contactId}.`); } - return contact.companyId; + return row.companyId; } return null; } } -function filterClause(filter: TimelineFilter): Prisma.ActivityWhereInput { +function filterClause(filter: TimelineFilter): SQL | undefined { switch (filter) { case "notes": - return { type: { in: NOTE_TYPES } }; + return inArray(activity.type, NOTE_TYPES); case "upcoming": - return { type: ActivityType.TASK, completedAt: null }; + return and( + eq(activity.type, ActivityType.TASK), + isNull(activity.completedAt), + ); case "done": - return { type: ActivityType.TASK, completedAt: { not: null } }; + return and( + eq(activity.type, ActivityType.TASK), + isNotNull(activity.completedAt), + ); case "history": - return { NOT: { type: ActivityType.TASK, completedAt: null } }; + return not( + and( + eq(activity.type, ActivityType.TASK), + isNull(activity.completedAt), + ) as SQL, + ); case "email": - return { type: ActivityType.EMAIL }; + return eq(activity.type, ActivityType.EMAIL); case "meetings": - return { type: ActivityType.MEETING }; + return eq(activity.type, ActivityType.MEETING); case "all": - return {}; + return undefined; } } -type Entry = Prisma.ActivityGetPayload<{ select: typeof ENTRY_SELECT }>; +function findEntry(db: Db, where: SQL) { + return db.query.activity.findFirst({ ...ENTRY_QUERY, where }); +} + +function selectTasks(db: Db) { + return db + .select(TASK_FIELDS) + .from(activity) + .innerJoin(user, eq(user.id, activity.createdById)) + .leftJoin(company, eq(company.id, activity.companyId)) + .leftJoin(contact, eq(contact.id, activity.contactId)) + .leftJoin(deal, eq(deal.id, activity.dealId)) + .$dynamic(); +} + +type Entry = NonNullable>>; -type Task = Prisma.ActivityGetPayload<{ select: typeof TASK_SELECT }>; +type Task = Awaited>[number]; function serializeTask(task: Task) { return { @@ -530,7 +621,7 @@ function serializeEntry(entry: Entry) { isAllDay: entry.calendarEvent.isAllDay, location: entry.calendarEvent.location, conferenceUrl: entry.calendarEvent.conferenceUrl, - attendeeCount: entry.calendarEvent._count.attendees, + attendeeCount: entry.calendarEvent.attendeeCount, } : null, }; diff --git a/apps/api/src/activities/task-due-date.ts b/apps/api/src/activities/task-due-date.ts index 84aaa198c..5380f826f 100644 --- a/apps/api/src/activities/task-due-date.ts +++ b/apps/api/src/activities/task-due-date.ts @@ -1,4 +1,4 @@ -import type { Prisma } from "@crm/db"; +import { activity, and, gte, isNull, lt, type SQL } from "@crm/db"; import { BadRequestException } from "@nestjs/common"; import { z } from "zod"; import { TASKS } from "./activities-config"; @@ -65,20 +65,26 @@ export function serializeTaskDueDay(value: Date | null): string | null { export function taskWindowFilter( window: TaskWindow, reference: Date, -): Prisma.ActivityWhereInput { +): SQL | undefined { const { startOfToday, startOfTomorrow, startOfNextWeek } = taskDayBoundaries(reference); switch (window) { case "overdue": - return { dueAt: { lt: startOfToday } }; + return lt(activity.dueAt, startOfToday); case "today": - return { dueAt: { gte: startOfToday, lt: startOfTomorrow } }; + return and( + gte(activity.dueAt, startOfToday), + lt(activity.dueAt, startOfTomorrow), + ); case "week": - return { dueAt: { gte: startOfTomorrow, lt: startOfNextWeek } }; + return and( + gte(activity.dueAt, startOfTomorrow), + lt(activity.dueAt, startOfNextWeek), + ); case "later": - return { dueAt: { gte: startOfNextWeek } }; + return gte(activity.dueAt, startOfNextWeek); case "none": - return { dueAt: null }; + return isNull(activity.dueAt); } } diff --git a/apps/api/src/agent/agent-access.service.ts b/apps/api/src/agent/agent-access.service.ts index 6d07f2ce2..0a215e3f1 100644 --- a/apps/api/src/agent/agent-access.service.ts +++ b/apps/api/src/agent/agent-access.service.ts @@ -5,7 +5,15 @@ import { type WorkspaceRole, workspaceRoleOf, } from "@crm/auth/organization"; -import type { Db, Prisma } from "@crm/db"; +import { + agentDefinition, + and, + type Db, + type Executor, + eq, + member, + ne, +} from "@crm/db"; import { ForbiddenException, Injectable, @@ -29,33 +37,39 @@ export class AgentAccessService { } async assertCanManageInTransaction( - tx: Prisma.TransactionClient, + tx: Executor, agentId: string, userId: string, ) { - const [member] = await tx.$queryRaw>` - SELECT role - FROM "member" - WHERE "organizationId" = ${WORKSPACE_ID} - AND "userId" = ${userId} - FOR SHARE - `; + const [membership] = await tx + .select({ role: member.role }) + .from(member) + .where( + and(eq(member.organizationId, WORKSPACE_ID), eq(member.userId, userId)), + ) + .for("share"); - if (!member) { + if (!membership) { throw new ForbiddenException("You are not a member of this workspace."); } - const role = toWorkspaceRole(member.role); - const agent = await tx.agentDefinition.findFirst({ - where: { id: agentId, status: { not: "DELETED" } }, - select: { - id: true, - createdById: true, - status: true, - name: true, - description: true, - }, - }); + const role = toWorkspaceRole(membership.role); + const [agent] = await tx + .select({ + id: agentDefinition.id, + createdById: agentDefinition.createdById, + status: agentDefinition.status, + name: agentDefinition.name, + description: agentDefinition.description, + }) + .from(agentDefinition) + .where( + and( + eq(agentDefinition.id, agentId), + ne(agentDefinition.status, "DELETED"), + ), + ) + .limit(1); if (!agent) { throw new NotFoundException(`No agent with id ${agentId}.`); @@ -76,15 +90,21 @@ export class AgentAccessService { async assertCanRead(agentId: string, userId: string) { const role = await this.assertMember(userId); - const agent = await this.db.agentDefinition.findFirst({ - where: { id: agentId, status: { not: "DELETED" } }, - select: { - id: true, - createdById: true, - status: true, - currentVersionId: true, - }, - }); + const [agent] = await this.db + .select({ + id: agentDefinition.id, + createdById: agentDefinition.createdById, + status: agentDefinition.status, + currentVersionId: agentDefinition.currentVersionId, + }) + .from(agentDefinition) + .where( + and( + eq(agentDefinition.id, agentId), + ne(agentDefinition.status, "DELETED"), + ), + ) + .limit(1); if (!agent || !canReadAgent(agent.status, agent.createdById, userId)) { throw new NotFoundException(`No agent with id ${agentId}.`); diff --git a/apps/api/src/agent/agent-definitions.service.ts b/apps/api/src/agent/agent-definitions.service.ts index 0faf435dd..801393f65 100644 --- a/apps/api/src/agent/agent-definitions.service.ts +++ b/apps/api/src/agent/agent-definitions.service.ts @@ -1,4 +1,24 @@ -import type { Db, Prisma } from "@crm/db"; +import { + agentAuditEvent, + agentBuilderArtifact, + agentDefinition, + agentRun, + agentRunEvent, + agentTrigger, + agentVersion, + and, + asc, + type Db, + desc, + type Executor, + eq, + inArray, + type JsonValue, + type NewAgentDefinition, + ne, + relatedCount, + sql, +} from "@crm/db"; import type { AgentDefinitionStatus } from "@crm/db/enums"; import { schemas } from "@crm/validation"; import { readAgentManifestSummary } from "@crm/validation/agent-manifest"; @@ -45,27 +65,29 @@ export class AgentDefinitionsService { async list(userId: string) { await this.access.assertMember(userId); - const rows = await this.db.agentDefinition.findMany({ - where: { status: { in: [...TEAM_AGENT_STATUSES] } }, - orderBy: { updatedAt: "desc" }, - select: { + const rows = await this.db.query.agentDefinition.findMany({ + where: inArray(agentDefinition.status, [...TEAM_AGENT_STATUSES]), + orderBy: [desc(agentDefinition.updatedAt)], + columns: { id: true, name: true, description: true, status: true, createdAt: true, updatedAt: true, - createdBy: { select: { id: true, name: true, image: true } }, + }, + extras: runCountExtra, + with: { + createdBy: { columns: { id: true, name: true, image: true } }, currentVersion: { - select: { id: true, number: true, deployedAt: true }, + columns: { id: true, number: true, deployedAt: true }, }, triggers: { - where: { enabled: true }, - orderBy: { nextRunAt: "asc" }, - take: 1, - select: { id: true, type: true, name: true, nextRunAt: true }, + where: eq(agentTrigger.enabled, true), + orderBy: [asc(agentTrigger.nextRunAt)], + limit: 1, + columns: { id: true, type: true, name: true, nextRunAt: true }, }, - _count: { select: { runs: true } }, }, }); @@ -83,16 +105,19 @@ export class AgentDefinitionsService { ...trigger, nextRunAt: trigger.nextRunAt?.toISOString() ?? null, })), - runCount: row._count.runs, + runCount: row.runCount, })); } async byId(id: string, userId: string) { await this.access.assertCanRead(id, userId); - const row = await this.db.agentDefinition.findFirst({ - where: { id, status: { not: "DELETED" } }, - select: { + const row = await this.db.query.agentDefinition.findFirst({ + where: and( + eq(agentDefinition.id, id), + ne(agentDefinition.status, "DELETED"), + ), + columns: { id: true, name: true, description: true, @@ -100,9 +125,12 @@ export class AgentDefinitionsService { createdById: true, createdAt: true, updatedAt: true, - createdBy: { select: { id: true, name: true, image: true } }, + }, + extras: runCountExtra, + with: { + createdBy: { columns: { id: true, name: true, image: true } }, currentVersion: { - select: { + columns: { id: true, number: true, status: true, @@ -114,10 +142,10 @@ export class AgentDefinitionsService { }, }, versions: { - where: { status: { in: ["DRAFT", "READY"] } }, - orderBy: { number: "desc" }, - take: 1, - select: { + where: inArray(agentVersion.status, ["DRAFT", "READY"]), + orderBy: [desc(agentVersion.number)], + limit: 1, + columns: { id: true, number: true, status: true, @@ -128,8 +156,8 @@ export class AgentDefinitionsService { }, }, triggers: { - orderBy: { createdAt: "asc" }, - select: { + orderBy: [asc(agentTrigger.createdAt)], + columns: { id: true, type: true, name: true, @@ -139,7 +167,6 @@ export class AgentDefinitionsService { lastRunAt: true, }, }, - _count: { select: { runs: true } }, }, }); @@ -168,7 +195,7 @@ export class AgentDefinitionsService { nextRunAt: trigger.nextRunAt?.toISOString() ?? null, lastRunAt: trigger.lastRunAt?.toISOString() ?? null, })), - runCount: agent._count.runs, + runCount: agent.runCount, capabilities: readCapabilities( agent.currentVersion?.manifest ?? draft?.manifest, ), @@ -178,26 +205,30 @@ export class AgentDefinitionsService { async update(input: AgentUpdateInput, userId: string) { const description = input.description?.trim() || null; - const updated = await this.db.$transaction(async (tx) => { + const updated = await this.db.transaction(async (tx) => { await this.access.assertCanManageInTransaction(tx, input.id, userId); const agent = await this.lockAgent(tx, input.id); - const row = await tx.agentDefinition.update({ - where: { id: input.id }, - data: { name: input.name, description }, - select: { id: true, name: true, description: true, status: true }, - }); - - await tx.agentAuditEvent.create({ - data: { - agentId: input.id, - actorUserId: userId, - actorType: "USER", - actorId: userId, - type: "agent.updated", - summary: "Changed agent details", - before: { name: agent.name, description: agent.description }, - after: { name: input.name, description }, - }, + const [row] = await tx + .update(agentDefinition) + .set({ name: input.name, description }) + .where(eq(agentDefinition.id, input.id)) + .returning({ + id: agentDefinition.id, + name: agentDefinition.name, + description: agentDefinition.description, + status: agentDefinition.status, + }); + if (!row) throw new NotFoundException(`No agent with id ${input.id}.`); + + await tx.insert(agentAuditEvent).values({ + agentId: input.id, + actorUserId: userId, + actorType: "USER", + actorId: userId, + type: "agent.updated", + summary: "Changed agent details", + before: { name: agent.name, description: agent.description }, + after: { name: input.name, description }, }); return row; @@ -209,23 +240,29 @@ export class AgentDefinitionsService { async files(id: string, userId: string) { await this.access.assertCanRead(id, userId); - const agent = await this.db.agentDefinition.findFirst({ - where: { id, status: { not: "DELETED" } }, - select: { currentVersionId: true }, - }); + const [agent] = await this.db + .select({ currentVersionId: agentDefinition.currentVersionId }) + .from(agentDefinition) + .where( + and(eq(agentDefinition.id, id), ne(agentDefinition.status, "DELETED")), + ) + .limit(1); if (!agent?.currentVersionId) return { versionId: null, files: [] }; - const rows = await this.db.agentBuilderArtifact.findMany({ - where: { versionId: agent.currentVersionId }, - orderBy: [{ path: "asc" }, { revision: "desc" }], - select: { - path: true, - language: true, - content: true, - previousContent: true, - revision: true, - }, - }); + const rows = await this.db + .select({ + path: agentBuilderArtifact.path, + language: agentBuilderArtifact.language, + content: agentBuilderArtifact.content, + previousContent: agentBuilderArtifact.previousContent, + revision: agentBuilderArtifact.revision, + }) + .from(agentBuilderArtifact) + .where(eq(agentBuilderArtifact.versionId, agent.currentVersionId)) + .orderBy( + asc(agentBuilderArtifact.path), + desc(agentBuilderArtifact.revision), + ); const latest = new Map(); for (const row of rows) @@ -238,105 +275,98 @@ export class AgentDefinitionsService { } async saveFile(input: AgentSaveFileInput, userId: string) { - return this.db.$transaction(async (tx) => { + return this.db.transaction(async (tx) => { await this.access.assertCanManageInTransaction(tx, input.id, userId); const agent = await this.lockAgent(tx, input.id); - const replay = await tx.agentAuditEvent.findFirst({ - where: { - agentId: input.id, - type: "agent.file.saved", - requestId: input.clientRequestId, - }, - select: { versionId: true }, - }); + const replay = await auditReplay( + tx, + input.id, + "agent.file.saved", + input.clientRequestId, + ); if (replay) return { saved: false, versionId: replay.versionId }; if (!agent.currentVersionId) { throw new BadRequestException("This agent has no deployed version."); } - const current = await tx.agentVersion.findFirstOrThrow({ - where: { id: agent.currentVersionId }, - select: { - status: true, - instructions: true, - manifest: true, - modelId: true, - modelContextWindowTokens: true, - sandboxPolicy: true, - validation: true, - sourceConversationId: true, - deployedAt: true, - approvedAt: true, - }, - }); + const current = await currentVersion(tx, agent.currentVersionId); - const file = await tx.agentBuilderArtifact.findFirst({ - where: { versionId: agent.currentVersionId, path: input.path }, - orderBy: { revision: "desc" }, - }); + const [file] = await tx + .select() + .from(agentBuilderArtifact) + .where( + and( + eq(agentBuilderArtifact.versionId, agent.currentVersionId), + eq(agentBuilderArtifact.path, input.path), + ), + ) + .orderBy(desc(agentBuilderArtifact.revision)) + .limit(1); if (!file) throw new NotFoundException(`No file at ${input.path}.`); if (file.content === input.content) { return { saved: false, versionId: agent.currentVersionId }; } - const version = await tx.agentVersion.create({ - data: { - agentId: input.id, - number: await nextVersionNumber(tx, input.id), - status: current.status, - instructions: - input.path === INSTRUCTIONS_PATH - ? input.content - : current.instructions, - manifest: current.manifest as Prisma.InputJsonValue, - modelId: current.modelId, - modelContextWindowTokens: current.modelContextWindowTokens, - sandboxPolicy: current.sandboxPolicy as Prisma.InputJsonValue, - validation: current.validation as Prisma.InputJsonValue, - sourceConversationId: current.sourceConversationId, - approvedAt: current.approvedAt, - deployedAt: current.deployedAt, - createdById: userId, - }, - select: { id: true }, + const version = await createVersion(tx, { + agentId: input.id, + number: await nextVersionNumber(tx, input.id), + status: current.status, + instructions: + input.path === INSTRUCTIONS_PATH + ? input.content + : current.instructions, + manifest: current.manifest, + modelId: current.modelId, + modelContextWindowTokens: current.modelContextWindowTokens, + sandboxPolicy: current.sandboxPolicy, + validation: current.validation, + sourceConversationId: current.sourceConversationId, + approvedAt: current.approvedAt, + deployedAt: current.deployedAt, + createdById: userId, }); await carryArtifactsForward(tx, agent.currentVersionId, version.id); - await tx.agentBuilderArtifact.updateMany({ - where: { versionId: version.id, path: input.path }, - data: { previousContent: file.content, content: input.content }, - }); + await tx + .update(agentBuilderArtifact) + .set({ previousContent: file.content, content: input.content }) + .where( + and( + eq(agentBuilderArtifact.versionId, version.id), + eq(agentBuilderArtifact.path, input.path), + ), + ); - await tx.agentDefinition.update({ - where: { id: input.id }, - data: { currentVersionId: version.id }, - }); + await tx + .update(agentDefinition) + .set({ currentVersionId: version.id }) + .where(eq(agentDefinition.id, input.id)); - const repointed = await tx.agentTrigger.updateMany({ - where: { agentId: input.id, versionId: agent.currentVersionId }, - data: { versionId: version.id }, - }); + const repointed = await repointTriggers( + tx, + input.id, + agent.currentVersionId, + version.id, + ); - await tx.agentAuditEvent.create({ - data: { - agentId: input.id, - versionId: version.id, - actorUserId: userId, - actorType: "USER", - actorId: userId, - type: "agent.file.saved", - summary: `Edited ${input.path}`, - before: { path: input.path, bytes: file.content.length }, - after: { - path: input.path, - bytes: input.content.length, - triggers: repointed.count, - }, - requestId: input.clientRequestId, + await tx.insert(agentAuditEvent).values({ + agentId: input.id, + versionId: version.id, + actorUserId: userId, + actorType: "USER", + actorId: userId, + type: "agent.file.saved", + summary: `Edited ${input.path}`, + before: { path: input.path, bytes: file.content.length }, + after: { + path: input.path, + bytes: input.content.length, + triggers: repointed, }, + requestId: input.clientRequestId, }); return { saved: true, versionId: version.id }; @@ -348,35 +378,19 @@ export class AgentDefinitionsService { await this.access.assertCanManageInTransaction(tx, input.id, userId); const agent = await this.lockAgent(tx, input.id); - const replay = await tx.agentAuditEvent.findFirst({ - where: { - agentId: input.id, - type: "agent.revised", - requestId: input.clientRequestId, - }, - select: { versionId: true }, - }); + const replay = await auditReplay( + tx, + input.id, + "agent.revised", + input.clientRequestId, + ); if (replay) return replay.versionId; if (!agent.currentVersionId) { throw new BadRequestException("This agent has no deployed version."); } - const current = await tx.agentVersion.findFirstOrThrow({ - where: { id: agent.currentVersionId }, - select: { - status: true, - instructions: true, - manifest: true, - modelId: true, - modelContextWindowTokens: true, - sandboxPolicy: true, - validation: true, - sourceConversationId: true, - deployedAt: true, - approvedAt: true, - }, - }); + const current = await currentVersion(tx, agent.currentVersionId); const parsed = agentManifest.safeParse(current.manifest); if (!parsed.success) { @@ -431,71 +445,68 @@ export class AgentDefinitionsService { } : manifest.dataScope; - const version = await tx.agentVersion.create({ - data: { - agentId: input.id, - number: await nextVersionNumber(tx, input.id), - status: current.status, - instructions: current.instructions, - manifest: { - ...manifest, - actions, - dataScope, - } as Prisma.InputJsonValue, - modelId: current.modelId, - modelContextWindowTokens: current.modelContextWindowTokens, - sandboxPolicy: current.sandboxPolicy as Prisma.InputJsonValue, - validation: reviseValidation( - current.validation, - manifest.actions.map((action) => action.type), - actions.map((action) => action.type), - ), - sourceConversationId: current.sourceConversationId, - approvedAt: current.approvedAt, - deployedAt: current.deployedAt, - createdById: userId, - }, - select: { id: true }, + const version = await createVersion(tx, { + agentId: input.id, + number: await nextVersionNumber(tx, input.id), + status: current.status, + instructions: current.instructions, + manifest: { + ...manifest, + actions, + dataScope, + } as JsonValue, + modelId: current.modelId, + modelContextWindowTokens: current.modelContextWindowTokens, + sandboxPolicy: current.sandboxPolicy, + validation: reviseValidation( + current.validation, + manifest.actions.map((action) => action.type), + actions.map((action) => action.type), + ), + sourceConversationId: current.sourceConversationId, + approvedAt: current.approvedAt, + deployedAt: current.deployedAt, + createdById: userId, }); await carryArtifactsForward(tx, agent.currentVersionId, version.id); - await tx.agentDefinition.update({ - where: { id: input.id }, - data: { currentVersionId: version.id }, - }); + await tx + .update(agentDefinition) + .set({ currentVersionId: version.id }) + .where(eq(agentDefinition.id, input.id)); - const repointed = await tx.agentTrigger.updateMany({ - where: { agentId: input.id, versionId: agent.currentVersionId }, - data: { versionId: version.id }, - }); + const repointed = await repointTriggers( + tx, + input.id, + agent.currentVersionId, + version.id, + ); if (channel && channel.id !== before?.destination?.id) { await queue.slackChannelJoinRequested(channel.id, channel.name); } - await tx.agentAuditEvent.create({ - data: { - agentId: input.id, - versionId: version.id, - actorUserId: userId, - actorType: "USER", - actorId: userId, - type: "agent.revised", - summary: reviseSummary(input), - before: { - channel: before?.destination?.label ?? null, - actions: manifest.actions.map((action) => action.type), - resources: manifest.dataScope.resources.length, - }, - after: { - channel: channel ? `#${channel.name}` : null, - actions: input.actions ?? null, - resources: input.resources?.length ?? null, - triggers: repointed.count, - }, - requestId: input.clientRequestId, + await tx.insert(agentAuditEvent).values({ + agentId: input.id, + versionId: version.id, + actorUserId: userId, + actorType: "USER", + actorId: userId, + type: "agent.revised", + summary: reviseSummary(input), + before: { + channel: before?.destination?.label ?? null, + actions: manifest.actions.map((action) => action.type), + resources: manifest.dataScope.resources.length, + }, + after: { + channel: channel ? `#${channel.name}` : null, + actions: input.actions ?? null, + resources: input.resources?.length ?? null, + triggers: repointed, }, + requestId: input.clientRequestId, }); return version.id; @@ -505,17 +516,15 @@ export class AgentDefinitionsService { } async deploy(input: AgentDeployInput, userId: string) { - return this.db.$transaction(async (tx) => { + return this.db.transaction(async (tx) => { await this.access.assertCanManageInTransaction(tx, input.id, userId); const agent = await this.lockAgent(tx, input.id); - const existing = await tx.agentAuditEvent.findFirst({ - where: { - agentId: input.id, - type: "agent.deployed", - requestId: input.clientRequestId, - }, - select: { versionId: true }, - }); + const existing = await auditReplay( + tx, + input.id, + "agent.deployed", + input.clientRequestId, + ); if (existing) { if (existing.versionId !== input.versionId) { @@ -527,10 +536,21 @@ export class AgentDefinitionsService { return { id: input.id, versionId: input.versionId, status: "LIVE" }; } - const version = await tx.agentVersion.findFirst({ - where: { id: input.versionId, agentId: input.id }, - select: { id: true, number: true, status: true, manifest: true }, - }); + const [version] = await tx + .select({ + id: agentVersion.id, + number: agentVersion.number, + status: agentVersion.status, + manifest: agentVersion.manifest, + }) + .from(agentVersion) + .where( + and( + eq(agentVersion.id, input.versionId), + eq(agentVersion.agentId, input.id), + ), + ) + .limit(1); if (!version) { throw new NotFoundException(`No version with id ${input.versionId}.`); @@ -544,57 +564,58 @@ export class AgentDefinitionsService { const metadata = versionMetadata(version.manifest); const now = new Date(); - await tx.agentVersion.updateMany({ - where: { - agentId: input.id, - status: "DEPLOYED", - id: { not: input.versionId }, - }, - data: { status: "READY" }, - }); + await tx + .update(agentVersion) + .set({ status: "READY" }) + .where( + and( + eq(agentVersion.agentId, input.id), + eq(agentVersion.status, "DEPLOYED"), + ne(agentVersion.id, input.versionId), + ), + ); - await tx.agentVersion.update({ - where: { id: input.versionId }, - data: { - status: "DEPLOYED", - approvedAt: now, - deployedAt: now, - }, - }); + await tx + .update(agentVersion) + .set({ status: "DEPLOYED", approvedAt: now, deployedAt: now }) + .where(eq(agentVersion.id, input.versionId)); - await tx.agentDefinition.update({ - where: { id: input.id }, - data: { + await tx + .update(agentDefinition) + .set({ currentVersionId: input.versionId, status: "LIVE", archivedAt: null, ...metadata, - }, - }); - - await tx.agentTrigger.updateMany({ - where: { agentId: input.id }, - data: { enabled: false }, - }); - - await tx.agentTrigger.updateMany({ - where: { agentId: input.id, versionId: input.versionId }, - data: { enabled: true }, - }); + }) + .where(eq(agentDefinition.id, input.id)); + + await tx + .update(agentTrigger) + .set({ enabled: false }) + .where(eq(agentTrigger.agentId, input.id)); + + await tx + .update(agentTrigger) + .set({ enabled: true }) + .where( + and( + eq(agentTrigger.agentId, input.id), + eq(agentTrigger.versionId, input.versionId), + ), + ); - await tx.agentAuditEvent.create({ - data: { - agentId: input.id, - versionId: input.versionId, - actorUserId: userId, - actorType: "USER", - actorId: userId, - type: "agent.deployed", - summary: `Made version ${version.number} live for the team`, - before: { status: agent.status }, - after: { status: "LIVE", version: version.number, ...metadata }, - requestId: input.clientRequestId, - }, + await tx.insert(agentAuditEvent).values({ + agentId: input.id, + versionId: input.versionId, + actorUserId: userId, + actorType: "USER", + actorId: userId, + type: "agent.deployed", + summary: `Made version ${version.number} live for the team`, + before: { status: agent.status }, + after: { status: "LIVE", version: version.number, ...metadata }, + requestId: input.clientRequestId, }); return { id: input.id, versionId: input.versionId, status: "LIVE" }; @@ -654,89 +675,94 @@ export class AgentDefinitionsService { async remove(id: string, userId: string) { const now = new Date(); - return this.db.$transaction(async (tx) => { + return this.db.transaction(async (tx) => { await this.access.assertCanManageInTransaction(tx, id, userId); - const [current] = await tx.$queryRaw< - Array<{ id: string; status: string }> - >` - SELECT id, status - FROM "agentDefinition" - WHERE id = ${id} - FOR UPDATE - `; + const [current] = await tx + .select({ id: agentDefinition.id, status: agentDefinition.status }) + .from(agentDefinition) + .where(eq(agentDefinition.id, id)) + .for("update"); if (!current || current.status === "DELETED") { throw new NotFoundException(`No agent with id ${id}.`); } - const disabledTriggers = await tx.agentTrigger.updateMany({ - where: { agentId: id, enabled: true }, - data: { enabled: false, nextRunAt: null }, - }); - - const cancellableRuns = await tx.$queryRaw>` - SELECT id - FROM "agentRun" - WHERE "agentId" = ${id} - AND ( - status IN ('QUEUED', 'WAITING_FOR_APPROVAL') - OR (status = 'RUNNING' AND "sessionId" IS NULL) + const disabledTriggers = ( + await tx + .update(agentTrigger) + .set({ enabled: false, nextRunAt: null }) + .where( + and(eq(agentTrigger.agentId, id), eq(agentTrigger.enabled, true)), ) - ORDER BY id - FOR UPDATE - `; + .returning({ id: agentTrigger.id }) + ).length; + + const cancellableRuns = await tx + .select({ id: agentRun.id }) + .from(agentRun) + .where( + and( + eq(agentRun.agentId, id), + sql`(${agentRun.status} IN ('QUEUED', 'WAITING_FOR_APPROVAL') OR (${agentRun.status} = 'RUNNING' AND ${agentRun.sessionId} IS NULL))`, + ), + ) + .orderBy(asc(agentRun.id)) + .for("update"); for (const run of cancellableRuns) { - const cancelled = await tx.agentRun.update({ - where: { id: run.id }, - data: { + const [cancelled] = await tx + .update(agentRun) + .set({ status: "CANCELLED", finishedAt: now, errorCode: "AGENT_DELETED", errorMessage: "The agent was deleted before this run completed.", - nextEventSequence: { increment: 1 }, - }, - select: { nextEventSequence: true }, - }); - - await tx.agentRunEvent.create({ - data: { - runId: run.id, - sequence: cancelled.nextEventSequence, - type: "run.cancelled", - data: { reason: "agent.deleted" }, - emittedAt: now, - }, + nextEventSequence: sql`${agentRun.nextEventSequence} + 1`, + }) + .where(eq(agentRun.id, run.id)) + .returning({ nextEventSequence: agentRun.nextEventSequence }); + if (!cancelled) continue; + + await tx.insert(agentRunEvent).values({ + runId: run.id, + sequence: cancelled.nextEventSequence, + type: "run.cancelled", + data: { reason: "agent.deleted" }, + emittedAt: now, }); } - const agent = await tx.agentDefinition.update({ - where: { id }, - data: { status: "DELETED", deletedAt: now }, - select: { id: true, name: true, status: true, updatedAt: true }, - }); - - await tx.agentAuditEvent.create({ - data: { - agentId: id, - actorUserId: userId, - actorType: "USER", - actorId: userId, - type: "agent.deleted", - summary: "Deleted agent", - before: { status: current.status }, - after: { - status: "DELETED", - disabledTriggers: disabledTriggers.count, - cancelledRuns: cancellableRuns.length, - }, + const [agent] = await tx + .update(agentDefinition) + .set({ status: "DELETED", deletedAt: now }) + .where(eq(agentDefinition.id, id)) + .returning({ + id: agentDefinition.id, + name: agentDefinition.name, + status: agentDefinition.status, + updatedAt: agentDefinition.updatedAt, + }); + if (!agent) throw new NotFoundException(`No agent with id ${id}.`); + + await tx.insert(agentAuditEvent).values({ + agentId: id, + actorUserId: userId, + actorType: "USER", + actorId: userId, + type: "agent.deleted", + summary: "Deleted agent", + before: { status: current.status }, + after: { + status: "DELETED", + disabledTriggers, + cancelledRuns: cancellableRuns.length, }, }); return { ...agent, updatedAt: agent.updatedAt.toISOString(), - disabledTriggers: disabledTriggers.count, + disabledTriggers, cancelledRuns: cancellableRuns.length, }; }); @@ -750,53 +776,54 @@ export class AgentDefinitionsService { type: string, summary: string, invalidStatusMessage: string, - extra: Prisma.AgentDefinitionUpdateInput = {}, + extra: Partial = {}, ) { - return this.db.$transaction(async (tx) => { + return this.db.transaction(async (tx) => { await this.access.assertCanManageInTransaction(tx, id, userId); const before = await this.lockAgent(tx, id); if (!allowedFrom.includes(before.status)) { throw new BadRequestException(invalidStatusMessage); } - const agent = await tx.agentDefinition.update({ - where: { id }, - data: { status, ...extra }, - select: { id: true, name: true, status: true, updatedAt: true }, - }); - - await tx.agentAuditEvent.create({ - data: { - agentId: id, - actorUserId: userId, - actorType: "USER", - actorId: userId, - type, - summary, - before: { status: before.status }, - after: { status }, - }, + const [agent] = await tx + .update(agentDefinition) + .set({ status, ...extra }) + .where(eq(agentDefinition.id, id)) + .returning({ + id: agentDefinition.id, + name: agentDefinition.name, + status: agentDefinition.status, + updatedAt: agentDefinition.updatedAt, + }); + if (!agent) throw new NotFoundException(`No agent with id ${id}.`); + + await tx.insert(agentAuditEvent).values({ + agentId: id, + actorUserId: userId, + actorType: "USER", + actorId: userId, + type, + summary, + before: { status: before.status }, + after: { status }, }); return { ...agent, updatedAt: agent.updatedAt.toISOString() }; }); } - private async lockAgent(tx: Prisma.TransactionClient, id: string) { - const [agent] = await tx.$queryRaw< - Array<{ - id: string; - status: AgentDefinitionStatus; - name: string; - description: string | null; - currentVersionId: string | null; - }> - >` - SELECT id, status, name, description, "currentVersionId" - FROM "agentDefinition" - WHERE id = ${id} - FOR UPDATE - `; + private async lockAgent(tx: Executor, id: string) { + const [agent] = await tx + .select({ + id: agentDefinition.id, + status: agentDefinition.status, + name: agentDefinition.name, + description: agentDefinition.description, + currentVersionId: agentDefinition.currentVersionId, + }) + .from(agentDefinition) + .where(eq(agentDefinition.id, id)) + .for("update"); if (!agent || agent.status === "DELETED") { throw new NotFoundException(`No agent with id ${id}.`); @@ -811,7 +838,7 @@ export class AgentDefinitionsService { } } -function versionMetadata(manifest: Prisma.JsonValue): VersionMetadata { +function versionMetadata(manifest: JsonValue): VersionMetadata { const summary = readAgentManifestSummary(manifest); const name = summary.name?.trim() ?? ""; const description = @@ -826,7 +853,7 @@ function versionMetadata(manifest: Prisma.JsonValue): VersionMetadata { return metadata; } -function readCapabilities(manifest: Prisma.JsonValue | undefined) { +function readCapabilities(manifest: JsonValue | undefined) { const parsed = schemas.agents.capabilities.safeParse(manifest); if (!parsed.success) { @@ -871,10 +898,10 @@ function reviseSummary(input: { } function reviseValidation( - validation: Prisma.JsonValue, + validation: JsonValue, before: string[], after: string[], -): Prisma.InputJsonValue { +): JsonValue { const removed = before.filter((type) => !after.includes(type)); const base = versionValidation.parse(validation); @@ -891,36 +918,117 @@ function reviseValidation( }; } +const runCountExtra = (agent: { id: typeof agentDefinition.id }) => ({ + runCount: relatedCount(agentRun, agentRun.agentId, agent.id).as("runCount"), +}); + +async function auditReplay( + tx: Executor, + agentId: string, + type: string, + requestId: string, +) { + const [row] = await tx + .select({ versionId: agentAuditEvent.versionId }) + .from(agentAuditEvent) + .where( + and( + eq(agentAuditEvent.agentId, agentId), + eq(agentAuditEvent.type, type), + eq(agentAuditEvent.requestId, requestId), + ), + ) + .limit(1); + return row ?? null; +} + +async function currentVersion(tx: Executor, id: string) { + const [row] = await tx + .select({ + status: agentVersion.status, + instructions: agentVersion.instructions, + manifest: agentVersion.manifest, + modelId: agentVersion.modelId, + modelContextWindowTokens: agentVersion.modelContextWindowTokens, + sandboxPolicy: agentVersion.sandboxPolicy, + validation: agentVersion.validation, + sourceConversationId: agentVersion.sourceConversationId, + deployedAt: agentVersion.deployedAt, + approvedAt: agentVersion.approvedAt, + }) + .from(agentVersion) + .where(eq(agentVersion.id, id)) + .limit(1); + if (!row) throw new NotFoundException(`No version with id ${id}.`); + return row; +} + +async function createVersion( + tx: Executor, + values: typeof agentVersion.$inferInsert, +): Promise<{ id: string }> { + const [row] = await tx + .insert(agentVersion) + .values(values) + .returning({ id: agentVersion.id }); + if (!row) throw new Error("The version insert returned no row."); + return row; +} + +async function repointTriggers( + tx: Executor, + agentId: string, + fromVersionId: string, + toVersionId: string, +): Promise { + const rows = await tx + .update(agentTrigger) + .set({ versionId: toVersionId }) + .where( + and( + eq(agentTrigger.agentId, agentId), + eq(agentTrigger.versionId, fromVersionId), + ), + ) + .returning({ id: agentTrigger.id }); + return rows.length; +} + async function nextVersionNumber( - tx: Prisma.TransactionClient, + tx: Executor, agentId: string, ): Promise { - const latest = await tx.agentVersion.findFirst({ - where: { agentId }, - orderBy: { number: "desc" }, - select: { number: true }, - }); + const [latest] = await tx + .select({ number: agentVersion.number }) + .from(agentVersion) + .where(eq(agentVersion.agentId, agentId)) + .orderBy(desc(agentVersion.number)) + .limit(1); return (latest?.number ?? 0) + 1; } async function carryArtifactsForward( - tx: Prisma.TransactionClient, + tx: Executor, fromVersionId: string, toVersionId: string, ): Promise { - const rows = await tx.agentBuilderArtifact.findMany({ - where: { versionId: fromVersionId }, - orderBy: [{ path: "asc" }, { revision: "desc" }], - }); + const rows = await tx + .select() + .from(agentBuilderArtifact) + .where(eq(agentBuilderArtifact.versionId, fromVersionId)) + .orderBy( + asc(agentBuilderArtifact.path), + desc(agentBuilderArtifact.revision), + ); const latest = new Map(); for (const row of rows) if (!latest.has(row.path)) latest.set(row.path, row); if (latest.size === 0) return; - await tx.agentBuilderArtifact.createMany({ - data: [...latest.values()].map((row) => ({ + await tx.insert(agentBuilderArtifact).values( + [...latest.values()].map((row) => ({ versionId: toVersionId, path: row.path, language: row.language, @@ -929,5 +1037,5 @@ async function carryArtifactsForward( revision: row.revision, status: row.status, })), - }); + ); } diff --git a/apps/api/src/agent/agent-queue.service.ts b/apps/api/src/agent/agent-queue.service.ts index f408f160c..824e2e9d3 100644 --- a/apps/api/src/agent/agent-queue.service.ts +++ b/apps/api/src/agent/agent-queue.service.ts @@ -1,4 +1,13 @@ -import type { Db, Prisma } from "@crm/db"; +import { + agentTask, + and, + type Db, + eq, + inArray, + isNull, + lte, + type SQL, +} from "@crm/db"; import { Injectable } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; @@ -9,11 +18,10 @@ export class AgentQueueService { async queuedCompanies(ids: readonly string[]): Promise> { if (ids.length === 0) return new Set(); - const rows = await this.db.agentTask.findMany({ - where: this.due({ companyId: { in: [...ids] } }), - select: { companyId: true }, - distinct: ["companyId"], - }); + const rows = await this.db + .selectDistinct({ companyId: agentTask.companyId }) + .from(agentTask) + .where(this.due(inArray(agentTask.companyId, [...ids]))); return this.identifiers(rows.map((row) => row.companyId)); } @@ -21,11 +29,10 @@ export class AgentQueueService { async queuedContacts(ids: readonly string[]): Promise> { if (ids.length === 0) return new Set(); - const rows = await this.db.agentTask.findMany({ - where: this.due({ contactId: { in: [...ids] } }), - select: { contactId: true }, - distinct: ["contactId"], - }); + const rows = await this.db + .selectDistinct({ contactId: agentTask.contactId }) + .from(agentTask) + .where(this.due(inArray(agentTask.contactId, [...ids]))); return this.identifiers(rows.map((row) => row.contactId)); } @@ -34,19 +41,32 @@ export class AgentQueueService { companyId?: string; contactId?: string; }): Promise { - const row = await this.db.agentTask.findFirst({ - where: this.due({ - companyId: subject.companyId, - contactId: subject.contactId, - }), - select: { id: true }, - }); - - return row !== null; + const [row] = await this.db + .select({ id: agentTask.id }) + .from(agentTask) + .where( + this.due( + and( + subject.companyId + ? eq(agentTask.companyId, subject.companyId) + : undefined, + subject.contactId + ? eq(agentTask.contactId, subject.contactId) + : undefined, + ), + ), + ) + .limit(1); + + return row !== undefined; } - private due(subject: Prisma.AgentTaskWhereInput): Prisma.AgentTaskWhereInput { - return { ...subject, finishedAt: null, dueAt: { lte: new Date() } }; + private due(subject: SQL | undefined): SQL | undefined { + return and( + subject, + isNull(agentTask.finishedAt), + lte(agentTask.dueAt, new Date()), + ); } private identifiers(values: (string | null)[]): Set { diff --git a/apps/api/src/agent/agent-runs.service.ts b/apps/api/src/agent/agent-runs.service.ts index 6cff770b4..f50a08cc7 100644 --- a/apps/api/src/agent/agent-runs.service.ts +++ b/apps/api/src/agent/agent-runs.service.ts @@ -1,5 +1,19 @@ import { randomUUID } from "node:crypto"; -import { type Db, Prisma } from "@crm/db"; +import { + agentAction, + agentAuditEvent, + agentDefinition, + agentRun, + agentRunEvent, + and, + asc, + type Db, + desc, + type Executor, + eq, + inArray, + relatedCount, +} from "@crm/db"; import type { AgentRunStatus } from "@crm/db/enums"; import { lockIdempotencyKey } from "@crm/db/idempotency"; import { @@ -38,11 +52,18 @@ export class AgentRunsService { async list(agentId: string, limit: number, userId: string) { const agent = await this.readableAgent(agentId, userId); - const rows = await this.db.agentRun.findMany({ - where: { agentId }, - orderBy: { createdAt: "desc" }, - take: limit, - select: { + const rows = await this.db.query.agentRun.findMany({ + where: eq(agentRun.agentId, agentId), + orderBy: [desc(agentRun.createdAt)], + limit, + extras: (run: { id: typeof agentRun.id }) => ({ + totalEvents: relatedCount( + agentRunEvent, + agentRunEvent.runId, + run.id, + ).as("totalEvents"), + }), + columns: { id: true, status: true, triggerType: true, @@ -56,13 +77,14 @@ export class AgentRunsService { createdAt: true, startedAt: true, finishedAt: true, - initiatedBy: { select: { id: true, name: true, image: true } }, - version: { select: { id: true, number: true } }, - _count: { select: { events: true } }, + }, + with: { + initiatedBy: { columns: { id: true, name: true, image: true } }, + version: { columns: { id: true, number: true } }, events: { - orderBy: { sequence: "asc" }, - take: RUN_EVENT_LIMIT, - select: { + orderBy: [asc(agentRunEvent.sequence)], + limit: RUN_EVENT_LIMIT, + columns: { id: true, sequence: true, type: true, @@ -71,8 +93,8 @@ export class AgentRunsService { }, }, actions: { - orderBy: { plannedAt: "asc" }, - select: { + orderBy: [asc(agentAction.plannedAt)], + columns: { id: true, type: true, provider: true, @@ -93,10 +115,10 @@ export class AgentRunsService { }, }); - return rows.map(({ _count, ...run }) => ({ + return rows.map((run) => ({ ...run, - totalEvents: _count.events, - eventsTruncated: _count.events > run.events.length, + totalEvents: run.totalEvents, + eventsTruncated: run.totalEvents > run.events.length, canCancel: CANCELLABLE_STATUSES.includes(run.status) && (agent.canManage || run.initiatedBy?.id === userId), @@ -120,11 +142,11 @@ export class AgentRunsService { async activity(agentId: string, limit: number, userId: string) { await this.readableAgent(agentId, userId); - const rows = await this.db.agentAuditEvent.findMany({ - where: { agentId }, - orderBy: { emittedAt: "desc" }, - take: limit, - select: { + const rows = await this.db.query.agentAuditEvent.findMany({ + where: eq(agentAuditEvent.agentId, agentId), + orderBy: [desc(agentAuditEvent.emittedAt)], + limit, + columns: { id: true, type: true, summary: true, @@ -134,8 +156,10 @@ export class AgentRunsService { emittedAt: true, actorType: true, actorId: true, - actorUser: { select: { id: true, name: true, image: true } }, - version: { select: { id: true, number: true } }, + }, + with: { + actorUser: { columns: { id: true, name: true, image: true } }, + version: { columns: { id: true, number: true } }, }, }); @@ -147,10 +171,7 @@ export class AgentRunsService { async runNow(input: AgentRunNowInput, userId: string) { await this.access.assertMember(userId); - const existing = await this.db.agentRun.findUnique({ - where: { idempotencyKey: input.clientRequestId }, - select: { id: true, agentId: true }, - }); + const existing = await runByKey(this.db, input.clientRequestId); if (existing) { this.assertReplayMatches(existing.agentId, input.id); @@ -158,29 +179,15 @@ export class AgentRunsService { return { id: existing.id }; } - const run = await this.db.$transaction(async (tx) => { + const run = await this.db.transaction(async (tx) => { await lockIdempotencyKey(tx, input.clientRequestId); - const replay = await tx.agentRun.findUnique({ - where: { idempotencyKey: input.clientRequestId }, - select: { id: true, agentId: true }, - }); + const replay = await runByKey(tx, input.clientRequestId); if (replay) { this.assertReplayMatches(replay.agentId, input.id); return { id: replay.id }; } - const [agent] = await tx.$queryRaw< - Array<{ - id: string; - status: string; - currentVersionId: string | null; - }> - >` - SELECT id, status, "currentVersionId" - FROM "agentDefinition" - WHERE id = ${input.id} - FOR UPDATE - `; + const agent = await lockAgent(tx, input.id); if (!agent || agent.status === "DELETED") { throw new NotFoundException(`No agent with id ${input.id}.`); @@ -190,45 +197,26 @@ export class AgentRunsService { throw new BadRequestException("This agent is not live yet."); } - const active = await tx.agentRun.findFirst({ - where: { - agentId: input.id, - status: { in: [...CANCELLABLE_STATUSES] }, - }, - select: { id: true }, - }); - if (active) { - throw new ConflictException( - "This agent already has an active run. Stop it or wait for it to finish.", - ); - } + await assertNoActiveRun(tx, input.id); - const created = await tx.agentRun.create({ - data: { - agentId: input.id, - versionId: agent.currentVersionId, - initiatedById: userId, - triggerType: "MANUAL", - idempotencyKey: input.clientRequestId, - correlationId: randomUUID(), - events: { - create: { sequence: 0, type: "run.queued", data: {} }, - }, - }, - select: { id: true }, + const created = await createRun(tx, { + agentId: input.id, + versionId: agent.currentVersionId, + initiatedById: userId, + triggerType: "MANUAL", + idempotencyKey: input.clientRequestId, + correlationId: randomUUID(), }); - await tx.agentAuditEvent.create({ - data: { - agentId: input.id, - versionId: agent.currentVersionId, - actorUserId: userId, - actorType: "USER", - actorId: userId, - type: "run.requested", - summary: "Requested a manual run", - requestId: input.clientRequestId, - }, + await tx.insert(agentAuditEvent).values({ + agentId: input.id, + versionId: agent.currentVersionId, + actorUserId: userId, + actorType: "USER", + actorId: userId, + type: "run.requested", + summary: "Requested a manual run", + requestId: input.clientRequestId, }); return created; @@ -241,28 +229,26 @@ export class AgentRunsService { async retryRun(input: AgentRetryRunInput, userId: string) { await this.access.assertMember(userId); - const run = await this.db.$transaction(async (tx) => { + const run = await this.db.transaction(async (tx) => { await lockIdempotencyKey(tx, input.clientRequestId); - const replay = await tx.agentRun.findUnique({ - where: { idempotencyKey: input.clientRequestId }, - select: { id: true, agentId: true }, - }); + const replay = await runByKey(tx, input.clientRequestId); if (replay) { this.assertReplayMatches(replay.agentId, input.id); return { id: replay.id }; } - const previous = await tx.agentRun.findUnique({ - where: { id: input.runId }, - select: { - agentId: true, - status: true, - versionId: true, - triggerId: true, - triggerType: true, - input: true, - }, - }); + const [previous] = await tx + .select({ + agentId: agentRun.agentId, + status: agentRun.status, + versionId: agentRun.versionId, + triggerId: agentRun.triggerId, + triggerType: agentRun.triggerType, + input: agentRun.input, + }) + .from(agentRun) + .where(eq(agentRun.id, input.runId)) + .limit(1); if (!previous || previous.agentId !== input.id) { throw new NotFoundException(`No run with id ${input.runId}.`); } @@ -270,14 +256,7 @@ export class AgentRunsService { throw new ConflictException("This run has not finished yet."); } - const [agent] = await tx.$queryRaw< - Array<{ id: string; status: string; currentVersionId: string | null }> - >` - SELECT id, status, "currentVersionId" - FROM "agentDefinition" - WHERE id = ${input.id} - FOR UPDATE - `; + const agent = await lockAgent(tx, input.id); if (!agent || agent.status === "DELETED") { throw new NotFoundException(`No agent with id ${input.id}.`); } @@ -285,42 +264,28 @@ export class AgentRunsService { throw new BadRequestException("This agent is not live yet."); } - const active = await tx.agentRun.findFirst({ - where: { agentId: input.id, status: { in: [...CANCELLABLE_STATUSES] } }, - select: { id: true }, + await assertNoActiveRun(tx, input.id); + + const created = await createRun(tx, { + agentId: input.id, + versionId: previous.versionId, + initiatedById: userId, + triggerId: previous.triggerId, + triggerType: previous.triggerType, + input: previous.input, + idempotencyKey: input.clientRequestId, + correlationId: randomUUID(), }); - if (active) { - throw new ConflictException( - "This agent already has an active run. Stop it or wait for it to finish.", - ); - } - const created = await tx.agentRun.create({ - data: { - agentId: input.id, - versionId: previous.versionId, - initiatedById: userId, - triggerId: previous.triggerId, - triggerType: previous.triggerType, - input: previous.input ?? Prisma.DbNull, - idempotencyKey: input.clientRequestId, - correlationId: randomUUID(), - events: { create: { sequence: 0, type: "run.queued", data: {} } }, - }, - select: { id: true }, - }); - - await tx.agentAuditEvent.create({ - data: { - agentId: input.id, - versionId: previous.versionId, - actorUserId: userId, - actorType: "USER", - actorId: userId, - type: "run.requested", - summary: `Retried run ${input.runId}`, - requestId: input.clientRequestId, - }, + await tx.insert(agentAuditEvent).values({ + agentId: input.id, + versionId: previous.versionId, + actorUserId: userId, + actorType: "USER", + actorId: userId, + type: "run.requested", + summary: `Retried run ${input.runId}`, + requestId: input.clientRequestId, }); return created; @@ -333,22 +298,19 @@ export class AgentRunsService { async cancelRun(input: AgentCancelRunInput, userId: string) { const agent = await this.readableAgent(input.id, userId); - const outcome = await this.db.$transaction(async (tx) => { - const [run] = await tx.$queryRaw< - Array<{ - id: string; - agentId: string; - versionId: string; - status: AgentRunStatus; - initiatedById: string | null; - nextEventSequence: number; - }> - >` - SELECT id, "agentId", "versionId", status, "initiatedById", "nextEventSequence" - FROM "agentRun" - WHERE id = ${input.runId} - FOR UPDATE - `; + const outcome = await this.db.transaction(async (tx) => { + const [run] = await tx + .select({ + id: agentRun.id, + agentId: agentRun.agentId, + versionId: agentRun.versionId, + status: agentRun.status, + initiatedById: agentRun.initiatedById, + nextEventSequence: agentRun.nextEventSequence, + }) + .from(agentRun) + .where(eq(agentRun.id, input.runId)) + .for("update"); if (!run || run.agentId !== input.id) { throw new NotFoundException(`No run with id ${input.runId}.`); @@ -367,47 +329,44 @@ export class AgentRunsService { const sequence = run.nextEventSequence + 1; const finishedAt = new Date(); - await tx.agentRun.update({ - where: { id: run.id }, - data: { + await tx + .update(agentRun) + .set({ status: "CANCELLED", errorCode: AGENT_DISPATCH.cancel.errorCode, errorMessage: AGENT_DISPATCH.cancel.message, finishedAt, nextEventSequence: sequence, - }, - }); + }) + .where(eq(agentRun.id, run.id)); - await tx.agentAction.updateMany({ - where: { runId: run.id, status: { in: ["PLANNED", "RUNNING"] } }, - data: { + await tx + .update(agentAction) + .set({ status: "CANCELLED", errorCode: AGENT_DISPATCH.cancel.errorCode, errorMessage: AGENT_DISPATCH.cancel.message, completedAt: finishedAt, - }, - }); + }) + .where( + and( + eq(agentAction.runId, run.id), + inArray(agentAction.status, ["PLANNED", "RUNNING"]), + ), + ); - await tx.agentRunEvent.create({ - data: { - id: `run-terminal:${run.id}:cancelled`, - runId: run.id, - sequence, - type: "run.cancelled", - data: { reason: "user.cancelled" }, - emittedAt: finishedAt, - }, + await tx.insert(agentRunEvent).values({ + id: `run-terminal:${run.id}:cancelled`, + runId: run.id, + sequence, + type: "run.cancelled", + data: { reason: "user.cancelled" }, + emittedAt: finishedAt, }); - await tx.agentAuditEvent.upsert({ - where: { - agentId_type_requestId: { - agentId: run.agentId, - type: "run.cancelled", - requestId: run.id, - }, - }, - create: { + await tx + .insert(agentAuditEvent) + .values({ agentId: run.agentId, versionId: run.versionId, actorUserId: userId, @@ -416,9 +375,14 @@ export class AgentRunsService { type: "run.cancelled", summary: "Stopped a run", requestId: run.id, - }, - update: {}, - }); + }) + .onConflictDoNothing({ + target: [ + agentAuditEvent.agentId, + agentAuditEvent.type, + agentAuditEvent.requestId, + ], + }); return { id: run.id, status: "CANCELLED" as const, cancelled: true }; }); @@ -443,3 +407,58 @@ export class AgentRunsService { } } } + +async function runByKey(client: Executor, idempotencyKey: string) { + const [row] = await client + .select({ id: agentRun.id, agentId: agentRun.agentId }) + .from(agentRun) + .where(eq(agentRun.idempotencyKey, idempotencyKey)) + .limit(1); + return row ?? null; +} + +async function lockAgent(tx: Executor, id: string) { + const [agent] = await tx + .select({ + id: agentDefinition.id, + status: agentDefinition.status, + currentVersionId: agentDefinition.currentVersionId, + }) + .from(agentDefinition) + .where(eq(agentDefinition.id, id)) + .for("update"); + return agent; +} + +async function assertNoActiveRun(tx: Executor, agentId: string): Promise { + const [active] = await tx + .select({ id: agentRun.id }) + .from(agentRun) + .where( + and( + eq(agentRun.agentId, agentId), + inArray(agentRun.status, [...CANCELLABLE_STATUSES]), + ), + ) + .limit(1); + if (active) { + throw new ConflictException( + "This agent already has an active run. Stop it or wait for it to finish.", + ); + } +} + +async function createRun( + tx: Executor, + values: typeof agentRun.$inferInsert, +): Promise<{ id: string }> { + const [created] = await tx + .insert(agentRun) + .values(values) + .returning({ id: agentRun.id }); + if (!created) throw new Error("The run insert returned no row."); + await tx + .insert(agentRunEvent) + .values({ runId: created.id, sequence: 0, type: "run.queued", data: {} }); + return created; +} diff --git a/apps/api/src/agent/agent-trigger.service.ts b/apps/api/src/agent/agent-trigger.service.ts index 98cde03dd..c404777b7 100644 --- a/apps/api/src/agent/agent-trigger.service.ts +++ b/apps/api/src/agent/agent-trigger.service.ts @@ -1,4 +1,21 @@ -import { type Db, type FieldEntity, Prisma } from "@crm/db"; +import { + agentRun, + agentTask, + and, + type Db, + desc, + type Executor, + eq, + type FieldEntity, + gte, + inArray, + isNotNull, + isNull, + type JsonObject, + type JsonValue, + type NewAgentTask, + sql, +} from "@crm/db"; import { PRIORITY } from "@crm/db/agent-tasks"; import { CRM_EVENT_CATALOG, type CrmEventType } from "@crm/db/crm-events"; import { RECORD_ID_COLUMNS } from "@crm/db/fields"; @@ -17,7 +34,7 @@ export type CrmEventInput = { id: string; }; occurredAt: Date; - data: Prisma.InputJsonObject; + data: JsonObject; }; }[CrmEventType]; @@ -150,14 +167,11 @@ export class AgentTriggerService { } async withTasks( - work: ( - tx: Prisma.TransactionClient, - queue: AgentTaskQueue, - ) => Promise, + work: (tx: Executor, queue: AgentTaskQueue) => Promise, ): Promise { let queued = false; - const result = await this.db.$transaction((tx) => + const result = await this.db.transaction((tx) => work(tx, { slackChannelJoinRequested: async (channelId, channelName) => { const created = await this.queueSlackChannelJoin( @@ -178,7 +192,7 @@ export class AgentTriggerService { private queueSlackChannelJoin( channelId: string, channelName: string, - client?: Prisma.TransactionClient, + client?: Executor, ): Promise { return this.enqueue( { @@ -200,12 +214,12 @@ export class AgentTriggerService { async withCrmEvents( work: ( - tx: Prisma.TransactionClient, + tx: Executor, emit: (input: CrmEventInput) => Promise, ) => Promise, ): Promise { let queued: CrmEventInput[] = []; - const result = await this.db.$transaction(async (tx) => { + const result = await this.db.transaction(async (tx) => { const pending: CrmEventInput[] = []; const completed = await work(tx, async (input) => { pending.push(input); @@ -239,27 +253,30 @@ export class AgentTriggerService { const column = RECORD_ID_COLUMNS[entity]; const dueAt = new Date(); - const data: Prisma.AgentTaskCreateManyInput[] = recordIds.map((id) => ({ + const data: NewAgentTask[] = recordIds.map((id) => ({ [column]: id, kind: "field-backfill", reason, priority: PRIORITY.fieldBackfill, budget: 8, dueAt, - payload: { entity, keys } satisfies Prisma.InputJsonValue, + payload: { entity, keys } satisfies JsonValue, })); - const result = await this.db.agentTask.createMany({ data }); + const inserted = await this.db + .insert(agentTask) + .values(data) + .returning({ id: agentTask.id }); this.logger.log({ message: "Agent task queued", kind: "field-backfill", entity, keys, - queued: result.count, + queued: inserted.length, }); - if (result.count > 0) this.poke(); + if (inserted.length > 0) this.poke(); - return { queued: result.count }; + return { queued: inserted.length }; } async fieldBackfillRecords( @@ -278,32 +295,33 @@ export class AgentTriggerService { const queueOne = async (id: string): Promise => { try { - const outcome = await this.db.$transaction(async (tx) => { + const outcome = await this.db.transaction(async (tx) => { await lockIdempotencyKey( tx, `agent-task:field-backfill:${entity}:${id}`, ); - const pending = await tx.agentTask.findFirst({ - where: { - kind: "field-backfill", - finishedAt: null, - [column]: id, - } as Prisma.AgentTaskWhereInput, - select: { id: true, payload: true }, - }); + const [pending] = await tx + .select({ id: agentTask.id, payload: agentTask.payload }) + .from(agentTask) + .where( + and( + eq(agentTask.kind, "field-backfill"), + isNull(agentTask.finishedAt), + eq(agentTask[column], id), + ), + ) + .limit(1); if (!pending) { - await tx.agentTask.create({ - data: { - [column]: id, - kind: "field-backfill", - reason, - priority: PRIORITY.fieldBackfill, - budget: 8, - dueAt: new Date(), - payload: { entity, keys } satisfies Prisma.InputJsonValue, - }, + await tx.insert(agentTask).values({ + [column]: id, + kind: "field-backfill", + reason, + priority: PRIORITY.fieldBackfill, + budget: 8, + dueAt: new Date(), + payload: { entity, keys } satisfies JsonValue, }); return "queued" as const; } @@ -313,15 +331,10 @@ export class AgentTriggerService { const nextKeys = [...new Set([...priorKeys, ...keys])]; if (nextKeys.length === priorKeys.length) return "unchanged" as const; - await tx.agentTask.update({ - where: { id: pending.id }, - data: { - payload: { - entity, - keys: nextKeys, - } satisfies Prisma.InputJsonValue, - }, - }); + await tx + .update(agentTask) + .set({ payload: { entity, keys: nextKeys } satisfies JsonValue }) + .where(eq(agentTask.id, pending.id)); return "merged" as const; }); @@ -388,17 +401,19 @@ export class AgentTriggerService { const since = new Date( Date.now() - AGENT_DISPATCH.cancel.redeliverWithinMs, ); - const runs = await this.db.agentRun.findMany({ - where: { - status: "CANCELLED", - errorCode: AGENT_DISPATCH.cancel.errorCode, - startedAt: { not: null }, - finishedAt: { gte: since }, - }, - orderBy: { finishedAt: "desc" }, - take: AGENT_DISPATCH.cancel.redeliverBatch, - select: { id: true }, - }); + const runs = await this.db + .select({ id: agentRun.id }) + .from(agentRun) + .where( + and( + eq(agentRun.status, "CANCELLED"), + eq(agentRun.errorCode, AGENT_DISPATCH.cancel.errorCode), + isNotNull(agentRun.startedAt), + gte(agentRun.finishedAt, since), + ), + ) + .orderBy(desc(agentRun.finishedAt)) + .limit(AGENT_DISPATCH.cancel.redeliverBatch); const outstanding = new Set(runs.map((run) => run.id)); for (const runId of this.cancellationsDelivered) { @@ -435,14 +450,19 @@ export class AgentTriggerService { if (ids.length === 0) return { queued: 0, alreadyQueued: 0 }; try { - const outstanding = await this.db.agentTask.findMany({ - where: { - kind: input.kind, - finishedAt: null, - [subject]: { in: ids }, - }, - select: { companyId: true, contactId: true }, - }); + const outstanding = await this.db + .select({ + companyId: agentTask.companyId, + contactId: agentTask.contactId, + }) + .from(agentTask) + .where( + and( + eq(agentTask.kind, input.kind), + isNull(agentTask.finishedAt), + inArray(agentTask[subject], ids), + ), + ); const taken = new Set( outstanding.map((row) => @@ -452,8 +472,8 @@ export class AgentTriggerService { const fresh = ids.filter((id) => !taken.has(id)); if (fresh.length > 0) { - await this.db.agentTask.createMany({ - data: fresh.map((id) => ({ + await this.db.insert(agentTask).values( + fresh.map((id) => ({ contactId: input.contactIds ? id : null, companyId: input.companyIds ? id : null, kind: input.kind, @@ -462,7 +482,7 @@ export class AgentTriggerService { budget: input.budget ?? 4, dueAt: new Date(), })), - }); + ); } this.logger.log({ @@ -495,50 +515,55 @@ export class AgentTriggerService { reason: string; priority: number; budget: number; - payload?: Prisma.InputJsonValue; + payload?: JsonValue; subject?: { path: string[]; value: string }; }, required = false, - client?: Prisma.TransactionClient, + client?: Executor, ): Promise { try { - const write = async (tx: Prisma.TransactionClient) => { + const write = async (tx: Executor) => { await lockIdempotencyKey( tx, `agent-task:${task.kind}:${task.contactId ?? ""}:${task.companyId ?? ""}:${task.subject?.value ?? ""}`, ); - const pending = await tx.agentTask.findFirst({ - where: { - kind: task.kind, - finishedAt: null, - contactId: task.contactId ?? undefined, - companyId: task.companyId ?? undefined, - payload: task.subject - ? { path: task.subject.path, equals: task.subject.value } - : undefined, - }, - select: { id: true }, - }); + const [pending] = await tx + .select({ id: agentTask.id }) + .from(agentTask) + .where( + and( + eq(agentTask.kind, task.kind), + isNull(agentTask.finishedAt), + task.contactId + ? eq(agentTask.contactId, task.contactId) + : undefined, + task.companyId + ? eq(agentTask.companyId, task.companyId) + : undefined, + task.subject + ? sql`${agentTask.payload} #>> ${`{${task.subject.path.join(",")}}`} = ${task.subject.value}` + : undefined, + ), + ) + .limit(1); if (pending) return false; - await tx.agentTask.create({ - data: { - contactId: task.contactId ?? null, - companyId: task.companyId ?? null, - kind: task.kind, - reason: task.reason, - priority: task.priority, - budget: task.budget, - dueAt: new Date(), - payload: task.payload ?? undefined, - }, + await tx.insert(agentTask).values({ + contactId: task.contactId ?? null, + companyId: task.companyId ?? null, + kind: task.kind, + reason: task.reason, + priority: task.priority, + budget: task.budget, + dueAt: new Date(), + payload: task.payload, }); return true; }; const created = client ? await write(client) - : await this.db.$transaction(write); + : await this.db.transaction(write); if (!created) return false; this.logger.log({ @@ -562,13 +587,13 @@ export class AgentTriggerService { } private async createEventTasks( - tx: Prisma.TransactionClient, + tx: Executor, inputs: CrmEventInput[], ): Promise { if (inputs.length === 0) return; const dueAt = new Date(); - await tx.agentTask.createMany({ - data: inputs.map((input) => ({ + await tx.insert(agentTask).values( + inputs.map((input) => ({ contactId: input.record.kind === "contact" ? input.record.id : null, companyId: input.record.kind === "company" ? input.record.id : null, dealId: input.record.kind === "deal" ? input.record.id : null, @@ -584,7 +609,7 @@ export class AgentTriggerService { budget: 1, dueAt, })), - }); + ); } canReachAgent(): boolean { diff --git a/apps/api/src/api-keys/api-keys.service.ts b/apps/api/src/api-keys/api-keys.service.ts index dff539960..a37fcfe1a 100644 --- a/apps/api/src/api-keys/api-keys.service.ts +++ b/apps/api/src/api-keys/api-keys.service.ts @@ -1,6 +1,6 @@ import { DAY_SECONDS } from "@crm/auth/api-keys"; import { auth } from "@crm/auth/auth"; -import type { Db, Prisma } from "@crm/db"; +import { and, apikey, contains, type Db, desc, eq, type SQL } from "@crm/db"; import { HttpException, Injectable, @@ -14,6 +14,7 @@ import { type OrderByColumns, paginate, resolveOrderBy, + sortBy, } from "../trpc/list-input"; import type { ApiKeyListInput, @@ -23,23 +24,31 @@ import type { RevokeApiKeyInput, } from "./api-keys.contracts"; -const KEY_SELECT = { - id: true, - name: true, - start: true, - enabled: true, - createdAt: true, - lastRequest: true, - expiresAt: true, -} satisfies Prisma.ApikeySelect; - -type KeyRow = Prisma.ApikeyGetPayload<{ select: typeof KEY_SELECT }>; - -const SORTABLE: OrderByColumns = { - name: (dir) => ({ name: dir }), - createdAt: (dir) => ({ createdAt: dir }), - lastRequest: (dir) => ({ lastRequest: dir }), - expiresAt: (dir) => ({ expiresAt: dir }), +const KEY_FIELDS = { + id: apikey.id, + name: apikey.name, + start: apikey.start, + enabled: apikey.enabled, + createdAt: apikey.createdAt, + lastRequest: apikey.lastRequest, + expiresAt: apikey.expiresAt, +}; + +type KeyRow = { + id: string; + name: string | null; + start: string | null; + enabled: boolean | null; + createdAt: Date; + lastRequest: Date | null; + expiresAt: Date | null; +}; + +const SORTABLE: OrderByColumns = { + name: (dir) => sortBy(apikey.name, dir), + createdAt: (dir) => sortBy(apikey.createdAt, dir), + lastRequest: (dir) => sortBy(apikey.lastRequest, dir), + expiresAt: (dir) => sortBy(apikey.expiresAt, dir), }; const STATUS_BY_CODE = new Map([ @@ -77,14 +86,14 @@ export class ApiKeysService { const { skip, take } = paginate(input); const [rows, total] = await Promise.all([ - this.db.apikey.findMany({ - where, - skip, - take, - select: KEY_SELECT, - orderBy: resolveOrderBy(input, SORTABLE, { createdAt: "desc" }), - }), - this.db.apikey.count({ where }), + this.db + .select(KEY_FIELDS) + .from(apikey) + .where(where) + .orderBy(resolveOrderBy(input, SORTABLE, desc(apikey.createdAt))) + .limit(take) + .offset(skip), + this.db.$count(apikey, where), ]); return { rows: rows.map(toSummary), total, facetCounts: {} }; @@ -144,15 +153,12 @@ export class ApiKeysService { return { id: input.id }; } - private searchWhere(userId: string, q: string): Prisma.ApikeyWhereInput { - const where: Prisma.ApikeyWhereInput = { referenceId: userId }; + private searchWhere(userId: string, q: string): SQL | undefined { const term = q.trim(); - - if (term) { - where.name = { contains: term, mode: "insensitive" }; - } - - return where; + return and( + eq(apikey.referenceId, userId), + term ? contains(apikey.name, term, { insensitive: true }) : undefined, + ); } private async call(run: () => Promise): Promise { diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 98b9408ea..5e329e3e2 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -1,4 +1,4 @@ -import type { Db } from "@crm/db"; +import { type Db, eq, user as userTable } from "@crm/db"; import { CACHE_MANAGER } from "@nestjs/cache-manager"; import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common"; import type { Cache } from "cache-manager"; @@ -36,17 +36,18 @@ export class AuthService { this.logger.debug({ message: "Profile cache miss", userId }); - const user = await this.db.user.findUnique({ - where: { id: userId }, - select: { - id: true, - name: true, - email: true, - emailVerified: true, - image: true, - createdAt: true, - }, - }); + const [user] = await this.db + .select({ + id: userTable.id, + name: userTable.name, + email: userTable.email, + emailVerified: userTable.emailVerified, + image: userTable.image, + createdAt: userTable.createdAt, + }) + .from(userTable) + .where(eq(userTable.id, userId)) + .limit(1); if (!user) { this.logger.warn({ message: "Session user no longer exists", userId }); diff --git a/apps/api/src/backfill/backfill.service.ts b/apps/api/src/backfill/backfill.service.ts index 04f0d8176..2095db114 100644 --- a/apps/api/src/backfill/backfill.service.ts +++ b/apps/api/src/backfill/backfill.service.ts @@ -1,5 +1,23 @@ import { onSignedIn } from "@crm/auth/signed-in"; -import { type Db, EnrichmentStatus, type Prisma } from "@crm/db"; +import { + agentTask, + and, + asc, + company, + contact, + type Db, + deal, + EnrichmentStatus, + eq, + exists, + gte, + inArray, + isNotNull, + isNull, + notInArray, + or, + type SQL, +} from "@crm/db"; import { PRIORITY } from "@crm/db/agent-tasks"; import { readWorkspaceIdentity } from "@crm/db/workspace"; import { CACHE_MANAGER } from "@nestjs/cache-manager"; @@ -23,9 +41,7 @@ const MAX_PER_RUN = 500; const MAX_FAVICONS = 25; -const NEVER_SUCCEEDED: Prisma.EnumEnrichmentStatusFilter = { - in: [EnrichmentStatus.PENDING, EnrichmentStatus.FAILED], -}; +const NEVER_SUCCEEDED = [EnrichmentStatus.PENDING, EnrichmentStatus.FAILED]; const AUTO_KEY = "backfill:auto"; @@ -98,13 +114,19 @@ export class BackfillService implements OnModuleInit { if (!us?.website || us.profile) return; - const attempted = await this.db.agentTask.findFirst({ - where: { - kind: "workspace-profile", - finishedAt: { gte: new Date(Date.now() - RECHECK_WORKSPACE_AFTER_MS) }, - }, - select: { id: true }, - }); + const [attempted] = await this.db + .select({ id: agentTask.id }) + .from(agentTask) + .where( + and( + eq(agentTask.kind, "workspace-profile"), + gte( + agentTask.finishedAt, + new Date(Date.now() - RECHECK_WORKSPACE_AFTER_MS), + ), + ), + ) + .limit(1); if (attempted) return; @@ -121,29 +143,32 @@ export class BackfillService implements OnModuleInit { } private async runCompanies(dealsOnly: boolean): Promise { - const onDeals: Prisma.CompanyWhereInput = dealsOnly - ? { deals: { some: {} } } - : {}; + const onDeals = dealsOnly + ? exists( + this.db + .select({ id: deal.id }) + .from(deal) + .where(eq(deal.companyId, company.id)), + ) + : undefined; const needsBrand = this.companiesNeedingBrand(); const needsArtwork = await this.companiesNeedingArtwork(); const [total, rows, artworkRows] = await Promise.all([ - this.db.company.count({ - where: { ...onDeals, OR: [needsBrand, needsArtwork] }, - }), - this.db.company.findMany({ - where: { ...needsBrand, ...onDeals }, - orderBy: { createdAt: "asc" }, - take: MAX_PER_RUN, - select: { id: true }, - }), - this.db.company.findMany({ - where: { ...needsArtwork, ...onDeals }, - orderBy: { createdAt: "asc" }, - take: MAX_PER_RUN, - select: { id: true }, - }), + this.db.$count(company, and(onDeals, or(needsBrand, needsArtwork))), + this.db + .select({ id: company.id }) + .from(company) + .where(and(needsBrand, onDeals)) + .orderBy(asc(company.createdAt)) + .limit(MAX_PER_RUN), + this.db + .select({ id: company.id }) + .from(company) + .where(and(needsArtwork, onDeals)) + .orderBy(asc(company.createdAt)) + .limit(MAX_PER_RUN), ]); const companyIds = [ @@ -185,13 +210,13 @@ export class BackfillService implements OnModuleInit { const needsPhoto = await this.contactsNeedingPhoto(); const [photoTotal, photoRows] = await Promise.all([ - this.db.contact.count({ where: needsPhoto }), - this.db.contact.findMany({ - where: needsPhoto, - orderBy: { createdAt: "asc" }, - take: MAX_PER_RUN, - select: { id: true }, - }), + this.db.$count(contact, needsPhoto), + this.db + .select({ id: contact.id }) + .from(contact) + .where(needsPhoto) + .orderBy(asc(contact.createdAt)) + .limit(MAX_PER_RUN), ]); const photos = await this.agent.backfill({ @@ -205,14 +230,14 @@ export class BackfillService implements OnModuleInit { const headroom = MAX_PER_RUN - photoRows.length; const [researchTotal, researchRows] = await Promise.all([ - this.db.contact.count({ where: this.contactsNeverResearched() }), + this.db.$count(contact, this.contactsNeverResearched()), headroom > 0 - ? this.db.contact.findMany({ - where: this.contactsNeverResearched(), - orderBy: { createdAt: "asc" }, - take: headroom, - select: { id: true }, - }) + ? this.db + .select({ id: contact.id }) + .from(contact) + .where(this.contactsNeverResearched()) + .orderBy(asc(contact.createdAt)) + .limit(headroom) : Promise.resolve([]), ]); @@ -233,12 +258,12 @@ export class BackfillService implements OnModuleInit { } private async sweepFavicons(): Promise { - const rows = await this.db.company.findMany({ - where: { domain: { not: null }, iconUrl: null }, - orderBy: { createdAt: "asc" }, - take: MAX_FAVICONS, - select: { id: true, domain: true }, - }); + const rows = await this.db + .select({ id: company.id, domain: company.domain }) + .from(company) + .where(and(isNotNull(company.domain), isNull(company.iconUrl))) + .orderBy(asc(company.createdAt)) + .limit(MAX_FAVICONS); if (rows.length === 0) return 0; @@ -257,30 +282,35 @@ export class BackfillService implements OnModuleInit { return rows.length; } - private companiesNeedingBrand(): Prisma.CompanyWhereInput { - return { domain: { not: null }, enrichmentStatus: NEVER_SUCCEEDED }; + private companiesNeedingBrand(): SQL | undefined { + return and( + isNotNull(company.domain), + inArray(company.enrichmentStatus, NEVER_SUCCEEDED), + ); } - private async companiesNeedingArtwork(): Promise { + private async companiesNeedingArtwork(): Promise { const since = new Date(Date.now() - RECHECK_BRAND_AFTER_MS); - const checked = await this.db.agentTask.findMany({ - where: { kind: "brand", finishedAt: { gte: since } }, - select: { companyId: true }, - }); + const checked = await this.db + .select({ companyId: agentTask.companyId }) + .from(agentTask) + .where( + and(eq(agentTask.kind, "brand"), gte(agentTask.finishedAt, since)), + ); const recentlyChecked = checked .map((row) => row.companyId) .filter((id): id is string => id !== null); - const where: Prisma.CompanyWhereInput = { - domain: { not: null }, - logoUrl: null, - iconUrl: null, - }; - if (recentlyChecked.length > 0) where.id = { notIn: recentlyChecked }; - - return where; + return and( + isNotNull(company.domain), + isNull(company.logoUrl), + isNull(company.iconUrl), + recentlyChecked.length > 0 + ? notInArray(company.id, recentlyChecked) + : undefined, + ); } /** @@ -298,35 +328,40 @@ export class BackfillService implements OnModuleInit { * task is the record that we looked; a month is long enough that a rebuilt * team page or a new LinkedIn account is still picked up eventually. */ - private async contactsNeedingPhoto(): Promise { + private async contactsNeedingPhoto(): Promise { const since = new Date(Date.now() - RECHECK_PHOTO_AFTER_MS); - // `AgentTask.contactId` is a bare column with no Prisma relation, so this - // cannot be a nested `some`. Two queries, and the id list is bounded by - // the number of contacts we have already looked for. - const checked = await this.db.agentTask.findMany({ - where: { kind: "portrait", finishedAt: { gte: since } }, - select: { contactId: true }, - }); + const checked = await this.db + .select({ contactId: agentTask.contactId }) + .from(agentTask) + .where( + and(eq(agentTask.kind, "portrait"), gte(agentTask.finishedAt, since)), + ); const recentlyChecked = checked .map((row) => row.contactId) .filter((id): id is string => id !== null); - const where: Prisma.ContactWhereInput = { - imageUrl: null, - OR: [ - { linkedinUrl: { not: null } }, - { githubUrl: { not: null } }, - { company: { domain: { not: null } } }, - ], - }; - if (recentlyChecked.length > 0) where.id = { notIn: recentlyChecked }; - - return where; + return and( + isNull(contact.imageUrl), + or( + isNotNull(contact.linkedinUrl), + isNotNull(contact.githubUrl), + inArray( + contact.companyId, + this.db + .select({ id: company.id }) + .from(company) + .where(isNotNull(company.domain)), + ), + ), + recentlyChecked.length > 0 + ? notInArray(contact.id, recentlyChecked) + : undefined, + ); } - private contactsNeverResearched(): Prisma.ContactWhereInput { - return { enrichmentStatus: NEVER_SUCCEEDED }; + private contactsNeverResearched(): SQL { + return inArray(contact.enrichmentStatus, NEVER_SUCCEEDED); } } diff --git a/apps/api/src/backfill/image-mirror.service.ts b/apps/api/src/backfill/image-mirror.service.ts index 1f295de76..376f927f7 100644 --- a/apps/api/src/backfill/image-mirror.service.ts +++ b/apps/api/src/backfill/image-mirror.service.ts @@ -1,4 +1,19 @@ -import type { Db, Prisma } from "@crm/db"; +import { + and, + asc, + company, + contact, + contains, + type Db, + eq, + isNotNull, + isNull, + type NewCompany, + not, + or, + type SQL, + user, +} from "@crm/db"; import { blobEnabled, mirror } from "@crm/db/blob"; import { BLOB_HOST_SUFFIX, @@ -12,15 +27,15 @@ const MAX_PER_SWEEP = 25; type CompanyImageRow = Record; -const EXTERNAL_CONTACT_IMAGE: Prisma.ContactWhereInput = { - imageUrl: { not: null }, - NOT: { imageUrl: { contains: BLOB_HOST_SUFFIX } }, -}; +const EXTERNAL_CONTACT_IMAGE = and( + isNotNull(contact.imageUrl), + not(contains(contact.imageUrl, BLOB_HOST_SUFFIX)), +); -const EXTERNAL_USER_IMAGE: Prisma.UserWhereInput = { - image: { not: null }, - NOT: { image: { contains: BLOB_HOST_SUFFIX } }, -}; +const EXTERNAL_USER_IMAGE = and( + isNotNull(user.image), + not(contains(user.image, BLOB_HOST_SUFFIX)), +); export type ImageMirrorResult = { scanned: number; @@ -58,25 +73,23 @@ export class ImageMirrorService { } private async sweepCompanies(): Promise { - const rows = await this.db.company.findMany({ - where: { - OR: COMPANY_IMAGE_FIELDS.map(externalCompanyImage), - }, - orderBy: { createdAt: "asc" }, - take: MAX_PER_SWEEP, - select: { - id: true, - logoUrl: true, - logoDarkUrl: true, - iconUrl: true, - iconDarkUrl: true, - }, - }); + const rows = await this.db + .select({ + id: company.id, + logoUrl: company.logoUrl, + logoDarkUrl: company.logoDarkUrl, + iconUrl: company.iconUrl, + iconDarkUrl: company.iconDarkUrl, + }) + .from(company) + .where(or(...COMPANY_IMAGE_FIELDS.map(externalCompanyImage))) + .orderBy(asc(company.createdAt)) + .limit(MAX_PER_SWEEP); let copied = 0; for (const row of rows) { - const data: Prisma.CompanyUpdateInput = {}; + const data: Partial = {}; for (const field of COMPANY_IMAGE_FIELDS) { const current = row[field]; @@ -91,22 +104,22 @@ export class ImageMirrorService { if (Object.keys(data).length === 0) continue; - await this.db.company.updateMany({ - where: { id: row.id, ...unchanged(row) }, - data, - }); + await this.db + .update(company) + .set(data) + .where(and(eq(company.id, row.id), unchanged(row))); } return { scanned: rows.length, copied }; } private async sweepContacts(): Promise { - const rows = await this.db.contact.findMany({ - where: EXTERNAL_CONTACT_IMAGE, - orderBy: { createdAt: "asc" }, - take: MAX_PER_SWEEP, - select: { id: true, imageUrl: true }, - }); + const rows = await this.db + .select({ id: contact.id, imageUrl: contact.imageUrl }) + .from(contact) + .where(EXTERNAL_CONTACT_IMAGE) + .orderBy(asc(contact.createdAt)) + .limit(MAX_PER_SWEEP); let copied = 0; @@ -116,23 +129,24 @@ export class ImageMirrorService { const stored = await mirror(row.imageUrl, `contacts/${row.id}`); if (!stored || stored === row.imageUrl) continue; - const { count } = await this.db.contact.updateMany({ - where: { id: row.id, imageUrl: row.imageUrl }, - data: { imageUrl: stored }, - }); + const changed = await this.db + .update(contact) + .set({ imageUrl: stored }) + .where(and(eq(contact.id, row.id), eq(contact.imageUrl, row.imageUrl))) + .returning({ id: contact.id }); - copied += count; + copied += changed.length; } return { scanned: rows.length, copied }; } private async sweepUsers(): Promise { - const rows = await this.db.user.findMany({ - where: EXTERNAL_USER_IMAGE, - take: MAX_PER_SWEEP, - select: { id: true, image: true }, - }); + const rows = await this.db + .select({ id: user.id, image: user.image }) + .from(user) + .where(EXTERNAL_USER_IMAGE) + .limit(MAX_PER_SWEEP); let copied = 0; @@ -142,33 +156,33 @@ export class ImageMirrorService { const stored = await mirror(row.image, `users/${row.id}/avatar`); if (!stored || stored === row.image) continue; - const { count } = await this.db.user.updateMany({ - where: { id: row.id, image: row.image }, - data: { image: stored }, - }); + const changed = await this.db + .update(user) + .set({ image: stored }) + .where(and(eq(user.id, row.id), eq(user.image, row.image))) + .returning({ id: user.id }); - copied += count; + copied += changed.length; } return { scanned: rows.length, copied }; } } -function externalCompanyImage( - field: CompanyImageField, -): Prisma.CompanyWhereInput { - const mirrored: Prisma.CompanyWhereInput = {}; - mirrored[field] = { contains: BLOB_HOST_SUFFIX }; - - const where: Prisma.CompanyWhereInput = { NOT: mirrored }; - where[field] = { not: null }; - - return where; +function externalCompanyImage(field: CompanyImageField): SQL | undefined { + return and( + isNotNull(company[field]), + not(contains(company[field], BLOB_HOST_SUFFIX)), + ); } -function unchanged(row: CompanyImageRow): Prisma.CompanyWhereInput { - const where: Prisma.CompanyWhereInput = {}; - for (const field of COMPANY_IMAGE_FIELDS) where[field] = row[field] ?? null; - - return where; +function unchanged(row: CompanyImageRow): SQL | undefined { + return and( + ...COMPANY_IMAGE_FIELDS.map((field) => { + const value = row[field]; + return value === null + ? isNull(company[field]) + : eq(company[field], value); + }), + ); } diff --git a/apps/api/src/companies/companies.service.ts b/apps/api/src/companies/companies.service.ts index 5d896f5c0..db5b4f277 100644 --- a/apps/api/src/companies/companies.service.ts +++ b/apps/api/src/companies/companies.service.ts @@ -1,9 +1,26 @@ import { + activity, + agentTask, + and, + asc, + company, + contact, + contains, type Db, + deal, + desc, EnrichmentStatus, - type Prisma, - Prisma as PrismaNamespace, + eq, + hasValues, + inArray, + isNull, + isUniqueViolation, + lte, + type NewCompany, + or, RecordSource, + type SQL, + user, } from "@crm/db"; import { OPEN_DEAL_STAGES } from "@crm/db/deal-stage"; import type { FieldDefinitionWithOptions } from "@crm/db/fields"; @@ -37,11 +54,14 @@ import { archivedFilter, countsByKey, FACET_UNASSIGNED, + groupCounts, type ListResult, type OrderByColumns, ownerFilter, paginate, resolveOrderBy, + sortBy, + sortNullsLast, } from "../trpc/list-input"; import type { CompanyBulkOwnerInput, @@ -53,29 +73,63 @@ import type { import { normalizeDomain } from "./domain"; import { FaviconService } from "./favicon.service"; -const OWNER_SELECT = { +const OWNER_COLUMNS = { id: true, name: true, email: true, image: true, } as const; -const SORTABLE: OrderByColumns = { - name: (dir) => ({ name: dir }), - domain: (dir) => ({ domain: dir }), - industry: (dir) => ({ industry: dir }), - createdAt: (dir) => ({ createdAt: dir }), - contacts: (dir) => ({ contacts: { _count: dir } }), - deals: (dir) => ({ deals: { _count: dir } }), - owner: (dir) => ({ owner: { name: dir } }), - lastActivity: (dir) => ({ lastActivityAt: { sort: dir, nulls: "last" } }), - archivedAt: (dir) => ({ archivedAt: { sort: dir, nulls: "last" } }), +const OWNER_FIELDS = { + id: user.id, + name: user.name, + email: user.email, + image: user.image, }; +@Injectable() +class CompanyCounts { + constructor(@InjectDatabase() private readonly db: Db) {} + + contacts() { + return this.db.$count(contact, eq(contact.companyId, company.id)); + } + + deals() { + return this.db.$count(deal, eq(deal.companyId, company.id)); + } + + openDeals() { + return this.db.$count( + deal, + and( + eq(deal.companyId, company.id), + inArray(deal.stage, [...OPEN_DEAL_STAGES]), + ), + ); + } +} + +function sortable(counts: CompanyCounts): OrderByColumns { + return { + name: (dir) => sortBy(company.name, dir), + domain: (dir) => sortBy(company.domain, dir), + industry: (dir) => sortBy(company.industry, dir), + createdAt: (dir) => sortBy(company.createdAt, dir), + contacts: (dir) => sortBy(counts.contacts(), dir), + deals: (dir) => sortBy(counts.deals(), dir), + owner: (dir) => sortBy(user.name, dir), + lastActivity: (dir) => sortNullsLast(company.lastActivityAt, dir), + archivedAt: (dir) => sortNullsLast(company.archivedAt, dir), + }; +} + @Injectable() export class CompaniesService { private readonly logger = new Logger(CompaniesService.name); + private readonly counts: CompanyCounts; + constructor( @InjectDatabase() private readonly db: Db, private readonly agent: AgentTriggerService, @@ -84,7 +138,9 @@ export class CompaniesService { private readonly stamp: ActivityStampService, private readonly conversion: ConversionService, private readonly fields: FieldsService, - ) {} + ) { + this.counts = new CompanyCounts(db); + } async list(input: CompanyListInput): Promise> { const filterableFields = await this.fields.filterableFieldsFor("COMPANY"); @@ -92,38 +148,35 @@ export class CompaniesService { const { skip, take } = paginate(input); const [rows, total, facetCounts] = await Promise.all([ - this.db.company.findMany({ - where, - skip, - take, - orderBy: resolveOrderBy(input, SORTABLE, { - createdAt: "desc", - }), - select: { - id: true, - name: true, - domain: true, - iconUrl: true, - iconDarkUrl: true, - iconTone: true, - logoUrl: true, - brandColor: true, - industry: true, - enrichmentStatus: true, - source: true, - owner: { select: OWNER_SELECT }, - _count: { - select: { - contacts: true, - deals: { where: { stage: { in: [...OPEN_DEAL_STAGES] } } }, - }, - }, - lastActivityAt: true, - createdAt: true, - archivedAt: true, - }, - }), - this.db.company.count({ where }), + this.db + .select({ + id: company.id, + name: company.name, + domain: company.domain, + iconUrl: company.iconUrl, + iconDarkUrl: company.iconDarkUrl, + iconTone: company.iconTone, + logoUrl: company.logoUrl, + brandColor: company.brandColor, + industry: company.industry, + enrichmentStatus: company.enrichmentStatus, + source: company.source, + owner: OWNER_FIELDS, + contactCount: this.counts.contacts(), + openDealCount: this.counts.openDeals(), + lastActivityAt: company.lastActivityAt, + createdAt: company.createdAt, + archivedAt: company.archivedAt, + }) + .from(company) + .leftJoin(user, eq(user.id, company.ownerId)) + .where(where) + .orderBy( + resolveOrderBy(input, sortable(this.counts), desc(company.createdAt)), + ) + .limit(take) + .offset(skip), + this.db.$count(company, where), this.facetCounts(input, filterableFields), ]); @@ -148,8 +201,8 @@ export class CompaniesService { queued: queued.has(row.id), source: row.source, owner: row.owner, - contactCount: row._count.contacts, - openDealCount: row._count.deals, + contactCount: row.contactCount, + openDealCount: row.openDealCount, lastActivityAt: row.lastActivityAt?.toISOString() ?? null, createdAt: row.createdAt.toISOString(), archivedAt: row.archivedAt?.toISOString() ?? null, @@ -161,9 +214,9 @@ export class CompaniesService { } async byId(id: string) { - const company = await this.db.company.findUnique({ - where: { id }, - select: { + const record = await this.db.query.company.findFirst({ + where: eq(company.id, id), + columns: { id: true, name: true, domain: true, @@ -194,9 +247,11 @@ export class CompaniesService { source: true, createdAt: true, archivedAt: true, - owner: { select: OWNER_SELECT }, + }, + with: { + owner: { columns: OWNER_COLUMNS }, primaryContact: { - select: { + columns: { id: true, firstName: true, lastName: true, @@ -206,20 +261,20 @@ export class CompaniesService { }, }, contacts: { - orderBy: [{ lastName: "asc" }, { firstName: "asc" }], - select: { + orderBy: [asc(contact.lastName), asc(contact.firstName)], + columns: { id: true, firstName: true, lastName: true, email: true, title: true, imageUrl: true, - owner: { select: OWNER_SELECT }, }, + with: { owner: { columns: OWNER_COLUMNS } }, }, deals: { - orderBy: [{ stage: "asc" }, { expectedCloseDate: "asc" }], - select: { + orderBy: [asc(deal.stage), asc(deal.expectedCloseDate)], + columns: { id: true, name: true, stage: true, @@ -227,13 +282,13 @@ export class CompaniesService { currency: true, baseAmount: true, expectedCloseDate: true, - owner: { select: OWNER_SELECT }, }, + with: { owner: { columns: OWNER_COLUMNS } }, }, }, }); - if (!company) { + if (!record) { throw new NotFoundException(`No company with id ${id}.`); } @@ -244,7 +299,7 @@ export class CompaniesService { createdAt, archivedAt, ...rest - } = company; + } = record; return { ...rest, @@ -268,24 +323,30 @@ export class CompaniesService { } async options(q: string) { - return this.db.company.findMany({ - where: this.searchFilter(q), - select: { id: true, name: true, domain: true, iconUrl: true }, - orderBy: { name: "asc" }, - take: 100, - }); + return this.db + .select({ + id: company.id, + name: company.name, + domain: company.domain, + iconUrl: company.iconUrl, + }) + .from(company) + .where(this.searchFilter(q)) + .orderBy(asc(company.name)) + .limit(100); } async create(input: CompanyCreateInput) { const domain = normalizeDomain(input.domain); - const company = await this.agent.withCrmEvents(async (tx, emit) => { + const created = await this.agent.withCrmEvents(async (tx, emit) => { if (domain) { await lockIdempotencyKey(tx, `company-directory:${domain}`); - const existing = await tx.company.findFirst({ - where: { domain, archivedAt: null }, - select: { name: true }, - }); + const [existing] = await tx + .select({ name: company.name }) + .from(company) + .where(and(eq(company.domain, domain), isNull(company.archivedAt))) + .limit(1); if (existing) { throw new ConflictException( `${existing.name} already uses the domain ${domain}.`, @@ -293,15 +354,22 @@ export class CompaniesService { } } - const created = await tx.company.create({ - data: { + const [inserted] = await tx + .insert(company) + .values({ name: input.name.trim(), domain, website: domain ? `https://${domain}` : null, ownerId: input.ownerId ?? null, - }, - select: { id: true, name: true, domain: true, createdAt: true }, - }); + }) + .returning({ + id: company.id, + name: company.name, + domain: company.domain, + createdAt: company.createdAt, + }); + if (!inserted) throw new Error("The company insert returned no row."); + const created = inserted; await emit({ type: "company.created", record: { kind: "company", id: created.id }, @@ -313,16 +381,16 @@ export class CompaniesService { this.logger.log({ message: "Company created", - companyId: company.id, - domain: company.domain, + companyId: created.id, + domain: created.domain, }); - await this.agent.companyCreated(company.id); + await this.agent.companyCreated(created.id); - void this.favicon.backfill(company.id, company.domain); - void this.fields.queueBackfillForNewRecord("COMPANY", company.id); + void this.favicon.backfill(created.id, created.domain); + void this.fields.queueBackfillForNewRecord("COMPANY", created.id); - return { id: company.id, name: company.name, domain: company.domain }; + return { id: created.id, name: created.name, domain: created.domain }; } async import(input: CompanyImportInput) { @@ -374,14 +442,20 @@ export class CompaniesService { ); const existing = new Set( - ( - await tx.company.findMany({ - where: { domain: { in: domains }, archivedAt: null }, - select: { domain: true }, - }) - ).flatMap((company) => (company.domain ? [company.domain] : [])), + (domains.length > 0 + ? await tx + .select({ domain: company.domain }) + .from(company) + .where( + and( + inArray(company.domain, domains), + isNull(company.archivedAt), + ), + ) + : ([] as { domain: string | null }[]) + ).flatMap((row) => (row.domain ? [row.domain] : [])), ); - const accepted = []; + const accepted: typeof candidates = []; const seen = new Set(); for (const candidate of candidates) { if (candidate.domain && existing.has(candidate.domain)) { @@ -403,22 +477,29 @@ export class CompaniesService { } if (accepted.length === 0) return []; - const rows = await tx.company.createManyAndReturn({ - data: accepted.map(({ row: _row, ...company }) => ({ - ...company, - website: company.domain ? `https://${company.domain}` : null, - source: RecordSource.IMPORT, - enrichmentStatus: EnrichmentStatus.PENDING, - })), - select: { id: true, name: true, domain: true, createdAt: true }, - }); + const rows = await tx + .insert(company) + .values( + accepted.map(({ row: _row, ...candidate }) => ({ + ...candidate, + website: candidate.domain ? `https://${candidate.domain}` : null, + source: RecordSource.IMPORT, + enrichmentStatus: EnrichmentStatus.PENDING, + })), + ) + .returning({ + id: company.id, + name: company.name, + domain: company.domain, + createdAt: company.createdAt, + }); - for (const company of rows) { + for (const row of rows) { await emit({ type: "company.created", - record: { kind: "company", id: company.id }, - occurredAt: company.createdAt, - data: { name: company.name, domain: company.domain }, + record: { kind: "company", id: row.id }, + occurredAt: row.createdAt, + data: { name: row.name, domain: row.domain }, }); } return rows; @@ -445,7 +526,7 @@ export class CompaniesService { } async update(id: string, input: CompanyUpdateInput) { - const data: Prisma.CompanyUpdateInput = {}; + const data: Partial = {}; if (input.name !== undefined) data.name = input.name.trim(); if (input.website !== undefined) data.website = blankToNull(input.website); @@ -464,11 +545,7 @@ export class CompaniesService { if (input.linkedinUrl !== undefined) { data.linkedinUrl = blankToNull(input.linkedinUrl); } - if (input.ownerId !== undefined) { - data.owner = input.ownerId - ? { connect: { id: input.ownerId } } - : { disconnect: true }; - } + if (input.ownerId !== undefined) data.ownerId = input.ownerId || null; if (input.domain !== undefined) { const domain = normalizeDomain(input.domain); @@ -478,10 +555,11 @@ export class CompaniesService { ); } data.domain = domain; - const current = await this.db.company.findUnique({ - where: { id }, - select: { domain: true }, - }); + const [current] = await this.db + .select({ domain: company.domain }) + .from(company) + .where(eq(company.id, id)) + .limit(1); if (current && current.domain !== domain) { data.enrichmentStatus = "PENDING"; data.enrichmentError = null; @@ -492,16 +570,29 @@ export class CompaniesService { } try { - const updated = await this.db.$transaction(async (tx) => { + const updated = await this.db.transaction(async (tx) => { if (input.fields) { await this.fields.applyValues(tx, "COMPANY", id, input.fields); } - return tx.company.update({ - where: { id }, - data, - select: { id: true, name: true, domain: true }, - }); + const fields = { + id: company.id, + name: company.name, + domain: company.domain, + }; + const [row] = hasValues(data) + ? await tx + .update(company) + .set(data) + .where(eq(company.id, id)) + .returning(fields) + : await tx + .select(fields) + .from(company) + .where(eq(company.id, id)) + .limit(1); + if (!row) throw new NotFoundException(`No company with id ${id}.`); + return row; }); if (data.enrichmentStatus === "PENDING") { @@ -519,35 +610,31 @@ export class CompaniesService { } async archive(id: string): Promise<{ id: string; name: string }> { - try { - const company = await this.db.company.update({ - where: { id }, - data: { archivedAt: new Date() }, - select: { name: true }, - }); - - this.logger.log({ message: "Company archived", companyId: id }); - - return { id, name: company.name }; - } catch (error) { - throw this.translate(error, id); - } + const name = await this.setArchivedAt(id, new Date()); + this.logger.log({ message: "Company archived", companyId: id }); + return { id, name }; } async restore(id: string): Promise<{ id: string; name: string }> { - try { - const company = await this.db.company.update({ - where: { id }, - data: { archivedAt: null }, - select: { name: true }, - }); - - this.logger.log({ message: "Company restored", companyId: id }); + const name = await this.setArchivedAt(id, null); + this.logger.log({ message: "Company restored", companyId: id }); + return { id, name }; + } - return { id, name: company.name }; - } catch (error) { - throw this.translate(error, id); - } + private async setArchivedAt( + id: string, + archivedAt: Date | null, + ): Promise { + const [row] = await this.db + .update(company) + .set({ archivedAt }) + .where(eq(company.id, id)) + .returning({ name: company.name }) + .catch((cause: unknown) => this.translate(cause, id)); + + if (!row) throw new NotFoundException(`No company with id ${id}.`); + + return row.name; } async purge(id: string): Promise<{ id: string; name: string }>; @@ -562,10 +649,12 @@ export class CompaniesService { let deleted: { targets: StampTargets; name: string } | null; try { - deleted = await this.db.$transaction(async (tx) => { - const [row] = await tx.$queryRaw>` - SELECT "archivedAt" FROM company WHERE id = ${id} FOR UPDATE - `; + deleted = await this.db.transaction(async (tx) => { + const [row] = await tx + .select({ archivedAt: company.archivedAt }) + .from(company) + .where(eq(company.id, id)) + .for("update"); if (!row) { if (guard) return null; @@ -578,31 +667,29 @@ export class CompaniesService { return null; } + const dealIds = tx + .select({ id: deal.id }) + .from(deal) + .where(eq(deal.companyId, id)); + const targets = await this.stamp.targetsOf( - { OR: [{ companyId: id }, { deal: { companyId: id } }] }, + or(eq(activity.companyId, id), inArray(activity.dealId, dealIds)), tx, ); - const deals = await tx.deal.findMany({ - where: { companyId: id }, - select: { id: true }, - }); - - await tx.agentTask.deleteMany({ - where: { - OR: [ - { companyId: id }, - { dealId: { in: deals.map((deal) => deal.id) } }, - ], - }, - }); + await tx + .delete(agentTask) + .where( + or(eq(agentTask.companyId, id), inArray(agentTask.dealId, dealIds)), + ); - const company = await tx.company.delete({ - where: { id }, - select: { name: true }, - }); + const [removed] = await tx + .delete(company) + .where(eq(company.id, id)) + .returning({ name: company.name }); + if (!removed) throw new NotFoundException(`No company with id ${id}.`); - return { targets, name: company.name }; + return { targets, name: removed.name }; }); } catch (error) { throw this.translate(error, id); @@ -622,11 +709,11 @@ export class CompaniesService { } async purgeExpired(before: Date): Promise { - const expired = await this.db.company.findMany({ - where: { archivedAt: { lte: before } }, - select: { id: true }, - take: ARCHIVE.prune.maxBatch, - }); + const expired = await this.db + .select({ id: company.id }) + .from(company) + .where(lte(company.archivedAt, before)) + .limit(ARCHIVE.prune.maxBatch); return runBulk( expired.map((row) => row.id), @@ -640,10 +727,13 @@ export class CompaniesService { await requireOwner(this.db, ownerId); const ids = [...new Set(input.ids)]; - const { count } = await this.db.company.updateMany({ - where: { id: { in: ids } }, - data: { ownerId }, - }); + const count = ( + await this.db + .update(company) + .set({ ownerId }) + .where(inArray(company.id, ids)) + .returning({ id: company.id }) + ).length; this.logger.log({ message: "Companies reassigned", @@ -677,12 +767,13 @@ export class CompaniesService { } async enrich(id: string): Promise<{ id: string; queued: boolean }> { - const company = await this.db.company.findUnique({ - where: { id }, - select: { id: true, updatedAt: true }, - }); + const [record] = await this.db + .select({ id: company.id, updatedAt: company.updatedAt }) + .from(company) + .where(eq(company.id, id)) + .limit(1); - if (!company) { + if (!record) { throw new NotFoundException(`No company with id ${id}.`); } @@ -692,26 +783,29 @@ export class CompaniesService { ); if (queued) { - await this.db.company.updateMany({ - where: { id, updatedAt: company.updatedAt }, - data: { enrichmentStatus: "PENDING", enrichmentError: null }, - }); + await this.db + .update(company) + .set({ enrichmentStatus: "PENDING", enrichmentError: null }) + .where( + and(eq(company.id, id), eq(company.updatedAt, record.updatedAt)), + ); } return { id, queued }; } async research(id: string, actingUserId: string) { - const company = await this.db.company.findUnique({ - where: { id }, - select: { id: true, domain: true }, - }); + const [record] = await this.db + .select({ id: company.id, domain: company.domain }) + .from(company) + .where(eq(company.id, id)) + .limit(1); - if (!company) { + if (!record) { throw new NotFoundException(`No company with id ${id}.`); } - if (!company.domain) { + if (!record.domain) { throw new BadRequestException( "There is nothing to read without a domain — add one first.", ); @@ -727,114 +821,100 @@ export class CompaniesService { async setPrimaryContact(companyId: string, contactId: string | null) { if (contactId) { - const contact = await this.db.contact.findUnique({ - where: { id: contactId }, - select: { companyId: true }, - }); - if (!contact) { + const [person] = await this.db + .select({ companyId: contact.companyId }) + .from(contact) + .where(eq(contact.id, contactId)) + .limit(1); + if (!person) { throw new NotFoundException(`No contact with id ${contactId}.`); } - if (contact.companyId !== companyId) { + if (person.companyId !== companyId) { throw new BadRequestException( "That contact does not work at this company.", ); } } - try { - return await this.db.company.update({ - where: { id: companyId }, - data: { primaryContactId: contactId }, - select: { id: true, primaryContactId: true }, - }); - } catch (error) { - throw this.translate(error, companyId); - } + const [row] = await this.db + .update(company) + .set({ primaryContactId: contactId }) + .where(eq(company.id, companyId)) + .returning({ id: company.id, primaryContactId: company.primaryContactId }) + .catch((cause: unknown) => this.translate(cause, companyId)); + + if (!row) throw new NotFoundException(`No company with id ${companyId}.`); + + return row; } - private searchFilter(q: string): Prisma.CompanyWhereInput { + private searchFilter(q: string): SQL | undefined { const term = q.trim(); - if (!term) return {}; + if (!term) return undefined; - return { - OR: [ - { name: { contains: term, mode: "insensitive" } }, - { domain: { contains: term, mode: "insensitive" } }, - ], - }; + return or( + contains(company.name, term, { insensitive: true }), + contains(company.domain, term, { insensitive: true }), + ); } private buildWhere( input: CompanyListInput, filterableFields: FieldDefinitionWithOptions[], - ): Prisma.CompanyWhereInput { - const and: Prisma.CompanyWhereInput[] = [ + ): SQL | undefined { + const conditions: (SQL | undefined)[] = [ this.searchFilter(input.q), - archivedFilter(input.archived), - ...this.fields.fieldFilters(filterableFields, input.fields), + archivedFilter(company.archivedAt, input.archived), + ...this.fields.fieldFilters("COMPANY", filterableFields, input.fields), + ownerFilter(company.ownerId, input.owner), ]; - const owner = ownerFilter(input.owner); - if (owner) and.push(owner); - - if (input.industry.length > 0) - and.push({ industry: { in: input.industry } }); + if (input.industry.length > 0) { + conditions.push(inArray(company.industry, input.industry)); + } if (input.enrichment.length > 0) { - and.push({ - enrichmentStatus: { in: input.enrichment as EnrichmentStatus[] }, - }); + conditions.push( + inArray( + company.enrichmentStatus, + input.enrichment as EnrichmentStatus[], + ), + ); } if (input.source.length > 0) { - and.push({ source: { in: input.source as RecordSource[] } }); + conditions.push(inArray(company.source, input.source as RecordSource[])); } - const activity = activityFilter(input.activity); - if (activity) and.push(activity); + conditions.push(activityFilter(company.lastActivityAt, input.activity)); - return { AND: and }; + return and(...conditions); } private async facetCounts( input: CompanyListInput, filterableFields: FieldDefinitionWithOptions[], ) { - const where = { - AND: [this.searchFilter(input.q), archivedFilter(input.archived)], - }; + const where = and( + this.searchFilter(input.q), + archivedFilter(company.archivedAt, input.archived), + ); const [owners, industries, enrichment, sources, activity, fieldFacets] = await Promise.all([ - this.db.company.groupBy({ - by: ["ownerId"], - where, - _count: { _all: true }, - }), - this.db.company.groupBy({ - by: ["industry"], - where, - _count: { _all: true }, - }), - this.db.company.groupBy({ - by: ["enrichmentStatus"], - where, - _count: { _all: true }, - }), - this.db.company.groupBy({ - by: ["source"], - where, - _count: { _all: true }, - }), - activityFacetCounts((activityWhere) => - this.db.company.count({ where: { AND: [where, activityWhere] } }), + groupCounts(this.db, company, company.ownerId, where), + groupCounts(this.db, company, company.industry, where), + groupCounts(this.db, company, company.enrichmentStatus, where), + groupCounts(this.db, company, company.source, where), + activityFacetCounts(company.lastActivityAt, (activityWhere) => + this.db.$count(company, and(where, activityWhere)), ), this.fields.filterFacetCounts("COMPANY", where, filterableFields), ]); return { - owner: countsByKey(owners, "ownerId", FACET_UNASSIGNED), - industry: countsByKey(industries, "industry"), - enrichment: countsByKey(enrichment, "enrichmentStatus"), - source: countsByKey(sources, "source"), + owner: countsByKey(owners, "value", FACET_UNASSIGNED), + industry: countsByKey(industries, "value"), + enrichment: countsByKey(enrichment, "value"), + source: countsByKey(sources, "value"), activity, ...Object.fromEntries( Object.entries(fieldFacets).map(([key, counts]) => [ @@ -845,16 +925,9 @@ export class CompaniesService { }; } - private translate(cause: unknown, id: string): never { - if (cause instanceof PrismaNamespace.PrismaClientKnownRequestError) { - if (cause.code === "P2025") { - throw new NotFoundException(`No company with id ${id}.`); - } - if (cause.code === "P2002") { - throw new ConflictException( - "Another company already uses that domain.", - ); - } + private translate(cause: unknown, _id: string): never { + if (isUniqueViolation(cause)) { + throw new ConflictException("Another company already uses that domain."); } throw cause; } diff --git a/apps/api/src/companies/company-directory.service.ts b/apps/api/src/companies/company-directory.service.ts index b60f09f15..0239c393e 100644 --- a/apps/api/src/companies/company-directory.service.ts +++ b/apps/api/src/companies/company-directory.service.ts @@ -1,4 +1,4 @@ -import { EnrichmentStatus } from "@crm/db"; +import { and, company, EnrichmentStatus, eq, isNull } from "@crm/db"; import { lockIdempotencyKey } from "@crm/db/idempotency"; import { Injectable, Logger } from "@nestjs/common"; import { AgentTriggerService } from "../agent/agent-trigger.service"; @@ -19,29 +19,36 @@ export class CompanyDirectoryService { const outcome = await this.agent.withCrmEvents(async (tx, emit) => { await lockIdempotencyKey(tx, `company-directory:${domain}`); - const existing = await tx.company.findFirst({ - where: { domain, archivedAt: null }, - select: { id: true }, - }); + const [existing] = await tx + .select({ id: company.id }) + .from(company) + .where(and(eq(company.domain, domain), isNull(company.archivedAt))) + .limit(1); if (existing) return { id: existing.id, created: false as const }; - const company = await tx.company.create({ - data: { + const [created] = await tx + .insert(company) + .values({ name: domain, domain, website: `https://${domain}`, enrichmentStatus: EnrichmentStatus.PENDING, ownerId: options.ownerId ?? null, - }, - select: { id: true, name: true, domain: true, createdAt: true }, - }); + }) + .returning({ + id: company.id, + name: company.name, + domain: company.domain, + createdAt: company.createdAt, + }); + if (!created) throw new Error("The company insert returned no row."); await emit({ type: "company.created", - record: { kind: "company", id: company.id }, - occurredAt: company.createdAt, - data: { name: company.name, domain: company.domain }, + record: { kind: "company", id: created.id }, + occurredAt: created.createdAt, + data: { name: created.name, domain: created.domain }, }); - return { id: company.id, created: true as const }; + return { id: created.id, created: true as const }; }); if (!outcome.created) return outcome.id; diff --git a/apps/api/src/companies/favicon.service.ts b/apps/api/src/companies/favicon.service.ts index 47f3c58e2..d4f10fb87 100644 --- a/apps/api/src/companies/favicon.service.ts +++ b/apps/api/src/companies/favicon.service.ts @@ -1,4 +1,4 @@ -import type { Db } from "@crm/db"; +import { and, company, type Db, eq, isNull } from "@crm/db"; import { mirror } from "@crm/db/blob"; import { resolveFavicon } from "@crm/db/favicon"; import { Injectable, Logger } from "@nestjs/common"; @@ -18,10 +18,21 @@ export class FaviconService { const iconUrl = (await mirror(resolved, `companies/${companyId}/icon`)) ?? resolved; - const { count } = await this.db.company.updateMany({ - where: { id: companyId, iconUrl: null, domain }, - data: { iconUrl }, - }); + const count = ( + await this.db + .update(company) + .set({ iconUrl }) + .where( + and( + eq(company.id, companyId), + isNull(company.iconUrl), + domain === null + ? isNull(company.domain) + : eq(company.domain, domain), + ), + ) + .returning({ id: company.id }) + ).length; if (count > 0) { this.logger.log({ message: "Favicon resolved", companyId, iconUrl }); diff --git a/apps/api/src/contacts/contacts.service.ts b/apps/api/src/contacts/contacts.service.ts index 043fd4b78..2bb664b3e 100644 --- a/apps/api/src/contacts/contacts.service.ts +++ b/apps/api/src/contacts/contacts.service.ts @@ -1,11 +1,42 @@ import { + activity, + agentEvent, + agentTask, + and, + asc, type ContactBriefSections, + calendarAttendee, + calendarEvent, + company, + contact, + contactFact, + contains, + count, type Db, + desc, + type Executor, + emailMessage, + emailThread, + eq, + equalsInsensitive, + exists, type FactEvidence, FactStatus, - type Prisma, - Prisma as PrismaNamespace, + gt, + hasValues, + inArray, + isNull, + isUniqueViolation, + lte, + type NewContact, + ne, + or, type RecordSource, + type SQL, + sql, + sum, + suppressedContact, + user, } from "@crm/db"; import type { FieldDefinitionWithOptions } from "@crm/db/fields"; import { @@ -32,11 +63,14 @@ import { archivedFilter, countsByKey, FACET_UNASSIGNED, + groupCounts, type ListResult, type OrderByColumns, ownerFilter, paginate, resolveOrderBy, + sortBy, + sortNullsLast, splitSentinel, } from "../trpc/list-input"; import type { @@ -49,14 +83,14 @@ import type { FactDecisionInput, } from "./contacts.contracts"; -const OWNER_SELECT = { +const OWNER_COLUMNS = { id: true, name: true, email: true, image: true, } as const; -const COMPANY_SELECT = { +const COMPANY_COLUMNS = { id: true, name: true, domain: true, @@ -66,9 +100,34 @@ const COMPANY_SELECT = { logoUrl: true, } as const; +const OWNER_FIELDS = { + id: user.id, + name: user.name, + email: user.email, + image: user.image, +}; + +const COMPANY_FIELDS = { + id: company.id, + name: company.name, + domain: company.domain, + iconUrl: company.iconUrl, + iconDarkUrl: company.iconDarkUrl, + iconTone: company.iconTone, + logoUrl: company.logoUrl, +}; + const NO_COMPANY = "none"; -type FactColumns = Record; +type FactColumn = + | "title" + | "seniority" + | "function" + | "linkedinUrl" + | "twitterUrl" + | "githubUrl"; + +type FactColumns = Record; const FACT_COLUMNS: FactColumns = { title: "title", @@ -79,15 +138,26 @@ const FACT_COLUMNS: FactColumns = { githubUrl: "githubUrl", }; -const SORTABLE: OrderByColumns = { - name: (dir) => [{ lastName: dir }, { firstName: dir }], - email: (dir) => [{ email: dir }], - title: (dir) => [{ title: dir }, { lastName: "asc" }], - company: (dir) => [{ company: { name: dir } }, { lastName: "asc" }], - createdAt: (dir) => [{ createdAt: dir }], - owner: (dir) => [{ owner: { name: dir } }, { lastName: "asc" }], - lastActivity: (dir) => [{ lastActivityAt: { sort: dir, nulls: "last" } }], - archivedAt: (dir) => [{ archivedAt: { sort: dir, nulls: "last" } }], +const _by = (column: Parameters[0], dir: "asc" | "desc") => + dir === "asc" ? asc(column) : desc(column); + +const _nullsLast = (column: Parameters[0], dir: "asc" | "desc") => + dir === "asc" + ? sql`${column} asc nulls last` + : sql`${column} desc nulls last`; + +const SORTABLE: OrderByColumns = { + name: (dir) => [ + sortBy(contact.lastName, dir), + sortBy(contact.firstName, dir), + ], + email: (dir) => [sortBy(contact.email, dir)], + title: (dir) => [sortBy(contact.title, dir), asc(contact.lastName)], + company: (dir) => [sortBy(company.name, dir), asc(contact.lastName)], + createdAt: (dir) => [sortBy(contact.createdAt, dir)], + owner: (dir) => [sortBy(user.name, dir), asc(contact.lastName)], + lastActivity: (dir) => [sortNullsLast(contact.lastActivityAt, dir)], + archivedAt: (dir) => [sortNullsLast(contact.archivedAt, dir)], }; @Injectable() @@ -109,27 +179,29 @@ export class ContactsService { const { skip, take } = paginate(input); const [rows, total, facetCounts] = await Promise.all([ - this.db.contact.findMany({ - where, - skip, - take, - orderBy: resolveOrderBy(input, SORTABLE, [{ createdAt: "desc" }]), - select: { - id: true, - firstName: true, - lastName: true, - email: true, - title: true, - imageUrl: true, - source: true, - company: { select: COMPANY_SELECT }, - owner: { select: OWNER_SELECT }, - lastActivityAt: true, - createdAt: true, - archivedAt: true, - }, - }), - this.db.contact.count({ where }), + this.db + .select({ + id: contact.id, + firstName: contact.firstName, + lastName: contact.lastName, + email: contact.email, + title: contact.title, + imageUrl: contact.imageUrl, + source: contact.source, + company: COMPANY_FIELDS, + owner: OWNER_FIELDS, + lastActivityAt: contact.lastActivityAt, + createdAt: contact.createdAt, + archivedAt: contact.archivedAt, + }) + .from(contact) + .leftJoin(company, eq(company.id, contact.companyId)) + .leftJoin(user, eq(user.id, contact.ownerId)) + .where(where) + .orderBy(...resolveOrderBy(input, SORTABLE, [desc(contact.createdAt)])) + .limit(take) + .offset(skip), + this.db.$count(contact, where), this.facetCounts(input, filterableFields), ]); @@ -152,9 +224,9 @@ export class ContactsService { } async byId(id: string) { - const contact = await this.db.contact.findUnique({ - where: { id }, - select: { + const record = await this.db.query.contact.findFirst({ + where: eq(contact.id, id), + columns: { id: true, firstName: true, lastName: true, @@ -169,8 +241,10 @@ export class ContactsService { enrichmentError: true, createdAt: true, archivedAt: true, + }, + with: { brief: { - select: { + columns: { narrative: true, sections: true, score: true, @@ -179,9 +253,12 @@ export class ContactsService { }, }, facts: { - where: { status: { in: [FactStatus.APPLIED, FactStatus.PROPOSED] } }, - orderBy: { observedAt: "desc" }, - select: { + where: inArray(contactFact.status, [ + FactStatus.APPLIED, + FactStatus.PROPOSED, + ]), + orderBy: [desc(contactFact.observedAt)], + columns: { id: true, field: true, value: true, @@ -195,43 +272,54 @@ export class ContactsService { }, }, company: { - select: { ...COMPANY_SELECT, industry: true, primaryContactId: true }, + columns: { + ...COMPANY_COLUMNS, + industry: true, + primaryContactId: true, + }, }, - owner: { select: OWNER_SELECT }, + owner: { columns: OWNER_COLUMNS }, deals: { - select: { - role: true, + columns: { role: true }, + with: { deal: { - select: { + columns: { id: true, name: true, stage: true, amount: true, currency: true, expectedCloseDate: true, - owner: { select: OWNER_SELECT }, }, + with: { owner: { columns: OWNER_COLUMNS } }, }, }, }, }, }); - if (!contact) { + if (!record) { throw new NotFoundException(`No contact with id ${id}.`); } const relationship = await this.relationship( id, - contact.company?.id ?? null, + record.company?.id ?? null, ); - const { deals, createdAt, archivedAt, brief, facts, company, ...rest } = - contact; + const { + deals, + createdAt, + archivedAt, + brief, + facts, + company: owningCompany, + ...rest + } = record; return { ...rest, - company, + company: owningCompany, fields: await this.fields.valuesFor("CONTACT", id), queued: await this.queue.isQueued({ contactId: id }), createdAt: createdAt.toISOString(), @@ -249,7 +337,7 @@ export class ContactsService { observedAt: fact.observedAt.toISOString(), })), relationship, - isPrimaryContact: company?.primaryContactId === contact.id, + isPrimaryContact: owningCompany?.primaryContactId === record.id, deals: deals.map(({ role, deal }) => ({ ...deal, role, @@ -264,13 +352,20 @@ export class ContactsService { const email = normalizeEmail(input.email ?? ""); if (email) { - const existing = await this.db.contact.findFirst({ - where: { - email: { equals: email, mode: "insensitive" }, - archivedAt: null, - }, - select: { id: true, firstName: true, lastName: true }, - }); + const [existing] = await this.db + .select({ + id: contact.id, + firstName: contact.firstName, + lastName: contact.lastName, + }) + .from(contact) + .where( + and( + equalsInsensitive(contact.email, email), + isNull(contact.archivedAt), + ), + ) + .limit(1); if (existing) { throw new ConflictException( `${[existing.firstName, existing.lastName].filter(Boolean).join(" ")} already uses ${email}.`, @@ -286,11 +381,12 @@ export class ContactsService { }) : null); - const contact = await this.agent.withCrmEvents(async (tx, emit) => { + const created = await this.agent.withCrmEvents(async (tx, emit) => { await this.allowAgain(tx, email); - const created = await tx.contact.create({ - data: { + const [inserted] = await tx + .insert(contact) + .values({ firstName: input.firstName.trim(), lastName: blankToNull(input.lastName ?? ""), email, @@ -298,16 +394,18 @@ export class ContactsService { title: blankToNull(input.title ?? ""), companyId, ownerId: input.ownerId ?? null, - }, - select: { - id: true, - firstName: true, - lastName: true, - email: true, - companyId: true, - createdAt: true, - }, - }); + }) + .returning({ + id: contact.id, + firstName: contact.firstName, + lastName: contact.lastName, + email: contact.email, + companyId: contact.companyId, + createdAt: contact.createdAt, + }) + .catch((cause: unknown) => this.translate(cause, "")); + if (!inserted) throw new Error("The contact insert returned no row."); + const created = inserted; await emit({ type: "contact.created", record: { kind: "contact", id: created.id }, @@ -322,61 +420,57 @@ export class ContactsService { return created; }); - this.logger.log({ message: "Contact created", contactId: contact.id }); + this.logger.log({ message: "Contact created", contactId: created.id }); await this.agent.contactCreated( - contact.id, + created.id, "Added by a rep, with nothing on the record yet", ); this.fields - .queueBackfillForNewRecord("CONTACT", contact.id) + .queueBackfillForNewRecord("CONTACT", created.id) .catch((error: Error) => { this.logger.error( { message: "Contact backfill queueing failed", - contactId: contact.id, + contactId: created.id, }, error.stack, ); }); return { - id: contact.id, - firstName: contact.firstName, - lastName: contact.lastName, + id: created.id, + firstName: created.firstName, + lastName: created.lastName, }; } async archive(id: string): Promise<{ id: string; name: string }> { - try { - const contact = await this.db.contact.update({ - where: { id }, - data: { archivedAt: new Date() }, - select: { firstName: true, lastName: true }, - }); - - this.logger.log({ message: "Contact archived", contactId: id }); - - return { id, name: nameOf(contact) }; - } catch (error) { - throw this.translate(error, id); - } + const name = await this.setArchivedAt(id, new Date()); + this.logger.log({ message: "Contact archived", contactId: id }); + return { id, name }; } async restore(id: string): Promise<{ id: string; name: string }> { - try { - const contact = await this.db.contact.update({ - where: { id }, - data: { archivedAt: null }, - select: { firstName: true, lastName: true }, - }); - - this.logger.log({ message: "Contact restored", contactId: id }); + const name = await this.setArchivedAt(id, null); + this.logger.log({ message: "Contact restored", contactId: id }); + return { id, name }; + } - return { id, name: nameOf(contact) }; - } catch (error) { - throw this.translate(error, id); - } + private async setArchivedAt( + id: string, + archivedAt: Date | null, + ): Promise { + const [row] = await this.db + .update(contact) + .set({ archivedAt }) + .where(eq(contact.id, id)) + .returning({ firstName: contact.firstName, lastName: contact.lastName }) + .catch((cause: unknown) => this.translate(cause, id)); + + if (!row) throw new NotFoundException(`No contact with id ${id}.`); + + return nameOf(row); } async purge(id: string): Promise<{ id: string; name: string }>; @@ -395,10 +489,12 @@ export class ContactsService { } | null; try { - deleted = await this.db.$transaction(async (tx) => { - const [row] = await tx.$queryRaw>` - SELECT "archivedAt" FROM contact WHERE id = ${id} FOR UPDATE - `; + deleted = await this.db.transaction(async (tx) => { + const [row] = await tx + .select({ archivedAt: contact.archivedAt }) + .from(contact) + .where(eq(contact.id, id)) + .for("update"); if (!row) { if (guard) return null; @@ -411,28 +507,35 @@ export class ContactsService { return null; } - const targets = await this.stamp.targetsOf({ contactId: id }, tx); + const targets = await this.stamp.targetsOf( + eq(activity.contactId, id), + tx, + ); - await tx.agentTask.deleteMany({ where: { contactId: id } }); - await tx.agentEvent.deleteMany({ where: { contactId: id } }); + await tx.delete(agentTask).where(eq(agentTask.contactId, id)); + await tx.delete(agentEvent).where(eq(agentEvent.contactId, id)); - const contact = await tx.contact.delete({ - where: { id }, - select: { firstName: true, lastName: true, email: true }, - }); + const [removed] = await tx + .delete(contact) + .where(eq(contact.id, id)) + .returning({ + firstName: contact.firstName, + lastName: contact.lastName, + email: contact.email, + }); + if (!removed) throw new NotFoundException(`No contact with id ${id}.`); - const name = nameOf(contact); - const suppress = normalizeEmail(contact.email ?? ""); + const name = nameOf(removed); + const suppress = normalizeEmail(removed.email ?? ""); if (suppress) { - await tx.suppressedContact.upsert({ - where: { email: suppress }, - create: { + await tx + .insert(suppressedContact) + .values({ email: suppress, reason: `Deleted from the CRM (${name})`, - }, - update: {}, - }); + }) + .onConflictDoNothing({ target: suppressedContact.email }); } return { targets, name, suppressed: suppress !== null }; @@ -455,11 +558,11 @@ export class ContactsService { } async purgeExpired(before: Date): Promise { - const expired = await this.db.contact.findMany({ - where: { archivedAt: { lte: before } }, - select: { id: true }, - take: ARCHIVE.prune.maxBatch, - }); + const expired = await this.db + .select({ id: contact.id }) + .from(contact) + .where(lte(contact.archivedAt, before)) + .limit(ARCHIVE.prune.maxBatch); return runBulk( expired.map((row) => row.id), @@ -468,7 +571,7 @@ export class ContactsService { } async update(id: string, input: ContactUpdateInput) { - const data: Prisma.ContactUpdateInput = {}; + const data: Partial = {}; if (input.firstName !== undefined) data.firstName = input.firstName.trim(); if (input.lastName !== undefined) @@ -487,28 +590,32 @@ export class ContactsService { if (input.githubUrl !== undefined) { data.githubUrl = blankToNull(input.githubUrl); } - if (input.companyId !== undefined) { - data.company = input.companyId - ? { connect: { id: input.companyId } } - : { disconnect: true }; - } - if (input.ownerId !== undefined) { - data.owner = input.ownerId - ? { connect: { id: input.ownerId } } - : { disconnect: true }; - } + if (input.companyId !== undefined) data.companyId = input.companyId || null; + if (input.ownerId !== undefined) data.ownerId = input.ownerId || null; try { - return await this.db.$transaction(async (tx) => { + return await this.db.transaction(async (tx) => { if (input.fields) { await this.fields.applyValues(tx, "CONTACT", id, input.fields); } - const updated = await tx.contact.update({ - where: { id }, - data, - select: { id: true, firstName: true, lastName: true }, - }); + const fields = { + id: contact.id, + firstName: contact.firstName, + lastName: contact.lastName, + }; + const [updated] = hasValues(data) + ? await tx + .update(contact) + .set(data) + .where(eq(contact.id, id)) + .returning(fields) + : await tx + .select(fields) + .from(contact) + .where(eq(contact.id, id)) + .limit(1); + if (!updated) throw new NotFoundException(`No contact with id ${id}.`); if (email !== null) { await this.allowAgain(tx, email); @@ -527,10 +634,13 @@ export class ContactsService { await requireOwner(this.db, ownerId); const ids = [...new Set(input.ids)]; - const { count } = await this.db.contact.updateMany({ - where: { id: { in: ids } }, - data: { ownerId }, - }); + const count = ( + await this.db + .update(contact) + .set({ ownerId }) + .where(inArray(contact.id, ids)) + .returning({ id: contact.id }) + ).length; this.logger.log({ message: "Contacts reassigned", @@ -551,20 +661,24 @@ export class ContactsService { const companyId = input.companyId || null; if (companyId) { - const company = await this.db.company.findUnique({ - where: { id: companyId }, - select: { id: true }, - }); - if (!company) { + const [found] = await this.db + .select({ id: company.id }) + .from(company) + .where(eq(company.id, companyId)) + .limit(1); + if (!found) { throw new NotFoundException(`No company with id ${companyId}.`); } } const ids = [...new Set(input.ids)]; - const { count } = await this.db.contact.updateMany({ - where: { id: { in: ids } }, - data: { companyId }, - }); + const count = ( + await this.db + .update(contact) + .set({ companyId }) + .where(inArray(contact.id, ids)) + .returning({ id: contact.id }) + ).length; this.logger.log({ message: "Contacts moved", @@ -597,62 +711,85 @@ export class ContactsService { return runBulk(ids, (id) => this.purge(id)); } - private async allowAgain( - tx: Prisma.TransactionClient, - email: string | null, - ): Promise { + private async allowAgain(tx: Executor, email: string | null): Promise { if (!email) return; - await tx.suppressedContact.deleteMany({ - where: { email: { equals: email, mode: "insensitive" } }, - }); + await tx + .delete(suppressedContact) + .where(equalsInsensitive(suppressedContact.email, email)); } private async relationship(contactId: string, companyId: string | null) { const now = new Date(); - const [threads, lastReply, meetings, nextMeeting, colleagues] = + const involved = or( + eq(calendarEvent.contactId, contactId), + exists( + this.db + .select({ id: calendarAttendee.id }) + .from(calendarAttendee) + .where( + and( + eq(calendarAttendee.eventId, calendarEvent.id), + eq(calendarAttendee.contactId, contactId), + ), + ), + ), + ); + + const [[threads], [lastReply], meetings, [nextMeeting], colleagues] = await Promise.all([ - this.db.emailThread.aggregate({ - where: { contactId }, - _sum: { messageCount: true }, - _count: { _all: true }, - }), - this.db.emailMessage.findFirst({ - where: { thread: { contactId }, direction: "INBOUND" }, - orderBy: { sentAt: "desc" }, - select: { sentAt: true }, - }), - this.db.calendarEvent.count({ - where: { - OR: [{ contactId }, { attendees: { some: { contactId } } }], - }, - }), - this.db.calendarEvent.findFirst({ - where: { - startsAt: { gt: now }, - OR: [{ contactId }, { attendees: { some: { contactId } } }], - }, - orderBy: { startsAt: "asc" }, - select: { title: true, startsAt: true }, - }), + this.db + .select({ + messages: sum(emailThread.messageCount).mapWith(Number), + threads: count(), + }) + .from(emailThread) + .where(eq(emailThread.contactId, contactId)), + this.db + .select({ sentAt: emailMessage.sentAt }) + .from(emailMessage) + .innerJoin(emailThread, eq(emailThread.id, emailMessage.threadId)) + .where( + and( + eq(emailThread.contactId, contactId), + eq(emailMessage.direction, "INBOUND"), + ), + ) + .orderBy(desc(emailMessage.sentAt)) + .limit(1), + this.db.$count(calendarEvent, involved), + this.db + .select({ + title: calendarEvent.title, + startsAt: calendarEvent.startsAt, + }) + .from(calendarEvent) + .where(and(gt(calendarEvent.startsAt, now), involved)) + .orderBy(asc(calendarEvent.startsAt)) + .limit(1), companyId - ? this.db.contact.findMany({ - where: { companyId, id: { not: contactId } }, - orderBy: { lastActivityAt: { sort: "desc", nulls: "last" } }, - take: 4, - select: { - id: true, - firstName: true, - lastName: true, - title: true, - }, - }) + ? this.db + .select({ + id: contact.id, + firstName: contact.firstName, + lastName: contact.lastName, + title: contact.title, + }) + .from(contact) + .where( + and( + eq(contact.companyId, companyId), + ne(contact.id, contactId), + ), + ) + .orderBy(sql`${contact.lastActivityAt} desc nulls last`) + .limit(4) : Promise.resolve([]), ]); return { - emails: threads._sum.messageCount ?? 0, - threads: threads._count._all, + emails: threads?.messages ?? 0, + threads: threads?.threads ?? 0, lastReplyAt: lastReply?.sentAt.toISOString() ?? null, meetings, nextMeeting: nextMeeting @@ -672,28 +809,36 @@ export class ContactsService { } async enrich(id: string): Promise<{ id: string; queued: boolean }> { - const contact = await this.db.contact.findUnique({ - where: { id }, - select: { id: true, imageUrl: true, linkedinUrl: true, updatedAt: true }, - }); - - if (!contact) { + const [record] = await this.db + .select({ + id: contact.id, + imageUrl: contact.imageUrl, + linkedinUrl: contact.linkedinUrl, + updatedAt: contact.updatedAt, + }) + .from(contact) + .where(eq(contact.id, id)) + .limit(1); + + if (!record) { throw new NotFoundException(`No contact with id ${id}.`); } const queued = await this.agent.contactCreated( id, - contact.linkedinUrl && !contact.imageUrl + record.linkedinUrl && !record.imageUrl ? "A rep asked for a fresh look — they have a LinkedIn profile on file but no picture" : "A rep asked for a fresh look", true, ); if (queued) { - await this.db.contact.updateMany({ - where: { id, updatedAt: contact.updatedAt }, - data: { enrichmentStatus: "PENDING", enrichmentError: null }, - }); + await this.db + .update(contact) + .set({ enrichmentStatus: "PENDING", enrichmentError: null }) + .where( + and(eq(contact.id, id), eq(contact.updatedAt, record.updatedAt)), + ); } return { id, queued }; @@ -703,16 +848,17 @@ export class ContactsService { input: FactDecisionInput, userId: string, ): Promise<{ contactId: string; field: string; applied: boolean }> { - const fact = await this.db.contactFact.findUnique({ - where: { id: input.factId }, - select: { - id: true, - contactId: true, - field: true, - value: true, - status: true, - }, - }); + const [fact] = await this.db + .select({ + id: contactFact.id, + contactId: contactFact.contactId, + field: contactFact.field, + value: contactFact.value, + status: contactFact.status, + }) + .from(contactFact) + .where(eq(contactFact.id, input.factId)) + .limit(1); if (!fact) { throw new NotFoundException(`No fact with id ${input.factId}.`); @@ -725,44 +871,45 @@ export class ContactsService { const accepted = input.decision === "accept"; const column = FACT_COLUMNS[fact.field]; - await this.db.$transaction(async (tx) => { + await this.db.transaction(async (tx) => { if (accepted) { - await tx.contactFact.updateMany({ - where: { - contactId: fact.contactId, - field: fact.field, - id: { not: fact.id }, - status: { in: [FactStatus.APPLIED, FactStatus.PROPOSED] }, - }, - data: { status: FactStatus.SUPERSEDED, supersededAt: new Date() }, - }); + await tx + .update(contactFact) + .set({ status: FactStatus.SUPERSEDED, supersededAt: new Date() }) + .where( + and( + eq(contactFact.contactId, fact.contactId), + eq(contactFact.field, fact.field), + ne(contactFact.id, fact.id), + inArray(contactFact.status, [ + FactStatus.APPLIED, + FactStatus.PROPOSED, + ]), + ), + ); } - await tx.contactFact.update({ - where: { id: fact.id }, - data: { + await tx + .update(contactFact) + .set({ status: accepted ? FactStatus.APPLIED : FactStatus.DISMISSED, decidedById: userId, decidedAt: new Date(), - }, - }); + }) + .where(eq(contactFact.id, fact.id)); if (accepted && column) { - await tx.contact.update({ - where: { id: fact.contactId }, - data: { [column]: fact.value }, + await this.updateContactOrThrow(tx, fact.contactId, { + [column]: fact.value, }); } if (accepted && fact.field === "name") { const [firstName, ...rest] = fact.value.trim().split(/\s+/); if (firstName) { - await tx.contact.update({ - where: { id: fact.contactId }, - data: { - firstName, - lastName: rest.length > 0 ? rest.join(" ") : null, - }, + await this.updateContactOrThrow(tx, fact.contactId, { + firstName, + lastName: rest.length > 0 ? rest.join(" ") : null, }); } } @@ -779,69 +926,69 @@ export class ContactsService { return { contactId: fact.contactId, field: fact.field, applied: accepted }; } - private searchFilter(q: string): Prisma.ContactWhereInput { + private searchFilter(q: string): SQL | undefined { const term = q.trim(); - if (!term) return {}; - - return { - OR: [ - { firstName: { contains: term, mode: "insensitive" } }, - { lastName: { contains: term, mode: "insensitive" } }, - { email: { contains: term, mode: "insensitive" } }, - { company: { name: { contains: term, mode: "insensitive" } } }, - ], - }; + if (!term) return undefined; + + return or( + contains(contact.firstName, term, { insensitive: true }), + contains(contact.lastName, term, { insensitive: true }), + contains(contact.email, term, { insensitive: true }), + inArray( + contact.companyId, + this.db + .select({ id: company.id }) + .from(company) + .where(contains(company.name, term, { insensitive: true })), + ), + ); } - private companyFilter( - values: string[], - ): Prisma.ContactWhereInput | undefined { + private companyFilter(values: string[]): SQL | undefined { if (values.length === 0) return undefined; const { ids, includesSentinel } = splitSentinel(values, NO_COMPANY); - if (includesSentinel && ids.length === 0) return { companyId: null }; - if (!includesSentinel) return { companyId: { in: ids } }; - return { OR: [{ companyId: { in: ids } }, { companyId: null }] }; + if (includesSentinel && ids.length === 0) return isNull(contact.companyId); + if (!includesSentinel) return inArray(contact.companyId, ids); + return or(inArray(contact.companyId, ids), isNull(contact.companyId)); } private buildWhere( input: ContactListInput, filterableFields: FieldDefinitionWithOptions[], - ): Prisma.ContactWhereInput { - const and: Prisma.ContactWhereInput[] = [ + ): SQL | undefined { + const conditions: (SQL | undefined)[] = [ this.searchFilter(input.q), - archivedFilter(input.archived), - ...this.fields.fieldFilters(filterableFields, input.fields), + archivedFilter(contact.archivedAt, input.archived), + ...this.fields.fieldFilters("CONTACT", filterableFields, input.fields), + ownerFilter(contact.ownerId, input.owner), + this.companyFilter(input.company), ]; - const owner = ownerFilter(input.owner); - if (owner) and.push(owner); - - const company = this.companyFilter(input.company); - if (company) and.push(company); - if (input.source.length > 0) { - and.push({ source: { in: input.source as RecordSource[] } }); + conditions.push(inArray(contact.source, input.source as RecordSource[])); } - if (input.title.length > 0) and.push({ title: { in: input.title } }); + if (input.title.length > 0) + conditions.push(inArray(contact.title, input.title)); if (input.seniority.length > 0) { - and.push({ seniority: { in: input.seniority } }); + conditions.push(inArray(contact.seniority, input.seniority)); } - if (input.persona.length > 0) and.push({ function: { in: input.persona } }); + if (input.persona.length > 0) + conditions.push(inArray(contact.function, input.persona)); - const activity = activityFilter(input.activity); - if (activity) and.push(activity); + conditions.push(activityFilter(contact.lastActivityAt, input.activity)); - return { AND: and }; + return and(...conditions); } private async facetCounts( input: ContactListInput, filterableFields: FieldDefinitionWithOptions[], ) { - const where = { - AND: [this.searchFilter(input.q), archivedFilter(input.archived)], - }; + const where = and( + this.searchFilter(input.q), + archivedFilter(contact.archivedAt, input.archived), + ); const [ owners, @@ -853,49 +1000,25 @@ export class ContactsService { activity, fieldFacets, ] = await Promise.all([ - this.db.contact.groupBy({ - by: ["ownerId"], - where, - _count: { _all: true }, - }), - this.db.contact.groupBy({ - by: ["companyId"], - where, - _count: { _all: true }, - }), - this.db.contact.groupBy({ - by: ["source"], - where, - _count: { _all: true }, - }), - this.db.contact.groupBy({ - by: ["title"], - where, - _count: { _all: true }, - }), - this.db.contact.groupBy({ - by: ["seniority"], - where, - _count: { _all: true }, - }), - this.db.contact.groupBy({ - by: ["function"], - where, - _count: { _all: true }, - }), - activityFacetCounts((activityWhere) => - this.db.contact.count({ where: { AND: [where, activityWhere] } }), + groupCounts(this.db, contact, contact.ownerId, where), + groupCounts(this.db, contact, contact.companyId, where), + groupCounts(this.db, contact, contact.source, where), + groupCounts(this.db, contact, contact.title, where), + groupCounts(this.db, contact, contact.seniority, where), + groupCounts(this.db, contact, contact.function, where), + activityFacetCounts(contact.lastActivityAt, (activityWhere) => + this.db.$count(contact, and(where, activityWhere)), ), this.fields.filterFacetCounts("CONTACT", where, filterableFields), ]); return { - owner: countsByKey(owners, "ownerId", FACET_UNASSIGNED), - company: countsByKey(companies, "companyId", NO_COMPANY), - source: countsByKey(sources, "source"), - title: countsByKey(titles, "title"), - seniority: countsByKey(seniorities, "seniority"), - persona: countsByKey(personas, "function"), + owner: countsByKey(owners, "value", FACET_UNASSIGNED), + company: countsByKey(companies, "value", NO_COMPANY), + source: countsByKey(sources, "value"), + title: countsByKey(titles, "value"), + seniority: countsByKey(seniorities, "value"), + persona: countsByKey(personas, "value"), activity, ...Object.fromEntries( Object.entries(fieldFacets).map(([key, counts]) => [ @@ -906,16 +1029,30 @@ export class ContactsService { }; } - private translate(cause: unknown, id: string): never { - if (cause instanceof PrismaNamespace.PrismaClientKnownRequestError) { - if (cause.code === "P2025") { - throw new NotFoundException(`No contact with id ${id}.`); - } - if (cause.code === "P2002") { - throw new ConflictException( - "Another contact already uses that email address.", - ); - } + private async updateContactOrThrow( + tx: Executor, + id: string, + data: Partial, + ): Promise { + const [updated] = hasValues(data) + ? await tx + .update(contact) + .set(data) + .where(eq(contact.id, id)) + .returning({ id: contact.id }) + : await tx + .select({ id: contact.id }) + .from(contact) + .where(eq(contact.id, id)) + .limit(1); + if (!updated) throw new NotFoundException(`No contact with id ${id}.`); + } + + private translate(cause: unknown, _id: string): never { + if (isUniqueViolation(cause)) { + throw new ConflictException( + "Another contact already uses that email address.", + ); } throw cause; } diff --git a/apps/api/src/conversations/conversation-attachments.ts b/apps/api/src/conversations/conversation-attachments.ts index 9eacc3758..447f76d07 100644 --- a/apps/api/src/conversations/conversation-attachments.ts +++ b/apps/api/src/conversations/conversation-attachments.ts @@ -1,4 +1,4 @@ -import type { Prisma } from "@crm/db"; +import type { JsonObject, JsonValue } from "@crm/db"; import { z } from "zod"; const builderMessageFields = z.record(z.string(), z.json()).catch({}); @@ -11,10 +11,10 @@ export type StoredBuilderAttachment = { }; export function builderMessageWithAttachments( - value: Prisma.JsonValue, + value: JsonValue, attachments: StoredBuilderAttachment[], shareToken?: string, -): Prisma.JsonObject { +): JsonObject { return { ...builderMessageFields.parse(value), attachments: attachments.map((attachment) => ({ diff --git a/apps/api/src/conversations/conversation-resume.ts b/apps/api/src/conversations/conversation-resume.ts index 26e50a20f..cd3d2001e 100644 --- a/apps/api/src/conversations/conversation-resume.ts +++ b/apps/api/src/conversations/conversation-resume.ts @@ -2,7 +2,7 @@ import { chatEventPurpose, createChatEventSecretBox, } from "@crm/auth/chat-event-crypto"; -import type { Prisma } from "@crm/db"; +import type { JsonValue } from "@crm/db"; import { publicEveEventData, publicEveEventInput, @@ -11,7 +11,7 @@ import { export type IndexedConversationEvent = { id: string; type: string; - data: Prisma.JsonValue; + data: JsonValue; emittedAt: Date; streamIndex: number | null; deliveryIds: string[]; @@ -25,7 +25,7 @@ export function conversationResumeSnapshot( ) { const events: Array<{ type: string; - data: Prisma.JsonValue; + data: JsonValue; meta: { id: string; at: string; deliveryIds?: string[] }; }> = []; const ids = new Set(); @@ -64,7 +64,7 @@ export function publicConversationEvent(row: IndexedConversationEvent) { function storedConversationEvent( row: IndexedConversationEvent, -): { available: true; data: Prisma.JsonValue } | { available: false } { +): { available: true; data: JsonValue } | { available: false } { if (!row.encryptedData) return { available: true, data: row.data }; if (!row.conversationId) return { available: false }; const box = createChatEventSecretBox(); diff --git a/apps/api/src/conversations/conversation-sharing.service.ts b/apps/api/src/conversations/conversation-sharing.service.ts index 90e569bb2..a94c45620 100644 --- a/apps/api/src/conversations/conversation-sharing.service.ts +++ b/apps/api/src/conversations/conversation-sharing.service.ts @@ -1,6 +1,23 @@ import { randomBytes } from "node:crypto"; import { WORKSPACE_ID } from "@crm/auth/organization"; -import type { Db, Prisma } from "@crm/db"; +import { + agentBuilderArtifact, + agentConversation, + agentConversationAttachment, + agentConversationShare, + agentConversationSubmission, + agentEvent, + and, + asc, + type Db, + desc, + type Executor, + eq, + gt, + isNull, + member, + or, +} from "@crm/db"; import { ForbiddenException, Injectable, @@ -18,17 +35,26 @@ export class ConversationSharingService { async status(conversationId: string, userId: string) { await this.ownedBuilder(conversationId, userId); - const share = await this.db.agentConversationShare.findFirst({ - where: { - conversationId, - revokedAt: null, - OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], - }, - select: { createdAt: true, expiresAt: true }, - }); + const [share] = await this.db + .select({ + createdAt: agentConversationShare.createdAt, + expiresAt: agentConversationShare.expiresAt, + }) + .from(agentConversationShare) + .where( + and( + eq(agentConversationShare.conversationId, conversationId), + isNull(agentConversationShare.revokedAt), + or( + isNull(agentConversationShare.expiresAt), + gt(agentConversationShare.expiresAt, new Date()), + ), + ), + ) + .limit(1); return { - enabled: share !== null, + enabled: share !== undefined, createdAt: share?.createdAt.toISOString() ?? null, expiresAt: share?.expiresAt?.toISOString() ?? null, }; @@ -38,19 +64,16 @@ export class ConversationSharingService { const token = randomBytes(32).toString("base64url"); const tokenHash = conversationShareTokenHash(token); - const created = await this.db.$transaction(async (tx) => { + const created = await this.db.transaction(async (tx) => { if (!(await this.lockOwnedBuilder(tx, conversationId, userId))) { return false; } - await tx.agentConversationShare.updateMany({ - where: { conversationId, revokedAt: null }, - data: { revokedAt: new Date() }, - }); + await this.revokeActive(tx, conversationId); - await tx.agentConversationShare.create({ - data: { conversationId, createdById: userId, tokenHash }, - }); + await tx + .insert(agentConversationShare) + .values({ conversationId, createdById: userId, tokenHash }); return true; }); @@ -60,15 +83,12 @@ export class ConversationSharingService { } async revoke(conversationId: string, userId: string) { - const revoked = await this.db.$transaction(async (tx) => { + const revoked = await this.db.transaction(async (tx) => { if (!(await this.lockOwnedBuilder(tx, conversationId, userId))) { return false; } - await tx.agentConversationShare.updateMany({ - where: { conversationId, revokedAt: null }, - data: { revokedAt: new Date() }, - }); + await this.revokeActive(tx, conversationId); return true; }); @@ -80,26 +100,48 @@ export class ConversationSharingService { async resolve(token: string, userId: string) { await this.assertWorkspaceMember(userId); - const share = await this.db.agentConversationShare.findFirst({ - where: { - tokenHash: conversationShareTokenHash(token), - revokedAt: null, - OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], - conversation: { kind: "BUILDER" }, - }, - select: { - conversation: { - select: { + const [shared] = await this.db + .select({ conversationId: agentConversationShare.conversationId }) + .from(agentConversationShare) + .innerJoin( + agentConversation, + eq(agentConversation.id, agentConversationShare.conversationId), + ) + .where( + and( + eq( + agentConversationShare.tokenHash, + conversationShareTokenHash(token), + ), + isNull(agentConversationShare.revokedAt), + or( + isNull(agentConversationShare.expiresAt), + gt(agentConversationShare.expiresAt, new Date()), + ), + eq(agentConversation.kind, "BUILDER"), + ), + ) + .limit(1); + + const conversationRow = shared + ? await this.db.query.agentConversation.findFirst({ + where: eq(agentConversation.id, shared.conversationId), + columns: { id: true, title: true, sessionId: true, lastMessageAt: true, - user: { select: { name: true } }, - agent: { select: { id: true, name: true, status: true } }, + }, + with: { + user: { columns: { name: true } }, + agent: { columns: { id: true, name: true, status: true } }, builderArtifacts: { - orderBy: [{ createdAt: "desc" }, { revision: "desc" }], - take: 100, - select: { + orderBy: [ + desc(agentBuilderArtifact.createdAt), + desc(agentBuilderArtifact.revision), + ], + limit: 100, + columns: { id: true, versionId: true, path: true, @@ -112,17 +154,19 @@ export class ConversationSharingService { }, }, submissions: { - orderBy: { createdAt: "asc" }, - select: { + orderBy: [asc(agentConversationSubmission.createdAt)], + columns: { id: true, commandType: true, message: true, status: true, errorMessage: true, createdAt: true, + }, + with: { attachments: { - orderBy: { position: "asc" }, - select: { + orderBy: [asc(agentConversationAttachment.position)], + columns: { id: true, name: true, mediaType: true, @@ -132,37 +176,36 @@ export class ConversationSharingService { }, }, }, - }, - }, - }); + }) + : undefined; - if (!share) { + if (!conversationRow) { throw new NotFoundException("That shared conversation is unavailable."); } - const { conversation } = share; - const events = await this.db.agentEvent.findMany({ - where: { - OR: [ - { conversationId: conversation.id }, - ...(conversation.sessionId - ? [{ sessionId: conversation.sessionId }] - : []), - ], - }, - orderBy: [{ emittedAt: "desc" }, { id: "desc" }], - take: 5000, - select: { - id: true, - type: true, - data: true, - encryptedData: true, - conversationId: true, - emittedAt: true, - streamIndex: true, - deliveryIds: true, - }, - }); + const conversation = conversationRow; + const events = await this.db + .select({ + id: agentEvent.id, + type: agentEvent.type, + data: agentEvent.data, + encryptedData: agentEvent.encryptedData, + conversationId: agentEvent.conversationId, + emittedAt: agentEvent.emittedAt, + streamIndex: agentEvent.streamIndex, + deliveryIds: agentEvent.deliveryIds, + }) + .from(agentEvent) + .where( + or( + eq(agentEvent.conversationId, conversation.id), + conversation.sessionId + ? eq(agentEvent.sessionId, conversation.sessionId) + : undefined, + ), + ) + .orderBy(desc(agentEvent.emittedAt), desc(agentEvent.id)) + .limit(5000); return { id: conversation.id, @@ -190,10 +233,17 @@ export class ConversationSharingService { } private async ownedBuilder(conversationId: string, userId: string) { - const conversation = await this.db.agentConversation.findFirst({ - where: { id: conversationId, userId, kind: "BUILDER" }, - select: { id: true }, - }); + const [conversation] = await this.db + .select({ id: agentConversation.id }) + .from(agentConversation) + .where( + and( + eq(agentConversation.id, conversationId), + eq(agentConversation.userId, userId), + eq(agentConversation.kind, "BUILDER"), + ), + ) + .limit(1); if (!conversation) { this.missingBuilder(conversationId); @@ -203,25 +253,44 @@ export class ConversationSharingService { } private async lockOwnedBuilder( - tx: Prisma.TransactionClient, + tx: Executor, conversationId: string, userId: string, ): Promise { - const rows = await tx.$queryRaw>` - SELECT id - FROM "agentConversation" - WHERE id = ${conversationId} - FOR UPDATE - `; + const rows = await tx + .select({ id: agentConversation.id }) + .from(agentConversation) + .where(eq(agentConversation.id, conversationId)) + .for("update"); if (rows.length === 0) return false; return ( - (await tx.agentConversation.count({ - where: { id: conversationId, userId, kind: "BUILDER" }, - })) === 1 + (await tx.$count( + agentConversation, + and( + eq(agentConversation.id, conversationId), + eq(agentConversation.userId, userId), + eq(agentConversation.kind, "BUILDER"), + ), + )) === 1 ); } + private async revokeActive( + tx: Executor, + conversationId: string, + ): Promise { + await tx + .update(agentConversationShare) + .set({ revokedAt: new Date() }) + .where( + and( + eq(agentConversationShare.conversationId, conversationId), + isNull(agentConversationShare.revokedAt), + ), + ); + } + private missingBuilder(conversationId: string): never { throw new NotFoundException( `No builder conversation with id ${conversationId}.`, @@ -229,14 +298,15 @@ export class ConversationSharingService { } private async assertWorkspaceMember(userId: string): Promise { - const member = await this.db.member.findUnique({ - where: { - organizationId_userId: { organizationId: WORKSPACE_ID, userId }, - }, - select: { id: true }, - }); + const [membership] = await this.db + .select({ id: member.id }) + .from(member) + .where( + and(eq(member.organizationId, WORKSPACE_ID), eq(member.userId, userId)), + ) + .limit(1); - if (!member) { + if (!membership) { throw new ForbiddenException( "This conversation belongs to another team.", ); diff --git a/apps/api/src/conversations/conversations.service.ts b/apps/api/src/conversations/conversations.service.ts index b38566208..0232f17b5 100644 --- a/apps/api/src/conversations/conversations.service.ts +++ b/apps/api/src/conversations/conversations.service.ts @@ -1,9 +1,39 @@ import { WORKSPACE_ID } from "@crm/auth/organization"; import { AgentConversationPermissionMode, + account, + agentBuilderArtifact, + agentConversation, + agentConversationAttachment, + agentConversationFeedback, + agentConversationShare, + agentConversationSubmission, + agentEvent, + agentTrigger, + agentVersion, + and, + asc, + company, + contact, + contains, type Db, - type Prisma, - Prisma as PrismaNamespace, + deal, + desc, + type Executor, + eq, + exists, + gt, + inArray, + isNotNull, + isNull, + isUniqueViolation, + type JsonValue, + member, + or, + type PgColumn, + relatedCount, + type SQL, + sql, } from "@crm/db"; import { type InputRequest, schemas } from "@crm/validation"; import { readAgentManifestSummary } from "@crm/validation/agent-manifest"; @@ -79,25 +109,31 @@ export class ConversationsService { const recordId = this.recordId(input); this.logger.debug({ message: "Conversation list read", recordId }); - const rows = await this.db.agentConversation.findMany({ - where: { - userId, - contactId: input.contactId ?? undefined, - companyId: input.companyId ?? undefined, - dealId: input.dealId ?? undefined, - }, - orderBy: { lastMessageAt: "desc" }, - take: 20, - select: { - id: true, - sessionId: true, - continuationToken: true, - streamIndex: true, - title: true, - messageCount: true, - lastMessageAt: true, - }, - }); + const rows = await this.db + .select({ + id: agentConversation.id, + sessionId: agentConversation.sessionId, + continuationToken: agentConversation.continuationToken, + streamIndex: agentConversation.streamIndex, + title: agentConversation.title, + messageCount: agentConversation.messageCount, + lastMessageAt: agentConversation.lastMessageAt, + }) + .from(agentConversation) + .where( + and( + eq(agentConversation.userId, userId), + input.contactId + ? eq(agentConversation.contactId, input.contactId) + : undefined, + input.companyId + ? eq(agentConversation.companyId, input.companyId) + : undefined, + input.dealId ? eq(agentConversation.dealId, input.dealId) : undefined, + ), + ) + .orderBy(desc(agentConversation.lastMessageAt)) + .limit(20); const summaries = rows.flatMap((row) => row.sessionId @@ -116,11 +152,14 @@ export class ConversationsService { async listBuilder(userId: string): Promise { await this.assertWorkspaceMember(userId); - const rows = await this.db.agentConversation.findMany({ - where: { userId, kind: "BUILDER" }, - orderBy: { lastMessageAt: "desc" }, - take: 50, - select: { + const rows = await this.db.query.agentConversation.findMany({ + where: and( + eq(agentConversation.userId, userId), + eq(agentConversation.kind, "BUILDER"), + ), + orderBy: [desc(agentConversation.lastMessageAt)], + limit: 50, + columns: { id: true, sessionId: true, continuationToken: true, @@ -130,16 +169,29 @@ export class ConversationsService { lastMessageAt: true, lastAssistantAt: true, lastReadAt: true, - agent: { select: { id: true, name: true, status: true } }, - _count: { - select: { - submissions: { where: { commandType: "CREATE_AGENT" } }, - }, - }, + }, + extras: (conversation: { id: typeof agentConversation.id }) => ({ + createAgentSubmissions: relatedCount( + agentConversationSubmission, + agentConversationSubmission.conversationId, + conversation.id, + [ + { + column: agentConversationSubmission.commandType, + value: "CREATE_AGENT", + }, + ], + ).as("createAgentSubmissions"), + }), + with: { + agent: { columns: { id: true, name: true, status: true } }, submissions: { - where: { status: { in: ["PENDING", "SENDING"] } }, - select: { id: true }, - take: 1, + where: inArray(agentConversationSubmission.status, [ + "PENDING", + "SENDING", + ]), + columns: { id: true }, + limit: 1, }, }, }); @@ -164,7 +216,7 @@ export class ConversationsService { lastAssistantAt: row.lastAssistantAt?.toISOString() ?? null, unread, state: - row._count.submissions > 0 && row.agent?.status === "LIVE" + row.createAgentSubmissions > 0 && row.agent?.status === "LIVE" ? "deployed" : working ? "working" @@ -180,52 +232,61 @@ export class ConversationsService { await this.assertWorkspaceMember(userId); const search = q.trim(); - const contains = search - ? { contains: search, mode: "insensitive" as const } - : undefined; - - const [companies, contacts, deals, slackAccount] = await Promise.all([ - this.db.company.findMany({ - where: contains ? { name: contains } : undefined, - orderBy: { lastActivityAt: { sort: "desc", nulls: "last" } }, - take: 6, - select: { id: true, name: true, domain: true, logoUrl: true }, - }), - this.db.contact.findMany({ - where: contains - ? { - OR: [ - { firstName: contains }, - { lastName: contains }, - { email: contains }, - ], - } - : undefined, - orderBy: { lastActivityAt: { sort: "desc", nulls: "last" } }, - take: 6, - select: { - id: true, - firstName: true, - lastName: true, - email: true, - imageUrl: true, - company: { select: { name: true } }, - }, - }), - this.db.deal.findMany({ - where: contains ? { name: contains } : undefined, - orderBy: { lastActivityAt: { sort: "desc", nulls: "last" } }, - take: 6, - select: { - id: true, - name: true, - company: { select: { name: true, logoUrl: true } }, - }, - }), - this.db.account.findFirst({ - where: { providerId: "slack", accessToken: { not: null } }, - select: { id: true }, - }), + const matches = (column: Parameters[0]) => + search ? contains(column, search, { insensitive: true }) : undefined; + + const [companies, contacts, deals, [slackAccount]] = await Promise.all([ + this.db + .select({ + id: company.id, + name: company.name, + domain: company.domain, + logoUrl: company.logoUrl, + }) + .from(company) + .where(matches(company.name)) + .orderBy(sql`${company.lastActivityAt} desc nulls last`) + .limit(6), + this.db + .select({ + id: contact.id, + firstName: contact.firstName, + lastName: contact.lastName, + email: contact.email, + imageUrl: contact.imageUrl, + company: { name: company.name }, + }) + .from(contact) + .leftJoin(company, eq(company.id, contact.companyId)) + .where( + search + ? or( + matches(contact.firstName), + matches(contact.lastName), + matches(contact.email), + ) + : undefined, + ) + .orderBy(sql`${contact.lastActivityAt} desc nulls last`) + .limit(6), + this.db + .select({ + id: deal.id, + name: deal.name, + company: { name: company.name, logoUrl: company.logoUrl }, + }) + .from(deal) + .innerJoin(company, eq(company.id, deal.companyId)) + .where(matches(deal.name)) + .orderBy(sql`${deal.lastActivityAt} desc nulls last`) + .limit(6), + this.db + .select({ id: account.id }) + .from(account) + .where( + and(eq(account.providerId, "slack"), isNotNull(account.accessToken)), + ) + .limit(1), ]); return [ @@ -266,9 +327,9 @@ export class ConversationsService { async builderById(id: string, userId: string) { await this.assertWorkspaceMember(userId); - const row = await this.db.agentConversation.findFirst({ - where: { id, userId, kind: "BUILDER" }, - select: { + const row = await this.db.query.agentConversation.findFirst({ + where: this.ownedBuilder(id, userId), + columns: { id: true, permissionMode: true, sessionId: true, @@ -280,15 +341,19 @@ export class ConversationsService { lastMessageAt: true, lastAssistantAt: true, lastReadAt: true, + }, + with: { agent: { - select: { + columns: { id: true, name: true, description: true, status: true, - createdBy: { select: { id: true, name: true } }, + }, + with: { + createdBy: { columns: { id: true, name: true } }, currentVersion: { - select: { + columns: { id: true, number: true, status: true, @@ -299,8 +364,8 @@ export class ConversationsService { }, }, triggers: { - orderBy: { createdAt: "asc" }, - select: { + orderBy: [asc(agentTrigger.createdAt)], + columns: { id: true, type: true, name: true, @@ -312,9 +377,9 @@ export class ConversationsService { }, }, createdVersions: { - orderBy: { number: "desc" }, - take: 1, - select: { + orderBy: [desc(agentVersion.number)], + limit: 1, + columns: { id: true, number: true, status: true, @@ -327,9 +392,12 @@ export class ConversationsService { }, }, builderArtifacts: { - orderBy: [{ createdAt: "desc" }, { revision: "desc" }], - take: 100, - select: { + orderBy: [ + desc(agentBuilderArtifact.createdAt), + desc(agentBuilderArtifact.revision), + ], + limit: 100, + columns: { id: true, versionId: true, path: true, @@ -342,12 +410,12 @@ export class ConversationsService { }, }, feedback: { - where: { userId }, - select: { messageId: true, rating: true }, + where: eq(agentConversationFeedback.userId, userId), + columns: { messageId: true, rating: true }, }, submissions: { - orderBy: { createdAt: "asc" }, - select: { + orderBy: [asc(agentConversationSubmission.createdAt)], + columns: { id: true, clientRequestId: true, commandType: true, @@ -359,9 +427,11 @@ export class ConversationsService { sentAt: true, acceptedAt: true, inputRequestId: true, + }, + with: { attachments: { - orderBy: { position: "asc" }, - select: { + orderBy: [asc(agentConversationAttachment.position)], + columns: { id: true, name: true, mediaType: true, @@ -387,24 +457,17 @@ export class ConversationsService { const resume = row.sessionId ? conversationResumeSnapshot( row.sessionId, - await this.db.agentEvent.findMany({ - where: { - conversationId: row.id, - sessionId: row.sessionId, - streamIndex: { not: null }, - }, - orderBy: { streamIndex: "asc" }, - select: { - id: true, - type: true, - data: true, - encryptedData: true, - conversationId: true, - emittedAt: true, - streamIndex: true, - deliveryIds: true, - }, - }), + await this.db + .select(EVENT_FIELDS) + .from(agentEvent) + .where( + and( + eq(agentEvent.conversationId, row.id), + eq(agentEvent.sessionId, row.sessionId), + isNotNull(agentEvent.streamIndex), + ), + ) + .orderBy(asc(agentEvent.streamIndex)), ) : null; @@ -471,26 +534,33 @@ export class ConversationsService { const now = new Date(); try { - const conversation = await this.db.agentConversation.create({ - data: { - kind: "BUILDER", - userId, - title: null, - lastReadAt: now, - lastMessageAt: now, - submissions: { - create: { - submittedById: userId, - clientRequestId: input.clientRequestId, - commandType: input.commandType, - message: this.builderMessage(input), - attachments: { - create: this.attachmentWrites(input.attachments), - }, - }, + const conversation = await this.db.transaction(async (tx) => { + const [created] = await tx + .insert(agentConversation) + .values({ + kind: "BUILDER", + userId, + title: null, + lastReadAt: now, + lastMessageAt: now, + }) + .returning({ id: agentConversation.id }); + if (!created) + throw new Error("The conversation insert returned no row."); + + await this.insertSubmission( + tx, + { + conversationId: created.id, + submittedById: userId, + clientRequestId: input.clientRequestId, + commandType: input.commandType, + message: this.builderMessage(input), }, - }, - select: { id: true }, + this.attachmentWrites(input.attachments), + ); + + return created; }); this.agent?.builderConversationQueued(); @@ -514,10 +584,7 @@ export class ConversationsService { return this.replayBuilderSubmission(existing, input.id, userId); } - const conversation = await this.db.agentConversation.findFirst({ - where: { id: input.id, userId, kind: "BUILDER" }, - select: { id: true }, - }); + const conversation = await this.builderId(input.id, userId); if (!conversation) { throw new NotFoundException( @@ -530,25 +597,20 @@ export class ConversationsService { input, userId, ); - const submission = await this.db.$transaction(async (tx) => { - const created = await tx.agentConversationSubmission.create({ - data: { + const submission = await this.db.transaction(async (tx) => { + const created = await this.insertSubmission( + tx, + { conversationId: input.id, submittedById: userId, clientRequestId: input.clientRequestId, commandType: input.commandType, message: this.builderMessage(input), - attachments: { - create: attachmentWrites, - }, }, - select: { id: true }, - }); + attachmentWrites, + ); - await tx.agentConversation.update({ - where: { id: input.id }, - data: { lastMessageAt: new Date(), lastReadAt: new Date() }, - }); + await this.touchConversation(tx, input.id); return created; }); @@ -574,16 +636,7 @@ export class ConversationsService { return this.replayBuilderSubmission(existing, input.id, userId); } - const conversation = await this.db.agentConversation.findFirst({ - where: { id: input.id, userId, kind: "BUILDER" }, - select: { - id: true, - sessionId: true, - continuationToken: true, - pendingInputRequests: true, - submissions: { select: { inputRequestId: true } }, - }, - }); + const conversation = await this.pendingConversation(input.id, userId); if (!conversation) { throw new NotFoundException( @@ -644,9 +697,10 @@ export class ConversationsService { ? { requestId: input.requestId, optionId: input.optionId } : { requestId: input.requestId, text: input.text }; try { - const submission = await this.db.$transaction(async (tx) => { - const created = await tx.agentConversationSubmission.create({ - data: { + const submission = await this.db.transaction(async (tx) => { + const created = await this.insertSubmission( + tx, + { conversationId: input.id, submittedById: userId, clientRequestId: input.clientRequestId, @@ -659,13 +713,10 @@ export class ConversationsService { inputResponse, }, }, - select: { id: true }, - }); + [], + ); - await tx.agentConversation.update({ - where: { id: input.id }, - data: { lastMessageAt: new Date(), lastReadAt: new Date() }, - }); + await this.touchConversation(tx, input.id); return created; }); @@ -680,13 +731,16 @@ export class ConversationsService { return this.replayBuilderSubmission(winner, input.id, userId); } - const answered = await this.db.agentConversationSubmission.findFirst({ - where: { - conversationId: input.id, - inputRequestId: input.requestId, - }, - select: { id: true }, - }); + const [answered] = await this.db + .select({ id: agentConversationSubmission.id }) + .from(agentConversationSubmission) + .where( + and( + eq(agentConversationSubmission.conversationId, input.id), + eq(agentConversationSubmission.inputRequestId, input.requestId), + ), + ) + .limit(1); if (answered) { throw new BadRequestException( "That follow-up question has already been answered.", @@ -708,16 +762,7 @@ export class ConversationsService { return this.replayBuilderSubmission(existing, input.id, userId); } - const conversation = await this.db.agentConversation.findFirst({ - where: { id: input.id, userId, kind: "BUILDER" }, - select: { - id: true, - sessionId: true, - continuationToken: true, - pendingInputRequests: true, - submissions: { select: { inputRequestId: true } }, - }, - }); + const conversation = await this.pendingConversation(input.id, userId); if (!conversation) { throw new NotFoundException( @@ -750,9 +795,10 @@ export class ConversationsService { } try { - const submission = await this.db.$transaction(async (tx) => { - const created = await tx.agentConversationSubmission.create({ - data: { + const submission = await this.db.transaction(async (tx) => { + const created = await this.insertSubmission( + tx, + { conversationId: input.id, submittedById: userId, clientRequestId: input.clientRequestId, @@ -768,13 +814,10 @@ export class ConversationsService { }, }, }, - select: { id: true }, - }); + [], + ); - await tx.agentConversation.update({ - where: { id: input.id }, - data: { lastMessageAt: new Date(), lastReadAt: new Date() }, - }); + await this.touchConversation(tx, input.id); return created; }); @@ -806,16 +849,7 @@ export class ConversationsService { return this.replayBuilderSubmission(existing, input.id, userId); } - const conversation = await this.db.agentConversation.findFirst({ - where: { id: input.id, userId, kind: "BUILDER" }, - select: { - id: true, - sessionId: true, - continuationToken: true, - pendingInputRequests: true, - submissions: { select: { inputRequestId: true } }, - }, - }); + const conversation = await this.pendingConversation(input.id, userId); if (!conversation) { throw new NotFoundException( @@ -851,9 +885,10 @@ export class ConversationsService { } try { - const submission = await this.db.$transaction(async (tx) => { - const created = await tx.agentConversationSubmission.create({ - data: { + const submission = await this.db.transaction(async (tx) => { + const created = await this.insertSubmission( + tx, + { conversationId: input.id, submittedById: userId, clientRequestId: input.clientRequestId, @@ -869,13 +904,10 @@ export class ConversationsService { }, }, }, - select: { id: true }, - }); + [], + ); - await tx.agentConversation.update({ - where: { id: input.id }, - data: { lastMessageAt: new Date(), lastReadAt: new Date() }, - }); + await this.touchConversation(tx, input.id); return created; }); @@ -901,12 +933,13 @@ export class ConversationsService { userId: string, ): Promise<{ id: string }> { await this.assertWorkspaceMember(userId); - const updated = await this.db.agentConversation.updateMany({ - where: { id: input.id, userId, kind: "BUILDER" }, - data: { permissionMode: permissionModeInput(input.permissionMode) }, - }); + const updated = await this.db + .update(agentConversation) + .set({ permissionMode: permissionModeInput(input.permissionMode) }) + .where(this.ownedBuilder(input.id, userId)) + .returning({ id: agentConversation.id }); - if (updated.count !== 1) { + if (updated.length !== 1) { throw new NotFoundException( `No builder conversation with id ${input.id}.`, ); @@ -917,12 +950,13 @@ export class ConversationsService { async markRead(id: string, userId: string): Promise<{ id: string }> { await this.assertWorkspaceMember(userId); - const updated = await this.db.agentConversation.updateMany({ - where: { id, userId, kind: "BUILDER" }, - data: { lastReadAt: new Date() }, - }); + const updated = await this.db + .update(agentConversation) + .set({ lastReadAt: new Date() }) + .where(this.ownedBuilder(id, userId)) + .returning({ id: agentConversation.id }); - if (updated.count === 0) { + if (updated.length === 0) { throw new NotFoundException(`No builder conversation with id ${id}.`); } @@ -941,36 +975,58 @@ export class ConversationsService { }> { await this.assertWorkspaceMember(userId); const share = shareToken?.trim(); - const row = await this.db.agentConversationAttachment.findFirst({ - where: { - id, - submission: { - conversation: { - kind: "BUILDER", - OR: [ - { userId }, - ...(share - ? [ - { - shares: { - some: { - tokenHash: conversationShareTokenHash(share), - revokedAt: null, - OR: [ - { expiresAt: null }, - { expiresAt: { gt: new Date() } }, - ], - }, - }, - }, - ] - : []), - ], - }, - }, - }, - select: { name: true, mediaType: true, content: true }, - }); + const [row] = await this.db + .select({ + name: agentConversationAttachment.name, + mediaType: agentConversationAttachment.mediaType, + content: agentConversationAttachment.content, + }) + .from(agentConversationAttachment) + .innerJoin( + agentConversationSubmission, + eq( + agentConversationSubmission.id, + agentConversationAttachment.submissionId, + ), + ) + .innerJoin( + agentConversation, + eq(agentConversation.id, agentConversationSubmission.conversationId), + ) + .where( + and( + eq(agentConversationAttachment.id, id), + eq(agentConversation.kind, "BUILDER"), + or( + eq(agentConversation.userId, userId), + share + ? exists( + this.db + .select({ id: agentConversationShare.id }) + .from(agentConversationShare) + .where( + and( + eq( + agentConversationShare.conversationId, + agentConversation.id, + ), + eq( + agentConversationShare.tokenHash, + conversationShareTokenHash(share), + ), + isNull(agentConversationShare.revokedAt), + or( + isNull(agentConversationShare.expiresAt), + gt(agentConversationShare.expiresAt, new Date()), + ), + ), + ), + ) + : undefined, + ), + ), + ) + .limit(1); if (!row) { throw new NotFoundException("That attachment is unavailable."); @@ -987,10 +1043,7 @@ export class ConversationsService { userId: string, ) { await this.assertWorkspaceMember(userId); - const conversation = await this.db.agentConversation.findFirst({ - where: { id: input.id, userId, kind: "BUILDER" }, - select: { id: true }, - }); + const conversation = await this.builderId(input.id, userId); if (!conversation) { throw new NotFoundException( @@ -998,31 +1051,35 @@ export class ConversationsService { ); } - const key = { - conversationId_userId_messageId: { - conversationId: input.id, - userId, - messageId: input.messageId, - }, - }; - if (!input.rating) { - await this.db.agentConversationFeedback.deleteMany({ - where: key.conversationId_userId_messageId, - }); + await this.db + .delete(agentConversationFeedback) + .where( + and( + eq(agentConversationFeedback.conversationId, input.id), + eq(agentConversationFeedback.userId, userId), + eq(agentConversationFeedback.messageId, input.messageId), + ), + ); return { id: input.messageId, rating: null }; } - await this.db.agentConversationFeedback.upsert({ - where: key, - create: { + await this.db + .insert(agentConversationFeedback) + .values({ conversationId: input.id, userId, messageId: input.messageId, rating: input.rating, - }, - update: { rating: input.rating }, - }); + }) + .onConflictDoUpdate({ + target: [ + agentConversationFeedback.conversationId, + agentConversationFeedback.userId, + agentConversationFeedback.messageId, + ], + set: { rating: input.rating }, + }); return { id: input.messageId, rating: input.rating }; } @@ -1054,24 +1111,29 @@ export class ConversationsService { ); } - const updated = await this.db.agentConversation.updateMany({ - where: { - id: existing.id, - kind: "RECORD", - userId, - contactId: input.contactId ?? null, - companyId: input.companyId ?? null, - dealId: input.dealId ?? null, - }, - data: { + const matchesRecord = (column: PgColumn, value?: string | null) => + value ? eq(column, value) : isNull(column); + const updated = await this.db + .update(agentConversation) + .set({ continuationToken: input.continuationToken ?? null, streamIndex: input.streamIndex ?? 0, messageCount: input.messageCount ?? 0, lastMessageAt: new Date(), - }, - }); + }) + .where( + and( + eq(agentConversation.id, existing.id), + eq(agentConversation.kind, "RECORD"), + eq(agentConversation.userId, userId), + matchesRecord(agentConversation.contactId, input.contactId), + matchesRecord(agentConversation.companyId, input.companyId), + matchesRecord(agentConversation.dealId, input.dealId), + ), + ) + .returning({ id: agentConversation.id }); - if (updated.count !== 1) { + if (updated.length !== 1) { throw new NotFoundException( `No record conversation with session ${input.sessionId}.`, ); @@ -1080,25 +1142,16 @@ export class ConversationsService { return { id: existing.id }; }; - const existing = await this.db.agentConversation.findUnique({ - where: { sessionId: input.sessionId }, - select: { - id: true, - kind: true, - userId: true, - contactId: true, - companyId: true, - dealId: true, - }, - }); + const existing = await this.bySession(input.sessionId); let conversation: { id: string }; if (existing) { conversation = await updateExisting(existing); } else { try { - conversation = await this.db.agentConversation.create({ - data: { + const [created] = await this.db + .insert(agentConversation) + .values({ sessionId: input.sessionId, continuationToken: input.continuationToken ?? null, streamIndex: input.streamIndex ?? 0, @@ -1108,22 +1161,14 @@ export class ConversationsService { contactId: input.contactId ?? null, companyId: input.companyId ?? null, dealId: input.dealId ?? null, - }, - select: { id: true }, - }); + }) + .returning({ id: agentConversation.id }); + if (!created) + throw new Error("The conversation insert returned no row."); + conversation = created; } catch (error) { if (!isUniqueConstraint(error)) throw error; - const winner = await this.db.agentConversation.findUnique({ - where: { sessionId: input.sessionId }, - select: { - id: true, - kind: true, - userId: true, - contactId: true, - companyId: true, - dealId: true, - }, - }); + const winner = await this.bySession(input.sessionId); if (!winner) throw error; conversation = await updateExisting(winner); } @@ -1133,10 +1178,15 @@ export class ConversationsService { } async events(input: ConversationEventsInput, userId: string) { - const conversation = await this.db.agentConversation.findUnique({ - where: { id: input.id }, - select: { kind: true, sessionId: true, userId: true }, - }); + const [conversation] = await this.db + .select({ + kind: agentConversation.kind, + sessionId: agentConversation.sessionId, + userId: agentConversation.userId, + }) + .from(agentConversation) + .where(eq(agentConversation.id, input.id)) + .limit(1); if (!conversation || conversation.userId !== userId) { throw new NotFoundException(`No conversation with id ${input.id}.`); @@ -1145,49 +1195,39 @@ export class ConversationsService { await this.assertWorkspaceMember(userId); } - const eventWhere: Prisma.AgentEventWhereInput = + const eventWhere: SQL | undefined = conversation.kind === "BUILDER" - ? { - OR: [ - { conversationId: input.id }, - ...(conversation.sessionId - ? [{ sessionId: conversation.sessionId }] - : []), - ], - } + ? or( + eq(agentEvent.conversationId, input.id), + conversation.sessionId + ? eq(agentEvent.sessionId, conversation.sessionId) + : undefined, + ) : conversation.sessionId - ? { sessionId: conversation.sessionId } - : { id: { in: [] } }; - - const events = await this.db.agentEvent.findMany({ - where: eventWhere, - orderBy: [{ emittedAt: "desc" }, { id: "desc" }], - take: input.limit, - select: { - id: true, - type: true, - data: true, - encryptedData: true, - conversationId: true, - emittedAt: true, - streamIndex: true, - deliveryIds: true, - }, - }); + ? eq(agentEvent.sessionId, conversation.sessionId) + : sql`false`; + + const events = await this.db + .select(EVENT_FIELDS) + .from(agentEvent) + .where(eventWhere) + .orderBy(desc(agentEvent.emittedAt), desc(agentEvent.id)) + .limit(input.limit); return events.reverse().map(publicConversationEvent); } async remove(id: string, userId: string): Promise<{ id: string }> { - const conversation = await this.db.agentConversation.findUnique({ - where: { id }, - select: { - id: true, - kind: true, - userId: true, - sessionId: true, - }, - }); + const [conversation] = await this.db + .select({ + id: agentConversation.id, + kind: agentConversation.kind, + userId: agentConversation.userId, + sessionId: agentConversation.sessionId, + }) + .from(agentConversation) + .where(eq(agentConversation.id, id)) + .limit(1); if (!conversation || conversation.userId !== userId) { throw new NotFoundException(`No conversation with id ${id}.`); @@ -1196,22 +1236,32 @@ export class ConversationsService { await this.assertWorkspaceMember(userId); } - await this.db.$transaction(async (tx) => { - await tx.agentBuilderArtifact.deleteMany({ - where: { conversationId: id, versionId: null }, - }); - await tx.agentEvent.deleteMany({ - where: { - OR: [ - { conversationId: id }, - ...(conversation.sessionId - ? [{ sessionId: conversation.sessionId }] - : []), - ], - }, - }); + await this.db.transaction(async (tx) => { + await tx + .delete(agentBuilderArtifact) + .where( + and( + eq(agentBuilderArtifact.conversationId, id), + isNull(agentBuilderArtifact.versionId), + ), + ); + await tx + .delete(agentEvent) + .where( + or( + eq(agentEvent.conversationId, id), + conversation.sessionId + ? eq(agentEvent.sessionId, conversation.sessionId) + : undefined, + ), + ); - await tx.agentConversation.delete({ where: { id } }); + const removed = await tx + .delete(agentConversation) + .where(eq(agentConversation.id, id)) + .returning({ id: agentConversation.id }); + if (removed.length === 0) + throw new NotFoundException(`No conversation with id ${id}.`); }); this.logger.log({ message: "Conversation removed", conversationId: id }); @@ -1244,7 +1294,7 @@ export class ConversationsService { attachments: | BuilderConversationCreateInput["attachments"] | BuilderConversationSubmitInput["attachments"]; - }): Prisma.InputJsonValue { + }): JsonValue { return { text: input.message, resources: input.resources, @@ -1277,21 +1327,35 @@ export class ConversationsService { ); const referenced = referencedIds.length > 0 - ? await this.db.agentConversationAttachment.findMany({ - where: { - id: { in: referencedIds }, - submission: { - conversation: { id: input.id, userId, kind: "BUILDER" }, - }, - }, - select: { - id: true, - name: true, - mediaType: true, - size: true, - content: true, - }, - }) + ? await this.db + .select({ + id: agentConversationAttachment.id, + name: agentConversationAttachment.name, + mediaType: agentConversationAttachment.mediaType, + size: agentConversationAttachment.size, + content: agentConversationAttachment.content, + }) + .from(agentConversationAttachment) + .innerJoin( + agentConversationSubmission, + eq( + agentConversationSubmission.id, + agentConversationAttachment.submissionId, + ), + ) + .innerJoin( + agentConversation, + eq( + agentConversation.id, + agentConversationSubmission.conversationId, + ), + ) + .where( + and( + inArray(agentConversationAttachment.id, referencedIds), + this.ownedBuilder(input.id, userId), + ), + ) : []; const referencedById = new Map(referenced.map((row) => [row.id, row])); @@ -1329,30 +1393,113 @@ export class ConversationsService { } private async assertWorkspaceMember(userId: string): Promise { - const member = await this.db.member.findUnique({ - where: { - organizationId_userId: { organizationId: WORKSPACE_ID, userId }, - }, - select: { id: true }, - }); - - if (!member) { + const [membership] = await this.db + .select({ id: member.id }) + .from(member) + .where( + and(eq(member.organizationId, WORKSPACE_ID), eq(member.userId, userId)), + ) + .limit(1); + + if (!membership) { throw new NotFoundException("No workspace membership was found."); } } - private requestByClientId( + private async requestByClientId( clientRequestId: string, ): Promise { - return this.db.agentConversationSubmission.findUnique({ - where: { clientRequestId }, - select: { + const row = await this.db.query.agentConversationSubmission.findFirst({ + where: eq(agentConversationSubmission.clientRequestId, clientRequestId), + columns: { id: true, conversationId: true, submittedById: true }, + with: { + conversation: { columns: { id: true, userId: true, kind: true } }, + }, + }); + return row ?? null; + } + + private ownedBuilder(id: string, userId: string): SQL | undefined { + return and( + eq(agentConversation.id, id), + eq(agentConversation.userId, userId), + eq(agentConversation.kind, "BUILDER"), + ); + } + + private async builderId( + id: string, + userId: string, + ): Promise<{ id: string } | null> { + const [row] = await this.db + .select({ id: agentConversation.id }) + .from(agentConversation) + .where(this.ownedBuilder(id, userId)) + .limit(1); + return row ?? null; + } + + private async pendingConversation(id: string, userId: string) { + const row = await this.db.query.agentConversation.findFirst({ + where: this.ownedBuilder(id, userId), + columns: { id: true, - conversationId: true, - submittedById: true, - conversation: { select: { id: true, userId: true, kind: true } }, + sessionId: true, + continuationToken: true, + pendingInputRequests: true, }, + with: { submissions: { columns: { inputRequestId: true } } }, }); + return row ?? null; + } + + private async bySession(sessionId: string) { + const [row] = await this.db + .select({ + id: agentConversation.id, + kind: agentConversation.kind, + userId: agentConversation.userId, + contactId: agentConversation.contactId, + companyId: agentConversation.companyId, + dealId: agentConversation.dealId, + }) + .from(agentConversation) + .where(eq(agentConversation.sessionId, sessionId)) + .limit(1); + return row ?? null; + } + + private async insertSubmission( + tx: Executor, + values: typeof agentConversationSubmission.$inferInsert, + attachments: Omit< + typeof agentConversationAttachment.$inferInsert, + "submissionId" + >[], + ): Promise<{ id: string }> { + const [created] = await tx + .insert(agentConversationSubmission) + .values(values) + .returning({ id: agentConversationSubmission.id }); + if (!created) throw new Error("The submission insert returned no row."); + + if (attachments.length > 0) { + await tx.insert(agentConversationAttachment).values( + attachments.map((attachment) => ({ + ...attachment, + submissionId: created.id, + })), + ); + } + + return created; + } + + private async touchConversation(tx: Executor, id: string): Promise { + await tx + .update(agentConversation) + .set({ lastMessageAt: new Date(), lastReadAt: new Date() }) + .where(eq(agentConversation.id, id)); } private replayBuilderCreation( @@ -1385,27 +1532,33 @@ export class ConversationsService { } } +const EVENT_FIELDS = { + id: agentEvent.id, + type: agentEvent.type, + data: agentEvent.data, + encryptedData: agentEvent.encryptedData, + conversationId: agentEvent.conversationId, + emittedAt: agentEvent.emittedAt, + streamIndex: agentEvent.streamIndex, + deliveryIds: agentEvent.deliveryIds, +}; + function isUniqueConstraint(cause: unknown): boolean { - return ( - cause instanceof PrismaNamespace.PrismaClientKnownRequestError && - cause.code === "P2002" - ); + return isUniqueViolation(cause); } -function pendingBuilderQuestionOf( - value: Prisma.JsonValue, -): BuilderQuestion | null { +function pendingBuilderQuestionOf(value: JsonValue): BuilderQuestion | null { return builderQuestion.parse(value); } function pendingBuilderApprovalOf( - value: Prisma.JsonValue, + value: JsonValue, ): BuilderToolApproval | null { return builderToolApproval.parse(value); } function pendingBuilderSessionLimitOf( - value: Prisma.JsonValue, + value: JsonValue, ): BuilderSessionLimit | null { return builderSessionLimit.parse(value); } @@ -1417,7 +1570,7 @@ const BUILDER_INPUT_PRIORITY = { } as const satisfies Record; function activeBuilderInputRequest( - value: Prisma.JsonValue, + value: JsonValue, answeredRequestIds: readonly string[], ): InputRequest | null { const answered = new Set(answeredRequestIds); diff --git a/apps/api/src/crm/activity-stamp.service.ts b/apps/api/src/crm/activity-stamp.service.ts index 63d83fb10..02f476511 100644 --- a/apps/api/src/crm/activity-stamp.service.ts +++ b/apps/api/src/crm/activity-stamp.service.ts @@ -1,4 +1,20 @@ -import { type Db, type Prisma, Prisma as PrismaNamespace } from "@crm/db"; +import { + activity, + and, + company, + contact, + type Db, + deal, + type Executor, + eq, + isNotNull, + isNull, + lt, + max, + or, + type SQL, + sql, +} from "@crm/db"; import { Injectable, Logger } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; @@ -25,75 +41,106 @@ export class ActivityStampService { constructor(@InjectDatabase() private readonly db: Db) {} async touch(target: ActivityTarget, at: Date): Promise { - const stale = { - OR: [{ lastActivityAt: null }, { lastActivityAt: { lt: at } }], - }; - await Promise.all([ target.companyId - ? this.db.company.updateMany({ - where: { id: target.companyId, ...stale }, - data: { lastActivityAt: at }, - }) + ? this.db + .update(company) + .set({ lastActivityAt: at }) + .where( + and( + eq(company.id, target.companyId), + or( + isNull(company.lastActivityAt), + lt(company.lastActivityAt, at), + ), + ), + ) : null, target.contactId - ? this.db.contact.updateMany({ - where: { id: target.contactId, ...stale }, - data: { lastActivityAt: at }, - }) + ? this.db + .update(contact) + .set({ lastActivityAt: at }) + .where( + and( + eq(contact.id, target.contactId), + or( + isNull(contact.lastActivityAt), + lt(contact.lastActivityAt, at), + ), + ), + ) : null, target.dealId - ? this.db.deal.updateMany({ - where: { id: target.dealId, ...stale }, - data: { lastActivityAt: at }, - }) + ? this.db + .update(deal) + .set({ lastActivityAt: at }) + .where( + and( + eq(deal.id, target.dealId), + or(isNull(deal.lastActivityAt), lt(deal.lastActivityAt, at)), + ), + ) : null, ]); } + private async latest(where: SQL): Promise { + const [row] = await this.db + .select({ at: max(activity.createdAt) }) + .from(activity) + .where(where); + return row?.at ?? null; + } + async recompute(target: ActivityTarget): Promise { if (target.companyId) { - const { _max } = await this.db.activity.aggregate({ - where: { companyId: target.companyId }, - _max: { createdAt: true }, - }); - await this.db.company.update({ - where: { id: target.companyId }, - data: { lastActivityAt: _max.createdAt }, - }); + const lastActivityAt = await this.latest( + eq(activity.companyId, target.companyId), + ); + await this.db + .update(company) + .set({ lastActivityAt }) + .where(eq(company.id, target.companyId)); } if (target.contactId) { - const { _max } = await this.db.activity.aggregate({ - where: { contactId: target.contactId }, - _max: { createdAt: true }, - }); - await this.db.contact.update({ - where: { id: target.contactId }, - data: { lastActivityAt: _max.createdAt }, - }); + const lastActivityAt = await this.latest( + eq(activity.contactId, target.contactId), + ); + await this.db + .update(contact) + .set({ lastActivityAt }) + .where(eq(contact.id, target.contactId)); } if (target.dealId) { - const { _max } = await this.db.activity.aggregate({ - where: { dealId: target.dealId }, - _max: { createdAt: true }, - }); - await this.db.deal.update({ - where: { id: target.dealId }, - data: { lastActivityAt: _max.createdAt }, - }); + const lastActivityAt = await this.latest( + eq(activity.dealId, target.dealId), + ); + await this.db + .update(deal) + .set({ lastActivityAt }) + .where(eq(deal.id, target.dealId)); } } async targetsOf( - where: Prisma.ActivityWhereInput, - client: Prisma.TransactionClient = this.db, + where: SQL | undefined, + client: Executor = this.db, ): Promise { const [companies, contacts, deals] = await Promise.all([ - client.activity.groupBy({ by: ["companyId"], where }), - client.activity.groupBy({ by: ["contactId"], where }), - client.activity.groupBy({ by: ["dealId"], where }), + client + .selectDistinct({ companyId: activity.companyId }) + .from(activity) + .where(and(where, isNotNull(activity.companyId))), + client + .selectDistinct({ contactId: activity.contactId }) + .from(activity) + .where(and(where, isNotNull(activity.contactId))), + client + .selectDistinct({ dealId: activity.dealId }) + .from(activity) + .where(and(where, isNotNull(activity.dealId))), ]); return { @@ -112,7 +159,9 @@ export class ActivityStampService { if (statements.length === 0) return; - await this.db.$transaction(statements); + await this.db.transaction(async (tx) => { + for (const statement of statements) await tx.execute(statement); + }); } async recomputeAfterDelete( @@ -133,23 +182,26 @@ export class ActivityStampService { } } - private restamp(table: string, column: string, ids: string[]) { + private restamp(table: string, column: string, ids: string[]): SQL | null { if (ids.length === 0) return null; - const record = PrismaNamespace.raw(`"${table}"`); - const key = PrismaNamespace.raw(`"${column}"`); + const record = sql.identifier(table); + const key = sql.identifier(column); - return this.db.$executeRaw` + return sql` UPDATE ${record} r SET "lastActivityAt" = ( SELECT MAX(a."createdAt") FROM "activity" a WHERE a.${key} = r.id ) - WHERE r.id IN (${PrismaNamespace.join(ids)})`; + WHERE r.id IN (${sql.join( + ids.map((id) => sql`${id}`), + sql`, `, + )})`; } async recomputeAll(): Promise { - await this.db.$transaction([ - this.db.$executeRaw` + const statements = [ + sql` UPDATE "company" c SET "lastActivityAt" = a.max FROM ( @@ -157,11 +209,11 @@ export class ActivityStampService { FROM "activity" WHERE "companyId" IS NOT NULL GROUP BY "companyId" ) a WHERE c.id = a.id AND c."lastActivityAt" IS DISTINCT FROM a.max`, - this.db.$executeRaw` + sql` UPDATE "company" SET "lastActivityAt" = NULL WHERE "lastActivityAt" IS NOT NULL AND id NOT IN (SELECT "companyId" FROM "activity" WHERE "companyId" IS NOT NULL)`, - this.db.$executeRaw` + sql` UPDATE "contact" c SET "lastActivityAt" = a.max FROM ( @@ -169,11 +221,11 @@ export class ActivityStampService { FROM "activity" WHERE "contactId" IS NOT NULL GROUP BY "contactId" ) a WHERE c.id = a.id AND c."lastActivityAt" IS DISTINCT FROM a.max`, - this.db.$executeRaw` + sql` UPDATE "contact" SET "lastActivityAt" = NULL WHERE "lastActivityAt" IS NOT NULL AND id NOT IN (SELECT "contactId" FROM "activity" WHERE "contactId" IS NOT NULL)`, - this.db.$executeRaw` + sql` UPDATE "deal" d SET "lastActivityAt" = a.max FROM ( @@ -181,10 +233,14 @@ export class ActivityStampService { FROM "activity" WHERE "dealId" IS NOT NULL GROUP BY "dealId" ) a WHERE d.id = a.id AND d."lastActivityAt" IS DISTINCT FROM a.max`, - this.db.$executeRaw` + sql` UPDATE "deal" SET "lastActivityAt" = NULL WHERE "lastActivityAt" IS NOT NULL AND id NOT IN (SELECT "dealId" FROM "activity" WHERE "dealId" IS NOT NULL)`, - ]); + ]; + + await this.db.transaction(async (tx) => { + for (const statement of statements) await tx.execute(statement); + }); } } diff --git a/apps/api/src/crm/bulk.ts b/apps/api/src/crm/bulk.ts index aa5474a01..943a7eca7 100644 --- a/apps/api/src/crm/bulk.ts +++ b/apps/api/src/crm/bulk.ts @@ -1,4 +1,4 @@ -import type { Db } from "@crm/db"; +import { type Executor, eq, user } from "@crm/db"; import { BadRequestException } from "@nestjs/common"; import { z } from "zod"; @@ -20,15 +20,16 @@ export type BulkResult = { }; export async function requireOwner( - db: Db, + db: Executor, ownerId: string | null, ): Promise { if (!ownerId) return; - const owner = await db.user.findUnique({ - where: { id: ownerId }, - select: { id: true }, - }); + const [owner] = await db + .select({ id: user.id }) + .from(user) + .where(eq(user.id, ownerId)) + .limit(1); if (!owner) { throw new BadRequestException("That owner does not work here any more."); diff --git a/apps/api/src/crm/enrichment-log.service.ts b/apps/api/src/crm/enrichment-log.service.ts index 2ba8cf5ab..488ec5294 100644 --- a/apps/api/src/crm/enrichment-log.service.ts +++ b/apps/api/src/crm/enrichment-log.service.ts @@ -1,4 +1,12 @@ -import { ActivityType, type Db } from "@crm/db"; +import { + ActivityType, + activity as activityTable, + company, + contact, + type Db, + eq, + user, +} from "@crm/db"; import type { ActivityMetaFields } from "@crm/validation/activity-meta"; import { Injectable } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; @@ -23,8 +31,9 @@ export class EnrichmentLogService { const author = await this.authorFor(event); if (!author) return null; - const activity = await this.db.activity.create({ - data: { + const [activity] = await this.db + .insert(activityTable) + .values({ type: ActivityType.ENRICHMENT, subject: event.subject, body: event.body ?? null, @@ -33,9 +42,9 @@ export class EnrichmentLogService { contactId: event.contactId ?? null, createdById: author, meta: { ...event.meta, automated: true }, - }, - select: { id: true, createdAt: true }, - }); + }) + .returning({ id: activityTable.id, createdAt: activityTable.createdAt }); + if (!activity) return null; await this.stamp.touch( { companyId: event.companyId, contactId: event.contactId }, @@ -47,22 +56,24 @@ export class EnrichmentLogService { private async authorFor(event: EnrichmentEvent): Promise { if (event.contactId) { - const contact = await this.db.contact.findUnique({ - where: { id: event.contactId }, - select: { ownerId: true }, - }); - if (contact?.ownerId) return contact.ownerId; + const [person] = await this.db + .select({ ownerId: contact.ownerId }) + .from(contact) + .where(eq(contact.id, event.contactId)) + .limit(1); + if (person?.ownerId) return person.ownerId; } if (event.companyId) { - const company = await this.db.company.findUnique({ - where: { id: event.companyId }, - select: { ownerId: true }, - }); - if (company?.ownerId) return company.ownerId; + const [owner] = await this.db + .select({ ownerId: company.ownerId }) + .from(company) + .where(eq(company.id, event.companyId)) + .limit(1); + if (owner?.ownerId) return owner.ownerId; } - const anyUser = await this.db.user.findFirst({ select: { id: true } }); + const [anyUser] = await this.db.select({ id: user.id }).from(user).limit(1); return anyUser?.id ?? null; } } diff --git a/apps/api/src/crm/values.ts b/apps/api/src/crm/values.ts index 52d8c8ed3..57ab6d261 100644 --- a/apps/api/src/crm/values.ts +++ b/apps/api/src/crm/values.ts @@ -1,7 +1,6 @@ -import type { Prisma } from "@crm/db"; -import { Prisma as PrismaNamespace } from "@crm/db"; +import { Decimal } from "@crm/db"; -export function toCents(amount: Prisma.Decimal | null): number | null { +export function toCents(amount: Decimal | null): number | null { return amount === null ? null : amount.times(100).toNumber(); } @@ -11,10 +10,10 @@ export function fromCents(cents: number | null | undefined): number | null { export function decimalFromCents( cents: number | null | undefined, -): Prisma.Decimal | null { +): Decimal | null { return cents === null || cents === undefined ? null - : new PrismaNamespace.Decimal(cents).dividedBy(100); + : new Decimal(cents).dividedBy(100); } export function blankToNull(value: string): string | null { diff --git a/apps/api/src/currency/conversion.service.ts b/apps/api/src/currency/conversion.service.ts index d4c35ebd1..78797e618 100644 --- a/apps/api/src/currency/conversion.service.ts +++ b/apps/api/src/currency/conversion.service.ts @@ -1,5 +1,17 @@ -import type { Db, Prisma as PrismaTypes } from "@crm/db"; -import { Prisma } from "@crm/db"; +import { + and, + count, + type Db, + Decimal, + deal, + eq, + isNotNull, + isNull, + ne, + or, + type SQL, + sql, +} from "@crm/db"; import { minorUnitsOf, normalizeCurrency } from "@crm/db/currency"; import { type Conversion, @@ -12,9 +24,9 @@ import { Injectable, Logger } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; export interface DealFxFields { - baseAmount: PrismaTypes.Decimal | null; + baseAmount: Decimal | null; baseCurrency: string | null; - fxRate: PrismaTypes.Decimal | null; + fxRate: Decimal | null; fxRateAt: Date | null; } @@ -45,7 +57,7 @@ export class ConversionService { } async convert( - amount: PrismaTypes.Decimal | null, + amount: Decimal | null, currency: string, ): Promise { return convertToBase( @@ -57,7 +69,7 @@ export class ConversionService { } async dealFields( - amount: PrismaTypes.Decimal | null, + amount: Decimal | null, currency: string, ): Promise { const converted = await this.convert(amount, currency); @@ -79,34 +91,32 @@ export class ConversionService { }; } - countedWhere(base: string): PrismaTypes.DealWhereInput { - return { baseAmount: { not: null }, baseCurrency: base }; + countedWhere(base: string): SQL | undefined { + return and(isNotNull(deal.baseAmount), eq(deal.baseCurrency, base)); } - pendingWhere(base: string): PrismaTypes.DealWhereInput { - return { - amount: { not: null }, - OR: [ - { baseAmount: null }, - { baseCurrency: null }, - { baseCurrency: { not: base } }, - ], - }; + pendingWhere(base: string): SQL | undefined { + return and( + isNotNull(deal.amount), + or( + isNull(deal.baseAmount), + isNull(deal.baseCurrency), + ne(deal.baseCurrency, base), + ), + ); } - async unconverted( - where: PrismaTypes.DealWhereInput = {}, - ): Promise { + async unconverted(where?: SQL): Promise { const base = await this.reportingCurrency(); - const rows = await this.db.deal.groupBy({ - by: ["currency"], - where: { AND: [where, this.pendingWhere(base)] }, - _count: { _all: true }, - }); + const rows = await this.db + .select({ currency: deal.currency, count: count() }) + .from(deal) + .where(and(where, this.pendingWhere(base))) + .groupBy(deal.currency); return { - count: rows.reduce((total, row) => total + row._count._all, 0), + count: rows.reduce((total, row) => total + row.count, 0), currencies: rows .map((row) => normalizeCurrency(row.currency)) .filter((code, index, all) => all.indexOf(code) === index) @@ -125,11 +135,11 @@ export class ConversionService { private async rerate(onlyMissing: boolean): Promise { const base = await this.reportingCurrency(); - const groups = await this.db.deal.groupBy({ - by: ["currency"], - where: onlyMissing ? this.pendingWhere(base) : { amount: { not: null } }, - _count: { _all: true }, - }); + const groups = await this.db + .select({ currency: deal.currency }) + .from(deal) + .where(onlyMissing ? this.pendingWhere(base) : isNotNull(deal.amount)) + .groupBy(deal.currency); const codes = [ ...new Set(groups.map((group) => normalizeCurrency(group.currency))), @@ -176,13 +186,13 @@ export class ConversionService { places: number, onlyMissing: boolean, ): Promise { - const value = new Prisma.Decimal(rate.rate).toString(); + const value = new Decimal(rate.rate).toString(); const filter = onlyMissing - ? Prisma.sql`AND ("baseAmount" IS NULL OR "baseCurrency" IS DISTINCT FROM ${base})` - : Prisma.empty; + ? sql`AND ("baseAmount" IS NULL OR "baseCurrency" IS DISTINCT FROM ${base})` + : sql``; - return this.db.$executeRaw` + const result = await this.db.execute(sql` UPDATE "deal" SET "baseAmount" = ROUND("amount" * ${value}::numeric, ${places}::int), "baseCurrency" = ${base}, @@ -191,11 +201,12 @@ export class ConversionService { WHERE "amount" IS NOT NULL AND upper(btrim("currency")) = ${code} ${filter} - `; + `); + return result.rowCount ?? 0; } private async clear(code: string): Promise { - return this.db.$executeRaw` + const result = await this.db.execute(sql` UPDATE "deal" SET "baseAmount" = NULL, "baseCurrency" = NULL, @@ -203,6 +214,7 @@ export class ConversionService { "fxRateAt" = NULL WHERE upper(btrim("currency")) = ${code} AND "baseAmount" IS NOT NULL - `; + `); + return result.rowCount ?? 0; } } diff --git a/apps/api/src/currency/currency.service.ts b/apps/api/src/currency/currency.service.ts index d0bfa973a..149c9d575 100644 --- a/apps/api/src/currency/currency.service.ts +++ b/apps/api/src/currency/currency.service.ts @@ -1,6 +1,15 @@ import { canManageCurrency, workspaceRoleOf } from "@crm/auth/organization"; import type { Db } from "@crm/db"; -import { Prisma, RateSource } from "@crm/db"; +import { + and, + count, + Decimal, + deal, + eq, + exchangeRate, + isNotNull, + RateSource, +} from "@crm/db"; import { CURRENCIES, currencyName, normalizeCurrency } from "@crm/db/currency"; import { writeReportingCurrency } from "@crm/db/settings"; import { @@ -28,23 +37,23 @@ export class CurrencyService { const reportingCurrency = await this.conversion.reportingCurrency(); const [rows, refreshedAt, unconverted, usage] = await Promise.all([ - this.db.exchangeRate.findMany({ - where: { baseCurrency: reportingCurrency }, - select: { - quoteCurrency: true, - rate: true, - asOf: true, - source: true, - provider: true, - }, - }), + this.db + .select({ + quoteCurrency: exchangeRate.quoteCurrency, + rate: exchangeRate.rate, + asOf: exchangeRate.asOf, + source: exchangeRate.source, + provider: exchangeRate.provider, + }) + .from(exchangeRate) + .where(eq(exchangeRate.baseCurrency, reportingCurrency)), this.rates.refreshedAt(), this.conversion.unconverted(), - this.db.deal.groupBy({ - by: ["currency"], - where: { amount: { not: null } }, - _count: { _all: true }, - }), + this.db + .select({ currency: deal.currency, count: count() }) + .from(deal) + .where(isNotNull(deal.amount)) + .groupBy(deal.currency), ]); const manual = new Set( @@ -88,7 +97,7 @@ export class CurrencyService { return { currency, name: currencyName(currency), - deals: row._count._all, + deals: row.count, convertible: convertible.has(currency), }; }) @@ -156,23 +165,23 @@ export class CurrencyService { const asOf = new Date(); - await this.db.exchangeRate.upsert({ - where: { - baseCurrency_quoteCurrency_source: { - baseCurrency, - quoteCurrency, - source: RateSource.MANUAL, - }, - }, - create: { + await this.db + .insert(exchangeRate) + .values({ baseCurrency, quoteCurrency, - rate: new Prisma.Decimal(rate), + rate: new Decimal(rate), asOf, source: RateSource.MANUAL, - }, - update: { rate: new Prisma.Decimal(rate), asOf }, - }); + }) + .onConflictDoUpdate({ + target: [ + exchangeRate.baseCurrency, + exchangeRate.quoteCurrency, + exchangeRate.source, + ], + set: { rate: new Decimal(rate), asOf }, + }); const filled = await this.conversion.fillMissing(); @@ -195,9 +204,15 @@ export class CurrencyService { const quoteCurrency = normalizeCurrency(code); const baseCurrency = await this.conversion.reportingCurrency(); - await this.db.exchangeRate.deleteMany({ - where: { baseCurrency, quoteCurrency, source: RateSource.MANUAL }, - }); + await this.db + .delete(exchangeRate) + .where( + and( + eq(exchangeRate.baseCurrency, baseCurrency), + eq(exchangeRate.quoteCurrency, quoteCurrency), + eq(exchangeRate.source, RateSource.MANUAL), + ), + ); this.logger.log({ message: "Manual exchange rate removed", diff --git a/apps/api/src/currency/rates.service.ts b/apps/api/src/currency/rates.service.ts index ac247975c..e992b2011 100644 --- a/apps/api/src/currency/rates.service.ts +++ b/apps/api/src/currency/rates.service.ts @@ -1,5 +1,13 @@ import type { Db } from "@crm/db"; -import { Prisma, RateSource } from "@crm/db"; +import { + and, + Decimal, + eq, + exchangeRate, + notInArray, + or, + RateSource, +} from "@crm/db"; import { CURRENCY_CODES, isCurrencyCode, @@ -108,51 +116,54 @@ export class RatesService { private async store( base: string, - rates: Map, + rates: Map, asOf: Date, ): Promise { let written = 0; for (const [quoteCurrency, rate] of rates) { - await this.db.exchangeRate.upsert({ - where: { - baseCurrency_quoteCurrency_source: { - baseCurrency: base, - quoteCurrency, - source: RateSource.FETCHED, - }, - }, - create: { + await this.db + .insert(exchangeRate) + .values({ baseCurrency: base, quoteCurrency, rate, asOf, source: RateSource.FETCHED, provider: RATES_PROVIDER, - }, - update: { rate, asOf, provider: RATES_PROVIDER }, - }); + }) + .onConflictDoUpdate({ + target: [ + exchangeRate.baseCurrency, + exchangeRate.quoteCurrency, + exchangeRate.source, + ], + set: { rate, asOf, provider: RATES_PROVIDER }, + }); written += 1; } const supported = [...CURRENCY_CODES]; - const stale = await this.db.exchangeRate.deleteMany({ - where: { - source: RateSource.FETCHED, - OR: [ - { baseCurrency: { notIn: supported } }, - { quoteCurrency: { notIn: supported } }, - ], - }, - }); - - if (stale.count > 0) { + const stale = await this.db + .delete(exchangeRate) + .where( + and( + eq(exchangeRate.source, RateSource.FETCHED), + or( + notInArray(exchangeRate.baseCurrency, supported), + notInArray(exchangeRate.quoteCurrency, supported), + ), + ), + ) + .returning({ id: exchangeRate.id }); + + if (stale.length > 0) { this.logger.log({ message: "Dropped fetched rates for currencies that are not supported", base, - dropped: stale.count, + dropped: stale.length, }); } @@ -161,7 +172,7 @@ export class RatesService { private async fetch( base: string, - ): Promise<{ rates: Map; asOf: Date } | null> { + ): Promise<{ rates: Map; asOf: Date } | null> { for (let attempt = 1; attempt <= RATES_ATTEMPTS; attempt += 1) { const quotes = await this.attempt(base, attempt); if (quotes) return quotes; @@ -175,7 +186,7 @@ export class RatesService { private async attempt( base: string, attempt: number, - ): Promise<{ rates: Map; asOf: Date } | null> { + ): Promise<{ rates: Map; asOf: Date } | null> { try { const response = await fetch(`${RATES_URL}/${encodeURIComponent(base)}`, { headers: { accept: "application/json" }, @@ -205,7 +216,7 @@ export class RatesService { } const asOf = parseAsOf(body.time_last_update_unix) ?? new Date(); - const rates = new Map(); + const rates = new Map(); for (const [code, value] of Object.entries(body.rates)) { const quoteCurrency = normalizeCurrency(code); @@ -217,7 +228,7 @@ export class RatesService { rates.set( quoteCurrency, - new Prisma.Decimal(1).dividedBy(perBase).toDecimalPlaces(10), + new Decimal(1).dividedBy(perBase).toDecimalPlaces(10), ); } diff --git a/apps/api/src/dashboard/dashboard.service.ts b/apps/api/src/dashboard/dashboard.service.ts index 30756818e..ab3e516e1 100644 --- a/apps/api/src/dashboard/dashboard.service.ts +++ b/apps/api/src/dashboard/dashboard.service.ts @@ -1,4 +1,24 @@ -import { ActivityType, type Db, DealStage } from "@crm/db"; +import { + ActivityType, + activity, + and, + asc, + company, + count, + type Db, + DealStage, + deal, + desc, + eq, + gte, + inArray, + isNull, + lt, + or, + sql, + sum, + user, +} from "@crm/db"; import { OPEN_DEAL_STAGES } from "@crm/db/deal-stage"; import { activityMeta } from "@crm/validation/activity-meta"; import { Injectable } from "@nestjs/common"; @@ -8,12 +28,12 @@ import { ConversionService } from "../currency/conversion.service"; import { InjectDatabase } from "../database/database.constants"; import type { DashboardSummaryInput } from "./dashboard.contracts"; -const OWNER_SELECT = { - id: true, - name: true, - email: true, - image: true, -} as const; +const OWNER_FIELDS = { + id: user.id, + name: user.name, + email: user.email, + image: user.image, +}; const TREND_MONTHS = 6; @@ -40,7 +60,8 @@ export class DashboardService { async summary(actingUserId: string, input: DashboardSummaryInput) { const mine = input.scope === "me"; - const owned = mine ? { ownerId: actingUserId } : {}; + const owned = mine ? eq(deal.ownerId, actingUserId) : undefined; + const open = and(owned, inArray(deal.stage, [...OPEN_DEAL_STAGES])); const now = new Date(); const { startOfToday } = taskDayBoundaries(now); @@ -63,108 +84,111 @@ export class DashboardService { recentActivity, unconverted, ] = await Promise.all([ - this.db.deal.groupBy({ - by: ["stage"], - where: { ...owned, stage: { in: [...OPEN_DEAL_STAGES] } }, - _count: { _all: true }, - }), - this.db.deal.groupBy({ - by: ["stage"], - where: { - AND: [{ ...owned, stage: { in: [...OPEN_DEAL_STAGES] } }, counted], - }, - _sum: { baseAmount: true }, - }), - this.db.deal.findMany({ - where: { - ...owned, - OR: [ - { createdAt: { gte: trendStart } }, - { closedAt: { gte: trendStart } }, - ], - }, - select: { - baseAmount: true, - baseCurrency: true, - stage: true, - createdAt: true, - closedAt: true, - }, - }), - this.db.deal.aggregate({ - where: { - AND: [ - { - ...owned, - stage: { in: [...OPEN_DEAL_STAGES] }, - expectedCloseDate: { gte: startOfMonth, lt: startOfNextMonth }, - }, + this.db + .select({ stage: deal.stage, count: count() }) + .from(deal) + .where(open) + .groupBy(deal.stage), + this.db + .select({ + stage: deal.stage, + baseAmount: sum(deal.baseAmount).mapWith(deal.baseAmount), + }) + .from(deal) + .where(and(open, counted)) + .groupBy(deal.stage), + this.db + .select({ + baseAmount: deal.baseAmount, + baseCurrency: deal.baseCurrency, + stage: deal.stage, + createdAt: deal.createdAt, + closedAt: deal.closedAt, + }) + .from(deal) + .where( + and( + owned, + or(gte(deal.createdAt, trendStart), gte(deal.closedAt, trendStart)), + ), + ), + this.db + .select({ + count: count(), + baseAmount: sum(deal.baseAmount).mapWith(deal.baseAmount), + }) + .from(deal) + .where( + and( + open, + gte(deal.expectedCloseDate, startOfMonth), + lt(deal.expectedCloseDate, startOfNextMonth), counted, - ], - }, - _count: { _all: true }, - _sum: { baseAmount: true }, - }), - this.db.deal.findMany({ - where: { ...owned, stage: { in: [...OPEN_DEAL_STAGES] } }, - orderBy: [ - { baseAmount: { sort: "desc", nulls: "last" } }, - { expectedCloseDate: "asc" }, - ], - take: 6, - select: { - id: true, - name: true, - stage: true, - amount: true, - currency: true, - baseAmount: true, - baseCurrency: true, - expectedCloseDate: true, - stageChangedAt: true, + ), + ) + .then(([row]) => row ?? { count: 0, baseAmount: null }), + this.db + .select({ + id: deal.id, + name: deal.name, + stage: deal.stage, + amount: deal.amount, + currency: deal.currency, + baseAmount: deal.baseAmount, + baseCurrency: deal.baseCurrency, + expectedCloseDate: deal.expectedCloseDate, + stageChangedAt: deal.stageChangedAt, company: { - select: { - id: true, - name: true, - iconUrl: true, - iconDarkUrl: true, - iconTone: true, - }, + id: company.id, + name: company.name, + iconUrl: company.iconUrl, + iconDarkUrl: company.iconDarkUrl, + iconTone: company.iconTone, }, - owner: { select: OWNER_SELECT }, - }, - }), - this.db.activity.findMany({ - where: { - type: ActivityType.TASK, - completedAt: null, - dueAt: { lt: startOfToday }, - createdById: actingUserId, - }, - orderBy: [{ dueAt: "asc" }], - take: 10, - select: { - id: true, - subject: true, - dueAt: true, - company: { select: { id: true, name: true } }, - deal: { select: { id: true, name: true } }, + owner: OWNER_FIELDS, + }) + .from(deal) + .innerJoin(company, eq(company.id, deal.companyId)) + .innerJoin(user, eq(user.id, deal.ownerId)) + .where(open) + .orderBy( + sql`${deal.baseAmount} desc nulls last`, + asc(deal.expectedCloseDate), + ) + .limit(6), + this.db.query.activity.findMany({ + where: and( + eq(activity.type, ActivityType.TASK), + isNull(activity.completedAt), + lt(activity.dueAt, startOfToday), + eq(activity.createdById, actingUserId), + ), + orderBy: [asc(activity.dueAt)], + limit: 10, + columns: { id: true, subject: true, dueAt: true }, + with: { + company: { columns: { id: true, name: true } }, + deal: { columns: { id: true, name: true } }, }, }), - this.db.activity.findMany({ - where: mine ? { createdById: actingUserId } : {}, - orderBy: [{ createdAt: "desc" }], - take: 12, - select: { + this.db.query.activity.findMany({ + where: mine ? eq(activity.createdById, actingUserId) : undefined, + orderBy: [desc(activity.createdAt)], + limit: 12, + columns: { id: true, type: true, subject: true, body: true, createdAt: true, meta: true, - createdBy: { select: OWNER_SELECT }, - company: { select: { id: true, name: true } }, - deal: { select: { id: true, name: true } }, + }, + with: { + createdBy: { + columns: { id: true, name: true, email: true, image: true }, + }, + company: { columns: { id: true, name: true } }, + deal: { columns: { id: true, name: true } }, }, }), this.conversion.unconverted(owned), @@ -175,8 +199,8 @@ export class DashboardService { const value = openValueByStage.find((row) => row.stage === stage); return { stage: stage as DealStage, - count: group?._count._all ?? 0, - valueCents: toCents(value?._sum.baseAmount ?? null) ?? 0, + count: group?.count ?? 0, + valueCents: toCents(value?.baseAmount ?? null) ?? 0, }; }); @@ -195,15 +219,15 @@ export class DashboardService { let wonCents = 0; let cycleDays = 0; - for (const deal of recentDeals) { + for (const recent of recentDeals) { const valued = - deal.baseCurrency === base ? toCents(deal.baseAmount) : null; + recent.baseCurrency === base ? toCents(recent.baseAmount) : null; const cents = valued ?? 0; - const created = trend[monthKey(deal.createdAt) - firstBucket]; + const created = trend[monthKey(recent.createdAt) - firstBucket]; if (created) created.created += cents; - const { closedAt, stage } = deal; + const { closedAt, stage } = recent; if (!closedAt) continue; const won = stage === DealStage.CLOSED_WON; @@ -227,7 +251,7 @@ export class DashboardService { valuedWins += 1; wonCents += cents; } - cycleDays += (closedAt.getTime() - deal.createdAt.getTime()) / DAY_MS; + cycleDays += (closedAt.getTime() - recent.createdAt.getTime()) / DAY_MS; } else if (stage === DealStage.CLOSED_LOST) { losses += 1; } @@ -257,8 +281,8 @@ export class DashboardService { }, trend, closingThisMonthTotal: { - count: closingThisMonthTotals._count._all, - valueCents: toCents(closingThisMonthTotals._sum.baseAmount) ?? 0, + count: closingThisMonthTotals.count, + valueCents: toCents(closingThisMonthTotals.baseAmount) ?? 0, }, biggestOpen: biggestOpen .map( @@ -268,9 +292,9 @@ export class DashboardService { baseCurrency, expectedCloseDate, stageChangedAt, - ...deal + ...row }) => ({ - ...deal, + ...row, amountCents: toCents(amount), baseAmountCents: baseCurrency === base ? toCents(baseAmount) : null, expectedCloseDate: expectedCloseDate?.toISOString() ?? null, diff --git a/apps/api/src/database/database.module.ts b/apps/api/src/database/database.module.ts index 5c0fa1317..f6260a966 100644 --- a/apps/api/src/database/database.module.ts +++ b/apps/api/src/database/database.module.ts @@ -1,4 +1,4 @@ -import { type Db, db } from "@crm/db"; +import { type Db, db, sql } from "@crm/db"; import { Global, Logger, @@ -20,7 +20,7 @@ export class DatabaseModule implements OnModuleInit, OnApplicationShutdown { async onModuleInit(): Promise { try { - await this.db.$connect(); + await this.db.execute(sql`SELECT 1`); this.logger.log({ message: "Database connected" }); } catch (error) { this.logger.fatal( @@ -32,7 +32,7 @@ export class DatabaseModule implements OnModuleInit, OnApplicationShutdown { } async onApplicationShutdown(signal?: string): Promise { - await this.db.$disconnect(); + await this.db.$client.end(); this.logger.log({ message: "Database disconnected", signal }); } } diff --git a/apps/api/src/deals/deals.service.ts b/apps/api/src/deals/deals.service.ts index 4a4d01c86..ced7fef4e 100644 --- a/apps/api/src/deals/deals.service.ts +++ b/apps/api/src/deals/deals.service.ts @@ -1,9 +1,31 @@ import { ActivityType, + activity, + agentTask, + and, + asc, + company, + contact, + contains, type Db, type DealStage, - type Prisma, - Prisma as PrismaNamespace, + deal, + dealContact, + desc, + eq, + gte, + hasValues, + inArray, + isForeignKeyViolation, + isNull, + lt, + lte, + type NewDeal, + notInArray, + or, + type SQL, + sum, + user, } from "@crm/db"; import { normalizeCurrency } from "@crm/db/currency"; import { @@ -26,12 +48,7 @@ import { type StampTargets, } from "../crm/activity-stamp.service"; import { type BulkResult, requireOwner, runBulk } from "../crm/bulk"; -import { - blankToNull, - decimalFromCents, - fromCents, - toCents, -} from "../crm/values"; +import { blankToNull, decimalFromCents, toCents } from "../crm/values"; import { ConversionService } from "../currency/conversion.service"; import { InjectDatabase } from "../database/database.constants"; import { FieldsService } from "../fields/fields.service"; @@ -39,11 +56,14 @@ import { archivedFilter, countsByKey, FACET_UNASSIGNED, + groupCounts, type ListResult, type OrderByColumns, ownerFilter, paginate, resolveOrderBy, + sortBy, + sortNullsLast, } from "../trpc/list-input"; import type { ClosingWindow, @@ -59,14 +79,14 @@ import type { } from "./deals.contracts"; import { CLOSING_WINDOWS } from "./deals.contracts"; -const OWNER_SELECT = { +const OWNER_COLUMNS = { id: true, name: true, email: true, image: true, } as const; -const COMPANY_SELECT = { +const COMPANY_COLUMNS = { id: true, name: true, domain: true, @@ -76,7 +96,7 @@ const COMPANY_SELECT = { logoUrl: true, } as const; -const CONTACT_SELECT = { +const CONTACT_COLUMNS = { id: true, firstName: true, lastName: true, @@ -85,18 +105,44 @@ const CONTACT_SELECT = { imageUrl: true, } as const; +const OWNER_FIELDS = { + id: user.id, + name: user.name, + email: user.email, + image: user.image, +}; + +const COMPANY_FIELDS = { + id: company.id, + name: company.name, + domain: company.domain, + iconUrl: company.iconUrl, + iconDarkUrl: company.iconDarkUrl, + iconTone: company.iconTone, + logoUrl: company.logoUrl, +}; + +const CONTACT_FIELDS = { + id: contact.id, + firstName: contact.firstName, + lastName: contact.lastName, + email: contact.email, + title: contact.title, + imageUrl: contact.imageUrl, +}; + const LOSING = new Set(LOSING_DEAL_STAGES); -const SORTABLE: OrderByColumns = { - name: (dir) => [{ name: dir }], - company: (dir) => [{ company: { name: dir } }, { name: "asc" }], - stage: (dir) => [{ stage: dir }, { expectedCloseDate: "asc" }], - amount: (dir) => [{ baseAmount: { sort: dir, nulls: "last" } }], - expectedCloseDate: (dir) => [{ expectedCloseDate: dir }], - createdAt: (dir) => [{ createdAt: dir }], - owner: (dir) => [{ owner: { name: dir } }, { name: "asc" }], - lastActivity: (dir) => [{ lastActivityAt: { sort: dir, nulls: "last" } }], - archivedAt: (dir) => [{ archivedAt: { sort: dir, nulls: "last" } }], +const SORTABLE: OrderByColumns = { + name: (dir) => [sortBy(deal.name, dir)], + company: (dir) => [sortBy(company.name, dir), asc(deal.name)], + stage: (dir) => [sortBy(deal.stage, dir), asc(deal.expectedCloseDate)], + amount: (dir) => [sortNullsLast(deal.baseAmount, dir)], + expectedCloseDate: (dir) => [sortBy(deal.expectedCloseDate, dir)], + createdAt: (dir) => [sortBy(deal.createdAt, dir)], + owner: (dir) => [sortBy(user.name, dir), asc(deal.name)], + lastActivity: (dir) => [sortNullsLast(deal.lastActivityAt, dir)], + archivedAt: (dir) => [sortNullsLast(deal.archivedAt, dir)], }; @Injectable() @@ -116,38 +162,41 @@ export class DealsService { const where = this.buildWhere(input, filterableFields); const { skip, take } = paginate(input); - const openWhere = { ...where, stage: { in: [...OPEN_DEAL_STAGES] } }; + const openWhere = and(where, inArray(deal.stage, [...OPEN_DEAL_STAGES])); const base = await this.conversion.reportingCurrency(); const [rows, total, facetCounts, openValue, unconverted] = await Promise.all([ - this.db.deal.findMany({ - where, - skip, - take, - orderBy: resolveOrderBy(input, SORTABLE, [{ createdAt: "desc" }]), - select: { - id: true, - name: true, - stage: true, - amount: true, - currency: true, - baseAmount: true, - expectedCloseDate: true, - closedAt: true, - company: { select: COMPANY_SELECT }, - owner: { select: OWNER_SELECT }, - lastActivityAt: true, - createdAt: true, - archivedAt: true, - }, - }), - this.db.deal.count({ where }), + this.db + .select({ + id: deal.id, + name: deal.name, + stage: deal.stage, + amount: deal.amount, + currency: deal.currency, + baseAmount: deal.baseAmount, + expectedCloseDate: deal.expectedCloseDate, + closedAt: deal.closedAt, + company: COMPANY_FIELDS, + owner: OWNER_FIELDS, + lastActivityAt: deal.lastActivityAt, + createdAt: deal.createdAt, + archivedAt: deal.archivedAt, + }) + .from(deal) + .innerJoin(company, eq(company.id, deal.companyId)) + .innerJoin(user, eq(user.id, deal.ownerId)) + .where(where) + .orderBy(...resolveOrderBy(input, SORTABLE, [desc(deal.createdAt)])) + .limit(take) + .offset(skip), + this.db.$count(deal, where), this.facetCounts(input, filterableFields), - this.db.deal.aggregate({ - where: { AND: [openWhere, this.conversion.countedWhere(base)] }, - _sum: { baseAmount: true }, - }), + this.db + .select({ baseAmount: sum(deal.baseAmount).mapWith(deal.baseAmount) }) + .from(deal) + .where(and(openWhere, this.conversion.countedWhere(base))) + .then(([row]) => row?.baseAmount ?? null), this.conversion.unconverted(openWhere), ]); @@ -181,7 +230,7 @@ export class DealsService { ), total, facetCounts, - openValueCents: toCents(openValue._sum.baseAmount), + openValueCents: toCents(openValue), reportingCurrency: base, unconverted, } satisfies ListResult & { @@ -192,9 +241,9 @@ export class DealsService { } async byId(id: string) { - const deal = await this.db.deal.findUnique({ - where: { id }, - select: { + const record = await this.db.query.deal.findFirst({ + where: eq(deal.id, id), + columns: { id: true, name: true, description: true, @@ -210,16 +259,18 @@ export class DealsService { closedReason: true, createdAt: true, archivedAt: true, - company: { select: { ...COMPANY_SELECT, industry: true } }, - owner: { select: OWNER_SELECT }, + }, + with: { + company: { columns: { ...COMPANY_COLUMNS, industry: true } }, + owner: { columns: OWNER_COLUMNS }, contacts: { - select: { role: true, contact: { select: CONTACT_SELECT } }, - orderBy: { contact: { firstName: "asc" } }, + columns: { role: true }, + with: { contact: { columns: CONTACT_COLUMNS } }, }, }, }); - if (!deal) { + if (!record) { throw new NotFoundException(`No deal with id ${id}.`); } @@ -231,7 +282,7 @@ export class DealsService { fxRateAt, archivedAt, ...rest - } = deal; + } = record; return { ...rest, @@ -241,12 +292,16 @@ export class DealsService { reportingCurrency: await this.conversion.reportingCurrency(), fxRate: fxRate?.toNumber() ?? null, fxRateAt: fxRateAt?.toISOString() ?? null, - stageChangedAt: deal.stageChangedAt.toISOString(), - expectedCloseDate: deal.expectedCloseDate?.toISOString() ?? null, - closedAt: deal.closedAt?.toISOString() ?? null, - createdAt: deal.createdAt.toISOString(), + stageChangedAt: record.stageChangedAt.toISOString(), + expectedCloseDate: record.expectedCloseDate?.toISOString() ?? null, + closedAt: record.closedAt?.toISOString() ?? null, + createdAt: record.createdAt.toISOString(), archivedAt: archivedAt?.toISOString() ?? null, - contacts: contacts.map(({ role, contact }) => ({ ...contact, role })), + contacts: contacts + .map(({ role, contact: person }) => ({ ...person, role })) + .toSorted((left, right) => + left.firstName.localeCompare(right.firstName), + ), }; } @@ -264,22 +319,28 @@ export class DealsService { ); try { - const deal = await this.agent.withCrmEvents(async (tx, emit) => { - const created = await tx.deal.create({ - data: { + const created = await this.agent.withCrmEvents(async (tx, emit) => { + const [inserted] = await tx + .insert(deal) + .values({ name: input.name.trim(), companyId: input.companyId, ownerId: input.ownerId, stage, stageChangedAt: now, closedAt: closed ? now : null, - amount: fromCents(input.amountCents), + amount: decimalFromCents(input.amountCents), currency, ...fx, expectedCloseDate: parseDate(input.expectedCloseDate), - }, - select: { id: true, name: true, companyId: true }, - }); + }) + .returning({ + id: deal.id, + name: deal.name, + companyId: deal.companyId, + }); + if (!inserted) throw new Error("The deal insert returned no row."); + const created = inserted; await emit({ type: "deal.created", record: { kind: "deal", id: created.id }, @@ -297,32 +358,28 @@ export class DealsService { return created; }); - this.logger.log({ message: "Deal created", dealId: deal.id, stage }); + this.logger.log({ message: "Deal created", dealId: created.id, stage }); - void this.fields.queueBackfillForNewRecord("DEAL", deal.id); + void this.fields.queueBackfillForNewRecord("DEAL", created.id); - return deal; + return created; } catch (error) { throw this.translateRelations(error); } } async update(id: string, input: DealUpdateInput) { - const data: Prisma.DealUpdateInput = {}; + const data: Partial = {}; if (input.name !== undefined) data.name = input.name.trim(); if (input.description !== undefined) { data.description = input.description === null ? null : blankToNull(input.description); } - if (input.companyId !== undefined) { - data.company = { connect: { id: input.companyId } }; - } - if (input.ownerId !== undefined) { - data.owner = { connect: { id: input.ownerId } }; - } + if (input.companyId !== undefined) data.companyId = input.companyId; + if (input.ownerId !== undefined) data.ownerId = input.ownerId; if (input.amountCents !== undefined) { - data.amount = fromCents(input.amountCents); + data.amount = decimalFromCents(input.amountCents); } if (input.currency !== undefined) { data.currency = normalizeCurrency(input.currency); @@ -332,10 +389,11 @@ export class DealsService { } if (input.amountCents !== undefined || input.currency !== undefined) { - const current = await this.db.deal.findUnique({ - where: { id }, - select: { amount: true, currency: true }, - }); + const [current] = await this.db + .select({ amount: deal.amount, currency: deal.currency }) + .from(deal) + .where(eq(deal.id, id)) + .limit(1); if (!current) { throw new NotFoundException(`No deal with id ${id}.`); @@ -354,16 +412,21 @@ export class DealsService { } try { - return await this.db.$transaction(async (tx) => { + return await this.db.transaction(async (tx) => { if (input.fields) { await this.fields.applyValues(tx, "DEAL", id, input.fields); } - return tx.deal.update({ - where: { id }, - data, - select: { id: true, name: true }, - }); + const fields = { id: deal.id, name: deal.name }; + const [updated] = hasValues(data) + ? await tx + .update(deal) + .set(data) + .where(eq(deal.id, id)) + .returning(fields) + : await tx.select(fields).from(deal).where(eq(deal.id, id)).limit(1); + if (!updated) throw new NotFoundException(`No deal with id ${id}.`); + return updated; }); } catch (error) { throw this.translate(error, id); @@ -371,35 +434,30 @@ export class DealsService { } async archive(id: string): Promise<{ id: string; name: string }> { - try { - const deal = await this.db.deal.update({ - where: { id }, - data: { archivedAt: new Date() }, - select: { name: true }, - }); - - this.logger.log({ message: "Deal archived", dealId: id }); - - return { id, name: deal.name }; - } catch (error) { - throw this.translate(error, id); - } + const name = await this.setArchivedAt(id, new Date()); + this.logger.log({ message: "Deal archived", dealId: id }); + return { id, name }; } async restore(id: string): Promise<{ id: string; name: string }> { - try { - const deal = await this.db.deal.update({ - where: { id }, - data: { archivedAt: null }, - select: { name: true }, - }); + const name = await this.setArchivedAt(id, null); + this.logger.log({ message: "Deal restored", dealId: id }); + return { id, name }; + } - this.logger.log({ message: "Deal restored", dealId: id }); + private async setArchivedAt( + id: string, + archivedAt: Date | null, + ): Promise { + const [row] = await this.db + .update(deal) + .set({ archivedAt }) + .where(eq(deal.id, id)) + .returning({ name: deal.name }); - return { id, name: deal.name }; - } catch (error) { - throw this.translate(error, id); - } + if (!row) throw new NotFoundException(`No deal with id ${id}.`); + + return row.name; } async purge(id: string): Promise<{ id: string; name: string }>; @@ -414,10 +472,12 @@ export class DealsService { let deleted: { targets: StampTargets; name: string } | null; try { - deleted = await this.db.$transaction(async (tx) => { - const [row] = await tx.$queryRaw>` - SELECT "archivedAt" FROM deal WHERE id = ${id} FOR UPDATE - `; + deleted = await this.db.transaction(async (tx) => { + const [row] = await tx + .select({ archivedAt: deal.archivedAt }) + .from(deal) + .where(eq(deal.id, id)) + .for("update"); if (!row) { if (guard) return null; @@ -430,15 +490,16 @@ export class DealsService { return null; } - const targets = await this.stamp.targetsOf({ dealId: id }, tx); - await tx.agentTask.deleteMany({ where: { dealId: id } }); + const targets = await this.stamp.targetsOf(eq(activity.dealId, id), tx); + await tx.delete(agentTask).where(eq(agentTask.dealId, id)); - const deal = await tx.deal.delete({ - where: { id }, - select: { name: true }, - }); + const [removed] = await tx + .delete(deal) + .where(eq(deal.id, id)) + .returning({ name: deal.name }); + if (!removed) throw new NotFoundException(`No deal with id ${id}.`); - return { targets, name: deal.name }; + return { targets, name: removed.name }; }); } catch (error) { throw this.translate(error, id); @@ -458,11 +519,11 @@ export class DealsService { } async purgeExpired(before: Date): Promise { - const expired = await this.db.deal.findMany({ - where: { archivedAt: { lte: before } }, - select: { id: true }, - take: ARCHIVE.prune.maxBatch, - }); + const expired = await this.db + .select({ id: deal.id }) + .from(deal) + .where(lte(deal.archivedAt, before)) + .limit(ARCHIVE.prune.maxBatch); return runBulk( expired.map((row) => row.id), @@ -474,24 +535,21 @@ export class DealsService { const closedReason = input.closedReason?.trim(); const closed = isClosedStage(input.stage); const transition = await this.agent.withCrmEvents(async (tx, emit) => { - const [deal] = await tx.$queryRaw< - Array<{ id: string; stage: DealStage; companyId: string }> - >` - SELECT id, stage, "companyId" - FROM deal - WHERE id = ${input.id} - FOR UPDATE - `; - - if (!deal) { + const [current] = await tx + .select({ id: deal.id, stage: deal.stage, companyId: deal.companyId }) + .from(deal) + .where(eq(deal.id, input.id)) + .for("update"); + + if (!current) { throw new NotFoundException(`No deal with id ${input.id}.`); } - if (deal.stage === input.stage) { + if (current.stage === input.stage) { return { changed: false as const, - deal, - updated: { id: deal.id, stage: deal.stage }, + deal: current, + updated: { id: current.id, stage: current.stage }, now: null, }; } @@ -502,74 +560,81 @@ export class DealsService { } const now = new Date(); - const updated = await tx.deal.update({ - where: { id: input.id }, - data: { + const [updated] = await tx + .update(deal) + .set({ stage: input.stage, stageChangedAt: now, closedAt: closed ? now : null, closedReason: closed ? (closedReason ?? null) : null, - }, - select: { id: true, stage: true }, - }); - await tx.activity.create({ - data: { - type: ActivityType.STAGE_CHANGE, - subject: "Stage changed", - body: closedReason ?? null, - occurredAt: now, - companyId: deal.companyId, - dealId: deal.id, - createdById: actingUserId, - meta: { from: deal.stage, to: input.stage }, - }, + }) + .where(eq(deal.id, input.id)) + .returning({ id: deal.id, stage: deal.stage }); + if (!updated) throw new NotFoundException(`No deal with id ${input.id}.`); + const record = current; + await tx.insert(activity).values({ + type: ActivityType.STAGE_CHANGE, + subject: "Stage changed", + body: closedReason ?? null, + occurredAt: now, + companyId: record.companyId, + dealId: record.id, + createdById: actingUserId, + meta: { from: record.stage, to: input.stage }, }); await emit({ type: "deal.stage.changed", - record: { kind: "deal", id: deal.id }, + record: { kind: "deal", id: record.id }, occurredAt: now, - data: { companyId: deal.companyId, from: deal.stage, to: input.stage }, + data: { + companyId: record.companyId, + from: record.stage, + to: input.stage, + }, }); - if (!isClosedStage(deal.stage) && closed) { + if (!isClosedStage(record.stage) && closed) { await emit({ type: "deal.closed", - record: { kind: "deal", id: deal.id }, + record: { kind: "deal", id: record.id }, occurredAt: now, data: { - companyId: deal.companyId, - from: deal.stage, + companyId: record.companyId, + from: record.stage, to: input.stage, }, }); } - if (isClosedStage(deal.stage) && !closed) { + if (isClosedStage(record.stage) && !closed) { await emit({ type: "deal.opened", - record: { kind: "deal", id: deal.id }, + record: { kind: "deal", id: record.id }, occurredAt: now, data: { - companyId: deal.companyId, - from: deal.stage, + companyId: record.companyId, + from: record.stage, to: input.stage, }, }); } - return { changed: true as const, deal, updated, now }; + return { changed: true as const, deal: record, updated, now }; }); if (!transition.changed) { return { ...transition.updated, changed: false }; } - const { deal, updated, now } = transition; + const { deal: changed, updated, now } = transition; - await this.stamp.touch({ companyId: deal.companyId, dealId: deal.id }, now); + await this.stamp.touch( + { companyId: changed.companyId, dealId: changed.id }, + now, + ); this.logger.log({ message: "Deal stage changed", - dealId: deal.id, - from: deal.stage, + dealId: changed.id, + from: changed.stage, to: input.stage, }); @@ -577,55 +642,58 @@ export class DealsService { } async contactOptions(dealId: string) { - const deal = await this.db.deal.findUnique({ - where: { id: dealId }, - select: { companyId: true, contacts: { select: { contactId: true } } }, + const record = await this.db.query.deal.findFirst({ + where: eq(deal.id, dealId), + columns: { companyId: true }, + with: { contacts: { columns: { contactId: true } } }, }); - if (!deal) { + if (!record) { throw new NotFoundException(`No deal with id ${dealId}.`); } - return this.db.contact.findMany({ - where: { - companyId: deal.companyId, - id: { notIn: deal.contacts.map((row) => row.contactId) }, - }, - select: CONTACT_SELECT, - orderBy: [{ firstName: "asc" }, { lastName: "asc" }], - take: 100, - }); + const attached = record.contacts.map((row) => row.contactId); + + return this.db + .select(CONTACT_FIELDS) + .from(contact) + .where( + and( + eq(contact.companyId, record.companyId), + attached.length > 0 ? notInArray(contact.id, attached) : undefined, + ), + ) + .orderBy(asc(contact.firstName), asc(contact.lastName)) + .limit(100); } async attachContact(input: DealAttachContactInput) { - const company = await this.companyOf(input.dealId); - const contact = await this.db.contact.findUnique({ - where: { id: input.contactId }, - select: { companyId: true }, - }); - - if (!contact) { + const owningCompany = await this.companyOf(input.dealId); + const [person] = await this.db + .select({ companyId: contact.companyId }) + .from(contact) + .where(eq(contact.id, input.contactId)) + .limit(1); + + if (!person) { throw new NotFoundException(`No contact with id ${input.contactId}.`); } - if (contact.companyId !== company.id) { + if (person.companyId !== owningCompany.id) { throw new BadRequestException( - `That contact does not work at ${company.name}.`, + `That contact does not work at ${owningCompany.name}.`, ); } const role = roleOrNull(input.role ?? null); - await this.db.dealContact.upsert({ - where: { - dealId_contactId: { - dealId: input.dealId, - contactId: input.contactId, - }, - }, - create: { dealId: input.dealId, contactId: input.contactId, role }, - update: role === null ? {} : { role }, - }); + const insert = this.db + .insert(dealContact) + .values({ dealId: input.dealId, contactId: input.contactId, role }); + const target = [dealContact.dealId, dealContact.contactId]; + await (role === null + ? insert.onConflictDoNothing({ target }) + : insert.onConflictDoUpdate({ target, set: { role } })); this.logger.log({ message: "Contact attached to deal", @@ -637,11 +705,17 @@ export class DealsService { } async detachContact(input: DealDetachContactInput) { - const { count } = await this.db.dealContact.deleteMany({ - where: { dealId: input.dealId, contactId: input.contactId }, - }); + const removed = await this.db + .delete(dealContact) + .where( + and( + eq(dealContact.dealId, input.dealId), + eq(dealContact.contactId, input.contactId), + ), + ) + .returning({ dealId: dealContact.dealId }); - if (count === 0) { + if (removed.length === 0) { throw new NotFoundException("That contact is not on this deal."); } @@ -657,12 +731,18 @@ export class DealsService { async setContactRole(input: DealContactRoleInput) { const role = roleOrNull(input.role); - const { count } = await this.db.dealContact.updateMany({ - where: { dealId: input.dealId, contactId: input.contactId }, - data: { role }, - }); + const changed = await this.db + .update(dealContact) + .set({ role }) + .where( + and( + eq(dealContact.dealId, input.dealId), + eq(dealContact.contactId, input.contactId), + ), + ) + .returning({ dealId: dealContact.dealId }); - if (count === 0) { + if (changed.length === 0) { throw new NotFoundException("That contact is not on this deal."); } @@ -673,10 +753,13 @@ export class DealsService { await requireOwner(this.db, input.ownerId); const ids = [...new Set(input.ids)]; - const { count } = await this.db.deal.updateMany({ - where: { id: { in: ids } }, - data: { ownerId: input.ownerId }, - }); + const count = ( + await this.db + .update(deal) + .set({ ownerId: input.ownerId }) + .where(inArray(deal.id, ids)) + .returning({ id: deal.id }) + ).length; this.logger.log({ message: "Deals reassigned", @@ -723,82 +806,89 @@ export class DealsService { } private async companyOf(dealId: string) { - const deal = await this.db.deal.findUnique({ - where: { id: dealId }, - select: { company: { select: { id: true, name: true } } }, - }); - - if (!deal) { + const [row] = await this.db + .select({ id: company.id, name: company.name }) + .from(deal) + .innerJoin(company, eq(company.id, deal.companyId)) + .where(eq(deal.id, dealId)) + .limit(1); + + if (!row) { throw new NotFoundException(`No deal with id ${dealId}.`); } - return deal.company; + return row; } - private searchFilter(q: string): Prisma.DealWhereInput { + private searchFilter(q: string): SQL | undefined { const term = q.trim(); - if (!term) return {}; - - return { - OR: [ - { name: { contains: term, mode: "insensitive" } }, - { company: { name: { contains: term, mode: "insensitive" } } }, - ], - }; + if (!term) return undefined; + + return or( + contains(deal.name, term, { insensitive: true }), + inArray( + deal.companyId, + this.db + .select({ id: company.id }) + .from(company) + .where(contains(company.name, term, { insensitive: true })), + ), + ); } private buildWhere( input: DealListInput, filterableFields: FieldDefinitionWithOptions[], - ): Prisma.DealWhereInput { - const and: Prisma.DealWhereInput[] = [ + ): SQL | undefined { + const conditions: (SQL | undefined)[] = [ this.searchFilter(input.q), - archivedFilter(input.archived), - ...this.fields.fieldFilters(filterableFields, input.fields), + archivedFilter(deal.archivedAt, input.archived), + ...this.fields.fieldFilters("DEAL", filterableFields, input.fields), + ownerFilter(deal.ownerId, input.owner), ]; - const owner = ownerFilter(input.owner); - if (owner) and.push(owner); - if (input.status === "open") { - and.push({ stage: { in: [...OPEN_DEAL_STAGES] } }); + conditions.push(inArray(deal.stage, [...OPEN_DEAL_STAGES])); } else if (input.status === "closed") { - and.push({ stage: { in: [...CLOSED_DEAL_STAGES] } }); + conditions.push(inArray(deal.stage, [...CLOSED_DEAL_STAGES])); } if (input.stage.length > 0) { - and.push({ stage: { in: input.stage as DealStage[] } }); + conditions.push(inArray(deal.stage, input.stage as DealStage[])); } if (input.closing.length > 0) { - and.push({ - OR: input.closing.map((window) => - closingFilter(window as ClosingWindow), + conditions.push( + or( + ...input.closing.map((window) => + closingFilter(window as ClosingWindow), + ), ), - }); + ); } - return { AND: and }; + return and(...conditions); } private async facetCounts( input: DealListInput, filterableFields: FieldDefinitionWithOptions[], ) { - const where: Prisma.DealWhereInput = { - AND: [this.searchFilter(input.q), archivedFilter(input.archived)], - }; + const where = and( + this.searchFilter(input.q), + archivedFilter(deal.archivedAt, input.archived), + ); const [owners, stages, fieldFacets, ...closingCounts] = await Promise.all([ - this.db.deal.groupBy({ by: ["ownerId"], where, _count: { _all: true } }), - this.db.deal.groupBy({ by: ["stage"], where, _count: { _all: true } }), + groupCounts(this.db, deal, deal.ownerId, where), + groupCounts(this.db, deal, deal.stage, where), this.fields.filterFacetCounts("DEAL", where, filterableFields), ...CLOSING_WINDOWS.map((window) => - this.db.deal.count({ where: { AND: [where, closingFilter(window)] } }), + this.db.$count(deal, and(where, closingFilter(window))), ), ]); - const stageCounts = countsByKey(stages, "stage"); + const stageCounts = countsByKey(stages, "value"); const openCount = OPEN_DEAL_STAGES.reduce( (total, stage) => total + (stageCounts[stage] ?? 0), 0, @@ -810,7 +900,7 @@ export class DealsService { return { status: { open: openCount, closed: closedCount }, - owner: countsByKey(owners, "ownerId", FACET_UNASSIGNED), + owner: countsByKey(owners, "value", FACET_UNASSIGNED), stage: stageCounts, closing: Object.fromEntries( CLOSING_WINDOWS.map((window, index) => [ @@ -827,21 +917,12 @@ export class DealsService { }; } - private translate(cause: unknown, id: string): never { - if ( - cause instanceof PrismaNamespace.PrismaClientKnownRequestError && - cause.code === "P2025" - ) { - throw new NotFoundException(`No deal with id ${id}.`); - } + private translate(cause: unknown, _id: string): never { return this.translateRelations(cause); } private translateRelations(cause: unknown): never { - if ( - cause instanceof PrismaNamespace.PrismaClientKnownRequestError && - (cause.code === "P2003" || cause.code === "P2025") - ) { + if (isForeignKeyViolation(cause)) { throw new BadRequestException( "That company or owner does not exist any more.", ); @@ -850,7 +931,7 @@ export class DealsService { } } -function closingFilter(window: ClosingWindow): Prisma.DealWhereInput { +function closingFilter(window: ClosingWindow): SQL | undefined { const now = new Date(); const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); const startOfNextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1); @@ -858,22 +939,24 @@ function closingFilter(window: ClosingWindow): Prisma.DealWhereInput { switch (window) { case "overdue": - return { - expectedCloseDate: { lt: now }, - stage: { in: [...OPEN_DEAL_STAGES] }, - }; + return and( + lt(deal.expectedCloseDate, now), + inArray(deal.stage, [...OPEN_DEAL_STAGES]), + ); case "this-month": - return { - expectedCloseDate: { gte: startOfMonth, lt: startOfNextMonth }, - }; + return and( + gte(deal.expectedCloseDate, startOfMonth), + lt(deal.expectedCloseDate, startOfNextMonth), + ); case "next-month": - return { - expectedCloseDate: { gte: startOfNextMonth, lt: startOfMonthAfter }, - }; + return and( + gte(deal.expectedCloseDate, startOfNextMonth), + lt(deal.expectedCloseDate, startOfMonthAfter), + ); case "later": - return { expectedCloseDate: { gte: startOfMonthAfter } }; + return gte(deal.expectedCloseDate, startOfMonthAfter); case "none": - return { expectedCloseDate: null }; + return isNull(deal.expectedCloseDate); } } diff --git a/apps/api/src/enrichment/enrichment.service.ts b/apps/api/src/enrichment/enrichment.service.ts index 6ff8e2601..51b110e13 100644 --- a/apps/api/src/enrichment/enrichment.service.ts +++ b/apps/api/src/enrichment/enrichment.service.ts @@ -1,4 +1,18 @@ -import type { Db, Prisma } from "@crm/db"; +import { + agentTask, + and, + asc, + company, + contact, + type Db, + desc, + gt, + inArray, + isNotNull, + isNull, + lte, + or, +} from "@crm/db"; import { ENRICHMENT_PAGE, pageSize } from "@crm/validation/enrichment-queue"; import { Injectable } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; @@ -47,16 +61,16 @@ export type EnrichmentQueue = { scheduledTotal: number; }; -const TASK_SELECT = { - id: true, - contactId: true, - companyId: true, - kind: true, - attempts: true, - leasedUntil: true, - startedAt: true, - dueAt: true, -} as const; +const TASK_FIELDS = { + id: agentTask.id, + contactId: agentTask.contactId, + companyId: agentTask.companyId, + kind: agentTask.kind, + attempts: agentTask.attempts, + leasedUntil: agentTask.leasedUntil, + startedAt: agentTask.startedAt, + dueAt: agentTask.dueAt, +}; type QueuedTask = { id: string; @@ -77,36 +91,30 @@ export class EnrichmentService { const take = pageSize(limit); const now = new Date(); - const named: Prisma.AgentTaskWhereInput = { - finishedAt: null, - OR: [{ contactId: { not: null } }, { companyId: { not: null } }], - }; + const named = and( + isNull(agentTask.finishedAt), + or(isNotNull(agentTask.contactId), isNotNull(agentTask.companyId)), + ); - const dueWhere: Prisma.AgentTaskWhereInput = { - ...named, - dueAt: { lte: now }, - }; + const dueWhere = and(named, lte(agentTask.dueAt, now)); - const scheduledWhere: Prisma.AgentTaskWhereInput = { - ...named, - dueAt: { gt: now }, - }; + const scheduledWhere = and(named, gt(agentTask.dueAt, now)); const [tasks, total, booked, scheduledTotal] = await Promise.all([ - this.db.agentTask.findMany({ - where: dueWhere, - orderBy: [{ priority: "desc" }, { dueAt: "asc" }], - take, - select: TASK_SELECT, - }), - this.db.agentTask.count({ where: dueWhere }), - this.db.agentTask.findMany({ - where: scheduledWhere, - orderBy: [{ dueAt: "asc" }], - take, - select: TASK_SELECT, - }), - this.db.agentTask.count({ where: scheduledWhere }), + this.db + .select(TASK_FIELDS) + .from(agentTask) + .where(dueWhere) + .orderBy(desc(agentTask.priority), asc(agentTask.dueAt)) + .limit(take), + this.db.$count(agentTask, dueWhere), + this.db + .select(TASK_FIELDS) + .from(agentTask) + .where(scheduledWhere) + .orderBy(asc(agentTask.dueAt)) + .limit(take), + this.db.$count(agentTask, scheduledWhere), ]); const everyTask: QueuedTask[] = [...tasks, ...booked]; @@ -116,30 +124,30 @@ export class EnrichmentService { const [contacts, companies] = await Promise.all([ contactIds.length === 0 ? [] - : this.db.contact.findMany({ - where: { id: { in: contactIds } }, - select: { - id: true, - firstName: true, - lastName: true, - email: true, - imageUrl: true, - }, - }), + : this.db + .select({ + id: contact.id, + firstName: contact.firstName, + lastName: contact.lastName, + email: contact.email, + imageUrl: contact.imageUrl, + }) + .from(contact) + .where(inArray(contact.id, contactIds)), companyIds.length === 0 ? [] - : this.db.company.findMany({ - where: { id: { in: companyIds } }, - select: { - id: true, - name: true, - iconUrl: true, - logoUrl: true, - iconDarkUrl: true, - logoDarkUrl: true, - iconTone: true, - }, - }), + : this.db + .select({ + id: company.id, + name: company.name, + iconUrl: company.iconUrl, + logoUrl: company.logoUrl, + iconDarkUrl: company.iconDarkUrl, + logoDarkUrl: company.logoDarkUrl, + iconTone: company.iconTone, + }) + .from(company) + .where(inArray(company.id, companyIds)), ]); const contactById = new Map( diff --git a/apps/api/src/fields/fields.service.ts b/apps/api/src/fields/fields.service.ts index 715a4af74..22879dfc1 100644 --- a/apps/api/src/fields/fields.service.ts +++ b/apps/api/src/fields/fields.service.ts @@ -1,8 +1,26 @@ import { + and, + asc, + company, + contact, + count, type Db, + deal, + desc, + type Executor, + eq, + exists, type FieldEntity, - type Prisma, - Prisma as PrismaNamespace, + fieldDefinition, + fieldOption, + fieldValue, + hasValues, + inArray, + isNotNull, + isNull, + notExists, + notInArray, + type SQL, } from "@crm/db"; import { attachValues, @@ -34,14 +52,22 @@ import type { import { FIELDS_CONFIG } from "./fields-config"; const WITH_OPTIONS = { - options: { orderBy: { position: "asc" } }, -} as const satisfies Prisma.FieldDefinitionInclude; + options: { orderBy: [asc(fieldOption.position)] }, +}; -const RELATIONS = { - COMPANY: "company", - CONTACT: "contact", - DEAL: "deal", -} as const satisfies Record; +const RECORD_TABLES = { + COMPANY: company, + CONTACT: contact, + DEAL: deal, +} as const; + +function recordIdColumn(entity: FieldEntity) { + return RECORD_TABLES[entity].id; +} + +function valueRecordColumn(entity: FieldEntity) { + return fieldValue[recordColumn(entity)]; +} @Injectable() export class FieldsService { @@ -54,19 +80,25 @@ export class FieldsService { entity: FieldEntity, includeArchived: boolean, ): Promise { - const definitions = await this.db.fieldDefinition.findMany({ - where: { entity, archivedAt: includeArchived ? undefined : null }, - include: WITH_OPTIONS, - orderBy: { position: "asc" }, + const definitions = await this.db.query.fieldDefinition.findMany({ + where: and( + eq(fieldDefinition.entity, entity), + includeArchived ? undefined : isNull(fieldDefinition.archivedAt), + ), + with: WITH_OPTIONS, + orderBy: [asc(fieldDefinition.position)], }); return definitions.map(serializeField); } async byKey(entity: FieldEntity, key: string): Promise { - const definition = await this.db.fieldDefinition.findUnique({ - where: { entity_key: { entity, key } }, - include: WITH_OPTIONS, + const definition = await this.db.query.fieldDefinition.findFirst({ + where: and( + eq(fieldDefinition.entity, entity), + eq(fieldDefinition.key, key), + ), + with: WITH_OPTIONS, }); if (!definition) throw new NotFoundException("That field does not exist."); @@ -81,10 +113,16 @@ export class FieldsService { throw new BadRequestException("That label does not make a usable key."); } - const taken = await this.db.fieldDefinition.findUnique({ - where: { entity_key: { entity: input.entity, key } }, - select: { id: true }, - }); + const [taken] = await this.db + .select({ id: fieldDefinition.id }) + .from(fieldDefinition) + .where( + and( + eq(fieldDefinition.entity, input.entity), + eq(fieldDefinition.key, key), + ), + ) + .limit(1); if (taken) { throw new ConflictException(`There is already a field called "${key}".`); @@ -94,35 +132,43 @@ export class FieldsService { throw new BadRequestException("A select needs at least one option."); } - const last = await this.db.fieldDefinition.findFirst({ - where: { entity: input.entity }, - orderBy: { position: "desc" }, - select: { position: true }, - }); + const [last] = await this.db + .select({ position: fieldDefinition.position }) + .from(fieldDefinition) + .where(eq(fieldDefinition.entity, input.entity)) + .orderBy(desc(fieldDefinition.position)) + .limit(1); + + const definition = await this.db.transaction(async (tx) => { + const [created] = await tx + .insert(fieldDefinition) + .values({ + entity: input.entity, + key, + label: input.label, + type: input.type, + agentFilled: input.agentFilled, + agentBrief: input.agentBrief, + required: input.required, + showOnSheet: input.showOnSheet, + showOnTable: input.showOnTable, + showOnFilter: input.showOnFilter, + position: (last?.position ?? -1) + 1, + }) + .returning({ id: fieldDefinition.id }); + if (!created) throw new Error("The field insert returned no row."); + + if (usesOptions(input.type) && input.options.length > 0) { + await tx.insert(fieldOption).values( + input.options.map((option, index) => ({ + fieldId: created.id, + label: option.label, + position: index, + })), + ); + } - const definition = await this.db.fieldDefinition.create({ - data: { - entity: input.entity, - key, - label: input.label, - type: input.type, - agentFilled: input.agentFilled, - agentBrief: input.agentBrief, - required: input.required, - showOnSheet: input.showOnSheet, - showOnTable: input.showOnTable, - showOnFilter: input.showOnFilter, - position: (last?.position ?? -1) + 1, - options: usesOptions(input.type) - ? { - create: input.options.map((option, index) => ({ - label: option.label, - position: index, - })), - } - : undefined, - }, - include: WITH_OPTIONS, + return this.definitionById(created.id, tx); }); if (definition.agentFilled) { @@ -143,9 +189,9 @@ export class FieldsService { } async update(id: string, data: FieldUpdateData): Promise { - const existing = await this.db.fieldDefinition.findUnique({ - where: { id }, - include: WITH_OPTIONS, + const existing = await this.db.query.fieldDefinition.findFirst({ + where: eq(fieldDefinition.id, id), + with: WITH_OPTIONS, }); if (!existing) throw new NotFoundException("That field does not exist."); @@ -153,9 +199,10 @@ export class FieldsService { const type = data.type ?? existing.type; if (data.type && data.type !== existing.type) { - const values = await this.db.fieldValue.count({ - where: { fieldId: id }, - }); + const values = await this.db.$count( + fieldValue, + eq(fieldValue.fieldId, id), + ); if (values > 0) { throw new ConflictException( @@ -172,7 +219,7 @@ export class FieldsService { throw new BadRequestException("A select needs at least one option."); } - const definition = await this.db.$transaction(async (tx) => { + const definition = await this.db.transaction(async (tx) => { if (data.options && usesOptions(type)) { const keep = new Set( data.options @@ -180,40 +227,53 @@ export class FieldsService { .filter((value): value is string => Boolean(value)), ); - await tx.fieldOption.updateMany({ - where: { fieldId: id, id: { notIn: [...keep] }, archivedAt: null }, - data: { archivedAt: new Date() }, - }); + await tx + .update(fieldOption) + .set({ archivedAt: new Date() }) + .where( + and( + eq(fieldOption.fieldId, id), + keep.size > 0 ? notInArray(fieldOption.id, [...keep]) : undefined, + isNull(fieldOption.archivedAt), + ), + ); for (const [index, option] of data.options.entries()) { if (option.id) { - await tx.fieldOption.update({ - where: { id: option.id }, - data: { label: option.label, position: index }, - }); + const [moved] = await tx + .update(fieldOption) + .set({ label: option.label, position: index }) + .where(eq(fieldOption.id, option.id)) + .returning({ id: fieldOption.id }); + if (!moved) + throw new NotFoundException("That option does not exist."); continue; } - await tx.fieldOption.create({ - data: { fieldId: id, label: option.label, position: index }, - }); + await tx + .insert(fieldOption) + .values({ fieldId: id, label: option.label, position: index }); } } - return tx.fieldDefinition.update({ - where: { id }, - data: { - label: data.label, - type: data.type, - agentFilled: data.agentFilled, - agentBrief: data.agentBrief, - required: data.required, - showOnSheet: data.showOnSheet, - showOnTable: data.showOnTable, - showOnFilter: data.showOnFilter, - }, - include: WITH_OPTIONS, - }); + const changes = { + label: data.label, + type: data.type, + agentFilled: data.agentFilled, + agentBrief: data.agentBrief, + required: data.required, + showOnSheet: data.showOnSheet, + showOnTable: data.showOnTable, + showOnFilter: data.showOnFilter, + }; + if (hasValues(changes)) { + await tx + .update(fieldDefinition) + .set(changes) + .where(eq(fieldDefinition.id, id)); + } + + return this.definitionById(id, tx); }); const briefChanged = @@ -240,10 +300,15 @@ export class FieldsService { } async reorder(input: FieldReorderInput): Promise { - const owned = await this.db.fieldDefinition.findMany({ - where: { id: { in: input.ids }, entity: input.entity }, - select: { id: true }, - }); + const owned = await this.db + .select({ id: fieldDefinition.id }) + .from(fieldDefinition) + .where( + and( + inArray(fieldDefinition.id, input.ids), + eq(fieldDefinition.entity, input.entity), + ), + ); if (owned.length !== input.ids.length) { throw new BadRequestException( @@ -251,70 +316,81 @@ export class FieldsService { ); } - await this.db.$transaction( - input.ids.map((id, index) => - this.db.fieldDefinition.update({ - where: { id }, - data: { position: index }, - }), - ), - ); + await this.db.transaction(async (tx) => { + for (const [index, id] of input.ids.entries()) { + await tx + .update(fieldDefinition) + .set({ position: index }) + .where(eq(fieldDefinition.id, id)); + } + }); return this.list(input.entity, false); } async archive(id: string): Promise { - try { - const definition = await this.db.fieldDefinition.update({ - where: { id }, - data: { archivedAt: new Date() }, - include: WITH_OPTIONS, - }); - - return serializeField(definition); - } catch (error) { - throw this.translate(error); - } + return this.setArchivedAt(id, new Date()); } async restore(id: string): Promise { - try { - const definition = await this.db.fieldDefinition.update({ - where: { id }, - data: { archivedAt: null }, - include: WITH_OPTIONS, - }); + return this.setArchivedAt(id, null); + } - return serializeField(definition); - } catch (error) { - throw this.translate(error); - } + private async setArchivedAt( + id: string, + archivedAt: Date | null, + ): Promise { + const [updated] = await this.db + .update(fieldDefinition) + .set({ archivedAt }) + .where(eq(fieldDefinition.id, id)) + .returning({ id: fieldDefinition.id }); + + if (!updated) throw new NotFoundException("That field does not exist."); + + return serializeField(await this.definitionById(id)); } async delete(id: string): Promise<{ id: string }> { - try { - await this.db.fieldDefinition.delete({ where: { id } }); - } catch (error) { - throw this.translate(error); - } + const [deleted] = await this.db + .delete(fieldDefinition) + .where(eq(fieldDefinition.id, id)) + .returning({ id: fieldDefinition.id }); + + if (!deleted) throw new NotFoundException("That field does not exist."); return { id }; } - async backfill(id: string): Promise<{ queued: boolean }> { - const definition = await this.db.fieldDefinition.findUnique({ - where: { id }, - select: { - entity: true, - key: true, - label: true, - agentFilled: true, - archivedAt: true, - }, + private async definitionById( + id: string, + client: Executor = this.db, + ): Promise { + const definition = await client.query.fieldDefinition.findFirst({ + where: eq(fieldDefinition.id, id), + with: WITH_OPTIONS, }); if (!definition) throw new NotFoundException("That field does not exist."); + return definition; + } + + async backfill(id: string): Promise<{ queued: boolean }> { + const [definition] = await this.db + .select({ + entity: fieldDefinition.entity, + key: fieldDefinition.key, + label: fieldDefinition.label, + agentFilled: fieldDefinition.agentFilled, + archivedAt: fieldDefinition.archivedAt, + }) + .from(fieldDefinition) + .where(eq(fieldDefinition.id, id)) + .limit(1); + + if (!definition) throw new NotFoundException("That field does not exist."); + if (!definition.agentFilled || definition.archivedAt !== null) { throw new BadRequestException( "Your agents do not fill this field, so there is nothing to run.", @@ -349,10 +425,16 @@ export class FieldsService { recordIds: string[], ): Promise { if (recordIds.length === 0) return; - const definitions = await this.db.fieldDefinition.findMany({ - where: { entity, archivedAt: null, agentFilled: true }, - select: { key: true }, - }); + const definitions = await this.db + .select({ key: fieldDefinition.key }) + .from(fieldDefinition) + .where( + and( + eq(fieldDefinition.entity, entity), + isNull(fieldDefinition.archivedAt), + eq(fieldDefinition.agentFilled, true), + ), + ); if (definitions.length === 0) return; @@ -369,49 +451,46 @@ export class FieldsService { fieldId: string, cap: number, ): Promise { - const where = { fieldValues: { none: { fieldId } } }; - - const rows = - entity === "COMPANY" - ? await this.db.company.findMany({ - where, - select: { id: true }, - take: cap, - }) - : entity === "CONTACT" - ? await this.db.contact.findMany({ - where, - select: { id: true }, - take: cap, - }) - : await this.db.deal.findMany({ - where, - select: { id: true }, - take: cap, - }); + const table = RECORD_TABLES[entity]; + const rows = await this.db + .select({ id: table.id }) + .from(table) + .where( + notExists( + this.db + .select({ id: fieldValue.id }) + .from(fieldValue) + .where( + and( + eq(fieldValue.fieldId, fieldId), + eq(valueRecordColumn(entity), recordIdColumn(entity)), + ), + ), + ), + ) + .limit(cap); return rows.map((row) => row.id); } async coverage(id: string): Promise<{ filled: number; total: number }> { - const definition = await this.db.fieldDefinition.findUnique({ - where: { id }, - select: { entity: true }, - }); + const [definition] = await this.db + .select({ entity: fieldDefinition.entity }) + .from(fieldDefinition) + .where(eq(fieldDefinition.id, id)) + .limit(1); if (!definition) throw new NotFoundException("That field does not exist."); - const column = recordColumn(definition.entity); - const [filled, total] = await Promise.all([ - this.db.fieldValue.count({ - where: { fieldId: id, [column]: { not: null } }, - }), - definition.entity === "COMPANY" - ? this.db.company.count() - : definition.entity === "CONTACT" - ? this.db.contact.count() - : this.db.deal.count(), + this.db.$count( + fieldValue, + and( + eq(fieldValue.fieldId, id), + isNotNull(valueRecordColumn(definition.entity)), + ), + ), + this.db.$count(RECORD_TABLES[definition.entity]), ]); return { filled, total }; @@ -419,12 +498,15 @@ export class FieldsService { async definitionsFor( entity: FieldEntity, - client: Prisma.TransactionClient = this.db, + client: Executor = this.db, ): Promise { - return client.fieldDefinition.findMany({ - where: { entity, archivedAt: null }, - include: WITH_OPTIONS, - orderBy: { position: "asc" }, + return client.query.fieldDefinition.findMany({ + where: and( + eq(fieldDefinition.entity, entity), + isNull(fieldDefinition.archivedAt), + ), + with: WITH_OPTIONS, + orderBy: [asc(fieldDefinition.position)], }); } @@ -432,11 +514,12 @@ export class FieldsService { entity: FieldEntity, recordId: string, ): Promise { - const column = recordColumn(entity); - const [definitions, rows] = await Promise.all([ this.definitionsFor(entity), - this.db.fieldValue.findMany({ where: { [column]: recordId } }), + this.db + .select() + .from(fieldValue) + .where(eq(valueRecordColumn(entity), recordId)), ]); return attachValues(definitions, rows); @@ -450,22 +533,32 @@ export class FieldsService { if (recordIds.length === 0) return byRecord; - const definitions = await this.db.fieldDefinition.findMany({ - where: { entity, archivedAt: null, showOnTable: true }, - include: WITH_OPTIONS, - orderBy: { position: "asc" }, + const definitions = await this.db.query.fieldDefinition.findMany({ + where: and( + eq(fieldDefinition.entity, entity), + isNull(fieldDefinition.archivedAt), + eq(fieldDefinition.showOnTable, true), + ), + with: WITH_OPTIONS, + orderBy: [asc(fieldDefinition.position)], }); if (definitions.length === 0) return byRecord; const column = recordColumn(entity); - const rows = await this.db.fieldValue.findMany({ - where: { - [column]: { in: recordIds }, - fieldId: { in: definitions.map((definition) => definition.id) }, - }, - }); + const rows = await this.db + .select() + .from(fieldValue) + .where( + and( + inArray(valueRecordColumn(entity), recordIds), + inArray( + fieldValue.fieldId, + definitions.map((definition) => definition.id), + ), + ), + ); const byId = new Map(definitions.map((entry) => [entry.id, entry])); @@ -501,15 +594,15 @@ export class FieldsService { async filterableFieldsFor( entity: FieldEntity, ): Promise { - return this.db.fieldDefinition.findMany({ - where: { - entity, - archivedAt: null, - showOnFilter: true, - type: { in: ["SELECT", "USER"] }, - }, - include: WITH_OPTIONS, - orderBy: { position: "asc" }, + return this.db.query.fieldDefinition.findMany({ + where: and( + eq(fieldDefinition.entity, entity), + isNull(fieldDefinition.archivedAt), + eq(fieldDefinition.showOnFilter, true), + inArray(fieldDefinition.type, ["SELECT", "USER"]), + ), + with: WITH_OPTIONS, + orderBy: [asc(fieldDefinition.position)], }); } @@ -521,16 +614,14 @@ export class FieldsService { */ async filterFacetCounts( entity: FieldEntity, - relationWhere: - | Prisma.CompanyWhereInput - | Prisma.ContactWhereInput - | Prisma.DealWhereInput, + relationWhere: SQL | undefined, definitions: FieldDefinitionWithOptions[], ): Promise>> { const facetCounts: Record> = {}; if (definitions.length === 0) return facetCounts; - const relation = RELATIONS[entity]; + const table = RECORD_TABLES[entity]; + const joinOn = eq(valueRecordColumn(entity), recordIdColumn(entity)); const byId = new Map( definitions.map((definition) => [definition.id, definition]), ); @@ -543,26 +634,40 @@ export class FieldsService { const [selectGroups, userGroups] = await Promise.all([ selectIds.length > 0 - ? this.db.fieldValue.groupBy({ - by: ["fieldId", "optionId"], - where: { - fieldId: { in: selectIds }, - optionId: { not: null }, - [relation]: relationWhere, - } as Prisma.FieldValueWhereInput, - _count: { _all: true }, - }) + ? this.db + .select({ + fieldId: fieldValue.fieldId, + optionId: fieldValue.optionId, + count: count(), + }) + .from(fieldValue) + .innerJoin(table, joinOn) + .where( + and( + inArray(fieldValue.fieldId, selectIds), + isNotNull(fieldValue.optionId), + relationWhere, + ), + ) + .groupBy(fieldValue.fieldId, fieldValue.optionId) : [], userIds.length > 0 - ? this.db.fieldValue.groupBy({ - by: ["fieldId", "userId"], - where: { - fieldId: { in: userIds }, - userId: { not: null }, - [relation]: relationWhere, - } as Prisma.FieldValueWhereInput, - _count: { _all: true }, - }) + ? this.db + .select({ + fieldId: fieldValue.fieldId, + userId: fieldValue.userId, + count: count(), + }) + .from(fieldValue) + .innerJoin(table, joinOn) + .where( + and( + inArray(fieldValue.fieldId, userIds), + isNotNull(fieldValue.userId), + relationWhere, + ), + ) + .groupBy(fieldValue.fieldId, fieldValue.userId) : [], ]); @@ -571,8 +676,7 @@ export class FieldsService { if (!definition || !group.optionId) continue; const bucket = facetCounts[definition.key] ?? {}; facetCounts[definition.key] = bucket; - bucket[group.optionId] = - (bucket[group.optionId] ?? 0) + group._count._all; + bucket[group.optionId] = (bucket[group.optionId] ?? 0) + group.count; } for (const group of userGroups) { @@ -580,7 +684,7 @@ export class FieldsService { if (!definition || !group.userId) continue; const bucket = facetCounts[definition.key] ?? {}; facetCounts[definition.key] = bucket; - bucket[group.userId] = (bucket[group.userId] ?? 0) + group._count._all; + bucket[group.userId] = (bucket[group.userId] ?? 0) + group.count; } return facetCounts; @@ -588,9 +692,10 @@ export class FieldsService { /** Translates selected filter values (by field key) into `FieldValue` `AND` conditions. */ fieldFilters( + entity: FieldEntity, definitions: FieldDefinitionWithOptions[], selected: Record, - ): Array<{ fieldValues: { some: Prisma.FieldValueWhereInput } }> { + ): SQL[] { const byKey = new Map( definitions.map((definition) => [definition.key, definition]), ); @@ -602,20 +707,26 @@ export class FieldsService { if (!definition) return []; return [ - { - fieldValues: { - some: - definition.type === "USER" - ? { fieldId: definition.id, userId: { in: values } } - : { fieldId: definition.id, optionId: { in: values } }, - }, - }, + exists( + this.db + .select({ id: fieldValue.id }) + .from(fieldValue) + .where( + and( + eq(fieldValue.fieldId, definition.id), + eq(valueRecordColumn(entity), recordIdColumn(entity)), + definition.type === "USER" + ? inArray(fieldValue.userId, values) + : inArray(fieldValue.optionId, values), + ), + ), + ), ]; }); } async applyValues( - tx: Prisma.TransactionClient, + tx: Executor, entity: FieldEntity, recordId: string, values: Record, @@ -634,17 +745,6 @@ export class FieldsService { throw error; } } - - private translate(cause: unknown): never { - if ( - cause instanceof PrismaNamespace.PrismaClientKnownRequestError && - cause.code === "P2025" - ) { - throw new NotFoundException("That field does not exist."); - } - - throw cause; - } } function tableValue( diff --git a/apps/api/src/google/calendar-sync.service.ts b/apps/api/src/google/calendar-sync.service.ts index d219b3e9e..0217f21f5 100644 --- a/apps/api/src/google/calendar-sync.service.ts +++ b/apps/api/src/google/calendar-sync.service.ts @@ -1,8 +1,18 @@ import { ActivityType, + activity as activityTable, + and, + calendarAttendee, + calendarEvent, + contact, + contactBrief, type Db, + eq, GoogleSyncStatus, - type MailboxSyncModel as MailboxSync, + inArray, + isNotNull, + type MailboxSync, + notExists, RecordSource, } from "@crm/db"; import { Injectable, Logger } from "@nestjs/common"; @@ -201,21 +211,17 @@ export class CalendarSyncService { if (!originalStart) return "ignored"; - const key = { - iCalUid_originalStartTime: { - iCalUid, - originalStartTime: originalStart.at, - }, - }; - if (event.status === "cancelled") { - const deleted = await this.db.calendarEvent.deleteMany({ - where: { - iCalUid, - originalStartTime: originalStart.at, - }, - }); - return deleted.count > 0 ? "removed" : "ignored"; + const deleted = await this.db + .delete(calendarEvent) + .where( + and( + eq(calendarEvent.iCalUid, iCalUid), + eq(calendarEvent.originalStartTime, originalStart.at), + ), + ) + .returning({ id: calendarEvent.id }); + return deleted.length > 0 ? "removed" : "ignored"; } const end = eventTime(event.end); @@ -243,9 +249,9 @@ export class CalendarSyncService { const organizer = event.organizer?.email?.toLowerCase() ?? null; - const record = await this.db.calendarEvent.upsert({ - where: key, - create: { + const [record] = await this.db + .insert(calendarEvent) + .values({ iCalUid, originalStartTime: originalStart.at, recurringEventId: event.recurringEventId ?? null, @@ -262,22 +268,25 @@ export class CalendarSyncService { contactId: match.contactId, syncedByUserId: row.userId, googleEventId: event.id ?? null, - }, - update: { - title: event.summary ?? null, - description: event.description ?? null, - location: event.location ?? null, - conferenceUrl: conferenceUrl(event), - startsAt: start.at, - endsAt: end.at, - isAllDay: start.isAllDay, - status: event.status ?? "confirmed", - organizerEmail: organizer, - companyId: match.companyId, - contactId: match.contactId, - }, - select: { id: true }, - }); + }) + .onConflictDoUpdate({ + target: [calendarEvent.iCalUid, calendarEvent.originalStartTime], + set: { + title: event.summary ?? null, + description: event.description ?? null, + location: event.location ?? null, + conferenceUrl: conferenceUrl(event), + startsAt: start.at, + endsAt: end.at, + isAllDay: start.isAllDay, + status: event.status ?? "confirmed", + organizerEmail: organizer, + companyId: match.companyId, + contactId: match.contactId, + }, + }) + .returning({ id: calendarEvent.id }); + if (!record) return "ignored"; await this.syncAttendees(record.id, event); await this.prepareForMeeting(record.id, start.at); @@ -309,10 +318,13 @@ export class CalendarSyncService { (attendee.email as string).toLowerCase(), ); - const contacts = await this.db.contact.findMany({ - where: { email: { in: emails } }, - select: { id: true, email: true }, - }); + const contacts = + emails.length > 0 + ? await this.db + .select({ id: contact.id, email: contact.email }) + .from(contact) + .where(inArray(contact.email, emails)) + : []; const contactByEmail = new Map( contacts.map((contact) => [contact.email as string, contact.id]), @@ -321,23 +333,20 @@ export class CalendarSyncService { for (const attendee of attendees) { const email = (attendee.email as string).toLowerCase(); - await this.db.calendarAttendee.upsert({ - where: { eventId_email: { eventId, email } }, - create: { - eventId, - email, - name: attendee.displayName ?? null, - responseStatus: attendee.responseStatus ?? null, - isOrganizer: attendee.organizer ?? false, - contactId: contactByEmail.get(email) ?? null, - }, - update: { - name: attendee.displayName ?? null, - responseStatus: attendee.responseStatus ?? null, - isOrganizer: attendee.organizer ?? false, - contactId: contactByEmail.get(email) ?? null, - }, - }); + const fields = { + name: attendee.displayName ?? null, + responseStatus: attendee.responseStatus ?? null, + isOrganizer: attendee.organizer ?? false, + contactId: contactByEmail.get(email) ?? null, + }; + + await this.db + .insert(calendarAttendee) + .values({ eventId, email, ...fields }) + .onConflictDoUpdate({ + target: [calendarAttendee.eventId, calendarAttendee.email], + set: fields, + }); } } @@ -348,14 +357,21 @@ export class CalendarSyncService { const soon = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); if (startsAt <= new Date() || startsAt > soon) return; - const attendees = await this.db.calendarAttendee.findMany({ - where: { - eventId, - contactId: { not: null }, - contact: { brief: { is: null } }, - }, - select: { contactId: true }, - }); + const attendees = await this.db + .select({ contactId: calendarAttendee.contactId }) + .from(calendarAttendee) + .where( + and( + eq(calendarAttendee.eventId, eventId), + isNotNull(calendarAttendee.contactId), + notExists( + this.db + .select({ contactId: contactBrief.contactId }) + .from(contactBrief) + .where(eq(contactBrief.contactId, calendarAttendee.contactId)), + ), + ), + ); for (const attendee of attendees) { if (attendee.contactId) { @@ -377,9 +393,9 @@ export class CalendarSyncService { ): Promise { const body = summary.location ? `Location: ${summary.location}` : null; - const activity = await this.db.activity.upsert({ - where: { calendarEventId }, - create: { + const [activity] = await this.db + .insert(activityTable) + .values({ type: ActivityType.MEETING, subject: summary.title, body, @@ -389,16 +405,19 @@ export class CalendarSyncService { createdById: userId, calendarEventId, meta: { synced: true, source: "calendar" }, - }, - update: { - subject: summary.title, - body, - occurredAt: summary.startsAt, - companyId: summary.companyId, - contactId: summary.contactId, - }, - select: { createdAt: true }, - }); + }) + .onConflictDoUpdate({ + target: activityTable.calendarEventId, + set: { + subject: summary.title, + body, + occurredAt: summary.startsAt, + companyId: summary.companyId, + contactId: summary.contactId, + }, + }) + .returning({ createdAt: activityTable.createdAt }); + if (!activity) return; await this.stamp.touch( { companyId: summary.companyId, contactId: summary.contactId }, diff --git a/apps/api/src/google/conversation.service.ts b/apps/api/src/google/conversation.service.ts index 232b84f58..c81a7d917 100644 --- a/apps/api/src/google/conversation.service.ts +++ b/apps/api/src/google/conversation.service.ts @@ -1,4 +1,18 @@ -import type { Db, Prisma } from "@crm/db"; +import { + asc, + calendarAttendee, + calendarEvent, + contact, + type Db, + desc, + emailMessage, + emailThread, + eq, + inArray, + type JsonValue, + sql, + user, +} from "@crm/db"; import { Injectable, NotFoundException } from "@nestjs/common"; import { z } from "zod"; import { InjectDatabase } from "../database/database.constants"; @@ -18,19 +32,21 @@ export class ConversationService { constructor(@InjectDatabase() private readonly db: Db) {} async thread(threadId: string) { - const thread = await this.db.emailThread.findUnique({ - where: { id: threadId }, - select: { + const thread = await this.db.query.emailThread.findFirst({ + where: eq(emailThread.id, threadId), + columns: { id: true, subject: true, messageCount: true, firstMessageAt: true, lastMessageAt: true, - company: { select: { id: true, name: true } }, - contact: { select: { id: true, firstName: true, lastName: true } }, + }, + with: { + company: { columns: { id: true, name: true } }, + contact: { columns: { id: true, firstName: true, lastName: true } }, messages: { - orderBy: { sentAt: "asc" }, - select: { + orderBy: [asc(emailMessage.sentAt)], + columns: { id: true, direction: true, fromEmail: true, @@ -83,14 +99,24 @@ export class ConversationService { if (emails.length === 0) return new Map(); const [contacts, users] = await Promise.all([ - this.db.contact.findMany({ - where: { email: { in: emails, mode: "insensitive" } }, - select: { email: true, imageUrl: true }, - }), - this.db.user.findMany({ - where: { email: { in: emails, mode: "insensitive" } }, - select: { email: true, image: true }, - }), + this.db + .select({ email: contact.email, imageUrl: contact.imageUrl }) + .from(contact) + .where( + inArray( + sql`lower(${contact.email})`, + emails.map((email) => email.toLowerCase()), + ), + ), + this.db + .select({ email: user.email, image: user.image }) + .from(user) + .where( + inArray( + sql`lower(${user.email})`, + emails.map((email) => email.toLowerCase()), + ), + ), ]); const faces = new Map(); @@ -107,9 +133,9 @@ export class ConversationService { } async event(eventId: string) { - const event = await this.db.calendarEvent.findUnique({ - where: { id: eventId }, - select: { + const event = await this.db.query.calendarEvent.findFirst({ + where: eq(calendarEvent.id, eventId), + columns: { id: true, title: true, description: true, @@ -120,19 +146,24 @@ export class ConversationService { isAllDay: true, status: true, organizerEmail: true, - company: { select: { id: true, name: true } }, - contact: { select: { id: true, firstName: true, lastName: true } }, + }, + with: { + company: { columns: { id: true, name: true } }, + contact: { columns: { id: true, firstName: true, lastName: true } }, attendees: { - orderBy: [{ isOrganizer: "desc" }, { email: "asc" }], - select: { + orderBy: [ + desc(calendarAttendee.isOrganizer), + asc(calendarAttendee.email), + ], + columns: { id: true, email: true, name: true, responseStatus: true, isOrganizer: true, contactId: true, - contact: { select: { imageUrl: true } }, }, + with: { contact: { columns: { imageUrl: true } } }, }, }, }); @@ -153,7 +184,7 @@ export class ConversationService { } } -function recipientsOf(value: Prisma.JsonValue): StoredRecipient[] { +function recipientsOf(value: JsonValue): StoredRecipient[] { return storedRecipients.parse(value).flatMap((entry) => { const parsed = storedRecipient.safeParse(entry); return parsed.success ? [parsed.data] : []; diff --git a/apps/api/src/google/gmail-sync.service.ts b/apps/api/src/google/gmail-sync.service.ts index a693261bd..a9f4017a6 100644 --- a/apps/api/src/google/gmail-sync.service.ts +++ b/apps/api/src/google/gmail-sync.service.ts @@ -1,7 +1,9 @@ import { type Db, + emailMessage, GoogleSyncStatus, - type MailboxSyncModel as MailboxSync, + inArray, + type MailboxSync, } from "@crm/db"; import { Injectable, Logger } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; @@ -194,10 +196,10 @@ export class GmailSyncService { ): Promise<{ written: number; remaining: number }> { if (ids.length === 0) return { written: 0, remaining: 0 }; - const alreadyHave = await this.db.emailMessage.findMany({ - where: { gmailMessageId: { in: [...ids] } }, - select: { gmailMessageId: true }, - }); + const alreadyHave = await this.db + .select({ gmailMessageId: emailMessage.gmailMessageId }) + .from(emailMessage) + .where(inArray(emailMessage.gmailMessageId, [...ids])); const seen = new Set( alreadyHave.map((existing) => existing.gmailMessageId), ); diff --git a/apps/api/src/google/google-connection.service.ts b/apps/api/src/google/google-connection.service.ts index d25e7d4d7..0e1fd1e39 100644 --- a/apps/api/src/google/google-connection.service.ts +++ b/apps/api/src/google/google-connection.service.ts @@ -1,6 +1,25 @@ import { isGoogleConfigured } from "@crm/auth/env"; import { GOOGLE_PROVIDER_ID, signsInWithGoogle } from "@crm/auth/scopes"; -import type { Db, Prisma } from "@crm/db"; +import { + account, + activity, + and, + asc, + calendarEvent, + company, + contains, + type Db, + type Executor, + emailMessage, + emailThread, + eq, + inArray, + isNotNull, + notExists, + or, + sql, + suppressedDomain, +} from "@crm/db"; import { Injectable, Logger, NotFoundException } from "@nestjs/common"; import { normalizeDomain } from "../companies/domain"; import { ActivityStampService } from "../crm/activity-stamp.service"; @@ -99,52 +118,72 @@ export class GoogleConnectionService { } async reconcileAll(): Promise { - const accounts = await this.db.account.findMany({ - where: { - providerId: GOOGLE_PROVIDER_ID, - OR: GOOGLE_SYNC_SOURCES.map((source) => ({ - scope: { contains: SCOPE_FOR_SOURCE[source] }, - })), - }, - select: { userId: true }, - }); + const accounts = await this.db + .select({ userId: account.userId }) + .from(account) + .where( + and( + eq(account.providerId, GOOGLE_PROVIDER_ID), + or( + ...GOOGLE_SYNC_SOURCES.map((source) => + contains(account.scope, SCOPE_FOR_SOURCE[source]), + ), + ), + ), + ); - for (const account of new Set(accounts.map((row) => row.userId))) { - await this.onConnected(account); + for (const userId of new Set(accounts.map((row) => row.userId))) { + await this.onConnected(userId); } } async purgeSyncedData(userId: string): Promise { - const mine: Prisma.EmailMessageWhereInput = { - syncedByUserId: userId, - gmailMessageId: { not: null }, - }; - - const purged = await this.db.$transaction( - async (tx) => { - const touched = await tx.emailMessage.findMany({ - where: mine, - select: { threadId: true }, - distinct: ["threadId"], - }); - - const threadIds = touched.map((row) => row.threadId); - const messages = await tx.emailMessage.deleteMany({ where: mine }); - - await tx.emailThread.deleteMany({ - where: { id: { in: threadIds }, messages: { none: {} } }, - }); - - await rebuildThreads(tx, threadIds); + const mine = and( + eq(emailMessage.syncedByUserId, userId), + isNotNull(emailMessage.gmailMessageId), + ); - const events = await tx.calendarEvent.deleteMany({ - where: { syncedByUserId: userId }, - }); + const purged = await this.db.transaction(async (tx) => { + await tx.execute( + sql`SET LOCAL statement_timeout = ${sql.raw(String(PURGE_TIMEOUT_MS))}`, + ); - return messages.count + events.count; - }, - { timeout: PURGE_TIMEOUT_MS }, - ); + const touched = await tx + .selectDistinct({ threadId: emailMessage.threadId }) + .from(emailMessage) + .where(mine); + + const threadIds = touched.map((row) => row.threadId); + const messages = await tx + .delete(emailMessage) + .where(mine) + .returning({ id: emailMessage.id }); + + if (threadIds.length > 0) { + await tx + .delete(emailThread) + .where( + and( + inArray(emailThread.id, threadIds), + notExists( + tx + .select({ id: emailMessage.id }) + .from(emailMessage) + .where(eq(emailMessage.threadId, emailThread.id)), + ), + ), + ); + } + + await rebuildThreads(tx, threadIds); + + const events = await tx + .delete(calendarEvent) + .where(eq(calendarEvent.syncedByUserId, userId)) + .returning({ id: calendarEvent.id }); + + return messages.length + events.length; + }); await this.stamp.recomputeAll(); @@ -191,49 +230,63 @@ export class GoogleConnectionService { ); } - await this.db.suppressedDomain.upsert({ - where: { domain: normalised }, - create: { domain: normalised, reason: options.reason ?? null }, - update: { reason: options.reason ?? null }, - }); + await this.db + .insert(suppressedDomain) + .values({ domain: normalised, reason: options.reason ?? null }) + .onConflictDoUpdate({ + target: suppressedDomain.domain, + set: { reason: options.reason ?? null }, + }); if (!options.purge) return { domain: normalised, purged: 0 }; - const company = await this.db.company.findUnique({ - where: { domain: normalised }, - select: { id: true }, - }); - - if (!company) return { domain: normalised, purged: 0 }; - - const [threads, events] = await this.db.$transaction([ - this.db.emailThread.deleteMany({ where: { companyId: company.id } }), - this.db.calendarEvent.deleteMany({ where: { companyId: company.id } }), - ]); + const [owner] = await this.db + .select({ id: company.id }) + .from(company) + .where(eq(company.domain, normalised)) + .limit(1); + + if (!owner) return { domain: normalised, purged: 0 }; + + const { threads, events } = await this.db.transaction(async (tx) => ({ + threads: await tx + .delete(emailThread) + .where(eq(emailThread.companyId, owner.id)) + .returning({ id: emailThread.id }), + events: await tx + .delete(calendarEvent) + .where(eq(calendarEvent.companyId, owner.id)) + .returning({ id: calendarEvent.id }), + })); await this.stamp.recomputeAll(); this.logger.log({ message: "Domain suppressed", domain: normalised, - purged: threads.count + events.count, + purged: threads.length + events.length, }); - return { domain: normalised, purged: threads.count + events.count }; + return { domain: normalised, purged: threads.length + events.length }; } } async function rebuildThreads( - tx: Prisma.TransactionClient, + tx: Executor, threadIds: string[], ): Promise { if (threadIds.length === 0) return; - const remaining = await tx.emailMessage.findMany({ - where: { threadId: { in: threadIds } }, - select: { threadId: true, sentAt: true, subject: true, snippet: true }, - orderBy: { sentAt: "asc" }, - }); + const remaining = await tx + .select({ + threadId: emailMessage.threadId, + sentAt: emailMessage.sentAt, + subject: emailMessage.subject, + snippet: emailMessage.snippet, + }) + .from(emailMessage) + .where(inArray(emailMessage.threadId, threadIds)) + .orderBy(asc(emailMessage.sentAt)); const byThread = new Map(); @@ -248,19 +301,19 @@ async function rebuildThreads( const last = messages.at(-1); if (!first || !last) continue; - await tx.emailThread.update({ - where: { id: threadId }, - data: { + await tx + .update(emailThread) + .set({ messageCount: messages.length, firstMessageAt: first.sentAt, lastMessageAt: last.sentAt, subject: first.subject, - }, - }); + }) + .where(eq(emailThread.id, threadId)); - await tx.activity.updateMany({ - where: { emailThreadId: threadId }, - data: { body: last.snippet, occurredAt: last.sentAt }, - }); + await tx + .update(activity) + .set({ body: last.snippet, occurredAt: last.sentAt }) + .where(eq(activity.emailThreadId, threadId)); } } diff --git a/apps/api/src/health/health.controller.ts b/apps/api/src/health/health.controller.ts index 32346a80f..1428edd0d 100644 --- a/apps/api/src/health/health.controller.ts +++ b/apps/api/src/health/health.controller.ts @@ -1,4 +1,4 @@ -import type { Db } from "@crm/db"; +import { type Db, sql } from "@crm/db"; import { Controller, Get, @@ -38,7 +38,7 @@ export class HealthController { const startedAt = process.hrtime.bigint(); try { - await this.db.$queryRaw`SELECT 1`; + await this.db.execute(sql`SELECT 1`); } catch (error) { this.logger.error( { message: "Database health check failed" }, diff --git a/apps/api/src/logging/prisma-log.bridge.ts b/apps/api/src/logging/db-log.bridge.ts similarity index 65% rename from apps/api/src/logging/prisma-log.bridge.ts rename to apps/api/src/logging/db-log.bridge.ts index 11f52bd33..4f0fa64f8 100644 --- a/apps/api/src/logging/prisma-log.bridge.ts +++ b/apps/api/src/logging/db-log.bridge.ts @@ -1,4 +1,4 @@ -import { setPrismaLogSink } from "@crm/db"; +import { setDbLogSink } from "@crm/db"; import { Injectable, Logger, @@ -7,11 +7,11 @@ import { } from "@nestjs/common"; @Injectable() -export class PrismaLogBridge implements OnModuleInit, OnApplicationShutdown { - private readonly logger = new Logger("Prisma"); +export class DbLogBridge implements OnModuleInit, OnApplicationShutdown { + private readonly logger = new Logger("Database"); onModuleInit(): void { - setPrismaLogSink(({ level, message, target, durationMs }) => { + setDbLogSink(({ level, message, target, durationMs }) => { const payload = durationMs === undefined ? { message, target } @@ -32,6 +32,6 @@ export class PrismaLogBridge implements OnModuleInit, OnApplicationShutdown { } onApplicationShutdown(): void { - setPrismaLogSink(null); + setDbLogSink(null); } } diff --git a/apps/api/src/logging/logging.module.ts b/apps/api/src/logging/logging.module.ts index 5cdb21aa7..628a3b3e2 100644 --- a/apps/api/src/logging/logging.module.ts +++ b/apps/api/src/logging/logging.module.ts @@ -8,7 +8,7 @@ import { import { APP_FILTER, APP_INTERCEPTOR } from "@nestjs/core"; import { AllExceptionsFilter } from "./all-exceptions.filter"; import { ContextLogger } from "./context-logger"; -import { PrismaLogBridge } from "./prisma-log.bridge"; +import { DbLogBridge } from "./db-log.bridge"; import { RequestLoggerMiddleware } from "./request-logger.middleware"; import { UserContextInterceptor } from "./user-context.interceptor"; @@ -16,7 +16,7 @@ import { UserContextInterceptor } from "./user-context.interceptor"; @Module({ providers: [ ContextLogger, - PrismaLogBridge, + DbLogBridge, { provide: APP_FILTER, useClass: AllExceptionsFilter }, { provide: APP_INTERCEPTOR, useClass: UserContextInterceptor }, ], diff --git a/apps/api/src/mailbox/mailbox-match.service.ts b/apps/api/src/mailbox/mailbox-match.service.ts index 06a613ce3..01b851de4 100644 --- a/apps/api/src/mailbox/mailbox-match.service.ts +++ b/apps/api/src/mailbox/mailbox-match.service.ts @@ -1,5 +1,15 @@ import { workspaceDomains } from "@crm/auth/workspace"; -import { type Db, RecordSource } from "@crm/db"; +import { + company, + contact as contactTable, + type Db, + eq, + inArray, + RecordSource, + suppressedContact, + suppressedDomain, + user, +} from "@crm/db"; import { lockIdempotencyKey } from "@crm/db/idempotency"; import { Injectable, Logger } from "@nestjs/common"; import { AgentTriggerService } from "../agent/agent-trigger.service"; @@ -54,7 +64,7 @@ export class MailboxMatchService { addresses: Set; domains: Set; }> { - const users = await this.db.user.findMany({ select: { email: true } }); + const users = await this.db.select({ email: user.email }).from(user); const addresses = new Set(); const domains = new Set(workspaceDomains()); @@ -71,16 +81,16 @@ export class MailboxMatchService { } async suppressedDomains(): Promise> { - const rows = await this.db.suppressedDomain.findMany({ - select: { domain: true }, - }); + const rows = await this.db + .select({ domain: suppressedDomain.domain }) + .from(suppressedDomain); return new Set(rows.map((row) => row.domain)); } async suppressedEmails(): Promise> { - const rows = await this.db.suppressedContact.findMany({ - select: { email: true }, - }); + const rows = await this.db + .select({ email: suppressedContact.email }) + .from(suppressedContact); return new Set(rows.map((row) => row.email.toLowerCase())); } @@ -99,10 +109,16 @@ export class MailboxMatchService { return { companyId: null, contactId: null, external }; } - const contact = await this.db.contact.findFirst({ - where: { email: { in: external.map((person) => person.email) } }, - select: { id: true, companyId: true }, - }); + const [contact] = await this.db + .select({ id: contactTable.id, companyId: contactTable.companyId }) + .from(contactTable) + .where( + inArray( + contactTable.email, + external.map((person) => person.email), + ), + ) + .limit(1); if (contact) { return { @@ -120,10 +136,13 @@ export class MailboxMatchService { ), ]; - const known = await this.db.company.findMany({ - where: { domain: { in: domains } }, - select: { id: true, domain: true }, - }); + const known = + domains.length > 0 + ? await this.db + .select({ id: company.id, domain: company.domain }) + .from(company) + .where(inArray(company.domain, domains)) + : []; const knownDomains = new Set( known @@ -170,10 +189,10 @@ export class MailboxMatchService { return { companyId: null, contactId: null, external }; } - await this.db.company.update({ - where: { id: companyId }, - data: { source: request.source }, - }); + await this.db + .update(company) + .set({ source: request.source }) + .where(eq(company.id, companyId)); const contactId = await this.createContact( external, @@ -216,37 +235,33 @@ export class MailboxMatchService { const outcome = await this.agent.withCrmEvents(async (tx, emit) => { await lockIdempotencyKey(tx, `mailbox-contact:${person.email}`); - const existing = await tx.contact.findUnique({ - where: { email: person.email }, - select: { - id: true, - firstName: true, - lastName: true, - email: true, - companyId: true, - createdAt: true, - }, - }); + const fields = { + id: contactTable.id, + firstName: contactTable.firstName, + lastName: contactTable.lastName, + email: contactTable.email, + companyId: contactTable.companyId, + createdAt: contactTable.createdAt, + }; + const [existing] = await tx + .select(fields) + .from(contactTable) + .where(eq(contactTable.email, person.email)) + .limit(1); if (existing) return { contact: existing, created: false as const }; - const contact = await tx.contact.create({ - data: { + const [contact] = await tx + .insert(contactTable) + .values({ firstName, lastName, email: person.email, companyId, source: request.source, ownerId: request.ownerId, - }, - select: { - id: true, - firstName: true, - lastName: true, - email: true, - companyId: true, - createdAt: true, - }, - }); + }) + .returning(fields); + if (!contact) throw new Error("The contact insert returned no row."); await emit({ type: "contact.created", record: { kind: "contact", id: contact.id }, @@ -281,10 +296,10 @@ export class MailboxMatchService { ); if (hasRealName && isPlaceholder) { - await this.db.contact.update({ - where: { id: contact.id }, - data: { firstName, lastName }, - }); + await this.db + .update(contactTable) + .set({ firstName, lastName }) + .where(eq(contactTable.id, contact.id)); return contact.id; } diff --git a/apps/api/src/mailbox/mailbox-token.service.ts b/apps/api/src/mailbox/mailbox-token.service.ts index 7353d9a15..91b75e100 100644 --- a/apps/api/src/mailbox/mailbox-token.service.ts +++ b/apps/api/src/mailbox/mailbox-token.service.ts @@ -5,7 +5,7 @@ import { parseScopes, type SignInAccount, } from "@crm/auth/scopes"; -import { type Db } from "@crm/db"; +import { account, and, type Db, eq } from "@crm/db"; import { Injectable, Logger } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; import { @@ -32,12 +32,15 @@ export class MailboxTokenService { userId: string, providerId: MailboxProviderId, ): Promise> { - const account = await this.db.account.findFirst({ - where: { userId, providerId }, - select: { scope: true }, - }); - - return parseScopes(account?.scope); + const [row] = await this.db + .select({ scope: account.scope }) + .from(account) + .where( + and(eq(account.userId, userId), eq(account.providerId, providerId)), + ) + .limit(1); + + return parseScopes(row?.scope); } async isConnected(userId: string, source: SyncSource): Promise { @@ -49,22 +52,25 @@ export class MailboxTokenService { } async signInAccounts(userId: string): Promise { - return this.db.account.findMany({ - where: { userId }, - select: { providerId: true, scope: true }, - }); + return this.db + .select({ providerId: account.providerId, scope: account.scope }) + .from(account) + .where(eq(account.userId, userId)); } async hasRefreshToken( userId: string, providerId: MailboxProviderId, ): Promise { - const account = await this.db.account.findFirst({ - where: { userId, providerId }, - select: { refreshToken: true }, - }); - - return Boolean(account?.refreshToken); + const [row] = await this.db + .select({ refreshToken: account.refreshToken }) + .from(account) + .where( + and(eq(account.userId, userId), eq(account.providerId, providerId)), + ) + .limit(1); + + return Boolean(row?.refreshToken); } async accessTokenFor( @@ -120,30 +126,42 @@ export class MailboxTokenService { return false; } - const cleared = await this.db.account.updateMany({ - where: { userId, providerId }, - data: { + const cleared = await this.db + .update(account) + .set({ accessToken: null, refreshToken: null, scope: null, accessTokenExpiresAt: null, refreshTokenExpiresAt: null, - }, - }); + }) + .where( + and(eq(account.userId, userId), eq(account.providerId, providerId)), + ) + .returning({ id: account.id }); - if (cleared.count === 0) return false; + if (cleared.length === 0) return false; this.logger.log({ message: "Mailbox access revoked", userId, providerId }); return true; } private async revokeWithGoogle(userId: string): Promise { - const account = await this.db.account.findFirst({ - where: { userId, providerId: GOOGLE_PROVIDER_ID }, - select: { refreshToken: true, accessToken: true }, - }); - - const token = account?.refreshToken ?? account?.accessToken; + const [row] = await this.db + .select({ + refreshToken: account.refreshToken, + accessToken: account.accessToken, + }) + .from(account) + .where( + and( + eq(account.userId, userId), + eq(account.providerId, GOOGLE_PROVIDER_ID), + ), + ) + .limit(1); + + const token = row?.refreshToken ?? row?.accessToken; if (!token) return true; const response = await fetch(GOOGLE_REVOKE_URL, { diff --git a/apps/api/src/mailbox/sync-state.service.ts b/apps/api/src/mailbox/sync-state.service.ts index ca56462b4..85f278a1d 100644 --- a/apps/api/src/mailbox/sync-state.service.ts +++ b/apps/api/src/mailbox/sync-state.service.ts @@ -1,8 +1,17 @@ import { + and, type Db, + eq, GoogleSyncStatus, - type MailboxSyncModel as MailboxSync, - type Prisma, + inArray, + isNull, + lte, + type MailboxSync, + mailboxSync, + type NewMailboxSync, + notInArray, + or, + sql, } from "@crm/db"; import { Injectable, Logger } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; @@ -17,45 +26,63 @@ export class SyncStateService { constructor(@InjectDatabase() private readonly db: Db) {} async get(userId: string, source: SyncSource): Promise { - return this.db.mailboxSync.findUnique({ - where: { userId_source: { userId, source } }, - }); + const [row] = await this.db + .select() + .from(mailboxSync) + .where( + and(eq(mailboxSync.userId, userId), eq(mailboxSync.source, source)), + ) + .limit(1); + return row ?? null; } async listForUser( userId: string, sources?: readonly SyncSource[], ): Promise { - const where: Prisma.MailboxSyncWhereInput = { userId }; - if (sources) where.source = { in: [...sources] }; - - return this.db.mailboxSync.findMany({ where }); + return this.db + .select() + .from(mailboxSync) + .where( + and( + eq(mailboxSync.userId, userId), + sources ? inArray(mailboxSync.source, [...sources]) : undefined, + ), + ); } async due(now: Date): Promise { - return this.db.mailboxSync.findMany({ - where: dueWhere(now), - orderBy: [{ lastSyncedAt: { sort: "asc", nulls: "first" } }], - }); + return this.db + .select() + .from(mailboxSync) + .where(dueWhere(now)) + .orderBy(sql`${mailboxSync.lastSyncedAt} asc nulls first`); } async claim(row: MailboxSync, now: Date): Promise { - const { count } = await this.db.mailboxSync.updateMany({ - where: { id: row.id, updatedAt: row.updatedAt, ...dueWhere(now) }, - data: { + const claimed = await this.db + .update(mailboxSync) + .set({ status: GoogleSyncStatus.RUNNING, retryAfter: new Date(now.getTime() + SYNC_LEASE_MS), - }, - }); - - return count === 1; + }) + .where( + and( + eq(mailboxSync.id, row.id), + eq(mailboxSync.updatedAt, row.updatedAt), + dueWhere(now), + ), + ) + .returning({ id: mailboxSync.id }); + + return claimed.length === 1; } async release(id: string): Promise { - await this.db.mailboxSync.updateMany({ - where: { id }, - data: { retryAfter: null }, - }); + await this.db + .update(mailboxSync) + .set({ retryAfter: null }) + .where(eq(mailboxSync.id, id)); } async ensure( @@ -63,27 +90,30 @@ export class SyncStateService { source: SyncSource, options: { autoCreate: boolean }, ): Promise { - return this.db.mailboxSync.upsert({ - where: { userId_source: { userId, source } }, - create: { + const [row] = await this.db + .insert(mailboxSync) + .values({ userId, source, status: GoogleSyncStatus.IDLE, autoCreate: options.autoCreate, - }, - update: { - status: GoogleSyncStatus.IDLE, - lastError: null, - retryAfter: null, - }, - }); + }) + .onConflictDoUpdate({ + target: [mailboxSync.userId, mailboxSync.source], + set: { + status: GoogleSyncStatus.IDLE, + lastError: null, + retryAfter: null, + }, + }) + .returning(); + if (!row) + throw new Error(`The ${source} sync row for ${userId} was not written.`); + return row; } async markRunning(id: string): Promise { - await this.db.mailboxSync.update({ - where: { id }, - data: { status: GoogleSyncStatus.RUNNING, lastError: null }, - }); + await this.write(id, { status: GoogleSyncStatus.RUNNING, lastError: null }); } async settle( @@ -93,14 +123,11 @@ export class SyncStateService { status: GoogleSyncStatus; }, ): Promise { - await this.db.mailboxSync.update({ - where: { id }, - data: { - ...update, - lastSyncedAt: new Date(), - lastError: null, - retryAfter: null, - }, + await this.write(id, { + ...update, + lastSyncedAt: new Date(), + lastError: null, + retryAfter: null, }); } @@ -111,46 +138,34 @@ export class SyncStateService { reason, }); - await this.db.mailboxSync.update({ - where: { id }, - data: { - cursor: null, - status: GoogleSyncStatus.IDLE, - lastError: null, - retryAfter: null, - }, + await this.write(id, { + cursor: null, + status: GoogleSyncStatus.IDLE, + lastError: null, + retryAfter: null, }); } async markNeedsReconnect(id: string, reason: string): Promise { - await this.db.mailboxSync.update({ - where: { id }, - data: { - status: GoogleSyncStatus.NEEDS_RECONNECT, - lastError: reason, - retryAfter: null, - }, + await this.write(id, { + status: GoogleSyncStatus.NEEDS_RECONNECT, + lastError: reason, + retryAfter: null, }); } async markRateLimited(id: string, retryAfterMs: number): Promise { - await this.db.mailboxSync.update({ - where: { id }, - data: { - status: GoogleSyncStatus.IDLE, - retryAfter: new Date(Date.now() + retryAfterMs), - }, + await this.write(id, { + status: GoogleSyncStatus.IDLE, + retryAfter: new Date(Date.now() + retryAfterMs), }); } async markFailed(id: string, reason: string): Promise { - await this.db.mailboxSync.update({ - where: { id }, - data: { - status: GoogleSyncStatus.FAILED, - lastError: reason, - retryAfter: null, - }, + await this.write(id, { + status: GoogleSyncStatus.FAILED, + lastError: reason, + retryAfter: null, }); } @@ -159,23 +174,41 @@ export class SyncStateService { source: SyncSource, enabled: boolean, ): Promise { - await this.db.mailboxSync.updateMany({ - where: { userId, source }, - data: { autoCreate: enabled }, - }); + await this.db + .update(mailboxSync) + .set({ autoCreate: enabled }) + .where( + and(eq(mailboxSync.userId, userId), eq(mailboxSync.source, source)), + ); } async remove(userId: string, source?: SyncSource): Promise { - const where: Prisma.MailboxSyncWhereInput = { userId }; - if (source) where.source = source; + await this.db + .delete(mailboxSync) + .where( + and( + eq(mailboxSync.userId, userId), + source ? eq(mailboxSync.source, source) : undefined, + ), + ); + } - await this.db.mailboxSync.deleteMany({ where }); + private async write( + id: string, + data: Partial, + ): Promise { + const [row] = await this.db + .update(mailboxSync) + .set(data) + .where(eq(mailboxSync.id, id)) + .returning({ id: mailboxSync.id }); + if (!row) throw new Error(`No mailbox sync row with id ${id}.`); } } function dueWhere(now: Date) { - return { - status: { notIn: [GoogleSyncStatus.NEEDS_RECONNECT] }, - OR: [{ retryAfter: null }, { retryAfter: { lte: now } }], - }; + return and( + notInArray(mailboxSync.status, [GoogleSyncStatus.NEEDS_RECONNECT]), + or(isNull(mailboxSync.retryAfter), lte(mailboxSync.retryAfter, now)), + ); } diff --git a/apps/api/src/mailbox/thread-writer.service.ts b/apps/api/src/mailbox/thread-writer.service.ts index 9b06692c6..9fc65ccc4 100644 --- a/apps/api/src/mailbox/thread-writer.service.ts +++ b/apps/api/src/mailbox/thread-writer.service.ts @@ -1,10 +1,19 @@ import { ActivityType, + activity as activityTable, + and, + count, type Db, EmailDirection, - type MailboxSyncModel as MailboxSync, - type Prisma, - Prisma as PrismaNamespace, + type Executor, + emailMessage, + emailThread, + eq, + isUniqueViolation, + type MailboxSync, + max, + min, + type NewEmailThread, RecordSource, } from "@crm/db"; import { Injectable, Logger } from "@nestjs/common"; @@ -62,22 +71,19 @@ export class ThreadWriterService { parsed: IncomingMessage, context: MatchContext, ): Promise { - const existing = await this.db.emailMessage.findUnique({ - where: { rfcMessageId: parsed.rfcMessageId }, - select: { - threadId: true, + const existing = await this.db.query.emailMessage.findFirst({ + where: eq(emailMessage.rfcMessageId, parsed.rfcMessageId), + columns: { threadId: true }, + with: { thread: { - select: { - companyId: true, - contactId: true, - activity: { select: { id: true } }, - }, + columns: { companyId: true, contactId: true }, + with: { activity: { columns: { id: true } } }, }, }, }); if (existing?.thread.activity) return false; - const repair = existing !== null; + const repair = existing !== undefined; const participants = [parsed.from, ...parsed.recipients]; const outbound = parsed.from.email === options.mailbox; @@ -87,10 +93,16 @@ export class ThreadWriterService { companyId: existing.thread.companyId, contactId: existing.thread.contactId, } - : await this.db.emailThread.findUnique({ - where: { rootMessageId: parsed.rootId }, - select: { id: true, companyId: true, contactId: true }, - }); + : await this.db + .select({ + id: emailThread.id, + companyId: emailThread.companyId, + contactId: emailThread.contactId, + }) + .from(emailThread) + .where(eq(emailThread.rootMessageId, parsed.rootId)) + .limit(1) + .then((rows) => rows[0] ?? null); let companyId = thread?.companyId ?? null; let contactId = thread?.contactId ?? null; @@ -121,66 +133,64 @@ export class ThreadWriterService { let occurredAt: Date; try { - occurredAt = await this.db.$transaction(async (tx) => { + occurredAt = await this.db.transaction(async (tx) => { const record = existing ? { id: existing.threadId } - : await tx.emailThread.upsert({ - where: { rootMessageId: parsed.rootId }, - create: { - rootMessageId: parsed.rootId, - subject: parsed.subject, - companyId, - contactId, - firstMessageAt: parsed.sentAt, - lastMessageAt: parsed.sentAt, - messageCount: 0, - }, - update: {}, - select: { id: true }, + : await this.threadFor(tx, { + rootMessageId: parsed.rootId, + subject: parsed.subject, + companyId, + contactId, + firstMessageAt: parsed.sentAt, + lastMessageAt: parsed.sentAt, + messageCount: 0, }); if (!repair) { - await tx.emailMessage.create({ - data: { - threadId: record.id, - rfcMessageId: parsed.rfcMessageId, - syncedByUserId: row.userId, - gmailMessageId: parsed.gmailMessageId ?? null, - outlookMessageId: parsed.outlookMessageId ?? null, - outlookWebLink: parsed.outlookWebLink ?? null, - direction: outbound - ? EmailDirection.OUTBOUND - : EmailDirection.INBOUND, - fromEmail: parsed.from.email, - fromName: parsed.from.name, - recipients: parsed.recipients, - subject: parsed.subject, - snippet: snippetOf(parsed.body), - body: parsed.body || null, - sentAt: parsed.sentAt, - }, + await tx.insert(emailMessage).values({ + threadId: record.id, + rfcMessageId: parsed.rfcMessageId, + syncedByUserId: row.userId, + gmailMessageId: parsed.gmailMessageId ?? null, + outlookMessageId: parsed.outlookMessageId ?? null, + outlookWebLink: parsed.outlookWebLink ?? null, + direction: outbound + ? EmailDirection.OUTBOUND + : EmailDirection.INBOUND, + fromEmail: parsed.from.email, + fromName: parsed.from.name, + recipients: parsed.recipients, + subject: parsed.subject, + snippet: snippetOf(parsed.body), + body: parsed.body || null, + sentAt: parsed.sentAt, }); } - const stats = await tx.emailMessage.aggregate({ - where: { threadId: record.id }, - _count: { _all: true }, - _min: { sentAt: true }, - _max: { sentAt: true }, - }); + const [stats] = await tx + .select({ + count: count(), + first: min(emailMessage.sentAt), + last: max(emailMessage.sentAt), + }) + .from(emailMessage) + .where(eq(emailMessage.threadId, record.id)); - const firstMessageAt = stats._min.sentAt ?? parsed.sentAt; - const lastMessageAt = stats._max.sentAt ?? parsed.sentAt; + const firstMessageAt = stats?.first ?? parsed.sentAt; + const lastMessageAt = stats?.last ?? parsed.sentAt; - const data: Prisma.EmailThreadUpdateInput = { - messageCount: stats._count._all, + const data: Partial = { + messageCount: stats?.count ?? 0, firstMessageAt, lastMessageAt, }; if (parsed.sentAt <= firstMessageAt) data.subject = parsed.subject; - await tx.emailThread.update({ where: { id: record.id }, data }); + await tx + .update(emailThread) + .set(data) + .where(eq(emailThread.id, record.id)); return this.project(tx, record.id, row.userId, { subject: parsed.subject ?? "(no subject)", @@ -205,17 +215,19 @@ export class ThreadWriterService { cause: unknown, rfcMessageId: string, ): Promise { - const duplicate = - cause instanceof PrismaNamespace.PrismaClientKnownRequestError && - cause.code === "P2002"; - if (!duplicate) return false; - - const winner = await this.db.emailMessage.findFirst({ - where: { rfcMessageId, thread: { activity: { isNot: null } } }, - select: { id: true }, - }); - - return winner !== null; + if (!isUniqueViolation(cause)) return false; + + const [winner] = await this.db + .select({ id: emailMessage.id }) + .from(emailMessage) + .innerJoin( + activityTable, + eq(activityTable.emailThreadId, emailMessage.threadId), + ) + .where(eq(emailMessage.rfcMessageId, rfcMessageId)) + .limit(1); + + return winner !== undefined; } private async touch( @@ -241,19 +253,44 @@ export class ThreadWriterService { rootMessageId: string, mailbox: string, ): Promise { - const found = await this.db.emailMessage.findFirst({ - where: { - thread: { rootMessageId }, - fromEmail: mailbox, - }, - select: { id: true }, - }); + const [found] = await this.db + .select({ id: emailMessage.id }) + .from(emailMessage) + .innerJoin(emailThread, eq(emailThread.id, emailMessage.threadId)) + .where( + and( + eq(emailThread.rootMessageId, rootMessageId), + eq(emailMessage.fromEmail, mailbox), + ), + ) + .limit(1); + + return found !== undefined; + } - return found !== null; + private async threadFor( + tx: Executor, + values: NewEmailThread, + ): Promise<{ id: string }> { + const [created] = await tx + .insert(emailThread) + .values(values) + .onConflictDoNothing({ target: emailThread.rootMessageId }) + .returning({ id: emailThread.id }); + if (created) return created; + + const [existing] = await tx + .select({ id: emailThread.id }) + .from(emailThread) + .where(eq(emailThread.rootMessageId, values.rootMessageId)) + .limit(1); + if (!existing) + throw new Error(`Thread ${values.rootMessageId} vanished mid-write.`); + return existing; } private async project( - tx: Prisma.TransactionClient, + tx: Executor, emailThreadId: string, userId: string, summary: { @@ -265,9 +302,9 @@ export class ThreadWriterService { origin: SyncSource; }, ): Promise { - const activity = await tx.activity.upsert({ - where: { emailThreadId }, - create: { + const [activity] = await tx + .insert(activityTable) + .values({ type: ActivityType.EMAIL, subject: summary.subject, body: summary.snippet, @@ -277,13 +314,14 @@ export class ThreadWriterService { createdById: userId, emailThreadId, meta: { synced: true, source: summary.origin }, - }, - update: { - body: summary.snippet, - occurredAt: summary.lastMessageAt, - }, - select: { createdAt: true }, - }); + }) + .onConflictDoUpdate({ + target: activityTable.emailThreadId, + set: { body: summary.snippet, occurredAt: summary.lastMessageAt }, + }) + .returning({ createdAt: activityTable.createdAt }); + if (!activity) + throw new Error(`Activity for thread ${emailThreadId} was not written.`); return activity.createdAt; } diff --git a/apps/api/src/mcp/mcp-connections.service.ts b/apps/api/src/mcp/mcp-connections.service.ts index ac6a8a188..b7730f4dd 100644 --- a/apps/api/src/mcp/mcp-connections.service.ts +++ b/apps/api/src/mcp/mcp-connections.service.ts @@ -13,7 +13,23 @@ import { WORKSPACE_ID, workspaceRoleOf, } from "@crm/auth/organization"; -import type { Db } from "@crm/db"; +import { + agentDefinition, + agentTask, + and, + type Db, + desc, + eq, + gt, + inArray, + isNotNull, + isNull, + mcpClientRegistration, + mcpConnection, + mcpOauthState, + mcpToolSnapshot, + type NewMcpConnection, +} from "@crm/db"; import { PRIORITY } from "@crm/db/agent-tasks"; import { vendorAccess, @@ -162,32 +178,32 @@ export class McpConnectionsService { async list(userId: string) { const [role, connections, agentRows] = await Promise.all([ workspaceRoleOf(userId, this.db), - this.db.mcpConnection.findMany({ - where: { organizationId: WORKSPACE_ID }, - select: { - platformSlug: true, - status: true, - accessMode: true, - grantedScopes: true, - connectedAt: true, - lastSuccessfulCallAt: true, - lastErrorCode: true, - }, - }), - this.db.agentDefinition.findMany({ - where: { - status: { in: ["LIVE", "PAUSED"] }, - deletedAt: null, - currentVersionId: { not: null }, - }, - orderBy: { updatedAt: "desc" }, - select: { + this.db + .select({ + platformSlug: mcpConnection.platformSlug, + status: mcpConnection.status, + accessMode: mcpConnection.accessMode, + grantedScopes: mcpConnection.grantedScopes, + connectedAt: mcpConnection.connectedAt, + lastSuccessfulCallAt: mcpConnection.lastSuccessfulCallAt, + lastErrorCode: mcpConnection.lastErrorCode, + }) + .from(mcpConnection) + .where(eq(mcpConnection.organizationId, WORKSPACE_ID)), + this.db.query.agentDefinition.findMany({ + where: and( + inArray(agentDefinition.status, ["LIVE", "PAUSED"]), + isNull(agentDefinition.deletedAt), + isNotNull(agentDefinition.currentVersionId), + ), + orderBy: [desc(agentDefinition.updatedAt)], + columns: { id: true, name: true, description: true, status: true, - currentVersion: { select: { manifest: true } }, }, + with: { currentVersion: { columns: { manifest: true } } }, }), ]); const bySlug = new Map( @@ -255,15 +271,10 @@ export class McpConnectionsService { const rawState = randomBytes(32).toString("base64url"); const hash = stateHash(rawState); - await this.db.$transaction(async (tx) => { - await tx.mcpConnection.upsert({ - where: { - organizationId_platformSlug: { - organizationId: WORKSPACE_ID, - platformSlug: vendor.slug, - }, - }, - create: { + await this.db.transaction(async (tx) => { + await tx + .insert(mcpConnection) + .values({ organizationId: WORKSPACE_ID, platformSlug: vendor.slug, status: "CONNECTING", @@ -271,27 +282,27 @@ export class McpConnectionsService { grantedScopes: [], registrationId: registration.id, connectedById: userId, - }, - update: { - status: "CONNECTING", - accessMode: ACCESS_MODE[input.accessMode], - registrationId: registration.id, - connectedById: userId, - lastErrorCode: null, - }, - }); - await tx.mcpOauthState.create({ - data: { - stateHash: hash, - organizationId: WORKSPACE_ID, - userId, - platformSlug: vendor.slug, - accessMode: ACCESS_MODE[input.accessMode], - returnPath: input.returnPath, - encryptedCodeVerifier: box.encrypt(pkce.verifier, statePurpose(hash)), - registrationId: registration.id, - expiresAt: new Date(Date.now() + MCP_AUTH.state.expiresMs), - }, + }) + .onConflictDoUpdate({ + target: [mcpConnection.organizationId, mcpConnection.platformSlug], + set: { + status: "CONNECTING", + accessMode: ACCESS_MODE[input.accessMode], + registrationId: registration.id, + connectedById: userId, + lastErrorCode: null, + }, + }); + await tx.insert(mcpOauthState).values({ + stateHash: hash, + organizationId: WORKSPACE_ID, + userId, + platformSlug: vendor.slug, + accessMode: ACCESS_MODE[input.accessMode], + returnPath: input.returnPath, + encryptedCodeVerifier: box.encrypt(pkce.verifier, statePurpose(hash)), + registrationId: registration.id, + expiresAt: new Date(Date.now() + MCP_AUTH.state.expiresMs), }); }); @@ -316,15 +327,7 @@ export class McpConnectionsService { async reconnect(userId: string, input: McpStartInput) { await this.assertManager(userId); - const connection = await this.db.mcpConnection.findUnique({ - where: { - organizationId_platformSlug: { - organizationId: WORKSPACE_ID, - platformSlug: input.platformSlug, - }, - }, - select: { id: true }, - }); + const connection = await this.connectionBySlug(input.platformSlug); if (!connection) { throw new NotFoundException("The MCP platform is not connected."); } @@ -333,18 +336,16 @@ export class McpConnectionsService { async tools(userId: string, platformSlug: string) { await workspaceRoleOf(userId, this.db); - const connection = await this.db.mcpConnection.findUnique({ - where: { - organizationId_platformSlug: { - organizationId: WORKSPACE_ID, - platformSlug, - }, - }, - select: { - status: true, + const connection = await this.db.query.mcpConnection.findFirst({ + where: and( + eq(mcpConnection.organizationId, WORKSPACE_ID), + eq(mcpConnection.platformSlug, platformSlug), + ), + columns: { status: true }, + with: { toolSnapshots: { - orderBy: { createdAt: "desc" }, - select: { + orderBy: [desc(mcpToolSnapshot.createdAt)], + columns: { name: true, description: true, fingerprint: true, @@ -388,32 +389,27 @@ export class McpConnectionsService { tools: readonly { name: string; fingerprint: string }[], ) { await this.assertManager(userId); - const connection = await this.db.mcpConnection.findUnique({ - where: { - organizationId_platformSlug: { - organizationId: WORKSPACE_ID, - platformSlug, - }, - }, - select: { id: true, status: true }, - }); + const connection = await this.connectionBySlug(platformSlug); if (connection?.status !== "CONNECTED") { throw new NotFoundException("The MCP connection is not active."); } - const count = await this.db.$transaction(async (tx) => { + const count = await this.db.transaction(async (tx) => { let total = 0; for (const tool of tools) { - const result = await tx.mcpToolSnapshot.updateMany({ - where: { - connectionId: connection.id, - name: tool.name, - fingerprint: tool.fingerprint, - reviewState: "PENDING", - }, - data: { reviewState: "REVIEWED" }, - }); - total += result.count; + const result = await tx + .update(mcpToolSnapshot) + .set({ reviewState: "REVIEWED" }) + .where( + and( + eq(mcpToolSnapshot.connectionId, connection.id), + eq(mcpToolSnapshot.name, tool.name), + eq(mcpToolSnapshot.fingerprint, tool.fingerprint), + eq(mcpToolSnapshot.reviewState, "PENDING"), + ), + ) + .returning({ id: mcpToolSnapshot.id }); + total += result.length; } if (total !== tools.length) { throw new BadRequestException("The MCP tool inventory changed."); @@ -432,9 +428,9 @@ export class McpConnectionsService { ); const hash = stateHash(input.state); - const state = await this.db.mcpOauthState.findUnique({ - where: { stateHash: hash }, - include: { registration: true }, + const state = await this.db.query.mcpOauthState.findFirst({ + where: eq(mcpOauthState.stateHash, hash), + with: { registration: true }, }); if ( !state || @@ -445,11 +441,18 @@ export class McpConnectionsService { throw new BadRequestException("The MCP authorization state is invalid."); } - const consumed = await this.db.mcpOauthState.updateMany({ - where: { id: state.id, consumedAt: null, expiresAt: { gt: new Date() } }, - data: { consumedAt: new Date() }, - }); - if (consumed.count !== 1) { + const consumed = await this.db + .update(mcpOauthState) + .set({ consumedAt: new Date() }) + .where( + and( + eq(mcpOauthState.id, state.id), + isNull(mcpOauthState.consumedAt), + gt(mcpOauthState.expiresAt, new Date()), + ), + ) + .returning({ id: mcpOauthState.id }); + if (consumed.length !== 1) { throw new BadRequestException( "The MCP authorization state was already used.", ); @@ -500,19 +503,13 @@ export class McpConnectionsService { ]; assertGrantedScopeBounds(vendor.slug, state.accessMode, granted); - const connection = await this.db.mcpConnection.findUniqueOrThrow({ - where: { - organizationId_platformSlug: { - organizationId: WORKSPACE_ID, - platformSlug: vendor.slug, - }, - }, - select: { id: true }, - }); - await this.db.$transaction([ - this.db.mcpConnection.update({ - where: { id: connection.id }, - data: { + const connection = await this.connectionBySlug(vendor.slug); + if (!connection) + throw new NotFoundException("The MCP platform is not connected."); + await this.db.transaction(async (tx) => { + await tx + .update(mcpConnection) + .set({ status: "CONNECTING", grantedScopes: granted, encryptedAccessToken: box.encrypt( @@ -533,52 +530,54 @@ export class McpConnectionsService { ), connectedAt: new Date(), lastErrorCode: null, - }, - }), - this.db.agentTask.create({ - data: { - kind: "mcp-tool-discovery", - reason: `Discover ${vendor.name} MCP tools after connection`, - payload: { connectionId: connection.id, platformSlug: vendor.slug }, - priority: PRIORITY.mcpDiscovery, - budget: 1, - dueAt: new Date(), - subject: connection.id, - }, - }), - ]); + }) + .where(eq(mcpConnection.id, connection.id)); + await tx.insert(agentTask).values({ + kind: "mcp-tool-discovery", + reason: `Discover ${vendor.name} MCP tools after connection`, + payload: { connectionId: connection.id, platformSlug: vendor.slug }, + priority: PRIORITY.mcpDiscovery, + budget: 1, + dueAt: new Date(), + subject: connection.id, + }); + }); this.agent.mcpToolDiscoveryQueued(); return new URL(state.returnPath, appUrl).toString(); } async failCallback(rawState: string, userId: string): Promise { - const state = await this.db.mcpOauthState.findUnique({ - where: { stateHash: stateHash(rawState) }, - select: { userId: true, platformSlug: true }, - }); + const [state] = await this.db + .select({ + userId: mcpOauthState.userId, + platformSlug: mcpOauthState.platformSlug, + }) + .from(mcpOauthState) + .where(eq(mcpOauthState.stateHash, stateHash(rawState))) + .limit(1); if (!state || state.userId !== userId) return; - await this.db.mcpConnection.updateMany({ - where: { - organizationId: WORKSPACE_ID, - platformSlug: state.platformSlug, - status: "CONNECTING", - }, - data: { status: "ERROR", lastErrorCode: "oauth_callback_failed" }, - }); + await this.db + .update(mcpConnection) + .set({ status: "ERROR", lastErrorCode: "oauth_callback_failed" }) + .where( + and( + eq(mcpConnection.organizationId, WORKSPACE_ID), + eq(mcpConnection.platformSlug, state.platformSlug), + eq(mcpConnection.status, "CONNECTING"), + ), + ); } async disconnect(userId: string, platformSlug: string) { await this.assertManager(userId); - const connection = await this.db.mcpConnection.findUnique({ - where: { - organizationId_platformSlug: { - organizationId: WORKSPACE_ID, - platformSlug, - }, - }, - include: { registration: true }, + const connection = await this.db.query.mcpConnection.findFirst({ + where: and( + eq(mcpConnection.organizationId, WORKSPACE_ID), + eq(mcpConnection.platformSlug, platformSlug), + ), + with: { registration: true }, }); if (!connection) throw new NotFoundException("The MCP platform is not connected."); @@ -613,22 +612,22 @@ export class McpConnectionsService { } } - await this.db.$transaction([ - this.db.mcpConnection.update({ - where: { id: connection.id }, - data: { + await this.db.transaction(async (tx) => { + await tx + .update(mcpConnection) + .set({ status: "DISCONNECTED", encryptedAccessToken: null, encryptedRefreshToken: null, tokenExpiresAt: null, lastErrorCode: revocationErrorCode, - }, - }), - this.db.mcpToolSnapshot.updateMany({ - where: { connectionId: connection.id }, - data: { reviewState: "REMOVED" }, - }), - ]); + }) + .where(eq(mcpConnection.id, connection.id)); + await tx + .update(mcpToolSnapshot) + .set({ reviewState: "REMOVED" }) + .where(eq(mcpToolSnapshot.connectionId, connection.id)); + }); return { disconnected: true as const }; } @@ -641,9 +640,9 @@ export class McpConnectionsService { ); } - const connection = await this.db.mcpConnection.findUnique({ - where: { id: connectionId }, - include: { registration: true }, + const connection = await this.db.query.mcpConnection.findFirst({ + where: eq(mcpConnection.id, connectionId), + with: { registration: true }, }); if ( !connection?.encryptedAccessToken || @@ -698,22 +697,19 @@ export class McpConnectionsService { const nextExpiresAt = refreshed.expiresIn ? new Date(Date.now() + refreshed.expiresIn * 1_000) : null; - await this.db.mcpConnection.update({ - where: { id: connection.id }, - data: { - encryptedAccessToken: box.encrypt( - refreshed.accessToken, - tokenPurpose(connection.id, "access"), - ), - encryptedRefreshToken: refreshed.refreshToken - ? box.encrypt( - refreshed.refreshToken, - tokenPurpose(connection.id, "refresh"), - ) - : connection.encryptedRefreshToken, - tokenExpiresAt: nextExpiresAt, - lastErrorCode: null, - }, + await this.writeConnection(connection.id, { + encryptedAccessToken: box.encrypt( + refreshed.accessToken, + tokenPurpose(connection.id, "access"), + ), + encryptedRefreshToken: refreshed.refreshToken + ? box.encrypt( + refreshed.refreshToken, + tokenPurpose(connection.id, "refresh"), + ) + : connection.encryptedRefreshToken, + tokenExpiresAt: nextExpiresAt, + lastErrorCode: null, }); return { token: refreshed.accessToken, @@ -721,14 +717,15 @@ export class McpConnectionsService { }; } catch (error) { const code = error instanceof Error ? error.message : "refresh_failed"; - const latest = await this.db.mcpConnection.findUnique({ - where: { id: connection.id }, - select: { - status: true, - encryptedAccessToken: true, - tokenExpiresAt: true, - }, - }); + const [latest] = await this.db + .select({ + status: mcpConnection.status, + encryptedAccessToken: mcpConnection.encryptedAccessToken, + tokenExpiresAt: mcpConnection.tokenExpiresAt, + }) + .from(mcpConnection) + .where(eq(mcpConnection.id, connection.id)) + .limit(1); if ( latest?.encryptedAccessToken && latest.encryptedAccessToken !== connection.encryptedAccessToken && @@ -755,9 +752,8 @@ export class McpConnectionsService { ); } - await this.db.mcpConnection.update({ - where: { id: connection.id }, - data: { lastErrorCode: code.slice(0, 120) }, + await this.writeConnection(connection.id, { + lastErrorCode: code.slice(0, 120), }); throw new ServiceUnavailableException( "The MCP connection is temporarily unavailable.", @@ -771,14 +767,16 @@ export class McpConnectionsService { server: Awaited>, scopes: readonly string[], ) { - const existing = await this.db.mcpClientRegistration.findUnique({ - where: { - platformSlug_metadataFingerprint: { - platformSlug: vendor.slug, - metadataFingerprint, - }, - }, - }); + const [existing] = await this.db + .select() + .from(mcpClientRegistration) + .where( + and( + eq(mcpClientRegistration.platformSlug, vendor.slug), + eq(mcpClientRegistration.metadataFingerprint, metadataFingerprint), + ), + ) + .limit(1); const box = createMcpSecretBox(); if (!box) throw new ServiceUnavailableException( @@ -800,14 +798,9 @@ export class McpConnectionsService { ); const id = existing?.id ?? randomUUID(); - return this.db.mcpClientRegistration.upsert({ - where: { - platformSlug_metadataFingerprint: { - platformSlug: vendor.slug, - metadataFingerprint, - }, - }, - create: { + const [saved] = await this.db + .insert(mcpClientRegistration) + .values({ id, platformSlug: vendor.slug, metadataFingerprint, @@ -826,25 +819,33 @@ export class McpConnectionsService { clientSecretExpiresAt: dateFromSeconds( registered.clientSecretExpiresAt, ), - }, - update: { - issuer: server.issuer, - clientId: registered.clientId, - encryptedClientSecret: registered.clientSecret - ? box.encrypt(registered.clientSecret, registrationPurpose(id)) - : null, - encryptedRegistrationAccessToken: registered.registrationAccessToken - ? box.encrypt( - registered.registrationAccessToken, - registrationAccessPurpose(id), - ) - : null, - clientIdIssuedAt: dateFromSeconds(registered.clientIdIssuedAt), - clientSecretExpiresAt: dateFromSeconds( - registered.clientSecretExpiresAt, - ), - }, - }); + }) + .onConflictDoUpdate({ + target: [ + mcpClientRegistration.platformSlug, + mcpClientRegistration.metadataFingerprint, + ], + set: { + issuer: server.issuer, + clientId: registered.clientId, + encryptedClientSecret: registered.clientSecret + ? box.encrypt(registered.clientSecret, registrationPurpose(id)) + : null, + encryptedRegistrationAccessToken: registered.registrationAccessToken + ? box.encrypt( + registered.registrationAccessToken, + registrationAccessPurpose(id), + ) + : null, + clientIdIssuedAt: dateFromSeconds(registered.clientIdIssuedAt), + clientSecretExpiresAt: dateFromSeconds( + registered.clientSecretExpiresAt, + ), + }, + }) + .returning(); + if (!saved) throw new Error("The MCP client registration was not written."); + return saved; } private callbackUrl(): string { @@ -855,12 +856,38 @@ export class McpConnectionsService { connectionId: string, code: string, ): Promise { - await this.db.mcpConnection.update({ - where: { id: connectionId }, - data: { status: "ERROR", lastErrorCode: code.slice(0, 120) }, + await this.writeConnection(connectionId, { + status: "ERROR", + lastErrorCode: code.slice(0, 120), }); } + private async connectionBySlug(platformSlug: string) { + const [row] = await this.db + .select({ id: mcpConnection.id, status: mcpConnection.status }) + .from(mcpConnection) + .where( + and( + eq(mcpConnection.organizationId, WORKSPACE_ID), + eq(mcpConnection.platformSlug, platformSlug), + ), + ) + .limit(1); + return row ?? null; + } + + private async writeConnection( + id: string, + data: Partial, + ): Promise { + const [row] = await this.db + .update(mcpConnection) + .set(data) + .where(eq(mcpConnection.id, id)) + .returning({ id: mcpConnection.id }); + if (!row) throw new NotFoundException("The MCP connection is not active."); + } + private async assertManager(userId: string): Promise { const role = await workspaceRoleOf(userId, this.db); if (!canManageConnections(role)) { diff --git a/apps/api/src/microsoft/microsoft-connection.service.ts b/apps/api/src/microsoft/microsoft-connection.service.ts index 1114a43ab..6dfd9c089 100644 --- a/apps/api/src/microsoft/microsoft-connection.service.ts +++ b/apps/api/src/microsoft/microsoft-connection.service.ts @@ -1,6 +1,22 @@ import { isMicrosoftConfigured } from "@crm/auth/env"; import { MICROSOFT_PROVIDER_ID, signsInWithMicrosoft } from "@crm/auth/scopes"; -import type { Db, Prisma } from "@crm/db"; +import { + account, + activity, + and, + asc, + contains, + type Db, + type Executor, + emailMessage, + emailThread, + eq, + inArray, + isNotNull, + notExists, + or, + sql, +} from "@crm/db"; import { Injectable, Logger, NotFoundException } from "@nestjs/common"; import { ActivityStampService } from "../crm/activity-stamp.service"; import { InjectDatabase } from "../database/database.constants"; @@ -99,15 +115,19 @@ export class MicrosoftConnectionService { } async reconcileAll(): Promise { - const accounts = await this.db.account.findMany({ - where: { - providerId: MICROSOFT_PROVIDER_ID, - OR: MICROSOFT_SYNC_SOURCES.map((source) => ({ - scope: { contains: SCOPE_FOR_SOURCE[source] }, - })), - }, - select: { userId: true }, - }); + const accounts = await this.db + .select({ userId: account.userId }) + .from(account) + .where( + and( + eq(account.providerId, MICROSOFT_PROVIDER_ID), + or( + ...MICROSOFT_SYNC_SOURCES.map((source) => + contains(account.scope, SCOPE_FOR_SOURCE[source]), + ), + ), + ), + ); for (const userId of new Set(accounts.map((row) => row.userId))) { await this.onConnected(userId); @@ -115,33 +135,48 @@ export class MicrosoftConnectionService { } async purgeSyncedData(userId: string): Promise { - const mine: Prisma.EmailMessageWhereInput = { - syncedByUserId: userId, - outlookMessageId: { not: null }, - }; - - const purged = await this.db.$transaction( - async (tx) => { - const touched = await tx.emailMessage.findMany({ - where: mine, - select: { threadId: true }, - distinct: ["threadId"], - }); - - const threadIds = touched.map((row) => row.threadId); - const messages = await tx.emailMessage.deleteMany({ where: mine }); - - await tx.emailThread.deleteMany({ - where: { id: { in: threadIds }, messages: { none: {} } }, - }); - - await rebuildThreads(tx, threadIds); - - return messages.count; - }, - { timeout: PURGE_TIMEOUT_MS }, + const mine = and( + eq(emailMessage.syncedByUserId, userId), + isNotNull(emailMessage.outlookMessageId), ); + const purged = await this.db.transaction(async (tx) => { + await tx.execute( + sql`SET LOCAL statement_timeout = ${sql.raw(String(PURGE_TIMEOUT_MS))}`, + ); + + const touched = await tx + .selectDistinct({ threadId: emailMessage.threadId }) + .from(emailMessage) + .where(mine); + + const threadIds = touched.map((row) => row.threadId); + const messages = await tx + .delete(emailMessage) + .where(mine) + .returning({ id: emailMessage.id }); + + if (threadIds.length > 0) { + await tx + .delete(emailThread) + .where( + and( + inArray(emailThread.id, threadIds), + notExists( + tx + .select({ id: emailMessage.id }) + .from(emailMessage) + .where(eq(emailMessage.threadId, emailThread.id)), + ), + ), + ); + } + + await rebuildThreads(tx, threadIds); + + return messages.length; + }); + await this.stamp.recomputeAll(); this.logger.log({ message: "Outlook data purged", userId, purged }); @@ -173,16 +208,21 @@ export class MicrosoftConnectionService { } async function rebuildThreads( - tx: Prisma.TransactionClient, + tx: Executor, threadIds: string[], ): Promise { if (threadIds.length === 0) return; - const remaining = await tx.emailMessage.findMany({ - where: { threadId: { in: threadIds } }, - select: { threadId: true, sentAt: true, subject: true, snippet: true }, - orderBy: { sentAt: "asc" }, - }); + const remaining = await tx + .select({ + threadId: emailMessage.threadId, + sentAt: emailMessage.sentAt, + subject: emailMessage.subject, + snippet: emailMessage.snippet, + }) + .from(emailMessage) + .where(inArray(emailMessage.threadId, threadIds)) + .orderBy(asc(emailMessage.sentAt)); const byThread = new Map(); @@ -197,19 +237,19 @@ async function rebuildThreads( const last = messages.at(-1); if (!first || !last) continue; - await tx.emailThread.update({ - where: { id: threadId }, - data: { + await tx + .update(emailThread) + .set({ messageCount: messages.length, firstMessageAt: first.sentAt, lastMessageAt: last.sentAt, subject: first.subject, - }, - }); + }) + .where(eq(emailThread.id, threadId)); - await tx.activity.updateMany({ - where: { emailThreadId: threadId }, - data: { body: last.snippet, occurredAt: last.sentAt }, - }); + await tx + .update(activity) + .set({ body: last.snippet, occurredAt: last.sentAt }) + .where(eq(activity.emailThreadId, threadId)); } } diff --git a/apps/api/src/microsoft/outlook-sync.service.ts b/apps/api/src/microsoft/outlook-sync.service.ts index 50d3d0064..b67222e85 100644 --- a/apps/api/src/microsoft/outlook-sync.service.ts +++ b/apps/api/src/microsoft/outlook-sync.service.ts @@ -1,7 +1,4 @@ -import { - GoogleSyncStatus, - type MailboxSyncModel as MailboxSync, -} from "@crm/db"; +import { GoogleSyncStatus, type MailboxSync } from "@crm/db"; import { Injectable, Logger } from "@nestjs/common"; import type { MailboxResult } from "../mailbox/mailbox-api.client"; import type { MatchContext } from "../mailbox/mailbox-match.service"; diff --git a/apps/api/src/saved-views/saved-views.service.ts b/apps/api/src/saved-views/saved-views.service.ts index afd870507..94e165a37 100644 --- a/apps/api/src/saved-views/saved-views.service.ts +++ b/apps/api/src/saved-views/saved-views.service.ts @@ -1,4 +1,14 @@ -import { type Db, type FieldEntity, Prisma as PrismaNamespace } from "@crm/db"; +import { + and, + asc, + type Db, + eq, + type FieldEntity, + hasValues, + isUniqueViolation, + or, + savedView, +} from "@crm/db"; import { parseSavedViewFilters } from "@crm/validation/saved-view"; import { ConflictException, @@ -17,10 +27,16 @@ export class SavedViewsService { constructor(@InjectDatabase() private readonly db: Db) {} async list(entity: FieldEntity, userId: string): Promise { - const rows = await this.db.savedView.findMany({ - where: { entity, OR: [{ shared: true }, { ownerId: userId }] }, - orderBy: { name: "asc" }, - }); + const rows = await this.db + .select() + .from(savedView) + .where( + and( + eq(savedView.entity, entity), + or(eq(savedView.shared, true), eq(savedView.ownerId, userId)), + ), + ) + .orderBy(asc(savedView.name)); return rows.map((row) => this.serialize(row, userId)); } @@ -30,15 +46,17 @@ export class SavedViewsService { userId: string, ): Promise { try { - const row = await this.db.savedView.create({ - data: { + const [row] = await this.db + .insert(savedView) + .values({ entity: input.entity, name: input.name, shared: input.shared, filters: input.filters, ownerId: userId, - }, - }); + }) + .returning(); + if (!row) throw new Error("The saved view insert returned no row."); return this.serialize(row, userId); } catch (error) { @@ -51,20 +69,28 @@ export class SavedViewsService { data: SavedViewUpdateData, userId: string, ): Promise { - const existing = await this.db.savedView.findUnique({ where: { id } }); + const [existing] = await this.db + .select() + .from(savedView) + .where(eq(savedView.id, id)); if (!existing || existing.ownerId !== userId) { throw new NotFoundException(`No saved view with id ${id}.`); } try { - const row = await this.db.savedView.update({ - where: { id }, - data: { - name: data.name, - shared: data.shared, - filters: data.filters, - }, - }); + const changes = { + name: data.name, + shared: data.shared, + filters: data.filters, + }; + const [row] = hasValues(changes) + ? await this.db + .update(savedView) + .set(changes) + .where(eq(savedView.id, id)) + .returning() + : [existing]; + if (!row) throw new NotFoundException(`No saved view with id ${id}.`); return this.serialize(row, userId); } catch (error) { @@ -73,16 +99,20 @@ export class SavedViewsService { } async delete(id: string, userId: string): Promise<{ id: string }> { - const existing = await this.db.savedView.findUnique({ where: { id } }); + const [existing] = await this.db + .select() + .from(savedView) + .where(eq(savedView.id, id)); if (!existing || existing.ownerId !== userId) { throw new NotFoundException(`No saved view with id ${id}.`); } - try { - await this.db.savedView.delete({ where: { id } }); - } catch (error) { - throw this.translate(error, id); - } + const removed = await this.db + .delete(savedView) + .where(eq(savedView.id, id)) + .returning({ id: savedView.id }); + if (removed.length === 0) + throw new NotFoundException(`No saved view with id ${id}.`); return { id }; } @@ -112,14 +142,9 @@ export class SavedViewsService { }; } - private translate(cause: unknown, id?: string): never { - if (cause instanceof PrismaNamespace.PrismaClientKnownRequestError) { - if (cause.code === "P2002") { - throw new ConflictException("You already have a view with that name."); - } - if (cause.code === "P2025") { - throw new NotFoundException(`No saved view with id ${id}.`); - } + private translate(cause: unknown, _id?: string): never { + if (isUniqueViolation(cause)) { + throw new ConflictException("You already have a view with that name."); } throw cause; } diff --git a/apps/api/src/search/search.service.ts b/apps/api/src/search/search.service.ts index 7a435dca8..efa39c822 100644 --- a/apps/api/src/search/search.service.ts +++ b/apps/api/src/search/search.service.ts @@ -1,4 +1,13 @@ -import type { Db } from "@crm/db"; +import { + asc, + company, + contact, + contains, + type Db, + deal, + eq, + or, +} from "@crm/db"; import { Injectable } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; @@ -24,60 +33,60 @@ export class SearchService { if (term.length < 2) return { hits: [] }; const [companies, contacts, deals] = await Promise.all([ - this.db.company.findMany({ - where: { - OR: [ - { name: { contains: term, mode: "insensitive" } }, - { domain: { contains: term, mode: "insensitive" } }, - ], - }, - take: PER_KIND, - orderBy: { name: "asc" }, - select: { - id: true, - name: true, - domain: true, - iconUrl: true, - iconDarkUrl: true, - iconTone: true, - }, - }), - this.db.contact.findMany({ - where: { - OR: [ - { firstName: { contains: term, mode: "insensitive" } }, - { lastName: { contains: term, mode: "insensitive" } }, - { email: { contains: term, mode: "insensitive" } }, - ], - }, - take: PER_KIND, - orderBy: [{ lastName: "asc" }, { firstName: "asc" }], - select: { - id: true, - firstName: true, - lastName: true, - email: true, - imageUrl: true, - company: { select: { name: true } }, - }, - }), - this.db.deal.findMany({ - where: { name: { contains: term, mode: "insensitive" } }, - take: PER_KIND, - orderBy: [{ stage: "asc" }, { name: "asc" }], - select: { - id: true, - name: true, + this.db + .select({ + id: company.id, + name: company.name, + domain: company.domain, + iconUrl: company.iconUrl, + iconDarkUrl: company.iconDarkUrl, + iconTone: company.iconTone, + }) + .from(company) + .where( + or( + contains(company.name, term, { insensitive: true }), + contains(company.domain, term, { insensitive: true }), + ), + ) + .orderBy(asc(company.name)) + .limit(PER_KIND), + this.db + .select({ + id: contact.id, + firstName: contact.firstName, + lastName: contact.lastName, + email: contact.email, + imageUrl: contact.imageUrl, + company: { name: company.name }, + }) + .from(contact) + .leftJoin(company, eq(company.id, contact.companyId)) + .where( + or( + contains(contact.firstName, term, { insensitive: true }), + contains(contact.lastName, term, { insensitive: true }), + contains(contact.email, term, { insensitive: true }), + ), + ) + .orderBy(asc(contact.lastName), asc(contact.firstName)) + .limit(PER_KIND), + this.db + .select({ + id: deal.id, + name: deal.name, company: { - select: { - name: true, - iconUrl: true, - iconDarkUrl: true, - iconTone: true, - }, + name: company.name, + iconUrl: company.iconUrl, + iconDarkUrl: company.iconDarkUrl, + iconTone: company.iconTone, }, - }, - }), + }) + .from(deal) + .innerJoin(company, eq(company.id, deal.companyId)) + .where(contains(deal.name, term, { insensitive: true })) + .orderBy(asc(deal.stage), asc(deal.name)) + .limit(PER_KIND), ]); return { diff --git a/apps/api/src/settings/settings.service.ts b/apps/api/src/settings/settings.service.ts index 1e8cfe857..2303436fc 100644 --- a/apps/api/src/settings/settings.service.ts +++ b/apps/api/src/settings/settings.service.ts @@ -1,4 +1,4 @@ -import type { Db } from "@crm/db"; +import { appSetting, type Db } from "@crm/db"; import { DEFAULT_AGENT_MODEL, maskKey, @@ -35,7 +35,11 @@ export class SettingsService { async agentModel(): Promise { const [model, row] = await Promise.all([ readAgentModel(this.db), - this.db.appSetting.findFirst({ select: { updatedAt: true } }), + this.db + .select({ updatedAt: appSetting.updatedAt }) + .from(appSetting) + .limit(1) + .then((rows) => rows[0]), ]); return { diff --git a/apps/api/src/slack/slack-connection.service.ts b/apps/api/src/slack/slack-connection.service.ts index c16398007..1a42aaf7e 100644 --- a/apps/api/src/slack/slack-connection.service.ts +++ b/apps/api/src/slack/slack-connection.service.ts @@ -1,6 +1,30 @@ import { isSlackConfigured } from "@crm/auth/env"; import { canManageConnections, WORKSPACE_ID } from "@crm/auth/organization"; -import type { Db, Prisma } from "@crm/db"; +import { + account as accountTable, + agentDefinition, + agentTask, + agentVersion, + and, + asc, + contains, + type Db, + desc, + eq, + gt, + inArray, + isNotNull, + isNull, + lt, + member, + or, + type SQL, + slackChannel, + slackMemberMatch, + slackWorkspaceGrant, + sql, + user, +} from "@crm/db"; import { SLACK_INVENTORY } from "@crm/db/slack-inventory"; import { schemas } from "@crm/validation"; import { @@ -42,37 +66,53 @@ export class SlackConnectionService { async status(userId: string): Promise { const role = await this.access.assertMember(userId); const [account, agents, matches, memberCount, grant] = await Promise.all([ - this.db.account.findFirst({ - where: { providerId: "slack", accessToken: { not: null } }, - orderBy: { updatedAt: "desc" }, - select: { accountId: true, updatedAt: true, scope: true }, - }), - this.db.agentDefinition.findMany({ - where: { - status: { in: ["LIVE", "PAUSED"] }, - deletedAt: null, - currentVersionId: { not: null }, - currentVersion: { - manifest: { - path: ["dataScope", "resources"], - array_contains: [{ id: SLACK_WORKSPACE_RESOURCE_ID }], - }, - }, - }, - orderBy: { updatedAt: "desc" }, - take: 30, - select: { id: true, name: true, description: true, status: true }, - }), - this.db.slackMemberMatch.findMany({ - where: { - crmUser: { members: { some: { organizationId: WORKSPACE_ID } } }, - }, - select: { slackUserId: true, updatedAt: true }, - }), - this.db.member.count({ where: { organizationId: WORKSPACE_ID } }), - this.db.slackWorkspaceGrant.findFirst({ - select: { id: true, teamName: true }, - }), + this.slackAccount(), + this.db + .select({ + id: agentDefinition.id, + name: agentDefinition.name, + description: agentDefinition.description, + status: agentDefinition.status, + }) + .from(agentDefinition) + .innerJoin( + agentVersion, + eq(agentVersion.id, agentDefinition.currentVersionId), + ) + .where( + and( + inArray(agentDefinition.status, ["LIVE", "PAUSED"]), + isNull(agentDefinition.deletedAt), + isNotNull(agentDefinition.currentVersionId), + sql`${agentVersion.manifest} #> '{dataScope,resources}' @> ${JSON.stringify([{ id: SLACK_WORKSPACE_RESOURCE_ID }])}::jsonb`, + ), + ) + .orderBy(desc(agentDefinition.updatedAt)) + .limit(30), + this.db + .select({ + slackUserId: slackMemberMatch.slackUserId, + updatedAt: slackMemberMatch.updatedAt, + }) + .from(slackMemberMatch) + .where( + inArray( + slackMemberMatch.crmUserId, + this.db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, WORKSPACE_ID)), + ), + ), + this.db.$count(member, eq(member.organizationId, WORKSPACE_ID)), + this.db + .select({ + id: slackWorkspaceGrant.id, + teamName: slackWorkspaceGrant.teamName, + }) + .from(slackWorkspaceGrant) + .limit(1) + .then((rows) => rows[0]), ]); const matched = matches.filter((match) => match.slackUserId).length; @@ -106,46 +146,57 @@ export class SlackConnectionService { async matches(userId: string): Promise { await this.access.assertMember(userId); const [members, syncing] = await Promise.all([ - this.db.member.findMany({ - where: { organizationId: WORKSPACE_ID }, - orderBy: { user: { name: "asc" } }, - select: { - user: { - select: { - id: true, - name: true, - email: true, - slackMemberMatch: { - select: { - slackUserId: true, - slackHandle: true, - slackEmail: true, - }, - }, - }, + this.db + .select({ + user: { id: user.id, name: user.name, email: user.email }, + match: { + slackUserId: slackMemberMatch.slackUserId, + slackHandle: slackMemberMatch.slackHandle, + slackEmail: slackMemberMatch.slackEmail, + id: slackMemberMatch.id, }, - }, - }), + }) + .from(member) + .innerJoin(user, eq(user.id, member.userId)) + .leftJoin(slackMemberMatch, eq(slackMemberMatch.crmUserId, user.id)) + .where(eq(member.organizationId, WORKSPACE_ID)) + .orderBy(asc(user.name)), this.peopleSyncState(), ]); return { - rows: members.map(({ user }) => ({ - crmUserId: user.id, - name: user.name, - email: user.email, - match: user.slackMemberMatch, + rows: members.map(({ user: person, match }) => ({ + crmUserId: person.id, + name: person.name, + email: person.email, + match: match + ? { + slackUserId: match.slackUserId, + slackHandle: match.slackHandle, + slackEmail: match.slackEmail, + } + : null, })), sync: syncing, }; } private async peopleSyncState(): Promise { - const pending = await this.db.agentTask.findFirst({ - where: { kind: "slack-people-match", finishedAt: null }, - orderBy: { createdAt: "desc" }, - select: { createdAt: true, startedAt: true, leasedUntil: true }, - }); + const [pending] = await this.db + .select({ + createdAt: agentTask.createdAt, + startedAt: agentTask.startedAt, + leasedUntil: agentTask.leasedUntil, + }) + .from(agentTask) + .where( + and( + eq(agentTask.kind, "slack-people-match"), + isNull(agentTask.finishedAt), + ), + ) + .orderBy(desc(agentTask.createdAt)) + .limit(1); if (!pending) return "idle"; const now = Date.now(); @@ -161,11 +212,7 @@ export class SlackConnectionService { async refreshPeople(userId: string): Promise { await this.access.assertMember(userId); - const account = await this.db.account.findFirst({ - where: { providerId: "slack", accessToken: { not: null } }, - orderBy: { updatedAt: "desc" }, - select: { id: true }, - }); + const account = await this.slackAccount(); if (!account) throw new NotFoundException("Slack is not connected."); await this.agent.slackPeopleRequested( @@ -185,27 +232,34 @@ export class SlackConnectionService { const take = input.limit ?? SLACK.channels.pageSize; const needle = input.query?.trim() ?? ""; - const where: Prisma.SlackChannelWhereInput = { available: true }; - if (needle) where.name = { contains: needle, mode: "insensitive" }; + const where = and( + eq(slackChannel.available, true), + needle + ? contains(slackChannel.name, needle, { insensitive: true }) + : undefined, + input.cursor ? await this.channelsAfter(input.cursor) : undefined, + ); const [rows, grant, sync] = await Promise.all([ - this.db.slackChannel.findMany({ - where, - orderBy: [{ isMember: "desc" }, { name: "asc" }, { id: "asc" }], - take: take + 1, - cursor: input.cursor ? { id: input.cursor } : undefined, - skip: input.cursor ? 1 : undefined, - select: { - id: true, - name: true, - memberCount: true, - isPrivate: true, - isMember: true, - classifiedAt: true, - inviteRequestedAt: true, - }, - }), - this.db.slackWorkspaceGrant.findFirst({ select: { id: true } }), + this.db + .select({ + id: slackChannel.id, + name: slackChannel.name, + memberCount: slackChannel.memberCount, + isPrivate: slackChannel.isPrivate, + isMember: slackChannel.isMember, + classifiedAt: slackChannel.classifiedAt, + inviteRequestedAt: slackChannel.inviteRequestedAt, + }) + .from(slackChannel) + .where(where) + .orderBy( + desc(slackChannel.isMember), + asc(slackChannel.name), + asc(slackChannel.id), + ) + .limit(take + 1), + this.grant(), this.peopleSyncState(), ]); @@ -213,19 +267,19 @@ export class SlackConnectionService { const emptyInventory = sync === "idle" && !needle && page.length === 0; const [account, lastFill] = emptyInventory ? await Promise.all([ - this.db.account.findFirst({ - where: { providerId: "slack", accessToken: { not: null } }, - orderBy: { updatedAt: "desc" }, - select: { updatedAt: true }, - }), - this.db.agentTask.findFirst({ - where: { - kind: SLACK_INVENTORY.kind, - finishedAt: { not: null }, - }, - orderBy: { finishedAt: "desc" }, - select: { finishedAt: true }, - }), + this.slackAccount(), + this.db + .select({ finishedAt: agentTask.finishedAt }) + .from(agentTask) + .where( + and( + eq(agentTask.kind, SLACK_INVENTORY.kind), + isNotNull(agentTask.finishedAt), + ), + ) + .orderBy(desc(agentTask.finishedAt)) + .limit(1) + .then((rows) => rows[0] ?? null), ]) : [null, null]; const refillDue = @@ -258,21 +312,25 @@ export class SlackConnectionService { userId: string, ): Promise { await this.access.assertMember(userId); - const channel = await this.db.slackChannel.findUnique({ - where: { id: input.channelId }, - select: { id: true, name: true, isMember: true, isPrivate: true }, - }); + const [channel] = await this.db + .select({ + id: slackChannel.id, + name: slackChannel.name, + isMember: slackChannel.isMember, + isPrivate: slackChannel.isPrivate, + }) + .from(slackChannel) + .where(eq(slackChannel.id, input.channelId)) + .limit(1); if (!channel) throw new NotFoundException("No such Slack channel."); if (channel.isMember) return { queued: false, alreadyJoined: true }; - const grant = await this.db.slackWorkspaceGrant.findFirst({ - select: { id: true }, - }); + const grant = await this.grant(); if (channel.isPrivate && !grant) { - await this.db.slackChannel.update({ - where: { id: channel.id }, - data: { inviteRequestedAt: new Date() }, - }); + await this.db + .update(slackChannel) + .set({ inviteRequestedAt: new Date() }) + .where(eq(slackChannel.id, channel.id)); return { queued: false, alreadyJoined: false }; } @@ -285,10 +343,11 @@ export class SlackConnectionService { userId: string, ): Promise { await this.access.assertMember(userId); - const existing = await this.db.slackChannel.findFirst({ - where: { name: input.name }, - select: { id: true }, - }); + const [existing] = await this.db + .select({ id: slackChannel.id }) + .from(slackChannel) + .where(eq(slackChannel.name, input.name)) + .limit(1); if (existing) { throw new BadRequestException("A channel with that name already exists."); } @@ -305,17 +364,72 @@ export class SlackConnectionService { ); } - const removed = await this.db.$transaction(async (tx) => { - const accounts = await tx.account.deleteMany({ - where: { providerId: "slack" }, - }); - await tx.slackChannel.deleteMany({}); - await tx.slackWorkspaceGrant.deleteMany({}); - return accounts.count; + const removed = await this.db.transaction(async (tx) => { + const accounts = await tx + .delete(accountTable) + .where(eq(accountTable.providerId, "slack")) + .returning({ id: accountTable.id }); + await tx.delete(slackChannel); + await tx.delete(slackWorkspaceGrant); + return accounts.length; }); if (removed === 0) throw new NotFoundException("Slack is not connected."); return { disconnected: true }; } + + private async slackAccount() { + const [row] = await this.db + .select({ + id: accountTable.id, + accountId: accountTable.accountId, + updatedAt: accountTable.updatedAt, + scope: accountTable.scope, + }) + .from(accountTable) + .where( + and( + eq(accountTable.providerId, "slack"), + isNotNull(accountTable.accessToken), + ), + ) + .orderBy(desc(accountTable.updatedAt)) + .limit(1); + return row ?? null; + } + + private async grant() { + const [row] = await this.db + .select({ id: slackWorkspaceGrant.id }) + .from(slackWorkspaceGrant) + .limit(1); + return row ?? null; + } + + private async channelsAfter(cursorId: string): Promise { + const [cursor] = await this.db + .select({ + id: slackChannel.id, + name: slackChannel.name, + isMember: slackChannel.isMember, + }) + .from(slackChannel) + .where(eq(slackChannel.id, cursorId)) + .limit(1); + if (!cursor) return sql`false`; + + return or( + lt(slackChannel.isMember, cursor.isMember), + and( + eq(slackChannel.isMember, cursor.isMember), + gt(slackChannel.name, cursor.name), + ), + and( + eq(slackChannel.isMember, cursor.isMember), + eq(slackChannel.name, cursor.name), + gt(slackChannel.id, cursor.id), + ), + ); + } } diff --git a/apps/api/src/sso/sso.service.ts b/apps/api/src/sso/sso.service.ts index 1f7c569ff..244fbe385 100644 --- a/apps/api/src/sso/sso.service.ts +++ b/apps/api/src/sso/sso.service.ts @@ -7,7 +7,16 @@ import { ssoCallbackURL, ssoProviderName, } from "@crm/auth/sso"; -import type { Db, Prisma } from "@crm/db"; +import { + and, + asc, + contains, + type Db, + eq, + or, + type SQL, + ssoProvider, +} from "@crm/db"; import { BadRequestException, ForbiddenException, @@ -24,6 +33,7 @@ import { type OrderByColumns, paginate, resolveOrderBy, + sortBy, } from "../trpc/list-input"; import type { DeleteSsoProviderInput, @@ -34,22 +44,26 @@ import type { SsoSettings, } from "./sso.contracts"; -const PROVIDER_SELECT = { - providerId: true, - issuer: true, - domain: true, - oidcConfig: true, - samlConfig: true, -} satisfies Prisma.SsoProviderSelect; - -type ProviderRow = Prisma.SsoProviderGetPayload<{ - select: typeof PROVIDER_SELECT; -}>; - -const SORTABLE: OrderByColumns = { - providerId: (dir) => ({ providerId: dir }), - domain: (dir) => ({ domain: dir }), - issuer: (dir) => ({ issuer: dir }), +const PROVIDER_FIELDS = { + providerId: ssoProvider.providerId, + issuer: ssoProvider.issuer, + domain: ssoProvider.domain, + oidcConfig: ssoProvider.oidcConfig, + samlConfig: ssoProvider.samlConfig, +}; + +type ProviderRow = { + providerId: string; + issuer: string; + domain: string; + oidcConfig: string | null; + samlConfig: string | null; +}; + +const SORTABLE: OrderByColumns = { + providerId: (dir) => sortBy(ssoProvider.providerId, dir), + domain: (dir) => sortBy(ssoProvider.domain, dir), + issuer: (dir) => sortBy(ssoProvider.issuer, dir), }; const STATUS_BY_CODE = new Map([ @@ -115,11 +129,11 @@ export class SsoService { constructor(@InjectDatabase() private readonly db: Db) {} async signInOptions(): Promise { - const rows = await this.db.ssoProvider.findMany({ - where: { organizationId: WORKSPACE_ID }, - select: { providerId: true }, - orderBy: { providerId: "asc" }, - }); + const rows = await this.db + .select({ providerId: ssoProvider.providerId }) + .from(ssoProvider) + .where(eq(ssoProvider.organizationId, WORKSPACE_ID)) + .orderBy(asc(ssoProvider.providerId)); return { google: isGoogleConfigured(), @@ -143,14 +157,14 @@ export class SsoService { const { skip, take } = paginate(input); const [rows, total] = await Promise.all([ - this.db.ssoProvider.findMany({ - where, - skip, - take, - select: PROVIDER_SELECT, - orderBy: resolveOrderBy(input, SORTABLE, { providerId: "asc" }), - }), - this.db.ssoProvider.count({ where }), + this.db + .select(PROVIDER_FIELDS) + .from(ssoProvider) + .where(where) + .orderBy(resolveOrderBy(input, SORTABLE, asc(ssoProvider.providerId))) + .limit(take) + .offset(skip), + this.db.$count(ssoProvider, where), ]); return { rows: rows.map(toProvider), total, facetCounts: {} }; @@ -195,10 +209,16 @@ export class SsoService { issuer: input.issuer, }); - const row = await this.db.ssoProvider.findUniqueOrThrow({ - where: { providerId: input.providerId }, - select: PROVIDER_SELECT, - }); + const [row] = await this.db + .select(PROVIDER_FIELDS) + .from(ssoProvider) + .where(eq(ssoProvider.providerId, input.providerId)) + .limit(1); + if (!row) { + throw new InternalServerErrorException( + "The provider was saved but cannot be read back.", + ); + } return toProvider(row); } @@ -226,21 +246,18 @@ export class SsoService { return { providerId: input.providerId }; } - private searchWhere(q: string): Prisma.SsoProviderWhereInput { + private searchWhere(q: string): SQL | undefined { const term = q.trim(); - const where: Prisma.SsoProviderWhereInput = { - organizationId: WORKSPACE_ID, - }; - - if (term) { - where.OR = [ - { providerId: { contains: term, mode: "insensitive" } }, - { domain: { contains: term, mode: "insensitive" } }, - { issuer: { contains: term, mode: "insensitive" } }, - ]; - } - - return where; + return and( + eq(ssoProvider.organizationId, WORKSPACE_ID), + term + ? or( + contains(ssoProvider.providerId, term, { insensitive: true }), + contains(ssoProvider.domain, term, { insensitive: true }), + contains(ssoProvider.issuer, term, { insensitive: true }), + ) + : undefined, + ); } private async call(run: () => Promise): Promise { diff --git a/apps/api/src/telemetry/funnel.service.ts b/apps/api/src/telemetry/funnel.service.ts index c0f795e9f..349bc137c 100644 --- a/apps/api/src/telemetry/funnel.service.ts +++ b/apps/api/src/telemetry/funnel.service.ts @@ -1,6 +1,23 @@ import { GOOGLE_PROVIDER_ID } from "@crm/auth/scopes"; -import type { Db } from "@crm/db"; -import { FactStatus } from "@crm/db"; +import { + account, + agentTask, + and, + asc, + contact, + contactFact, + type Db, + eq, + FactStatus, + inArray, + isNotNull, + isNull, + mailboxSync, + not, + or, + session, + startsWith, +} from "@crm/db"; import { MILESTONES, type Milestone, @@ -49,11 +66,11 @@ export class FunnelService { case "first_sign_in": return this.earliest( - this.db.session.findFirst({ - orderBy: { createdAt: "asc" }, - select: { createdAt: true }, - }), - (row) => row.createdAt, + this.db + .select({ at: session.createdAt }) + .from(session) + .orderBy(asc(session.createdAt)) + .limit(1), ); case "google_oauth_configured": { @@ -64,12 +81,12 @@ export class FunnelService { if (!configured) return null; const linked = await this.earliest( - this.db.account.findFirst({ - where: { providerId: GOOGLE_PROVIDER_ID }, - orderBy: { createdAt: "asc" }, - select: { createdAt: true }, - }), - (row) => row.createdAt, + this.db + .select({ at: account.createdAt }) + .from(account) + .where(eq(account.providerId, GOOGLE_PROVIDER_ID)) + .orderBy(asc(account.createdAt)) + .limit(1), ); return linked ?? new Date(); @@ -77,47 +94,47 @@ export class FunnelService { case "first_mailbox_sync": return this.earliest( - this.db.mailboxSync.findFirst({ - where: { lastSyncedAt: { not: null } }, - orderBy: { lastSyncedAt: "asc" }, - select: { lastSyncedAt: true }, - }), - (row) => row.lastSyncedAt, + this.db + .select({ at: mailboxSync.lastSyncedAt }) + .from(mailboxSync) + .where(isNotNull(mailboxSync.lastSyncedAt)) + .orderBy(asc(mailboxSync.lastSyncedAt)) + .limit(1), ); case "first_non_seed_contact": return this.earliest( - this.db.contact.findFirst({ - where: { - OR: [ - { ownerId: null }, - { ownerId: { not: { startsWith: SEED_OWNER_PREFIX } } }, - ], - }, - orderBy: { createdAt: "asc" }, - select: { createdAt: true }, - }), - (row) => row.createdAt, + this.db + .select({ at: contact.createdAt }) + .from(contact) + .where( + or( + isNull(contact.ownerId), + not(startsWith(contact.ownerId, SEED_OWNER_PREFIX)), + ), + ) + .orderBy(asc(contact.createdAt)) + .limit(1), ); case "first_agent_task_claimed": return this.earliest( - this.db.agentTask.findFirst({ - where: { startedAt: { not: null } }, - orderBy: { startedAt: "asc" }, - select: { startedAt: true }, - }), - (row) => row.startedAt, + this.db + .select({ at: agentTask.startedAt }) + .from(agentTask) + .where(isNotNull(agentTask.startedAt)) + .orderBy(asc(agentTask.startedAt)) + .limit(1), ); case "first_agent_task_completed": return this.earliest( - this.db.agentTask.findFirst({ - where: { finishedAt: { not: null } }, - orderBy: { finishedAt: "asc" }, - select: { finishedAt: true }, - }), - (row) => row.finishedAt, + this.db + .select({ at: agentTask.finishedAt }) + .from(agentTask) + .where(isNotNull(agentTask.finishedAt)) + .orderBy(asc(agentTask.finishedAt)) + .limit(1), ); case "first_fact_applied": @@ -126,21 +143,24 @@ export class FunnelService { } private async firstApplied(): Promise { - const everApplied = { - status: { in: [FactStatus.APPLIED, FactStatus.SUPERSEDED] }, - }; - - const [decided, undecided] = await Promise.all([ - this.db.contactFact.findFirst({ - where: { ...everApplied, decidedAt: { not: null } }, - orderBy: { decidedAt: "asc" }, - select: { decidedAt: true }, - }), - this.db.contactFact.findFirst({ - where: { ...everApplied, decidedAt: null }, - orderBy: { observedAt: "asc" }, - select: { observedAt: true }, - }), + const everApplied = inArray(contactFact.status, [ + FactStatus.APPLIED, + FactStatus.SUPERSEDED, + ]); + + const [[decided], [undecided]] = await Promise.all([ + this.db + .select({ decidedAt: contactFact.decidedAt }) + .from(contactFact) + .where(and(everApplied, isNotNull(contactFact.decidedAt))) + .orderBy(asc(contactFact.decidedAt)) + .limit(1), + this.db + .select({ observedAt: contactFact.observedAt }) + .from(contactFact) + .where(and(everApplied, isNull(contactFact.decidedAt))) + .orderBy(asc(contactFact.observedAt)) + .limit(1), ]); const applied = [decided?.decidedAt, undecided?.observedAt].filter( @@ -152,11 +172,10 @@ export class FunnelService { return new Date(Math.min(...applied.map((at) => at.getTime()))); } - private async earliest( - query: PromiseLike, - pick: (row: T) => Date | null, + private async earliest( + query: PromiseLike<{ at: Date | null }[]>, ): Promise { - const row = await query; - return row ? pick(row) : null; + const [row] = await query; + return row?.at ?? null; } } diff --git a/apps/api/src/telemetry/rollup.service.ts b/apps/api/src/telemetry/rollup.service.ts index c53b77a43..469cc26ce 100644 --- a/apps/api/src/telemetry/rollup.service.ts +++ b/apps/api/src/telemetry/rollup.service.ts @@ -1,11 +1,44 @@ import { ActivityType, + activity, + agentConversation, + agentTask, + and, + appSetting, + avg, + company, + contact, + contactFact, + count, type Db, DealStage, + deal, EnrichmentStatus, + emailMessage, + emailThread, + eq, FactBand, FactStatus, + formSubmission, + gte, + isNotNull, + isNull, + mailboxSync, + max as maxOf, + member, + not, + or, + type PgColumn, + type PgTable, RecordSource, + sql, + ssoProvider, + startsWith, + suppressedContact, + suppressedDomain, + trackedDomain, + trackedEvent, + workspaceProfile as workspaceProfileTable, } from "@crm/db"; import { RETIRED_OUTCOME } from "@crm/db/agent-tasks"; import { readAgentModel } from "@crm/db/settings"; @@ -28,6 +61,7 @@ import { } from "@crm/telemetry"; import { Injectable, Logger } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; +import { groupCounts } from "../trpc/list-input"; import { FunnelService } from "./funnel.service"; import { SEED_OWNER_PREFIX } from "./seed"; @@ -137,10 +171,14 @@ export class RollupService { const [model, members, ssoProviders, postgres, contextKey] = await Promise.all([ readAgentModel(this.db).catch(() => null), - this.db.member.count({ where: { organizationId: WORKSPACE_ID } }), - this.db.ssoProvider.count(), + this.db.$count(member, eq(member.organizationId, WORKSPACE_ID)), + this.db.$count(ssoProvider), this.postgresMajor(), - this.db.appSetting.findFirst({ select: { contextDevApiKey: true } }), + this.db + .select({ contextDevApiKey: appSetting.contextDevApiKey }) + .from(appSetting) + .limit(1) + .then((rows) => rows[0]), ]); return { @@ -168,11 +206,11 @@ export class RollupService { private async postgresMajor(): Promise { try { - const rows = await this.db.$queryRaw<{ version: string }[]>` + const result = await this.db.execute<{ version: string }>(sql` SELECT current_setting('server_version_num') AS version; - `; + `); - const raw = Number(rows[0]?.version); + const raw = Number(result.rows[0]?.version); if (!Number.isFinite(raw)) return null; return String(Math.floor(raw / 10_000)); @@ -192,7 +230,7 @@ export class RollupService { this.tasks(since), this.attempts(since), this.rechecks(since), - this.db.agentConversation.count(), + this.db.$count(agentConversation), ]); const total = Object.values(tools.calls).reduce((sum, n) => sum + n, 0); @@ -230,9 +268,11 @@ export class RollupService { errors: Record; sandbox: boolean; }> { - const rows = await this.db.$queryRaw< - { tool: string | null; failed: boolean; count: bigint }[] - >` + const { rows } = await this.db.execute<{ + tool: string | null; + failed: boolean; + count: string; + }>(sql` SELECT "data"->'result'->>'toolName' AS tool, COALESCE("data"->>'status', 'completed') <> 'completed' AS failed, @@ -240,7 +280,7 @@ export class RollupService { FROM "agentEvent" WHERE "type" = 'action.result' AND "emittedAt" >= ${since} GROUP BY 1, 2; - `; + `); const calls: Record = {}; const errors: Record = {}; @@ -264,13 +304,16 @@ export class RollupService { failed: number; withTools: number; }> { - const rows = await this.db.$queryRaw<{ type: string; sessions: bigint }[]>` + const { rows } = await this.db.execute<{ + type: string; + sessions: string; + }>(sql` SELECT "type", COUNT(DISTINCT "sessionId") AS sessions FROM "agentEvent" WHERE "emittedAt" >= ${since} AND "type" IN ('session.started', 'session.waiting', 'session.failed', 'action.result') GROUP BY 1; - `; + `); const of = (type: string) => Number(rows.find((row) => row.type === type)?.sessions ?? 0); @@ -289,16 +332,20 @@ export class RollupService { retired: Record; }> { const [claimed, finished] = await Promise.all([ - this.db.agentTask.groupBy({ - by: ["kind"], - where: { startedAt: { gte: since } }, - _count: { _all: true }, - }), - this.db.agentTask.groupBy({ - by: ["kind", "outcome"], - where: { finishedAt: { gte: since } }, - _count: { _all: true }, - }), + this.db + .select({ kind: agentTask.kind, count: count() }) + .from(agentTask) + .where(gte(agentTask.startedAt, since)) + .groupBy(agentTask.kind), + this.db + .select({ + kind: agentTask.kind, + outcome: agentTask.outcome, + count: count(), + }) + .from(agentTask) + .where(gte(agentTask.finishedAt, since)) + .groupBy(agentTask.kind, agentTask.outcome), ]); const completed: Record = {}; @@ -307,12 +354,12 @@ export class RollupService { for (const row of finished) { const kind = permittedTaskKind(row.kind); const into = row.outcome === RETIRED_OUTCOME ? retired : completed; - into[kind] = (into[kind] ?? 0) + row._count._all; + into[kind] = (into[kind] ?? 0) + row.count; } return { claimed: byKind( - claimed.map((row) => ({ key: row.kind, count: row._count._all })), + claimed.map((row) => ({ key: row.kind, count: row.count })), ), completed, retired, @@ -322,20 +369,23 @@ export class RollupService { private async attempts( since: Date, ): Promise<{ mean: Record; max: Record }> { - const rows = await this.db.agentTask.groupBy({ - by: ["kind"], - where: { finishedAt: { gte: since } }, - _avg: { attempts: true }, - _max: { attempts: true }, - }); + const rows = await this.db + .select({ + kind: agentTask.kind, + mean: avg(agentTask.attempts).mapWith(Number), + max: maxOf(agentTask.attempts), + }) + .from(agentTask) + .where(gte(agentTask.finishedAt, since)) + .groupBy(agentTask.kind); const mean: Record = {}; const max: Record = {}; for (const row of rows) { const kind = permittedTaskKind(row.kind); - mean[kind] = round(row._avg.attempts ?? 0); - max[kind] = row._max.attempts ?? 0; + mean[kind] = round(row.mean ?? 0); + max[kind] = row.max ?? 0; } return { mean, max }; @@ -344,10 +394,12 @@ export class RollupService { private async rechecks( since: Date, ): Promise<{ total: number; buckets: Record }> { - const rows = await this.db.agentTask.findMany({ - where: { kind: "recheck", createdAt: { gte: since } }, - select: { createdAt: true, dueAt: true }, - }); + const rows = await this.db + .select({ createdAt: agentTask.createdAt, dueAt: agentTask.dueAt }) + .from(agentTask) + .where( + and(eq(agentTask.kind, "recheck"), gte(agentTask.createdAt, since)), + ); const buckets: Record = {}; @@ -366,24 +418,15 @@ export class RollupService { private async ledger(): Promise { const [byStatus, byBand, methods, kinds, decided, superseded] = await Promise.all([ - this.db.contactFact.groupBy({ - by: ["status"], - _count: { _all: true }, - }), - this.db.contactFact.groupBy({ by: ["band"], _count: { _all: true } }), - this.db.contactFact.groupBy({ - by: ["method"], - _count: { _all: true }, - }), + this.counted(contactFact, contactFact.status), + this.counted(contactFact, contactFact.band), + this.counted(contactFact, contactFact.method), this.evidenceKinds(), this.decisionHours(), this.supersededWithin(SUPERSEDE_WINDOW_DAYS), ]); - const statuses = countsOf( - byStatus.map((row) => ({ key: row.status, count: row._count._all })), - Object.values(FactStatus), - ); + const statuses = countsOf(byStatus, Object.values(FactStatus)); const dismissed = statuses[FactStatus.DISMISSED] ?? 0; const proposed = statuses[FactStatus.PROPOSED] ?? 0; @@ -391,14 +434,11 @@ export class RollupService { return { facts_by_status: statuses, - facts_by_band: countsOf( - byBand.map((row) => ({ key: row.band, count: row._count._all })), - Object.values(FactBand), - ), + facts_by_band: countsOf(byBand, Object.values(FactBand)), facts_by_method: merge( methods.map((row) => ({ - key: permittedMethod(row.method), - count: row._count._all, + key: permittedMethod(row.key), + count: row.count, })), ), facts_by_evidence_kind: kinds, @@ -409,12 +449,12 @@ export class RollupService { } private async evidenceKinds(): Promise> { - const rows = await this.db.$queryRaw<{ kind: string; count: bigint }[]>` + const { rows } = await this.db.execute<{ kind: string; count: string }>(sql` SELECT item->>'kind' AS kind, COUNT(*) AS count FROM "contactFact", jsonb_array_elements("evidence") AS item WHERE jsonb_typeof("evidence") = 'array' GROUP BY 1; - `; + `); return merge( rows.map((row) => ({ @@ -425,29 +465,38 @@ export class RollupService { } private async decisionHours(): Promise { - const rows = await this.db.$queryRaw<{ median: number | null }[]>` + const { rows } = await this.db.execute<{ + median: number | string | null; + }>(sql` SELECT PERCENTILE_CONT(0.5) WITHIN GROUP ( ORDER BY EXTRACT(EPOCH FROM ("decidedAt" - "observedAt")) / 3600 ) AS median FROM "contactFact" WHERE "decidedAt" IS NOT NULL; - `; + `); const median = rows[0]?.median; - return median === null || median === undefined ? null : round(median); + return median === null || median === undefined + ? null + : round(Number(median)); } private async supersededWithin(days: number): Promise { - const rows = await this.db.$queryRaw<{ count: bigint }[]>` + const { rows } = await this.db.execute<{ count: string }>(sql` SELECT COUNT(*) AS count FROM "contactFact" WHERE "supersededAt" IS NOT NULL AND "supersededAt" - "observedAt" < make_interval(days => ${days}); - `; + `); return Number(rows[0]?.count ?? 0); } + private async counted(table: PgTable, column: PgColumn): Promise { + const rows = await groupCounts(this.db, table, column, undefined); + return rows.map((row) => ({ key: row.value ?? "", count: row.count })); + } + private async crm(since: Date): Promise { const [ contacts, @@ -467,32 +516,28 @@ export class RollupService { workspaceProfile, nonSeedContacts, ] = await Promise.all([ - this.db.contact.count(), - this.db.company.count(), - this.db.deal.count(), - this.db.activity.count(), - this.db.contact.groupBy({ by: ["source"], _count: { _all: true } }), - this.db.company.groupBy({ by: ["source"], _count: { _all: true } }), - this.db.deal.groupBy({ by: ["stage"], _count: { _all: true } }), - this.db.activity.groupBy({ by: ["type"], _count: { _all: true } }), - this.db.mailboxSync.groupBy({ by: ["status"], _count: { _all: true } }), - this.db.emailThread.count({ where: { createdAt: { gte: since } } }), - this.db.emailMessage.count({ where: { createdAt: { gte: since } } }), - this.db.company.groupBy({ - by: ["enrichmentStatus"], - _count: { _all: true }, - }), - this.db.suppressedDomain.count(), - this.db.suppressedContact.count(), - this.db.workspaceProfile.count(), - this.db.contact.count({ - where: { - OR: [ - { ownerId: null }, - { ownerId: { not: { startsWith: SEED_OWNER_PREFIX } } }, - ], - }, - }), + this.db.$count(contact), + this.db.$count(company), + this.db.$count(deal), + this.db.$count(activity), + this.counted(contact, contact.source), + this.counted(company, company.source), + this.counted(deal, deal.stage), + this.counted(activity, activity.type), + this.counted(mailboxSync, mailboxSync.status), + this.db.$count(emailThread, gte(emailThread.createdAt, since)), + this.db.$count(emailMessage, gte(emailMessage.createdAt, since)), + this.counted(company, company.enrichmentStatus), + this.db.$count(suppressedDomain), + this.db.$count(suppressedContact), + this.db.$count(workspaceProfileTable), + this.db.$count( + contact, + or( + isNull(contact.ownerId), + not(startsWith(contact.ownerId, SEED_OWNER_PREFIX)), + ), + ), ]); const [ @@ -504,22 +549,34 @@ export class RollupService { trackingCapped, trackingPaused, ] = await Promise.all([ - this.db.appSetting.count({ where: { trackingSiteId: { not: null } } }), - this.db.trackedDomain.count(), - this.db.trackedEvent.count({ - where: { type: "page_view", occurredAt: { gte: since } }, - }), - this.db.formSubmission.count({ where: { createdAt: { gte: since } } }), - this.db.contact.count({ - where: { createdAt: { gte: since }, source: RecordSource.TRACKING }, - }), - this.db.formSubmission.count({ - where: { createdAt: { gte: since }, skipReason: CONTACT_CAP_REASON }, - }), - this.db.appSetting.count({ where: { trackingPaused: true } }), + this.db.$count(appSetting, isNotNull(appSetting.trackingSiteId)), + this.db.$count(trackedDomain), + this.db.$count( + trackedEvent, + and( + eq(trackedEvent.type, "page_view"), + gte(trackedEvent.occurredAt, since), + ), + ), + this.db.$count(formSubmission, gte(formSubmission.createdAt, since)), + this.db.$count( + contact, + and( + gte(contact.createdAt, since), + eq(contact.source, RecordSource.TRACKING), + ), + ), + this.db.$count( + formSubmission, + and( + gte(formSubmission.createdAt, since), + eq(formSubmission.skipReason, CONTACT_CAP_REASON), + ), + ), + this.db.$count(appSetting, eq(appSetting.trackingPaused, true)), ]); - const configured = syncs.reduce((sum, row) => sum + row._count._all, 0); + const configured = syncs.reduce((sum, row) => sum + row.count, 0); return { seed_only: contacts > 0 && nonSeedContacts === 0, @@ -529,28 +586,13 @@ export class RollupService { deals_bucket: bucket(deals), activities_bucket: bucket(activities), - contacts_by_source: countsOf( - contactSources.map((row) => ({ - key: row.source, - count: row._count._all, - })), - Object.values(RecordSource), - ), + contacts_by_source: countsOf(contactSources, Object.values(RecordSource)), companies_by_source: countsOf( - companySources.map((row) => ({ - key: row.source, - count: row._count._all, - })), + companySources, Object.values(RecordSource), ), - deals_by_stage: countsOf( - stages.map((row) => ({ key: row.stage, count: row._count._all })), - Object.values(DealStage), - ), - activities_by_type: countsOf( - types.map((row) => ({ key: row.type, count: row._count._all })), - Object.values(ActivityType), - ), + deals_by_stage: countsOf(stages, Object.values(DealStage)), + activities_by_type: countsOf(types, Object.values(ActivityType)), cap_tracking: trackingSite > 0, tracking_domains: bucket(trackingDomains), @@ -561,17 +603,12 @@ export class RollupService { tracking_paused: trackingPaused > 0, mailbox_sync_configured: configured > 0, - mailbox_sync_status: merge( - syncs.map((row) => ({ key: row.status, count: row._count._all })), - ), + mailbox_sync_status: merge(syncs), threads_ingested: threads, messages_ingested: messages, enrichment_by_status: countsOf( - enrichment.map((row) => ({ - key: row.enrichmentStatus, - count: row._count._all, - })), + enrichment, Object.values(EnrichmentStatus), ), diff --git a/apps/api/src/tracking/tracking-config.service.ts b/apps/api/src/tracking/tracking-config.service.ts index b6db8efbd..4fad7b3c3 100644 --- a/apps/api/src/tracking/tracking-config.service.ts +++ b/apps/api/src/tracking/tracking-config.service.ts @@ -1,4 +1,4 @@ -import type { Db } from "@crm/db"; +import { appSetting, type Db, eq } from "@crm/db"; import { SETTINGS_ID } from "@crm/db/settings"; import { configHash, @@ -49,10 +49,11 @@ export class TrackingConfigService { } private async current(hash: string): Promise { - const row = await this.db.appSetting.findUnique({ - where: { id: SETTINGS_ID }, - select: { trackingConfigHash: true }, - }); + const [row] = await this.db + .select({ trackingConfigHash: appSetting.trackingConfigHash }) + .from(appSetting) + .where(eq(appSetting.id, SETTINGS_ID)) + .limit(1); return row?.trackingConfigHash === hash; } @@ -71,20 +72,20 @@ export class TrackingConfigService { const config = await readTrackingConfig(this.db); if (!config) { - await this.db.appSetting.updateMany({ - where: { id: SETTINGS_ID }, - data: { trackingConfigHash: null }, - }); + await this.db + .update(appSetting) + .set({ trackingConfigHash: null }) + .where(eq(appSetting.id, SETTINGS_ID)); return; } const hash = configHash(config); - await this.db.appSetting.update({ - where: { id: SETTINGS_ID }, - data: { trackingConfigHash: hash }, - }); + await this.db + .update(appSetting) + .set({ trackingConfigHash: hash }) + .where(eq(appSetting.id, SETTINGS_ID)); if (written !== this.generation) return; if (!(await this.current(hash))) return; @@ -93,20 +94,20 @@ export class TrackingConfigService { } async ensureSiteId(): Promise { - const existing = await this.db.appSetting.findUnique({ - where: { id: SETTINGS_ID }, - select: { trackingSiteId: true }, - }); + const [existing] = await this.db + .select({ trackingSiteId: appSetting.trackingSiteId }) + .from(appSetting) + .where(eq(appSetting.id, SETTINGS_ID)) + .limit(1); if (existing?.trackingSiteId) return existing.trackingSiteId; const trackingSiteId = mintSiteId(); - await this.db.appSetting.upsert({ - where: { id: SETTINGS_ID }, - create: { id: SETTINGS_ID, trackingSiteId }, - update: { trackingSiteId }, - }); + await this.db + .insert(appSetting) + .values({ id: SETTINGS_ID, trackingSiteId }) + .onConflictDoUpdate({ target: appSetting.id, set: { trackingSiteId } }); await this.invalidate(); @@ -118,11 +119,10 @@ export class TrackingConfigService { async rotateSiteId(): Promise { const trackingSiteId = mintSiteId(); - await this.db.appSetting.upsert({ - where: { id: SETTINGS_ID }, - create: { id: SETTINGS_ID, trackingSiteId }, - update: { trackingSiteId }, - }); + await this.db + .insert(appSetting) + .values({ id: SETTINGS_ID, trackingSiteId }) + .onConflictDoUpdate({ target: appSetting.id, set: { trackingSiteId } }); await this.invalidate(); diff --git a/apps/api/src/tracking/tracking-counter.service.ts b/apps/api/src/tracking/tracking-counter.service.ts index 95006936d..9460b48e1 100644 --- a/apps/api/src/tracking/tracking-counter.service.ts +++ b/apps/api/src/tracking/tracking-counter.service.ts @@ -1,4 +1,4 @@ -import type { Db } from "@crm/db"; +import { type Db, lt, sql, trackingCounter } from "@crm/db"; import { windowExpiry } from "@crm/db/tracking"; import { Injectable, Logger } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; @@ -14,16 +14,16 @@ export class TrackingCounterService { if (amount > limit) return false; try { - const charged = await this.db.$queryRaw<{ value: number }[]>` + const charged = await this.db.execute<{ value: number }>(sql` INSERT INTO "trackingCounter" ("key", "value", "expiresAt") VALUES (${key}, ${amount}, ${windowExpiry(key)}) ON CONFLICT ("key") DO UPDATE SET "value" = "trackingCounter"."value" + ${amount} WHERE "trackingCounter"."value" + ${amount} <= ${limit} RETURNING "value"; - `; + `); - return charged.length > 0; + return charged.rows.length > 0; } catch (error) { this.logger.error( { message: "Tracking counter could not be read — refusing the write" }, @@ -36,11 +36,11 @@ export class TrackingCounterService { async release(key: string, amount = 1): Promise { try { - await this.db.$executeRaw` + await this.db.execute(sql` UPDATE "trackingCounter" SET "value" = GREATEST("value" - ${amount}, 0) WHERE "key" = ${key}; - `; + `); } catch (error) { this.logger.error( { message: "Tracking counter could not be released" }, @@ -50,10 +50,11 @@ export class TrackingCounterService { } async sweep(): Promise { - const removed = await this.db.trackingCounter.deleteMany({ - where: { expiresAt: { lt: new Date() } }, - }); + const removed = await this.db + .delete(trackingCounter) + .where(lt(trackingCounter.expiresAt, new Date())) + .returning({ key: trackingCounter.key }); - return removed.count; + return removed.length; } } diff --git a/apps/api/src/tracking/tracking-filing.service.ts b/apps/api/src/tracking/tracking-filing.service.ts index bc79a3dfe..08603b38f 100644 --- a/apps/api/src/tracking/tracking-filing.service.ts +++ b/apps/api/src/tracking/tracking-filing.service.ts @@ -1,5 +1,21 @@ import { workspaceDomains } from "@crm/auth/workspace"; -import { ActivityType, type Db, Prisma, RecordSource } from "@crm/db"; +import { + ActivityType, + activity as activityTable, + and, + contact as contactTable, + type Db, + eq, + equalsInsensitive, + formSubmission, + isNull, + isUniqueViolation, + RecordSource, + suppressedContact, + suppressedDomain, + trackedVisitor, + user, +} from "@crm/db"; import type { Touch } from "@crm/db/attribution"; import { CONTACT_CAP_REASON, @@ -86,10 +102,7 @@ export class TrackingFilingService { const suppressed = await this.suppressed(email, domain); if (suppressed) return this.skip(submission.id, suppressed); - const existing = await this.db.contact.findFirst({ - where: { email, archivedAt: null }, - select: { id: true }, - }); + const existing = await this.activeContact(email); if (existing) { await this.attach(submission.id, existing.id, submission); @@ -107,17 +120,19 @@ export class TrackingFilingService { let contact: { id: string }; try { - contact = await this.db.contact.create({ - data: { + const [inserted] = await this.db + .insert(contactTable) + .values({ firstName, lastName, email, companyId, source: RecordSource.TRACKING, lastActivityAt: new Date(), - }, - select: { id: true }, - }); + }) + .returning({ id: contactTable.id }); + if (!inserted) throw new Error("The contact insert returned no row."); + contact = inserted; } catch (error) { await this.counters.release(window); @@ -149,17 +164,20 @@ export class TrackingFilingService { cause: unknown, email: string, ): Promise<{ id: string } | null> { - if ( - !(cause instanceof Prisma.PrismaClientKnownRequestError) || - cause.code !== "P2002" - ) { - return null; - } + if (!isUniqueViolation(cause)) return null; - return this.db.contact.findFirst({ - where: { email, archivedAt: null }, - select: { id: true }, - }); + return this.activeContact(email); + } + + private async activeContact(email: string): Promise<{ id: string } | null> { + const [row] = await this.db + .select({ id: contactTable.id }) + .from(contactTable) + .where( + and(eq(contactTable.email, email), isNull(contactTable.archivedAt)), + ) + .limit(1); + return row ?? null; } private async attach( @@ -172,23 +190,25 @@ export class TrackingFilingService { }, ): Promise { const { visitorId } = context; - const claimed = await this.db.formSubmission.updateMany({ - where: { id: submissionId, filedAt: null }, - data: { contactId, filedAt: new Date(), skipReason: null }, - }); - - if (claimed.count === 0) return; - - const submission = await this.db.formSubmission.findUniqueOrThrow({ - where: { id: submissionId }, - select: { host: true, path: true }, - }); + const [submission] = await this.db + .update(formSubmission) + .set({ contactId, filedAt: new Date(), skipReason: null }) + .where( + and( + eq(formSubmission.id, submissionId), + isNull(formSubmission.filedAt), + ), + ) + .returning({ host: formSubmission.host, path: formSubmission.path }); + + if (!submission) return; const author = await this.author(contactId); if (author) { - const activity = await this.db.activity.create({ - data: { + const [activity] = await this.db + .insert(activityTable) + .values({ type: ActivityType.NOTE, subject: `Submitted a form on ${submission.host}`, body: `${submission.host}${submission.path}`, @@ -196,11 +216,10 @@ export class TrackingFilingService { occurredAt: new Date(), createdById: author, meta: { automated: true, source: "tracking" }, - }, - select: { createdAt: true }, - }); + }) + .returning({ createdAt: activityTable.createdAt }); - await this.stamp.touch({ contactId }, activity.createdAt); + if (activity) await this.stamp.touch({ contactId }, activity.createdAt); } if (!visitorId) return; @@ -208,22 +227,25 @@ export class TrackingFilingService { const first = columns(context.firstTouch, "first"); const last = columns(context.lastTouch, "last"); - await this.db.trackedVisitor.upsert({ - where: { id: visitorId }, - create: { id: visitorId, contactId, ...first, ...last }, - update: { contactId, ...last }, - }); + await this.db + .insert(trackedVisitor) + .values({ id: visitorId, contactId, ...first, ...last }) + .onConflictDoUpdate({ + target: trackedVisitor.id, + set: { contactId, ...last }, + }); } private async author(contactId: string): Promise { - const contact = await this.db.contact.findUnique({ - where: { id: contactId }, - select: { ownerId: true }, - }); + const [contact] = await this.db + .select({ ownerId: contactTable.ownerId }) + .from(contactTable) + .where(eq(contactTable.id, contactId)) + .limit(1); if (contact?.ownerId) return contact.ownerId; - const anyUser = await this.db.user.findFirst({ select: { id: true } }); + const [anyUser] = await this.db.select({ id: user.id }).from(user).limit(1); return anyUser?.id ?? null; } @@ -232,10 +254,10 @@ export class TrackingFilingService { submissionId: string, reason: string, ): Promise { - await this.db.formSubmission.update({ - where: { id: submissionId }, - data: { skipReason: reason }, - }); + await this.db + .update(formSubmission) + .set({ skipReason: reason }) + .where(eq(formSubmission.id, submissionId)); return { filed: false, reason }; } @@ -245,14 +267,18 @@ export class TrackingFilingService { domain: string, ): Promise { const [contact, host] = await Promise.all([ - this.db.suppressedContact.findFirst({ - where: { email: { equals: email, mode: "insensitive" } }, - select: { email: true }, - }), - this.db.suppressedDomain.findUnique({ - where: { domain }, - select: { domain: true }, - }), + this.db + .select({ email: suppressedContact.email }) + .from(suppressedContact) + .where(equalsInsensitive(suppressedContact.email, email)) + .limit(1) + .then((rows) => rows[0]), + this.db + .select({ domain: suppressedDomain.domain }) + .from(suppressedDomain) + .where(eq(suppressedDomain.domain, domain)) + .limit(1) + .then((rows) => rows[0]), ]); if (contact) return "This address was deleted by a rep"; diff --git a/apps/api/src/tracking/tracking-ingest.service.ts b/apps/api/src/tracking/tracking-ingest.service.ts index 64df58d6f..37a3b0600 100644 --- a/apps/api/src/tracking/tracking-ingest.service.ts +++ b/apps/api/src/tracking/tracking-ingest.service.ts @@ -1,4 +1,11 @@ -import type { Db } from "@crm/db"; +import { + type Db, + eq, + formSubmission, + sql, + trackedDomain, + trackedEvent, +} from "@crm/db"; import { classifyTouch, type RawTouch, type Touch } from "@crm/db/attribution"; import { dedupeKey, @@ -156,7 +163,7 @@ export class TrackingIngestService { }; }); - await this.db.trackedEvent.createMany({ data: rows }); + if (rows.length > 0) await this.db.insert(trackedEvent).values(rows); await this.countViews(rows, config); } @@ -179,10 +186,13 @@ export class TrackingIngestService { await Promise.all( [...tallies].map(([host, views]) => - this.db.trackedDomain.updateMany({ - where: { host }, - data: { pageViews: { increment: views }, lastSeenAt }, - }), + this.db + .update(trackedDomain) + .set({ + pageViews: sql`${trackedDomain.pageViews} + ${views}`, + lastSeenAt, + }) + .where(eq(trackedDomain.host, host)), ), ); } @@ -203,29 +213,33 @@ export class TrackingIngestService { const key = dedupeKey({ host, path, email, at }); - const created = await this.db.formSubmission.createMany({ - data: [ - { - visitorId, - host, - path, - email, - fields, - firstTouch: stored(firstTouch), - lastTouch: stored(lastTouch), - dedupeKey: key, - }, - ], - skipDuplicates: true, - }); - - const submission = await this.db.formSubmission.findUnique({ - where: { dedupeKey: key }, - select: { id: true, filedAt: true, skipReason: true }, - }); + const created = await this.db + .insert(formSubmission) + .values({ + visitorId, + host, + path, + email, + fields, + firstTouch: stored(firstTouch), + lastTouch: stored(lastTouch), + dedupeKey: key, + }) + .onConflictDoNothing() + .returning({ id: formSubmission.id }); + + const [submission] = await this.db + .select({ + id: formSubmission.id, + filedAt: formSubmission.filedAt, + skipReason: formSubmission.skipReason, + }) + .from(formSubmission) + .where(eq(formSubmission.dedupeKey, key)) + .limit(1); if (!submission) return; - if (created.count === 0 && !unfiled(submission)) return; + if (created.length === 0 && !unfiled(submission)) return; const outcome = await this.filing.file({ id: submission.id, diff --git a/apps/api/src/tracking/tracking-rollup.service.ts b/apps/api/src/tracking/tracking-rollup.service.ts index c30e2413a..552754146 100644 --- a/apps/api/src/tracking/tracking-rollup.service.ts +++ b/apps/api/src/tracking/tracking-rollup.service.ts @@ -1,4 +1,4 @@ -import type { Db } from "@crm/db"; +import { type Db, sql } from "@crm/db"; import { Injectable } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; @@ -7,7 +7,7 @@ export class TrackingRollupService { constructor(@InjectDatabase() private readonly db: Db) {} async run(before: Date): Promise { - const rolled = await this.db.$executeRaw` + const rolled = await this.db.execute(sql` INSERT INTO "trackedPageDaily" ("day", "host", "path", "views", "visitors") SELECT date_trunc('day', "occurredAt") AS "day", @@ -21,8 +21,8 @@ export class TrackingRollupService { ON CONFLICT ("day", "host", "path") DO UPDATE SET "views" = GREATEST("trackedPageDaily"."views", EXCLUDED."views"), "visitors" = GREATEST("trackedPageDaily"."visitors", EXCLUDED."visitors"); - `; + `); - return rolled; + return rolled.rowCount ?? 0; } } diff --git a/apps/api/src/tracking/tracking.controller.ts b/apps/api/src/tracking/tracking.controller.ts index 2a3603e79..d69494cfc 100644 --- a/apps/api/src/tracking/tracking.controller.ts +++ b/apps/api/src/tracking/tracking.controller.ts @@ -1,5 +1,5 @@ import type { IncomingMessage } from "node:http"; -import type { Db } from "@crm/db"; +import { type Db, sql } from "@crm/db"; import { EVENT_RETENTION_DAYS, isSiteId, @@ -220,14 +220,15 @@ export class TrackingRetentionController { let removed = 0; for (let pass = 0; pass < MAX_SWEEP_PASSES; pass += 1) { - const deleted = await this.db.$executeRaw` + const result = await this.db.execute(sql` DELETE FROM "trackedEvent" WHERE "id" IN ( SELECT "id" FROM "trackedEvent" WHERE "occurredAt" < ${before} LIMIT ${SWEEP_BATCH} ); - `; + `); + const deleted = result.rowCount ?? 0; removed += deleted; if (deleted < SWEEP_BATCH) return { removed, complete: true }; @@ -237,7 +238,7 @@ export class TrackingRetentionController { } private async sweepVisitors(before: Date): Promise { - const orphaned = await this.db.$executeRaw` + const orphaned = await this.db.execute(sql` DELETE FROM "trackedVisitor" WHERE "contactId" IS NULL AND "lastSeen" < ${before} @@ -245,9 +246,9 @@ export class TrackingRetentionController { SELECT 1 FROM "trackedEvent" WHERE "trackedEvent"."visitorId" = "trackedVisitor"."id" ); - `; + `); - return orphaned; + return orphaned.rowCount ?? 0; } } diff --git a/apps/api/src/tracking/tracking.service.ts b/apps/api/src/tracking/tracking.service.ts index 58c4b84d6..9e8ccba0f 100644 --- a/apps/api/src/tracking/tracking.service.ts +++ b/apps/api/src/tracking/tracking.service.ts @@ -5,7 +5,29 @@ import { WORKSPACE_ID, type WorkspaceRole, } from "@crm/auth/organization"; -import { type Db, DomainScope, Prisma } from "@crm/db"; +import { + and, + appSetting, + asc, + contact, + count, + type Db, + DomainScope, + desc, + eq, + formSubmission, + gte, + inArray, + isNotNull, + isUniqueViolation, + max, + member, + type NewAppSetting, + sql, + trackedDomain, + trackedEvent, + trackedVisitor, +} from "@crm/db"; import { describeTouch } from "@crm/db/attribution"; import { safeFetch } from "@crm/db/safe-fetch"; import { SETTINGS_ID } from "@crm/db/settings"; @@ -50,26 +72,33 @@ export class TrackingService { async settings(userId: string): Promise { const [row, domains, latest, pageViews, submissions] = await Promise.all([ - this.db.appSetting.findUnique({ - where: { id: SETTINGS_ID }, - select: { - trackingSiteId: true, - trackingCrossDomain: true, - trackingLimitToDomains: true, - trackingCookieSubdomains: true, - trackingSecureCookies: true, - trackingHonourDnt: true, - trackingCookieDays: true, - trackingPaused: true, - }, - }), - this.db.trackedDomain.findMany({ orderBy: { createdAt: "asc" } }), - this.db.trackedEvent.findFirst({ - orderBy: { occurredAt: "desc" }, - select: { occurredAt: true }, - }), - this.db.trackedEvent.count({ where: { type: "page_view" } }), - this.db.formSubmission.count(), + this.db + .select({ + trackingSiteId: appSetting.trackingSiteId, + trackingCrossDomain: appSetting.trackingCrossDomain, + trackingLimitToDomains: appSetting.trackingLimitToDomains, + trackingCookieSubdomains: appSetting.trackingCookieSubdomains, + trackingSecureCookies: appSetting.trackingSecureCookies, + trackingHonourDnt: appSetting.trackingHonourDnt, + trackingCookieDays: appSetting.trackingCookieDays, + trackingPaused: appSetting.trackingPaused, + }) + .from(appSetting) + .where(eq(appSetting.id, SETTINGS_ID)) + .limit(1) + .then((rows) => rows[0]), + this.db + .select() + .from(trackedDomain) + .orderBy(asc(trackedDomain.createdAt)), + this.db + .select({ occurredAt: trackedEvent.occurredAt }) + .from(trackedEvent) + .orderBy(desc(trackedEvent.occurredAt)) + .limit(1) + .then((rows) => rows[0]), + this.db.$count(trackedEvent, eq(trackedEvent.type, "page_view")), + this.db.$count(formSubmission), ]); const ready = trackingReady( @@ -131,11 +160,11 @@ export class TrackingService { paused: "trackingPaused", }[flag]; - await this.db.appSetting.upsert({ - where: { id: SETTINGS_ID }, - create: { id: SETTINGS_ID, [column]: enabled }, - update: { [column]: enabled }, - }); + const fields: Partial = { [column]: enabled }; + await this.db + .insert(appSetting) + .values({ id: SETTINGS_ID, ...fields }) + .onConflictDoUpdate({ target: appSetting.id, set: fields }); await this.config.invalidate(); } @@ -147,11 +176,13 @@ export class TrackingService { throw new BadRequestException("That is not a cookie lifetime we offer."); } - await this.db.appSetting.upsert({ - where: { id: SETTINGS_ID }, - create: { id: SETTINGS_ID, trackingCookieDays: days }, - update: { trackingCookieDays: days }, - }); + await this.db + .insert(appSetting) + .values({ id: SETTINGS_ID, trackingCookieDays: days }) + .onConflictDoUpdate({ + target: appSetting.id, + set: { trackingCookieDays: days }, + }); await this.config.invalidate(); } @@ -170,9 +201,11 @@ export class TrackingService { } try { - const domain = await this.db.trackedDomain.create({ - data: { host, scope: input.scope }, - }); + const [domain] = await this.db + .insert(trackedDomain) + .values({ host, scope: input.scope }) + .returning(); + if (!domain) throw new Error("The domain insert returned no row."); await this.config.invalidate(); @@ -184,10 +217,7 @@ export class TrackingService { lastSeenAt: null, }; } catch (error) { - if ( - error instanceof Prisma.PrismaClientKnownRequestError && - error.code === "P2002" - ) { + if (isUniqueViolation(error)) { throw new BadRequestException(`${host} is already on the list.`); } @@ -198,17 +228,12 @@ export class TrackingService { async removeDomain(userId: string, id: string): Promise { await this.assertCanManage(userId); - try { - await this.db.trackedDomain.delete({ where: { id } }); - } catch (error) { - if ( - error instanceof Prisma.PrismaClientKnownRequestError && - error.code === "P2025" - ) { - throw new NotFoundException("That domain is already gone."); - } - - throw error; + const removed = await this.db + .delete(trackedDomain) + .where(eq(trackedDomain.id, id)) + .returning({ id: trackedDomain.id }); + if (removed.length === 0) { + throw new NotFoundException("That domain is already gone."); } await this.config.invalidate(); @@ -223,11 +248,12 @@ export class TrackingService { async verify(userId: string, url: string): Promise { await this.assertCanManage(userId); - const domains = await this.db.trackedDomain.count(); - const row = await this.db.appSetting.findUnique({ - where: { id: SETTINGS_ID }, - select: { trackingLimitToDomains: true }, - }); + const domains = await this.db.$count(trackedDomain); + const [row] = await this.db + .select({ trackingLimitToDomains: appSetting.trackingLimitToDomains }) + .from(appSetting) + .where(eq(appSetting.id, SETTINGS_ID)) + .limit(1); if (!trackingReady(row?.trackingLimitToDomains ?? true, domains)) { throw new BadRequestException( @@ -276,17 +302,20 @@ export class TrackingService { } const since = new Date(Date.now() - VERIFY_WINDOW_MS); - const seen = await this.db.trackedEvent.findFirst({ - where: { host, occurredAt: { gte: since } }, - select: { id: true }, - }); + const [seen] = await this.db + .select({ id: trackedEvent.id }) + .from(trackedEvent) + .where( + and(eq(trackedEvent.host, host), gte(trackedEvent.occurredAt, since)), + ) + .limit(1); return { status: "found", host, responseMs, allowed: compiled ? hostAllowed(host, compiled.config) : false, - pageView: seen !== null, + pageView: seen !== undefined, container, }; } @@ -316,19 +345,20 @@ export class TrackingService { } async activityForCompany(companyId: string): Promise { - const visitors = await this.db.trackedVisitor.findMany({ - where: { contact: { companyId } }, - select: { id: true }, - }); + const visitors = await this.db + .select({ id: trackedVisitor.id }) + .from(trackedVisitor) + .innerJoin(contact, eq(contact.id, trackedVisitor.contactId)) + .where(eq(contact.companyId, companyId)); return this.activityFor(visitors.map((visitor) => visitor.id)); } async activityForContact(contactId: string): Promise { - const visitors = await this.db.trackedVisitor.findMany({ - where: { contactId }, - select: { id: true }, - }); + const visitors = await this.db + .select({ id: trackedVisitor.id }) + .from(trackedVisitor) + .where(eq(trackedVisitor.contactId, contactId)); return this.activityFor(visitors.map((visitor) => visitor.id)); } @@ -346,34 +376,57 @@ export class TrackingService { }; } - const [first, last] = await Promise.all([ - this.db.trackedVisitor.findFirst({ - where: { id: { in: visitorIds }, firstSource: { not: null } }, - orderBy: { firstTouchAt: "asc" }, - }), - this.db.trackedVisitor.findFirst({ - where: { id: { in: visitorIds }, lastSource: { not: null } }, - orderBy: { lastTouchAt: "desc" }, - }), + const [[first], [last]] = await Promise.all([ + this.db + .select() + .from(trackedVisitor) + .where( + and( + inArray(trackedVisitor.id, visitorIds), + isNotNull(trackedVisitor.firstSource), + ), + ) + .orderBy(asc(trackedVisitor.firstTouchAt)) + .limit(1), + this.db + .select() + .from(trackedVisitor) + .where( + and( + inArray(trackedVisitor.id, visitorIds), + isNotNull(trackedVisitor.lastSource), + ), + ) + .orderBy(sql`${trackedVisitor.lastTouchAt} desc nulls first`) + .limit(1), ]); + const pageViews = and( + inArray(trackedEvent.visitorId, visitorIds), + eq(trackedEvent.type, "page_view"), + ); + const [grouped, latest, views] = await Promise.all([ - this.db.trackedEvent.groupBy({ - by: ["host", "path"], - where: { visitorId: { in: visitorIds }, type: "page_view" }, - _count: { _all: true }, - _max: { occurredAt: true }, - orderBy: { _count: { path: "desc" } }, - take: 10, - }), - this.db.trackedEvent.findFirst({ - where: { visitorId: { in: visitorIds } }, - orderBy: { occurredAt: "desc" }, - select: { occurredAt: true }, - }), - this.db.trackedEvent.count({ - where: { visitorId: { in: visitorIds }, type: "page_view" }, - }), + this.db + .select({ + host: trackedEvent.host, + path: trackedEvent.path, + views: count(), + lastSeenAt: max(trackedEvent.occurredAt), + }) + .from(trackedEvent) + .where(pageViews) + .groupBy(trackedEvent.host, trackedEvent.path) + .orderBy(desc(count(trackedEvent.path))) + .limit(10), + this.db + .select({ occurredAt: trackedEvent.occurredAt }) + .from(trackedEvent) + .where(inArray(trackedEvent.visitorId, visitorIds)) + .orderBy(desc(trackedEvent.occurredAt)) + .limit(1) + .then((rows) => rows[0]), + this.db.$count(trackedEvent, pageViews), ]); return { @@ -382,13 +435,13 @@ export class TrackingService { views, lastSeenAt: latest?.occurredAt.toISOString() ?? null, pages: grouped.flatMap((row) => - row._max.occurredAt + row.lastSeenAt ? [ { host: row.host, path: row.path, - views: row._count._all, - lastSeenAt: row._max.occurredAt.toISOString(), + views: row.views, + lastSeenAt: row.lastSeenAt.toISOString(), }, ] : [], @@ -420,19 +473,32 @@ export class TrackingService { await this.assertCanManage(userId); const [views, contacts] = await Promise.all([ - this.db.trackedEvent.groupBy({ - by: ["source", "medium"], - where: { type: "page_view", source: { not: null } }, - _count: { _all: true }, - }), - this.db.$queryRaw< - { firstSource: string; firstMedium: string | null; contacts: bigint }[] - >` + this.db + .select({ + source: trackedEvent.source, + medium: trackedEvent.medium, + count: count(), + }) + .from(trackedEvent) + .where( + and( + eq(trackedEvent.type, "page_view"), + isNotNull(trackedEvent.source), + ), + ) + .groupBy(trackedEvent.source, trackedEvent.medium), + this.db + .execute<{ + firstSource: string; + firstMedium: string | null; + contacts: string; + }>(sql` SELECT "firstSource", "firstMedium", count(DISTINCT "contactId") AS contacts FROM "trackedVisitor" WHERE "contactId" IS NOT NULL AND "firstSource" IS NOT NULL GROUP BY 1, 2; - `, + `) + .then((result) => result.rows), ]); const rows = new Map(); @@ -443,7 +509,7 @@ export class TrackingService { rows.set(key, { source: row.source, medium: row.medium, - views: row._count._all, + views: row.count, contacts: 0, }); } @@ -481,12 +547,17 @@ export class TrackingService { } private async roleOf(userId: string): Promise { - const member = await this.db.member.findFirst({ - where: { organizationId: WORKSPACE_ID, userId }, - select: { role: true }, - }); - - return member && isWorkspaceRole(member.role) ? member.role : null; + const [membership] = await this.db + .select({ role: member.role }) + .from(member) + .where( + and(eq(member.organizationId, WORKSPACE_ID), eq(member.userId, userId)), + ) + .limit(1); + + return membership && isWorkspaceRole(membership.role) + ? membership.role + : null; } } diff --git a/apps/api/src/trpc/list-input.ts b/apps/api/src/trpc/list-input.ts index e394dacba..81b5d7bf3 100644 --- a/apps/api/src/trpc/list-input.ts +++ b/apps/api/src/trpc/list-input.ts @@ -1,3 +1,19 @@ +import { + asc, + type Column, + count, + desc, + type Executor, + gte, + inArray, + isNotNull, + isNull, + or, + type PgColumn, + type PgTable, + type SQL, + sql, +} from "@crm/db"; import { z } from "zod"; export const listInput = z.object({ @@ -38,6 +54,18 @@ export interface OrderByColumns { [column: string]: (dir: SortDirection) => TOrderBy; } +type Sortable = Column | SQL | SQL.Aliased; + +export function sortBy(column: Sortable, dir: SortDirection): SQL { + return dir === "asc" ? asc(column) : desc(column); +} + +export function sortNullsLast(column: Sortable, dir: SortDirection): SQL { + return dir === "asc" + ? sql`${column} asc nulls last` + : sql`${column} desc nulls last`; +} + export function resolveOrderBy( input: Pick, columns: OrderByColumns, @@ -49,7 +77,7 @@ export function resolveOrderBy( export function countsByKey< TKey extends string, - TGroup extends { _count: { _all: number } } & { + TGroup extends { count: number } & { [K in TKey]?: string | null; }, >(groups: TGroup[], key: TKey, nullKey?: string): FacetCount { @@ -58,12 +86,31 @@ export function countsByKey< for (const group of groups) { const value = group[key] ?? nullKey; if (value == null) continue; - counts[value] = (counts[value] ?? 0) + group._count._all; + counts[value] = (counts[value] ?? 0) + group.count; } return counts; } +export type GroupCount = { value: string | null; count: number }; + +export async function groupCounts( + db: Executor, + table: PgTable, + column: PgColumn, + where: SQL | undefined, +): Promise { + const rows = await db + .select({ value: column, count: count() }) + .from(table) + .where(where) + .groupBy(column); + return rows.map((row) => ({ + value: row.value === null ? null : String(row.value), + count: Number(row.count), + })); +} + export const FACET_UNASSIGNED = "unassigned"; export function splitSentinel(values: string[], sentinel: string) { @@ -71,22 +118,17 @@ export function splitSentinel(values: string[], sentinel: string) { return { ids, includesSentinel: ids.length !== values.length }; } -export function ownerFilter< - TWhere = - | { ownerId: { in: string[] } } - | { ownerId: null } - | { OR: unknown[] }, ->(values: string[]): TWhere | undefined { +export function ownerFilter(column: Column, values: string[]): SQL | undefined { if (values.length === 0) return undefined; const { ids, includesSentinel } = splitSentinel(values, FACET_UNASSIGNED); - if (includesSentinel && ids.length === 0) return { ownerId: null } as TWhere; - if (!includesSentinel) return { ownerId: { in: ids } } as TWhere; - return { OR: [{ ownerId: { in: ids } }, { ownerId: null }] } as TWhere; + if (includesSentinel && ids.length === 0) return isNull(column); + if (!includesSentinel) return inArray(column, ids); + return or(inArray(column, ids), isNull(column)); } -export function archivedFilter(archived: boolean) { - return { archivedAt: archived ? { not: null } : null }; +export function archivedFilter(column: Column, archived: boolean): SQL { + return archived ? isNotNull(column) : isNull(column); } const ACTIVITY_WINDOWS = ["7", "30", "90"] as const; @@ -104,19 +146,21 @@ function activityCutoff(days: number): Date { } export function activityFilter( + column: Column, values: string[], -): { lastActivityAt: { gte: Date } } | undefined { +): SQL | undefined { if (values.length === 0) return undefined; const days = values.reduce((max, value) => Math.max(max, Number(value)), 0); - return { lastActivityAt: { gte: activityCutoff(days) } }; + return gte(column, activityCutoff(days)); } export async function activityFacetCounts( - countWhere: (where: { lastActivityAt: { gte: Date } }) => Promise, + column: Column, + countWhere: (where: SQL) => Promise, ): Promise { const counts = await Promise.all( ACTIVITY_WINDOWS.map((days) => - countWhere({ lastActivityAt: { gte: activityCutoff(Number(days)) } }), + countWhere(gte(column, activityCutoff(Number(days)))), ), ); return Object.fromEntries( diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index 68f621331..abf5876e5 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -1,4 +1,4 @@ -import type { Db } from "@crm/db"; +import { asc, type Db, user } from "@crm/db"; import { Injectable } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; @@ -14,9 +14,14 @@ export class UsersService { constructor(@InjectDatabase() private readonly db: Db) {} async list(): Promise { - return this.db.user.findMany({ - select: { id: true, name: true, email: true, image: true }, - orderBy: [{ name: "asc" }, { email: "asc" }], - }); + return this.db + .select({ + id: user.id, + name: user.name, + email: user.email, + image: user.image, + }) + .from(user) + .orderBy(asc(user.name), asc(user.email)); } } diff --git a/apps/api/src/workspace/workspace.service.ts b/apps/api/src/workspace/workspace.service.ts index 7ed3e70a9..96d77098d 100644 --- a/apps/api/src/workspace/workspace.service.ts +++ b/apps/api/src/workspace/workspace.service.ts @@ -7,7 +7,20 @@ import { type WorkspaceRole, workspaceRoleOf, } from "@crm/auth/organization"; -import type { Db, Prisma } from "@crm/db"; +import { + and, + asc, + contains, + type Db, + type Executor, + eq, + inArray, + member, + or, + organization, + type SQL, + user, +} from "@crm/db"; import { isOnboarded, markOnboarded, workspaceSlug } from "@crm/db/workspace"; import { BadRequestException, @@ -22,10 +35,12 @@ import { normalizeDomain } from "../companies/domain"; import { InjectDatabase } from "../database/database.constants"; import { countsByKey, + groupCounts, type ListResult, type OrderByColumns, paginate, resolveOrderBy, + sortBy, } from "../trpc/list-input"; import type { MemberListInput, @@ -35,23 +50,36 @@ import type { WorkspaceMember, } from "./workspace.contracts"; -const MEMBER_SELECT = { - id: true, - role: true, - createdAt: true, - userId: true, - user: { select: { name: true, email: true, image: true } }, -} as const; - -type MemberRow = Prisma.MemberGetPayload<{ select: typeof MEMBER_SELECT }>; - -const SORTABLE: OrderByColumns = { - name: (dir) => ({ user: { name: dir } }), - email: (dir) => ({ user: { email: dir } }), - role: (dir) => ({ role: dir }), - joinedAt: (dir) => ({ createdAt: dir }), +const MEMBER_FIELDS = { + id: member.id, + role: member.role, + createdAt: member.createdAt, + userId: member.userId, + user: { name: user.name, email: user.email, image: user.image }, +}; + +type MemberRow = { + id: string; + role: string; + createdAt: Date; + userId: string; + user: { name: string; email: string; image: string | null }; +}; + +const SORTABLE: OrderByColumns = { + name: (dir) => sortBy(user.name, dir), + email: (dir) => sortBy(user.email, dir), + role: (dir) => sortBy(member.role, dir), + joinedAt: (dir) => sortBy(member.createdAt, dir), }; +function members(db: Executor) { + return db + .select(MEMBER_FIELDS) + .from(member) + .innerJoin(user, eq(user.id, member.userId)); +} + function toRole(value: string): WorkspaceRole { return isWorkspaceRole(value) ? value : "member"; } @@ -105,10 +133,14 @@ export class WorkspaceService { ); } - const before = await this.db.organization.findUnique({ - where: { id: WORKSPACE_ID }, - select: { website: true, metadata: true }, - }); + const [before] = await this.db + .select({ + website: organization.website, + metadata: organization.metadata, + }) + .from(organization) + .where(eq(organization.id, WORKSPACE_ID)) + .limit(1); const website = normalizeDomain(input.website); @@ -118,15 +150,18 @@ export class WorkspaceService { ); } - await this.db.organization.update({ - where: { id: WORKSPACE_ID }, - data: { + const renamed = await this.db + .update(organization) + .set({ name: input.name, slug: workspaceSlug(input.slug ?? input.name), website, metadata: markOnboarded(before?.metadata ?? null, new Date()), - }, - }); + }) + .where(eq(organization.id, WORKSPACE_ID)) + .returning({ id: organization.id }); + if (renamed.length === 0) + throw new NotFoundException("The workspace does not exist."); this.logger.log({ message: "Workspace updated", userId }); @@ -150,25 +185,19 @@ export class WorkspaceService { const { skip, take } = paginate(input); const [rows, total, roles] = await Promise.all([ - this.db.member.findMany({ - where, - skip, - take, - select: MEMBER_SELECT, - orderBy: resolveOrderBy(input, SORTABLE, { createdAt: "asc" }), - }), - this.db.member.count({ where }), - this.db.member.groupBy({ - by: ["role"], - where: this.searchWhere(input.q), - _count: { _all: true }, - }), + members(this.db) + .where(where) + .orderBy(resolveOrderBy(input, SORTABLE, asc(member.createdAt))) + .limit(take) + .offset(skip), + this.db.$count(member, where), + groupCounts(this.db, member, member.role, this.searchWhere(input.q)), ]); return { rows: rows.map((row) => this.toMember(row, userId)), total, - facetCounts: { role: countsByKey(roles, "role") }, + facetCounts: { role: countsByKey(roles, "value") }, }; } @@ -184,22 +213,33 @@ export class WorkspaceService { ); } - const updated = await this.db.$transaction(async (tx) => { - const target = await tx.member.findFirst({ - where: { id: input.memberId, organizationId: WORKSPACE_ID }, - select: { id: true, role: true }, - }); + const updated = await this.db.transaction(async (tx) => { + const [target] = await tx + .select({ id: member.id, role: member.role }) + .from(member) + .where( + and( + eq(member.id, input.memberId), + eq(member.organizationId, WORKSPACE_ID), + ), + ) + .limit(1); if (!target) { throw new NotFoundException("That person is not in this workspace."); } if (target.role === "owner" && input.role !== "owner") { - const owners = await tx.$queryRaw<{ id: string }[]>` - SELECT id FROM "member" - WHERE "organizationId" = ${WORKSPACE_ID} AND role = 'owner' - FOR UPDATE - `; + const owners = await tx + .select({ id: member.id }) + .from(member) + .where( + and( + eq(member.organizationId, WORKSPACE_ID), + eq(member.role, "owner"), + ), + ) + .for("update"); if (owners.length <= 1) { throw new ForbiddenException( @@ -208,11 +248,15 @@ export class WorkspaceService { } } - return tx.member.update({ - where: { id: target.id }, - data: { role: input.role }, - select: MEMBER_SELECT, - }); + await tx + .update(member) + .set({ role: input.role }) + .where(eq(member.id, target.id)); + + const [row] = await members(tx).where(eq(member.id, target.id)); + if (!row) + throw new NotFoundException("That person is not in this workspace."); + return row; }); this.logger.log({ @@ -238,42 +282,46 @@ export class WorkspaceService { }; } - private searchWhere(q: string): Prisma.MemberWhereInput { + private searchWhere(q: string): SQL | undefined { const term = q.trim(); - const where: Prisma.MemberWhereInput = { organizationId: WORKSPACE_ID }; - - if (term) { - where.user = { - OR: [ - { name: { contains: term, mode: "insensitive" } }, - { email: { contains: term, mode: "insensitive" } }, - ], - }; - } - - return where; + return and( + eq(member.organizationId, WORKSPACE_ID), + term + ? inArray( + member.userId, + this.db + .select({ id: user.id }) + .from(user) + .where( + or( + contains(user.name, term, { insensitive: true }), + contains(user.email, term, { insensitive: true }), + ), + ), + ) + : undefined, + ); } - private buildWhere(input: MemberListInput): Prisma.MemberWhereInput { - const where = this.searchWhere(input.q); - - if (input.role.length > 0) { - where.role = { in: input.role }; - } - - return where; + private buildWhere(input: MemberListInput): SQL | undefined { + return and( + this.searchWhere(input.q), + input.role.length > 0 ? inArray(member.role, input.role) : undefined, + ); } private async readWorkspace() { - return this.db.organization.findUnique({ - where: { id: WORKSPACE_ID }, - select: { - id: true, - slug: true, - name: true, - website: true, - metadata: true, - }, - }); + const [row] = await this.db + .select({ + id: organization.id, + slug: organization.slug, + name: organization.name, + website: organization.website, + metadata: organization.metadata, + }) + .from(organization) + .where(eq(organization.id, WORKSPACE_ID)) + .limit(1); + return row ?? null; } } diff --git a/apps/api/test/agent-delete.spec.ts b/apps/api/test/agent-delete.spec.ts index b5edf0a17..7f183361c 100644 --- a/apps/api/test/agent-delete.spec.ts +++ b/apps/api/test/agent-delete.spec.ts @@ -1,10 +1,26 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { DEFAULT_WORKSPACE_NAME, WORKSPACE_ID } from "@crm/auth/organization"; -import { db } from "@crm/db"; +import { + agentAction, + agentAuditEvent, + agentDefinition, + agentRun, + agentRunEvent, + agentTrigger, + agentVersion, + and, + db, + eq, + inArray, + member, + organization, + user, +} from "@crm/db"; import { workspaceSlug } from "@crm/db/workspace"; import { AgentAccessService } from "../src/agent/agent-access.service"; import { AgentDefinitionsService } from "../src/agent/agent-definitions.service"; import { AgentTriggerService } from "../src/agent/agent-trigger.service"; +import { first, one } from "./rows"; const suffix = crypto.randomUUID(); const userId = `agent-delete-user-${suffix}`; @@ -28,146 +44,180 @@ let deliveryRunId: string; async function clean() { if (agentId) { - await db.agentRunEvent.deleteMany({ where: { run: { agentId } } }); - await db.agentAction.deleteMany({ where: { agentId } }); - await db.agentAuditEvent.deleteMany({ where: { agentId } }); - await db.agentRun.deleteMany({ where: { agentId } }); - await db.agentTrigger.deleteMany({ where: { agentId } }); - await db.agentDefinition.updateMany({ - where: { id: agentId }, - data: { currentVersionId: null }, - }); - await db.agentVersion.deleteMany({ where: { agentId } }); - await db.agentDefinition.deleteMany({ where: { id: agentId } }); + await db + .delete(agentRunEvent) + .where( + inArray( + agentRunEvent.runId, + db + .select({ id: agentRun.id }) + .from(agentRun) + .where(eq(agentRun.agentId, agentId)), + ), + ); + await db.delete(agentAction).where(eq(agentAction.agentId, agentId)); + await db + .delete(agentAuditEvent) + .where(eq(agentAuditEvent.agentId, agentId)); + await db.delete(agentRun).where(eq(agentRun.agentId, agentId)); + await db.delete(agentTrigger).where(eq(agentTrigger.agentId, agentId)); + await db + .update(agentDefinition) + .set({ currentVersionId: null }) + .where(eq(agentDefinition.id, agentId)); + await db.delete(agentVersion).where(eq(agentVersion.agentId, agentId)); + await db.delete(agentDefinition).where(eq(agentDefinition.id, agentId)); } - await db.member.deleteMany({ where: { id: memberId } }); - await db.user.deleteMany({ where: { id: userId } }); + await db.delete(member).where(eq(member.id, memberId)); + await db.delete(user).where(eq(user.id, userId)); } beforeAll(async () => { - await db.organization.upsert({ - where: { id: WORKSPACE_ID }, - update: {}, - create: { + await db + .insert(organization) + .values({ id: WORKSPACE_ID, name: DEFAULT_WORKSPACE_NAME, slug: workspaceSlug(DEFAULT_WORKSPACE_NAME), createdAt: new Date(), - }, - }); - await db.user.create({ - data: { - id: userId, - name: "Agent Delete Test", - email: `${userId}@example.test`, - }, - }); - await db.member.create({ - data: { - id: memberId, - organizationId: WORKSPACE_ID, - userId, - role: "member", - createdAt: new Date(), - }, - }); + }) + .onConflictDoNothing(); + await one( + db + .insert(user) + .values({ + id: userId, + name: "Agent Delete Test", + email: `${userId}@example.test`, + }) + .returning(), + ); + await one( + db + .insert(member) + .values({ + id: memberId, + organizationId: WORKSPACE_ID, + userId, + role: "member", + createdAt: new Date(), + }) + .returning(), + ); - const agent = await db.agentDefinition.create({ - data: { - name: "Delete me", - status: "PAUSED", - createdById: userId, - }, - select: { id: true }, - }); + const agent = await one( + db + .insert(agentDefinition) + .values({ + name: "Delete me", + status: "PAUSED", + createdById: userId, + }) + .returning({ id: agentDefinition.id }), + ); agentId = agent.id; - const version = await db.agentVersion.create({ - data: { - agentId, - number: 1, - status: "DEPLOYED", - instructions: "Test deletion behavior.", - manifest: {}, - modelId: "test/model", - sandboxPolicy: {}, - createdById: userId, - approvedAt: new Date(), - deployedAt: new Date(), - }, - select: { id: true }, - }); + const version = await one( + db + .insert(agentVersion) + .values({ + agentId, + number: 1, + status: "DEPLOYED", + instructions: "Test deletion behavior.", + manifest: {}, + modelId: "test/model", + sandboxPolicy: {}, + createdById: userId, + approvedAt: new Date(), + deployedAt: new Date(), + }) + .returning({ id: agentVersion.id }), + ); versionId = version.id; - await db.agentDefinition.update({ - where: { id: agentId }, - data: { currentVersionId: versionId }, - }); + await one( + db + .update(agentDefinition) + .set({ currentVersionId: versionId }) + .where(eq(agentDefinition.id, agentId)) + .returning(), + ); - const trigger = await db.agentTrigger.create({ - data: { - agentId, - versionId, - type: "SCHEDULE", - name: "Every hour", - config: { intervalMinutes: 60 }, - createdById: userId, - enabled: true, - nextRunAt: new Date(Date.now() + 60 * 60 * 1000), - }, - select: { id: true }, - }); + const trigger = await one( + db + .insert(agentTrigger) + .values({ + agentId, + versionId, + type: "SCHEDULE", + name: "Every hour", + config: { intervalMinutes: 60 }, + createdById: userId, + enabled: true, + nextRunAt: new Date(Date.now() + 60 * 60 * 1000), + }) + .returning({ id: agentTrigger.id }), + ); triggerId = trigger.id; const [queued, waiting, running, delivery] = await Promise.all([ - db.agentRun.create({ - data: { - agentId, - versionId, - triggerId, - triggerType: "SCHEDULE", - status: "QUEUED", - idempotencyKey: `${idempotencyPrefix}-queued`, - correlationId: `${idempotencyPrefix}-queued`, - }, - select: { id: true }, - }), - db.agentRun.create({ - data: { - agentId, - versionId, - triggerType: "MANUAL", - status: "WAITING_FOR_APPROVAL", - idempotencyKey: `${idempotencyPrefix}-waiting`, - correlationId: `${idempotencyPrefix}-waiting`, - }, - select: { id: true }, - }), - db.agentRun.create({ - data: { - agentId, - versionId, - triggerType: "MANUAL", - status: "RUNNING", - idempotencyKey: `${idempotencyPrefix}-running`, - correlationId: `${idempotencyPrefix}-running`, - startedAt: new Date(), - sessionId: `${idempotencyPrefix}-active-session`, - }, - select: { id: true }, - }), - db.agentRun.create({ - data: { - agentId, - versionId, - triggerType: "MANUAL", - status: "RUNNING", - idempotencyKey: `${idempotencyPrefix}-delivery`, - correlationId: `${idempotencyPrefix}-delivery`, - startedAt: new Date(), - }, - select: { id: true }, - }), + one( + db + .insert(agentRun) + .values({ + agentId, + versionId, + triggerId, + triggerType: "SCHEDULE", + status: "QUEUED", + idempotencyKey: `${idempotencyPrefix}-queued`, + correlationId: `${idempotencyPrefix}-queued`, + }) + .returning({ id: agentRun.id }), + ), + one( + db + .insert(agentRun) + .values({ + agentId, + versionId, + triggerType: "MANUAL", + status: "WAITING_FOR_APPROVAL", + idempotencyKey: `${idempotencyPrefix}-waiting`, + correlationId: `${idempotencyPrefix}-waiting`, + }) + .returning({ id: agentRun.id }), + ), + one( + db + .insert(agentRun) + .values({ + agentId, + versionId, + triggerType: "MANUAL", + status: "RUNNING", + idempotencyKey: `${idempotencyPrefix}-running`, + correlationId: `${idempotencyPrefix}-running`, + startedAt: new Date(), + sessionId: `${idempotencyPrefix}-active-session`, + }) + .returning({ id: agentRun.id }), + ), + one( + db + .insert(agentRun) + .values({ + agentId, + versionId, + triggerType: "MANUAL", + status: "RUNNING", + idempotencyKey: `${idempotencyPrefix}-delivery`, + correlationId: `${idempotencyPrefix}-delivery`, + startedAt: new Date(), + }) + .returning({ id: agentRun.id }), + ), ]); queuedRunId = queued.id; waitingRunId = waiting.id; @@ -189,30 +239,62 @@ describe("deleting an agent", () => { const [definition, trigger, runs, runEvents, auditEvent, listed] = await Promise.all([ - db.agentDefinition.findUnique({ where: { id: agentId } }), - db.agentTrigger.findUnique({ where: { id: triggerId } }), - db.agentRun.findMany({ - where: { - id: { - in: [queuedRunId, waitingRunId, runningRunId, deliveryRunId], - }, - }, - select: { - id: true, - status: true, - errorCode: true, - finishedAt: true, - }, - }), - db.agentRunEvent.findMany({ - where: { - runId: { in: [queuedRunId, waitingRunId, deliveryRunId] }, - }, - select: { runId: true, type: true, data: true }, - }), - db.agentAuditEvent.findFirst({ - where: { agentId, type: "agent.deleted" }, - }), + first( + db + .select() + .from(agentDefinition) + .where(eq(agentDefinition.id, agentId)) + .limit(1), + ), + first( + db + .select() + .from(agentTrigger) + .where(eq(agentTrigger.id, triggerId)) + .limit(1), + ), + db + .select({ + id: agentRun.id, + status: agentRun.status, + errorCode: agentRun.errorCode, + finishedAt: agentRun.finishedAt, + }) + .from(agentRun) + .where( + inArray(agentRun.id, [ + queuedRunId, + waitingRunId, + runningRunId, + deliveryRunId, + ]), + ), + db + .select({ + runId: agentRunEvent.runId, + type: agentRunEvent.type, + data: agentRunEvent.data, + }) + .from(agentRunEvent) + .where( + inArray(agentRunEvent.runId, [ + queuedRunId, + waitingRunId, + deliveryRunId, + ]), + ), + first( + db + .select() + .from(agentAuditEvent) + .where( + and( + eq(agentAuditEvent.agentId, agentId), + eq(agentAuditEvent.type, "agent.deleted"), + ), + ) + .limit(1), + ), agents.list(userId), ]); diff --git a/apps/api/test/agent-events.spec.ts b/apps/api/test/agent-events.spec.ts index 2fe38f520..47d9ae3b7 100644 --- a/apps/api/test/agent-events.spec.ts +++ b/apps/api/test/agent-events.spec.ts @@ -1,10 +1,23 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { db } from "@crm/db"; +import { + activity, + agentTask, + and, + company as companyTable, + db, + deal as dealTable, + eq, + inArray, + or, + sql, + user, +} from "@crm/db"; import { AgentTriggerService } from "../src/agent/agent-trigger.service"; import { ActivityStampService } from "../src/crm/activity-stamp.service"; import { ConversionService } from "../src/currency/conversion.service"; import { DealsService } from "../src/deals/deals.service"; import { FieldsService } from "../src/fields/fields.service"; +import { first, one } from "./rows"; const suffix = crypto.randomUUID(); const dealId = `event-deal-${suffix}`; @@ -25,46 +38,57 @@ let previousBridgeSecret: string | undefined; beforeAll(async () => { previousBridgeSecret = process.env.AGENT_BRIDGE_SECRET; delete process.env.AGENT_BRIDGE_SECRET; - await db.agentTask.deleteMany({ - where: { OR: [{ dealId }, { contactId }] }, - }); - await db.user.create({ - data: { - id: ownerId, - name: "Event Test Owner", - email: `${ownerId}@example.test`, - }, - }); - const company = await db.company.create({ - data: { name: "Event Test Company", domain }, - select: { id: true }, - }); + await db + .delete(agentTask) + .where( + or(eq(agentTask.dealId, dealId), eq(agentTask.contactId, contactId)), + ); + await one( + db + .insert(user) + .values({ + id: ownerId, + name: "Event Test Owner", + email: `${ownerId}@example.test`, + }) + .returning(), + ); + const company = await one( + db + .insert(companyTable) + .values({ name: "Event Test Company", domain }) + .returning({ id: companyTable.id }), + ); persistedCompanyId = company.id; }); afterAll(async () => { - await db.agentTask.deleteMany({ - where: { - OR: [ - { dealId: { in: [dealId, persistedDealId].filter(Boolean) } }, - { contactId }, - ], - }, - }); - await db.agentTask.deleteMany({ - where: { - kind: "slack-channel-join", - payload: { path: ["channelId"], equals: channelId }, - }, - }); + await db + .delete(agentTask) + .where( + or( + inArray(agentTask.dealId, [dealId, persistedDealId].filter(Boolean)), + eq(agentTask.contactId, contactId), + ), + ); + await db + .delete(agentTask) + .where( + and( + eq(agentTask.kind, "slack-channel-join"), + sql`${agentTask.payload} #>> '{channelId}' = ${channelId}`, + ), + ); if (persistedDealId) { - await db.activity.deleteMany({ where: { dealId: persistedDealId } }); - await db.deal.deleteMany({ where: { id: persistedDealId } }); + await db.delete(activity).where(eq(activity.dealId, persistedDealId)); + await db.delete(dealTable).where(eq(dealTable.id, persistedDealId)); } if (persistedCompanyId) { - await db.company.deleteMany({ where: { id: persistedCompanyId } }); + await db + .delete(companyTable) + .where(eq(companyTable.id, persistedCompanyId)); } - await db.user.deleteMany({ where: { id: ownerId } }); + await db.delete(user).where(eq(user.id, ownerId)); if (previousBridgeSecret === undefined) { delete process.env.AGENT_BRIDGE_SECRET; } else { @@ -85,15 +109,23 @@ describe("CRM agent events", () => { }); expect( - await db.agentTask.findFirstOrThrow({ - where: { contactId, kind: "agent-event" }, - select: { - contactId: true, - companyId: true, - dealId: true, - payload: true, - }, - }), + await one( + db + .select({ + contactId: agentTask.contactId, + companyId: agentTask.companyId, + dealId: agentTask.dealId, + payload: agentTask.payload, + }) + .from(agentTask) + .where( + and( + eq(agentTask.contactId, contactId), + eq(agentTask.kind, "agent-event"), + ), + ) + .limit(1), + ), ).toEqual({ contactId, companyId: null, @@ -126,15 +158,17 @@ describe("CRM agent events", () => { }); }); - const tasks = await db.agentTask.findMany({ - where: { dealId, kind: "agent-event" }, - select: { - dealId: true, - reason: true, - payload: true, - finishedAt: true, - }, - }); + const tasks = await db + .select({ + dealId: agentTask.dealId, + reason: agentTask.reason, + payload: agentTask.payload, + finishedAt: agentTask.finishedAt, + }) + .from(agentTask) + .where( + and(eq(agentTask.dealId, dealId), eq(agentTask.kind, "agent-event")), + ); expect(tasks).toHaveLength(2); expect(tasks.find((task) => task.reason === "deal.created")).toEqual({ @@ -166,13 +200,15 @@ describe("CRM agent events", () => { await service.slackChannelJoinRequested(channelId, "deal-room-renamed"); expect( - await db.agentTask.findMany({ - where: { - kind: "slack-channel-join", - payload: { path: ["channelId"], equals: channelId }, - }, - select: { reason: true }, - }), + await db + .select({ reason: agentTask.reason }) + .from(agentTask) + .where( + and( + eq(agentTask.kind, "slack-channel-join"), + sql`${agentTask.payload} #>> '{channelId}' = ${channelId}`, + ), + ), ).toEqual([{ reason: "Add Comp AI to #deal-room" }]); }); @@ -182,13 +218,18 @@ describe("CRM agent events", () => { try { await service.withCrmEvents(async (tx, emit) => { - const company = await tx.company.create({ - data: { - id: rollbackCompanyId, - name: "Rollback Event Company", - }, - select: { id: true, createdAt: true }, - }); + const company = await one( + tx + .insert(companyTable) + .values({ + id: rollbackCompanyId, + name: "Rollback Event Company", + }) + .returning({ + id: companyTable.id, + createdAt: companyTable.createdAt, + }), + ); await emit({ type: "company.created", record: { kind: "company", id: company.id }, @@ -203,12 +244,22 @@ describe("CRM agent events", () => { expect(error?.message).toBe("Rollback the record and outbox together."); expect( - await db.company.findUnique({ where: { id: rollbackCompanyId } }), + await first( + db + .select() + .from(companyTable) + .where(eq(companyTable.id, rollbackCompanyId)) + .limit(1), + ), ).toBeNull(); expect( - await db.agentTask.count({ - where: { companyId: rollbackCompanyId, kind: "agent-event" }, - }), + await db.$count( + agentTask, + and( + eq(agentTask.companyId, rollbackCompanyId), + eq(agentTask.kind, "agent-event"), + ), + ), ).toBe(0); }); @@ -234,10 +285,12 @@ describe("CRM agent events", () => { await deals.setStage({ id: deal.id, stage: "QUALIFIED_TO_BUY" }, ownerId); const reasons = ( - await db.agentTask.findMany({ - where: { dealId: deal.id, kind: "agent-event" }, - select: { reason: true }, - }) + await db + .select({ reason: agentTask.reason }) + .from(agentTask) + .where( + and(eq(agentTask.dealId, deal.id), eq(agentTask.kind, "agent-event")), + ) ) .map((task) => task.reason) .sort(); @@ -249,9 +302,10 @@ describe("CRM agent events", () => { "deal.stage.changed", ]); expect( - await db.activity.count({ - where: { dealId: deal.id, type: "STAGE_CHANGE" }, - }), + await db.$count( + activity, + and(eq(activity.dealId, deal.id), eq(activity.type, "STAGE_CHANGE")), + ), ).toBe(2); }); }); diff --git a/apps/api/test/agent-lifecycle.spec.ts b/apps/api/test/agent-lifecycle.spec.ts index c0f9da185..654b2439c 100644 --- a/apps/api/test/agent-lifecycle.spec.ts +++ b/apps/api/test/agent-lifecycle.spec.ts @@ -1,10 +1,29 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { DEFAULT_WORKSPACE_NAME, WORKSPACE_ID } from "@crm/auth/organization"; -import { db, type Prisma } from "@crm/db"; +import { + agentAction, + agentAuditEvent, + agentBuilderArtifact, + agentDefinition, + agentRun, + agentRunEvent, + agentTask, + agentTrigger, + agentVersion, + and, + db, + eq, + inArray, + type JsonObject, + member, + organization, + user, +} from "@crm/db"; import { workspaceSlug } from "@crm/db/workspace"; import { AgentAccessService } from "../src/agent/agent-access.service"; import { AgentDefinitionsService } from "../src/agent/agent-definitions.service"; import { AgentTriggerService } from "../src/agent/agent-trigger.service"; +import { first, one } from "./rows"; const suffix = crypto.randomUUID(); const userId = `agent-lifecycle-user-${suffix}`; @@ -21,121 +40,137 @@ const agents = new AgentDefinitionsService( ); beforeAll(async () => { - await db.organization.upsert({ - where: { id: WORKSPACE_ID }, - update: {}, - create: { + await db + .insert(organization) + .values({ id: WORKSPACE_ID, name: DEFAULT_WORKSPACE_NAME, slug: workspaceSlug(DEFAULT_WORKSPACE_NAME), createdAt: new Date(), + }) + .onConflictDoNothing(); + await db.insert(user).values([ + { + id: userId, + name: "Agent Lifecycle Test", + email: `${userId}@example.test`, }, - }); - await db.user.createMany({ - data: [ - { - id: userId, - name: "Agent Lifecycle Test", - email: `${userId}@example.test`, - }, - { - id: teammateId, - name: "Agent Lifecycle Teammate", - email: `${teammateId}@example.test`, - }, - ], - }); - await db.member.createMany({ - data: [ - { - id: memberId, - organizationId: WORKSPACE_ID, - userId, - role: "member", - createdAt: new Date(), - }, - { - id: teammateMemberId, - organizationId: WORKSPACE_ID, - userId: teammateId, - role: "member", - createdAt: new Date(), - }, - ], - }); + { + id: teammateId, + name: "Agent Lifecycle Teammate", + email: `${teammateId}@example.test`, + }, + ]); + await db.insert(member).values([ + { + id: memberId, + organizationId: WORKSPACE_ID, + userId, + role: "member", + createdAt: new Date(), + }, + { + id: teammateMemberId, + organizationId: WORKSPACE_ID, + userId: teammateId, + role: "member", + createdAt: new Date(), + }, + ]); }); afterAll(async () => { const agentIds = ( - await db.agentDefinition.findMany({ - where: { createdById: userId }, - select: { id: true }, - }) + await db + .select({ id: agentDefinition.id }) + .from(agentDefinition) + .where(eq(agentDefinition.createdById, userId)) ).map((agent) => agent.id); if (agentIds.length > 0) { - await db.agentRunEvent.deleteMany({ - where: { run: { agentId: { in: agentIds } } }, - }); - await db.agentAction.deleteMany({ - where: { agentId: { in: agentIds } }, - }); - await db.agentAuditEvent.deleteMany({ - where: { agentId: { in: agentIds } }, - }); - await db.agentRun.deleteMany({ - where: { agentId: { in: agentIds } }, - }); - await db.agentTrigger.deleteMany({ - where: { agentId: { in: agentIds } }, - }); - await db.agentBuilderArtifact.deleteMany({ - where: { version: { agentId: { in: agentIds } } }, - }); - await db.agentDefinition.updateMany({ - where: { id: { in: agentIds } }, - data: { currentVersionId: null }, - }); - await db.agentVersion.deleteMany({ - where: { agentId: { in: agentIds } }, - }); - await db.agentDefinition.deleteMany({ - where: { id: { in: agentIds } }, - }); + await db + .delete(agentRunEvent) + .where( + inArray( + agentRunEvent.runId, + db + .select({ id: agentRun.id }) + .from(agentRun) + .where(inArray(agentRun.agentId, agentIds)), + ), + ); + await db.delete(agentAction).where(inArray(agentAction.agentId, agentIds)); + await db + .delete(agentAuditEvent) + .where(inArray(agentAuditEvent.agentId, agentIds)); + await db.delete(agentRun).where(inArray(agentRun.agentId, agentIds)); + await db + .delete(agentTrigger) + .where(inArray(agentTrigger.agentId, agentIds)); + await db + .delete(agentBuilderArtifact) + .where( + inArray( + agentBuilderArtifact.versionId, + db + .select({ id: agentVersion.id }) + .from(agentVersion) + .where(inArray(agentVersion.agentId, agentIds)), + ), + ); + await db + .update(agentDefinition) + .set({ currentVersionId: null }) + .where(inArray(agentDefinition.id, agentIds)); + await db + .delete(agentVersion) + .where(inArray(agentVersion.agentId, agentIds)); + await db + .delete(agentDefinition) + .where(inArray(agentDefinition.id, agentIds)); } - await db.agentTask.deleteMany({ - where: { kind: "slack-channel-join", reason: joinReason }, - }); - await db.member.deleteMany({ - where: { id: { in: [memberId, teammateMemberId] } }, - }); - await db.user.deleteMany({ where: { id: { in: [userId, teammateId] } } }); + await db + .delete(agentTask) + .where( + and( + eq(agentTask.kind, "slack-channel-join"), + eq(agentTask.reason, joinReason), + ), + ); + await db + .delete(member) + .where(inArray(member.id, [memberId, teammateMemberId])); + await db.delete(user).where(inArray(user.id, [userId, teammateId])); }); async function createAgent(versionCount = 1, status = "DRAFT" as const) { - const agent = await db.agentDefinition.create({ - data: { - name: `Lifecycle agent ${crypto.randomUUID()}`, - status, - createdById: userId, - }, - select: { id: true }, - }); + const agent = await one( + db + .insert(agentDefinition) + .values({ + name: `Lifecycle agent ${crypto.randomUUID()}`, + status, + createdById: userId, + }) + .returning({ id: agentDefinition.id }), + ); const versions = []; for (let number = 1; number <= versionCount; number += 1) { versions.push( - await db.agentVersion.create({ - data: { - agentId: agent.id, - number, - status: "READY", - instructions: `Version ${number}`, - manifest: {}, - modelId: "test/model", - sandboxPolicy: {}, - createdById: userId, - }, - select: { id: true, number: true }, - }), + await one( + db + .insert(agentVersion) + .values({ + agentId: agent.id, + number, + status: "READY", + instructions: `Version ${number}`, + manifest: {}, + modelId: "test/model", + sandboxPolicy: {}, + createdById: userId, + }) + .returning({ id: agentVersion.id, number: agentVersion.number }), + ), ); } return { agentId: agent.id, versions }; @@ -161,15 +196,18 @@ const capableManifest = { dataScope: { mode: "WORKSPACE", summary: "Everything", resources: [] }, }; -async function deployedAgent(manifest: Prisma.InputJsonObject) { +async function deployedAgent(manifest: JsonObject) { const { agentId, versions } = await createAgent(); const versionId = versions[0]?.id; if (!versionId) throw new Error("Missing version"); - await db.agentVersion.update({ - where: { id: versionId }, - data: { manifest }, - }); + await one( + db + .update(agentVersion) + .set({ manifest }) + .where(eq(agentVersion.id, versionId)) + .returning(), + ); await agents.deploy( { id: agentId, versionId, clientRequestId: crypto.randomUUID() }, userId, @@ -225,25 +263,37 @@ describe("agent lifecycle", () => { ); const nextRunAt = new Date("2036-08-06T12:00:00.000Z"); const lastRunAt = new Date("2036-08-05T12:00:00.000Z"); - await db.agentTrigger.create({ - data: { - agentId, - versionId, - type: "SCHEDULE", - name: "Every morning", - config: { intervalMinutes: 1440 }, - createdById: userId, - enabled: true, - nextRunAt, - lastRunAt, - }, - }); + await one( + db + .insert(agentTrigger) + .values({ + agentId, + versionId, + type: "SCHEDULE", + name: "Every morning", + config: { intervalMinutes: 1440 }, + createdById: userId, + enabled: true, + nextRunAt, + lastRunAt, + }) + .returning(), + ); const detail = await agents.byId(agentId, teammateId); const listed = await agents.list(teammateId); - const audit = await db.agentAuditEvent.findFirstOrThrow({ - where: { agentId, type: "agent.updated" }, - }); + const audit = await one( + db + .select() + .from(agentAuditEvent) + .where( + and( + eq(agentAuditEvent.agentId, agentId), + eq(agentAuditEvent.type, "agent.updated"), + ), + ) + .limit(1), + ); const listItem = listed.find((agent) => agent.id === agentId); expect(detail).toMatchObject({ id: agentId, @@ -286,19 +336,29 @@ describe("agent lifecycle", () => { const versionId = versions[0]?.id; if (!versionId) throw new Error("Missing version"); - await db.agentVersion.update({ - where: { id: versionId }, - data: { - manifest: { - name: "Pipeline health monitor", - description: "Summarize pipeline health for the team.", - }, - }, - }); - const before = await db.agentDefinition.findUniqueOrThrow({ - where: { id: agentId }, - select: { name: true, description: true, status: true }, - }); + await one( + db + .update(agentVersion) + .set({ + manifest: { + name: "Pipeline health monitor", + description: "Summarize pipeline health for the team.", + }, + }) + .where(eq(agentVersion.id, versionId)) + .returning(), + ); + const before = await one( + db + .select({ + name: agentDefinition.name, + description: agentDefinition.description, + status: agentDefinition.status, + }) + .from(agentDefinition) + .where(eq(agentDefinition.id, agentId)) + .limit(1), + ); expect(before).toMatchObject({ name: expect.stringContaining("Lifecycle agent"), description: null, @@ -310,15 +370,18 @@ describe("agent lifecycle", () => { userId, ); - const after = await db.agentDefinition.findUniqueOrThrow({ - where: { id: agentId }, - select: { - name: true, - description: true, - status: true, - currentVersionId: true, - }, - }); + const after = await one( + db + .select({ + name: agentDefinition.name, + description: agentDefinition.description, + status: agentDefinition.status, + currentVersionId: agentDefinition.currentVersionId, + }) + .from(agentDefinition) + .where(eq(agentDefinition.id, agentId)) + .limit(1), + ); expect(after).toEqual({ name: "Pipeline health monitor", description: "Summarize pipeline health for the team.", @@ -344,7 +407,13 @@ describe("agent lifecycle", () => { ]); const [definition, teamAgents] = await Promise.all([ - db.agentDefinition.findUnique({ where: { id: agentId } }), + first( + db + .select() + .from(agentDefinition) + .where(eq(agentDefinition.id, agentId)) + .limit(1), + ), agents.list(userId), ]); expect(definition?.status).toBe("DRAFT"); @@ -380,11 +449,22 @@ describe("agent lifecycle", () => { ); const [definition, deployedVersions] = await Promise.all([ - db.agentDefinition.findUnique({ where: { id: agentId } }), - db.agentVersion.findMany({ - where: { agentId, status: "DEPLOYED" }, - select: { id: true }, - }), + first( + db + .select() + .from(agentDefinition) + .where(eq(agentDefinition.id, agentId)) + .limit(1), + ), + db + .select({ id: agentVersion.id }) + .from(agentVersion) + .where( + and( + eq(agentVersion.agentId, agentId), + eq(agentVersion.status, "DEPLOYED"), + ), + ), ]); expect(definition).toMatchObject({ status: "LIVE", @@ -419,10 +499,13 @@ describe("agent lifecycle", () => { collision.filter((result) => result.status === "rejected"), ).toHaveLength(1); - const definition = await db.agentDefinition.findUniqueOrThrow({ - where: { id: agentId }, - select: { currentVersionId: true }, - }); + const definition = await one( + db + .select({ currentVersionId: agentDefinition.currentVersionId }) + .from(agentDefinition) + .where(eq(agentDefinition.id, agentId)) + .limit(1), + ); const currentVersionId = definition.currentVersionId; if (!currentVersionId) throw new Error("Missing current version"); const retries = await Promise.all( @@ -441,32 +524,43 @@ describe("agent lifecycle", () => { new Set([currentVersionId]), ); expect( - await db.agentAuditEvent.count({ - where: { agentId, type: "agent.deployed", requestId: clientRequestId }, - }), + await db.$count( + agentAuditEvent, + and( + eq(agentAuditEvent.agentId, agentId), + eq(agentAuditEvent.type, "agent.deployed"), + eq(agentAuditEvent.requestId, clientRequestId), + ), + ), ).toBe(1); expect( - await db.agentVersion.count({ - where: { agentId, status: "DEPLOYED" }, - }), + await db.$count( + agentVersion, + and( + eq(agentVersion.agentId, agentId), + eq(agentVersion.status, "DEPLOYED"), + ), + ), ).toBe(1); }); it("moves triggers onto the version a revision creates", async () => { const { agentId, versionId } = await deployedAgent(capableManifest); - const trigger = await db.agentTrigger.create({ - data: { - agentId, - versionId, - type: "SCHEDULE", - name: "Every morning", - config: { intervalMinutes: 1440 }, - createdById: userId, - enabled: true, - nextRunAt: new Date("2036-08-12T06:00:00.000Z"), - }, - select: { id: true }, - }); + const trigger = await one( + db + .insert(agentTrigger) + .values({ + agentId, + versionId, + type: "SCHEDULE", + name: "Every morning", + config: { intervalMinutes: 1440 }, + createdById: userId, + enabled: true, + nextRunAt: new Date("2036-08-12T06:00:00.000Z"), + }) + .returning({ id: agentTrigger.id }), + ); const revised = await agents.revise( { @@ -481,18 +575,30 @@ describe("agent lifecycle", () => { if (!revisedId) throw new Error("Missing revised version"); const [definition, revision, moved] = await Promise.all([ - db.agentDefinition.findUniqueOrThrow({ - where: { id: agentId }, - select: { currentVersionId: true }, - }), - db.agentVersion.findUniqueOrThrow({ - where: { id: revisedId }, - select: { manifest: true }, - }), - db.agentTrigger.findUniqueOrThrow({ - where: { id: trigger.id }, - select: { versionId: true, enabled: true }, - }), + one( + db + .select({ currentVersionId: agentDefinition.currentVersionId }) + .from(agentDefinition) + .where(eq(agentDefinition.id, agentId)) + .limit(1), + ), + one( + db + .select({ manifest: agentVersion.manifest }) + .from(agentVersion) + .where(eq(agentVersion.id, revisedId)) + .limit(1), + ), + one( + db + .select({ + versionId: agentTrigger.versionId, + enabled: agentTrigger.enabled, + }) + .from(agentTrigger) + .where(eq(agentTrigger.id, trigger.id)) + .limit(1), + ), ]); expect(revisedId).not.toBe(versionId); @@ -516,29 +622,34 @@ describe("agent lifecycle", () => { it("moves triggers onto the version a file save creates", async () => { const { agentId, versionId } = await deployedAgent(capableManifest); - await db.agentBuilderArtifact.create({ - data: { - versionId, - path: "agent/instructions.md", - language: "markdown", - content: "Watch renewals.", - revision: 1, - status: "READY", - }, - }); - const trigger = await db.agentTrigger.create({ - data: { - agentId, - versionId, - type: "SCHEDULE", - name: "Every morning", - config: { intervalMinutes: 1440 }, - createdById: userId, - enabled: true, - nextRunAt: new Date("2036-08-12T06:00:00.000Z"), - }, - select: { id: true }, - }); + await one( + db + .insert(agentBuilderArtifact) + .values({ + versionId, + path: "agent/instructions.md", + language: "markdown", + content: "Watch renewals.", + revision: 1, + status: "READY", + }) + .returning(), + ); + const trigger = await one( + db + .insert(agentTrigger) + .values({ + agentId, + versionId, + type: "SCHEDULE", + name: "Every morning", + config: { intervalMinutes: 1440 }, + createdById: userId, + enabled: true, + nextRunAt: new Date("2036-08-12T06:00:00.000Z"), + }) + .returning({ id: agentTrigger.id }), + ); const saved = await agents.saveFile( { @@ -553,18 +664,35 @@ describe("agent lifecycle", () => { if (!savedId) throw new Error("Missing saved version"); const [definition, moved, audit] = await Promise.all([ - db.agentDefinition.findUniqueOrThrow({ - where: { id: agentId }, - select: { currentVersionId: true }, - }), - db.agentTrigger.findUniqueOrThrow({ - where: { id: trigger.id }, - select: { versionId: true, enabled: true }, - }), - db.agentAuditEvent.findFirstOrThrow({ - where: { agentId, type: "agent.file.saved" }, - select: { after: true }, - }), + one( + db + .select({ currentVersionId: agentDefinition.currentVersionId }) + .from(agentDefinition) + .where(eq(agentDefinition.id, agentId)) + .limit(1), + ), + one( + db + .select({ + versionId: agentTrigger.versionId, + enabled: agentTrigger.enabled, + }) + .from(agentTrigger) + .where(eq(agentTrigger.id, trigger.id)) + .limit(1), + ), + one( + db + .select({ after: agentAuditEvent.after }) + .from(agentAuditEvent) + .where( + and( + eq(agentAuditEvent.agentId, agentId), + eq(agentAuditEvent.type, "agent.file.saved"), + ), + ) + .limit(1), + ), ]); expect(saved.saved).toBe(true); @@ -576,18 +704,21 @@ describe("agent lifecycle", () => { it("numbers a revision above every existing version", async () => { const { agentId } = await deployedAgent(capableManifest); - await db.agentVersion.create({ - data: { - agentId, - number: 9, - status: "DRAFT", - instructions: "A later draft", - manifest: capableManifest, - modelId: "test/model", - sandboxPolicy: {}, - createdById: userId, - }, - }); + await one( + db + .insert(agentVersion) + .values({ + agentId, + number: 9, + status: "DRAFT", + instructions: "A later draft", + manifest: capableManifest, + modelId: "test/model", + sandboxPolicy: {}, + createdById: userId, + }) + .returning(), + ); const revised = await agents.revise( { @@ -600,10 +731,13 @@ describe("agent lifecycle", () => { if (!revised.versionId) throw new Error("Missing revised version"); expect( - await db.agentVersion.findUniqueOrThrow({ - where: { id: revised.versionId }, - select: { number: true }, - }), + await one( + db + .select({ number: agentVersion.number }) + .from(agentVersion) + .where(eq(agentVersion.id, revised.versionId)) + .limit(1), + ), ).toEqual({ number: 10 }); }); @@ -621,14 +755,25 @@ describe("agent lifecycle", () => { if (!revised.versionId) throw new Error("Missing revised version"); const [version, join] = await Promise.all([ - db.agentVersion.findUniqueOrThrow({ - where: { id: revised.versionId }, - select: { manifest: true }, - }), - db.agentTask.findFirst({ - where: { kind: "slack-channel-join", reason: joinReason }, - select: { payload: true }, - }), + one( + db + .select({ manifest: agentVersion.manifest }) + .from(agentVersion) + .where(eq(agentVersion.id, revised.versionId)) + .limit(1), + ), + first( + db + .select({ payload: agentTask.payload }) + .from(agentTask) + .where( + and( + eq(agentTask.kind, "slack-channel-join"), + eq(agentTask.reason, joinReason), + ), + ) + .limit(1), + ), ]); expect(version.manifest).toMatchObject({ @@ -670,7 +815,9 @@ describe("agent lifecycle", () => { expect((refusal as Error).message).toBe( "None of this agent's actions post to a channel, so its channel cannot be changed.", ); - expect(await db.agentVersion.count({ where: { agentId } })).toBe(1); + expect( + await db.$count(agentVersion, eq(agentVersion.agentId, agentId)), + ).toBe(1); }); it("cannot resurrect an agent when deletion races a transition", async () => { @@ -692,10 +839,13 @@ describe("agent lifecycle", () => { ]); expect( - await db.agentDefinition.findUnique({ - where: { id: agentId }, - select: { status: true }, - }), + await first( + db + .select({ status: agentDefinition.status }) + .from(agentDefinition) + .where(eq(agentDefinition.id, agentId)) + .limit(1), + ), ).toEqual({ status: "DELETED" }); }); }); diff --git a/apps/api/test/agent-queue.spec.ts b/apps/api/test/agent-queue.spec.ts index 3a658e235..ee9267c3f 100644 --- a/apps/api/test/agent-queue.spec.ts +++ b/apps/api/test/agent-queue.spec.ts @@ -1,6 +1,13 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { db } from "@crm/db"; +import { + agentTask, + company as companyTable, + contact as contactTable, + db, + eq, +} from "@crm/db"; import { AgentQueueService } from "../src/agent/agent-queue.service"; +import { one } from "./rows"; const suffix = process.env.TEST_RUN_ID ?? "agent-queue-spec"; const email = `badge-${suffix}@example.test`; @@ -15,45 +22,52 @@ let contactId: string; let companyId: string; async function clean() { - await db.agentTask.deleteMany({ where: { kind } }); - await db.contact.deleteMany({ where: { email } }); - await db.company.deleteMany({ where: { name } }); + await db.delete(agentTask).where(eq(agentTask.kind, kind)); + await db.delete(contactTable).where(eq(contactTable.email, email)); + await db.delete(companyTable).where(eq(companyTable.name, name)); } beforeAll(async () => { await clean(); - const contact = await db.contact.create({ - data: { firstName: "Badge", lastName: "Later", email }, - select: { id: true }, - }); - const company = await db.company.create({ - data: { name }, - select: { id: true }, - }); + const contact = await one( + db + .insert(contactTable) + .values({ firstName: "Badge", lastName: "Later", email }) + .returning({ id: contactTable.id }), + ); + const company = await one( + db.insert(companyTable).values({ name }).returning({ id: companyTable.id }), + ); contactId = contact.id; companyId = company.id; - await db.agentTask.create({ - data: { - kind, - reason: "recheck in three months", - dueAt: new Date(Date.now() + 90 * DAY_MS), - budget: 4, - contactId, - }, - }); + await one( + db + .insert(agentTask) + .values({ + kind, + reason: "recheck in three months", + dueAt: new Date(Date.now() + 90 * DAY_MS), + budget: 4, + contactId, + }) + .returning(), + ); - await db.agentTask.create({ - data: { - kind, - reason: "due now", - dueAt: new Date(Date.now() - 60_000), - budget: 4, - companyId, - }, - }); + await one( + db + .insert(agentTask) + .values({ + kind, + reason: "due now", + dueAt: new Date(Date.now() - 60_000), + budget: 4, + companyId, + }) + .returning(), + ); }); afterAll(clean); diff --git a/apps/api/test/agent-runs.spec.ts b/apps/api/test/agent-runs.spec.ts index 7683b95fa..98c073813 100644 --- a/apps/api/test/agent-runs.spec.ts +++ b/apps/api/test/agent-runs.spec.ts @@ -1,10 +1,27 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; import { DEFAULT_WORKSPACE_NAME, WORKSPACE_ID } from "@crm/auth/organization"; -import { db } from "@crm/db"; +import { + agentAction, + agentAuditEvent, + agentDefinition, + agentRun, + agentRunEvent, + agentVersion, + and, + asc, + Decimal, + db, + eq, + inArray, + member, + organization, + user, +} from "@crm/db"; import { workspaceSlug } from "@crm/db/workspace"; import { AgentAccessService } from "../src/agent/agent-access.service"; import { AgentRunsService } from "../src/agent/agent-runs.service"; import { AgentTriggerService } from "../src/agent/agent-trigger.service"; +import { one } from "./rows"; const suffix = crypto.randomUUID(); const userId = `agent-run-user-${suffix}`; @@ -25,113 +42,126 @@ const trigger = { const service = new AgentRunsService(db, new AgentAccessService(db), trigger); beforeAll(async () => { - await db.organization.upsert({ - where: { id: WORKSPACE_ID }, - update: {}, - create: { + await db + .insert(organization) + .values({ id: WORKSPACE_ID, name: DEFAULT_WORKSPACE_NAME, slug: workspaceSlug(DEFAULT_WORKSPACE_NAME), createdAt: new Date(), + }) + .onConflictDoNothing(); + await db.insert(user).values([ + { + id: userId, + name: "Agent Run Test", + email: `${userId}@example.test`, }, - }); - await db.user.createMany({ - data: [ - { - id: userId, - name: "Agent Run Test", - email: `${userId}@example.test`, - }, - { - id: outsiderId, - name: "Agent Run Outsider", - email: `${outsiderId}@example.test`, - }, - ], - }); - await db.member.create({ - data: { - id: memberId, - organizationId: WORKSPACE_ID, - userId, - role: "member", - createdAt: new Date(), + { + id: outsiderId, + name: "Agent Run Outsider", + email: `${outsiderId}@example.test`, }, - }); - const agent = await db.agentDefinition.create({ - data: { name: "Run safely", status: "LIVE", createdById: userId }, - select: { id: true }, - }); + ]); + await one( + db + .insert(member) + .values({ + id: memberId, + organizationId: WORKSPACE_ID, + userId, + role: "member", + createdAt: new Date(), + }) + .returning(), + ); + const agent = await one( + db + .insert(agentDefinition) + .values({ name: "Run safely", status: "LIVE", createdById: userId }) + .returning({ id: agentDefinition.id }), + ); agentId = agent.id; - const version = await db.agentVersion.create({ - data: { - agentId, - number: 1, - status: "DEPLOYED", - instructions: "Run once.", - manifest: {}, - modelId: "test/model", - sandboxPolicy: {}, - createdById: userId, - }, - select: { id: true }, - }); + const version = await one( + db + .insert(agentVersion) + .values({ + agentId, + number: 1, + status: "DEPLOYED", + instructions: "Run once.", + manifest: {}, + modelId: "test/model", + sandboxPolicy: {}, + createdById: userId, + }) + .returning({ id: agentVersion.id }), + ); versionId = version.id; - await db.agentDefinition.update({ - where: { id: agentId }, - data: { currentVersionId: versionId }, - }); + await one( + db + .update(agentDefinition) + .set({ currentVersionId: versionId }) + .where(eq(agentDefinition.id, agentId)) + .returning(), + ); }); afterAll(async () => { const agentIds = ( - await db.agentDefinition.findMany({ - where: { createdById: userId }, - select: { id: true }, - }) + await db + .select({ id: agentDefinition.id }) + .from(agentDefinition) + .where(eq(agentDefinition.createdById, userId)) ).map((agent) => agent.id); if (agentIds.length > 0) { - await db.agentRunEvent.deleteMany({ - where: { run: { agentId: { in: agentIds } } }, - }); - await db.agentAction.deleteMany({ - where: { agentId: { in: agentIds } }, - }); - await db.agentAuditEvent.deleteMany({ - where: { agentId: { in: agentIds } }, - }); - await db.agentRun.deleteMany({ - where: { agentId: { in: agentIds } }, - }); - await db.agentDefinition.updateMany({ - where: { id: { in: agentIds } }, - data: { currentVersionId: null }, - }); - await db.agentVersion.deleteMany({ - where: { agentId: { in: agentIds } }, - }); - await db.agentDefinition.deleteMany({ - where: { id: { in: agentIds } }, - }); + await db + .delete(agentRunEvent) + .where( + inArray( + agentRunEvent.runId, + db + .select({ id: agentRun.id }) + .from(agentRun) + .where(inArray(agentRun.agentId, agentIds)), + ), + ); + await db.delete(agentAction).where(inArray(agentAction.agentId, agentIds)); + await db + .delete(agentAuditEvent) + .where(inArray(agentAuditEvent.agentId, agentIds)); + await db.delete(agentRun).where(inArray(agentRun.agentId, agentIds)); + await db + .update(agentDefinition) + .set({ currentVersionId: null }) + .where(inArray(agentDefinition.id, agentIds)); + await db + .delete(agentVersion) + .where(inArray(agentVersion.agentId, agentIds)); + await db + .delete(agentDefinition) + .where(inArray(agentDefinition.id, agentIds)); } - await db.member.deleteMany({ where: { id: memberId } }); - await db.user.deleteMany({ where: { id: { in: [userId, outsiderId] } } }); + await db.delete(member).where(eq(member.id, memberId)); + await db.delete(user).where(inArray(user.id, [userId, outsiderId])); }); afterEach(async () => { if (!agentId) return; - await db.agentRun.updateMany({ - where: { - agentId, - status: { in: ["QUEUED", "RUNNING", "WAITING_FOR_APPROVAL"] }, - }, - data: { + await db + .update(agentRun) + .set({ status: "CANCELLED", errorCode: "TEST_CLEANUP", errorMessage: "Settled between tests.", finishedAt: new Date(), - }, - }); + }) + .where( + and( + eq(agentRun.agentId, agentId), + inArray(agentRun.status, ["QUEUED", "RUNNING", "WAITING_FOR_APPROVAL"]), + ), + ); }); describe("manual agent runs", () => { @@ -143,45 +173,54 @@ describe("manual agent runs", () => { ); const startedAt = new Date("2026-08-05T12:00:00.000Z"); const completedAt = new Date("2026-08-05T12:00:02.000Z"); - await db.agentRun.update({ - where: { id: runId }, - data: { - status: "SUCCEEDED", - summary: "Prepared the account brief", - modelId: "test/model", - inputTokens: 120, - outputTokens: 80, - costUsd: "0.012345", - startedAt, - finishedAt: completedAt, - }, - }); - await db.agentRunEvent.create({ - data: { - runId, - sequence: 1, - type: "run.completed", - data: { summary: "Prepared the account brief" }, - emittedAt: completedAt, - }, - }); - await db.agentAction.create({ - data: { - agentId, - runId, - type: "timeline.note.created", - provider: "crm", - targetType: "company", - targetId: "company-1", - targetLabel: "Acme", - summary: "Added a meeting brief", - status: "SUCCEEDED", - idempotencyKey: `action-${clientRequestId}`, - plannedAt: startedAt, - startedAt, - completedAt, - }, - }); + await one( + db + .update(agentRun) + .set({ + status: "SUCCEEDED", + summary: "Prepared the account brief", + modelId: "test/model", + inputTokens: 120, + outputTokens: 80, + costUsd: new Decimal("0.012345"), + startedAt, + finishedAt: completedAt, + }) + .where(eq(agentRun.id, runId)) + .returning(), + ); + await one( + db + .insert(agentRunEvent) + .values({ + runId, + sequence: 1, + type: "run.completed", + data: { summary: "Prepared the account brief" }, + emittedAt: completedAt, + }) + .returning(), + ); + await one( + db + .insert(agentAction) + .values({ + agentId, + runId, + type: "timeline.note.created", + provider: "crm", + targetType: "company", + targetId: "company-1", + targetLabel: "Acme", + summary: "Added a meeting brief", + status: "SUCCEEDED", + idempotencyKey: `action-${clientRequestId}`, + plannedAt: startedAt, + startedAt, + completedAt, + }) + .returning(), + ); const [runs, activity] = await Promise.all([ service.list(agentId, 1, userId), @@ -226,14 +265,16 @@ describe("manual agent runs", () => { it("rejects a manual run while an agent is not live", async () => { const beforePokeCount = pokeCount; - const draft = await db.agentDefinition.create({ - data: { - name: "Draft run guard", - status: "DRAFT", - createdById: userId, - }, - select: { id: true }, - }); + const draft = await one( + db + .insert(agentDefinition) + .values({ + name: "Draft run guard", + status: "DRAFT", + createdById: userId, + }) + .returning({ id: agentDefinition.id }), + ); let runError: unknown; try { @@ -259,12 +300,17 @@ describe("manual agent runs", () => { expect(new Set(results.map((result) => result.id)).size).toBe(1); expect( - await db.agentRun.count({ where: { idempotencyKey: clientRequestId } }), + await db.$count(agentRun, eq(agentRun.idempotencyKey, clientRequestId)), ).toBe(1); expect( - await db.agentAuditEvent.count({ - where: { agentId, type: "run.requested", requestId: clientRequestId }, - }), + await db.$count( + agentAuditEvent, + and( + eq(agentAuditEvent.agentId, agentId), + eq(agentAuditEvent.type, "run.requested"), + eq(agentAuditEvent.requestId, clientRequestId), + ), + ), ).toBe(1); expect(pokeCount).toBe(beforePokeCount + 4); }); @@ -289,15 +335,22 @@ describe("manual agent runs", () => { "This agent already has an active run. Stop it or wait for it to finish.", ); expect( - await db.agentRun.count({ - where: { - agentId, - status: { in: ["QUEUED", "RUNNING", "WAITING_FOR_APPROVAL"] }, - }, - }), + await db.$count( + agentRun, + and( + eq(agentRun.agentId, agentId), + inArray(agentRun.status, [ + "QUEUED", + "RUNNING", + "WAITING_FOR_APPROVAL", + ]), + ), + ), ).toBe(1); expect( - await db.agentRun.findUniqueOrThrow({ where: { id: first.id } }), + await one( + db.select().from(agentRun).where(eq(agentRun.id, first.id)).limit(1), + ), ).toMatchObject({ status: "QUEUED" }); }); @@ -319,32 +372,40 @@ describe("manual agent runs", () => { { id: agentId, clientRequestId: crypto.randomUUID() }, userId, ); - await db.agentRun.update({ - where: { id: first.id }, - data: { - status: "FAILED", - errorCode: "TEST_FAILURE", - errorMessage: "Broke before the retry.", - finishedAt: new Date(), - }, - }); - const redeployed = await db.agentVersion.create({ - data: { - agentId, - number: 2, - status: "DEPLOYED", - instructions: "Run differently.", - manifest: {}, - modelId: "test/model", - sandboxPolicy: {}, - createdById: userId, - }, - select: { id: true }, - }); - await db.agentDefinition.update({ - where: { id: agentId }, - data: { currentVersionId: redeployed.id }, - }); + await one( + db + .update(agentRun) + .set({ + status: "FAILED", + errorCode: "TEST_FAILURE", + errorMessage: "Broke before the retry.", + finishedAt: new Date(), + }) + .where(eq(agentRun.id, first.id)) + .returning(), + ); + const redeployed = await one( + db + .insert(agentVersion) + .values({ + agentId, + number: 2, + status: "DEPLOYED", + instructions: "Run differently.", + manifest: {}, + modelId: "test/model", + sandboxPolicy: {}, + createdById: userId, + }) + .returning({ id: agentVersion.id }), + ); + await one( + db + .update(agentDefinition) + .set({ currentVersionId: redeployed.id }) + .where(eq(agentDefinition.id, agentId)) + .returning(), + ); const clientRequestId = crypto.randomUUID(); try { @@ -354,47 +415,73 @@ describe("manual agent runs", () => { ); expect( - await db.agentRun.findUniqueOrThrow({ - where: { id: retried.id }, - select: { versionId: true }, - }), + await one( + db + .select({ versionId: agentRun.versionId }) + .from(agentRun) + .where(eq(agentRun.id, retried.id)) + .limit(1), + ), ).toEqual({ versionId }); expect( - await db.agentAuditEvent.findFirstOrThrow({ - where: { agentId, type: "run.requested", requestId: clientRequestId }, - select: { versionId: true }, - }), + await one( + db + .select({ versionId: agentAuditEvent.versionId }) + .from(agentAuditEvent) + .where( + and( + eq(agentAuditEvent.agentId, agentId), + eq(agentAuditEvent.type, "run.requested"), + eq(agentAuditEvent.requestId, clientRequestId), + ), + ) + .limit(1), + ), ).toEqual({ versionId }); } finally { - await db.agentDefinition.update({ - where: { id: agentId }, - data: { currentVersionId: versionId }, - }); + await one( + db + .update(agentDefinition) + .set({ currentVersionId: versionId }) + .where(eq(agentDefinition.id, agentId)) + .returning(), + ); } }); it("allows only one agent to claim a globally reused request id", async () => { - const otherAgent = await db.agentDefinition.create({ - data: { name: "Other live agent", status: "LIVE", createdById: userId }, - select: { id: true }, - }); - const otherVersion = await db.agentVersion.create({ - data: { - agentId: otherAgent.id, - number: 1, - status: "DEPLOYED", - instructions: "Run once.", - manifest: {}, - modelId: "test/model", - sandboxPolicy: {}, - createdById: userId, - }, - select: { id: true }, - }); - await db.agentDefinition.update({ - where: { id: otherAgent.id }, - data: { currentVersionId: otherVersion.id }, - }); + const otherAgent = await one( + db + .insert(agentDefinition) + .values({ + name: "Other live agent", + status: "LIVE", + createdById: userId, + }) + .returning({ id: agentDefinition.id }), + ); + const otherVersion = await one( + db + .insert(agentVersion) + .values({ + agentId: otherAgent.id, + number: 1, + status: "DEPLOYED", + instructions: "Run once.", + manifest: {}, + modelId: "test/model", + sandboxPolicy: {}, + createdById: userId, + }) + .returning({ id: agentVersion.id }), + ); + await one( + db + .update(agentDefinition) + .set({ currentVersionId: otherVersion.id }) + .where(eq(agentDefinition.id, otherAgent.id)) + .returning(), + ); const clientRequestId = crypto.randomUUID(); const attempts = await Promise.allSettled([ @@ -408,7 +495,7 @@ describe("manual agent runs", () => { attempts.filter((attempt) => attempt.status === "rejected"), ).toHaveLength(1); expect( - await db.agentRun.count({ where: { idempotencyKey: clientRequestId } }), + await db.$count(agentRun, eq(agentRun.idempotencyKey, clientRequestId)), ).toBe(1); }); }); @@ -432,10 +519,17 @@ describe("cancelling a run", () => { expect(result.cancelled).toBe(true); expect(cancelPokes).toEqual([runId]); - const run = await db.agentRun.findUniqueOrThrow({ - where: { id: runId }, - select: { status: true, errorCode: true, finishedAt: true }, - }); + const run = await one( + db + .select({ + status: agentRun.status, + errorCode: agentRun.errorCode, + finishedAt: agentRun.finishedAt, + }) + .from(agentRun) + .where(eq(agentRun.id, runId)) + .limit(1), + ); expect(run.status).toBe("CANCELLED"); expect(run.errorCode).toBe("CANCELLED_BY_USER"); expect(run.finishedAt).not.toBeNull(); @@ -445,54 +539,62 @@ describe("cancelling a run", () => { const runId = await queuedRun(); await service.cancelRun({ id: agentId, runId }, userId); - const events = await db.agentRunEvent.findMany({ - where: { runId, type: "run.cancelled" }, - select: { id: true }, - }); + const events = await db + .select({ id: agentRunEvent.id }) + .from(agentRunEvent) + .where( + and( + eq(agentRunEvent.runId, runId), + eq(agentRunEvent.type, "run.cancelled"), + ), + ); expect(events).toHaveLength(1); expect(events[0]?.id).toBe(`run-terminal:${runId}:cancelled`); expect( - await db.agentAuditEvent.count({ - where: { agentId, type: "run.cancelled", requestId: runId }, - }), + await db.$count( + agentAuditEvent, + and( + eq(agentAuditEvent.agentId, agentId), + eq(agentAuditEvent.type, "run.cancelled"), + eq(agentAuditEvent.requestId, runId), + ), + ), ).toBe(1); }); it("cancels outstanding actions so nothing is left running", async () => { const runId = await queuedRun(); - await db.agentAction.createMany({ - data: [ - { - agentId, - runId, - type: "crm.activity.create", - provider: "crm", - summary: "Planned note", - status: "PLANNED", - idempotencyKey: `${runId}:planned`, - requestHash: "planned", - }, - { - agentId, - runId, - type: "slack.message.post", - provider: "slack", - summary: "Already posted", - status: "SUCCEEDED", - idempotencyKey: `${runId}:done`, - requestHash: "done", - }, - ], - }); + await db.insert(agentAction).values([ + { + agentId, + runId, + type: "crm.activity.create", + provider: "crm", + summary: "Planned note", + status: "PLANNED", + idempotencyKey: `${runId}:planned`, + requestHash: "planned", + }, + { + agentId, + runId, + type: "slack.message.post", + provider: "slack", + summary: "Already posted", + status: "SUCCEEDED", + idempotencyKey: `${runId}:done`, + requestHash: "done", + }, + ]); await service.cancelRun({ id: agentId, runId }, userId); - const actions = await db.agentAction.findMany({ - where: { runId }, - orderBy: { idempotencyKey: "asc" }, - select: { status: true }, - }); + const actions = await db + .select({ status: agentAction.status }) + .from(agentAction) + .where(eq(agentAction.runId, runId)) + .orderBy(asc(agentAction.idempotencyKey)); expect(actions.map((action) => action.status)).toEqual([ "SUCCEEDED", "CANCELLED", @@ -510,16 +612,25 @@ describe("cancelling a run", () => { expect(again.status).toBe("CANCELLED"); expect(cancelPokes).toEqual([]); expect( - await db.agentRunEvent.count({ where: { runId, type: "run.cancelled" } }), + await db.$count( + agentRunEvent, + and( + eq(agentRunEvent.runId, runId), + eq(agentRunEvent.type, "run.cancelled"), + ), + ), ).toBe(1); }); it("leaves a finished run alone", async () => { const runId = await queuedRun(); - await db.agentRun.update({ - where: { id: runId }, - data: { status: "SUCCEEDED", finishedAt: new Date() }, - }); + await one( + db + .update(agentRun) + .set({ status: "SUCCEEDED", finishedAt: new Date() }) + .where(eq(agentRun.id, runId)) + .returning(), + ); const result = await service.cancelRun({ id: agentId, runId }, userId); @@ -529,10 +640,12 @@ describe("cancelling a run", () => { it("refuses a run id that belongs to another agent", async () => { const runId = await queuedRun(); - const other = await db.agentDefinition.create({ - data: { name: "Unrelated", status: "LIVE", createdById: userId }, - select: { id: true }, - }); + const other = await one( + db + .insert(agentDefinition) + .values({ name: "Unrelated", status: "LIVE", createdById: userId }) + .returning({ id: agentDefinition.id }), + ); let error: Error | null = null; try { @@ -569,10 +682,13 @@ describe("cancelling a run", () => { it("asks the agent again until the cancellation lands", async () => { const runId = await queuedRun(); - await db.agentRun.update({ - where: { id: runId }, - data: { status: "RUNNING", startedAt: new Date() }, - }); + await one( + db + .update(agentRun) + .set({ status: "RUNNING", startedAt: new Date() }) + .where(eq(agentRun.id, runId)) + .returning(), + ); await service.cancelRun({ id: agentId, runId }, userId); const trigger = new AgentTriggerService(db); diff --git a/apps/api/test/agent-trigger-bulk.spec.ts b/apps/api/test/agent-trigger-bulk.spec.ts index 78e18c466..7246713ca 100644 --- a/apps/api/test/agent-trigger-bulk.spec.ts +++ b/apps/api/test/agent-trigger-bulk.spec.ts @@ -1,22 +1,26 @@ import { describe, expect, it } from "bun:test"; -import type { Db, Prisma } from "@crm/db"; +import type { Db, NewAgentTask } from "@crm/db"; import { AgentTriggerService } from "../src/agent/agent-trigger.service"; function service() { - const writes: Prisma.AgentTaskCreateManyInput[][] = []; - const agentTask = { - createMany: async ({ data }: Prisma.AgentTaskCreateManyArgs) => { + const writes: NewAgentTask[][] = []; + const insert = () => ({ + values: (data: NewAgentTask | NewAgentTask[]) => { const rows = Array.isArray(data) ? data : [data]; writes.push(rows); - return { count: rows.length }; + const result = Promise.resolve({ rowCount: rows.length }); + return Object.assign(result, { + returning: async () => + rows.map((_, index) => ({ id: `task-${index}` })), + }); }, - }; - const transaction = { agentTask } as unknown as Prisma.TransactionClient; + }); + type Transaction = { insert: typeof insert }; + const transaction: Transaction = { insert }; const db = { - agentTask, - $transaction: async ( - work: (tx: Prisma.TransactionClient) => Promise, - ) => work(transaction), + insert, + transaction: async (work: (tx: Transaction) => Promise) => + work(transaction), } as unknown as Db; return { trigger: new AgentTriggerService(db), writes }; diff --git a/apps/api/test/agent-trigger.stub.ts b/apps/api/test/agent-trigger.stub.ts index 6661d7890..fba5a36a7 100644 --- a/apps/api/test/agent-trigger.stub.ts +++ b/apps/api/test/agent-trigger.stub.ts @@ -1,11 +1,11 @@ -import { db, type Prisma } from "@crm/db"; +import { db, type Executor } from "@crm/db"; import type { CrmEventInput } from "../src/agent/agent-trigger.service"; export function withDiscardedCrmEvents( work: ( - tx: Prisma.TransactionClient, + tx: Executor, emit: (input: CrmEventInput) => Promise, ) => Promise, ): Promise { - return db.$transaction((tx) => work(tx, async () => undefined)); + return db.transaction((tx) => work(tx, async () => undefined)); } diff --git a/apps/api/test/bulk.spec.ts b/apps/api/test/bulk.spec.ts index 9a0315188..6ba334cc0 100644 --- a/apps/api/test/bulk.spec.ts +++ b/apps/api/test/bulk.spec.ts @@ -1,5 +1,19 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { db } from "@crm/db"; +import { + activity, + agentTask, + and, + company as companyTable, + contact as contactTable, + db, + deal as dealTable, + endsWith, + eq, + inArray, + type PgColumn, + suppressedContact, + user, +} from "@crm/db"; import { AgentQueueService } from "../src/agent/agent-queue.service"; import type { AgentTriggerService } from "../src/agent/agent-trigger.service"; import { CompaniesService } from "../src/companies/companies.service"; @@ -11,12 +25,13 @@ import { ConversionService } from "../src/currency/conversion.service"; import { DealsService } from "../src/deals/deals.service"; import { FieldsService } from "../src/fields/fields.service"; import { withDiscardedCrmEvents } from "./agent-trigger.stub"; +import { first, one } from "./rows"; const suffix = process.env.TEST_RUN_ID ?? "bulk-spec"; const domain = `bulk-${suffix}.test`; const ownerId = `owner-${suffix}`; const secondOwnerId = `second-owner-${suffix}`; -const ours = { OR: [{ email: { endsWith: `@${domain}` } }] }; +const ours = (email: PgColumn) => endsWith(email, `@${domain}`); const agent = { contactCreated: async () => true, @@ -53,35 +68,35 @@ const deals = new DealsService(db, agent, stamp, conversion, fields); let companyId: string; async function clean() { - const owned = await db.company.findMany({ - where: { domain: { endsWith: domain } }, - select: { id: true }, - }); + const owned = await db + .select({ id: companyTable.id }) + .from(companyTable) + .where(endsWith(companyTable.domain, domain)); const companyIds = owned.map((row) => row.id); - await db.deal.deleteMany({ where: { companyId: { in: companyIds } } }); - await db.activity.deleteMany({ where: { companyId: { in: companyIds } } }); - await db.agentTask.deleteMany({ where: { companyId: { in: companyIds } } }); - await db.contact.deleteMany({ where: ours }); - await db.suppressedContact.deleteMany({ where: ours }); - await db.company.deleteMany({ where: { domain: { endsWith: domain } } }); - await db.user.deleteMany({ where: { id: { in: [ownerId, secondOwnerId] } } }); + await db.delete(dealTable).where(inArray(dealTable.companyId, companyIds)); + await db.delete(activity).where(inArray(activity.companyId, companyIds)); + await db.delete(agentTask).where(inArray(agentTask.companyId, companyIds)); + await db.delete(contactTable).where(ours(contactTable.email)); + await db.delete(suppressedContact).where(ours(suppressedContact.email)); + await db.delete(companyTable).where(endsWith(companyTable.domain, domain)); + await db.delete(user).where(inArray(user.id, [ownerId, secondOwnerId])); } beforeAll(async () => { await clean(); - await db.user.createMany({ - data: [ - { id: ownerId, name: "First Rep", email: `first@${domain}` }, - { id: secondOwnerId, name: "Second Rep", email: `second@${domain}` }, - ], - }); - - const company = await db.company.create({ - data: { name: `Bulk Co ${suffix}`, domain }, - select: { id: true }, - }); + await db.insert(user).values([ + { id: ownerId, name: "First Rep", email: `first@${domain}` }, + { id: secondOwnerId, name: "Second Rep", email: `second@${domain}` }, + ]); + + const company = await one( + db + .insert(companyTable) + .values({ name: `Bulk Co ${suffix}`, domain }) + .returning({ id: companyTable.id }), + ); companyId = company.id; }); @@ -114,9 +129,13 @@ describe("assigning an owner to a selection", () => { }); expect( - await db.contact.count({ - where: { id: { in: [first.id, second.id] }, ownerId: secondOwnerId }, - }), + await db.$count( + contactTable, + and( + inArray(contactTable.id, [first.id, second.id]), + eq(contactTable.ownerId, secondOwnerId), + ), + ), ).toBe(2); }); @@ -158,10 +177,13 @@ describe("assigning an owner to a selection", () => { expect(refused?.message).toMatch(/does not work here/); expect( - await db.contact.findUnique({ - where: { id: contact.id }, - select: { ownerId: true }, - }), + await first( + db + .select({ ownerId: contactTable.ownerId }) + .from(contactTable) + .where(eq(contactTable.id, contact.id)) + .limit(1), + ), ).toEqual({ ownerId: null }); }); }); @@ -186,9 +208,13 @@ describe("purging a selection", () => { }); expect( - await db.suppressedContact.count({ - where: { email: { in: [`gone@${domain}`, `also-gone@${domain}`] } }, - }), + await db.$count( + suppressedContact, + inArray(suppressedContact.email, [ + `gone@${domain}`, + `also-gone@${domain}`, + ]), + ), ).toBe(2); }); @@ -204,7 +230,13 @@ describe("purging a selection", () => { expect(result.failed).toBe(1); expect(result.message).toMatch(/No contact with id/); expect( - await db.contact.findUnique({ where: { id: survivor.id } }), + await first( + db + .select() + .from(contactTable) + .where(eq(contactTable.id, survivor.id)) + .limit(1), + ), ).toBeNull(); }); @@ -227,7 +259,11 @@ describe("purging a selection", () => { message: null, }); - expect(await db.deal.findUnique({ where: { id: deal.id } })).toBeNull(); + expect( + await first( + db.select().from(dealTable).where(eq(dealTable.id, deal.id)).limit(1), + ), + ).toBeNull(); }); }); @@ -251,10 +287,13 @@ describe("moving a selection of deals to a stage", () => { expect(refused?.message).toMatch(/teaches nobody anything/); expect( - await db.deal.findUnique({ - where: { id: deal.id }, - select: { stage: true }, - }), + await first( + db + .select({ stage: dealTable.stage }) + .from(dealTable) + .where(eq(dealTable.id, deal.id)) + .limit(1), + ), ).toEqual({ stage: "DEMO_BOOKED" }); }); @@ -287,10 +326,14 @@ describe("moving a selection of deals to a stage", () => { message: null, }); - const closed = await db.deal.findMany({ - where: { id: { in: [first.id, second.id] } }, - select: { stage: true, closedReason: true, closedAt: true }, - }); + const closed = await db + .select({ + stage: dealTable.stage, + closedReason: dealTable.closedReason, + closedAt: dealTable.closedAt, + }) + .from(dealTable) + .where(inArray(dealTable.id, [first.id, second.id])); expect(closed.every((deal) => deal.stage === "CLOSED_LOST")).toBe(true); expect(closed.every((deal) => deal.closedReason === "Budget pulled")).toBe( @@ -299,13 +342,14 @@ describe("moving a selection of deals to a stage", () => { expect(closed.every((deal) => deal.closedAt !== null)).toBe(true); expect( - await db.activity.count({ - where: { - dealId: { in: [first.id, second.id] }, - type: "STAGE_CHANGE", - body: "Budget pulled", - }, - }), + await db.$count( + activity, + and( + inArray(activity.dealId, [first.id, second.id]), + eq(activity.type, "STAGE_CHANGE"), + eq(activity.body, "Budget pulled"), + ), + ), ).toBe(2); }); }); diff --git a/apps/api/test/company-import.spec.ts b/apps/api/test/company-import.spec.ts index f202e1513..3ec94ec45 100644 --- a/apps/api/test/company-import.spec.ts +++ b/apps/api/test/company-import.spec.ts @@ -1,5 +1,12 @@ import { afterAll, describe, expect, it } from "bun:test"; -import { db } from "@crm/db"; +import { + agentTask, + asc, + company as companyTable, + db, + type Executor, + inArray, +} from "@crm/db"; import { AgentQueueService } from "../src/agent/agent-queue.service"; import type { AgentTriggerService, @@ -26,11 +33,11 @@ const agent = { }, withCrmEvents: async ( work: ( - tx: Parameters[0]>[0], + tx: Executor, emit: (input: CrmEventInput) => Promise, ) => Promise, ) => - db.$transaction((tx) => + db.transaction((tx) => work(tx, async (input) => { events.push(input); }), @@ -58,10 +65,10 @@ const companies = new CompaniesService( ); async function clean() { - await db.agentTask.deleteMany({ where: { companyId: { in: importedIds } } }); - await db.company.deleteMany({ - where: { domain: { in: [domain, secondDomain] } }, - }); + await db.delete(agentTask).where(inArray(agentTask.companyId, importedIds)); + await db + .delete(companyTable) + .where(inArray(companyTable.domain, [domain, secondDomain])); } afterAll(clean); @@ -84,19 +91,19 @@ describe("importing companies", () => { ], }); - const stored = await db.company.findMany({ - where: { domain: { in: [domain, secondDomain] } }, - orderBy: { name: "asc" }, - select: { - id: true, - name: true, - domain: true, - website: true, - industry: true, - source: true, - enrichmentStatus: true, - }, - }); + const stored = await db + .select({ + id: companyTable.id, + name: companyTable.name, + domain: companyTable.domain, + website: companyTable.website, + industry: companyTable.industry, + source: companyTable.source, + enrichmentStatus: companyTable.enrichmentStatus, + }) + .from(companyTable) + .where(inArray(companyTable.domain, [domain, secondDomain])) + .orderBy(asc(companyTable.name)); importedIds.push(...stored.map((company) => company.id)); const [first, second] = stored; if (!first || !second) { diff --git a/apps/api/test/company-requested.spec.ts b/apps/api/test/company-requested.spec.ts index 138a1d36d..ea4e066b5 100644 --- a/apps/api/test/company-requested.spec.ts +++ b/apps/api/test/company-requested.spec.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { db } from "@crm/db"; +import { agentTask, and, company, db, eq, isNull } from "@crm/db"; import { AgentTriggerService } from "../src/agent/agent-trigger.service"; const suffix = process.env.TEST_RUN_ID ?? "company-requested-spec"; @@ -12,20 +12,22 @@ let companyId: string; let bridgeSecret: string | undefined; async function clean() { - if (companyId) await db.agentTask.deleteMany({ where: { companyId } }); - await db.company.deleteMany({ where: { name } }); + if (companyId) + await db.delete(agentTask).where(eq(agentTask.companyId, companyId)); + await db.delete(company).where(eq(company.name, name)); } beforeAll(async () => { bridgeSecret = process.env.AGENT_BRIDGE_SECRET; process.env.AGENT_BRIDGE_SECRET = ""; - await db.company.deleteMany({ where: { name } }); - const company = await db.company.create({ - data: { name, domain: `requested-${suffix}.test`.toLowerCase() }, - select: { id: true }, - }); - companyId = company.id; + await db.delete(company).where(eq(company.name, name)); + const [created] = await db + .insert(company) + .values({ name, domain: `requested-${suffix}.test`.toLowerCase() }) + .returning({ id: company.id }); + if (!created) throw new Error("Could not seed the company."); + companyId = created.id; }); afterAll(async () => { @@ -44,10 +46,16 @@ describe("asking for a fresh look", () => { expect(await agent.companyRequested(companyId, reason)).toBe(false); - await db.agentTask.updateMany({ - where: { companyId, reason, finishedAt: null }, - data: { finishedAt: new Date(), outcome: "done" }, - }); + await db + .update(agentTask) + .set({ finishedAt: new Date(), outcome: "done" }) + .where( + and( + eq(agentTask.companyId, companyId), + eq(agentTask.reason, reason), + isNull(agentTask.finishedAt), + ), + ); expect(await agent.companyRequested(companyId, reason)).toBe(true); }); diff --git a/apps/api/test/conversation-sharing.spec.ts b/apps/api/test/conversation-sharing.spec.ts index 8c2fbe7d6..2cdbd4936 100644 --- a/apps/api/test/conversation-sharing.spec.ts +++ b/apps/api/test/conversation-sharing.spec.ts @@ -1,10 +1,25 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { DEFAULT_WORKSPACE_NAME, WORKSPACE_ID } from "@crm/auth/organization"; -import { db } from "@crm/db"; +import { + agentConversation, + agentConversationAttachment, + agentConversationShare, + agentConversationSubmission, + agentEvent, + and, + db, + eq, + inArray, + isNull, + member, + organization, + user, +} from "@crm/db"; import { workspaceSlug } from "@crm/db/workspace"; import { z } from "zod"; import { ConversationSharingService } from "../src/conversations/conversation-sharing.service"; import { ConversationsService } from "../src/conversations/conversations.service"; +import { one } from "./rows"; const record = z.record(z.string(), z.unknown()).catch({}); @@ -23,93 +38,94 @@ const service = new ConversationSharingService(db); const conversations = new ConversationsService(db); beforeAll(async () => { - await db.organization.upsert({ - where: { id: WORKSPACE_ID }, - update: {}, - create: { + await db + .insert(organization) + .values({ id: WORKSPACE_ID, name: DEFAULT_WORKSPACE_NAME, slug: workspaceSlug(DEFAULT_WORKSPACE_NAME), createdAt: new Date(), + }) + .onConflictDoNothing(); + await db.insert(user).values([ + { id: userId, name: "Share Owner", email: `${userId}@example.test` }, + { + id: outsiderId, + name: "Share Outsider", + email: `${outsiderId}@example.test`, }, - }); - await db.user.createMany({ - data: [ - { id: userId, name: "Share Owner", email: `${userId}@example.test` }, - { - id: outsiderId, - name: "Share Outsider", - email: `${outsiderId}@example.test`, - }, - { - id: viewerId, - name: "Share Viewer", - email: `${viewerId}@example.test`, - }, - ], - }); - await db.member.createMany({ - data: [ - { - id: memberId, - organizationId: WORKSPACE_ID, - userId, - role: "member", - createdAt: new Date(), - }, - { - id: viewerMemberId, - organizationId: WORKSPACE_ID, - userId: viewerId, - role: "member", - createdAt: new Date(), - }, - ], - }); - const conversation = await db.agentConversation.create({ - data: { - kind: "BUILDER", + { + id: viewerId, + name: "Share Viewer", + email: `${viewerId}@example.test`, + }, + ]); + await db.insert(member).values([ + { + id: memberId, + organizationId: WORKSPACE_ID, userId, - title: "Share safely", - sessionId, + role: "member", + createdAt: new Date(), }, - select: { id: true }, - }); + { + id: viewerMemberId, + organizationId: WORKSPACE_ID, + userId: viewerId, + role: "member", + createdAt: new Date(), + }, + ]); + const conversation = await one( + db + .insert(agentConversation) + .values({ + kind: "BUILDER", + userId, + title: "Share safely", + sessionId, + }) + .returning({ id: agentConversation.id }), + ); conversationId = conversation.id; - const submission = await db.agentConversationSubmission.create({ - data: { - conversationId, - submittedById: userId, - clientRequestId: crypto.randomUUID(), - message: { - text: "Review the image", - resources: [], - attachments: [{ name: "shared.png", type: "image/png", size: 4 }], - }, - attachments: { - create: { - name: "shared.png", - mediaType: "image/png", - size: 4, - content: Buffer.from([1, 2, 3, 4]), - position: 0, + const submission = await one( + db + .insert(agentConversationSubmission) + .values({ + conversationId, + submittedById: userId, + clientRequestId: crypto.randomUUID(), + message: { + text: "Review the image", + resources: [], + attachments: [{ name: "shared.png", type: "image/png", size: 4 }], }, - }, - }, - select: { attachments: { select: { id: true } } }, - }); - attachmentId = submission.attachments[0]?.id ?? ""; + }) + .returning({ id: agentConversationSubmission.id }), + ); + const attachment = await one( + db + .insert(agentConversationAttachment) + .values({ + submissionId: submission.id, + name: "shared.png", + mediaType: "image/png", + size: 4, + content: Buffer.from([1, 2, 3, 4]), + position: 0, + }) + .returning({ id: agentConversationAttachment.id }), + ); + attachmentId = attachment.id; }); afterAll(async () => { - await db.agentEvent.deleteMany({ where: { sessionId } }); - await db.agentConversation.deleteMany({ where: { id: conversationId } }); - await db.member.deleteMany({ - where: { id: { in: [memberId, viewerMemberId] } }, - }); - await db.user.deleteMany({ - where: { id: { in: [userId, outsiderId, viewerId] } }, - }); + await db.delete(agentEvent).where(eq(agentEvent.sessionId, sessionId)); + await db + .delete(agentConversation) + .where(eq(agentConversation.id, conversationId)); + await db.delete(member).where(inArray(member.id, [memberId, viewerMemberId])); + await db.delete(user).where(inArray(user.id, [userId, outsiderId, viewerId])); }); describe("conversation sharing", () => { @@ -149,10 +165,15 @@ describe("conversation sharing", () => { const results = await Promise.all( Array.from({ length: 4 }, () => service.create(conversationId, userId)), ); - const activeShares = await db.agentConversationShare.findMany({ - where: { conversationId, revokedAt: null }, - select: { tokenHash: true }, - }); + const activeShares = await db + .select({ tokenHash: agentConversationShare.tokenHash }) + .from(agentConversationShare) + .where( + and( + eq(agentConversationShare.conversationId, conversationId), + isNull(agentConversationShare.revokedAt), + ), + ); expect(activeShares).toHaveLength(1); const resolutions = await Promise.allSettled( @@ -206,9 +227,13 @@ describe("conversation sharing", () => { await service.revoke(conversationId, userId); expect( - await db.agentConversationShare.count({ - where: { conversationId, revokedAt: null }, - }), + await db.$count( + agentConversationShare, + and( + eq(agentConversationShare.conversationId, conversationId), + isNull(agentConversationShare.revokedAt), + ), + ), ).toBe(0); let unavailable: unknown; try { diff --git a/apps/api/test/conversations.spec.ts b/apps/api/test/conversations.spec.ts index d2b5f335a..8fd96c495 100644 --- a/apps/api/test/conversations.spec.ts +++ b/apps/api/test/conversations.spec.ts @@ -1,6 +1,20 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { DEFAULT_WORKSPACE_NAME, WORKSPACE_ID } from "@crm/auth/organization"; -import { db } from "@crm/db"; +import { + agentBuilderArtifact, + agentConversation, + agentConversationSubmission, + agentEvent, + and, + contact as contactTable, + db, + eq, + member, + or, + organization, + startsWith, + user, +} from "@crm/db"; import { workspaceSlug } from "@crm/db/workspace"; import { z } from "zod"; import { @@ -9,6 +23,7 @@ import { conversationSaveInput, } from "../src/conversations/conversations.contracts"; import { ConversationsService } from "../src/conversations/conversations.service"; +import { first, one } from "./rows"; const record = z.record(z.string(), z.unknown()).catch({}); @@ -25,63 +40,74 @@ let contactId: string; let service: ConversationsService; beforeAll(async () => { - await db.agentEvent.deleteMany({ - where: { - OR: [ - { sessionId: { startsWith: `builder-question-${suffix}` } }, - { sessionId: { startsWith: `ses_${suffix}` } }, - ], - }, - }); - await db.agentConversation.deleteMany({ where: { userId } }); - await db.member.deleteMany({ where: { id: memberId } }); - await db.user.deleteMany({ where: { id: userId } }); - await db.contact.deleteMany({ where: { email } }); - await db.organization.upsert({ - where: { id: WORKSPACE_ID }, - update: {}, - create: { + await db + .delete(agentEvent) + .where( + or( + startsWith(agentEvent.sessionId, `builder-question-${suffix}`), + startsWith(agentEvent.sessionId, `ses_${suffix}`), + ), + ); + await db + .delete(agentConversation) + .where(eq(agentConversation.userId, userId)); + await db.delete(member).where(eq(member.id, memberId)); + await db.delete(user).where(eq(user.id, userId)); + await db.delete(contactTable).where(eq(contactTable.email, email)); + await db + .insert(organization) + .values({ id: WORKSPACE_ID, name: DEFAULT_WORKSPACE_NAME, slug: workspaceSlug(DEFAULT_WORKSPACE_NAME), createdAt: new Date(), - }, - }); - - await db.user.create({ - data: { id: userId, name: "Test Rep", email: `${userId}@example.test` }, - }); - await db.member.create({ - data: { - id: memberId, - organizationId: WORKSPACE_ID, - userId, - role: "member", - createdAt: new Date(), - }, - }); - const contact = await db.contact.create({ - data: { firstName: "Conversation", lastName: "Subject", email }, - select: { id: true }, - }); + }) + .onConflictDoNothing(); + + await one( + db + .insert(user) + .values({ id: userId, name: "Test Rep", email: `${userId}@example.test` }) + .returning(), + ); + await one( + db + .insert(member) + .values({ + id: memberId, + organizationId: WORKSPACE_ID, + userId, + role: "member", + createdAt: new Date(), + }) + .returning(), + ); + const contact = await one( + db + .insert(contactTable) + .values({ firstName: "Conversation", lastName: "Subject", email }) + .returning({ id: contactTable.id }), + ); contactId = contact.id; service = new ConversationsService(db); }); afterAll(async () => { - await db.agentEvent.deleteMany({ - where: { - OR: [ - { sessionId: { startsWith: `builder-question-${suffix}` } }, - { sessionId: { startsWith: `ses_${suffix}` } }, - ], - }, - }); - await db.contact.deleteMany({ where: { email } }); - await db.agentConversation.deleteMany({ where: { userId } }); - await db.member.deleteMany({ where: { id: memberId } }); - await db.user.deleteMany({ where: { id: userId } }); + await db + .delete(agentEvent) + .where( + or( + startsWith(agentEvent.sessionId, `builder-question-${suffix}`), + startsWith(agentEvent.sessionId, `ses_${suffix}`), + ), + ); + await db.delete(contactTable).where(eq(contactTable.email, email)); + await db + .delete(agentConversation) + .where(eq(agentConversation.userId, userId)); + await db.delete(member).where(eq(member.id, memberId)); + await db.delete(user).where(eq(user.id, userId)); }); describe("ConversationsService", () => { @@ -205,10 +231,16 @@ describe("ConversationsService", () => { expect(ownershipError).toBeDefined(); expect( - await db.agentConversation.findUnique({ - where: { sessionId }, - select: { continuationToken: true, streamIndex: true }, - }), + await first( + db + .select({ + continuationToken: agentConversation.continuationToken, + streamIndex: agentConversation.streamIndex, + }) + .from(agentConversation) + .where(eq(agentConversation.sessionId, sessionId)) + .limit(1), + ), ).toEqual({ continuationToken: "owner-token", streamIndex: 3 }); }); @@ -235,7 +267,12 @@ describe("ConversationsService", () => { ); expect(new Set(results.map((result) => result.id)).size).toBe(1); - expect(await db.agentConversation.count({ where: { sessionId } })).toBe(1); + expect( + await db.$count( + agentConversation, + eq(agentConversation.sessionId, sessionId), + ), + ).toBe(1); }); it("does not treat a builder session as a record conversation", async () => { @@ -250,10 +287,13 @@ describe("ConversationsService", () => { userId, ); const sessionId = `ses_${suffix}_builder`; - await db.agentConversation.update({ - where: { id: builder.id }, - data: { sessionId }, - }); + await one( + db + .update(agentConversation) + .set({ sessionId }) + .where(eq(agentConversation.id, builder.id)) + .returning(), + ); let saveError: unknown; try { @@ -263,10 +303,16 @@ describe("ConversationsService", () => { } expect(saveError).toBeDefined(); expect( - await db.agentConversation.findUnique({ - where: { id: builder.id }, - select: { kind: true, contactId: true }, - }), + await first( + db + .select({ + kind: agentConversation.kind, + contactId: agentConversation.contactId, + }) + .from(agentConversation) + .where(eq(agentConversation.id, builder.id)) + .limit(1), + ), ).toEqual({ kind: "BUILDER", contactId: null }); }); @@ -280,8 +326,8 @@ describe("ConversationsService", () => { "step.started", "step.completed", ]; - await db.agentEvent.createMany({ - data: eventTypes.map((type, position) => ({ + await db.insert(agentEvent).values( + eventTypes.map((type, position) => ({ id: `evt_${suffix}_window_${position}`, sessionId, contactId, @@ -289,7 +335,7 @@ describe("ConversationsService", () => { data: {}, emittedAt: new Date(emittedAt.getTime() + position), })), - }); + ); expect( (await service.events({ id: saved.id, limit: 2 }, userId)).map( @@ -310,54 +356,55 @@ describe("ConversationsService", () => { userId, ); const rootSessionId = `builder-question-${suffix}-root`; - await db.agentConversation.update({ - where: { id: builder.id }, - data: { sessionId: rootSessionId }, - }); + await one( + db + .update(agentConversation) + .set({ sessionId: rootSessionId }) + .where(eq(agentConversation.id, builder.id)) + .returning(), + ); const emittedAt = new Date("2026-08-05T13:00:00.000Z"); - await db.agentEvent.createMany({ - data: [ - { - id: `evt_${suffix}_builder_root`, - sessionId: rootSessionId, - conversationId: builder.id, - type: "actions.requested", - data: { - actions: [ - { + await db.insert(agentEvent).values([ + { + id: `evt_${suffix}_builder_root`, + sessionId: rootSessionId, + conversationId: builder.id, + type: "actions.requested", + data: { + actions: [ + { + kind: "tool-call", + callId: "call-root", + toolName: "search_crm", + input: {}, + }, + ], + }, + emittedAt, + }, + { + id: `evt_${suffix}_builder_child`, + sessionId: `builder-question-${suffix}-child`, + conversationId: builder.id, + type: "input.requested", + data: { + requests: [ + { + requestId: "request-child", + kind: "question", + action: { kind: "tool-call", - callId: "call-root", - toolName: "search_crm", + callId: "call-child", + toolName: "ask_user", input: {}, }, - ], - }, - emittedAt, - }, - { - id: `evt_${suffix}_builder_child`, - sessionId: `builder-question-${suffix}-child`, - conversationId: builder.id, - type: "input.requested", - data: { - requests: [ - { - requestId: "request-child", - kind: "question", - action: { - kind: "tool-call", - callId: "call-child", - toolName: "ask_user", - input: {}, - }, - prompt: "Choose one option.", - }, - ], - }, - emittedAt: new Date(emittedAt.getTime() + 1), + prompt: "Choose one option.", + }, + ], }, - ], - }); + emittedAt: new Date(emittedAt.getTime() + 1), + }, + ]); expect( (await service.events({ id: builder.id, limit: 10 }, userId)).map( @@ -380,12 +427,15 @@ describe("ConversationsService resume", () => { userId, ); const sessionId = `builder-resume-${suffix}`; - await db.agentConversation.update({ - where: { id: builder.id }, - data: { sessionId, streamIndex: 2 }, - }); - await db.agentEvent.createMany({ - data: [0, 1].map((streamIndex) => ({ + await one( + db + .update(agentConversation) + .set({ sessionId, streamIndex: 2 }) + .where(eq(agentConversation.id, builder.id)) + .returning(), + ); + await db.insert(agentEvent).values( + [0, 1].map((streamIndex) => ({ id: `evt_${suffix}_resume_${streamIndex}`, sessionId, conversationId: builder.id, @@ -395,7 +445,7 @@ describe("ConversationsService resume", () => { data: { message: `Message ${streamIndex}` }, emittedAt: new Date(1_786_000_000_000 + streamIndex), })), - }); + ); const reloaded = await service.builderById(builder.id, userId); @@ -412,40 +462,51 @@ describe("ConversationsService continued", () => { const sessionId = `ses_${suffix}_delete`; const conversation = await service.save({ contactId, sessionId }, userId); - await db.agentEvent.create({ - data: { - id: `evt_${suffix}`, - sessionId, - contactId, - type: "turn.completed", - data: {}, - emittedAt: new Date(), - }, - }); - await db.agentBuilderArtifact.create({ - data: { - conversationId: conversation.id, - path: "agent/instructions.md", - language: "markdown", - content: "Temporary draft", - revision: 1, - }, - }); + await one( + db + .insert(agentEvent) + .values({ + id: `evt_${suffix}`, + sessionId, + contactId, + type: "turn.completed", + data: {}, + emittedAt: new Date(), + }) + .returning(), + ); + await one( + db + .insert(agentBuilderArtifact) + .values({ + conversationId: conversation.id, + path: "agent/instructions.md", + language: "markdown", + content: "Temporary draft", + revision: 1, + }) + .returning(), + ); await service.remove(conversation.id, userId); expect( - await db.agentConversation.findUnique({ where: { id: conversation.id } }), + await first( + db + .select() + .from(agentConversation) + .where(eq(agentConversation.id, conversation.id)) + .limit(1), + ), ).toBeNull(); expect( - await db.agentEvent.count({ - where: { sessionId }, - }), + await db.$count(agentEvent, eq(agentEvent.sessionId, sessionId)), ).toBe(0); expect( - await db.agentBuilderArtifact.count({ - where: { conversationId: conversation.id }, - }), + await db.$count( + agentBuilderArtifact, + eq(agentBuilderArtifact.conversationId, conversation.id), + ), ).toBe(0); }); @@ -463,7 +524,13 @@ describe("ConversationsService continued", () => { } expect(removeError).toBeDefined(); expect( - await db.agentConversation.findUnique({ where: { id: conversation.id } }), + await first( + db + .select() + .from(agentConversation) + .where(eq(agentConversation.id, conversation.id)) + .limit(1), + ), ).not.toBeNull(); }); @@ -710,9 +777,10 @@ describe("ConversationsService continued", () => { expect(new Set(results.map((result) => result.id)).size).toBe(1); expect( - await db.agentConversationSubmission.count({ - where: { clientRequestId }, - }), + await db.$count( + agentConversationSubmission, + eq(agentConversationSubmission.clientRequestId, clientRequestId), + ), ).toBe(1); }); @@ -746,9 +814,10 @@ describe("ConversationsService continued", () => { expect(new Set(results.map((result) => result.id)).size).toBe(1); expect( - await db.agentConversationSubmission.count({ - where: { clientRequestId }, - }), + await db.$count( + agentConversationSubmission, + eq(agentConversationSubmission.clientRequestId, clientRequestId), + ), ).toBe(1); }); @@ -764,28 +833,31 @@ describe("ConversationsService continued", () => { userId, ); const sessionId = `builder-question-${suffix}-1`; - await db.agentConversation.update({ - where: { id: conversation.id }, - data: { - sessionId, - continuationToken: `crm:builder:${conversation.id}`, - pendingInputRequests: [ - { - kind: "question", - requestId: "question-1", - prompt: "Where should this go?", - action: { - kind: "tool-call", - callId: "call-question-1", - toolName: "ask_question", - input: { prompt: "Where should this go?" }, + await one( + db + .update(agentConversation) + .set({ + sessionId, + continuationToken: `crm:builder:${conversation.id}`, + pendingInputRequests: [ + { + kind: "question", + requestId: "question-1", + prompt: "Where should this go?", + action: { + kind: "tool-call", + callId: "call-question-1", + toolName: "ask_question", + input: { prompt: "Where should this go?" }, + }, + display: "select", + options: [{ id: "crm-task", label: "Create a CRM task" }], }, - display: "select", - options: [{ id: "crm-task", label: "Create a CRM task" }], - }, - ], - }, - }); + ], + }) + .where(eq(agentConversation.id, conversation.id)) + .returning(), + ); expect( (await service.builderById(conversation.id, userId)).pendingQuestion, @@ -803,15 +875,18 @@ describe("ConversationsService continued", () => { }, userId, ); - const submission = await db.agentConversationSubmission.findUnique({ - where: { id: response.id }, - select: { - commandType: true, - inputRequestId: true, - message: true, - status: true, - }, - }); + const submission = await first( + db + .select({ + commandType: agentConversationSubmission.commandType, + inputRequestId: agentConversationSubmission.inputRequestId, + message: agentConversationSubmission.message, + status: agentConversationSubmission.status, + }) + .from(agentConversationSubmission) + .where(eq(agentConversationSubmission.id, response.id)) + .limit(1), + ); expect(submission).toMatchObject({ commandType: "CREATE_AGENT", @@ -839,30 +914,33 @@ describe("ConversationsService continued", () => { userId, ); const sessionId = `builder-question-${suffix}-approval`; - await db.agentConversation.update({ - where: { id: conversation.id }, - data: { - sessionId, - continuationToken: `crm:builder:${conversation.id}`, - pendingInputRequests: [ - { - kind: "tool-approval", - requestId: "approval-1", - prompt: "Archive the Renewal date field?", - action: { - kind: "tool-call", - callId: "call-approval-1", - toolName: "archive_field", - input: { fieldId: "field-1" }, + await one( + db + .update(agentConversation) + .set({ + sessionId, + continuationToken: `crm:builder:${conversation.id}`, + pendingInputRequests: [ + { + kind: "tool-approval", + requestId: "approval-1", + prompt: "Archive the Renewal date field?", + action: { + kind: "tool-call", + callId: "call-approval-1", + toolName: "archive_field", + input: { fieldId: "field-1" }, + }, + options: [ + { id: "approve", label: "Approve", style: "primary" }, + { id: "cancel", label: "Decline", style: "danger" }, + ], }, - options: [ - { id: "approve", label: "Approve", style: "primary" }, - { id: "cancel", label: "Decline", style: "danger" }, - ], - }, - ], - }, - }); + ], + }) + .where(eq(agentConversation.id, conversation.id)) + .returning(), + ); expect( (await service.builderById(conversation.id, userId)).permissionMode, @@ -887,15 +965,18 @@ describe("ConversationsService continued", () => { }, userId, ); - const submission = await db.agentConversationSubmission.findUnique({ - where: { id: response.id }, - select: { - commandType: true, - inputRequestId: true, - message: true, - status: true, - }, - }); + const submission = await first( + db + .select({ + commandType: agentConversationSubmission.commandType, + inputRequestId: agentConversationSubmission.inputRequestId, + message: agentConversationSubmission.message, + status: agentConversationSubmission.status, + }) + .from(agentConversationSubmission) + .where(eq(agentConversationSubmission.id, response.id)) + .limit(1), + ); expect(submission).toMatchObject({ commandType: "CHAT", @@ -922,57 +1003,60 @@ describe("ConversationsService continued", () => { }, userId, ); - await db.agentConversation.update({ - where: { id: conversation.id }, - data: { - sessionId: `builder-input-priority-${suffix}`, - continuationToken: `crm:builder:${conversation.id}`, - pendingInputRequests: [ - { - kind: "question", - requestId: "question-after-gates", - prompt: "Which stage?", - action: { - kind: "tool-call", - callId: "call-question-after-gates", - toolName: "ask_question", - input: { prompt: "Which stage?" }, + await one( + db + .update(agentConversation) + .set({ + sessionId: `builder-input-priority-${suffix}`, + continuationToken: `crm:builder:${conversation.id}`, + pendingInputRequests: [ + { + kind: "question", + requestId: "question-after-gates", + prompt: "Which stage?", + action: { + kind: "tool-call", + callId: "call-question-after-gates", + toolName: "ask_question", + input: { prompt: "Which stage?" }, + }, + display: "text", }, - display: "text", - }, - { - kind: "tool-approval", - requestId: "approval-after-limit", - prompt: "Save the draft?", - action: { - kind: "tool-call", - callId: "call-approval-after-limit", - toolName: "save_agent_draft", - input: { name: "Deal review" }, + { + kind: "tool-approval", + requestId: "approval-after-limit", + prompt: "Save the draft?", + action: { + kind: "tool-call", + callId: "call-approval-after-limit", + toolName: "save_agent_draft", + input: { name: "Deal review" }, + }, + options: [ + { id: "approve", label: "Approve" }, + { id: "cancel", label: "Decline" }, + ], }, - options: [ - { id: "approve", label: "Approve" }, - { id: "cancel", label: "Decline" }, - ], - }, - { - kind: "session-limit", - requestId: "limit-before-approval", - prompt: "Continue this builder session?", - action: { - kind: "tool-call", - callId: "call-limit-before-approval", - toolName: "session_limit_continuation", - input: { kind: "output", limit: 40_000 }, + { + kind: "session-limit", + requestId: "limit-before-approval", + prompt: "Continue this builder session?", + action: { + kind: "tool-call", + callId: "call-limit-before-approval", + toolName: "session_limit_continuation", + input: { kind: "output", limit: 40_000 }, + }, + options: [ + { id: "continue", label: "Continue" }, + { id: "stop", label: "Stop" }, + ], }, - options: [ - { id: "continue", label: "Continue" }, - { id: "stop", label: "Stop" }, - ], - }, - ], - }, - }); + ], + }) + .where(eq(agentConversation.id, conversation.id)) + .returning(), + ); const first = await service.builderById(conversation.id, userId); expect(first.pendingSessionLimit?.requestId).toBe("limit-before-approval"); @@ -1017,13 +1101,16 @@ describe("ConversationsService continued", () => { }, userId, ); - await db.agentConversation.update({ - where: { id: conversation.id }, - data: { - sessionId: `builder-question-${suffix}-recovery`, - continuationToken: `builder:${conversation.id}`, - }, - }); + await one( + db + .update(agentConversation) + .set({ + sessionId: `builder-question-${suffix}-recovery`, + continuationToken: `builder:${conversation.id}`, + }) + .where(eq(agentConversation.id, conversation.id)) + .returning(), + ); let error: Error | null = null; try { @@ -1056,31 +1143,34 @@ describe("ConversationsService continued", () => { userId, ); const sessionId = `builder-question-${suffix}-2`; - await db.agentConversation.update({ - where: { id: conversation.id }, - data: { - sessionId, - continuationToken: `crm:builder:${conversation.id}`, - pendingInputRequests: [ - { - kind: "question", - requestId: "question-concurrent", - prompt: "Which output?", - action: { - kind: "tool-call", - callId: "call-question-concurrent", - toolName: "ask_question", - input: { prompt: "Which output?" }, + await one( + db + .update(agentConversation) + .set({ + sessionId, + continuationToken: `crm:builder:${conversation.id}`, + pendingInputRequests: [ + { + kind: "question", + requestId: "question-concurrent", + prompt: "Which output?", + action: { + kind: "tool-call", + callId: "call-question-concurrent", + toolName: "ask_question", + input: { prompt: "Which output?" }, + }, + display: "select", + options: [ + { id: "note", label: "Create a note" }, + { id: "task", label: "Create a task" }, + ], }, - display: "select", - options: [ - { id: "note", label: "Create a note" }, - { id: "task", label: "Create a task" }, - ], - }, - ], - }, - }); + ], + }) + .where(eq(agentConversation.id, conversation.id)) + .returning(), + ); const results = await Promise.allSettled([ service.answerBuilderQuestion( @@ -1110,12 +1200,13 @@ describe("ConversationsService continued", () => { results.filter((result) => result.status === "rejected"), ).toHaveLength(1); expect( - await db.agentConversationSubmission.count({ - where: { - conversationId: conversation.id, - inputRequestId: "question-concurrent", - }, - }), + await db.$count( + agentConversationSubmission, + and( + eq(agentConversationSubmission.conversationId, conversation.id), + eq(agentConversationSubmission.inputRequestId, "question-concurrent"), + ), + ), ).toBe(1); }); }); diff --git a/apps/api/test/currency-totals.integration.spec.ts b/apps/api/test/currency-totals.integration.spec.ts index 5cb97c7a4..30192d0bd 100644 --- a/apps/api/test/currency-totals.integration.spec.ts +++ b/apps/api/test/currency-totals.integration.spec.ts @@ -1,5 +1,20 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { DealStage, db, RateSource } from "@crm/db"; +import { + and, + appSetting, + company as companyTable, + count, + DealStage, + Decimal, + db, + deal as dealTable, + eq, + exchangeRate, + inArray, + isNotNull, + RateSource, + user, +} from "@crm/db"; import { normalizeCurrency } from "@crm/db/currency"; import { SETTINGS_ID, writeReportingCurrency } from "@crm/db/settings"; import type { AgentTriggerService } from "../src/agent/agent-trigger.service"; @@ -9,6 +24,7 @@ import { DashboardService } from "../src/dashboard/dashboard.service"; import { DealsService } from "../src/deals/deals.service"; import { FieldsService } from "../src/fields/fields.service"; import { withDiscardedCrmEvents } from "./agent-trigger.stub"; +import { first, one } from "./rows"; const suffix = process.env.TEST_RUN_ID ?? "currency-totals-spec"; const userId = `user-${suffix}`; @@ -35,32 +51,34 @@ const MILLION = 100_000_000; const HALF_MILLION = 50_000_000; async function rate(quote: string, value: string, source: RateSource) { - await db.exchangeRate.upsert({ - where: { - baseCurrency_quoteCurrency_source: { - baseCurrency: "USD", - quoteCurrency: quote, - source, - }, - }, - create: { + await db + .insert(exchangeRate) + .values({ baseCurrency: "USD", quoteCurrency: quote, - rate: value, + rate: new Decimal(value), asOf: new Date("2026-08-01T00:00:00.000Z"), source, - }, - update: { rate: value }, - }); + }) + .onConflictDoUpdate({ + target: [ + exchangeRate.baseCurrency, + exchangeRate.quoteCurrency, + exchangeRate.source, + ], + set: { rate: new Decimal(value) }, + }); } async function clearRates() { - await db.exchangeRate.deleteMany({ - where: { - baseCurrency: { in: ["USD", "EUR"] }, - quoteCurrency: { in: ["USD", "EUR", "CHF"] }, - }, - }); + await db + .delete(exchangeRate) + .where( + and( + inArray(exchangeRate.baseCurrency, ["USD", "EUR"]), + inArray(exchangeRate.quoteCurrency, ["USD", "EUR", "CHF"]), + ), + ); } async function pipelineCents(): Promise { @@ -69,47 +87,58 @@ async function pipelineCents(): Promise { } beforeAll(async () => { - const existing = await db.appSetting.findUnique({ - where: { id: SETTINGS_ID }, - select: { reportingCurrency: true }, - }); + const existing = await first( + db + .select({ reportingCurrency: appSetting.reportingCurrency }) + .from(appSetting) + .where(eq(appSetting.id, SETTINGS_ID)) + .limit(1), + ); previousReportingCurrency = existing?.reportingCurrency ?? null; await writeReportingCurrency(db, "USD"); await clearRates(); - await db.user.upsert({ - where: { id: userId }, - create: { + await db + .insert(user) + .values({ id: userId, name: "Rate Tester", email: `rates@${domain}`, emailVerified: true, - }, - update: {}, - }); - - const company = await db.company.upsert({ - where: { domain }, - create: { name: `Money Co ${suffix}`, domain }, - update: {}, - select: { id: true }, - }); + }) + .onConflictDoNothing(); + + const existingCompany = await first( + db + .select({ id: companyTable.id }) + .from(companyTable) + .where(eq(companyTable.domain, domain)) + .limit(1), + ); + const company = + existingCompany ?? + (await one( + db + .insert(companyTable) + .values({ name: `Money Co ${suffix}`, domain }) + .returning({ id: companyTable.id }), + )); companyId = company.id; await rate("EUR", "1.10", RateSource.FETCHED); }); afterAll(async () => { - await db.deal.deleteMany({ where: { companyId } }); - await db.company.deleteMany({ where: { domain } }); - await db.user.deleteMany({ where: { id: userId } }); + await db.delete(dealTable).where(eq(dealTable.companyId, companyId)); + await db.delete(companyTable).where(eq(companyTable.domain, domain)); + await db.delete(user).where(eq(user.id, userId)); await clearRates(); if (previousReportingCurrency) { await writeReportingCurrency(db, previousReportingCurrency); } else { - await db.appSetting.updateMany({ data: { reportingCurrency: null } }); + await db.update(appSetting).set({ reportingCurrency: null }); } await conversion.rerateAll(); @@ -137,10 +166,23 @@ describe("a total across currencies", () => { }); it("locks the rate onto the deal, so the row says how it was converted", async () => { - const row = await db.deal.findFirst({ - where: { companyId, currency: "EUR" }, - select: { amount: true, baseAmount: true, fxRate: true, fxRateAt: true }, - }); + const row = await first( + db + .select({ + amount: dealTable.amount, + baseAmount: dealTable.baseAmount, + fxRate: dealTable.fxRate, + fxRateAt: dealTable.fxRateAt, + }) + .from(dealTable) + .where( + and( + eq(dealTable.companyId, companyId), + eq(dealTable.currency, "EUR"), + ), + ) + .limit(1), + ); expect(row?.amount?.toNumber()).toBe(1_000_000); expect(row?.baseAmount?.toNumber()).toBe(1_100_000); @@ -186,10 +228,18 @@ describe("a total across currencies", () => { await conversion.fillMissing(); - const row = await db.deal.findFirst({ - where: { companyId, currency: "EUR" }, - select: { baseAmount: true }, - }); + const row = await first( + db + .select({ baseAmount: dealTable.baseAmount }) + .from(dealTable) + .where( + and( + eq(dealTable.companyId, companyId), + eq(dealTable.currency, "EUR"), + ), + ) + .limit(1), + ); expect(row?.baseAmount?.toNumber()).toBe(1_100_000); }); @@ -199,10 +249,18 @@ describe("a total across currencies", () => { await conversion.rerateAll(); - const row = await db.deal.findFirst({ - where: { companyId, currency: "EUR" }, - select: { baseAmount: true, fxRate: true }, - }); + const row = await first( + db + .select({ baseAmount: dealTable.baseAmount, fxRate: dealTable.fxRate }) + .from(dealTable) + .where( + and( + eq(dealTable.companyId, companyId), + eq(dealTable.currency, "EUR"), + ), + ) + .limit(1), + ); expect(row?.fxRate?.toNumber()).toBe(1.5); expect(row?.baseAmount?.toNumber()).toBe(1_500_000); @@ -271,10 +329,13 @@ describe("a converted figure knows which currency it is in", () => { expect(await pipelineCents()).toBe(before + MILLION); - await db.deal.update({ - where: { id: deal.id }, - data: { baseCurrency: "JPY" }, - }); + await one( + db + .update(dealTable) + .set({ baseCurrency: "JPY" }) + .where(eq(dealTable.id, deal.id)) + .returning(), + ); expect(await pipelineCents()).toBe(before); @@ -286,7 +347,9 @@ describe("a converted figure knows which currency it is in", () => { expect(await pipelineCents()).toBe(before + MILLION); - await db.deal.delete({ where: { id: deal.id } }); + await one( + db.delete(dealTable).where(eq(dealTable.id, deal.id)).returning(), + ); }); it("never lets a converted figure with no currency on it go unnoticed", async () => { @@ -295,19 +358,21 @@ describe("a converted figure knows which currency it is in", () => { const before = await pipelineCents(); - const orphan = await db.deal.create({ - data: { - name: `Orphan ${suffix}`, - companyId, - ownerId: userId, - amount: 50_000, - currency: "USD", - baseAmount: 50_000, - fxRate: 1, - fxRateAt: new Date(), - }, - select: { id: true }, - }); + const orphan = await one( + db + .insert(dealTable) + .values({ + name: `Orphan ${suffix}`, + companyId, + ownerId: userId, + amount: new Decimal(50_000), + currency: "USD", + baseAmount: new Decimal(50_000), + fxRate: new Decimal(1), + fxRateAt: new Date(), + }) + .returning({ id: dealTable.id }), + ); expect(await pipelineCents()).toBe(before); @@ -316,29 +381,36 @@ describe("a converted figure knows which currency it is in", () => { await conversion.fillMissing(); - const healed = await db.deal.findUnique({ - where: { id: orphan.id }, - select: { baseCurrency: true }, - }); + const healed = await first( + db + .select({ baseCurrency: dealTable.baseCurrency }) + .from(dealTable) + .where(eq(dealTable.id, orphan.id)) + .limit(1), + ); expect(healed?.baseCurrency).toBe("USD"); expect(await pipelineCents()).toBe(before + 5_000_000); - await db.deal.delete({ where: { id: orphan.id } }); + await one( + db.delete(dealTable).where(eq(dealTable.id, orphan.id)).returning(), + ); }); it("counts a currency once however it was cased or padded", async () => { const rows = await Promise.all( [" usd ", "Usd"].map((currency, index) => - db.deal.create({ - data: { - name: `Variant ${index} ${suffix}`, - companyId, - ownerId: userId, - amount: 1000, - currency, - }, - select: { id: true }, - }), + one( + db + .insert(dealTable) + .values({ + name: `Variant ${index} ${suffix}`, + companyId, + ownerId: userId, + amount: new Decimal(1000), + currency, + }) + .returning({ id: dealTable.id }), + ), ), ); @@ -349,33 +421,44 @@ describe("a converted figure knows which currency it is in", () => { const rerated = await conversion.rerateAll(); - const written = await db.deal.findMany({ - where: { id: { in: rows.map((row) => row.id) } }, - select: { baseAmount: true, baseCurrency: true }, - }); + const written = await db + .select({ + baseAmount: dealTable.baseAmount, + baseCurrency: dealTable.baseCurrency, + }) + .from(dealTable) + .where( + inArray( + dealTable.id, + rows.map((row) => row.id), + ), + ); for (const row of written) { expect(row.baseCurrency).toBe("USD"); expect(row.baseAmount?.toNumber()).toBe(1000); } - const groups = await db.deal.groupBy({ - by: ["currency"], - where: { amount: { not: null } }, - _count: { _all: true }, - }); + const groups = await db + .select({ currency: dealTable.currency, count: count() }) + .from(dealTable) + .where(isNotNull(dealTable.amount)) + .groupBy(dealTable.currency); const convertible = groups .filter( (group) => !rerated.missing.includes(normalizeCurrency(group.currency)), ) - .reduce((total, group) => total + group._count._all, 0); + .reduce((total, group) => total + group.count, 0); expect(rerated.converted).toBe(convertible); - await db.deal.deleteMany({ - where: { id: { in: rows.map((row) => row.id) } }, - }); + await db.delete(dealTable).where( + inArray( + dealTable.id, + rows.map((row) => row.id), + ), + ); }); it("keeps a converted deal when the rate behind it has gone away", async () => { @@ -390,42 +473,53 @@ describe("a converted figure knows which currency it is in", () => { currency: "EUR", }); - const frozen = await db.deal.findUnique({ - where: { id: deal.id }, - select: { baseAmount: true }, - }); + const frozen = await first( + db + .select({ baseAmount: dealTable.baseAmount }) + .from(dealTable) + .where(eq(dealTable.id, deal.id)) + .limit(1), + ); expect(frozen?.baseAmount).not.toBeNull(); const before = await pipelineCents(); await clearRates(); - const stranded = await db.deal.create({ - data: { - name: `Stranded ${suffix}`, - companyId, - ownerId: userId, - amount: 1000, - currency: "EUR", - }, - select: { id: true }, - }); + const stranded = await one( + db + .insert(dealTable) + .values({ + name: `Stranded ${suffix}`, + companyId, + ownerId: userId, + amount: new Decimal(1000), + currency: "EUR", + }) + .returning({ id: dealTable.id }), + ); const filled = await conversion.fillMissing(); expect(filled.missing).toContain("EUR"); expect(filled.cleared).toBe(0); - const kept = await db.deal.findUnique({ - where: { id: deal.id }, - select: { baseAmount: true, baseCurrency: true }, - }); + const kept = await first( + db + .select({ + baseAmount: dealTable.baseAmount, + baseCurrency: dealTable.baseCurrency, + }) + .from(dealTable) + .where(eq(dealTable.id, deal.id)) + .limit(1), + ); expect(kept?.baseCurrency).toBe("USD"); expect(kept?.baseAmount?.toNumber()).toBe(frozen?.baseAmount?.toNumber()); expect(await pipelineCents()).toBe(before); - await db.deal.deleteMany({ - where: { id: { in: [deal.id, stranded.id] } }, - }); + await db + .delete(dealTable) + .where(inArray(dealTable.id, [deal.id, stranded.id])); await rate("EUR", "1.10", RateSource.FETCHED); }); }); @@ -436,42 +530,43 @@ describe("the dashboard only values what it can convert", () => { beforeAll(async () => { await writeReportingCurrency(db, "USD"); - await db.user.upsert({ - where: { id: analystId }, - create: { + await db + .insert(user) + .values({ id: analystId, name: "Dashboard Tester", email: `dashboard@${domain}`, emailVerified: true, - }, - update: {}, - }); + }) + .onConflictDoNothing(); }); afterAll(async () => { - await db.deal.deleteMany({ where: { ownerId: analystId } }); - await db.user.deleteMany({ where: { id: analystId } }); + await db.delete(dealTable).where(eq(dealTable.ownerId, analystId)); + await db.delete(user).where(eq(user.id, analystId)); }); async function stale(name: string, stage: DealStage) { const closed = stage === DealStage.CLOSED_WON; - return db.deal.create({ - data: { - name: `${name} ${suffix}`, - companyId, - ownerId: analystId, - stage, - amount: 9_000, - currency: "USD", - baseAmount: 9_000, - baseCurrency: "JPY", - fxRate: 1, - fxRateAt: new Date(), - closedAt: closed ? new Date() : null, - }, - select: { id: true }, - }); + return one( + db + .insert(dealTable) + .values({ + name: `${name} ${suffix}`, + companyId, + ownerId: analystId, + stage, + amount: new Decimal(9_000), + currency: "USD", + baseAmount: new Decimal(9_000), + baseCurrency: "JPY", + fxRate: new Decimal(1), + fxRateAt: new Date(), + closedAt: closed ? new Date() : null, + }) + .returning({ id: dealTable.id }), + ); } it("does not average a won deal it cannot value into the rest", async () => { @@ -492,7 +587,9 @@ describe("the dashboard only values what it can convert", () => { expect(summary.performance.avgDealCents).toBe(10_000); expect(summary.unconverted.count).toBe(1); - await db.deal.deleteMany({ where: { id: { in: [won.id, unvalued.id] } } }); + await db + .delete(dealTable) + .where(inArray(dealTable.id, [won.id, unvalued.id])); }); it("does not let a stale figure set the largest open deal", async () => { @@ -515,6 +612,8 @@ describe("the dashboard only values what it can convert", () => { ?.baseAmountCents, ).toBeNull(); - await db.deal.deleteMany({ where: { id: { in: [open.id, unvalued.id] } } }); + await db + .delete(dealTable) + .where(inArray(dealTable.id, [open.id, unvalued.id])); }); }); diff --git a/apps/api/test/deal-contacts.spec.ts b/apps/api/test/deal-contacts.spec.ts index ccf9af4e4..d893b05ec 100644 --- a/apps/api/test/deal-contacts.spec.ts +++ b/apps/api/test/deal-contacts.spec.ts @@ -1,11 +1,21 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { db } from "@crm/db"; +import { + company as companyTable, + contact, + contact as contactTable, + db, + deal as dealTable, + eq, + inArray, + user, +} from "@crm/db"; import type { AgentTriggerService } from "../src/agent/agent-trigger.service"; import { ActivityStampService } from "../src/crm/activity-stamp.service"; import { ConversionService } from "../src/currency/conversion.service"; import { DealsService } from "../src/deals/deals.service"; import { FieldsService } from "../src/fields/fields.service"; import { withDiscardedCrmEvents } from "./agent-trigger.stub"; +import { one } from "./rows"; const suffix = process.env.TEST_RUN_ID ?? "deal-contacts-spec"; const userId = `user-${suffix}`; @@ -31,55 +41,84 @@ let colleagueId: string; let outsiderId: string; async function clean() { - await db.deal.deleteMany({ where: { company: { domain } } }); - await db.contact.deleteMany({ - where: { company: { domain: { in: [domain, otherDomain] } } }, - }); - await db.company.deleteMany({ - where: { domain: { in: [domain, otherDomain] } }, - }); - await db.user.deleteMany({ where: { id: userId } }); + await db + .delete(dealTable) + .where( + inArray( + dealTable.companyId, + db + .select({ id: companyTable.id }) + .from(companyTable) + .where(eq(companyTable.domain, domain)), + ), + ); + await db.delete(contactTable).where( + inArray( + contactTable.companyId, + db + .select({ id: companyTable.id }) + .from(companyTable) + .where(inArray(companyTable.domain, [domain, otherDomain])), + ), + ); + await db + .delete(companyTable) + .where(inArray(companyTable.domain, [domain, otherDomain])); + await db.delete(user).where(eq(user.id, userId)); } beforeAll(async () => { await clean(); - await db.user.create({ - data: { - id: userId, - name: "Deal Rep", - email: `${userId}@example.test`, - emailVerified: true, - }, - }); - - const company = await db.company.create({ - data: { name: `People Co ${suffix}`, domain }, - select: { id: true }, - }); + await one( + db + .insert(user) + .values({ + id: userId, + name: "Deal Rep", + email: `${userId}@example.test`, + emailVerified: true, + }) + .returning(), + ); + + const company = await one( + db + .insert(companyTable) + .values({ name: `People Co ${suffix}`, domain }) + .returning({ id: companyTable.id }), + ); companyId = company.id; - const other = await db.company.create({ - data: { name: `Other Co ${suffix}`, domain: otherDomain }, - select: { id: true }, - }); - - const champion = await db.contact.create({ - data: { firstName: "Ada", lastName: "Champion", companyId }, - select: { id: true }, - }); + const other = await one( + db + .insert(companyTable) + .values({ name: `Other Co ${suffix}`, domain: otherDomain }) + .returning({ id: companyTable.id }), + ); + + const champion = await one( + db + .insert(contact) + .values({ firstName: "Ada", lastName: "Champion", companyId }) + .returning({ id: contact.id }), + ); championId = champion.id; - const colleague = await db.contact.create({ - data: { firstName: "Beau", lastName: "Colleague", companyId }, - select: { id: true }, - }); + const colleague = await one( + db + .insert(contact) + .values({ firstName: "Beau", lastName: "Colleague", companyId }) + .returning({ id: contact.id }), + ); colleagueId = colleague.id; - const outsider = await db.contact.create({ - data: { firstName: "Cass", lastName: "Outsider", companyId: other.id }, - select: { id: true }, - }); + const outsider = await one( + db + .insert(contact) + .values({ firstName: "Cass", lastName: "Outsider", companyId: other.id }) + .returning({ id: contact.id }), + ); outsiderId = outsider.id; const deal = await deals.create({ @@ -161,7 +200,7 @@ describe("bringing a contact onto a deal", () => { const deal = await deals.byId(dealId); expect(deal.contacts).toHaveLength(0); - expect(await db.contact.count({ where: { id: championId } })).toBe(1); + expect(await db.$count(contact, eq(contact.id, championId))).toBe(1); }); it("says so when they were never on it", async () => { diff --git a/apps/api/test/enrichment-queue.spec.ts b/apps/api/test/enrichment-queue.spec.ts index dbece0abc..ccbae984a 100644 --- a/apps/api/test/enrichment-queue.spec.ts +++ b/apps/api/test/enrichment-queue.spec.ts @@ -1,5 +1,12 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { db } from "@crm/db"; +import { + agentTask, + company as companyTable, + contact as contactTable, + db, + endsWith, + eq, +} from "@crm/db"; import { ENRICHMENT_PAGE, ENRICHMENT_PAGE_MAX, @@ -7,6 +14,7 @@ import { pageSize, } from "@crm/validation/enrichment-queue"; import { EnrichmentService } from "../src/enrichment/enrichment.service"; +import { one } from "./rows"; const suffix = process.env.TEST_RUN_ID ?? "enrichment-queue-spec"; const email = `queue-${suffix}@example.test`; @@ -25,72 +33,82 @@ let dueId: string; let scheduledId: string; async function clean() { - await db.agentTask.deleteMany({ where: { kind } }); - await db.contact.deleteMany({ where: { email } }); - await db.contact.deleteMany({ - where: { email: { endsWith: `-${suffix}@example.test` } }, - }); - await db.company.deleteMany({ where: { name } }); + await db.delete(agentTask).where(eq(agentTask.kind, kind)); + await db.delete(contactTable).where(eq(contactTable.email, email)); + await db + .delete(contactTable) + .where(endsWith(contactTable.email, `-${suffix}@example.test`)); + await db.delete(companyTable).where(eq(companyTable.name, name)); } beforeAll(async () => { await clean(); - const contact = await db.contact.create({ - data: { firstName: "Queue", lastName: "Split", email }, - select: { id: true }, - }); - const company = await db.company.create({ - data: { name }, - select: { id: true }, - }); + const contact = await one( + db + .insert(contactTable) + .values({ firstName: "Queue", lastName: "Split", email }) + .returning({ id: contactTable.id }), + ); + const company = await one( + db.insert(companyTable).values({ name }).returning({ id: companyTable.id }), + ); contactId = contact.id; companyId = company.id; - const due = await db.agentTask.create({ - data: { - kind, - reason: "due now", - dueAt: new Date(Date.now() - 60_000), - budget: 4, - companyId, - }, - select: { id: true }, - }); - const scheduled = await db.agentTask.create({ - data: { - kind, - reason: "booked for later", - dueAt: new Date(Date.now() + 90 * DAY_MS), - budget: 4, - contactId, - }, - select: { id: true }, - }); + const due = await one( + db + .insert(agentTask) + .values({ + kind, + reason: "due now", + dueAt: new Date(Date.now() - 60_000), + budget: 4, + companyId, + }) + .returning({ id: agentTask.id }), + ); + const scheduled = await one( + db + .insert(agentTask) + .values({ + kind, + reason: "booked for later", + dueAt: new Date(Date.now() + 90 * DAY_MS), + budget: 4, + contactId, + }) + .returning({ id: agentTask.id }), + ); dueId = due.id; scheduledId = scheduled.id; for (let index = 0; index < EXTRA_ROWS; index++) { - const extra = await db.contact.create({ - data: { - firstName: "Queue", - lastName: `Page ${index}`, - email: `page-${index}-${suffix}@example.test`, - }, - select: { id: true }, - }); - - await db.agentTask.create({ - data: { - kind, - reason: "due now", - dueAt: new Date(Date.now() - 60_000), - budget: 4, - contactId: extra.id, - }, - }); + const extra = await one( + db + .insert(contactTable) + .values({ + firstName: "Queue", + lastName: `Page ${index}`, + email: `page-${index}-${suffix}@example.test`, + }) + .returning({ id: contactTable.id }), + ); + + await one( + db + .insert(agentTask) + .values({ + kind, + reason: "due now", + dueAt: new Date(Date.now() - 60_000), + budget: 4, + contactId: extra.id, + }) + .returning(), + ); } }); diff --git a/apps/api/test/fake-db.ts b/apps/api/test/fake-db.ts new file mode 100644 index 000000000..2b0baa54a --- /dev/null +++ b/apps/api/test/fake-db.ts @@ -0,0 +1,182 @@ +import { + type Db, + type Decimal, + getTableName, + PgDialect, + type PgTable, + QueryPromise, + type SQL, +} from "@crm/db"; + +const dialect = new PgDialect(); + +export function rendered(where: SQL | undefined) { + if (!where) return null; + const { sql, params } = dialect.sqlToQuery(where); + return { sql, params }; +} + +export type Cell = + | string + | number + | boolean + | Date + | null + | undefined + | SQL + | Uint8Array + | Decimal + | Cell[] + | { [key: string]: Cell }; + +export type Row = { [column: string]: Cell }; + +export type FakeCall = { + op: "select" | "insert" | "update" | "delete"; + table: string; + values?: Row | Row[]; + set?: Row; + conflict?: { set?: Row }; + where?: SQL; +}; + +type Rows = Row[] | Promise; + +export type FakeHandlers = { + select?: (table: string, call: FakeCall) => Rows; + count?: (table: string, where: SQL | undefined) => number; + insert?: (call: FakeCall) => Rows; + update?: (call: FakeCall) => Rows; + delete?: (call: FakeCall) => Rows; + query?: { + [table: string]: { + findFirst?: () => Row | undefined; + findMany?: () => Row[]; + }; + }; +}; + +class FakeQuery extends QueryPromise { + constructor( + private readonly call: FakeCall, + private readonly run: (call: FakeCall) => Promise, + ) { + super(); + } + + override execute(): Promise { + return this.run(this.call); + } + + from(table: PgTable) { + this.call.table = getTableName(table); + return this; + } + + where(where: SQL) { + this.call.where = where; + return this; + } + + values(values: Row | Row[]) { + this.call.values = values; + return this; + } + + set(set: Row) { + this.call.set = set; + return this; + } + + onConflictDoUpdate(conflict: { set?: Row }) { + this.call.conflict = conflict; + return this; + } + + onConflictDoNothing() { + return this; + } + + limit() { + return this; + } + + offset() { + return this; + } + + orderBy() { + return this; + } + + groupBy() { + return this; + } + + returning() { + return this; + } + + innerJoin() { + return this; + } + + leftJoin() { + return this; + } + + for() { + return this; + } +} + +type FakeExecutor = { + query: FakeHandlers["query"]; + select: () => FakeQuery; + insert: (table: PgTable) => FakeQuery; + update: (table: PgTable) => FakeQuery; + delete: (table: PgTable) => FakeQuery; + $count: (table: PgTable, where?: SQL) => Promise; + transaction: ( + work: (tx: FakeExecutor) => Promise, + ) => Promise; +}; + +export function fakeDb(handlers: FakeHandlers) { + const calls: FakeCall[] = []; + + const run = async (call: FakeCall): Promise => { + calls.push(call); + if (call.op === "select") + return (await handlers.select?.(call.table, call)) ?? []; + return (await handlers[call.op]?.(call)) ?? []; + }; + + const query = new Proxy( + {}, + { + get: (_target, name: string) => ({ + findFirst: async () => handlers.query?.[name]?.findFirst?.(), + findMany: async () => handlers.query?.[name]?.findMany?.() ?? [], + }), + }, + ); + + const fake: FakeExecutor = { + query, + select: () => new FakeQuery({ op: "select", table: "" }, run), + insert: (table) => + new FakeQuery({ op: "insert", table: getTableName(table) }, run), + update: (table) => + new FakeQuery({ op: "update", table: getTableName(table) }, run), + delete: (table) => + new FakeQuery({ op: "delete", table: getTableName(table) }, run), + $count: async (table, where) => { + calls.push({ op: "select", table: getTableName(table), where }); + return handlers.count?.(getTableName(table), where) ?? 0; + }, + transaction: (work) => work(fake), + }; + + return { db: fake as unknown as Db, calls }; +} diff --git a/apps/api/test/fields.spec.ts b/apps/api/test/fields.spec.ts index acecd1376..25f8e4afa 100644 --- a/apps/api/test/fields.spec.ts +++ b/apps/api/test/fields.spec.ts @@ -6,7 +6,24 @@ import { expect, it, } from "bun:test"; -import { db, type FieldEntity } from "@crm/db"; +import { + agentTask, + and, + company as companyTable, + contact as contactTable, + contains, + db, + deal as dealTable, + endsWith, + eq, + type FieldEntity, + fieldDefinition, + fieldValue, + inArray, + or, + startsWith, + user, +} from "@crm/db"; import { AgentQueueService } from "../src/agent/agent-queue.service"; import { AgentTriggerService } from "../src/agent/agent-trigger.service"; import { CompaniesService } from "../src/companies/companies.service"; @@ -18,6 +35,7 @@ import { ConversionService } from "../src/currency/conversion.service"; import { DealsService } from "../src/deals/deals.service"; import { FieldsService } from "../src/fields/fields.service"; import { withDiscardedCrmEvents } from "./agent-trigger.stub"; +import { one } from "./rows"; const suffix = process.env.TEST_RUN_ID ?? "fields-spec"; const domain = `fields-${suffix}.test`; @@ -74,44 +92,48 @@ let companyId: string; let bridgeSecret: string | undefined; async function clean() { - const owned = await db.company.findMany({ - where: { domain: { endsWith: domain } }, - select: { id: true }, - }); + const owned = await db + .select({ id: companyTable.id }) + .from(companyTable) + .where(endsWith(companyTable.domain, domain)); const companyIds = owned.map((row) => row.id); - const ownedContacts = await db.contact.findMany({ - where: { companyId: { in: companyIds } }, - select: { id: true }, - }); + const ownedContacts = await db + .select({ id: contactTable.id }) + .from(contactTable) + .where(inArray(contactTable.companyId, companyIds)); const contactIds = ownedContacts.map((row) => row.id); - await db.agentTask.deleteMany({ - where: { - kind: "field-backfill", - OR: [ - { reason: { contains: "spec_" } }, - { companyId: { in: companyIds } }, - { contactId: { in: contactIds } }, - ], - }, - }); - await db.deal.deleteMany({ where: { companyId: { in: companyIds } } }); - await db.contact.deleteMany({ where: { companyId: { in: companyIds } } }); - await db.fieldValue.deleteMany({ - where: { companyId: { in: companyIds } }, - }); - await db.fieldDefinition.deleteMany({ - where: { key: { startsWith: "spec_" } }, - }); - await db.company.deleteMany({ where: { domain: { endsWith: domain } } }); - await db.user.deleteMany({ where: { id: ownerId } }); + await db + .delete(agentTask) + .where( + and( + eq(agentTask.kind, "field-backfill"), + or( + contains(agentTask.reason, "spec_"), + inArray(agentTask.companyId, companyIds), + inArray(agentTask.contactId, contactIds), + ), + ), + ); + await db.delete(dealTable).where(inArray(dealTable.companyId, companyIds)); + await db + .delete(contactTable) + .where(inArray(contactTable.companyId, companyIds)); + await db.delete(fieldValue).where(inArray(fieldValue.companyId, companyIds)); + await db + .delete(fieldDefinition) + .where(startsWith(fieldDefinition.key, "spec_")); + await db.delete(companyTable).where(endsWith(companyTable.domain, domain)); + await db.delete(user).where(eq(user.id, ownerId)); } async function makeCompany(name: string): Promise { - const company = await db.company.create({ - data: { name: `${name} ${suffix}`, domain: `${name}-${domain}` }, - select: { id: true }, - }); + const company = await one( + db + .insert(companyTable) + .values({ name: `${name} ${suffix}`, domain: `${name}-${domain}` }) + .returning({ id: companyTable.id }), + ); return company.id; } @@ -122,9 +144,12 @@ beforeAll(async () => { await clean(); - await db.user.create({ - data: { id: ownerId, name: "Fields Rep", email: `rep@${domain}` }, - }); + await one( + db + .insert(user) + .values({ id: ownerId, name: "Fields Rep", email: `rep@${domain}` }) + .returning(), + ); companyId = await makeCompany("fields-co"); }); @@ -245,7 +270,9 @@ describe("field definitions", () => { expect( (await fields.valuesFor("COMPANY", companyId)).map((entry) => entry.key), ).not.toContain("spec_runs_on"); - expect(await db.fieldValue.count({ where: { fieldId: field.id } })).toBe(1); + expect(await db.$count(fieldValue, eq(fieldValue.fieldId, field.id))).toBe( + 1, + ); await fields.restore(field.id); @@ -380,7 +407,9 @@ describe("field values", () => { } expect(refused?.message).toMatch(/takes a date/); - expect(await db.fieldValue.count({ where: { companyId: record } })).toBe(0); + expect(await db.$count(fieldValue, eq(fieldValue.companyId, record))).toBe( + 0, + ); }); it("refuses a user who does not work here, and keeps the batch out", async () => { @@ -410,7 +439,9 @@ describe("field values", () => { } expect(refused?.message).toMatch(/works here/); - expect(await db.fieldValue.count({ where: { companyId: record } })).toBe(0); + expect(await db.$count(fieldValue, eq(fieldValue.companyId, record))).toBe( + 0, + ); await fields.applyValues(db, "COMPANY", record, { spec_champion: ownerId, @@ -439,9 +470,13 @@ describe("field values", () => { spec_renewal: "2027-01-01", }); - await db.company.delete({ where: { id: doomed } }); + await one( + db.delete(companyTable).where(eq(companyTable.id, doomed)).returning(), + ); - expect(await db.fieldValue.count({ where: { companyId: doomed } })).toBe(0); + expect(await db.$count(fieldValue, eq(fieldValue.companyId, doomed))).toBe( + 0, + ); }); }); @@ -541,7 +576,9 @@ describe("a record update that fails", () => { } expect(refused).not.toBeNull(); - expect(await db.fieldValue.count({ where: { companyId: record } })).toBe(0); + expect(await db.$count(fieldValue, eq(fieldValue.companyId, record))).toBe( + 0, + ); }); it("leaves a contact's field values as they were", async () => { @@ -558,14 +595,16 @@ describe("a record update that fails", () => { showOnFilter: false, }); - const contact = await db.contact.create({ - data: { - firstName: "Ada", - email: `ada@${domain}`, - companyId, - }, - select: { id: true }, - }); + const contact = await one( + db + .insert(contactTable) + .values({ + firstName: "Ada", + email: `ada@${domain}`, + companyId, + }) + .returning({ id: contactTable.id }), + ); let refused: Error | null = null; try { @@ -579,7 +618,7 @@ describe("a record update that fails", () => { expect(refused).not.toBeNull(); expect( - await db.fieldValue.count({ where: { contactId: contact.id } }), + await db.$count(fieldValue, eq(fieldValue.contactId, contact.id)), ).toBe(0); }); @@ -597,10 +636,12 @@ describe("a record update that fails", () => { showOnFilter: false, }); - const deal = await db.deal.create({ - data: { name: `Spec deal ${suffix}`, companyId, ownerId }, - select: { id: true }, - }); + const deal = await one( + db + .insert(dealTable) + .values({ name: `Spec deal ${suffix}`, companyId, ownerId }) + .returning({ id: dealTable.id }), + ); let refused: Error | null = null; try { @@ -613,11 +654,11 @@ describe("a record update that fails", () => { } expect(refused).not.toBeNull(); - expect(await db.fieldValue.count({ where: { dealId: deal.id } })).toBe(0); + expect(await db.$count(fieldValue, eq(fieldValue.dealId, deal.id))).toBe(0); await deals.update(deal.id, { fields: { spec_risk: "Champion left" } }); - expect(await db.fieldValue.count({ where: { dealId: deal.id } })).toBe(1); + expect(await db.$count(fieldValue, eq(fieldValue.dealId, deal.id))).toBe(1); }); }); @@ -642,13 +683,15 @@ describe("queueing a backfill", () => { ); expect(second).toEqual({ queued: 0, merged: 1 }); - const tasks = await db.agentTask.findMany({ - where: { - kind: "field-backfill", - companyId: { in: [companyId, otherCompanyId] }, - }, - select: { companyId: true, payload: true }, - }); + const tasks = await db + .select({ companyId: agentTask.companyId, payload: agentTask.payload }) + .from(agentTask) + .where( + and( + eq(agentTask.kind, "field-backfill"), + inArray(agentTask.companyId, [companyId, otherCompanyId]), + ), + ); expect(tasks).toHaveLength(2); const mine = tasks.find((task) => task.companyId === companyId); @@ -660,10 +703,16 @@ describe("queueing a backfill", () => { it("keeps a company's field apart from a contact's field with the same key", async () => { const trigger = new AgentTriggerService(db); - const contact = await db.contact.create({ - data: { firstName: "Backfill", email: `backfill@${domain}`, companyId }, - select: { id: true }, - }); + const contact = await one( + db + .insert(contactTable) + .values({ + firstName: "Backfill", + email: `backfill@${domain}`, + companyId, + }) + .returning({ id: contactTable.id }), + ); await trigger.fieldBackfillRecords( "CONTACT", @@ -672,10 +721,15 @@ describe("queueing a backfill", () => { "New field", ); - const tasks = await db.agentTask.findMany({ - where: { kind: "field-backfill", contactId: contact.id }, - select: { payload: true }, - }); + const tasks = await db + .select({ payload: agentTask.payload }) + .from(agentTask) + .where( + and( + eq(agentTask.kind, "field-backfill"), + eq(agentTask.contactId, contact.id), + ), + ); expect(tasks).toHaveLength(1); expect(tasks[0]?.payload).toEqual({ diff --git a/apps/api/test/mailbox-purge.spec.ts b/apps/api/test/mailbox-purge.spec.ts index 4bc8a3892..ce2e0fb96 100644 --- a/apps/api/test/mailbox-purge.spec.ts +++ b/apps/api/test/mailbox-purge.spec.ts @@ -5,13 +5,31 @@ import { OUTLOOK_MAIL_SCOPE, SYNC_SCOPES, } from "@crm/auth/scopes"; -import { ActivityType, db, EmailDirection, GoogleSyncStatus } from "@crm/db"; +import { + ActivityType, + account as accountTable, + activity, + and, + asc, + calendarEvent, + company as companyTable, + db, + EmailDirection, + emailMessage, + emailThread, + eq, + GoogleSyncStatus, + inArray, + mailboxSync, + user, +} from "@crm/db"; import { ActivityStampService } from "../src/crm/activity-stamp.service"; import { GoogleConnectionService } from "../src/google/google-connection.service"; import type { MailboxMatchService } from "../src/mailbox/mailbox-match.service"; import { MailboxTokenService } from "../src/mailbox/mailbox-token.service"; import { SyncStateService } from "../src/mailbox/sync-state.service"; import { MicrosoftConnectionService } from "../src/microsoft/microsoft-connection.service"; +import { first, one } from "./rows"; const suffix = process.env.TEST_RUN_ID ?? "mailbox-purge-spec"; const domain = `purge-${suffix}.test`; @@ -61,48 +79,53 @@ async function thread( const last = messages[messages.length - 1]; if (!first || !last) return; - await db.emailThread.create({ - data: { - rootMessageId, - subject: first.subject, - companyId, - firstMessageAt: first.sentAt, - lastMessageAt: last.sentAt, - messageCount: messages.length, - messages: { - create: messages.map((message) => ({ - rfcMessageId: `${message.id}@${domain}`, - syncedByUserId: message.syncedByUserId, - gmailMessageId: message.provider === "gmail" ? message.id : null, - outlookMessageId: message.provider === "outlook" ? message.id : null, - direction: EmailDirection.INBOUND, - fromEmail: `them@${domain}`, - recipients: [], - subject: message.subject, - snippet: message.snippet, - sentAt: message.sentAt, - })), - }, - activity: { - create: { - type: ActivityType.EMAIL, - subject: first.subject, - body: last.snippet, - occurredAt: last.sentAt, - companyId, - createdById: first.syncedByUserId, - meta: { synced: true }, - }, - }, - }, + const thread = await one( + db + .insert(emailThread) + .values({ + rootMessageId, + subject: first.subject, + companyId, + firstMessageAt: first.sentAt, + lastMessageAt: last.sentAt, + messageCount: messages.length, + }) + .returning({ id: emailThread.id }), + ); + await db.insert(emailMessage).values( + messages.map((message) => ({ + threadId: thread.id, + rfcMessageId: `${message.id}@${domain}`, + syncedByUserId: message.syncedByUserId, + gmailMessageId: message.provider === "gmail" ? message.id : null, + outlookMessageId: message.provider === "outlook" ? message.id : null, + direction: EmailDirection.INBOUND, + fromEmail: `them@${domain}`, + recipients: [], + subject: message.subject, + snippet: message.snippet, + sentAt: message.sentAt, + })), + ); + await db.insert(activity).values({ + type: ActivityType.EMAIL, + subject: first.subject, + body: last.snippet, + occurredAt: last.sentAt, + companyId, + createdById: first.syncedByUserId, + meta: { synced: true }, + emailThreadId: thread.id, }); } async function seed(): Promise { - const company = await db.company.create({ - data: { name: "Purge Co", domain }, - select: { id: true }, - }); + const company = await one( + db + .insert(companyTable) + .values({ name: "Purge Co", domain }) + .returning({ id: companyTable.id }), + ); await thread(shared, company.id, [ { @@ -153,63 +176,68 @@ async function seed(): Promise { }, ]); - await db.calendarEvent.create({ - data: { - iCalUid: `ical-${suffix}`, - originalStartTime: at(12), - startsAt: at(12), - endsAt: at(13), - status: "confirmed", - companyId: company.id, - syncedByUserId: gmailRep, - }, - }); + await one( + db + .insert(calendarEvent) + .values({ + iCalUid: `ical-${suffix}`, + originalStartTime: at(12), + startsAt: at(12), + endsAt: at(13), + status: "confirmed", + companyId: company.id, + syncedByUserId: gmailRep, + }) + .returning(), + ); } async function clean(): Promise { - await db.calendarEvent.deleteMany({ - where: { syncedByUserId: { in: userIds } }, - }); - await db.emailThread.deleteMany({ where: { rootMessageId: { in: roots } } }); - await db.mailboxSync.deleteMany({ where: { userId: { in: userIds } } }); - await db.account.deleteMany({ where: { userId: { in: userIds } } }); - await db.user.deleteMany({ where: { id: { in: userIds } } }); - await db.company.deleteMany({ where: { domain } }); + await db + .delete(calendarEvent) + .where(inArray(calendarEvent.syncedByUserId, userIds)); + await db.delete(emailThread).where(inArray(emailThread.rootMessageId, roots)); + await db.delete(mailboxSync).where(inArray(mailboxSync.userId, userIds)); + await db.delete(accountTable).where(inArray(accountTable.userId, userIds)); + await db.delete(user).where(inArray(user.id, userIds)); + await db.delete(companyTable).where(eq(companyTable.domain, domain)); } async function messagesOn(rootMessageId: string): Promise { - const rows = await db.emailMessage.findMany({ - where: { thread: { rootMessageId } }, - orderBy: { sentAt: "asc" }, - select: { snippet: true }, - }); + const rows = await db + .select({ snippet: emailMessage.snippet }) + .from(emailMessage) + .innerJoin(emailThread, eq(emailThread.id, emailMessage.threadId)) + .where(eq(emailThread.rootMessageId, rootMessageId)) + .orderBy(asc(emailMessage.sentAt)); return rows.map((row) => row.snippet ?? ""); } async function threadState(rootMessageId: string) { - return db.emailThread.findUnique({ - where: { rootMessageId }, - select: { + const row = await db.query.emailThread.findFirst({ + where: eq(emailThread.rootMessageId, rootMessageId), + columns: { subject: true, messageCount: true, firstMessageAt: true, lastMessageAt: true, - activity: { select: { body: true, occurredAt: true } }, }, + with: { activity: { columns: { body: true, occurredAt: true } } }, }); + return row ?? null; } beforeEach(async () => { await clean(); - await db.user.createMany({ - data: userIds.map((id) => ({ + await db.insert(user).values( + userIds.map((id) => ({ id, name: id, email: `${id}@${domain}`, })), - }); + ); await seed(); }); @@ -238,7 +266,7 @@ describe("purging Gmail data", () => { await google.purgeSyncedData(gmailRep); expect(await threadState(solo)).toBeNull(); - expect(await db.activity.count({ where: { subject: "Only mine" } })).toBe( + expect(await db.$count(activity, eq(activity.subject, "Only mine"))).toBe( 0, ); }); @@ -280,22 +308,28 @@ describe("purging Outlook data", () => { expect(await messagesOn(solo)).toEqual(["alone"]); expect( - await db.calendarEvent.count({ where: { iCalUid: `ical-${suffix}` } }), + await db.$count( + calendarEvent, + eq(calendarEvent.iCalUid, `ical-${suffix}`), + ), ).toBe(1); }); }); describe("disconnecting Microsoft", () => { async function grant(scope: string): Promise { - await db.account.create({ - data: { - id: `ms-${suffix}`, - accountId: `ms-account-${suffix}`, - providerId: MICROSOFT_PROVIDER_ID, - userId: outlookRep, - scope, - }, - }); + await one( + db + .insert(accountTable) + .values({ + id: `ms-${suffix}`, + accountId: `ms-account-${suffix}`, + providerId: MICROSOFT_PROVIDER_ID, + userId: outlookRep, + scope, + }) + .returning(), + ); } it("clears the grant even when the token columns are already empty", async () => { @@ -304,10 +338,18 @@ describe("disconnecting Microsoft", () => { expect(await microsoft.revoke(outlookRep)).toEqual({ revoked: true }); - const account = await db.account.findFirst({ - where: { userId: outlookRep, providerId: MICROSOFT_PROVIDER_ID }, - select: { scope: true }, - }); + const account = await first( + db + .select({ scope: accountTable.scope }) + .from(accountTable) + .where( + and( + eq(accountTable.userId, outlookRep), + eq(accountTable.providerId, MICROSOFT_PROVIDER_ID), + ), + ) + .limit(1), + ); expect(account?.scope).toBeNull(); }); @@ -346,23 +388,34 @@ describe("disconnecting Google", () => { const realFetch = globalThis.fetch; async function grant(scope: string, refreshToken: string | null) { - await db.account.create({ - data: { - id: `goog-${suffix}`, - accountId: `goog-account-${suffix}`, - providerId: GOOGLE_PROVIDER_ID, - userId: gmailRep, - refreshToken, - scope, - }, - }); + await one( + db + .insert(accountTable) + .values({ + id: `goog-${suffix}`, + accountId: `goog-account-${suffix}`, + providerId: GOOGLE_PROVIDER_ID, + userId: gmailRep, + refreshToken, + scope, + }) + .returning(), + ); } async function scopeOf(): Promise { - const account = await db.account.findFirst({ - where: { userId: gmailRep, providerId: GOOGLE_PROVIDER_ID }, - select: { scope: true }, - }); + const account = await first( + db + .select({ scope: accountTable.scope }) + .from(accountTable) + .where( + and( + eq(accountTable.userId, gmailRep), + eq(accountTable.providerId, GOOGLE_PROVIDER_ID), + ), + ) + .limit(1), + ); return account?.scope; } diff --git a/apps/api/test/mailbox-sync-tick.spec.ts b/apps/api/test/mailbox-sync-tick.spec.ts index e721ed07f..a818ac66d 100644 --- a/apps/api/test/mailbox-sync-tick.spec.ts +++ b/apps/api/test/mailbox-sync-tick.spec.ts @@ -1,8 +1,5 @@ import { beforeEach, describe, expect, it } from "bun:test"; -import { - GoogleSyncStatus, - type MailboxSyncModel as MailboxSync, -} from "@crm/db"; +import { GoogleSyncStatus, MailboxSync } from "@crm/db"; import type { GoogleConnectionService } from "../src/google/google-connection.service"; import type { GoogleSyncService } from "../src/google/google-sync.service"; import { diff --git a/apps/api/test/mailbox-thread-writer.spec.ts b/apps/api/test/mailbox-thread-writer.spec.ts index da6b34862..a852bdf62 100644 --- a/apps/api/test/mailbox-thread-writer.spec.ts +++ b/apps/api/test/mailbox-thread-writer.spec.ts @@ -1,5 +1,17 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { db, type MailboxSyncModel as MailboxSync } from "@crm/db"; +import { + activity, + company as companyTable, + contact, + db, + emailMessage, + emailThread, + eq, + inArray, + type MailboxSync, + mailboxSync, + user, +} from "@crm/db"; import type { AgentTriggerService } from "../src/agent/agent-trigger.service"; import { CompanyDirectoryService } from "../src/companies/company-directory.service"; import { ActivityStampService } from "../src/crm/activity-stamp.service"; @@ -10,6 +22,7 @@ import { ThreadWriterService, } from "../src/mailbox/thread-writer.service"; import { withDiscardedCrmEvents } from "./agent-trigger.stub"; +import { first, one } from "./rows"; const suffix = process.env.TEST_RUN_ID ?? "thread-writer-spec"; const domain = `threads-${suffix}.test`; @@ -50,37 +63,48 @@ function message(id: string, sentAt: Date, root = rootId): IncomingMessage { } async function clean() { - await db.emailThread.deleteMany({ - where: { rootMessageId: { in: [rootId, movedRoot] } }, - }); - await db.contact.deleteMany({ where: { email: person } }); - await db.company.deleteMany({ where: { domain } }); - await db.mailboxSync.deleteMany({ where: { userId } }); - await db.user.deleteMany({ where: { id: userId } }); + await db + .delete(emailThread) + .where(inArray(emailThread.rootMessageId, [rootId, movedRoot])); + await db.delete(contact).where(eq(contact.email, person)); + await db.delete(companyTable).where(eq(companyTable.domain, domain)); + await db.delete(mailboxSync).where(eq(mailboxSync.userId, userId)); + await db.delete(user).where(eq(user.id, userId)); } beforeAll(async () => { await clean(); - await db.user.create({ - data: { id: userId, name: "Test Rep", email: mailbox }, - }); - row = await db.mailboxSync.create({ - data: { userId, source: "gmail", autoCreate: false }, - }); - - const company = await db.company.create({ - data: { name: "Buyer Co", domain }, - select: { id: true }, - }); - await db.contact.create({ - data: { - firstName: "A", - lastName: "Buyer", - email: person, - companyId: company.id, - }, - }); + await one( + db + .insert(user) + .values({ id: userId, name: "Test Rep", email: mailbox }) + .returning(), + ); + row = await one( + db + .insert(mailboxSync) + .values({ userId, source: "gmail", autoCreate: false }) + .returning(), + ); + + const company = await one( + db + .insert(companyTable) + .values({ name: "Buyer Co", domain }) + .returning({ id: companyTable.id }), + ); + await one( + db + .insert(contact) + .values({ + firstName: "A", + lastName: "Buyer", + email: person, + companyId: company.id, + }) + .returning(), + ); }); afterAll(clean); @@ -96,13 +120,10 @@ describe("storing a synced email", () => { expect(stored).toBe(true); - const thread = await db.emailThread.findUnique({ - where: { rootMessageId: rootId }, - select: { - id: true, - messageCount: true, - activity: { select: { id: true } }, - }, + const thread = await db.query.emailThread.findFirst({ + where: eq(emailThread.rootMessageId, rootId), + columns: { id: true, messageCount: true }, + with: { activity: { columns: { id: true } } }, }); expect(thread?.messageCount).toBe(1); @@ -110,17 +131,23 @@ describe("storing a synced email", () => { }); it("repairs a thread whose projection was lost rather than skipping it forever", async () => { - const thread = await db.emailThread.findUnique({ - where: { rootMessageId: rootId }, - select: { id: true }, - }); + const thread = await first( + db + .select({ id: emailThread.id }) + .from(emailThread) + .where(eq(emailThread.rootMessageId, rootId)) + .limit(1), + ); if (!thread) throw new Error("the first message was not stored"); - await db.activity.deleteMany({ where: { emailThreadId: thread.id } }); - await db.emailThread.update({ - where: { id: thread.id }, - data: { messageCount: 0 }, - }); + await db.delete(activity).where(eq(activity.emailThreadId, thread.id)); + await one( + db + .update(emailThread) + .set({ messageCount: 0 }) + .where(eq(emailThread.id, thread.id)) + .returning(), + ); const stored = await threads.store( row, @@ -131,9 +158,10 @@ describe("storing a synced email", () => { expect(stored).toBe(false); - const repaired = await db.emailThread.findUnique({ - where: { id: thread.id }, - select: { messageCount: true, activity: { select: { id: true } } }, + const repaired = await db.query.emailThread.findFirst({ + where: eq(emailThread.id, thread.id), + columns: { messageCount: true }, + with: { activity: { columns: { id: true } } }, }); expect(repaired?.messageCount).toBe(1); @@ -154,14 +182,16 @@ describe("storing a synced email", () => { expect(results.filter(Boolean)).toHaveLength(1); expect( - await db.emailMessage.count({ - where: { rfcMessageId: parsed.rfcMessageId }, - }), + await db.$count( + emailMessage, + eq(emailMessage.rfcMessageId, parsed.rfcMessageId), + ), ).toBe(1); - const thread = await db.emailThread.findUnique({ - where: { rootMessageId: rootId }, - select: { messageCount: true, activity: { select: { id: true } } }, + const thread = await db.query.emailThread.findFirst({ + where: eq(emailThread.rootMessageId, rootId), + columns: { messageCount: true }, + with: { activity: { columns: { id: true } } }, }); expect(thread?.messageCount).toBe(2); @@ -169,17 +199,23 @@ describe("storing a synced email", () => { }); it("repairs the thread the message is already on when the root id has moved", async () => { - const thread = await db.emailThread.findUnique({ - where: { rootMessageId: rootId }, - select: { id: true }, - }); + const thread = await first( + db + .select({ id: emailThread.id }) + .from(emailThread) + .where(eq(emailThread.rootMessageId, rootId)) + .limit(1), + ); if (!thread) throw new Error("the first message was not stored"); - await db.activity.deleteMany({ where: { emailThreadId: thread.id } }); - await db.emailThread.update({ - where: { id: thread.id }, - data: { messageCount: 0 }, - }); + await db.delete(activity).where(eq(activity.emailThreadId, thread.id)); + await one( + db + .update(emailThread) + .set({ messageCount: 0 }) + .where(eq(emailThread.id, thread.id)) + .returning(), + ); const stored = await threads.store( row, @@ -194,12 +230,13 @@ describe("storing a synced email", () => { expect(stored).toBe(false); expect( - await db.emailThread.count({ where: { rootMessageId: movedRoot } }), + await db.$count(emailThread, eq(emailThread.rootMessageId, movedRoot)), ).toBe(0); - const repaired = await db.emailThread.findUnique({ - where: { id: thread.id }, - select: { messageCount: true, activity: { select: { id: true } } }, + const repaired = await db.query.emailThread.findFirst({ + where: eq(emailThread.id, thread.id), + columns: { messageCount: true }, + with: { activity: { columns: { id: true } } }, }); expect(repaired?.messageCount).toBe(2); diff --git a/apps/api/test/mcp-connections.spec.ts b/apps/api/test/mcp-connections.spec.ts index 1dbc59066..21c10ee8e 100644 --- a/apps/api/test/mcp-connections.spec.ts +++ b/apps/api/test/mcp-connections.spec.ts @@ -2,12 +2,12 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { createHash } from "node:crypto"; import { createMcpSecretBox } from "@crm/auth/mcp-crypto"; import { type AuthorizationServer, McpOAuthClient } from "@crm/auth/mcp-oauth"; -import type { Db } from "@crm/db"; import type { AgentTriggerService } from "../src/agent/agent-trigger.service"; import { assertGrantedScopeBounds, McpConnectionsService, } from "../src/mcp/mcp-connections.service"; +import { type FakeCall, fakeDb, type Row } from "./fake-db"; const userId = "mcp-service-user"; const encryptionKey = Buffer.alloc(32, 7).toString("base64"); @@ -15,63 +15,58 @@ let previousKey: string | undefined; function serviceFor(input: { role?: string | null; - connections?: unknown[]; - agents?: unknown[]; - oauthState?: unknown; - connection?: unknown; - registration?: unknown; + connections?: Row[]; + agents?: Row[]; + oauthState?: Row; + connection?: Row; + registration?: Row; consumeCount?: number; oauth?: McpOAuthClient; - callbackTransaction?: boolean; }) { - type ConnectionUpdate = Parameters[0]; - type ConnectionUpsert = Parameters[0]; - type AgentTaskCreate = Parameters[0]; - type RegistrationUpsert = Parameters< - Db["mcpClientRegistration"]["upsert"] - >[0]; - type OauthStateCreate = Parameters[0]; - const updates: ConnectionUpdate[] = []; - const registrationUpserts: RegistrationUpsert[] = []; - let db: Db; - const transaction = input.callbackTransaction - ? async (operation: (tx: Db) => Promise) => operation(db) - : async (operations: Promise[]) => Promise.all(operations); - db = { - member: { - findUnique: async () => - input.role === null ? null : { role: input.role ?? "owner" }, + const updates: FakeCall[] = []; + const registrationUpserts: FakeCall[] = []; + const { db } = fakeDb({ + select: (table) => { + if (table === "member") + return input.role === null ? [] : [{ role: input.role ?? "owner" }]; + if (table === "mcpConnection") + return ( + input.connections ?? (input.connection ? [input.connection] : []) + ); + if (table === "mcpClientRegistration") + return input.registration ? [input.registration] : []; + return []; }, - mcpConnection: { - findMany: async () => input.connections ?? [], - findUnique: async () => input.connection ?? null, - findUniqueOrThrow: async () => input.connection, - upsert: async (value: ConnectionUpsert) => value, - update: async (value: ConnectionUpdate) => { - updates.push(value); - return value; - }, + insert: (call) => { + if (call.table === "mcpClientRegistration") { + registrationUpserts.push(call); + return [ + { + id: "registration-1", + clientId: "refreshed-client", + ...call.conflict?.set, + }, + ]; + } + if (Array.isArray(call.values)) return call.values; + return call.values ? [call.values] : []; }, - agentTask: { create: async (value: AgentTaskCreate) => value }, - agentDefinition: { findMany: async () => input.agents ?? [] }, - mcpClientRegistration: { - findUnique: async () => input.registration ?? null, - upsert: async (value: RegistrationUpsert) => { - registrationUpserts.push(value); - return { - id: "registration-1", - clientId: "refreshed-client", - ...(value as { update?: object }).update, - }; - }, + update: (call) => { + if (call.table === "mcpOauthState") + return Array.from({ length: input.consumeCount ?? 0 }, () => ({ + id: "state-1", + })); + if (call.table === "mcpConnection") updates.push(call); + return [{ id: "connection-1" }]; }, - mcpOauthState: { - findUnique: async () => input.oauthState ?? null, - updateMany: async () => ({ count: input.consumeCount ?? 0 }), - create: async (value: OauthStateCreate) => value, + query: { + mcpConnection: { findFirst: () => input.connection }, + mcpOauthState: { findFirst: () => input.oauthState }, + agentDefinition: { + findMany: () => input.agents ?? [], + }, }, - $transaction: transaction, - } as unknown as Db; + }); const oauth = input.oauth ?? ({} as McpOAuthClient); const discoveries: boolean[] = []; const agent = { @@ -207,7 +202,6 @@ describe("MCP connections", () => { .digest("hex"); const { service, registrationUpserts } = serviceFor({ oauth, - callbackTransaction: true, registration: { id: "registration-1", clientId: "stale-client", diff --git a/apps/api/test/outlook-sync.spec.ts b/apps/api/test/outlook-sync.spec.ts index 38b2adb2c..3f9907c7b 100644 --- a/apps/api/test/outlook-sync.spec.ts +++ b/apps/api/test/outlook-sync.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import type { MailboxSyncModel as MailboxSync } from "@crm/db"; +import type { MailboxSync } from "@crm/db"; import type { SyncSource } from "../src/mailbox/mailbox.constants"; import type { MailboxTokenService } from "../src/mailbox/mailbox-token.service"; import type { SyncStateService } from "../src/mailbox/sync-state.service"; diff --git a/apps/api/test/record-delete.spec.ts b/apps/api/test/record-delete.spec.ts index cac084ab1..ab9f4a061 100644 --- a/apps/api/test/record-delete.spec.ts +++ b/apps/api/test/record-delete.spec.ts @@ -1,5 +1,21 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { db, RecordSource } from "@crm/db"; +import { + activity, + agentEvent, + agentTask, + company as companyTable, + contact as contactTable, + db, + deal as dealTable, + endsWith, + eq, + inArray, + or, + type PgColumn, + RecordSource, + suppressedContact, + user, +} from "@crm/db"; import { AgentQueueService } from "../src/agent/agent-queue.service"; import { AgentTriggerService } from "../src/agent/agent-trigger.service"; import { CompaniesService } from "../src/companies/companies.service"; @@ -12,6 +28,7 @@ import { ConversionService } from "../src/currency/conversion.service"; import { FieldsService } from "../src/fields/fields.service"; import { MailboxMatchService } from "../src/mailbox/mailbox-match.service"; import { withDiscardedCrmEvents } from "./agent-trigger.stub"; +import { first, one } from "./rows"; const suffix = process.env.TEST_RUN_ID ?? "record-delete-spec"; const domain = `delete-${suffix}.test`; @@ -67,55 +84,62 @@ async function matchContext() { } const domains = [domain, doomedDomain, stampDomain, orphanDomain]; -const ours = { - OR: domains.map((host) => ({ email: { endsWith: `@${host}` } })), -}; +const ours = (email: PgColumn) => + or(...domains.map((host) => endsWith(email, `@${host}`))); async function parked(subject: { contactId?: string; companyId?: string }) { - return db.agentTask.create({ - data: { - ...subject, - kind: "identify", - reason: `record-delete-spec (${suffix})`, - dueAt: new Date(Date.now() + 60 * 60 * 1000), - }, - select: { id: true }, - }); + return one( + db + .insert(agentTask) + .values({ + ...subject, + kind: "identify", + reason: `record-delete-spec (${suffix})`, + dueAt: new Date(Date.now() + 60 * 60 * 1000), + }) + .returning({ id: agentTask.id }), + ); } async function clean() { const [existingContacts, existingCompanies] = await Promise.all([ - db.contact.findMany({ where: ours, select: { id: true } }), - db.company.findMany({ - where: { domain: { in: domains } }, - select: { id: true }, - }), + db + .select({ id: contactTable.id }) + .from(contactTable) + .where(ours(contactTable.email)), + db + .select({ id: companyTable.id }) + .from(companyTable) + .where(inArray(companyTable.domain, domains)), ]); const contactIds = existingContacts.map((row) => row.id); const companyIds = existingCompanies.map((row) => row.id); - await db.agentTask.deleteMany({ - where: { - OR: [ - { reason: `record-delete-spec (${suffix})` }, - { contactId: { in: contactIds } }, - { companyId: { in: companyIds } }, - ], - }, - }); - await db.agentEvent.deleteMany({ where: { contactId: { in: contactIds } } }); - await db.contact.deleteMany({ where: ours }); - await db.company.deleteMany({ where: { domain: { in: domains } } }); - await db.suppressedContact.deleteMany({ where: ours }); - await db.user.deleteMany({ where: { id: userId } }); + await db + .delete(agentTask) + .where( + or( + eq(agentTask.reason, `record-delete-spec (${suffix})`), + inArray(agentTask.contactId, contactIds), + inArray(agentTask.companyId, companyIds), + ), + ); + await db.delete(agentEvent).where(inArray(agentEvent.contactId, contactIds)); + await db.delete(contactTable).where(ours(contactTable.email)); + await db.delete(companyTable).where(inArray(companyTable.domain, domains)); + await db.delete(suppressedContact).where(ours(suppressedContact.email)); + await db.delete(user).where(eq(user.id, userId)); } beforeAll(async () => { await clean(); - await db.user.create({ - data: { id: userId, name: "Test Rep", email: `${userId}@example.test` }, - }); + await one( + db + .insert(user) + .values({ id: userId, name: "Test Rep", email: `${userId}@example.test` }) + .returning(), + ); }); afterAll(clean); @@ -134,16 +158,19 @@ describe("purging a contact", () => { await parked({ contactId }); - await db.agentEvent.create({ - data: { - id: `evt-${suffix}`, - sessionId: `ses-${suffix}`, - contactId, - type: "session.started", - data: {}, - emittedAt: new Date(), - }, - }); + await one( + db + .insert(agentEvent) + .values({ + id: `evt-${suffix}`, + sessionId: `ses-${suffix}`, + contactId, + type: "session.started", + data: {}, + emittedAt: new Date(), + }) + .returning(), + ); expect(await contacts.purge(contactId)).toEqual({ id: contactId, @@ -151,16 +178,30 @@ describe("purging a contact", () => { }); expect( - await db.contact.findUnique({ where: { id: contactId } }), + await first( + db + .select() + .from(contactTable) + .where(eq(contactTable.id, contactId)) + .limit(1), + ), ).toBeNull(); - expect(await db.agentTask.count({ where: { contactId } })).toBe(0); - expect(await db.agentEvent.count({ where: { contactId } })).toBe(0); + expect(await db.$count(agentTask, eq(agentTask.contactId, contactId))).toBe( + 0, + ); + expect( + await db.$count(agentEvent, eq(agentEvent.contactId, contactId)), + ).toBe(0); }); it("remembers the address so the sync cannot bring them back", async () => { - const suppressed = await db.suppressedContact.findUnique({ - where: { email }, - }); + const suppressed = await first( + db + .select() + .from(suppressedContact) + .where(eq(suppressedContact.email, email)) + .limit(1), + ); expect(suppressed).not.toBeNull(); const result = await match.resolve( @@ -175,7 +216,15 @@ describe("purging a contact", () => { expect(result.external).toEqual([]); expect(result.contactId).toBeNull(); - expect(await db.contact.findFirst({ where: { email } })).toBeNull(); + expect( + await first( + db + .select() + .from(contactTable) + .where(eq(contactTable.email, email)) + .limit(1), + ), + ).toBeNull(); }); it("still files the colleagues who were not deleted", async () => { @@ -194,7 +243,13 @@ describe("purging a contact", () => { expect(result.external.map((person) => person.email)).toEqual([colleague]); - const created = await db.contact.findFirst({ where: { email: colleague } }); + const created = await first( + db + .select() + .from(contactTable) + .where(eq(contactTable.email, colleague)) + .limit(1), + ); expect(created?.id).toBe(result.contactId ?? undefined); }); @@ -202,11 +257,24 @@ describe("purging a contact", () => { const readded = await contacts.create({ firstName: "Gone", email }); expect( - await db.suppressedContact.findUnique({ where: { email } }), + await first( + db + .select() + .from(suppressedContact) + .where(eq(suppressedContact.email, email)) + .limit(1), + ), ).toBeNull(); - await db.contact.delete({ where: { id: readded.id } }); - await db.suppressedContact.deleteMany({ where: { email } }); + await one( + db + .delete(contactTable) + .where(eq(contactTable.id, readded.id)) + .returning(), + ); + await db + .delete(suppressedContact) + .where(eq(suppressedContact.email, email)); }); it("suppresses an address a rep typed in caps as the sync will see it", async () => { @@ -216,16 +284,25 @@ describe("purging a contact", () => { const created = await contacts.create({ firstName: "Mixed", email: typed }); expect( - await db.contact.findUnique({ - where: { id: created.id }, - select: { email: true }, - }), + await first( + db + .select({ email: contactTable.email }) + .from(contactTable) + .where(eq(contactTable.id, created.id)) + .limit(1), + ), ).toEqual({ email: asSynced }); await contacts.purge(created.id); expect( - await db.suppressedContact.findUnique({ where: { email: asSynced } }), + await first( + db + .select() + .from(suppressedContact) + .where(eq(suppressedContact.email, asSynced)) + .limit(1), + ), ).not.toBeNull(); const result = await match.resolve( @@ -240,7 +317,13 @@ describe("purging a contact", () => { expect(result.external).toEqual([]); expect( - await db.contact.findFirst({ where: { email: asSynced } }), + await first( + db + .select() + .from(contactTable) + .where(eq(contactTable.email, asSynced)) + .limit(1), + ), ).toBeNull(); }); }); @@ -257,10 +340,12 @@ describe("purging a company", () => { email: `left@${doomedDomain}`, companyId: company.id, }); - const deal = await db.deal.create({ - data: { name: "Doomed deal", companyId: company.id, ownerId: userId }, - select: { id: true }, - }); + const deal = await one( + db + .insert(dealTable) + .values({ name: "Doomed deal", companyId: company.id, ownerId: userId }) + .returning({ id: dealTable.id }), + ); await parked({ companyId: company.id }); @@ -269,18 +354,30 @@ describe("purging a company", () => { name: "Doomed", }); - expect(await db.deal.findUnique({ where: { id: deal.id } })).toBeNull(); - expect(await db.agentTask.count({ where: { companyId: company.id } })).toBe( - 0, + expect( + await first( + db.select().from(dealTable).where(eq(dealTable.id, deal.id)).limit(1), + ), + ).toBeNull(); + expect( + await db.$count(agentTask, eq(agentTask.companyId, company.id)), + ).toBe(0); + + const survivor = await first( + db + .select({ companyId: contactTable.companyId }) + .from(contactTable) + .where(eq(contactTable.id, contact.id)) + .limit(1), ); - - const survivor = await db.contact.findUnique({ - where: { id: contact.id }, - select: { companyId: true }, - }); expect(survivor?.companyId).toBeNull(); - await db.contact.delete({ where: { id: contact.id } }); + await one( + db + .delete(contactTable) + .where(eq(contactTable.id, contact.id)) + .returning(), + ); }); }); @@ -295,23 +392,32 @@ describe("the activity stamps a purge leaves behind", () => { email: `stamped@${stampDomain}`, companyId: company.id, }); - const deal = await db.deal.create({ - data: { name: "Stamped deal", companyId: company.id, ownerId: userId }, - select: { id: true }, - }); + const deal = await one( + db + .insert(dealTable) + .values({ + name: "Stamped deal", + companyId: company.id, + ownerId: userId, + }) + .returning({ id: dealTable.id }), + ); const at = new Date(); - await db.activity.create({ - data: { - type: "NOTE", - subject: "The only thing on this account", - companyId: company.id, - contactId: contact.id, - dealId: deal.id, - createdById: userId, - createdAt: at, - }, - }); + await one( + db + .insert(activity) + .values({ + type: "NOTE", + subject: "The only thing on this account", + companyId: company.id, + contactId: contact.id, + dealId: deal.id, + createdById: userId, + createdAt: at, + }) + .returning(), + ); await stamp.touch( { companyId: company.id, contactId: contact.id, dealId: deal.id }, at, @@ -320,16 +426,22 @@ describe("the activity stamps a purge leaves behind", () => { await contacts.purge(contact.id); expect( - await db.company.findUnique({ - where: { id: company.id }, - select: { lastActivityAt: true }, - }), + await first( + db + .select({ lastActivityAt: companyTable.lastActivityAt }) + .from(companyTable) + .where(eq(companyTable.id, company.id)) + .limit(1), + ), ).toEqual({ lastActivityAt: null }); expect( - await db.deal.findUnique({ - where: { id: deal.id }, - select: { lastActivityAt: true }, - }), + await first( + db + .select({ lastActivityAt: dealTable.lastActivityAt }) + .from(dealTable) + .where(eq(dealTable.id, deal.id)) + .limit(1), + ), ).toEqual({ lastActivityAt: null }); }); @@ -343,33 +455,53 @@ describe("the activity stamps a purge leaves behind", () => { email: `orphaned@${orphanDomain}`, companyId: company.id, }); - const deal = await db.deal.create({ - data: { name: "Orphaned deal", companyId: company.id, ownerId: userId }, - select: { id: true }, - }); + const deal = await one( + db + .insert(dealTable) + .values({ + name: "Orphaned deal", + companyId: company.id, + ownerId: userId, + }) + .returning({ id: dealTable.id }), + ); const at = new Date(); - await db.activity.create({ - data: { - type: "MEETING", - subject: "Only ever attached to the deal", - contactId: contact.id, - dealId: deal.id, - createdById: userId, - createdAt: at, - }, - }); + await one( + db + .insert(activity) + .values({ + type: "MEETING", + subject: "Only ever attached to the deal", + contactId: contact.id, + dealId: deal.id, + createdById: userId, + createdAt: at, + }) + .returning(), + ); await stamp.touch({ contactId: contact.id, dealId: deal.id }, at); await companies.purge(company.id); expect( - await db.contact.findUnique({ - where: { id: contact.id }, - select: { companyId: true, lastActivityAt: true }, - }), + await first( + db + .select({ + companyId: contactTable.companyId, + lastActivityAt: contactTable.lastActivityAt, + }) + .from(contactTable) + .where(eq(contactTable.id, contact.id)) + .limit(1), + ), ).toEqual({ companyId: null, lastActivityAt: null }); - await db.contact.delete({ where: { id: contact.id } }); + await one( + db + .delete(contactTable) + .where(eq(contactTable.id, contact.id)) + .returning(), + ); }); }); diff --git a/apps/api/test/rows.ts b/apps/api/test/rows.ts new file mode 100644 index 000000000..484eaab14 --- /dev/null +++ b/apps/api/test/rows.ts @@ -0,0 +1,12 @@ +export async function one(rows: PromiseLike): Promise { + const [row] = await rows; + if (row === undefined) throw new Error("The query returned no row."); + return row; +} + +export async function first( + rows: PromiseLike, +): Promise { + const [row] = await rows; + return row ?? null; +} diff --git a/apps/api/test/setup.ts b/apps/api/test/setup.ts index f8ec45ffe..16318ec4c 100644 --- a/apps/api/test/setup.ts +++ b/apps/api/test/setup.ts @@ -3,5 +3,5 @@ import { afterAll } from "bun:test"; afterAll(async () => { if (!process.env.DATABASE_URL) return; const { db } = await import("@crm/db"); - await db.$disconnect(); + await db.$client.end(); }); diff --git a/apps/api/test/slack-connection.spec.ts b/apps/api/test/slack-connection.spec.ts index 48b0c6a2e..3114ac03a 100644 --- a/apps/api/test/slack-connection.spec.ts +++ b/apps/api/test/slack-connection.spec.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "bun:test"; import type { WorkspaceRole } from "@crm/auth/organization"; -import type { Db } from "@crm/db"; import { SLACK_INVENTORY } from "@crm/db/slack-inventory"; import type { AgentAccessService } from "../src/agent/agent-access.service"; import type { AgentTriggerService } from "../src/agent/agent-trigger.service"; import type { SlackChannelsService } from "../src/slack/slack-channels.service"; import { SlackConnectionService } from "../src/slack/slack-connection.service"; +import { fakeDb, type Row, rendered } from "./fake-db"; const userId = "crm-1"; @@ -25,7 +25,7 @@ function serviceFor(input: { }; }>; memberCount?: number; - agents?: unknown[]; + agents?: Row[]; syncingTask?: { createdAt: Date; startedAt: Date | null; @@ -47,57 +47,48 @@ function serviceFor(input: { const requested: Array<{ reason: string; required: boolean | undefined }> = []; const deleted: string[] = []; - const tx = { - account: { - deleteMany: async () => { - deleted.push("account"); - return { count: input.accountUpdatedAt ? 1 : 0 }; - }, - }, - slackChannel: { - deleteMany: async () => { - deleted.push("slackChannel"); - return { count: 0 }; - }, + const { db } = fakeDb({ + select: (table, call) => { + if (table === "account") + return input.accountUpdatedAt + ? [ + { + id: "account-1", + accountId: "slack-user", + updatedAt: input.accountUpdatedAt, + scope: null, + }, + ] + : []; + if (table === "agentDefinition") return input.agents ?? []; + if (table === "slackMemberMatch") return input.matches ?? []; + if (table === "slackWorkspaceGrant") + return input.grant ? [{ id: "grant-1", teamName: null }] : []; + if (table === "member") + return (input.members ?? []).map(({ user }) => ({ + user: { id: user.id, name: user.name, email: user.email }, + match: user.slackMemberMatch + ? { ...user.slackMemberMatch, id: `match-${user.id}` } + : null, + })); + if (table === "agentTask") { + const open = rendered(call.where)?.sql.includes( + '"agentTask"."finishedAt" is null', + ); + const row = open ? input.syncingTask : input.lastFill; + return row ? [row] : []; + } + if (table === "slackChannel") return input.channels ?? []; + return []; }, - slackWorkspaceGrant: { - deleteMany: async () => { - deleted.push("slackWorkspaceGrant"); - return { count: 0 }; - }, - }, - }; - const db = { - $transaction: async (run: (client: typeof tx) => Promise) => run(tx), - account: { - findFirst: async () => - input.accountUpdatedAt - ? { - id: "account-1", - accountId: "slack-user", - updatedAt: input.accountUpdatedAt, - } - : null, + count: () => input.memberCount ?? 0, + delete: (call) => { + deleted.push(call.table); + return call.table === "account" && input.accountUpdatedAt + ? [{ id: "account-1" }] + : []; }, - agentDefinition: { findMany: async () => input.agents ?? [] }, - slackMemberMatch: { findMany: async () => input.matches ?? [] }, - slackWorkspaceGrant: { - findFirst: async () => (input.grant ? { id: "grant-1" } : null), - }, - member: { - count: async () => input.memberCount ?? 0, - findMany: async () => input.members ?? [], - }, - agentTask: { - findFirst: async (args: { where: { finishedAt?: null } }) => - args.where.finishedAt === null - ? (input.syncingTask ?? null) - : (input.lastFill ?? null), - }, - slackChannel: { - findMany: async () => input.channels ?? [], - }, - } as unknown as Db; + }); const agent = { slackPeopleRequested: async (reason: string, required?: boolean) => { requested.push({ reason, required }); diff --git a/apps/api/test/sso.spec.ts b/apps/api/test/sso.spec.ts index 6024204fd..286fadd31 100644 --- a/apps/api/test/sso.spec.ts +++ b/apps/api/test/sso.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "bun:test"; import { isGoogleConfigured } from "@crm/auth/env"; import { WORKSPACE_ID } from "@crm/auth/organization"; -import type { Db } from "@crm/db"; import { ForbiddenException } from "@nestjs/common"; import { SsoService } from "../src/sso/sso.service"; +import { fakeDb, rendered } from "./fake-db"; type Row = { providerId: string; @@ -26,18 +26,17 @@ type Seen = { providerWhere?: unknown }; function service(role: string | null, rows: Row[] = []) { const seen: Seen = {}; - const db = { - member: { - findUnique: async () => (role === null ? null : { role }), - }, - ssoProvider: { - findMany: async ({ where }: { where: unknown }) => { - seen.providerWhere = where; + const { db } = fakeDb({ + select: (table, call) => { + if (table === "member") return role === null ? [] : [{ role }]; + if (table === "ssoProvider") { + seen.providerWhere = rendered(call.where); return rows; - }, - count: async () => rows.length, + } + return []; }, - } as unknown as Db; + count: () => rows.length, + }); return { sso: new SsoService(db), seen }; } @@ -110,7 +109,10 @@ describe("what a provider looks like once it is saved", () => { const { sso, seen } = service("owner", [OKTA]); await sso.list(LIST); - expect(seen.providerWhere).toEqual({ organizationId: WORKSPACE_ID }); + expect(seen.providerWhere).toEqual({ + sql: '"ssoProvider"."organizationId" = $1', + params: [WORKSPACE_ID], + }); }); it("searches the name, the domain and the issuer", async () => { @@ -118,12 +120,8 @@ describe("what a provider looks like once it is saved", () => { await sso.list({ ...LIST, q: " acme " }); expect(seen.providerWhere).toEqual({ - organizationId: WORKSPACE_ID, - OR: [ - { providerId: { contains: "acme", mode: "insensitive" } }, - { domain: { contains: "acme", mode: "insensitive" } }, - { issuer: { contains: "acme", mode: "insensitive" } }, - ], + sql: '("ssoProvider"."organizationId" = $1 and ("ssoProvider"."providerId" ilike $2 or "ssoProvider"."domain" ilike $3 or "ssoProvider"."issuer" ilike $4))', + params: [WORKSPACE_ID, "%acme%", "%acme%", "%acme%"], }); }); }); diff --git a/apps/api/test/task-due-date.spec.ts b/apps/api/test/task-due-date.spec.ts index c65cdba2b..6f9ceac2b 100644 --- a/apps/api/test/task-due-date.spec.ts +++ b/apps/api/test/task-due-date.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import { PgDialect, type SQL } from "@crm/db"; import { parseTaskDueDay, serializeTaskDueDay, @@ -6,6 +7,14 @@ import { taskWindowFilter, } from "../src/activities/task-due-date"; +const dialect = new PgDialect(); + +function rendered(filter: SQL | undefined) { + if (!filter) throw new Error("The window returned no filter."); + const { sql, params } = dialect.sqlToQuery(filter); + return { sql, params }; +} + describe("task calendar days", () => { it("stores and returns a selected day without a timezone shift", () => { const stored = parseTaskDueDay("2026-10-22"); @@ -29,25 +38,22 @@ describe("task calendar days", () => { it("ends this week at the next Monday", () => { const reference = new Date("2026-10-22T12:00:00.000Z"); - expect(taskWindowFilter("week", reference)).toEqual({ - dueAt: { - gte: new Date("2026-10-23T00:00:00.000Z"), - lt: new Date("2026-10-26T00:00:00.000Z"), - }, + expect(rendered(taskWindowFilter("week", reference))).toEqual({ + sql: '("activity"."dueAt" >= $1 and "activity"."dueAt" < $2)', + params: ["2026-10-23T00:00:00.000Z", "2026-10-26T00:00:00.000Z"], }); }); it("keeps today separate from overdue work", () => { const reference = new Date("2026-10-22T12:00:00.000Z"); - expect(taskWindowFilter("overdue", reference)).toEqual({ - dueAt: { lt: new Date("2026-10-22T00:00:00.000Z") }, + expect(rendered(taskWindowFilter("overdue", reference))).toEqual({ + sql: '"activity"."dueAt" < $1', + params: ["2026-10-22T00:00:00.000Z"], }); - expect(taskWindowFilter("today", reference)).toEqual({ - dueAt: { - gte: new Date("2026-10-22T00:00:00.000Z"), - lt: new Date("2026-10-23T00:00:00.000Z"), - }, + expect(rendered(taskWindowFilter("today", reference))).toEqual({ + sql: '("activity"."dueAt" >= $1 and "activity"."dueAt" < $2)', + params: ["2026-10-22T00:00:00.000Z", "2026-10-23T00:00:00.000Z"], }); }); }); diff --git a/apps/api/test/tasks.spec.ts b/apps/api/test/tasks.spec.ts index 36ea62117..b8ddae8f8 100644 --- a/apps/api/test/tasks.spec.ts +++ b/apps/api/test/tasks.spec.ts @@ -1,11 +1,20 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { ActivityType, db } from "@crm/db"; +import { + ActivityType, + activity, + and, + company as companyTable, + db, + eq, + user, +} from "@crm/db"; import { ActivitiesService } from "../src/activities/activities.service"; import { serializeTaskDueDay, taskDayBoundaries, } from "../src/activities/task-due-date"; import { ActivityStampService } from "../src/crm/activity-stamp.service"; +import { one } from "./rows"; const suffix = process.env.TEST_RUN_ID ?? "tasks-spec"; const userId = `tasks-user-${suffix}`; @@ -15,55 +24,58 @@ const activities = new ActivitiesService(db, stamp); let companyId: string; async function clean() { - await db.activity.deleteMany({ where: { createdById: userId } }); - await db.company.deleteMany({ where: { domain } }); - await db.user.deleteMany({ where: { id: userId } }); + await db.delete(activity).where(eq(activity.createdById, userId)); + await db.delete(companyTable).where(eq(companyTable.domain, domain)); + await db.delete(user).where(eq(user.id, userId)); } beforeAll(async () => { await clean(); - await db.user.create({ - data: { id: userId, name: "Task Owner", email: `owner@${domain}` }, - }); - const company = await db.company.create({ - data: { name: "Task Company", domain }, - select: { id: true }, - }); + await one( + db + .insert(user) + .values({ id: userId, name: "Task Owner", email: `owner@${domain}` }) + .returning(), + ); + const company = await one( + db + .insert(companyTable) + .values({ name: "Task Company", domain }) + .returning({ id: companyTable.id }), + ); companyId = company.id; const { startOfToday } = taskDayBoundaries(new Date()); const yesterday = new Date(startOfToday.getTime() - 86_400_000); - await db.activity.createMany({ - data: [ - { - type: ActivityType.TASK, - subject: "Ship 100% complete", - dueAt: startOfToday, - companyId, - createdById: userId, - }, - { - type: ActivityType.TASK, - subject: "Past task", - dueAt: yesterday, - companyId, - createdById: userId, - }, - { - type: ActivityType.TASK, - subject: "No date task", - companyId, - createdById: userId, - }, - { - type: ActivityType.TASK, - subject: "Done task", - completedAt: new Date(), - companyId, - createdById: userId, - }, - ], - }); + await db.insert(activity).values([ + { + type: ActivityType.TASK, + subject: "Ship 100% complete", + dueAt: startOfToday, + companyId, + createdById: userId, + }, + { + type: ActivityType.TASK, + subject: "Past task", + dueAt: yesterday, + companyId, + createdById: userId, + }, + { + type: ActivityType.TASK, + subject: "No date task", + companyId, + createdById: userId, + }, + { + type: ActivityType.TASK, + subject: "Done task", + completedAt: new Date(), + companyId, + createdById: userId, + }, + ]); }); afterAll(clean); @@ -138,10 +150,18 @@ describe("task list", () => { describe("task editing", () => { it("updates a task with a calendar day", async () => { - const task = await db.activity.findFirstOrThrow({ - where: { createdById: userId, subject: "No date task" }, - select: { id: true }, - }); + const task = await one( + db + .select({ id: activity.id }) + .from(activity) + .where( + and( + eq(activity.createdById, userId), + eq(activity.subject, "No date task"), + ), + ) + .limit(1), + ); const updated = await activities.update({ id: task.id, diff --git a/apps/api/test/tracking-filing.integration.spec.ts b/apps/api/test/tracking-filing.integration.spec.ts index f9da07670..479619f66 100644 --- a/apps/api/test/tracking-filing.integration.spec.ts +++ b/apps/api/test/tracking-filing.integration.spec.ts @@ -6,7 +6,24 @@ import { expect, it, } from "bun:test"; -import { db } from "@crm/db"; +import { + activity, + and, + company, + contact as contactTable, + contains, + db, + endsWith, + eq, + formSubmission, + inArray, + startsWith, + suppressedContact, + suppressedDomain, + trackedVisitor, + trackingCounter, + user as userTable, +} from "@crm/db"; import { CONTACT_CAP_REASON, CONTACTS_PER_HOUR, @@ -18,6 +35,7 @@ import { ActivityStampService } from "../src/crm/activity-stamp.service"; import { TrackingCounterService } from "../src/tracking/tracking-counter.service"; import { TrackingFilingService } from "../src/tracking/tracking-filing.service"; import { withDiscardedCrmEvents } from "./agent-trigger.stub"; +import { first, one } from "./rows"; const suffix = process.env.TEST_RUN_ID ?? "filing-spec"; const domain = `visitors-${suffix}.test`; @@ -43,16 +61,18 @@ const filing = new TrackingFilingService(db, counters, directory, agent, stamp); let userId: string; async function submit(email: string | null, name: string | null = "Dana Reed") { - const row = await db.formSubmission.create({ - data: { - host, - path: "/pricing", - email, - fields: name ? { name, email: email ?? "" } : {}, - dedupeKey: `${suffix}-${Math.random().toString(36).slice(2)}`, - }, - select: { id: true }, - }); + const row = await one( + db + .insert(formSubmission) + .values({ + host, + path: "/pricing", + email, + fields: name ? { name, email: email ?? "" } : {}, + dedupeKey: `${suffix}-${Math.random().toString(36).slice(2)}`, + }) + .returning({ id: formSubmission.id }), + ); const outcome = await filing.file({ id: row.id, @@ -62,57 +82,72 @@ async function submit(email: string | null, name: string | null = "Dana Reed") { name, }); - const stored = await db.formSubmission.findUnique({ - where: { id: row.id }, - select: { contactId: true, filedAt: true, skipReason: true }, - }); + const stored = await first( + db + .select({ + contactId: formSubmission.contactId, + filedAt: formSubmission.filedAt, + skipReason: formSubmission.skipReason, + }) + .from(formSubmission) + .where(eq(formSubmission.id, row.id)) + .limit(1), + ); return { outcome, stored }; } async function clean() { - await db.activity.deleteMany({ where: { body: { contains: host } } }); - await db.formSubmission.deleteMany({ where: { host } }); - await db.trackedVisitor.deleteMany({ - where: { id: { startsWith: "visitor-" } }, - }); - await db.contact.deleteMany({ where: { email: { endsWith: `@${domain}` } } }); - await db.contact.deleteMany({ where: { email: `free-${suffix}@gmail.com` } }); - await db.company.deleteMany({ where: { domain } }); - await db.suppressedContact.deleteMany({ - where: { email: { endsWith: `@${domain}` } }, - }); - await db.suppressedDomain.deleteMany({ where: { domain } }); - await db.trackingCounter.deleteMany({ where: {} }); + await db.delete(activity).where(contains(activity.body, host)); + await db.delete(formSubmission).where(eq(formSubmission.host, host)); + await db + .delete(trackedVisitor) + .where(startsWith(trackedVisitor.id, "visitor-")); + await db + .delete(contactTable) + .where(endsWith(contactTable.email, `@${domain}`)); + await db + .delete(contactTable) + .where(eq(contactTable.email, `free-${suffix}@gmail.com`)); + await db.delete(company).where(eq(company.domain, domain)); + await db + .delete(suppressedContact) + .where(endsWith(suppressedContact.email, `@${domain}`)); + await db.delete(suppressedDomain).where(eq(suppressedDomain.domain, domain)); + await db.delete(trackingCounter); } beforeAll(async () => { await clean(); - const user = await db.user.findFirst({ select: { id: true } }); + const user = await first( + db.select({ id: userTable.id }).from(userTable).limit(1), + ); userId = user?.id ?? ( - await db.user.create({ - data: { - id: `user-${suffix}`, - name: "Test Rep", - email: `rep-${suffix}@example.test`, - }, - select: { id: true }, - }) + await one( + db + .insert(userTable) + .values({ + id: `user-${suffix}`, + name: "Test Rep", + email: `rep-${suffix}@example.test`, + }) + .returning({ id: userTable.id }), + ) ).id; }); beforeEach(async () => { queued.length = 0; - await db.trackingCounter.deleteMany({ where: {} }); + await db.delete(trackingCounter); }); afterAll(async () => { await clean(); if (userId.startsWith(`user-${suffix}`)) { - await db.user.deleteMany({ where: { id: userId } }); + await db.delete(userTable).where(eq(userTable.id, userId)); } }); @@ -134,7 +169,7 @@ describe("filing a form submission", () => { it("refuses an address a rep has deleted", async () => { const email = `deleted@${domain}`; - await db.suppressedContact.create({ data: { email } }); + await one(db.insert(suppressedContact).values({ email }).returning()); const { outcome, stored } = await submit(email); @@ -143,14 +178,16 @@ describe("filing a form submission", () => { }); it("refuses a suppressed domain", async () => { - await db.suppressedDomain.create({ data: { domain } }); + await one(db.insert(suppressedDomain).values({ domain }).returning()); const { outcome, stored } = await submit(`someone@${domain}`); expect(outcome.filed).toBe(false); expect(stored?.skipReason).toContain("suppressed"); - await db.suppressedDomain.deleteMany({ where: { domain } }); + await db + .delete(suppressedDomain) + .where(eq(suppressedDomain.domain, domain)); }); it("files a free-mail address as a contact with no company", async () => { @@ -159,10 +196,16 @@ describe("filing a form submission", () => { expect(outcome.filed).toBe(true); expect(stored?.filedAt).not.toBeNull(); - const contact = await db.contact.findUnique({ - where: { email: `free-${suffix}@gmail.com` }, - select: { companyId: true, source: true }, - }); + const contact = await first( + db + .select({ + companyId: contactTable.companyId, + source: contactTable.source, + }) + .from(contactTable) + .where(eq(contactTable.email, `free-${suffix}@gmail.com`)) + .limit(1), + ); expect(contact?.companyId).toBeNull(); expect(contact?.source).toBe("TRACKING"); @@ -176,10 +219,16 @@ describe("filing a form submission", () => { expect(stored?.contactId).toBeTruthy(); expect(queued).toHaveLength(1); - const contact = await db.contact.findUnique({ - where: { email }, - select: { companyId: true, firstName: true }, - }); + const contact = await first( + db + .select({ + companyId: contactTable.companyId, + firstName: contactTable.firstName, + }) + .from(contactTable) + .where(eq(contactTable.email, email)) + .limit(1), + ); expect(contact?.companyId).toBeTruthy(); expect(contact?.firstName).toBe("Dana"); @@ -187,16 +236,18 @@ describe("filing a form submission", () => { it("writes one note when the same submission is filed twice at once", async () => { const email = `raced@${domain}`; - const row = await db.formSubmission.create({ - data: { - host, - path: "/pricing", - email, - fields: { email }, - dedupeKey: `${suffix}-raced`, - }, - select: { id: true }, - }); + const row = await one( + db + .insert(formSubmission) + .values({ + host, + path: "/pricing", + email, + fields: { email }, + dedupeKey: `${suffix}-raced`, + }) + .returning({ id: formSubmission.id }), + ); const file = () => filing.file({ @@ -211,11 +262,23 @@ describe("filing a form submission", () => { expect(first.filed).toBe(true); expect(second.filed).toBe(true); - expect(await db.contact.count({ where: { email } })).toBe(1); + expect(await db.$count(contactTable, eq(contactTable.email, email))).toBe( + 1, + ); - const notes = await db.activity.count({ - where: { body: { contains: host }, contact: { email } }, - }); + const notes = await db.$count( + activity, + and( + contains(activity.body, host), + inArray( + activity.contactId, + db + .select({ id: contactTable.id }) + .from(contactTable) + .where(eq(contactTable.email, email)), + ), + ), + ); expect(notes).toBe(1); }); @@ -224,16 +287,18 @@ describe("filing a form submission", () => { const email = `refunded@${domain}`; const rows = await Promise.all( [1, 2].map((n) => - db.formSubmission.create({ - data: { - host, - path: "/pricing", - email, - fields: { email }, - dedupeKey: `${suffix}-refunded-${n}`, - }, - select: { id: true }, - }), + one( + db + .insert(formSubmission) + .values({ + host, + path: "/pricing", + email, + fields: { email }, + dedupeKey: `${suffix}-refunded-${n}`, + }) + .returning({ id: formSubmission.id }), + ), ), ); @@ -249,12 +314,17 @@ describe("filing a form submission", () => { ), ); - expect(await db.contact.count({ where: { email } })).toBe(1); + expect(await db.$count(contactTable, eq(contactTable.email, email))).toBe( + 1, + ); - const counter = await db.trackingCounter.findUnique({ - where: { key: contactWindowKey() }, - select: { value: true }, - }); + const counter = await first( + db + .select({ value: trackingCounter.value }) + .from(trackingCounter) + .where(eq(trackingCounter.key, contactWindowKey())) + .limit(1), + ); expect(counter?.value).toBe(1); }); @@ -271,20 +341,22 @@ describe("filing a form submission", () => { expect(second.stored?.contactId).toBe(first.stored?.contactId ?? ""); expect(queued).toHaveLength(0); - const count = await db.contact.count({ where: { email } }); + const count = await db.$count(contactTable, eq(contactTable.email, email)); expect(count).toBe(1); }); it("stores but does not file once the hourly cap is reached", async () => { - await db.trackingCounter.upsert({ - where: { key: contactWindowKey() }, - create: { + await db + .insert(trackingCounter) + .values({ key: contactWindowKey(), value: CONTACTS_PER_HOUR, expiresAt: new Date(Date.now() + 3_600_000), - }, - update: { value: CONTACTS_PER_HOUR }, - }); + }) + .onConflictDoUpdate({ + target: trackingCounter.key, + set: { value: CONTACTS_PER_HOUR }, + }); const { outcome, stored } = await submit(`capped@${domain}`); @@ -303,19 +375,31 @@ describe("the hourly counter", () => { expect(await counters.take(key, 2)).toBe(false); expect(await counters.take(key, 2)).toBe(false); - await db.trackingCounter.deleteMany({ where: { key } }); + await db.delete(trackingCounter).where(eq(trackingCounter.key, key)); }); it("keeps a fixed window rather than sliding on every write", async () => { const key = contactWindowKey(); await counters.take(key, 10); - const first = await db.trackingCounter.findUnique({ where: { key } }); + const initial = await first( + db + .select() + .from(trackingCounter) + .where(eq(trackingCounter.key, key)) + .limit(1), + ); await counters.take(key, 10); - const second = await db.trackingCounter.findUnique({ where: { key } }); + const second = await first( + db + .select() + .from(trackingCounter) + .where(eq(trackingCounter.key, key)) + .limit(1), + ); - expect(second?.expiresAt.getTime()).toBe(first?.expiresAt.getTime() ?? 0); + expect(second?.expiresAt.getTime()).toBe(initial?.expiresAt.getTime() ?? 0); expect(second?.value).toBe(2); }); }); diff --git a/apps/api/test/tracking-ingest.integration.spec.ts b/apps/api/test/tracking-ingest.integration.spec.ts index 510629793..65aa4a985 100644 --- a/apps/api/test/tracking-ingest.integration.spec.ts +++ b/apps/api/test/tracking-ingest.integration.spec.ts @@ -6,7 +6,19 @@ import { expect, it, } from "bun:test"; -import { db } from "@crm/db"; +import { + and, + asc, + db, + eq, + formSubmission, + inArray, + isNull, + startsWith, + trackedDomain, + trackedEvent, + trackingCounter, +} from "@crm/db"; import { EVENTS_PER_MINUTE, type TrackingConfig } from "@crm/db/tracking"; import type { TrackingConfigService } from "../src/tracking/tracking-config.service"; import { TrackingCounterService } from "../src/tracking/tracking-counter.service"; @@ -15,6 +27,7 @@ import { type IncomingEvent, TrackingIngestService, } from "../src/tracking/tracking-ingest.service"; +import { first } from "./rows"; const suffix = process.env.TEST_RUN_ID ?? "ingest-spec"; const parent = `sites-${suffix}.test`; @@ -46,12 +59,12 @@ const filed: string[] = []; const filing = { file: async ({ id }: { id: string }) => { - const claimed = await db.formSubmission.updateMany({ - where: { id, filedAt: null }, - data: { filedAt: new Date() }, - }); + const claimed = await db + .update(formSubmission) + .set({ filedAt: new Date() }) + .where(and(eq(formSubmission.id, id), isNull(formSubmission.filedAt))); - if (claimed.count === 0) { + if (claimed.rowCount === 0) { return { filed: false as const, reason: "another delivery filed it" }; } @@ -93,14 +106,14 @@ function view(host: string, path = "/pricing"): IncomingEvent { async function clean() { for (const host of [parent, child, exact]) { - await db.formSubmission.deleteMany({ where: { host } }); - await db.trackedEvent.deleteMany({ where: { host } }); + await db.delete(formSubmission).where(eq(formSubmission.host, host)); + await db.delete(trackedEvent).where(eq(trackedEvent.host, host)); } - await db.trackedDomain.deleteMany({ - where: { host: { in: [parent, exact] } }, - }); - await db.trackingCounter.deleteMany({ where: {} }); + await db + .delete(trackedDomain) + .where(inArray(trackedDomain.host, [parent, exact])); + await db.delete(trackingCounter); } beforeAll(clean); @@ -108,12 +121,10 @@ beforeAll(clean); beforeEach(async () => { filed.length = 0; await clean(); - await db.trackedDomain.createMany({ - data: [ - { host: parent, scope: "SITE_AND_SUBDOMAINS" }, - { host: exact, scope: "EXACT_HOST" }, - ], - }); + await db.insert(trackedDomain).values([ + { host: parent, scope: "SITE_AND_SUBDOMAINS" }, + { host: exact, scope: "EXACT_HOST" }, + ]); }); afterAll(clean); @@ -122,10 +133,13 @@ describe("counting page views against a domain", () => { it("credits a subdomain's views to the parent that covers it", async () => { await accept([view(child), view(child), view(parent)]); - const row = await db.trackedDomain.findUnique({ - where: { host: parent }, - select: { pageViews: true }, - }); + const row = await first( + db + .select({ pageViews: trackedDomain.pageViews }) + .from(trackedDomain) + .where(eq(trackedDomain.host, parent)) + .limit(1), + ); expect(row?.pageViews).toBe(3); }); @@ -133,11 +147,11 @@ describe("counting page views against a domain", () => { it("gives each domain only the views that were its own", async () => { await accept([view(child), view(exact), view(exact)]); - const rows = await db.trackedDomain.findMany({ - where: { host: { in: [parent, exact] } }, - select: { host: true, pageViews: true }, - orderBy: { host: "asc" }, - }); + const rows = await db + .select({ host: trackedDomain.host, pageViews: trackedDomain.pageViews }) + .from(trackedDomain) + .where(inArray(trackedDomain.host, [parent, exact])) + .orderBy(asc(trackedDomain.host)); expect(rows.find((row) => row.host === parent)?.pageViews).toBe(1); expect(rows.find((row) => row.host === exact)?.pageViews).toBe(2); @@ -149,10 +163,13 @@ describe("counting page views against a domain", () => { { type: "click", host: parent, path: "/pricing", at: Date.now() }, ]); - const row = await db.trackedDomain.findUnique({ - where: { host: parent }, - select: { pageViews: true }, - }); + const row = await first( + db + .select({ pageViews: trackedDomain.pageViews }) + .from(trackedDomain) + .where(eq(trackedDomain.host, parent)) + .limit(1), + ); expect(row?.pageViews).toBe(1); }); @@ -162,16 +179,19 @@ describe("the rate limit", () => { it("charges every event in the batch, not one per request", async () => { await accept([view(parent), view(parent), view(parent)]); - const counter = await db.trackingCounter.findFirst({ - where: { key: { startsWith: "rate:" } }, - select: { value: true }, - }); + const counter = await first( + db + .select({ value: trackingCounter.value }) + .from(trackingCounter) + .where(startsWith(trackingCounter.key, "rate:")) + .limit(1), + ); expect(counter?.value).toBe(3); }); it("stops writing once the window is spent", async () => { - await db.trackingCounter.deleteMany({ where: {} }); + await db.delete(trackingCounter); for (const key of spendableWindows()) { await counters.take(key, EVENTS_PER_MINUTE, EVENTS_PER_MINUTE); @@ -179,21 +199,27 @@ describe("the rate limit", () => { await accept([view(parent)]); - expect(await db.trackedEvent.count({ where: { host: parent } })).toBe(0); + expect(await db.$count(trackedEvent, eq(trackedEvent.host, parent))).toBe( + 0, + ); }); it("does not spend the window on a batch it refuses", async () => { - await db.trackingCounter.deleteMany({ where: {} }); + await db.delete(trackingCounter); for (const key of spendableWindows()) { await counters.take(key, EVENTS_PER_MINUTE, EVENTS_PER_MINUTE - 1); } await accept([view(parent), view(parent)]); - expect(await db.trackedEvent.count({ where: { host: parent } })).toBe(0); + expect(await db.$count(trackedEvent, eq(trackedEvent.host, parent))).toBe( + 0, + ); await accept([view(parent)]); - expect(await db.trackedEvent.count({ where: { host: parent } })).toBe(1); + expect(await db.$count(trackedEvent, eq(trackedEvent.host, parent))).toBe( + 1, + ); }); }); @@ -210,10 +236,13 @@ describe("what a stored event keeps", () => { }, ]); - const row = await db.trackedEvent.findFirst({ - where: { host: parent }, - select: { path: true, referrer: true }, - }); + const row = await first( + db + .select({ path: trackedEvent.path, referrer: trackedEvent.referrer }) + .from(trackedEvent) + .where(eq(trackedEvent.host, parent)) + .limit(1), + ); expect(row?.path).toBe("/pricing"); expect(row?.referrer).toBe("https://elsewhere.test/reset"); @@ -232,10 +261,16 @@ describe("what a stored event keeps", () => { }, ]); - const row = await db.formSubmission.findFirst({ - where: { host: parent }, - select: { firstTouch: true, lastTouch: true }, - }); + const row = await first( + db + .select({ + firstTouch: formSubmission.firstTouch, + lastTouch: formSubmission.lastTouch, + }) + .from(formSubmission) + .where(eq(formSubmission.host, parent)) + .limit(1), + ); expect(JSON.stringify(row)).not.toContain("token=secret"); }); @@ -256,15 +291,17 @@ describe("a batch delivered twice", () => { await accept([submission], id); expect(filed).toHaveLength(1); - await db.formSubmission.updateMany({ - where: { host: parent }, - data: { filedAt: null, contactId: null, skipReason: null }, - }); + await db + .update(formSubmission) + .set({ filedAt: null, contactId: null, skipReason: null }) + .where(eq(formSubmission.host, parent)); await accept([submission], id); expect(filed).toHaveLength(2); - expect(await db.formSubmission.count({ where: { host: parent } })).toBe(1); + expect( + await db.$count(formSubmission, eq(formSubmission.host, parent)), + ).toBe(1); }); it("files once when both deliveries arrive together", async () => { @@ -280,7 +317,9 @@ describe("a batch delivered twice", () => { const id = visitorId(); await Promise.all([accept([submission], id), accept([submission], id)]); - expect(await db.formSubmission.count({ where: { host: parent } })).toBe(1); + expect( + await db.$count(formSubmission, eq(formSubmission.host, parent)), + ).toBe(1); expect(filed).toHaveLength(1); }); @@ -296,10 +335,10 @@ describe("a batch delivered twice", () => { const id = visitorId(); await accept([submission], id); - await db.formSubmission.updateMany({ - where: { host: parent }, - data: { filedAt: new Date() }, - }); + await db + .update(formSubmission) + .set({ filedAt: new Date() }) + .where(eq(formSubmission.host, parent)); await accept([submission], id); @@ -317,7 +356,9 @@ describe("a batch that looks scripted", () => { { type: "page_view", host: parent, path: "/c", at }, ]); - expect(await db.trackedEvent.count({ where: { host: parent } })).toBe(0); + expect(await db.$count(trackedEvent, eq(trackedEvent.host, parent))).toBe( + 0, + ); }); it("keeps a batch whose events carry no timestamp at all", async () => { @@ -327,7 +368,9 @@ describe("a batch that looks scripted", () => { { type: "page_view", host: parent, path: "/c" }, ]); - expect(await db.trackedEvent.count({ where: { host: parent } })).toBe(3); + expect(await db.$count(trackedEvent, eq(trackedEvent.host, parent))).toBe( + 3, + ); }); }); diff --git a/apps/app/app/(app)/[slug]/settings/research-key.tsx b/apps/app/app/(app)/[slug]/settings/research-key.tsx index 3c5dab1a4..1d55279cf 100644 --- a/apps/app/app/(app)/[slug]/settings/research-key.tsx +++ b/apps/app/app/(app)/[slug]/settings/research-key.tsx @@ -1,6 +1,6 @@ "use client"; -import { CONTEXT_DEV_SIGNUP_URL } from "@crm/db/settings"; +import { CONTEXT_DEV_SIGNUP_URL } from "@crm/db/settings-shape"; import { Button } from "@crm/ui/components/button"; import { Card, diff --git a/apps/app/app/(landing)/onboarding/onboarding-form.tsx b/apps/app/app/(landing)/onboarding/onboarding-form.tsx index 7f813dbb5..739586f2d 100644 --- a/apps/app/app/(landing)/onboarding/onboarding-form.tsx +++ b/apps/app/app/(landing)/onboarding/onboarding-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { workspaceSlug } from "@crm/db/workspace"; +import { workspaceSlug } from "@crm/db/workspace-shape"; import { Button } from "@crm/ui/components/button"; import { Field, diff --git a/apps/app/app/(landing)/onboarding/research/research-form.tsx b/apps/app/app/(landing)/onboarding/research/research-form.tsx index 30887d2eb..39d4330db 100644 --- a/apps/app/app/(landing)/onboarding/research/research-form.tsx +++ b/apps/app/app/(landing)/onboarding/research/research-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { CONTEXT_DEV_SIGNUP_URL } from "@crm/db/settings"; +import { CONTEXT_DEV_SIGNUP_URL } from "@crm/db/settings-shape"; import { Button } from "@crm/ui/components/button"; import { Field, diff --git a/apps/app/app/eve/v1/[...path]/route.ts b/apps/app/app/eve/v1/[...path]/route.ts index 7c8283cb7..e45b39815 100644 --- a/apps/app/app/eve/v1/[...path]/route.ts +++ b/apps/app/app/eve/v1/[...path]/route.ts @@ -1,4 +1,4 @@ -import { db } from "@crm/db"; +import { agentConversation, db, eq } from "@crm/db"; import { connection } from "next/server"; import { z } from "zod"; import { @@ -51,17 +51,18 @@ async function handler(request: Request): Promise { if (!requested) return failure("Name one CRM record.", 400); record = requested; } else { - const conversation = await db.agentConversation.findUnique({ - where: { sessionId: route.sessionId }, - select: { - id: true, - kind: true, - userId: true, - contactId: true, - companyId: true, - dealId: true, - }, - }); + const [conversation] = await db + .select({ + id: agentConversation.id, + kind: agentConversation.kind, + userId: agentConversation.userId, + contactId: agentConversation.contactId, + companyId: agentConversation.companyId, + dealId: agentConversation.dealId, + }) + .from(agentConversation) + .where(eq(agentConversation.sessionId, route.sessionId)) + .limit(1); const builderId = request.headers.get("x-crm-builder-conversation"); if ( !conversation || @@ -168,14 +169,12 @@ async function handler(request: Request): Promise { } response = { ...response, sessionId: createdSessionId }; try { - await db.agentConversation.create({ - data: { - kind: "RECORD", - sessionId: createdSessionId, - userId: session.user.id, - title: eveProxyMessageTitle(body), - ...record, - }, + await db.insert(agentConversation).values({ + kind: "RECORD", + sessionId: createdSessionId, + userId: session.user.id, + title: eveProxyMessageTitle(body), + ...record, }); } catch { await cancelSession(createdSessionId, authorization); diff --git a/apps/app/lib/session.ts b/apps/app/lib/session.ts index d731ff383..40458e90c 100644 --- a/apps/app/lib/session.ts +++ b/apps/app/lib/session.ts @@ -1,6 +1,6 @@ import { auth, type Session } from "@crm/auth/auth"; import { needsMailboxGrant } from "@crm/auth/scopes"; -import { db } from "@crm/db"; +import { account, db, eq } from "@crm/db"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; import { cache } from "react"; @@ -21,10 +21,10 @@ export async function requireSession(): Promise { } export const signInAccounts = cache(async (userId: string) => - db.account.findMany({ - where: { userId }, - select: { providerId: true, scope: true }, - }), + db + .select({ providerId: account.providerId, scope: account.scope }) + .from(account) + .where(eq(account.userId, userId)), ); export async function requireMailboxAccess(): Promise { diff --git a/apps/app/next.config.ts b/apps/app/next.config.ts index 98b199a7b..8cb058717 100644 --- a/apps/app/next.config.ts +++ b/apps/app/next.config.ts @@ -27,7 +27,7 @@ const nextConfig: NextConfig = { transpilePackages: ["@crm/auth", "@crm/db", "@crm/telemetry", "@crm/ui"], - serverExternalPackages: ["@prisma/client", "@prisma/adapter-pg", "pg"], + serverExternalPackages: ["pg"], images: { remotePatterns: [ diff --git a/bun.lock b/bun.lock index 81579ea8c..7399489be 100644 --- a/bun.lock +++ b/bun.lock @@ -154,15 +154,18 @@ "version": "0.0.0", "dependencies": { "@crm/env": "workspace:*", - "@prisma/adapter-pg": "^7.10.1", - "@prisma/client": "^7.10.1", + "@paralleldrive/cuid2": "^3.3.0", "@vercel/blob": "^2.6.1", + "decimal.js": "^10.6.0", + "drizzle-orm": "^0.45.4", + "pg": "^8.22.0", }, "devDependencies": { "@crm/typescript-config": "workspace:*", + "@types/bun": "^1.4.4", "@types/node": "^24.10.1", - "pg": "^8.22.0", - "prisma": "^7.10.1", + "@types/pg": "^8.23.1", + "drizzle-kit": "^0.31.11", "typescript": "5.9.2", }, }, @@ -462,11 +465,7 @@ "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], - "@electric-sql/pglite": ["@electric-sql/pglite@0.4.3", "", {}, "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ=="], - - "@electric-sql/pglite-socket": ["@electric-sql/pglite-socket@0.1.3", "", { "peerDependencies": { "@electric-sql/pglite": "0.4.3" }, "bin": { "pglite-server": "dist/scripts/server.js" } }, "sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ=="], - - "@electric-sql/pglite-tools": ["@electric-sql/pglite-tools@0.3.3", "", { "peerDependencies": { "@electric-sql/pglite": "0.4.3" } }, "sha512-AlzLJTRJ8+UFgK8CmxIpyIpJ0+YaFw02IiOSdYrqxwPXdSyeIShz8aa9Tq+tYFXdPwcaMp/Fc80mQZ1dkOQ/wg=="], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], "@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="], @@ -474,6 +473,62 @@ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], + + "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], "@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="], @@ -778,7 +833,7 @@ "@oxlint/plugins": ["@oxlint/plugins@1.89.0", "", {}, "sha512-TJBBNl4KIxbxrSk7gsqlz6iM1kxidfyTizEzfWqXvijF2Ou8/Da58GvjemBVmCmaUamwRij+JwsVc0kpBFuqfg=="], - "@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="], + "@paralleldrive/cuid2": ["@paralleldrive/cuid2@3.3.0", "", { "dependencies": { "@noble/hashes": "^2.0.1", "bignumber.js": "^9.3.1", "error-causes": "^3.0.2" }, "bin": { "cuid2": "bin/cuid2.js" } }, "sha512-OqiFvSOF0dBSesELYY2CAMa4YINvlLpvKOz/rv6NeZEqiyttlHgv98Juwv4Ch+GrEV7IZ8jfI2VcEoYUjXXCjw=="], "@pierre/diffs": ["@pierre/diffs@1.3.4", "", { "dependencies": { "@pierre/theme": "2.0.0", "@pierre/theming": "1.0.1", "@shikijs/transformers": "^3.0.0 || ^4.0.0", "diff": "9.0.0", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-6Dt48jQIL+H54QyNB943Oqn5p4uRw7NpiXLSEapnaYgYvTmSHNPAUCDfg6AcRC+W02z0IYJnJJvcs3lsNp7gCw=="], @@ -796,34 +851,6 @@ "@posthog/types": ["@posthog/types@1.416.0", "", {}, "sha512-0/9+02s/0uDyrH4G/qUg1yyl6gXUg8Uuxw/PlDad3KLzo9FFS9zS/kAQaEshx/saVGupj1aAy/4EUBxWisBogw=="], - "@prisma/adapter-pg": ["@prisma/adapter-pg@7.10.1", "", { "dependencies": { "@prisma/driver-adapter-utils": "7.10.1", "@types/pg": "^8.16.0", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, "sha512-EP9ruPYHjKP80WZlb5qVaEXKgZjJieBFjAxcBtQnDAHD6+16UN4keVmBh6L/RUpuDQT2FQRbvKNgq5d4Ix+IkQ=="], - - "@prisma/client": ["@prisma/client@7.10.1", "", { "dependencies": { "@prisma/client-runtime-utils": "7.10.1" }, "peerDependencies": { "prisma": "*", "typescript": ">=5.4.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-5tgAbFGh//zVOC6WPNVgziOErJHYkUE+wmqCwbIyq6IrTXUYtv9y2iBFGh/Owo99Mx0+cr0j1FOrxkYBMuyhaQ=="], - - "@prisma/client-runtime-utils": ["@prisma/client-runtime-utils@7.10.1", "", {}, "sha512-AzoATVIgmO3lcZfM443+9SI7+cyLMW2+Psw4op8WQHpHvHCX//CKxv8fwhiL0yGWF8DjdQrFewxrZo5McBdB8g=="], - - "@prisma/config": ["@prisma/config@7.10.1", "", { "dependencies": { "c12": "3.3.4", "deepmerge-ts": "8.0.2", "effect": "3.20.0", "empathic": "2.0.0" } }, "sha512-+0swF5ELUa3P/9cIR26+gpYjjCbGE0qvEBZhV4zMDkYsrtHjZ884MQbOnWFWXj600N0sSJNnHbtBqe12yDngWg=="], - - "@prisma/debug": ["@prisma/debug@7.10.1", "", {}, "sha512-eFCAkGZ13D/qPg48JBlG5IWi9cSBBOC7oG3abDPcAkYioDWlIjh0YJJLK2J7qo5S+xWWD8I6egAN/8KbCaiMCA=="], - - "@prisma/dev": ["@prisma/dev@0.24.17", "", { "dependencies": { "@electric-sql/pglite": "0.4.3", "@electric-sql/pglite-socket": "0.1.3", "@electric-sql/pglite-tools": "0.3.3", "@prisma/get-platform": "7.2.0", "@prisma/query-plan-executor": "7.2.0", "@prisma/streams-local": "0.1.11", "find-my-way": "9.7.0", "foreground-child": "3.3.1", "get-port-please": "3.2.0", "pathe": "2.0.3", "proper-lockfile": "4.1.2", "remeda": "2.33.4", "std-env": "3.10.0", "valibot": "1.4.2", "zeptomatch": "2.1.0" } }, "sha512-UvdZzmpFwknnfreh6Jije84ekkYGPYEJhXG1tFzCsCfQyzJifrOo38eZc0qajzvaC6OLUOrN9ML5XfCnEZL9DA=="], - - "@prisma/driver-adapter-utils": ["@prisma/driver-adapter-utils@7.10.1", "", { "dependencies": { "@prisma/debug": "7.10.1" } }, "sha512-DJ4c6xvVnm3s9ZUyql9nCUicADSqyvH+CjXRCM9L6m5zJxCNmebCElG6X4irICq3rosBwlX+S19Dxq/4IrZk3A=="], - - "@prisma/engines": ["@prisma/engines@7.10.1", "", { "dependencies": { "@prisma/debug": "7.10.1", "@prisma/engines-version": "8.1.0-1.d1b3f377ca835d73fd50954a5e22128b9ebdd6b8", "@prisma/fetch-engine": "7.10.1", "@prisma/get-platform": "7.10.1" } }, "sha512-R6/ghv1Bu36FhCdQLFof1pNtyoWoeDzJLYeTKjgqb4eGtOAJx7ZqJtcm4pzkahLfZ9rYz8AkmsMDwkyiYbXPCQ=="], - - "@prisma/engines-version": ["@prisma/engines-version@8.1.0-1.d1b3f377ca835d73fd50954a5e22128b9ebdd6b8", "", {}, "sha512-XjXmwjN2NE340RpXoXv8ZF8aNAvr0FE+7gNfkKeW6vyjXs7F8uxvEbWQoVrhioXDQoIsnNmv7r+wxvzfdEZaWA=="], - - "@prisma/fetch-engine": ["@prisma/fetch-engine@7.10.1", "", { "dependencies": { "@prisma/debug": "7.10.1", "@prisma/engines-version": "8.1.0-1.d1b3f377ca835d73fd50954a5e22128b9ebdd6b8", "@prisma/get-platform": "7.10.1" } }, "sha512-qZezeaExLWG2ptbHOrMxdiWluKHgqEclTdJbvY6pk6+Wg7EiVkeq5U3cXz9ti8nd0bngNvsDgledLHVRg5LF1w=="], - - "@prisma/get-platform": ["@prisma/get-platform@7.2.0", "", { "dependencies": { "@prisma/debug": "7.2.0" } }, "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA=="], - - "@prisma/query-plan-executor": ["@prisma/query-plan-executor@7.2.0", "", {}, "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ=="], - - "@prisma/streams-local": ["@prisma/streams-local@0.1.11", "", { "dependencies": { "ajv": "^8.12.0", "better-result": "^2.7.0", "env-paths": "^3.0.0", "proper-lockfile": "^4.1.2" } }, "sha512-0TcebL559MByKqTJ+SsrFIEg228iw8UCVRFckzgfRSiJqczhs+MuAgWOF9lnOIV/IVqvu+KMnFTH0eDeTQMpUg=="], - - "@prisma/studio-core": ["@prisma/studio-core@0.33.0", "", { "dependencies": { "@radix-ui/react-toggle": "1.1.10", "@visx/curve": "4.0.1-alpha.0", "@visx/event": "4.0.1-alpha.0", "@visx/grid": "4.0.1-alpha.0", "@visx/group": "4.0.1-alpha.0", "@visx/responsive": "4.0.1-alpha.0", "@visx/scale": "4.0.1-alpha.0", "@visx/shape": "4.0.1-alpha.0", "d3-array": "3.2.4", "d3-shape": "3.2.0", "elkjs": "0.11.1" }, "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-V2fX/nKEymNTrHXwfP26PGjoLStO35Ogu+ex7CFJbLrMYEcZxxZpiSNOs7px23Hk5mzLWvM5RsqG6Ka+rha+wg=="], - "@radix-ui/number": ["@radix-ui/number@1.1.3", "", {}, "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="], @@ -1092,7 +1119,7 @@ "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], - "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/bun": ["@types/bun@1.4.4", "", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -1104,14 +1131,8 @@ "@types/d3-color": ["@types/d3-color@3.1.0", "", {}, "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA=="], - "@types/d3-delaunay": ["@types/d3-delaunay@6.0.1", "", {}, "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ=="], - "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], - "@types/d3-format": ["@types/d3-format@3.0.1", "", {}, "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg=="], - - "@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="], - "@types/d3-interpolate": ["@types/d3-interpolate@3.0.1", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw=="], "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], @@ -1122,8 +1143,6 @@ "@types/d3-time": ["@types/d3-time@3.0.0", "", {}, "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg=="], - "@types/d3-time-format": ["@types/d3-time-format@2.1.0", "", {}, "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA=="], - "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], @@ -1138,14 +1157,10 @@ "@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.2", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-d3KvEXBSo/lOAMc2u6fkyDHBvetBHeqD7wm/AcXfLpSOQwlmG9D/aQ0SFswVjv05p7ullQS7Mjohj6/VdbZuTg=="], - "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], - "@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="], - "@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="], - "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], "@types/methods": ["@types/methods@1.1.4", "", {}, "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ=="], @@ -1154,7 +1169,7 @@ "@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], - "@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="], + "@types/pg": ["@types/pg@8.23.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A=="], "@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="], @@ -1194,24 +1209,6 @@ "@vercel/oidc": ["@vercel/oidc@3.8.1", "", { "dependencies": { "@vercel/cli-config": "0.2.1", "@vercel/cli-exec": "1.0.0", "jose": "^5.9.6" } }, "sha512-ufdalm2MWOYksyj8KVpWjoOFPJO6zoYpuyvIggIQ2bB0CFCjTCiTkGXHqAKwG77GVRjOaN3/8S5ITlZpXWmqOw=="], - "@visx/curve": ["@visx/curve@4.0.1-alpha.0", "", { "dependencies": { "@visx/vendor": "4.0.0-alpha.0" } }, "sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg=="], - - "@visx/event": ["@visx/event@4.0.1-alpha.0", "", { "dependencies": { "@types/react": "*", "@visx/point": "4.0.1-alpha.0" } }, "sha512-EQqCMSv/s8NbFjo+hz3FKsvvYfP+2QslsFJ/24/O5l/W+7UC6J6aAvO0ujVwrTwdYbuQ+vhxKi1xdPdKR/qj1g=="], - - "@visx/grid": ["@visx/grid@4.0.1-alpha.0", "", { "dependencies": { "@types/react": "*", "@visx/curve": "4.0.1-alpha.0", "@visx/group": "4.0.1-alpha.0", "@visx/point": "4.0.1-alpha.0", "@visx/scale": "4.0.1-alpha.0", "@visx/shape": "4.0.1-alpha.0", "classnames": "^2.3.1" }, "peerDependencies": { "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-rycutGmTHO+znNdPumheWMglm7YfpffvRwUkVy5zy4WoORIuKTMkDxwnOzHG2xMxU3EE/YCd37xFV5AxA30yeg=="], - - "@visx/group": ["@visx/group@4.0.1-alpha.0", "", { "dependencies": { "@types/react": "*", "classnames": "^2.3.1" }, "peerDependencies": { "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-V19l7iQ7jccBv8kao/EByuI6o4xtxzzLV9nqVI1hRvmdzTVsuLpqlwzYCZUXJaTVvUWf8s4D2SQFjGkj/Nw+0w=="], - - "@visx/point": ["@visx/point@4.0.1-alpha.0", "", {}, "sha512-ijTfr/Nx09f03vIj9nyTr3z4Xth4Y75427UaogJh6dnIRLMEFHQOwNu791sbfiNj0a+ZXuaE32h0vKrFe4/8Qg=="], - - "@visx/responsive": ["@visx/responsive@4.0.1-alpha.0", "", { "dependencies": { "@types/lodash": "^4.17.13", "@types/react": "*", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-o+1zGywQZY0+yOx3Iw87wc4bbPJRr/HnIukTwfOz4UVyj9pB1OQNVHB7OORO1+LBHJceWpB31co/ZV9KHncKrA=="], - - "@visx/scale": ["@visx/scale@4.0.1-alpha.0", "", { "dependencies": { "@visx/vendor": "4.0.0-alpha.0" } }, "sha512-nzjeE87vFSAXGWFiiNfBpNLAf0Q8Qmf6syvKLjqNi4kGZkdhbUll3E/59YsgWXmjM8+llPLWzGsP+JPvo5eq1A=="], - - "@visx/shape": ["@visx/shape@4.0.1-alpha.0", "", { "dependencies": { "@types/lodash": "^4.17.13", "@types/react": "*", "@visx/curve": "4.0.1-alpha.0", "@visx/group": "4.0.1-alpha.0", "@visx/scale": "4.0.1-alpha.0", "@visx/vendor": "4.0.0-alpha.0", "classnames": "^2.3.1", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-62QeiVNmPlterQGwhkEDcbq7M0MqY0lBsK5QKXtM9ZoPZWkuGV3aykA3+Xu20B2FAvyJq4LqJzBc7Sxr+EAdbA=="], - - "@visx/vendor": ["@visx/vendor@4.0.0-alpha.0", "", { "dependencies": { "@types/d3-array": "3.0.3", "@types/d3-color": "3.1.0", "@types/d3-delaunay": "6.0.1", "@types/d3-format": "3.0.1", "@types/d3-geo": "3.1.0", "@types/d3-interpolate": "3.0.1", "@types/d3-path": "3.1.1", "@types/d3-scale": "4.0.2", "@types/d3-shape": "3.1.7", "@types/d3-time": "3.0.0", "@types/d3-time-format": "2.1.0", "d3-array": "3.2.1", "d3-color": "3.1.0", "d3-delaunay": "6.0.2", "d3-format": "3.1.0", "d3-geo": "3.1.0", "d3-interpolate": "3.0.1", "d3-path": "3.1.0", "d3-scale": "4.0.2", "d3-shape": "3.2.0", "d3-time": "3.1.0", "d3-time-format": "4.1.0", "internmap": "2.0.3" } }, "sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ=="], - "@vitest/browser": ["@vitest/browser@5.0.5", "", { "dependencies": { "@blazediff/core": "1.10.0", "@vitest/mocker": "5.0.5", "@vitest/ui": "5.0.5", "@vitest/utils": "5.0.5", "magic-string": "^1.4.3", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.2.0", "ws": "^8.22.0" }, "peerDependencies": { "vitest": "5.0.5" } }, "sha512-IDMoZX/iZ8rkilw7weaSgN8tJ7iZ9hUv0wYoKXyiyjWjWFwLSWAY707DcVTgxTYOudWC1E3HA9LYn7kVa9a7uQ=="], "@vitest/browser-preview": ["@vitest/browser-preview@5.0.5", "", { "dependencies": { "@testing-library/dom": "^10.4.2", "@testing-library/user-event": "^14.6.7", "@vitest/browser": "5.0.5" }, "peerDependencies": { "vitest": "5.0.5" } }, "sha512-ViNg4UlomGSJHtnnr7Txm+55P7/aAMMoS+1WYnWjp5w5tzf7YEUAICcs2sb1uMovEnC8el/8zUfkWZ8+7fPCKA=="], @@ -1316,8 +1313,6 @@ "auth": ["auth@1.6.33", "", { "dependencies": { "@babel/core": "^7.29.7", "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.28.5", "@better-auth/core": "1.6.33", "@better-auth/telemetry": "1.6.33", "@better-auth/utils": "0.4.2", "@clack/prompts": "^1.6.0", "@mrleebo/prisma-ast": "^0.13.1", "better-auth": "1.6.33", "c12": "^3.3.3", "chalk": "^5.6.2", "commander": "^12.1.0", "dotenv": "^17.3.1", "get-tsconfig": "^4.13.6", "open": "^10.2.0", "prettier": "^3.8.1", "prompts": "^2.4.2", "semver": "^7.7.4", "yocto-spinner": "^0.2.3", "zod": "^4.3.6" }, "bin": { "auth": "./dist/index.mjs", "better-auth": "./dist/index.mjs" } }, "sha512-eM3vAovK2VvD8alYa5wCpLLjBrbfUEQnRjh9TcVAKeK8FNhvqJ04gDOkVrGSuIfMzK7XBoKfQDYT/x9jRWKTrg=="], - "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], - "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.11.8", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-zAgkquC2WYF0PIc6XbNYkA2uuxxFavzgmX61R+dHDUa558V8Ejf8ozTZFR6QzM24RWu4kBcRkhJ5kpz77j9fnQ=="], @@ -1326,15 +1321,17 @@ "better-call": ["better-call@1.4.0", "", { "dependencies": { "@better-auth/utils": "^0.5.0", "@better-fetch/fetch": "^1.3.1", "rou3": "^0.9.1", "set-cookie-parser": "^3.1.2" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA=="], - "better-result": ["better-result@2.10.0", "", {}, "sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw=="], + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], "browserslist": ["browserslist@4.28.7", "", { "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", "electron-to-chromium": "^1.5.393", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], - "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bun-types": ["bun-types@1.4.4", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -1376,8 +1373,6 @@ "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], - "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], - "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], @@ -1434,14 +1429,10 @@ "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], - "d3-delaunay": ["d3-delaunay@6.0.2", "", { "dependencies": { "delaunator": "5" } }, "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ=="], - "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], "d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="], - "d3-geo": ["d3-geo@3.1.0", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA=="], - "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], @@ -1462,12 +1453,12 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], - "deepmerge-ts": ["deepmerge-ts@8.0.2", "", {}, "sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw=="], - "default-browser": ["default-browser@5.5.1", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw=="], "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], @@ -1476,8 +1467,6 @@ "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], - "delaunator": ["delaunator@5.1.0", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="], - "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], @@ -1504,30 +1493,28 @@ "dotenv-expand": ["dotenv-expand@12.0.3", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA=="], + "drizzle-kit": ["drizzle-kit@0.31.11", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-YCYqxTLIB2OCqf6w9/Vef13baBVbwQIPcbT4Y56AfO6B9ajZhDhD8Jkveg/hJvT/iuMloKD/IYYkyMMNhr7Kqg=="], + + "drizzle-orm": ["drizzle-orm@0.45.4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@netlify/db": ">=0.4.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@netlify/db", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-NiM1YZpJ2UxyeE67DpDDZ+Tsw4YLX3trSlkNT7Tm9eKoUNb1LvaAaQzXgYQsWs+AQ9TUmmwMmBOCXuqqxUWKKQ=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "effect": ["effect@3.20.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw=="], - "electron-to-chromium": ["electron-to-chromium@1.5.399", "", {}, "sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA=="], - "elkjs": ["elkjs@0.11.1", "", {}, "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg=="], - "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], - "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], "enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="], "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], - "env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], - "env-runner": ["env-runner@0.2.3", "", { "dependencies": { "crossws": "^0.4.12", "exsolve": "^1.1.1", "httpxy": "^0.5.5", "srvx": "^1.0.5" }, "bin": { "env-runner": "./dist/cli.mjs" } }, "sha512-S8EFBKyznRwPVwayK187cYjvnpgC92xy+q0BIF++V965pfjMVVRbQ/HoTYGA/1EHmZ7E68k6Heq+4WnvVhOL5w=="], + "error-causes": ["error-causes@3.0.2", "", {}, "sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -1540,6 +1527,8 @@ "es-toolkit": ["es-toolkit@1.50.0", "", {}, "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w=="], + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], @@ -1572,14 +1561,8 @@ "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], - "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], - - "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], - "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - "fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="], - "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], @@ -1604,12 +1587,8 @@ "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], - "find-my-way": ["find-my-way@9.7.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ=="], - "flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="], - "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], - "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="], "formatly": ["formatly@0.11.4", "", { "dependencies": { "fd-package-json": "^2.0.0", "package-manager-detector": "^1.8.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-TeCC10lP8KCgoX+Fe5lryILIT0/Dy+nP0OSVnsuB5CwXoLuOdqxCYlPFF1inR8S/zTy1ec7TjhS2TeRKWzzImA=="], @@ -1626,8 +1605,6 @@ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], - "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], @@ -1638,8 +1615,6 @@ "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], - "get-port-please": ["get-port-please@3.2.0", "", {}, "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A=="], - "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], "get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], @@ -1652,10 +1627,6 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "grammex": ["grammex@3.1.13", "", {}, "sha512-LnPnhOBLEJEVKS8WFDVaA397L9Kq55Q9oSITJiVLHVdhAclfUkWzQv74KhvZHKL2Q09Pb1XdsrOsZ4LfTFFTEg=="], - - "graphmatch": ["graphmatch@1.1.1", "", {}, "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg=="], - "graphql": ["graphql@17.0.2", "", {}, "sha512-FRWbddMxfkjiB7z+aQDWIR+E34xo9I8c9mtK2RPv8PmMzKRvrdsreHL/Ui/TmwHJfhHChEtsFPyMHKI+xuarQQ=="], "h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="], @@ -1746,8 +1717,6 @@ "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], - "is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], - "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], "is-unsafe": ["is-unsafe@2.0.0", "", {}, "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA=="], @@ -1816,16 +1785,12 @@ "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], - "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], - "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], - "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], "lucide-react": ["lucide-react@1.28.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg=="], @@ -1956,10 +1921,6 @@ "multer": ["multer@2.4.0", "", { "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", "type-is": "^1.6.18" } }, "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg=="], - "mysql2": ["mysql2@3.24.4", "", { "dependencies": { "aws-ssl-profiles": "^1.1.2", "generate-function": "^2.3.1", "iconv-lite": "^0.7.3", "long": "^5.3.2", "lru.min": "^1.1.4", "named-placeholders": "^1.1.6", "sql-escaper": "^1.5.1" }, "peerDependencies": { "@types/node": ">= 8" } }, "sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA=="], - - "named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="], - "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], "nanostores": ["nanostores@1.4.2", "", {}, "sha512-Wxv8Roefr2nqtiRG0bnaFlpYqpIVtOEeJZHaH+4nGgOK1/7n6OHOuHCb/bhqrNQgZM8fyd0s1PqhdrJc9Ib44g=="], @@ -2068,9 +2029,7 @@ "postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], - "postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="], - - "postgres-array": ["postgres-array@3.0.4", "", {}, "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ=="], + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], @@ -2090,20 +2049,14 @@ "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], - "prisma": ["prisma@7.10.1", "", { "dependencies": { "@prisma/config": "7.10.1", "@prisma/dev": "0.24.17", "@prisma/engines": "7.10.1", "@prisma/studio-core": "0.33.0", "mysql2": "3.24.4", "postgres": "3.4.7" }, "peerDependencies": { "better-sqlite3": ">=9.0.0", "typescript": ">=5.4.0" }, "optionalPeers": ["better-sqlite3", "typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-GoVeErHGGtsSjsflQ1r2BA3RKTXtxNraQJz8OLkLhoLr7OgCscdx4qw4WMM4HnERYc+a1cq2VnMNbep+vOjNcQ=="], - "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], - "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], - "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], "proxy-addr": ["proxy-addr@2.0.8", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ=="], - "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], - "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="], "query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="], @@ -2164,8 +2117,6 @@ "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], - "remeda": ["remeda@2.33.4", "", {}, "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ=="], - "remend": ["remend@1.4.0", "", {}, "sha512-mHiYhhwphs1IgIZmkToKLwJn0U99IjPXuutkA2acrNcG0vlEq2G4TGnd2eT0saIHwh2KjaCpvleHcsd5SAz96A=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -2174,12 +2125,8 @@ "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], - "ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="], - "retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], - "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], - "rolldown": ["rolldown@1.2.14", "", { "dependencies": { "@oxc-project/types": "=0.154.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.14", "@rolldown/binding-android-arm64": "1.2.14", "@rolldown/binding-darwin-arm64": "1.2.14", "@rolldown/binding-darwin-x64": "1.2.14", "@rolldown/binding-freebsd-x64": "1.2.14", "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", "@rolldown/binding-linux-arm64-gnu": "1.2.14", "@rolldown/binding-linux-arm64-musl": "1.2.14", "@rolldown/binding-linux-ppc64-gnu": "1.2.14", "@rolldown/binding-linux-s390x-gnu": "1.2.14", "@rolldown/binding-linux-x64-gnu": "1.2.14", "@rolldown/binding-linux-x64-musl": "1.2.14", "@rolldown/binding-openharmony-arm64": "1.2.14", "@rolldown/binding-win32-arm64-msvc": "1.2.14", "@rolldown/binding-win32-x64-msvc": "1.2.14" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw=="], "rou3": ["rou3@0.9.2", "", {}, "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ=="], @@ -2190,8 +2137,6 @@ "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], - "safe-regex2": ["safe-regex2@5.1.1", "", { "dependencies": { "ret": "~0.5.0" }, "bin": { "safe-regex2": "bin/safe-regex2.js" } }, "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA=="], - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "samlify": ["samlify@2.13.1", "", { "dependencies": { "@authenio/xml-encryption": "^2.0.2", "@xmldom/xmldom": "^0.8.11", "node-rsa": "^1.1.1", "xml": "^1.0.1", "xml-crypto": "^6.1.2", "xml-escape": "^1.1.0", "xpath": "^0.0.34" } }, "sha512-vdYr/zohDGBbfWNU4miEzc1jmWOtkLySPViapC6nfGkv9KxzLq4UlGkKyryzwLw4jVlZk88Rw93HaCRVpe+t+g=="], @@ -2228,7 +2173,7 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], - "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "simple-icons": ["simple-icons@16.35.0", "", {}, "sha512-TCfl/2+GTrx0nCo4pEWGvxhOb8c6NsqOU1OpwjtF4lwJAthB8IYVD0fuIMjLMg6JFuG1YBO0t1iETQqJLqkoJA=="], @@ -2240,14 +2185,16 @@ "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], - "sql-escaper": ["sql-escaper@1.5.2", "", {}, "sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg=="], - "srvx": ["srvx@1.0.6", "", { "bin": { "srvx": "./bin/srvx.mjs" } }, "sha512-QeTKgAhk8OSsfeq60bl3wuVs9PmBftzmk7owBInA/F1YQne+TRw0UcPymNpURldAmx8hfmM2PNNGH/zgo3GpLA=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], @@ -2326,6 +2273,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tsx": ["tsx@4.23.15", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw=="], + "turbo": ["turbo@2.11.9", "", { "optionalDependencies": { "@turbo/darwin-64": "2.11.9", "@turbo/darwin-arm64": "2.11.9", "@turbo/linux-64": "2.11.9", "@turbo/linux-arm64": "2.11.9", "@turbo/windows-64": "2.11.9", "@turbo/windows-arm64": "2.11.9" }, "bin": { "turbo": "bin/turbo" } }, "sha512-k2ev5dv9OgP2pJwaPuydkZdid+haX2U5TDGcPtpAz0gb0nbRR3eXY/cpfrlvHih52YRTxWIcRVKc0ri3C2Scrg=="], "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], @@ -2374,8 +2323,6 @@ "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], - "valibot": ["valibot@1.4.2", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg=="], - "validator": ["validator@13.15.35", "", {}, "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], @@ -2452,8 +2399,6 @@ "yuku-parser": ["yuku-parser@0.19.0", "", { "dependencies": { "@yuku-toolchain/types": "^0.19.0", "yuku-core": "0.19.0" } }, "sha512-XrBh9ZiQK5mJVRxZBAvD6LwRyAYrK3MFtH3+onyuxFL5hFD44EC3QOmIC1FjTj6H2MMwq46/NAI+NZxLZPZgxg=="], - "zeptomatch": ["zeptomatch@2.1.0", "", { "dependencies": { "grammex": "^3.1.11", "graphmatch": "^1.1.0" } }, "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA=="], - "zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], "zod-openapi": ["zod-openapi@5.4.6", "", { "peerDependencies": { "zod": "^3.25.74 || ^4.0.0" } }, "sha512-P2jsOOBAq/6hCwUsMCjUATZ8szkMsV5VAwZENfyxp2Hc/XPJQpVwAgevWZc65xZauCwWB9LAn7zYeiCJFAEL+A=="], @@ -2474,8 +2419,12 @@ "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@crm/mcp-integrations/@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@crm/ui/react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + "@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], "@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], @@ -2484,8 +2433,6 @@ "@nestjs/config/dotenv": ["dotenv@17.4.1", "", {}, "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw=="], - "@paralleldrive/cuid2/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], - "@pierre/trees/@pierre/theming": ["@pierre/theming@1.0.0", "", { "peerDependencies": { "@pierre/theme": "^1.1.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-WsdrnhKfjeyXGDikZmN9pkpeZ5S/cl6EE72feiSc0tlynT1tMYqXqouhuv/foK+PY9OEnebOAVRQn3+rAstR8g=="], "@posthog/browser-common/@posthog/core": ["@posthog/core@1.59.4", "", { "dependencies": { "@posthog/types": "^1.416.0" } }, "sha512-2e44KMrhboQyK7OrQFnWwNopa8ZLK6hQoni8Bxm258+lLERoRAUKOFC4jt+zp0RxVsYQAb9SqPMdH+jguVqdKQ=="], @@ -2494,16 +2441,6 @@ "@posthog/core/@posthog/types": ["@posthog/types@1.402.1", "", {}, "sha512-4PZ9wMYI8m8AqJuZ9YR1IAHGVtSnYbBVBgTevrKpzZbcSe/OUwhEV2Ks//rJh/L8eMTU8R5DrD4D/hlrOwaAiQ=="], - "@prisma/dev/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], - - "@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.10.1", "", { "dependencies": { "@prisma/debug": "7.10.1" } }, "sha512-iVnghHne5vcaobBjPVCMEjh5r/1UCj394EIGTUclasFRatpR/MQwcM2veLv5kgoFSyYP54nch/lNFCQRYcDxvg=="], - - "@prisma/fetch-engine/@prisma/get-platform": ["@prisma/get-platform@7.10.1", "", { "dependencies": { "@prisma/debug": "7.10.1" } }, "sha512-iVnghHne5vcaobBjPVCMEjh5r/1UCj394EIGTUclasFRatpR/MQwcM2veLv5kgoFSyYP54nch/lNFCQRYcDxvg=="], - - "@prisma/get-platform/@prisma/debug": ["@prisma/debug@7.2.0", "", {}, "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw=="], - - "@prisma/studio-core/@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="], - "@radix-ui/react-accordion/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], "@radix-ui/react-accordion/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], @@ -2664,14 +2601,16 @@ "@vercel/oidc/jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], - "@visx/vendor/d3-array": ["d3-array@3.2.1", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ=="], - "@vitest/ui/fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], "agent/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "api/@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "api/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "app/@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "app/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], "app/react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], @@ -2694,10 +2633,10 @@ "eve/undici": ["undici@8.10.2", "", {}, "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ=="], - "execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "formidable/@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="], + "h3/crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="], "multer/type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], @@ -2712,8 +2651,6 @@ "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - "pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], - "posthog-js/@posthog/core": ["@posthog/core@1.59.4", "", { "dependencies": { "@posthog/types": "^1.416.0" } }, "sha512-2e44KMrhboQyK7OrQFnWwNopa8ZLK6hQoni8Bxm258+lLERoRAUKOFC4jt+zp0RxVsYQAb9SqPMdH+jguVqdKQ=="], "posthog-js/preact": ["preact@10.29.8", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q=="], @@ -2722,10 +2659,6 @@ "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], - "proper-lockfile/retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], - - "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "radix-ui/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], "radix-ui/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], @@ -2734,6 +2667,8 @@ "tinyglobby/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + "tsx/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], + "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "vite/postcss": ["postcss@8.5.31", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg=="], @@ -2746,9 +2681,51 @@ "xml-crypto/xpath": ["xpath@0.0.33", "", {}, "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA=="], - "@prisma/studio-core/@radix-ui/react-toggle/@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + "@crm/mcp-integrations/@types/bun/bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="], - "@prisma/studio-core/@radix-ui/react-toggle/@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -2774,6 +2751,10 @@ "@tailwindcss/node/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + "api/@types/bun/bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "app/@types/bun/bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "app/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "co-body/raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], @@ -2784,19 +2765,71 @@ "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "formidable/@paralleldrive/cuid2/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "multer/type-is/media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], "multer/type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "nitro/h3/rou3": ["rou3@1.0.0", "", {}, "sha512-ySeaQkSrua+ut9g+65I7FHqOFBaY8tV0FmccXItoq0q21se32Q3u4YiW7/RqXSosWgYJaaKwRHXy3BST9PeCNQ=="], + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], + + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], + + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], + + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], + + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], + + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], + + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], + + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], + + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], + + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], + + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], + + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], + + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], + + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], + + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], + + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], + + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], + + "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], + + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], + + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], + + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], + + "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], + + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], + + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], + + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], + + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], + "vite/postcss/nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], "vitest/vite/postcss": ["postcss@8.5.31", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg=="], - "@prisma/studio-core/@radix-ui/react-toggle/@radix-ui/react-use-controllable-state/@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], - - "@prisma/studio-core/@radix-ui/react-toggle/@radix-ui/react-use-controllable-state/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + "app/@types/bun/bun-types/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], "co-body/type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], diff --git a/docs/agent.md b/docs/agent.md index e1fc98353..64ea06599 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -26,7 +26,7 @@ agent and the API both need it. `mirror()` copies bytes to Vercel Blob; the record points at our copy. Lives in **`@crm/db/blob`** — writers are `lib/brand-images.ts`, `lib/portrait.ts`, -`FaviconService`, `ImageMirrorService`, `prisma/seed.ts`. +`FaviconService`, `ImageMirrorService`, `scripts/seed.ts`. - **The key hashes the bytes** — idempotent, and a redesigned mark gets a new URL. - **`COMPANY_IMAGE_FIELDS` (`@crm/db/images`) is the one list of picture columns.** diff --git a/docs/api.md b/docs/api.md index 93f4df63f..70c9a8c39 100644 --- a/docs/api.md +++ b/docs/api.md @@ -14,8 +14,8 @@ via its own `middleware` option — it mounts before `MiddlewareConsumer`, so `/api/auth/*` never reaches ours. - `requestId` from `RequestLoggerMiddleware` via `AsyncLocalStorage`; - `UserContextInterceptor` adds `userId`. Prisma statements are opt-in - (`PRISMA_LOG_QUERIES`). + `UserContextInterceptor` adds `userId`. SQL statements are opt-in + (`DB_LOG_QUERIES`). ## Intelligence never lives in the API @@ -131,9 +131,9 @@ self-hoster's admin cannot redeploy. - **One router per module**, `*.router.ts` (the codegen glob), with `@Router({ alias })` and `@UseMiddlewares(AuthMiddleware)`. **No `AuthMiddleware` means public — there is no other guard.** -- **Routers are thin**: zod in, service call out; Prisma lives in `*.service.ts`. +- **Routers are thin**: zod in, service call out; Drizzle queries live in `*.service.ts`. - Services throw Nest's `HttpException` family; `DomainErrorMiddleware` maps them. -- **Filter, sort and paginate in Prisma.** List procedures take `listInput` and return +- **Filter, sort and paginate in SQL.** List procedures take `listInput` and return `{ rows, total, facetCounts }`. Never filter a whole table in the browser; never interpolate `sort` into a field name — use `resolveOrderBy`. - **`src/generated/server.ts` is generated *and committed*, and `build` must never diff --git a/docs/currency.md b/docs/currency.md index 5bb0a40c1..30da3c398 100644 --- a/docs/currency.md +++ b/docs/currency.md @@ -17,7 +17,7 @@ to dollars and printed `$2.0M`, silently.) - **Compose with `AND`, never spread** — it contains an `OR`, and so does the deals list's own `where`. - **Every writer of `baseAmount` writes `baseCurrency` in the same statement**: - `ConversionService` and `prisma/seed.ts`. + `ConversionService` and `scripts/seed.ts`. - **The rate is resolved once and frozen.** `create`/`update` call `ConversionService.dealFields` when `amount` *or* `currency` changes, reading the unchanged one back in the same call. Converting on read makes a closed quarter change diff --git a/docs/environment.md b/docs/environment.md index 0da0d4b17..74cd91405 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -89,7 +89,7 @@ list fails closed.** Parsed on demand. `packages/auth/src/workspace.ts`. `"true"` serves `app/(landing)` at `/`; anything else sends a signed-out visitor to `/sign-in`, because the page markets *this* product. -- **Only the literal `true`** (same shape as `PRISMA_LOG_QUERIES`). +- **Only the literal `true`** (same shape as `DB_LOG_QUERIES`). - **It decides one thing**: what a stranger at `/` sees. - **`isMarketing()` (`apps/app/lib/env.ts`) reads per request**, so a config change needs no rebuild. Declared in `apps/app/turbo.json` `passThroughEnv`. diff --git a/docs/setup.md b/docs/setup.md index 03b8912f7..27ce3fcfc 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -13,16 +13,20 @@ bun run db:migrate && bun run db:seed bun run dev # app :3000, api :3001, agent :2000 ``` -Prisma from the repo root: `db:generate`, `db:migrate`, `db:push`, `db:reset`, -`db:seed`, `db:studio`, `db:deploy`. +Drizzle from the repo root: `db:generate`, `db:migrate`, `db:check`, `db:push`, +`db:reset`, `db:seed`, `db:studio`, `db:deploy`. -`dev` depends on `^dev:prepare`, so every start applies pending migrations and -regenerates the Prisma client before a single server boots. That is why the first -run needs `db:migrate` only for the seed that follows it. When the database and -`schema.prisma` have diverged past what `migrate deploy` can reconcile, -`dev:prepare` stops the whole run rather than starting servers against a schema -they do not match — reconcile with `db:migrate`, or `db:reset` when the divergence -is an edited migration that has already been applied. +`dev` depends on `^dev:prepare`, so every start applies pending migrations before a +single server boots. That is why the first run needs `db:migrate` only for the seed +that follows it. When `src/schema/schema.ts` has changes that no migration in +`packages/db/drizzle` covers, `dev:prepare` stops the whole run rather than +starting servers against a schema the database does not have — create the +migration with `db:migrate`, or `db:reset` when the divergence is an edited +migration that has already been applied. + +A database that the Prisma releases migrated needs no manual step. `db:deploy` +adopts it when every Prisma migration is recorded, and refuses it otherwise. +`packages/db/README.md` says how. ## Google Cloud @@ -95,12 +99,12 @@ the live database. On 2026-08-01 eleven migrations landed on Neon from a laptop. 2. **`packages/db/scripts/require-local-db.ts` guards `db:migrate`, `db:push`, `db:reset`, `db:seed`** and takes `ALLOW_REMOTE_DB=1`. `db:deploy` is unguarded on purpose. It reads the root files directly rather than `process.env`, because Bun - auto-loads the working directory's `.env` while Prisma's CLI only sees - `@crm/env/load`. + auto-loads the working directory's `.env` while drizzle-kit only sees + `loadRootEnv()`. ## Migrations run on the production deploy, and nowhere else -`apps/api/scripts/build-func.mjs` runs `prisma migrate deploy` during the crm-api +`apps/api/scripts/build-func.mjs` runs `bun scripts/migrate.ts` in `packages/db` during the crm-api build, gated on `VERCEL_ENV === "production"`. The schema therefore moves when the release pull request merges and `release` deploys — with the code that needs it, and once rather than once per branch. @@ -113,14 +117,15 @@ builds, and the pages that touch them fail. Test schema changes locally, where worse: every preview applied its own migrations to the production database, so on 2026-08-07 the live schema ran six migrations ahead of the live code all day. -### `migrate deploy` is not proof the schema is right +### Applied migrations are not proof the schema is right -The build follows the deploy with `prisma migrate diff --exit-code` against -`schema.prisma` and shouts in the build log when they disagree. **`No pending -migrations to apply` only means `_prisma_migrations` has a row for every file** — -it says nothing about what the tables actually look like. +The build follows the deploy with `scripts/check-drift.ts`, which fails when +`src/schema/schema.ts` has changes that no migration covers, and shouts in the build +log when it does. **It does not compare the live tables.** A row in +`drizzle.__drizzle_migrations` for every file says nothing about what the tables +actually look like. -They came apart once. A `prisma db push` shaped production from a laptop, the +They came apart once, under Prisma. A `prisma db push` shaped production from a laptop, the migration rows were recorded as applied without their SQL ever running, and `agentConversationAttachment` went live without its `position` column. Every deploy reported nothing pending, for days, while `conversations.builderById` returned 500. @@ -128,11 +133,12 @@ The tell is an object in the database that no migration defines — there was an `agentConversationAttachment_submissionId_createdAt_idx` that appears in no migration file, only in a `db push` of an older schema. -Reconciling is one command, and it is worth reading before running: +To see what the live database lacks, introspect it and read the result before +running anything: ```sh -DATABASE_URL="…" bunx prisma migrate diff \ - --from-config-datasource --to-schema prisma/schema.prisma --script +cd packages/db +bunx drizzle-kit pull --dialect postgresql --url "…" --out /tmp/live ``` ## Secrets hygiene @@ -151,11 +157,11 @@ bun run --filter=agent test # integration specs need DATABASE_URL + real Post ### The test database rebuilds itself when it drifts -`bun run db:test` creates `crm_test` and runs `migrate deploy` on it. The database +`bun run db:test` creates `crm_test` and applies the migrations to it. The database name must end in `_test`; the suite deletes rows it expects to put back, so it refuses anything else. -**`migrate deploy` only applies migrations that are missing. It never removes a +**Migrating only applies migrations that are missing. It never removes a table, a column or a constraint the database has and the schema does not.** A `crm_test` built on a branch that was later abandoned therefore keeps that branch's objects forever, and `db:test` used to report `already exists` and move on. The @@ -168,8 +174,8 @@ So `db:test` now checks the database it found and rebuilds it when either is tru - **It holds a migration this branch does not have.** The database came from another branch. The name of the first one is printed. -- **It no longer matches `schema.prisma`**, by `prisma migrate diff`. Something - was pushed or altered by hand. +- **It was migrated from different migration files.** A migration file changed + after the database applied it. A rebuild drops the database and re-runs every migration, and it says which of the two reasons fired. Force one with `bun run db:test --reset`. Nothing else in the diff --git a/knip.json b/knip.json index b02366e01..c0c5a853f 100644 --- a/knip.json +++ b/knip.json @@ -40,8 +40,7 @@ }, "packages/db": { "entry": ["src/*.ts"], - "project": ["src/**/*.ts"], - "ignoreDependencies": ["@prisma/client"] + "project": ["src/**/*.ts"] }, "packages/ui": { "entry": ["src/components/**/*.tsx", "src/lib/*.ts", "src/hooks/*.ts"], diff --git a/packages/auth/README.md b/packages/auth/README.md index 9ff6b04bb..e0f0c14c5 100644 --- a/packages/auth/README.md +++ b/packages/auth/README.md @@ -84,7 +84,8 @@ Adding a plugin or an additional user field changes the database schema. After editing `src/auth.ts`: ```bash -bun run auth:generate # rewrites packages/db/prisma/schema.prisma +bun run auth:generate # writes packages/db/drizzle/auth-schema.generated.ts + # carry the differences into packages/db/src/schema/schema.ts bun run db:migrate # create the migration ``` diff --git a/packages/auth/package.json b/packages/auth/package.json index 8b37dc0d0..adff58ff9 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -22,7 +22,7 @@ "./workspace": "./src/workspace.ts" }, "scripts": { - "auth:generate": "better-auth generate --config src/auth.ts --output ../db/prisma/schema.prisma --y", + "auth:generate": "better-auth generate --config src/auth.ts --output ../db/drizzle/auth-schema.generated.ts --y", "check-types": "tsc --noEmit", "lint": "biome check .", "test": "bun test", diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts index cb49764ff..d7feea4c2 100644 --- a/packages/auth/src/auth.ts +++ b/packages/auth/src/auth.ts @@ -1,9 +1,10 @@ import { apiKey } from "@better-auth/api-key"; import { sso } from "@better-auth/sso"; -import { db } from "@crm/db"; +import { db, eq, user as userTable } from "@crm/db"; +import * as schema from "@crm/db/schema"; import { schemas } from "@crm/validation"; import { type BetterAuthOptions, betterAuth } from "better-auth"; -import { prismaAdapter } from "better-auth/adapters/prisma"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { APIError } from "better-auth/api"; import { genericOAuth } from "better-auth/plugins/generic-oauth"; import { organization } from "better-auth/plugins/organization"; @@ -74,8 +75,9 @@ export const auth = betterAuth({ appName: "CRM", baseURL: env.apiUrl, - database: prismaAdapter(db, { - provider: "postgresql", + database: drizzleAdapter(db, { + provider: "pg", + schema, }), emailAndPassword: { @@ -292,10 +294,11 @@ export const auth = betterAuth({ }, after: async (session) => { - const user = await db.user.findUnique({ - where: { id: session.userId }, - select: { id: true, email: true }, - }); + const [user] = await db + .select({ id: userTable.id, email: userTable.email }) + .from(userTable) + .where(eq(userTable.id, session.userId)) + .limit(1); if (user) await notifySignedIn(user); }, diff --git a/packages/auth/src/organization.ts b/packages/auth/src/organization.ts index df863e2f3..d3658f2e3 100644 --- a/packages/auth/src/organization.ts +++ b/packages/auth/src/organization.ts @@ -1,4 +1,13 @@ -import { type Db, db } from "@crm/db"; +import { + and, + asc, + db, + type Executor, + eq, + member, + organization, + user, +} from "@crm/db"; import { WORKSPACE_ID, workspaceSlug } from "@crm/db/workspace"; export { WORKSPACE_ID }; @@ -41,63 +50,77 @@ export async function ensureWorkspaceMembership( userId: string, ): Promise { try { - return await db.$transaction(async (tx) => { - const workspace = await tx.organization.upsert({ - where: { id: WORKSPACE_ID }, - create: { + return await db.transaction(async (tx) => { + await tx + .insert(organization) + .values({ id: WORKSPACE_ID, name: DEFAULT_WORKSPACE_NAME, slug: workspaceSlug(DEFAULT_WORKSPACE_NAME), createdAt: new Date(), - }, - update: {}, - select: { id: true, name: true, slug: true }, - }); + }) + .onConflictDoNothing({ target: organization.id }); + + const [workspace] = await tx + .select({ + id: organization.id, + name: organization.name, + slug: organization.slug, + }) + .from(organization) + .where(eq(organization.id, WORKSPACE_ID)) + .limit(1); + if (!workspace) + throw new Error(`Workspace ${WORKSPACE_ID} is missing after insert.`); const slug = workspaceSlug(workspace.name); if (workspace.slug !== slug) { - await tx.organization.update({ - where: { id: workspace.id }, - data: { slug }, - }); + await tx + .update(organization) + .set({ slug }) + .where(eq(organization.id, workspace.id)); } - const enrolled = await tx.member.count({ - where: { organizationId: workspace.id }, - }); + const enrolled = await tx.$count( + member, + eq(member.organizationId, workspace.id), + ); if (enrolled === 0) { - const existing = await tx.user.findMany({ - select: { id: true }, - orderBy: [{ createdAt: "asc" }, { id: "asc" }], - }); - - await tx.member.createMany({ - data: existing.map((user, index) => ({ - id: crypto.randomUUID(), - organizationId: workspace.id, - userId: user.id, - role: index === 0 ? "owner" : "member", - createdAt: new Date(), - })), - skipDuplicates: true, - }); + const existing = await tx + .select({ id: user.id }) + .from(user) + .orderBy(asc(user.createdAt), asc(user.id)); + + if (existing.length > 0) { + await tx + .insert(member) + .values( + existing.map((row, index) => ({ + id: crypto.randomUUID(), + organizationId: workspace.id, + userId: row.id, + role: index === 0 ? "owner" : "member", + createdAt: new Date(), + })), + ) + .onConflictDoNothing(); + } } - await tx.member.upsert({ - where: { - organizationId_userId: { organizationId: workspace.id, userId }, - }, - create: { + await tx + .insert(member) + .values({ id: crypto.randomUUID(), organizationId: workspace.id, userId, role: "member", createdAt: new Date(), - }, - update: {}, - }); + }) + .onConflictDoNothing({ + target: [member.organizationId, member.userId], + }); return workspace.id; }); @@ -114,16 +137,19 @@ export function toWorkspaceRole(value: string): WorkspaceRole { return isWorkspaceRole(value) ? value : "member"; } -export type WorkspaceMemberReader = Pick; +export type WorkspaceMemberReader = Executor; export async function workspaceRoleOf( userId: string, client: WorkspaceMemberReader = db, ): Promise { - const member = await client.member.findUnique({ - where: { organizationId_userId: { organizationId: WORKSPACE_ID, userId } }, - select: { role: true }, - }); - - return member ? toWorkspaceRole(member.role) : null; + const [row] = await client + .select({ role: member.role }) + .from(member) + .where( + and(eq(member.organizationId, WORKSPACE_ID), eq(member.userId, userId)), + ) + .limit(1); + + return row ? toWorkspaceRole(row.role) : null; } diff --git a/packages/auth/src/slack-connect.ts b/packages/auth/src/slack-connect.ts index 1632c335b..e2048e16a 100644 --- a/packages/auth/src/slack-connect.ts +++ b/packages/auth/src/slack-connect.ts @@ -1,4 +1,4 @@ -import { db } from "@crm/db"; +import { and, db, eq, inArray, member } from "@crm/db"; import type { JsonValue } from "@crm/db/json"; import { APIError, @@ -39,12 +39,13 @@ export const slackConnectGuard = createAuthMiddleware(async (ctx) => { const [role, managers] = await Promise.all([ workspaceRoleOf(session.user.id), - db.member.count({ - where: { - organizationId: WORKSPACE_ID, - role: { in: [...CONNECT_MANAGER_ROLES] }, - }, - }), + db.$count( + member, + and( + eq(member.organizationId, WORKSPACE_ID), + inArray(member.role, [...CONNECT_MANAGER_ROLES]), + ), + ), ]); if (!role) { diff --git a/packages/auth/src/slack-grant.ts b/packages/auth/src/slack-grant.ts index c1e3f0338..2de9ae4a9 100644 --- a/packages/auth/src/slack-grant.ts +++ b/packages/auth/src/slack-grant.ts @@ -1,4 +1,13 @@ -import { db } from "@crm/db"; +import { + account as accountTable, + and, + db, + eq, + lt, + ne, + slackInstallation, + slackWorkspaceGrant, +} from "@crm/db"; import { lockIdempotencyKey } from "@crm/db/idempotency"; import type { OauthAccess } from "@crm/validation"; import { SLACK_PROVIDER_ID } from "./scopes"; @@ -16,11 +25,13 @@ export async function rememberSlackInstall(grant: OauthAccess): Promise { createdAt: new Date(), }; - await db.slackInstallation.upsert({ - where: { installerId: installer.id }, - create: { installerId: installer.id, ...install }, - update: install, - }); + await db + .insert(slackInstallation) + .values({ installerId: installer.id, ...install }) + .onConflictDoUpdate({ + target: slackInstallation.installerId, + set: install, + }); await forgetStaleInstalls(); } @@ -29,25 +40,27 @@ export async function replaceSlackConnection(account: { id: string; accountId: string; }): Promise { - await db.$transaction(async (tx) => { + await db.transaction(async (tx) => { await lockIdempotencyKey(tx, SLACK_CONNECTION.locks.connection); - await tx.account.deleteMany({ - where: { providerId: SLACK_PROVIDER_ID, id: { not: account.id } }, - }); + await tx + .delete(accountTable) + .where( + and( + eq(accountTable.providerId, SLACK_PROVIDER_ID), + ne(accountTable.id, account.id), + ), + ); - const install = await tx.slackInstallation.findUnique({ - where: { installerId: account.accountId }, - }); + const [install] = await tx + .delete(slackInstallation) + .where(eq(slackInstallation.installerId, account.accountId)) + .returning(); if (!install) return; - await tx.slackInstallation.delete({ - where: { installerId: account.accountId }, - }); - - await tx.slackWorkspaceGrant.deleteMany({ - where: { teamId: { not: install.teamId } }, - }); + await tx + .delete(slackWorkspaceGrant) + .where(ne(slackWorkspaceGrant.teamId, install.teamId)); if (!install.userToken) return; @@ -57,20 +70,20 @@ export async function replaceSlackConnection(account: { userScopes: install.userScopes, }; - await tx.slackWorkspaceGrant.upsert({ - where: { teamId: install.teamId }, - create: { teamId: install.teamId, ...grant }, - update: grant, - }); + await tx + .insert(slackWorkspaceGrant) + .values({ teamId: install.teamId, ...grant }) + .onConflictDoUpdate({ target: slackWorkspaceGrant.teamId, set: grant }); }); } async function forgetStaleInstalls(): Promise { - await db.slackInstallation.deleteMany({ - where: { - createdAt: { - lt: new Date(Date.now() - SLACK_CONNECTION.install.staleMs), - }, - }, - }); + await db + .delete(slackInstallation) + .where( + lt( + slackInstallation.createdAt, + new Date(Date.now() - SLACK_CONNECTION.install.staleMs), + ), + ); } diff --git a/packages/auth/test/organization.integration.spec.ts b/packages/auth/test/organization.integration.spec.ts index 9f5f3a015..8df595fe0 100644 --- a/packages/auth/test/organization.integration.spec.ts +++ b/packages/auth/test/organization.integration.spec.ts @@ -1,5 +1,5 @@ import { afterAll, beforeEach, describe, expect, it } from "bun:test"; -import { db } from "@crm/db"; +import { and, db, endsWith, eq, member, startsWith, user } from "@crm/db"; import { ensureWorkspaceMembership, WORKSPACE_ID } from "../src/organization"; const suffix = process.env.TEST_RUN_ID ?? "organization-spec"; @@ -10,40 +10,40 @@ let firstId: string; let secondId: string; const seedUser = async (label: string, createdAt: Date): Promise => { - const user = await db.user.create({ - data: { + const [row] = await db + .insert(user) + .values({ id: `${suffix}-${label}`, name: label, email: emailOf(label), createdAt, updatedAt: createdAt, - }, - select: { id: true }, - }); + }) + .returning({ id: user.id }); - return user.id; + if (!row) throw new Error(`Could not seed ${label}.`); + return row.id; }; const roleOf = async (userId: string): Promise => { - const member = await db.member.findUnique({ - where: { organizationId_userId: { organizationId: WORKSPACE_ID, userId } }, - select: { role: true }, - }); + const [row] = await db + .select({ role: member.role }) + .from(member) + .where( + and(eq(member.organizationId, WORKSPACE_ID), eq(member.userId, userId)), + ); - return member?.role ?? null; + return row?.role ?? null; }; const clear = async () => { - await db.member.deleteMany({ - where: { userId: { startsWith: `${suffix}-` } }, - }); - await db.user.deleteMany({ - where: { email: { endsWith: `.${suffix}@example.test` } }, - }); + await db.delete(member).where(startsWith(member.userId, `${suffix}-`)); + await db.delete(user).where(endsWith(user.email, `.${suffix}@example.test`)); - const strangers = await db.member.count({ - where: { organizationId: WORKSPACE_ID }, - }); + const strangers = await db.$count( + member, + eq(member.organizationId, WORKSPACE_ID), + ); if (strangers > 0) { throw new Error( @@ -73,22 +73,28 @@ describe("ensureWorkspaceMembership", () => { it("is idempotent, so signing in again neither duplicates nor re-roles", async () => { await ensureWorkspaceMembership(secondId); - await db.member.update({ - where: { - organizationId_userId: { - organizationId: WORKSPACE_ID, - userId: secondId, - }, - }, - data: { role: "admin" }, - }); + await db + .update(member) + .set({ role: "admin" }) + .where( + and( + eq(member.organizationId, WORKSPACE_ID), + eq(member.userId, secondId), + ), + ); await ensureWorkspaceMembership(secondId); await ensureWorkspaceMembership(secondId); - const rows = await db.member.findMany({ - where: { organizationId: WORKSPACE_ID, userId: secondId }, - }); + const rows = await db + .select() + .from(member) + .where( + and( + eq(member.organizationId, WORKSPACE_ID), + eq(member.userId, secondId), + ), + ); expect(rows).toHaveLength(1); expect(rows[0]?.role).toBe("admin"); @@ -113,9 +119,10 @@ describe("ensureWorkspaceMembership", () => { expect(await roleOf(firstId)).toBe("owner"); - const owners = await db.member.count({ - where: { organizationId: WORKSPACE_ID, role: "owner" }, - }); + const owners = await db.$count( + member, + and(eq(member.organizationId, WORKSPACE_ID), eq(member.role, "owner")), + ); expect(owners).toBe(1); }); diff --git a/packages/auth/test/slack-connect.integration.spec.ts b/packages/auth/test/slack-connect.integration.spec.ts index c5b7e9207..ba3227c5c 100644 --- a/packages/auth/test/slack-connect.integration.spec.ts +++ b/packages/auth/test/slack-connect.integration.spec.ts @@ -6,10 +6,19 @@ import { expect, it, } from "bun:test"; -import { db } from "@crm/db"; +import { + db, + endsWith, + eq, + member, + organization as organizationTable, + session, + user, +} from "@crm/db"; +import * as schema from "@crm/db/schema"; import { workspaceSlug } from "@crm/db/workspace"; import { type BetterAuthPlugin, betterAuth } from "better-auth"; -import { prismaAdapter } from "better-auth/adapters/prisma"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api"; import { applySetCookies } from "better-auth/cookies"; import { genericOAuth } from "better-auth/plugins/generic-oauth"; @@ -53,7 +62,7 @@ const probe = { const guarded = betterAuth({ baseURL: BASE_URL, secret: "slack-connect-spec-secret", - database: prismaAdapter(db, { provider: "postgresql" }), + database: drizzleAdapter(db, { provider: "pg", schema }), emailAndPassword: { enabled: false }, hooks: { before: slackConnectGuard }, plugins: [probe], @@ -80,15 +89,13 @@ const sessionCookie = async (userId: string): Promise => { const context = await guarded.$context; const token = idOf(`${userId}-token`); - await db.session.create({ - data: { - id: idOf(`${userId}-session`), - token, - userId, - expiresAt: new Date(Date.now() + SESSION_MS), - createdAt: new Date(), - updatedAt: new Date(), - }, + await db.insert(session).values({ + id: idOf(`${userId}-session`), + token, + userId, + expiresAt: new Date(Date.now() + SESSION_MS), + createdAt: new Date(), + updatedAt: new Date(), }); const cookie = context.authCookies.sessionToken; @@ -108,30 +115,27 @@ const seat = async ( ): Promise => { const now = new Date(); - const user = await db.user.create({ - data: { - id: idOf(label), - name: label, - email: `${label}${EMAIL_SUFFIX}`, - createdAt: now, - updatedAt: now, - }, - select: { id: true }, + const userId = idOf(label); + + await db.insert(user).values({ + id: userId, + name: label, + email: `${label}${EMAIL_SUFFIX}`, + createdAt: now, + updatedAt: now, }); if (role) { - await db.member.create({ - data: { - id: idOf(`${label}-member`), - organizationId: WORKSPACE_ID, - userId: user.id, - role, - createdAt: now, - }, + await db.insert(member).values({ + id: idOf(`${label}-member`), + organizationId: WORKSPACE_ID, + userId, + role, + createdAt: now, }); } - return sessionCookie(user.id); + return sessionCookie(userId); }; const startConnect = ( @@ -167,36 +171,46 @@ const arrived = async (response: Response): Promise => arrival.safeParse(await response.json()).success; const clear = async () => { - await db.member.deleteMany({ where: { organizationId: WORKSPACE_ID } }); - await db.organization.deleteMany({ where: { id: WORKSPACE_ID } }); - await db.user.deleteMany({ where: { email: { endsWith: EMAIL_SUFFIX } } }); + await db.delete(member).where(eq(member.organizationId, WORKSPACE_ID)); + await db + .delete(organizationTable) + .where(eq(organizationTable.id, WORKSPACE_ID)); + await db.delete(user).where(endsWith(user.email, EMAIL_SUFFIX)); }; beforeAll(async () => { - const organization = await db.organization.findUnique({ - where: { id: WORKSPACE_ID }, - select: { name: true, slug: true, website: true, metadata: true }, - }); + const [organization] = await db + .select({ + name: organizationTable.name, + slug: organizationTable.slug, + website: organizationTable.website, + metadata: organizationTable.metadata, + }) + .from(organizationTable) + .where(eq(organizationTable.id, WORKSPACE_ID)); snapshot = { - organization, - members: await db.member.findMany({ - where: { organizationId: WORKSPACE_ID }, - select: { id: true, userId: true, role: true, createdAt: true }, - }), + organization: organization ?? null, + members: await db + .select({ + id: member.id, + userId: member.userId, + role: member.role, + createdAt: member.createdAt, + }) + .from(member) + .where(eq(member.organizationId, WORKSPACE_ID)), }; }); beforeEach(async () => { await clear(); - await db.organization.create({ - data: { - id: WORKSPACE_ID, - name: DEFAULT_WORKSPACE_NAME, - slug: workspaceSlug(DEFAULT_WORKSPACE_NAME), - createdAt: new Date(), - }, + await db.insert(organizationTable).values({ + id: WORKSPACE_ID, + name: DEFAULT_WORKSPACE_NAME, + slug: workspaceSlug(DEFAULT_WORKSPACE_NAME), + createdAt: new Date(), }); }); @@ -204,20 +218,20 @@ afterAll(async () => { await clear(); if (snapshot.organization) { - await db.organization.create({ - data: { - id: WORKSPACE_ID, - createdAt: new Date(), - ...snapshot.organization, - }, + await db.insert(organizationTable).values({ + id: WORKSPACE_ID, + createdAt: new Date(), + ...snapshot.organization, }); - await db.member.createMany({ - data: snapshot.members.map((member) => ({ - ...member, - organizationId: WORKSPACE_ID, - })), - }); + if (snapshot.members.length > 0) { + await db.insert(member).values( + snapshot.members.map((row) => ({ + ...row, + organizationId: WORKSPACE_ID, + })), + ); + } } }); diff --git a/packages/db/README.md b/packages/db/README.md index e4be5ff76..da19fa2a2 100644 --- a/packages/db/README.md +++ b/packages/db/README.md @@ -1,20 +1,24 @@ # `@crm/db` -PostgreSQL access for the monorepo: the Prisma schema, migrations, and a shared -`PrismaClient` instance. +PostgreSQL access for the monorepo: the Drizzle schema, the SQL migrations, and +one shared `db` instance on a `pg` pool. ## Usage ```ts -import { db } from "@crm/db"; +import { db, desc, user } from "@crm/db"; -const users = await db.user.findMany({ take: 10 }); +const users = await db.select().from(user).orderBy(desc(user.createdAt)).limit(10); ``` -Types and query helpers come from the same entrypoint: +Tables, enums, row types (`User`, `NewUser`) and the Drizzle operators all come +from the same entrypoint. The relational API reads nested records: ```ts -import { Prisma, type User } from "@crm/db"; +const deal = await db.query.deal.findFirst({ + where: eq(dealTable.id, id), + with: { company: true, contacts: { with: { contact: true } } }, +}); ``` ## Setup @@ -22,51 +26,63 @@ import { Prisma, type User } from "@crm/db"; ```bash docker compose up -d # Postgres matching the DATABASE_URL in .env.example cp .env.example .env # at the repo root -bun run db:generate # generate Prisma Client bun run db:deploy # apply the migrations ``` `DATABASE_URL` comes from the **repo-root `.env`**, loaded by `@crm/env` — see [`docs/environment.md`](../../docs/environment.md). `src/client.ts` imports -`@crm/env/load` before reading it, and `prisma.config.ts` does the same so the -CLI works without any app running. +`@crm/env/load` before reading it, and `drizzle.config.ts` calls `loadRootEnv()` +so the CLI works without any app running. ## Scripts -| Script | Purpose | -| ------------- | -------------------------------------------------------- | -| `build` | `prisma generate` — cached by Turborepo, runs via `^build` | -| `dev:prepare` | Apply pending local migrations, reject drift, and generate Prisma Client | -| `db:generate` | Regenerate Prisma Client | -| `db:migrate` | Create and apply a migration (development) | -| `db:deploy` | Apply pending migrations (CI / production) | -| `db:push` | Push the schema without a migration (prototyping only) | -| `db:reset` | Drop and recreate the database | -| `db:seed` | Run `prisma/seed.ts` | -| `db:studio` | Open Prisma Studio | +| Script | Purpose | +| ------------- | -------------------------------------------------------------------- | +| `dev:prepare` | Apply pending local migrations and reject a schema no migration covers | +| `db:generate` | Write a migration for the changes in `src/schema/schema.ts` | +| `db:migrate` | Generate and apply a migration (development) | +| `db:deploy` | Apply pending migrations (CI / production) | +| `db:check` | Fail when `src/schema/schema.ts` has changes that no migration covers | +| `db:push` | Push the schema without a migration (prototyping only) | +| `db:reset` | Drop and recreate the database | +| `db:seed` | Run `scripts/seed.ts` | +| `db:studio` | Open Drizzle Studio | +| `db:test` | Create or rebuild `crm_test` | Each is also exposed at the repo root (`bun run db:migrate`) and routed through `turbo run`. -The root `bun run dev` command runs `dev:prepare` before any service starts and -reruns it when the schema or migrations change. Services that hold a Prisma -client restart only after preparation finishes, so they cannot retain an older -generated model map. +## Moving from the Prisma releases + +The Drizzle history starts at `drizzle/0000_baseline.sql`, which creates the schema +the last Prisma migration left. `db:deploy` recognises a database that Prisma +migrated: when every migration in `drizzle/legacy-prisma-migrations.txt` is in +`_prisma_migrations`, it records the baseline as applied and runs only what comes +after it. It refuses a Prisma database that misses any of them, because the +baseline would then describe tables that do not exist. `_prisma_migrations` stays +in place; nothing reads it again. ## Notes -- **Prisma 7 + driver adapter.** There is no query engine binary; the client - talks to PostgreSQL through `@prisma/adapter-pg`. See `src/client.ts`. -- **Generated code is not committed.** `prisma generate` writes to - `src/generated/`, which is gitignored and declared as the `build` task's - output so Turborepo caches it. +- **No generated client.** The schema is TypeScript; there is no `build` step. +- **`timestamp(3)` without a time zone, written and read as UTC.** `src/client.ts` + sets `pg.defaults.parseInputDatesAsUTC`, and the schema fills `createdAt`-style + defaults in the app, so a server with a non-UTC `TimeZone` stores the same + instants Prisma stored. +- **`numeric` is `Decimal`** (decimal.js) and `bytea` is `Uint8Array`, through the + custom types in `src/schema/columns.ts`. +- **Raw SQL goes through `queryRows`.** It returns timestamps as `Date`. Bind an + array as one parameter with `sql.param(list)`; a bare `${list}` expands into a + tuple. +- **`whereEq(column, value)` keeps Prisma's meaning**: `undefined` adds no filter, + `null` is `IS NULL`. - **JIT package.** `exports` point at TypeScript sources; the consumer compiles - them. Turbopack transpiles workspace packages automatically, so a Next.js app - needs no `transpilePackages` entry. Non-bundler consumers need a TypeScript - runtime — the NestJS API runs on Bun for exactly this reason. -- **Auth models are generated.** `User`, `Session`, `Account`, `Verification` - and `RateLimit` come from `@better-auth/cli`. Do not hand-edit them — change - the Better Auth config in `@crm/auth` and re-run `bun run auth:generate`. - The generator is additive: it adds models and fields a plugin needs but never - removes the ones a dropped plugin left behind, so removing a plugin means - deleting its models from the schema by hand. + them. Non-bundler consumers need a TypeScript runtime — the NestJS API runs on + Bun for exactly this reason. +- **Client components import `-shape` modules only** (`@crm/db/fields-shape`, + `@crm/db/settings-shape`, `@crm/db/workspace-shape`). They hold constants and + pure functions and reach no table. +- **Auth tables follow Better Auth.** `bun run auth:generate` writes the schema + Better Auth expects to `drizzle/auth-schema.generated.ts`. Compare it with the + `user`, `session`, `account`, `verification` and `rateLimit` tables in + `src/schema/schema.ts` and carry the differences over by hand. diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts new file mode 100644 index 000000000..f4d8d95d9 --- /dev/null +++ b/packages/db/drizzle.config.ts @@ -0,0 +1,25 @@ +import { loadRootEnv } from "@crm/env"; + +import { defineConfig } from "drizzle-kit"; + +loadRootEnv(); + +const url = + process.env.DIRECT_DATABASE_URL || + process.env.POSTGRES_URL_NON_POOLING || + process.env.DATABASE_URL; + +if (!url) { + throw new Error( + "DATABASE_URL is not set. Copy .env.example to .env at the root of the repo and fill it in.", + ); +} + +export default defineConfig({ + dialect: "postgresql", + schema: "./src/schema/schema.ts", + out: "./drizzle", + dbCredentials: { url }, + strict: true, + verbose: true, +}); diff --git a/packages/db/drizzle/0000_baseline.sql b/packages/db/drizzle/0000_baseline.sql new file mode 100644 index 000000000..8866b7634 --- /dev/null +++ b/packages/db/drizzle/0000_baseline.sql @@ -0,0 +1,1445 @@ +CREATE TYPE "public"."ActivityType" AS ENUM('NOTE', 'CALL', 'EMAIL', 'MEETING', 'TASK', 'STAGE_CHANGE', 'ENRICHMENT'); +--> statement-breakpoint +CREATE TYPE "public"."AgentActionStatus" AS ENUM('PLANNED', 'RUNNING', 'SUCCEEDED', 'FAILED', 'CANCELLED'); +--> statement-breakpoint +CREATE TYPE "public"."AgentBuilderArtifactStatus" AS ENUM('WRITING', 'READY'); +--> statement-breakpoint +CREATE TYPE "public"."AgentConversationCommandType" AS ENUM('CHAT', 'CREATE_AGENT'); +--> statement-breakpoint +CREATE TYPE "public"."AgentConversationKind" AS ENUM('RECORD', 'BUILDER'); +--> statement-breakpoint +CREATE TYPE "public"."AgentConversationPermissionMode" AS ENUM('MANUAL', 'AUTO', 'BYPASS'); +--> statement-breakpoint +CREATE TYPE "public"."AgentConversationShareScope" AS ENUM('WORKSPACE_LINK'); +--> statement-breakpoint +CREATE TYPE "public"."AgentConversationSubmissionStatus" AS ENUM('PENDING', 'SENDING', 'ACCEPTED', 'FAILED', 'CANCELLED'); +--> statement-breakpoint +CREATE TYPE "public"."AgentDefinitionStatus" AS ENUM('DRAFT', 'DEPLOYING', 'LIVE', 'PAUSED', 'ARCHIVED', 'DELETED'); +--> statement-breakpoint +CREATE TYPE "public"."AgentResponseRating" AS ENUM('UP', 'DOWN'); +--> statement-breakpoint +CREATE TYPE "public"."AgentRunStatus" AS ENUM('QUEUED', 'RUNNING', 'WAITING_FOR_APPROVAL', 'SUCCEEDED', 'FAILED', 'CANCELLED'); +--> statement-breakpoint +CREATE TYPE "public"."AgentTriggerType" AS ENUM('MANUAL', 'SCHEDULE', 'EVENT', 'WEBHOOK'); +--> statement-breakpoint +CREATE TYPE "public"."AgentVersionStatus" AS ENUM('DRAFT', 'VALIDATING', 'READY', 'DEPLOYED', 'REJECTED'); +--> statement-breakpoint +CREATE TYPE "public"."DealStage" AS ENUM('DEMO_BOOKED', 'QUALIFIED_TO_BUY', 'UNQUALIFIED_TO_BUY', 'DECISION_MAKER_BOUGHT_IN', 'CONTRACT_SENT', 'CLOSED_WON', 'CLOSED_LOST'); +--> statement-breakpoint +CREATE TYPE "public"."DomainScope" AS ENUM('SITE_AND_SUBDOMAINS', 'EXACT_HOST'); +--> statement-breakpoint +CREATE TYPE "public"."EmailDirection" AS ENUM('INBOUND', 'OUTBOUND'); +--> statement-breakpoint +CREATE TYPE "public"."EnrichmentStatus" AS ENUM('PENDING', 'RUNNING', 'COMPLETE', 'FAILED', 'SKIPPED'); +--> statement-breakpoint +CREATE TYPE "public"."FactBand" AS ENUM('VERIFIED', 'PROBABLE', 'POSSIBLE'); +--> statement-breakpoint +CREATE TYPE "public"."FactStatus" AS ENUM('APPLIED', 'PROPOSED', 'DISMISSED', 'SUPERSEDED'); +--> statement-breakpoint +CREATE TYPE "public"."FieldEntity" AS ENUM('COMPANY', 'CONTACT', 'DEAL'); +--> statement-breakpoint +CREATE TYPE "public"."FieldType" AS ENUM('TEXT', 'LONG_TEXT', 'NUMBER', 'DATE', 'CHECKBOX', 'SELECT', 'URL', 'EMAIL', 'PHONE', 'USER'); +--> statement-breakpoint +CREATE TYPE "public"."GoogleSyncStatus" AS ENUM('IDLE', 'RUNNING', 'NEEDS_RECONNECT', 'FAILED'); +--> statement-breakpoint +CREATE TYPE "public"."McpAccessMode" AS ENUM('READ_ONLY', 'READ_WRITE'); +--> statement-breakpoint +CREATE TYPE "public"."McpConnectionStatus" AS ENUM('CONNECTING', 'CONNECTED', 'ERROR', 'DISCONNECTED'); +--> statement-breakpoint +CREATE TYPE "public"."McpToolReviewState" AS ENUM('PENDING', 'REVIEWED', 'CHANGED', 'REMOVED'); +--> statement-breakpoint +CREATE TYPE "public"."RateSource" AS ENUM('FETCHED', 'MANUAL'); +--> statement-breakpoint +CREATE TYPE "public"."RecordSource" AS ENUM('MANUAL', 'IMPORT', 'EMAIL', 'CALENDAR', 'TRACKING'); +--> statement-breakpoint +CREATE TABLE "account" ( + "id" text PRIMARY KEY NOT NULL, + "accountId" text NOT NULL, + "providerId" text NOT NULL, + "userId" text NOT NULL, + "accessToken" text, + "refreshToken" text, + "idToken" text, + "accessTokenExpiresAt" timestamp (3), + "refreshTokenExpiresAt" timestamp (3), + "scope" text, + "password" text, + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamp (3) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "activity" ( + "id" text PRIMARY KEY NOT NULL, + "type" "ActivityType" NOT NULL, + "subject" text, + "body" text, + "occurredAt" timestamp (3), + "dueAt" timestamp (3), + "completedAt" timestamp (3), + "companyId" text, + "contactId" text, + "dealId" text, + "createdById" text NOT NULL, + "meta" jsonb, + "emailThreadId" text, + "calendarEventId" text, + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamp (3) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "agentAction" ( + "id" text PRIMARY KEY NOT NULL, + "agentId" text NOT NULL, + "runId" text NOT NULL, + "type" text NOT NULL, + "provider" text NOT NULL, + "targetType" text, + "targetId" text, + "targetLabel" text, + "summary" text NOT NULL, + "metadata" jsonb, + "status" "AgentActionStatus" DEFAULT 'PLANNED' NOT NULL, + "idempotencyKey" text NOT NULL, + "requestHash" text, + "externalId" text, + "attemptCount" integer DEFAULT 0 NOT NULL, + "errorCode" text, + "errorMessage" text, + "plannedAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "startedAt" timestamp (3), + "completedAt" timestamp (3), + "updatedAt" timestamp (3) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "agentAuditEvent" ( + "id" text PRIMARY KEY NOT NULL, + "agentId" text NOT NULL, + "versionId" text, + "actorUserId" text, + "type" text NOT NULL, + "actorType" text NOT NULL, + "actorId" text, + "summary" text NOT NULL, + "before" jsonb, + "after" jsonb, + "requestId" text, + "emittedAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "agentBuilderArtifact" ( + "id" text PRIMARY KEY NOT NULL, + "conversationId" text, + "versionId" text, + "path" text NOT NULL, + "language" text NOT NULL, + "content" text NOT NULL, + "previousContent" text, + "revision" integer NOT NULL, + "status" "AgentBuilderArtifactStatus" DEFAULT 'WRITING' NOT NULL, + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "agentBuilderArtifact_owner_present" CHECK ("agentBuilderArtifact"."conversationId" IS NOT NULL OR "agentBuilderArtifact"."versionId" IS NOT NULL) +); +--> statement-breakpoint +CREATE TABLE "agentConversation" ( + "id" text PRIMARY KEY NOT NULL, + "kind" "AgentConversationKind" DEFAULT 'RECORD' NOT NULL, + "contactId" text, + "companyId" text, + "dealId" text, + "userId" text NOT NULL, + "agentId" text, + "sessionId" text, + "continuationToken" text, + "streamIndex" integer DEFAULT 0 NOT NULL, + "pendingInputRequests" jsonb, + "permissionMode" "AgentConversationPermissionMode" DEFAULT 'MANUAL' NOT NULL, + "title" text, + "messageCount" integer DEFAULT 0 NOT NULL, + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamp (3) NOT NULL, + "lastMessageAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "lastAssistantAt" timestamp (3), + "lastReadAt" timestamp (3) +); +--> statement-breakpoint +CREATE TABLE "agentConversationAttachment" ( + "id" text PRIMARY KEY NOT NULL, + "submissionId" text NOT NULL, + "name" text NOT NULL, + "mediaType" text NOT NULL, + "size" integer NOT NULL, + "content" "bytea" NOT NULL, + "position" integer NOT NULL, + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "agentConversationFeedback" ( + "id" text PRIMARY KEY NOT NULL, + "conversationId" text NOT NULL, + "userId" text NOT NULL, + "messageId" text NOT NULL, + "rating" "AgentResponseRating" NOT NULL, + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamp (3) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "agentConversationShare" ( + "id" text PRIMARY KEY NOT NULL, + "conversationId" text NOT NULL, + "createdById" text NOT NULL, + "scope" "AgentConversationShareScope" DEFAULT 'WORKSPACE_LINK' NOT NULL, + "tokenHash" text NOT NULL, + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "expiresAt" timestamp (3), + "revokedAt" timestamp (3) +); +--> statement-breakpoint +CREATE TABLE "agentConversationSubmission" ( + "id" text PRIMARY KEY NOT NULL, + "conversationId" text NOT NULL, + "submittedById" text NOT NULL, + "clientRequestId" text NOT NULL, + "inputRequestId" text, + "commandType" "AgentConversationCommandType" DEFAULT 'CHAT' NOT NULL, + "message" jsonb NOT NULL, + "status" "AgentConversationSubmissionStatus" DEFAULT 'PENDING' NOT NULL, + "attemptCount" integer DEFAULT 0 NOT NULL, + "errorCode" text, + "errorMessage" text, + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "sentAt" timestamp (3), + "acceptedAt" timestamp (3) +); +--> statement-breakpoint +CREATE TABLE "agentDefinition" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "description" text, + "status" "AgentDefinitionStatus" DEFAULT 'DRAFT' NOT NULL, + "createdById" text NOT NULL, + "currentVersionId" text, + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamp (3) NOT NULL, + "archivedAt" timestamp (3), + "deletedAt" timestamp (3) +); +--> statement-breakpoint +CREATE TABLE "agentEvent" ( + "id" text PRIMARY KEY NOT NULL, + "sessionId" text NOT NULL, + "streamIndex" integer, + "deliveryIds" text[] DEFAULT ARRAY[]::text[] NOT NULL, + "contactId" text, + "conversationId" text, + "type" text NOT NULL, + "data" jsonb NOT NULL, + "encryptedData" text, + "emittedAt" timestamp (3) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "agentRun" ( + "id" text PRIMARY KEY NOT NULL, + "agentId" text NOT NULL, + "versionId" text NOT NULL, + "triggerId" text, + "initiatedById" text, + "triggerType" "AgentTriggerType" NOT NULL, + "status" "AgentRunStatus" DEFAULT 'QUEUED' NOT NULL, + "principalId" text, + "sessionId" text, + "idempotencyKey" text NOT NULL, + "correlationId" text NOT NULL, + "input" jsonb, + "result" jsonb, + "summary" text, + "modelId" text, + "inputTokens" integer, + "outputTokens" integer, + "costUsd" numeric(12, 6), + "errorCode" text, + "errorMessage" text, + "nextEventSequence" integer DEFAULT 0 NOT NULL, + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "startedAt" timestamp (3), + "finishedAt" timestamp (3), + "cancelRequestedAt" timestamp (3), + "cancelDeliveredAt" timestamp (3) +); +--> statement-breakpoint +CREATE TABLE "agentRunEvent" ( + "id" text PRIMARY KEY NOT NULL, + "runId" text NOT NULL, + "sequence" integer NOT NULL, + "type" text NOT NULL, + "data" jsonb NOT NULL, + "emittedAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "agentTask" ( + "id" text PRIMARY KEY NOT NULL, + "contactId" text, + "companyId" text, + "dealId" text, + "kind" text NOT NULL, + "reason" text NOT NULL, + "payload" jsonb, + "priority" integer DEFAULT 0 NOT NULL, + "budget" integer DEFAULT 4 NOT NULL, + "attempts" integer DEFAULT 0 NOT NULL, + "dueAt" timestamp (3) NOT NULL, + "leasedUntil" timestamp (3), + "sessionId" text, + "startedAt" timestamp (3), + "finishedAt" timestamp (3), + "outcome" text, + "subject" text, + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "agentTrigger" ( + "id" text PRIMARY KEY NOT NULL, + "agentId" text NOT NULL, + "versionId" text NOT NULL, + "type" "AgentTriggerType" NOT NULL, + "name" text NOT NULL, + "config" jsonb NOT NULL, + "createdById" text NOT NULL, + "enabled" boolean DEFAULT false NOT NULL, + "nextRunAt" timestamp (3), + "lastRunAt" timestamp (3), + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamp (3) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "agentVersion" ( + "id" text PRIMARY KEY NOT NULL, + "agentId" text NOT NULL, + "number" integer NOT NULL, + "status" "AgentVersionStatus" DEFAULT 'DRAFT' NOT NULL, + "instructions" text NOT NULL, + "manifest" jsonb NOT NULL, + "modelId" text NOT NULL, + "modelContextWindowTokens" integer DEFAULT 1000000 NOT NULL, + "sandboxPolicy" jsonb NOT NULL, + "validation" jsonb, + "sourceConversationId" text, + "createdById" text NOT NULL, + "deploymentId" text, + "approvedAt" timestamp (3), + "deployedAt" timestamp (3), + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "apikey" ( + "id" text PRIMARY KEY NOT NULL, + "configId" text DEFAULT 'default' NOT NULL, + "name" text, + "start" text, + "referenceId" text NOT NULL, + "prefix" text, + "key" text NOT NULL, + "refillInterval" integer, + "refillAmount" integer, + "lastRefillAt" timestamp (3), + "enabled" boolean DEFAULT true, + "rateLimitEnabled" boolean DEFAULT true, + "rateLimitTimeWindow" integer, + "rateLimitMax" integer, + "requestCount" integer DEFAULT 0, + "remaining" integer, + "lastRequest" timestamp (3), + "expiresAt" timestamp (3), + "createdAt" timestamp (3) NOT NULL, + "updatedAt" timestamp (3) NOT NULL, + "permissions" text, + "metadata" text +); +--> statement-breakpoint +CREATE TABLE "appSetting" ( + "id" text PRIMARY KEY NOT NULL, + "agentModelId" text, + "agentModelContextWindow" integer, + "contextDevApiKey" text, + "reportingCurrency" text, + "ratesRefreshedAt" timestamp (3), + "archiveRetentionDays" integer DEFAULT 180 NOT NULL, + "trackingSiteId" text, + "trackingCrossDomain" boolean DEFAULT true NOT NULL, + "trackingLimitToDomains" boolean DEFAULT true NOT NULL, + "trackingCookieSubdomains" boolean DEFAULT false NOT NULL, + "trackingSecureCookies" boolean DEFAULT true NOT NULL, + "trackingHonourDnt" boolean DEFAULT true NOT NULL, + "trackingCookieDays" integer DEFAULT 395 NOT NULL, + "trackingConfigHash" text, + "trackingPaused" boolean DEFAULT false NOT NULL, + "updatedAt" timestamp (3) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "calendarAttendee" ( + "id" text PRIMARY KEY NOT NULL, + "eventId" text NOT NULL, + "email" text NOT NULL, + "name" text, + "responseStatus" text, + "isOrganizer" boolean DEFAULT false NOT NULL, + "contactId" text +); +--> statement-breakpoint +CREATE TABLE "calendarEvent" ( + "id" text PRIMARY KEY NOT NULL, + "iCalUid" text NOT NULL, + "originalStartTime" timestamp (3) NOT NULL, + "recurringEventId" text, + "title" text, + "description" text, + "location" text, + "conferenceUrl" text, + "startsAt" timestamp (3) NOT NULL, + "endsAt" timestamp (3) NOT NULL, + "isAllDay" boolean DEFAULT false NOT NULL, + "status" text NOT NULL, + "organizerEmail" text, + "companyId" text, + "contactId" text, + "syncedByUserId" text, + "googleEventId" text, + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamp (3) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "company" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "domain" text, + "website" text, + "description" text, + "logoUrl" text, + "logoDarkUrl" text, + "iconUrl" text, + "iconDarkUrl" text, + "iconTone" text, + "brandColor" text, + "industry" text, + "subIndustry" text, + "city" text, + "stateCode" text, + "country" text, + "countryCode" text, + "phone" text, + "email" text, + "linkedinUrl" text, + "twitterUrl" text, + "githubUrl" text, + "pricingUrl" text, + "careersUrl" text, + "ownerId" text, + "primaryContactId" text, + "enrichmentStatus" "EnrichmentStatus" DEFAULT 'PENDING' NOT NULL, + "enrichedAt" timestamp (3), + "enrichmentError" text, + "source" "RecordSource" DEFAULT 'MANUAL' NOT NULL, + "lastActivityAt" timestamp (3), + "archivedAt" timestamp (3), + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamp (3) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "companyEnrichment" ( + "companyId" text PRIMARY KEY NOT NULL, + "source" text DEFAULT 'context.dev' NOT NULL, + "raw" jsonb NOT NULL, + "fetchedAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "contact" ( + "id" text PRIMARY KEY NOT NULL, + "firstName" text NOT NULL, + "lastName" text, + "email" text, + "phone" text, + "title" text, + "seniority" text, + "function" text, + "linkedinUrl" text, + "twitterUrl" text, + "githubUrl" text, + "imageUrl" text, + "socialsCheckedAt" timestamp (3), + "enrichmentStatus" "EnrichmentStatus" DEFAULT 'PENDING' NOT NULL, + "enrichedAt" timestamp (3), + "enrichmentError" text, + "companyId" text, + "ownerId" text, + "source" "RecordSource" DEFAULT 'MANUAL' NOT NULL, + "lastActivityAt" timestamp (3), + "archivedAt" timestamp (3), + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamp (3) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "contactBrief" ( + "contactId" text PRIMARY KEY NOT NULL, + "narrative" text NOT NULL, + "sections" jsonb NOT NULL, + "score" double precision NOT NULL, + "sourceUrl" text, + "sessionId" text, + "refreshedAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "contactFact" ( + "id" text PRIMARY KEY NOT NULL, + "contactId" text NOT NULL, + "field" text NOT NULL, + "value" text NOT NULL, + "score" double precision NOT NULL, + "band" "FactBand" NOT NULL, + "evidence" jsonb NOT NULL, + "method" text NOT NULL, + "sourceUrl" text, + "sessionId" text, + "status" "FactStatus" DEFAULT 'PROPOSED' NOT NULL, + "decidedById" text, + "decidedAt" timestamp (3), + "observedAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "supersededAt" timestamp (3) +); +--> statement-breakpoint +CREATE TABLE "deal" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "description" text, + "companyId" text NOT NULL, + "ownerId" text NOT NULL, + "stage" "DealStage" DEFAULT 'DEMO_BOOKED' NOT NULL, + "stageChangedAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "amount" numeric(14, 2), + "currency" text DEFAULT 'USD' NOT NULL, + "expectedCloseDate" timestamp (3), + "closedAt" timestamp (3), + "closedReason" text, + "baseAmount" numeric(24, 4), + "baseCurrency" text, + "fxRate" numeric(20, 10), + "fxRateAt" timestamp (3), + "lastActivityAt" timestamp (3), + "archivedAt" timestamp (3), + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamp (3) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "dealContact" ( + "dealId" text NOT NULL, + "contactId" text NOT NULL, + "role" text, + CONSTRAINT "dealContact_pkey" PRIMARY KEY("dealId","contactId") +); +--> statement-breakpoint +CREATE TABLE "emailMessage" ( + "id" text PRIMARY KEY NOT NULL, + "threadId" text NOT NULL, + "rfcMessageId" text NOT NULL, + "syncedByUserId" text, + "gmailMessageId" text, + "outlookMessageId" text, + "outlookWebLink" text, + "direction" "EmailDirection" NOT NULL, + "fromEmail" text NOT NULL, + "fromName" text, + "recipients" jsonb NOT NULL, + "subject" text, + "snippet" text, + "body" text, + "sentAt" timestamp (3) NOT NULL, + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "emailThread" ( + "id" text PRIMARY KEY NOT NULL, + "rootMessageId" text NOT NULL, + "subject" text, + "companyId" text, + "contactId" text, + "firstMessageAt" timestamp (3) NOT NULL, + "lastMessageAt" timestamp (3) NOT NULL, + "messageCount" integer DEFAULT 0 NOT NULL, + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamp (3) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "exchangeRate" ( + "id" text PRIMARY KEY NOT NULL, + "baseCurrency" text NOT NULL, + "quoteCurrency" text NOT NULL, + "rate" numeric(20, 10) NOT NULL, + "asOf" timestamp (3) NOT NULL, + "source" "RateSource" NOT NULL, + "provider" text, + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamp (3) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "fieldDefinition" ( + "id" text PRIMARY KEY NOT NULL, + "entity" "FieldEntity" NOT NULL, + "key" text NOT NULL, + "label" text NOT NULL, + "type" "FieldType" NOT NULL, + "agentFilled" boolean DEFAULT true NOT NULL, + "agentBrief" text, + "required" boolean DEFAULT false NOT NULL, + "showOnSheet" boolean DEFAULT true NOT NULL, + "showOnTable" boolean DEFAULT false NOT NULL, + "showOnFilter" boolean DEFAULT false NOT NULL, + "position" integer NOT NULL, + "archivedAt" timestamp (3), + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamp (3) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "fieldOption" ( + "id" text PRIMARY KEY NOT NULL, + "fieldId" text NOT NULL, + "label" text NOT NULL, + "position" integer NOT NULL, + "archivedAt" timestamp (3) +); +--> statement-breakpoint +CREATE TABLE "fieldValue" ( + "id" text PRIMARY KEY NOT NULL, + "fieldId" text NOT NULL, + "companyId" text, + "contactId" text, + "dealId" text, + "text" text, + "number" numeric(24, 4), + "date" timestamp (3), + "bool" boolean, + "optionId" text, + "userId" text, + "updatedAt" timestamp (3) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "formSubmission" ( + "id" text PRIMARY KEY NOT NULL, + "visitorId" text, + "contactId" text, + "host" text NOT NULL, + "path" text NOT NULL, + "email" text, + "fields" jsonb NOT NULL, + "firstTouch" jsonb, + "lastTouch" jsonb, + "dedupeKey" text NOT NULL, + "filedAt" timestamp (3), + "skipReason" text, + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "install" ( + "id" text PRIMARY KEY NOT NULL, + "uuid" text NOT NULL, + "version" text NOT NULL, + "lastRollupAt" timestamp (3), + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamp (3) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "invitation" ( + "id" text PRIMARY KEY NOT NULL, + "organizationId" text NOT NULL, + "email" text NOT NULL, + "role" text, + "status" text DEFAULT 'pending' NOT NULL, + "expiresAt" timestamp (3) NOT NULL, + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "inviterId" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "mailboxSync" ( + "id" text PRIMARY KEY NOT NULL, + "userId" text NOT NULL, + "source" text NOT NULL, + "status" "GoogleSyncStatus" DEFAULT 'IDLE' NOT NULL, + "cursor" text, + "lastSyncedAt" timestamp (3), + "lastError" text, + "retryAfter" timestamp (3), + "autoCreate" boolean DEFAULT false NOT NULL, + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamp (3) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "mcpClientRegistration" ( + "id" text PRIMARY KEY NOT NULL, + "platformSlug" text NOT NULL, + "metadataFingerprint" text NOT NULL, + "issuer" text NOT NULL, + "clientId" text NOT NULL, + "encryptedClientSecret" text, + "encryptedRegistrationAccessToken" text, + "clientIdIssuedAt" timestamp (3), + "clientSecretExpiresAt" timestamp (3), + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamp (3) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "mcpConnection" ( + "id" text PRIMARY KEY NOT NULL, + "organizationId" text NOT NULL, + "platformSlug" text NOT NULL, + "status" "McpConnectionStatus" DEFAULT 'CONNECTING' NOT NULL, + "accessMode" "McpAccessMode" NOT NULL, + "grantedScopes" text[], + "encryptedAccessToken" text, + "encryptedRefreshToken" text, + "tokenExpiresAt" timestamp (3), + "registrationId" text NOT NULL, + "connectedById" text NOT NULL, + "connectedAt" timestamp (3), + "lastSuccessfulCallAt" timestamp (3), + "lastErrorCode" text, + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamp (3) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "mcpOauthState" ( + "id" text PRIMARY KEY NOT NULL, + "stateHash" text NOT NULL, + "organizationId" text NOT NULL, + "userId" text NOT NULL, + "platformSlug" text NOT NULL, + "accessMode" "McpAccessMode" NOT NULL, + "returnPath" text NOT NULL, + "encryptedCodeVerifier" text NOT NULL, + "registrationId" text NOT NULL, + "expiresAt" timestamp (3) NOT NULL, + "consumedAt" timestamp (3), + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "mcpToolSnapshot" ( + "id" text PRIMARY KEY NOT NULL, + "connectionId" text NOT NULL, + "name" text NOT NULL, + "description" text, + "inputSchema" jsonb NOT NULL, + "annotations" jsonb, + "fingerprint" text NOT NULL, + "callKind" text NOT NULL, + "reviewState" "McpToolReviewState" DEFAULT 'PENDING' NOT NULL, + "lastSeenAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "member" ( + "id" text PRIMARY KEY NOT NULL, + "organizationId" text NOT NULL, + "userId" text NOT NULL, + "role" text DEFAULT 'member' NOT NULL, + "createdAt" timestamp (3) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "organization" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "slug" text NOT NULL, + "logo" text, + "createdAt" timestamp (3) NOT NULL, + "metadata" text, + "website" text +); +--> statement-breakpoint +CREATE TABLE "rateLimit" ( + "id" text PRIMARY KEY NOT NULL, + "key" text NOT NULL, + "count" integer NOT NULL, + "lastRequest" bigint NOT NULL +); +--> statement-breakpoint +CREATE TABLE "savedView" ( + "id" text PRIMARY KEY NOT NULL, + "entity" "FieldEntity" NOT NULL, + "name" text NOT NULL, + "shared" boolean DEFAULT false NOT NULL, + "filters" jsonb NOT NULL, + "ownerId" text NOT NULL, + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamp (3) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "session" ( + "id" text PRIMARY KEY NOT NULL, + "expiresAt" timestamp (3) NOT NULL, + "token" text NOT NULL, + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamp (3) NOT NULL, + "ipAddress" text, + "userAgent" text, + "userId" text NOT NULL, + "activeOrganizationId" text +); +--> statement-breakpoint +CREATE TABLE "slackChannel" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "memberCount" integer, + "available" boolean DEFAULT true NOT NULL, + "isPrivate" boolean DEFAULT false NOT NULL, + "isMember" boolean DEFAULT false NOT NULL, + "inviteRequestedAt" timestamp (3), + "classifiedAt" timestamp (3), + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamp (3) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "slackInstallation" ( + "installerId" text PRIMARY KEY NOT NULL, + "teamId" text NOT NULL, + "teamName" text, + "userToken" text, + "userScopes" text NOT NULL, + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "slackMemberMatch" ( + "id" text PRIMARY KEY NOT NULL, + "crmUserId" text NOT NULL, + "slackUserId" text, + "slackHandle" text, + "slackEmail" text, + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamp (3) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "slackWorkspaceGrant" ( + "id" text PRIMARY KEY NOT NULL, + "teamId" text NOT NULL, + "teamName" text, + "userToken" text NOT NULL, + "userScopes" text NOT NULL, + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamp (3) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "ssoProvider" ( + "id" text PRIMARY KEY NOT NULL, + "issuer" text NOT NULL, + "oidcConfig" text, + "samlConfig" text, + "userId" text, + "providerId" text NOT NULL, + "organizationId" text, + "domain" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "suppressedContact" ( + "email" text PRIMARY KEY NOT NULL, + "reason" text, + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "suppressedDomain" ( + "domain" text PRIMARY KEY NOT NULL, + "reason" text, + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "telemetryCounter" ( + "name" text PRIMARY KEY NOT NULL, + "count" integer DEFAULT 0 NOT NULL, + "updatedAt" timestamp (3) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "telemetryMilestone" ( + "step" text PRIMARY KEY NOT NULL, + "reachedAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "trackedDomain" ( + "id" text PRIMARY KEY NOT NULL, + "host" text NOT NULL, + "scope" "DomainScope" DEFAULT 'EXACT_HOST' NOT NULL, + "pageViews" integer DEFAULT 0 NOT NULL, + "lastSeenAt" timestamp (3), + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "trackedEvent" ( + "id" text PRIMARY KEY NOT NULL, + "visitorId" text NOT NULL, + "type" text NOT NULL, + "host" text NOT NULL, + "path" text NOT NULL, + "referrer" text, + "label" text, + "source" text, + "medium" text, + "campaign" text, + "occurredAt" timestamp (3) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "trackedPageDaily" ( + "day" timestamp (3) NOT NULL, + "host" text NOT NULL, + "path" text NOT NULL, + "views" integer DEFAULT 0 NOT NULL, + "visitors" integer DEFAULT 0 NOT NULL, + CONSTRAINT "trackedPageDaily_pkey" PRIMARY KEY("day","host","path") +); +--> statement-breakpoint +CREATE TABLE "trackedVisitor" ( + "id" text PRIMARY KEY NOT NULL, + "contactId" text, + "firstSource" text, + "firstMedium" text, + "firstCampaign" text, + "firstTerm" text, + "firstContent" text, + "firstReferrer" text, + "firstLanding" text, + "firstTouchAt" timestamp (3), + "lastSource" text, + "lastMedium" text, + "lastCampaign" text, + "lastTerm" text, + "lastContent" text, + "lastReferrer" text, + "lastLanding" text, + "lastTouchAt" timestamp (3), + "firstSeen" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "lastSeen" timestamp (3) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "trackingCounter" ( + "key" text PRIMARY KEY NOT NULL, + "value" integer DEFAULT 0 NOT NULL, + "expiresAt" timestamp (3) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "user" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "emailVerified" boolean DEFAULT false NOT NULL, + "image" text, + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamp (3) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "verification" ( + "id" text PRIMARY KEY NOT NULL, + "identifier" text NOT NULL, + "value" text NOT NULL, + "expiresAt" timestamp (3) NOT NULL, + "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamp (3) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "workspaceProfile" ( + "id" text PRIMARY KEY NOT NULL, + "website" text NOT NULL, + "narrative" text NOT NULL, + "sections" jsonb NOT NULL, + "sourceUrl" text, + "sessionId" text, + "refreshedAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE INDEX "account_userId_idx" ON "account" USING btree ("userId"); +--> statement-breakpoint +CREATE UNIQUE INDEX "activity_emailThreadId_key" ON "activity" USING btree ("emailThreadId"); +--> statement-breakpoint +CREATE UNIQUE INDEX "activity_calendarEventId_key" ON "activity" USING btree ("calendarEventId"); +--> statement-breakpoint +CREATE INDEX "activity_companyId_createdAt_idx" ON "activity" USING btree ("companyId","createdAt"); +--> statement-breakpoint +CREATE INDEX "activity_dealId_createdAt_idx" ON "activity" USING btree ("dealId","createdAt"); +--> statement-breakpoint +CREATE INDEX "activity_contactId_createdAt_idx" ON "activity" USING btree ("contactId","createdAt"); +--> statement-breakpoint +CREATE INDEX "activity_dueAt_idx" ON "activity" USING btree ("dueAt"); +--> statement-breakpoint +CREATE INDEX "activity_createdById_idx" ON "activity" USING btree ("createdById"); +--> statement-breakpoint +CREATE UNIQUE INDEX "agentAction_idempotencyKey_key" ON "agentAction" USING btree ("idempotencyKey"); +--> statement-breakpoint +CREATE INDEX "agentAction_agentId_plannedAt_idx" ON "agentAction" USING btree ("agentId","plannedAt"); +--> statement-breakpoint +CREATE INDEX "agentAction_runId_plannedAt_idx" ON "agentAction" USING btree ("runId","plannedAt"); +--> statement-breakpoint +CREATE INDEX "agentAction_provider_externalId_idx" ON "agentAction" USING btree ("provider","externalId"); +--> statement-breakpoint +CREATE INDEX "agentAction_status_plannedAt_idx" ON "agentAction" USING btree ("status","plannedAt"); +--> statement-breakpoint +CREATE UNIQUE INDEX "agentAuditEvent_agentId_type_requestId_key" ON "agentAuditEvent" USING btree ("agentId","type","requestId"); +--> statement-breakpoint +CREATE INDEX "agentAuditEvent_agentId_emittedAt_idx" ON "agentAuditEvent" USING btree ("agentId","emittedAt"); +--> statement-breakpoint +CREATE INDEX "agentAuditEvent_versionId_emittedAt_idx" ON "agentAuditEvent" USING btree ("versionId","emittedAt"); +--> statement-breakpoint +CREATE INDEX "agentAuditEvent_actorUserId_emittedAt_idx" ON "agentAuditEvent" USING btree ("actorUserId","emittedAt"); +--> statement-breakpoint +CREATE INDEX "agentAuditEvent_type_emittedAt_idx" ON "agentAuditEvent" USING btree ("type","emittedAt"); +--> statement-breakpoint +CREATE UNIQUE INDEX "agentBuilderArtifact_conversation_path_revision_key" ON "agentBuilderArtifact" USING btree ("conversationId","path","revision") WHERE "conversationId" IS NOT NULL; +--> statement-breakpoint +CREATE UNIQUE INDEX "agentBuilderArtifact_version_path_revision_key" ON "agentBuilderArtifact" USING btree ("versionId","path","revision") WHERE "versionId" IS NOT NULL; +--> statement-breakpoint +CREATE INDEX "agentBuilderArtifact_conversationId_createdAt_idx" ON "agentBuilderArtifact" USING btree ("conversationId","createdAt"); +--> statement-breakpoint +CREATE INDEX "agentBuilderArtifact_versionId_path_idx" ON "agentBuilderArtifact" USING btree ("versionId","path"); +--> statement-breakpoint +CREATE UNIQUE INDEX "agentConversation_sessionId_key" ON "agentConversation" USING btree ("sessionId"); +--> statement-breakpoint +CREATE INDEX "agentConversation_contactId_lastMessageAt_idx" ON "agentConversation" USING btree ("contactId","lastMessageAt"); +--> statement-breakpoint +CREATE INDEX "agentConversation_companyId_lastMessageAt_idx" ON "agentConversation" USING btree ("companyId","lastMessageAt"); +--> statement-breakpoint +CREATE INDEX "agentConversation_dealId_lastMessageAt_idx" ON "agentConversation" USING btree ("dealId","lastMessageAt"); +--> statement-breakpoint +CREATE INDEX "agentConversation_userId_kind_lastMessageAt_idx" ON "agentConversation" USING btree ("userId","kind","lastMessageAt"); +--> statement-breakpoint +CREATE INDEX "agentConversation_agentId_lastMessageAt_idx" ON "agentConversation" USING btree ("agentId","lastMessageAt"); +--> statement-breakpoint +CREATE INDEX "agentConversationAttachment_submissionId_position_idx" ON "agentConversationAttachment" USING btree ("submissionId","position"); +--> statement-breakpoint +CREATE UNIQUE INDEX "agentConversationFeedback_conversationId_userId_messageId_key" ON "agentConversationFeedback" USING btree ("conversationId","userId","messageId"); +--> statement-breakpoint +CREATE INDEX "agentConversationFeedback_conversationId_createdAt_idx" ON "agentConversationFeedback" USING btree ("conversationId","createdAt"); +--> statement-breakpoint +CREATE UNIQUE INDEX "agentConversationShare_tokenHash_key" ON "agentConversationShare" USING btree ("tokenHash"); +--> statement-breakpoint +CREATE UNIQUE INDEX "agentConversationShare_one_active_per_conversation" ON "agentConversationShare" USING btree ("conversationId") WHERE "revokedAt" IS NULL; +--> statement-breakpoint +CREATE INDEX "agentConversationShare_conversationId_revokedAt_idx" ON "agentConversationShare" USING btree ("conversationId","revokedAt"); +--> statement-breakpoint +CREATE UNIQUE INDEX "agentConversationSubmission_clientRequestId_key" ON "agentConversationSubmission" USING btree ("clientRequestId"); +--> statement-breakpoint +CREATE UNIQUE INDEX "agentConversationSubmission_conversationId_inputRequestId_key" ON "agentConversationSubmission" USING btree ("conversationId","inputRequestId"); +--> statement-breakpoint +CREATE INDEX "agentConversationSubmission_conversationId_createdAt_idx" ON "agentConversationSubmission" USING btree ("conversationId","createdAt"); +--> statement-breakpoint +CREATE INDEX "agentConversationSubmission_status_createdAt_idx" ON "agentConversationSubmission" USING btree ("status","createdAt"); +--> statement-breakpoint +CREATE UNIQUE INDEX "agentDefinition_currentVersionId_key" ON "agentDefinition" USING btree ("currentVersionId"); +--> statement-breakpoint +CREATE UNIQUE INDEX "agentDefinition_currentVersionId_id_key" ON "agentDefinition" USING btree ("currentVersionId","id"); +--> statement-breakpoint +CREATE INDEX "agentDefinition_status_updatedAt_idx" ON "agentDefinition" USING btree ("status","updatedAt"); +--> statement-breakpoint +CREATE INDEX "agentDefinition_createdById_createdAt_idx" ON "agentDefinition" USING btree ("createdById","createdAt"); +--> statement-breakpoint +CREATE INDEX "agentEvent_sessionId_emittedAt_idx" ON "agentEvent" USING btree ("sessionId","emittedAt"); +--> statement-breakpoint +CREATE UNIQUE INDEX "agentEvent_sessionId_streamIndex_key" ON "agentEvent" USING btree ("sessionId","streamIndex"); +--> statement-breakpoint +CREATE INDEX "agentEvent_contactId_emittedAt_idx" ON "agentEvent" USING btree ("contactId","emittedAt"); +--> statement-breakpoint +CREATE INDEX "agentEvent_conversationId_emittedAt_idx" ON "agentEvent" USING btree ("conversationId","emittedAt"); +--> statement-breakpoint +CREATE UNIQUE INDEX "agentRun_sessionId_key" ON "agentRun" USING btree ("sessionId"); +--> statement-breakpoint +CREATE UNIQUE INDEX "agentRun_idempotencyKey_key" ON "agentRun" USING btree ("idempotencyKey"); +--> statement-breakpoint +CREATE UNIQUE INDEX "agentRun_correlationId_key" ON "agentRun" USING btree ("correlationId"); +--> statement-breakpoint +CREATE UNIQUE INDEX "agentRun_id_agentId_key" ON "agentRun" USING btree ("id","agentId"); +--> statement-breakpoint +CREATE INDEX "agentRun_agentId_createdAt_idx" ON "agentRun" USING btree ("agentId","createdAt"); +--> statement-breakpoint +CREATE INDEX "agentRun_versionId_createdAt_idx" ON "agentRun" USING btree ("versionId","createdAt"); +--> statement-breakpoint +CREATE INDEX "agentRun_status_createdAt_idx" ON "agentRun" USING btree ("status","createdAt"); +--> statement-breakpoint +CREATE INDEX "agentRun_triggerId_createdAt_idx" ON "agentRun" USING btree ("triggerId","createdAt"); +--> statement-breakpoint +CREATE UNIQUE INDEX "agentRunEvent_runId_sequence_key" ON "agentRunEvent" USING btree ("runId","sequence"); +--> statement-breakpoint +CREATE INDEX "agentRunEvent_runId_emittedAt_idx" ON "agentRunEvent" USING btree ("runId","emittedAt"); +--> statement-breakpoint +CREATE INDEX "agentTask_dueAt_leasedUntil_idx" ON "agentTask" USING btree ("dueAt","leasedUntil"); +--> statement-breakpoint +CREATE INDEX "agentTask_contactId_idx" ON "agentTask" USING btree ("contactId"); +--> statement-breakpoint +CREATE INDEX "agentTask_dealId_idx" ON "agentTask" USING btree ("dealId"); +--> statement-breakpoint +CREATE INDEX "agentTask_kind_subject_idx" ON "agentTask" USING btree ("kind","subject") WHERE "finishedAt" IS NULL; +--> statement-breakpoint +CREATE UNIQUE INDEX "agentTrigger_id_agentId_key" ON "agentTrigger" USING btree ("id","agentId"); +--> statement-breakpoint +CREATE INDEX "agentTrigger_agentId_enabled_idx" ON "agentTrigger" USING btree ("agentId","enabled"); +--> statement-breakpoint +CREATE INDEX "agentTrigger_enabled_nextRunAt_idx" ON "agentTrigger" USING btree ("enabled","nextRunAt"); +--> statement-breakpoint +CREATE INDEX "agentTrigger_versionId_idx" ON "agentTrigger" USING btree ("versionId"); +--> statement-breakpoint +CREATE UNIQUE INDEX "agentVersion_agentId_number_key" ON "agentVersion" USING btree ("agentId","number"); +--> statement-breakpoint +CREATE UNIQUE INDEX "agentVersion_id_agentId_key" ON "agentVersion" USING btree ("id","agentId"); +--> statement-breakpoint +CREATE INDEX "agentVersion_agentId_createdAt_idx" ON "agentVersion" USING btree ("agentId","createdAt"); +--> statement-breakpoint +CREATE INDEX "agentVersion_status_createdAt_idx" ON "agentVersion" USING btree ("status","createdAt"); +--> statement-breakpoint +CREATE INDEX "apikey_referenceId_idx" ON "apikey" USING btree ("referenceId"); +--> statement-breakpoint +CREATE INDEX "apikey_configId_idx" ON "apikey" USING btree ("configId"); +--> statement-breakpoint +CREATE INDEX "apikey_key_idx" ON "apikey" USING btree ("key"); +--> statement-breakpoint +CREATE UNIQUE INDEX "appSetting_trackingSiteId_key" ON "appSetting" USING btree ("trackingSiteId"); +--> statement-breakpoint +CREATE UNIQUE INDEX "calendarAttendee_eventId_email_key" ON "calendarAttendee" USING btree ("eventId","email"); +--> statement-breakpoint +CREATE INDEX "calendarAttendee_contactId_idx" ON "calendarAttendee" USING btree ("contactId"); +--> statement-breakpoint +CREATE UNIQUE INDEX "calendarEvent_iCalUid_originalStartTime_key" ON "calendarEvent" USING btree ("iCalUid","originalStartTime"); +--> statement-breakpoint +CREATE INDEX "calendarEvent_companyId_startsAt_idx" ON "calendarEvent" USING btree ("companyId","startsAt"); +--> statement-breakpoint +CREATE INDEX "calendarEvent_contactId_startsAt_idx" ON "calendarEvent" USING btree ("contactId","startsAt"); +--> statement-breakpoint +CREATE UNIQUE INDEX "company_primaryContactId_key" ON "company" USING btree ("primaryContactId"); +--> statement-breakpoint +CREATE UNIQUE INDEX "company_domain_active_key" ON "company" USING btree ("domain") WHERE "archivedAt" IS NULL; +--> statement-breakpoint +CREATE INDEX "company_ownerId_idx" ON "company" USING btree ("ownerId"); +--> statement-breakpoint +CREATE INDEX "company_name_idx" ON "company" USING btree ("name"); +--> statement-breakpoint +CREATE INDEX "company_lastActivityAt_idx" ON "company" USING btree ("lastActivityAt"); +--> statement-breakpoint +CREATE INDEX "company_archivedAt_idx" ON "company" USING btree ("archivedAt"); +--> statement-breakpoint +CREATE UNIQUE INDEX "contact_email_active_key" ON "contact" USING btree ("email") WHERE "archivedAt" IS NULL; +--> statement-breakpoint +CREATE INDEX "contact_companyId_idx" ON "contact" USING btree ("companyId"); +--> statement-breakpoint +CREATE INDEX "contact_ownerId_idx" ON "contact" USING btree ("ownerId"); +--> statement-breakpoint +CREATE INDEX "contact_lastActivityAt_idx" ON "contact" USING btree ("lastActivityAt"); +--> statement-breakpoint +CREATE INDEX "contact_archivedAt_idx" ON "contact" USING btree ("archivedAt"); +--> statement-breakpoint +CREATE INDEX "contactFact_contactId_field_status_idx" ON "contactFact" USING btree ("contactId","field","status"); +--> statement-breakpoint +CREATE INDEX "contactFact_status_observedAt_idx" ON "contactFact" USING btree ("status","observedAt"); +--> statement-breakpoint +CREATE INDEX "deal_companyId_idx" ON "deal" USING btree ("companyId"); +--> statement-breakpoint +CREATE INDEX "deal_ownerId_idx" ON "deal" USING btree ("ownerId"); +--> statement-breakpoint +CREATE INDEX "deal_stage_idx" ON "deal" USING btree ("stage"); +--> statement-breakpoint +CREATE INDEX "deal_expectedCloseDate_idx" ON "deal" USING btree ("expectedCloseDate"); +--> statement-breakpoint +CREATE INDEX "deal_lastActivityAt_idx" ON "deal" USING btree ("lastActivityAt"); +--> statement-breakpoint +CREATE INDEX "deal_baseAmount_idx" ON "deal" USING btree ("baseAmount"); +--> statement-breakpoint +CREATE INDEX "deal_currency_idx" ON "deal" USING btree ("currency"); +--> statement-breakpoint +CREATE INDEX "deal_archivedAt_idx" ON "deal" USING btree ("archivedAt"); +--> statement-breakpoint +CREATE INDEX "dealContact_contactId_idx" ON "dealContact" USING btree ("contactId"); +--> statement-breakpoint +CREATE UNIQUE INDEX "emailMessage_rfcMessageId_key" ON "emailMessage" USING btree ("rfcMessageId"); +--> statement-breakpoint +CREATE INDEX "emailMessage_threadId_sentAt_idx" ON "emailMessage" USING btree ("threadId","sentAt"); +--> statement-breakpoint +CREATE UNIQUE INDEX "emailThread_rootMessageId_key" ON "emailThread" USING btree ("rootMessageId"); +--> statement-breakpoint +CREATE INDEX "emailThread_companyId_lastMessageAt_idx" ON "emailThread" USING btree ("companyId","lastMessageAt"); +--> statement-breakpoint +CREATE INDEX "emailThread_contactId_lastMessageAt_idx" ON "emailThread" USING btree ("contactId","lastMessageAt"); +--> statement-breakpoint +CREATE UNIQUE INDEX "exchangeRate_baseCurrency_quoteCurrency_source_key" ON "exchangeRate" USING btree ("baseCurrency","quoteCurrency","source"); +--> statement-breakpoint +CREATE INDEX "exchangeRate_baseCurrency_quoteCurrency_idx" ON "exchangeRate" USING btree ("baseCurrency","quoteCurrency"); +--> statement-breakpoint +CREATE UNIQUE INDEX "fieldDefinition_entity_key_key" ON "fieldDefinition" USING btree ("entity","key"); +--> statement-breakpoint +CREATE INDEX "fieldDefinition_entity_position_idx" ON "fieldDefinition" USING btree ("entity","position"); +--> statement-breakpoint +CREATE INDEX "fieldOption_fieldId_position_idx" ON "fieldOption" USING btree ("fieldId","position"); +--> statement-breakpoint +CREATE UNIQUE INDEX "fieldValue_fieldId_companyId_key" ON "fieldValue" USING btree ("fieldId","companyId"); +--> statement-breakpoint +CREATE UNIQUE INDEX "fieldValue_fieldId_contactId_key" ON "fieldValue" USING btree ("fieldId","contactId"); +--> statement-breakpoint +CREATE UNIQUE INDEX "fieldValue_fieldId_dealId_key" ON "fieldValue" USING btree ("fieldId","dealId"); +--> statement-breakpoint +CREATE INDEX "fieldValue_fieldId_text_idx" ON "fieldValue" USING btree ("fieldId","text"); +--> statement-breakpoint +CREATE INDEX "fieldValue_fieldId_number_idx" ON "fieldValue" USING btree ("fieldId","number"); +--> statement-breakpoint +CREATE INDEX "fieldValue_fieldId_date_idx" ON "fieldValue" USING btree ("fieldId","date"); +--> statement-breakpoint +CREATE INDEX "fieldValue_companyId_idx" ON "fieldValue" USING btree ("companyId"); +--> statement-breakpoint +CREATE INDEX "fieldValue_contactId_idx" ON "fieldValue" USING btree ("contactId"); +--> statement-breakpoint +CREATE INDEX "fieldValue_dealId_idx" ON "fieldValue" USING btree ("dealId"); +--> statement-breakpoint +CREATE INDEX "fieldValue_optionId_idx" ON "fieldValue" USING btree ("optionId"); +--> statement-breakpoint +CREATE INDEX "fieldValue_userId_idx" ON "fieldValue" USING btree ("userId"); +--> statement-breakpoint +CREATE UNIQUE INDEX "formSubmission_dedupeKey_key" ON "formSubmission" USING btree ("dedupeKey"); +--> statement-breakpoint +CREATE INDEX "formSubmission_contactId_idx" ON "formSubmission" USING btree ("contactId"); +--> statement-breakpoint +CREATE INDEX "formSubmission_createdAt_idx" ON "formSubmission" USING btree ("createdAt"); +--> statement-breakpoint +CREATE UNIQUE INDEX "install_uuid_key" ON "install" USING btree ("uuid"); +--> statement-breakpoint +CREATE INDEX "invitation_organizationId_idx" ON "invitation" USING btree ("organizationId"); +--> statement-breakpoint +CREATE INDEX "invitation_email_idx" ON "invitation" USING btree ("email"); +--> statement-breakpoint +CREATE UNIQUE INDEX "mailboxSync_userId_source_key" ON "mailboxSync" USING btree ("userId","source"); +--> statement-breakpoint +CREATE INDEX "mailboxSync_status_idx" ON "mailboxSync" USING btree ("status"); +--> statement-breakpoint +CREATE UNIQUE INDEX "mcpClientRegistration_platformSlug_metadataFingerprint_key" ON "mcpClientRegistration" USING btree ("platformSlug","metadataFingerprint"); +--> statement-breakpoint +CREATE INDEX "mcpClientRegistration_clientSecretExpiresAt_idx" ON "mcpClientRegistration" USING btree ("clientSecretExpiresAt"); +--> statement-breakpoint +CREATE UNIQUE INDEX "mcpConnection_organizationId_platformSlug_key" ON "mcpConnection" USING btree ("organizationId","platformSlug"); +--> statement-breakpoint +CREATE INDEX "mcpConnection_status_updatedAt_idx" ON "mcpConnection" USING btree ("status","updatedAt"); +--> statement-breakpoint +CREATE UNIQUE INDEX "mcpOauthState_stateHash_key" ON "mcpOauthState" USING btree ("stateHash"); +--> statement-breakpoint +CREATE INDEX "mcpOauthState_expiresAt_consumedAt_idx" ON "mcpOauthState" USING btree ("expiresAt","consumedAt"); +--> statement-breakpoint +CREATE UNIQUE INDEX "mcpToolSnapshot_connectionId_name_fingerprint_key" ON "mcpToolSnapshot" USING btree ("connectionId","name","fingerprint"); +--> statement-breakpoint +CREATE INDEX "mcpToolSnapshot_connectionId_name_lastSeenAt_idx" ON "mcpToolSnapshot" USING btree ("connectionId","name","lastSeenAt"); +--> statement-breakpoint +CREATE INDEX "mcpToolSnapshot_reviewState_lastSeenAt_idx" ON "mcpToolSnapshot" USING btree ("reviewState","lastSeenAt"); +--> statement-breakpoint +CREATE UNIQUE INDEX "member_organizationId_userId_key" ON "member" USING btree ("organizationId","userId"); +--> statement-breakpoint +CREATE INDEX "member_organizationId_idx" ON "member" USING btree ("organizationId"); +--> statement-breakpoint +CREATE INDEX "member_userId_idx" ON "member" USING btree ("userId"); +--> statement-breakpoint +CREATE UNIQUE INDEX "organization_slug_key" ON "organization" USING btree ("slug"); +--> statement-breakpoint +CREATE UNIQUE INDEX "rateLimit_key_key" ON "rateLimit" USING btree ("key"); +--> statement-breakpoint +CREATE UNIQUE INDEX "savedView_entity_ownerId_name_key" ON "savedView" USING btree ("entity","ownerId","name"); +--> statement-breakpoint +CREATE INDEX "savedView_entity_shared_idx" ON "savedView" USING btree ("entity","shared"); +--> statement-breakpoint +CREATE UNIQUE INDEX "session_token_key" ON "session" USING btree ("token"); +--> statement-breakpoint +CREATE INDEX "session_userId_idx" ON "session" USING btree ("userId"); +--> statement-breakpoint +CREATE INDEX "slackChannel_available_name_idx" ON "slackChannel" USING btree ("available","name"); +--> statement-breakpoint +CREATE INDEX "slackChannel_updatedAt_idx" ON "slackChannel" USING btree ("updatedAt"); +--> statement-breakpoint +CREATE UNIQUE INDEX "slackMemberMatch_crmUserId_key" ON "slackMemberMatch" USING btree ("crmUserId"); +--> statement-breakpoint +CREATE INDEX "slackMemberMatch_slackUserId_idx" ON "slackMemberMatch" USING btree ("slackUserId"); +--> statement-breakpoint +CREATE UNIQUE INDEX "slackWorkspaceGrant_teamId_key" ON "slackWorkspaceGrant" USING btree ("teamId"); +--> statement-breakpoint +CREATE UNIQUE INDEX "ssoProvider_providerId_key" ON "ssoProvider" USING btree ("providerId"); +--> statement-breakpoint +CREATE UNIQUE INDEX "trackedDomain_host_key" ON "trackedDomain" USING btree ("host"); +--> statement-breakpoint +CREATE INDEX "trackedEvent_visitorId_occurredAt_idx" ON "trackedEvent" USING btree ("visitorId","occurredAt"); +--> statement-breakpoint +CREATE INDEX "trackedEvent_occurredAt_idx" ON "trackedEvent" USING btree ("occurredAt"); +--> statement-breakpoint +CREATE INDEX "trackedEvent_host_occurredAt_idx" ON "trackedEvent" USING btree ("host","occurredAt"); +--> statement-breakpoint +CREATE INDEX "trackedEvent_source_occurredAt_idx" ON "trackedEvent" USING btree ("source","occurredAt"); +--> statement-breakpoint +CREATE INDEX "trackedPageDaily_host_day_idx" ON "trackedPageDaily" USING btree ("host","day"); +--> statement-breakpoint +CREATE INDEX "trackedVisitor_contactId_idx" ON "trackedVisitor" USING btree ("contactId"); +--> statement-breakpoint +CREATE INDEX "trackedVisitor_firstSource_idx" ON "trackedVisitor" USING btree ("firstSource"); +--> statement-breakpoint +CREATE INDEX "trackingCounter_expiresAt_idx" ON "trackingCounter" USING btree ("expiresAt"); +--> statement-breakpoint +CREATE UNIQUE INDEX "user_email_key" ON "user" USING btree ("email"); +--> statement-breakpoint +CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier"); +--> statement-breakpoint +ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "activity" ADD CONSTRAINT "activity_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "public"."company"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "activity" ADD CONSTRAINT "activity_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "public"."contact"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "activity" ADD CONSTRAINT "activity_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "public"."deal"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "activity" ADD CONSTRAINT "activity_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "activity" ADD CONSTRAINT "activity_emailThreadId_fkey" FOREIGN KEY ("emailThreadId") REFERENCES "public"."emailThread"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "activity" ADD CONSTRAINT "activity_calendarEventId_fkey" FOREIGN KEY ("calendarEventId") REFERENCES "public"."calendarEvent"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentAction" ADD CONSTRAINT "agentAction_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "public"."agentDefinition"("id") ON DELETE restrict ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentAction" ADD CONSTRAINT "agentAction_runId_agentId_fkey" FOREIGN KEY ("runId","agentId") REFERENCES "public"."agentRun"("id","agentId") ON DELETE restrict ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentAuditEvent" ADD CONSTRAINT "agentAuditEvent_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "public"."agentDefinition"("id") ON DELETE restrict ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentAuditEvent" ADD CONSTRAINT "agentAuditEvent_versionId_agentId_fkey" FOREIGN KEY ("versionId","agentId") REFERENCES "public"."agentVersion"("id","agentId") ON DELETE restrict ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentAuditEvent" ADD CONSTRAINT "agentAuditEvent_actorUserId_fkey" FOREIGN KEY ("actorUserId") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentBuilderArtifact" ADD CONSTRAINT "agentBuilderArtifact_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "public"."agentConversation"("id") ON DELETE set null ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentBuilderArtifact" ADD CONSTRAINT "agentBuilderArtifact_versionId_fkey" FOREIGN KEY ("versionId") REFERENCES "public"."agentVersion"("id") ON DELETE set null ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentConversation" ADD CONSTRAINT "agentConversation_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "public"."contact"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentConversation" ADD CONSTRAINT "agentConversation_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "public"."company"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentConversation" ADD CONSTRAINT "agentConversation_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "public"."deal"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentConversation" ADD CONSTRAINT "agentConversation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentConversation" ADD CONSTRAINT "agentConversation_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "public"."agentDefinition"("id") ON DELETE set null ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentConversationAttachment" ADD CONSTRAINT "agentConversationAttachment_submissionId_fkey" FOREIGN KEY ("submissionId") REFERENCES "public"."agentConversationSubmission"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentConversationFeedback" ADD CONSTRAINT "agentConversationFeedback_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "public"."agentConversation"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentConversationFeedback" ADD CONSTRAINT "agentConversationFeedback_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentConversationShare" ADD CONSTRAINT "agentConversationShare_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "public"."agentConversation"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentConversationShare" ADD CONSTRAINT "agentConversationShare_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentConversationSubmission" ADD CONSTRAINT "agentConversationSubmission_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "public"."agentConversation"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentConversationSubmission" ADD CONSTRAINT "agentConversationSubmission_submittedById_fkey" FOREIGN KEY ("submittedById") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentDefinition" ADD CONSTRAINT "agentDefinition_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentDefinition" ADD CONSTRAINT "agentDefinition_currentVersionId_id_fkey" FOREIGN KEY ("currentVersionId","id") REFERENCES "public"."agentVersion"("id","agentId") ON DELETE restrict ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentEvent" ADD CONSTRAINT "agentEvent_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "public"."agentConversation"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentRun" ADD CONSTRAINT "agentRun_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "public"."agentDefinition"("id") ON DELETE restrict ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentRun" ADD CONSTRAINT "agentRun_versionId_agentId_fkey" FOREIGN KEY ("versionId","agentId") REFERENCES "public"."agentVersion"("id","agentId") ON DELETE restrict ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentRun" ADD CONSTRAINT "agentRun_triggerId_agentId_fkey" FOREIGN KEY ("triggerId","agentId") REFERENCES "public"."agentTrigger"("id","agentId") ON DELETE restrict ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentRun" ADD CONSTRAINT "agentRun_initiatedById_fkey" FOREIGN KEY ("initiatedById") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentRunEvent" ADD CONSTRAINT "agentRunEvent_runId_fkey" FOREIGN KEY ("runId") REFERENCES "public"."agentRun"("id") ON DELETE restrict ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentTrigger" ADD CONSTRAINT "agentTrigger_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "public"."agentDefinition"("id") ON DELETE restrict ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentTrigger" ADD CONSTRAINT "agentTrigger_versionId_agentId_fkey" FOREIGN KEY ("versionId","agentId") REFERENCES "public"."agentVersion"("id","agentId") ON DELETE restrict ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentTrigger" ADD CONSTRAINT "agentTrigger_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentVersion" ADD CONSTRAINT "agentVersion_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "public"."agentDefinition"("id") ON DELETE restrict ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentVersion" ADD CONSTRAINT "agentVersion_sourceConversationId_fkey" FOREIGN KEY ("sourceConversationId") REFERENCES "public"."agentConversation"("id") ON DELETE set null ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "agentVersion" ADD CONSTRAINT "agentVersion_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "apikey" ADD CONSTRAINT "apikey_referenceId_fkey" FOREIGN KEY ("referenceId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "calendarAttendee" ADD CONSTRAINT "calendarAttendee_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "public"."calendarEvent"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "calendarAttendee" ADD CONSTRAINT "calendarAttendee_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "public"."contact"("id") ON DELETE set null ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "calendarEvent" ADD CONSTRAINT "calendarEvent_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "public"."company"("id") ON DELETE set null ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "calendarEvent" ADD CONSTRAINT "calendarEvent_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "public"."contact"("id") ON DELETE set null ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "company" ADD CONSTRAINT "company_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "company" ADD CONSTRAINT "company_primaryContactId_fkey" FOREIGN KEY ("primaryContactId") REFERENCES "public"."contact"("id") ON DELETE set null ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "companyEnrichment" ADD CONSTRAINT "companyEnrichment_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "public"."company"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "contact" ADD CONSTRAINT "contact_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "public"."company"("id") ON DELETE set null ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "contact" ADD CONSTRAINT "contact_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "contactBrief" ADD CONSTRAINT "contactBrief_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "public"."contact"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "contactFact" ADD CONSTRAINT "contactFact_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "public"."contact"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "contactFact" ADD CONSTRAINT "contactFact_decidedById_fkey" FOREIGN KEY ("decidedById") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "deal" ADD CONSTRAINT "deal_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "public"."company"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "deal" ADD CONSTRAINT "deal_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "dealContact" ADD CONSTRAINT "dealContact_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "public"."deal"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "dealContact" ADD CONSTRAINT "dealContact_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "public"."contact"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "emailMessage" ADD CONSTRAINT "emailMessage_threadId_fkey" FOREIGN KEY ("threadId") REFERENCES "public"."emailThread"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "emailThread" ADD CONSTRAINT "emailThread_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "public"."company"("id") ON DELETE set null ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "emailThread" ADD CONSTRAINT "emailThread_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "public"."contact"("id") ON DELETE set null ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "fieldOption" ADD CONSTRAINT "fieldOption_fieldId_fkey" FOREIGN KEY ("fieldId") REFERENCES "public"."fieldDefinition"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "fieldValue" ADD CONSTRAINT "fieldValue_fieldId_fkey" FOREIGN KEY ("fieldId") REFERENCES "public"."fieldDefinition"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "fieldValue" ADD CONSTRAINT "fieldValue_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "public"."company"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "fieldValue" ADD CONSTRAINT "fieldValue_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "public"."contact"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "fieldValue" ADD CONSTRAINT "fieldValue_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "public"."deal"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "fieldValue" ADD CONSTRAINT "fieldValue_optionId_fkey" FOREIGN KEY ("optionId") REFERENCES "public"."fieldOption"("id") ON DELETE set null ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "fieldValue" ADD CONSTRAINT "fieldValue_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "formSubmission" ADD CONSTRAINT "formSubmission_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "public"."contact"("id") ON DELETE set null ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "invitation" ADD CONSTRAINT "invitation_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "invitation" ADD CONSTRAINT "invitation_inviterId_fkey" FOREIGN KEY ("inviterId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "mailboxSync" ADD CONSTRAINT "mailboxSync_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "mcpConnection" ADD CONSTRAINT "mcpConnection_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "mcpConnection" ADD CONSTRAINT "mcpConnection_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "public"."mcpClientRegistration"("id") ON DELETE restrict ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "mcpConnection" ADD CONSTRAINT "mcpConnection_connectedById_fkey" FOREIGN KEY ("connectedById") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "mcpOauthState" ADD CONSTRAINT "mcpOauthState_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "mcpOauthState" ADD CONSTRAINT "mcpOauthState_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "mcpOauthState" ADD CONSTRAINT "mcpOauthState_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "public"."mcpClientRegistration"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "mcpToolSnapshot" ADD CONSTRAINT "mcpToolSnapshot_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "public"."mcpConnection"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "member" ADD CONSTRAINT "member_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "member" ADD CONSTRAINT "member_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "savedView" ADD CONSTRAINT "savedView_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "slackMemberMatch" ADD CONSTRAINT "slackMemberMatch_crmUserId_fkey" FOREIGN KEY ("crmUserId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "ssoProvider" ADD CONSTRAINT "ssoProvider_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE cascade; +--> statement-breakpoint +ALTER TABLE "trackedVisitor" ADD CONSTRAINT "trackedVisitor_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "public"."contact"("id") ON DELETE set null ON UPDATE cascade; +--> statement-breakpoint +INSERT INTO "install" ("id", "uuid", "version", "updatedAt") VALUES ('install', gen_random_uuid()::text, 'unknown', CURRENT_TIMESTAMP) ON CONFLICT ("id") DO NOTHING; diff --git a/packages/db/drizzle/legacy-prisma-migrations.txt b/packages/db/drizzle/legacy-prisma-migrations.txt new file mode 100644 index 000000000..8823361b0 --- /dev/null +++ b/packages/db/drizzle/legacy-prisma-migrations.txt @@ -0,0 +1,62 @@ +20260731150000_init +20260731160000_remove_organizations +20260731164536_add_crm_models +20260731200000_add_google_sync +20260731210000_forward_only_sync +20260731220000_denormalise_last_activity +20260731230000_logo_dark_mode +20260801000000_contact_avatar +20260801120000_contact_summary_and_socials +20260801130000_contact_socials_checked +20260801140000_contact_intelligence +20260801160000_agent_conversations +20260801170000_conversations_on_deals +20260801180000_task_timeline_position +20260801190000_agent_task_attempts +20260802120000_app_settings +20260803151440_add_workspace_organization +20260803160044_add_sso_provider +20260803162518_workspace_profile +20260803190000_workspace_onboarded_metadata +20260803200000_blank_website_is_not_onboarded +20260803210000_agent_builder_foundation +20260803210000_suppressed_contact +20260803220000_context_dev_api_key +20260804160000_agent_conversation_command_type +20260804210000_agent_builder_artifacts +20260805120000_deal_description +20260805130000_builder_submission_concurrency +20260805130000_multi_currency +20260805140000_single_active_conversation_share +20260805150000_deal_base_currency +20260805160000_telemetry_install +20260805220000_conversation_attachments +20260806140000_dynamic_fields +20260806150000_field_value_record_indexes_and_user_fk +20260806160000_agent_version_model_context_window +20260807120000_outlook_mail +20260809120000_slack_member_matches +20260810090000_agent_conversation_pending_input +20260810100000_agent_task_deal_events +20260810110000_agent_event_conversation +20260810111000_canonical_builder_tokens +20260810120000_plural_agent_triggers +20260810120000_website_tracking +20260810130000_drop_unused_slack_unmatched +20260810130000_tracking_hardening +20260810140000_source_attribution +20260811050330_slack_workspace_grant +20260811212311_slack_install_cancel_delivery_task_subject +20260820150251_archive_crm_records +20260820150700_list_building_fields +20260820152910_add_api_keys +20260820154345_saved_views +20260820154825_field_show_on_filter +20260820160000_backfill_seniority_function +20260820161500_archive_scoped_uniqueness +20261020191459_mcp_integrations +20261020191833_link_mcp_registration +20261020221500_agent_event_stream_index +20261020224500_agent_conversation_permission_mode +20261020225500_encrypt_builder_chat_events +20261022153000_plural_builder_input_requests diff --git a/packages/db/drizzle/meta/0000_snapshot.json b/packages/db/drizzle/meta/0000_snapshot.json new file mode 100644 index 000000000..d3b5e6beb --- /dev/null +++ b/packages/db/drizzle/meta/0000_snapshot.json @@ -0,0 +1,9244 @@ +{ + "id": "3afbe014-cf9f-4add-97b9-134942f64acf", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_userId_fkey": { + "name": "account_userId_fkey", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.activity": { + "name": "activity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "ActivityType", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurredAt": { + "name": "occurredAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "dueAt": { + "name": "dueAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "companyId": { + "name": "companyId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contactId": { + "name": "contactId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dealId": { + "name": "dealId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdById": { + "name": "createdById", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "emailThreadId": { + "name": "emailThreadId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "calendarEventId": { + "name": "calendarEventId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "activity_emailThreadId_key": { + "name": "activity_emailThreadId_key", + "columns": [ + { + "expression": "emailThreadId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_calendarEventId_key": { + "name": "activity_calendarEventId_key", + "columns": [ + { + "expression": "calendarEventId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_companyId_createdAt_idx": { + "name": "activity_companyId_createdAt_idx", + "columns": [ + { + "expression": "companyId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_dealId_createdAt_idx": { + "name": "activity_dealId_createdAt_idx", + "columns": [ + { + "expression": "dealId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_contactId_createdAt_idx": { + "name": "activity_contactId_createdAt_idx", + "columns": [ + { + "expression": "contactId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_dueAt_idx": { + "name": "activity_dueAt_idx", + "columns": [ + { + "expression": "dueAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_createdById_idx": { + "name": "activity_createdById_idx", + "columns": [ + { + "expression": "createdById", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_companyId_fkey": { + "name": "activity_companyId_fkey", + "tableFrom": "activity", + "tableTo": "company", + "columnsFrom": ["companyId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "activity_contactId_fkey": { + "name": "activity_contactId_fkey", + "tableFrom": "activity", + "tableTo": "contact", + "columnsFrom": ["contactId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "activity_dealId_fkey": { + "name": "activity_dealId_fkey", + "tableFrom": "activity", + "tableTo": "deal", + "columnsFrom": ["dealId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "activity_createdById_fkey": { + "name": "activity_createdById_fkey", + "tableFrom": "activity", + "tableTo": "user", + "columnsFrom": ["createdById"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "activity_emailThreadId_fkey": { + "name": "activity_emailThreadId_fkey", + "tableFrom": "activity", + "tableTo": "emailThread", + "columnsFrom": ["emailThreadId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "activity_calendarEventId_fkey": { + "name": "activity_calendarEventId_fkey", + "tableFrom": "activity", + "tableTo": "calendarEvent", + "columnsFrom": ["calendarEventId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agentAction": { + "name": "agentAction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agentId": { + "name": "agentId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runId": { + "name": "runId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "targetType": { + "name": "targetType", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "targetId": { + "name": "targetId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "targetLabel": { + "name": "targetLabel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "AgentActionStatus", + "primaryKey": false, + "notNull": true, + "default": "'PLANNED'" + }, + "idempotencyKey": { + "name": "idempotencyKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requestHash": { + "name": "requestHash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "externalId": { + "name": "externalId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attemptCount": { + "name": "attemptCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "errorCode": { + "name": "errorCode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plannedAt": { + "name": "plannedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "startedAt": { + "name": "startedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "agentAction_idempotencyKey_key": { + "name": "agentAction_idempotencyKey_key", + "columns": [ + { + "expression": "idempotencyKey", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentAction_agentId_plannedAt_idx": { + "name": "agentAction_agentId_plannedAt_idx", + "columns": [ + { + "expression": "agentId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plannedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentAction_runId_plannedAt_idx": { + "name": "agentAction_runId_plannedAt_idx", + "columns": [ + { + "expression": "runId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plannedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentAction_provider_externalId_idx": { + "name": "agentAction_provider_externalId_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "externalId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentAction_status_plannedAt_idx": { + "name": "agentAction_status_plannedAt_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plannedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agentAction_agentId_fkey": { + "name": "agentAction_agentId_fkey", + "tableFrom": "agentAction", + "tableTo": "agentDefinition", + "columnsFrom": ["agentId"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "agentAction_runId_agentId_fkey": { + "name": "agentAction_runId_agentId_fkey", + "tableFrom": "agentAction", + "tableTo": "agentRun", + "columnsFrom": ["runId", "agentId"], + "columnsTo": ["id", "agentId"], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agentAuditEvent": { + "name": "agentAuditEvent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agentId": { + "name": "agentId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actorUserId": { + "name": "actorUserId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actorType": { + "name": "actorType", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actorId": { + "name": "actorId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "before": { + "name": "before", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after": { + "name": "after", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "requestId": { + "name": "requestId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emittedAt": { + "name": "emittedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "agentAuditEvent_agentId_type_requestId_key": { + "name": "agentAuditEvent_agentId_type_requestId_key", + "columns": [ + { + "expression": "agentId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requestId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentAuditEvent_agentId_emittedAt_idx": { + "name": "agentAuditEvent_agentId_emittedAt_idx", + "columns": [ + { + "expression": "agentId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "emittedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentAuditEvent_versionId_emittedAt_idx": { + "name": "agentAuditEvent_versionId_emittedAt_idx", + "columns": [ + { + "expression": "versionId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "emittedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentAuditEvent_actorUserId_emittedAt_idx": { + "name": "agentAuditEvent_actorUserId_emittedAt_idx", + "columns": [ + { + "expression": "actorUserId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "emittedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentAuditEvent_type_emittedAt_idx": { + "name": "agentAuditEvent_type_emittedAt_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "emittedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agentAuditEvent_agentId_fkey": { + "name": "agentAuditEvent_agentId_fkey", + "tableFrom": "agentAuditEvent", + "tableTo": "agentDefinition", + "columnsFrom": ["agentId"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "agentAuditEvent_versionId_agentId_fkey": { + "name": "agentAuditEvent_versionId_agentId_fkey", + "tableFrom": "agentAuditEvent", + "tableTo": "agentVersion", + "columnsFrom": ["versionId", "agentId"], + "columnsTo": ["id", "agentId"], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "agentAuditEvent_actorUserId_fkey": { + "name": "agentAuditEvent_actorUserId_fkey", + "tableFrom": "agentAuditEvent", + "tableTo": "user", + "columnsFrom": ["actorUserId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agentBuilderArtifact": { + "name": "agentBuilderArtifact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversationId": { + "name": "conversationId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "previousContent": { + "name": "previousContent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "AgentBuilderArtifactStatus", + "primaryKey": false, + "notNull": true, + "default": "'WRITING'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "agentBuilderArtifact_conversation_path_revision_key": { + "name": "agentBuilderArtifact_conversation_path_revision_key", + "columns": [ + { + "expression": "conversationId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revision", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"conversationId\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentBuilderArtifact_version_path_revision_key": { + "name": "agentBuilderArtifact_version_path_revision_key", + "columns": [ + { + "expression": "versionId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revision", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"versionId\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentBuilderArtifact_conversationId_createdAt_idx": { + "name": "agentBuilderArtifact_conversationId_createdAt_idx", + "columns": [ + { + "expression": "conversationId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentBuilderArtifact_versionId_path_idx": { + "name": "agentBuilderArtifact_versionId_path_idx", + "columns": [ + { + "expression": "versionId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agentBuilderArtifact_conversationId_fkey": { + "name": "agentBuilderArtifact_conversationId_fkey", + "tableFrom": "agentBuilderArtifact", + "tableTo": "agentConversation", + "columnsFrom": ["conversationId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "agentBuilderArtifact_versionId_fkey": { + "name": "agentBuilderArtifact_versionId_fkey", + "tableFrom": "agentBuilderArtifact", + "tableTo": "agentVersion", + "columnsFrom": ["versionId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agentBuilderArtifact_owner_present": { + "name": "agentBuilderArtifact_owner_present", + "value": "\"agentBuilderArtifact\".\"conversationId\" IS NOT NULL OR \"agentBuilderArtifact\".\"versionId\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.agentConversation": { + "name": "agentConversation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "AgentConversationKind", + "primaryKey": false, + "notNull": true, + "default": "'RECORD'" + }, + "contactId": { + "name": "contactId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "companyId": { + "name": "companyId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dealId": { + "name": "dealId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agentId": { + "name": "agentId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sessionId": { + "name": "sessionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "continuationToken": { + "name": "continuationToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "streamIndex": { + "name": "streamIndex", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "pendingInputRequests": { + "name": "pendingInputRequests", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "permissionMode": { + "name": "permissionMode", + "type": "AgentConversationPermissionMode", + "primaryKey": false, + "notNull": true, + "default": "'MANUAL'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "messageCount": { + "name": "messageCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "lastMessageAt": { + "name": "lastMessageAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "lastAssistantAt": { + "name": "lastAssistantAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "lastReadAt": { + "name": "lastReadAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agentConversation_sessionId_key": { + "name": "agentConversation_sessionId_key", + "columns": [ + { + "expression": "sessionId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentConversation_contactId_lastMessageAt_idx": { + "name": "agentConversation_contactId_lastMessageAt_idx", + "columns": [ + { + "expression": "contactId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lastMessageAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentConversation_companyId_lastMessageAt_idx": { + "name": "agentConversation_companyId_lastMessageAt_idx", + "columns": [ + { + "expression": "companyId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lastMessageAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentConversation_dealId_lastMessageAt_idx": { + "name": "agentConversation_dealId_lastMessageAt_idx", + "columns": [ + { + "expression": "dealId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lastMessageAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentConversation_userId_kind_lastMessageAt_idx": { + "name": "agentConversation_userId_kind_lastMessageAt_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lastMessageAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentConversation_agentId_lastMessageAt_idx": { + "name": "agentConversation_agentId_lastMessageAt_idx", + "columns": [ + { + "expression": "agentId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lastMessageAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agentConversation_contactId_fkey": { + "name": "agentConversation_contactId_fkey", + "tableFrom": "agentConversation", + "tableTo": "contact", + "columnsFrom": ["contactId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "agentConversation_companyId_fkey": { + "name": "agentConversation_companyId_fkey", + "tableFrom": "agentConversation", + "tableTo": "company", + "columnsFrom": ["companyId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "agentConversation_dealId_fkey": { + "name": "agentConversation_dealId_fkey", + "tableFrom": "agentConversation", + "tableTo": "deal", + "columnsFrom": ["dealId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "agentConversation_userId_fkey": { + "name": "agentConversation_userId_fkey", + "tableFrom": "agentConversation", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "agentConversation_agentId_fkey": { + "name": "agentConversation_agentId_fkey", + "tableFrom": "agentConversation", + "tableTo": "agentDefinition", + "columnsFrom": ["agentId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agentConversationAttachment": { + "name": "agentConversationAttachment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "submissionId": { + "name": "submissionId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mediaType": { + "name": "mediaType", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "agentConversationAttachment_submissionId_position_idx": { + "name": "agentConversationAttachment_submissionId_position_idx", + "columns": [ + { + "expression": "submissionId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agentConversationAttachment_submissionId_fkey": { + "name": "agentConversationAttachment_submissionId_fkey", + "tableFrom": "agentConversationAttachment", + "tableTo": "agentConversationSubmission", + "columnsFrom": ["submissionId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agentConversationFeedback": { + "name": "agentConversationFeedback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversationId": { + "name": "conversationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "messageId": { + "name": "messageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "AgentResponseRating", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "agentConversationFeedback_conversationId_userId_messageId_key": { + "name": "agentConversationFeedback_conversationId_userId_messageId_key", + "columns": [ + { + "expression": "conversationId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "messageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentConversationFeedback_conversationId_createdAt_idx": { + "name": "agentConversationFeedback_conversationId_createdAt_idx", + "columns": [ + { + "expression": "conversationId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agentConversationFeedback_conversationId_fkey": { + "name": "agentConversationFeedback_conversationId_fkey", + "tableFrom": "agentConversationFeedback", + "tableTo": "agentConversation", + "columnsFrom": ["conversationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "agentConversationFeedback_userId_fkey": { + "name": "agentConversationFeedback_userId_fkey", + "tableFrom": "agentConversationFeedback", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agentConversationShare": { + "name": "agentConversationShare", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversationId": { + "name": "conversationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdById": { + "name": "createdById", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "AgentConversationShareScope", + "primaryKey": false, + "notNull": true, + "default": "'WORKSPACE_LINK'" + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agentConversationShare_tokenHash_key": { + "name": "agentConversationShare_tokenHash_key", + "columns": [ + { + "expression": "tokenHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentConversationShare_one_active_per_conversation": { + "name": "agentConversationShare_one_active_per_conversation", + "columns": [ + { + "expression": "conversationId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"revokedAt\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentConversationShare_conversationId_revokedAt_idx": { + "name": "agentConversationShare_conversationId_revokedAt_idx", + "columns": [ + { + "expression": "conversationId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revokedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agentConversationShare_conversationId_fkey": { + "name": "agentConversationShare_conversationId_fkey", + "tableFrom": "agentConversationShare", + "tableTo": "agentConversation", + "columnsFrom": ["conversationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "agentConversationShare_createdById_fkey": { + "name": "agentConversationShare_createdById_fkey", + "tableFrom": "agentConversationShare", + "tableTo": "user", + "columnsFrom": ["createdById"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agentConversationSubmission": { + "name": "agentConversationSubmission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversationId": { + "name": "conversationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "submittedById": { + "name": "submittedById", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "clientRequestId": { + "name": "clientRequestId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inputRequestId": { + "name": "inputRequestId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commandType": { + "name": "commandType", + "type": "AgentConversationCommandType", + "primaryKey": false, + "notNull": true, + "default": "'CHAT'" + }, + "message": { + "name": "message", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "AgentConversationSubmissionStatus", + "primaryKey": false, + "notNull": true, + "default": "'PENDING'" + }, + "attemptCount": { + "name": "attemptCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "errorCode": { + "name": "errorCode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "sentAt": { + "name": "sentAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "acceptedAt": { + "name": "acceptedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agentConversationSubmission_clientRequestId_key": { + "name": "agentConversationSubmission_clientRequestId_key", + "columns": [ + { + "expression": "clientRequestId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentConversationSubmission_conversationId_inputRequestId_key": { + "name": "agentConversationSubmission_conversationId_inputRequestId_key", + "columns": [ + { + "expression": "conversationId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "inputRequestId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentConversationSubmission_conversationId_createdAt_idx": { + "name": "agentConversationSubmission_conversationId_createdAt_idx", + "columns": [ + { + "expression": "conversationId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentConversationSubmission_status_createdAt_idx": { + "name": "agentConversationSubmission_status_createdAt_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agentConversationSubmission_conversationId_fkey": { + "name": "agentConversationSubmission_conversationId_fkey", + "tableFrom": "agentConversationSubmission", + "tableTo": "agentConversation", + "columnsFrom": ["conversationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "agentConversationSubmission_submittedById_fkey": { + "name": "agentConversationSubmission_submittedById_fkey", + "tableFrom": "agentConversationSubmission", + "tableTo": "user", + "columnsFrom": ["submittedById"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agentDefinition": { + "name": "agentDefinition", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "AgentDefinitionStatus", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "createdById": { + "name": "createdById", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currentVersionId": { + "name": "currentVersionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "archivedAt": { + "name": "archivedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agentDefinition_currentVersionId_key": { + "name": "agentDefinition_currentVersionId_key", + "columns": [ + { + "expression": "currentVersionId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentDefinition_currentVersionId_id_key": { + "name": "agentDefinition_currentVersionId_id_key", + "columns": [ + { + "expression": "currentVersionId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentDefinition_status_updatedAt_idx": { + "name": "agentDefinition_status_updatedAt_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentDefinition_createdById_createdAt_idx": { + "name": "agentDefinition_createdById_createdAt_idx", + "columns": [ + { + "expression": "createdById", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agentDefinition_createdById_fkey": { + "name": "agentDefinition_createdById_fkey", + "tableFrom": "agentDefinition", + "tableTo": "user", + "columnsFrom": ["createdById"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "agentDefinition_currentVersionId_id_fkey": { + "name": "agentDefinition_currentVersionId_id_fkey", + "tableFrom": "agentDefinition", + "tableTo": "agentVersion", + "columnsFrom": ["currentVersionId", "id"], + "columnsTo": ["id", "agentId"], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agentEvent": { + "name": "agentEvent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sessionId": { + "name": "sessionId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "streamIndex": { + "name": "streamIndex", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deliveryIds": { + "name": "deliveryIds", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "contactId": { + "name": "contactId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conversationId": { + "name": "conversationId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "encryptedData": { + "name": "encryptedData", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emittedAt": { + "name": "emittedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "agentEvent_sessionId_emittedAt_idx": { + "name": "agentEvent_sessionId_emittedAt_idx", + "columns": [ + { + "expression": "sessionId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "emittedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentEvent_sessionId_streamIndex_key": { + "name": "agentEvent_sessionId_streamIndex_key", + "columns": [ + { + "expression": "sessionId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "streamIndex", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentEvent_contactId_emittedAt_idx": { + "name": "agentEvent_contactId_emittedAt_idx", + "columns": [ + { + "expression": "contactId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "emittedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentEvent_conversationId_emittedAt_idx": { + "name": "agentEvent_conversationId_emittedAt_idx", + "columns": [ + { + "expression": "conversationId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "emittedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agentEvent_conversationId_fkey": { + "name": "agentEvent_conversationId_fkey", + "tableFrom": "agentEvent", + "tableTo": "agentConversation", + "columnsFrom": ["conversationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agentRun": { + "name": "agentRun", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agentId": { + "name": "agentId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "triggerId": { + "name": "triggerId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initiatedById": { + "name": "initiatedById", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "triggerType": { + "name": "triggerType", + "type": "AgentTriggerType", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "AgentRunStatus", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "principalId": { + "name": "principalId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sessionId": { + "name": "sessionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotencyKey": { + "name": "idempotencyKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlationId": { + "name": "correlationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "modelId": { + "name": "modelId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputTokens": { + "name": "inputTokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "outputTokens": { + "name": "outputTokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "costUsd": { + "name": "costUsd", + "type": "numeric(12, 6)", + "primaryKey": false, + "notNull": false + }, + "errorCode": { + "name": "errorCode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nextEventSequence": { + "name": "nextEventSequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "startedAt": { + "name": "startedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "finishedAt": { + "name": "finishedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "cancelRequestedAt": { + "name": "cancelRequestedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "cancelDeliveredAt": { + "name": "cancelDeliveredAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agentRun_sessionId_key": { + "name": "agentRun_sessionId_key", + "columns": [ + { + "expression": "sessionId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentRun_idempotencyKey_key": { + "name": "agentRun_idempotencyKey_key", + "columns": [ + { + "expression": "idempotencyKey", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentRun_correlationId_key": { + "name": "agentRun_correlationId_key", + "columns": [ + { + "expression": "correlationId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentRun_id_agentId_key": { + "name": "agentRun_id_agentId_key", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agentId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentRun_agentId_createdAt_idx": { + "name": "agentRun_agentId_createdAt_idx", + "columns": [ + { + "expression": "agentId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentRun_versionId_createdAt_idx": { + "name": "agentRun_versionId_createdAt_idx", + "columns": [ + { + "expression": "versionId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentRun_status_createdAt_idx": { + "name": "agentRun_status_createdAt_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentRun_triggerId_createdAt_idx": { + "name": "agentRun_triggerId_createdAt_idx", + "columns": [ + { + "expression": "triggerId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agentRun_agentId_fkey": { + "name": "agentRun_agentId_fkey", + "tableFrom": "agentRun", + "tableTo": "agentDefinition", + "columnsFrom": ["agentId"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "agentRun_versionId_agentId_fkey": { + "name": "agentRun_versionId_agentId_fkey", + "tableFrom": "agentRun", + "tableTo": "agentVersion", + "columnsFrom": ["versionId", "agentId"], + "columnsTo": ["id", "agentId"], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "agentRun_triggerId_agentId_fkey": { + "name": "agentRun_triggerId_agentId_fkey", + "tableFrom": "agentRun", + "tableTo": "agentTrigger", + "columnsFrom": ["triggerId", "agentId"], + "columnsTo": ["id", "agentId"], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "agentRun_initiatedById_fkey": { + "name": "agentRun_initiatedById_fkey", + "tableFrom": "agentRun", + "tableTo": "user", + "columnsFrom": ["initiatedById"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agentRunEvent": { + "name": "agentRunEvent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "runId": { + "name": "runId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "emittedAt": { + "name": "emittedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "agentRunEvent_runId_sequence_key": { + "name": "agentRunEvent_runId_sequence_key", + "columns": [ + { + "expression": "runId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentRunEvent_runId_emittedAt_idx": { + "name": "agentRunEvent_runId_emittedAt_idx", + "columns": [ + { + "expression": "runId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "emittedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agentRunEvent_runId_fkey": { + "name": "agentRunEvent_runId_fkey", + "tableFrom": "agentRunEvent", + "tableTo": "agentRun", + "columnsFrom": ["runId"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agentTask": { + "name": "agentTask", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contactId": { + "name": "contactId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "companyId": { + "name": "companyId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dealId": { + "name": "dealId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "budget": { + "name": "budget", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 4 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dueAt": { + "name": "dueAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "leasedUntil": { + "name": "leasedUntil", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "sessionId": { + "name": "sessionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "startedAt": { + "name": "startedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "finishedAt": { + "name": "finishedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "agentTask_dueAt_leasedUntil_idx": { + "name": "agentTask_dueAt_leasedUntil_idx", + "columns": [ + { + "expression": "dueAt", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "leasedUntil", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentTask_contactId_idx": { + "name": "agentTask_contactId_idx", + "columns": [ + { + "expression": "contactId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentTask_dealId_idx": { + "name": "agentTask_dealId_idx", + "columns": [ + { + "expression": "dealId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentTask_kind_subject_idx": { + "name": "agentTask_kind_subject_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"finishedAt\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agentTrigger": { + "name": "agentTrigger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agentId": { + "name": "agentId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "AgentTriggerType", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdById": { + "name": "createdById", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "nextRunAt": { + "name": "nextRunAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "lastRunAt": { + "name": "lastRunAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "agentTrigger_id_agentId_key": { + "name": "agentTrigger_id_agentId_key", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agentId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentTrigger_agentId_enabled_idx": { + "name": "agentTrigger_agentId_enabled_idx", + "columns": [ + { + "expression": "agentId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentTrigger_enabled_nextRunAt_idx": { + "name": "agentTrigger_enabled_nextRunAt_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "nextRunAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentTrigger_versionId_idx": { + "name": "agentTrigger_versionId_idx", + "columns": [ + { + "expression": "versionId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agentTrigger_agentId_fkey": { + "name": "agentTrigger_agentId_fkey", + "tableFrom": "agentTrigger", + "tableTo": "agentDefinition", + "columnsFrom": ["agentId"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "agentTrigger_versionId_agentId_fkey": { + "name": "agentTrigger_versionId_agentId_fkey", + "tableFrom": "agentTrigger", + "tableTo": "agentVersion", + "columnsFrom": ["versionId", "agentId"], + "columnsTo": ["id", "agentId"], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "agentTrigger_createdById_fkey": { + "name": "agentTrigger_createdById_fkey", + "tableFrom": "agentTrigger", + "tableTo": "user", + "columnsFrom": ["createdById"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agentVersion": { + "name": "agentVersion", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agentId": { + "name": "agentId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "AgentVersionStatus", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manifest": { + "name": "manifest", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "modelId": { + "name": "modelId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "modelContextWindowTokens": { + "name": "modelContextWindowTokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1000000 + }, + "sandboxPolicy": { + "name": "sandboxPolicy", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "validation": { + "name": "validation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sourceConversationId": { + "name": "sourceConversationId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdById": { + "name": "createdById", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deploymentId": { + "name": "deploymentId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approvedAt": { + "name": "approvedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "deployedAt": { + "name": "deployedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "agentVersion_agentId_number_key": { + "name": "agentVersion_agentId_number_key", + "columns": [ + { + "expression": "agentId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentVersion_id_agentId_key": { + "name": "agentVersion_id_agentId_key", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agentId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentVersion_agentId_createdAt_idx": { + "name": "agentVersion_agentId_createdAt_idx", + "columns": [ + { + "expression": "agentId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentVersion_status_createdAt_idx": { + "name": "agentVersion_status_createdAt_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agentVersion_agentId_fkey": { + "name": "agentVersion_agentId_fkey", + "tableFrom": "agentVersion", + "tableTo": "agentDefinition", + "columnsFrom": ["agentId"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "agentVersion_sourceConversationId_fkey": { + "name": "agentVersion_sourceConversationId_fkey", + "tableFrom": "agentVersion", + "tableTo": "agentConversation", + "columnsFrom": ["sourceConversationId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "agentVersion_createdById_fkey": { + "name": "agentVersion_createdById_fkey", + "tableFrom": "agentVersion", + "tableTo": "user", + "columnsFrom": ["createdById"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "configId": { + "name": "configId", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referenceId": { + "name": "referenceId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refillInterval": { + "name": "refillInterval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refillAmount": { + "name": "refillAmount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lastRefillAt": { + "name": "lastRefillAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rateLimitEnabled": { + "name": "rateLimitEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rateLimitTimeWindow": { + "name": "rateLimitTimeWindow", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rateLimitMax": { + "name": "rateLimitMax", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "requestCount": { + "name": "requestCount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lastRequest": { + "name": "lastRequest", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "apikey_referenceId_idx": { + "name": "apikey_referenceId_idx", + "columns": [ + { + "expression": "referenceId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_configId_idx": { + "name": "apikey_configId_idx", + "columns": [ + { + "expression": "configId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "apikey_referenceId_fkey": { + "name": "apikey_referenceId_fkey", + "tableFrom": "apikey", + "tableTo": "user", + "columnsFrom": ["referenceId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appSetting": { + "name": "appSetting", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agentModelId": { + "name": "agentModelId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentModelContextWindow": { + "name": "agentModelContextWindow", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "contextDevApiKey": { + "name": "contextDevApiKey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reportingCurrency": { + "name": "reportingCurrency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ratesRefreshedAt": { + "name": "ratesRefreshedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "archiveRetentionDays": { + "name": "archiveRetentionDays", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 180 + }, + "trackingSiteId": { + "name": "trackingSiteId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trackingCrossDomain": { + "name": "trackingCrossDomain", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "trackingLimitToDomains": { + "name": "trackingLimitToDomains", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "trackingCookieSubdomains": { + "name": "trackingCookieSubdomains", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trackingSecureCookies": { + "name": "trackingSecureCookies", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "trackingHonourDnt": { + "name": "trackingHonourDnt", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "trackingCookieDays": { + "name": "trackingCookieDays", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 395 + }, + "trackingConfigHash": { + "name": "trackingConfigHash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trackingPaused": { + "name": "trackingPaused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "appSetting_trackingSiteId_key": { + "name": "appSetting_trackingSiteId_key", + "columns": [ + { + "expression": "trackingSiteId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.calendarAttendee": { + "name": "calendarAttendee", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "eventId": { + "name": "eventId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "responseStatus": { + "name": "responseStatus", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isOrganizer": { + "name": "isOrganizer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "contactId": { + "name": "contactId", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "calendarAttendee_eventId_email_key": { + "name": "calendarAttendee_eventId_email_key", + "columns": [ + { + "expression": "eventId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendarAttendee_contactId_idx": { + "name": "calendarAttendee_contactId_idx", + "columns": [ + { + "expression": "contactId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "calendarAttendee_eventId_fkey": { + "name": "calendarAttendee_eventId_fkey", + "tableFrom": "calendarAttendee", + "tableTo": "calendarEvent", + "columnsFrom": ["eventId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "calendarAttendee_contactId_fkey": { + "name": "calendarAttendee_contactId_fkey", + "tableFrom": "calendarAttendee", + "tableTo": "contact", + "columnsFrom": ["contactId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.calendarEvent": { + "name": "calendarEvent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "iCalUid": { + "name": "iCalUid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "originalStartTime": { + "name": "originalStartTime", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "recurringEventId": { + "name": "recurringEventId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conferenceUrl": { + "name": "conferenceUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "startsAt": { + "name": "startsAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "endsAt": { + "name": "endsAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "isAllDay": { + "name": "isAllDay", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organizerEmail": { + "name": "organizerEmail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "companyId": { + "name": "companyId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contactId": { + "name": "contactId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "syncedByUserId": { + "name": "syncedByUserId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "googleEventId": { + "name": "googleEventId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "calendarEvent_iCalUid_originalStartTime_key": { + "name": "calendarEvent_iCalUid_originalStartTime_key", + "columns": [ + { + "expression": "iCalUid", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "originalStartTime", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendarEvent_companyId_startsAt_idx": { + "name": "calendarEvent_companyId_startsAt_idx", + "columns": [ + { + "expression": "companyId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "startsAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendarEvent_contactId_startsAt_idx": { + "name": "calendarEvent_contactId_startsAt_idx", + "columns": [ + { + "expression": "contactId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "startsAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "calendarEvent_companyId_fkey": { + "name": "calendarEvent_companyId_fkey", + "tableFrom": "calendarEvent", + "tableTo": "company", + "columnsFrom": ["companyId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "calendarEvent_contactId_fkey": { + "name": "calendarEvent_contactId_fkey", + "tableFrom": "calendarEvent", + "tableTo": "contact", + "columnsFrom": ["contactId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company": { + "name": "company", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logoUrl": { + "name": "logoUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logoDarkUrl": { + "name": "logoDarkUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "iconUrl": { + "name": "iconUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "iconDarkUrl": { + "name": "iconDarkUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "iconTone": { + "name": "iconTone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "brandColor": { + "name": "brandColor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "industry": { + "name": "industry", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subIndustry": { + "name": "subIndustry", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stateCode": { + "name": "stateCode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "countryCode": { + "name": "countryCode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linkedinUrl": { + "name": "linkedinUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "twitterUrl": { + "name": "twitterUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "githubUrl": { + "name": "githubUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pricingUrl": { + "name": "pricingUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "careersUrl": { + "name": "careersUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ownerId": { + "name": "ownerId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "primaryContactId": { + "name": "primaryContactId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enrichmentStatus": { + "name": "enrichmentStatus", + "type": "EnrichmentStatus", + "primaryKey": false, + "notNull": true, + "default": "'PENDING'" + }, + "enrichedAt": { + "name": "enrichedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "enrichmentError": { + "name": "enrichmentError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "RecordSource", + "primaryKey": false, + "notNull": true, + "default": "'MANUAL'" + }, + "lastActivityAt": { + "name": "lastActivityAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "archivedAt": { + "name": "archivedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "company_primaryContactId_key": { + "name": "company_primaryContactId_key", + "columns": [ + { + "expression": "primaryContactId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_domain_active_key": { + "name": "company_domain_active_key", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"archivedAt\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_ownerId_idx": { + "name": "company_ownerId_idx", + "columns": [ + { + "expression": "ownerId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_name_idx": { + "name": "company_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_lastActivityAt_idx": { + "name": "company_lastActivityAt_idx", + "columns": [ + { + "expression": "lastActivityAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_archivedAt_idx": { + "name": "company_archivedAt_idx", + "columns": [ + { + "expression": "archivedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_ownerId_fkey": { + "name": "company_ownerId_fkey", + "tableFrom": "company", + "tableTo": "user", + "columnsFrom": ["ownerId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "company_primaryContactId_fkey": { + "name": "company_primaryContactId_fkey", + "tableFrom": "company", + "tableTo": "contact", + "columnsFrom": ["primaryContactId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companyEnrichment": { + "name": "companyEnrichment", + "schema": "", + "columns": { + "companyId": { + "name": "companyId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'context.dev'" + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "fetchedAt": { + "name": "fetchedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "companyEnrichment_companyId_fkey": { + "name": "companyEnrichment_companyId_fkey", + "tableFrom": "companyEnrichment", + "tableTo": "company", + "columnsFrom": ["companyId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contact": { + "name": "contact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "firstName": { + "name": "firstName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lastName": { + "name": "lastName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "seniority": { + "name": "seniority", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "function": { + "name": "function", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linkedinUrl": { + "name": "linkedinUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "twitterUrl": { + "name": "twitterUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "githubUrl": { + "name": "githubUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "imageUrl": { + "name": "imageUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "socialsCheckedAt": { + "name": "socialsCheckedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "enrichmentStatus": { + "name": "enrichmentStatus", + "type": "EnrichmentStatus", + "primaryKey": false, + "notNull": true, + "default": "'PENDING'" + }, + "enrichedAt": { + "name": "enrichedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "enrichmentError": { + "name": "enrichmentError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "companyId": { + "name": "companyId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ownerId": { + "name": "ownerId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "RecordSource", + "primaryKey": false, + "notNull": true, + "default": "'MANUAL'" + }, + "lastActivityAt": { + "name": "lastActivityAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "archivedAt": { + "name": "archivedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "contact_email_active_key": { + "name": "contact_email_active_key", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"archivedAt\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_companyId_idx": { + "name": "contact_companyId_idx", + "columns": [ + { + "expression": "companyId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_ownerId_idx": { + "name": "contact_ownerId_idx", + "columns": [ + { + "expression": "ownerId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_lastActivityAt_idx": { + "name": "contact_lastActivityAt_idx", + "columns": [ + { + "expression": "lastActivityAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_archivedAt_idx": { + "name": "contact_archivedAt_idx", + "columns": [ + { + "expression": "archivedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contact_companyId_fkey": { + "name": "contact_companyId_fkey", + "tableFrom": "contact", + "tableTo": "company", + "columnsFrom": ["companyId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "contact_ownerId_fkey": { + "name": "contact_ownerId_fkey", + "tableFrom": "contact", + "tableTo": "user", + "columnsFrom": ["ownerId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contactBrief": { + "name": "contactBrief", + "schema": "", + "columns": { + "contactId": { + "name": "contactId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "narrative": { + "name": "narrative", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sections": { + "name": "sections", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sessionId": { + "name": "sessionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refreshedAt": { + "name": "refreshedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "contactBrief_contactId_fkey": { + "name": "contactBrief_contactId_fkey", + "tableFrom": "contactBrief", + "tableTo": "contact", + "columnsFrom": ["contactId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contactFact": { + "name": "contactFact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contactId": { + "name": "contactId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field": { + "name": "field", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "band": { + "name": "band", + "type": "FactBand", + "primaryKey": false, + "notNull": true + }, + "evidence": { + "name": "evidence", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sessionId": { + "name": "sessionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "FactStatus", + "primaryKey": false, + "notNull": true, + "default": "'PROPOSED'" + }, + "decidedById": { + "name": "decidedById", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decidedAt": { + "name": "decidedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "observedAt": { + "name": "observedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "supersededAt": { + "name": "supersededAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "contactFact_contactId_field_status_idx": { + "name": "contactFact_contactId_field_status_idx", + "columns": [ + { + "expression": "contactId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "field", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contactFact_status_observedAt_idx": { + "name": "contactFact_status_observedAt_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "observedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contactFact_contactId_fkey": { + "name": "contactFact_contactId_fkey", + "tableFrom": "contactFact", + "tableTo": "contact", + "columnsFrom": ["contactId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "contactFact_decidedById_fkey": { + "name": "contactFact_decidedById_fkey", + "tableFrom": "contactFact", + "tableTo": "user", + "columnsFrom": ["decidedById"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deal": { + "name": "deal", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "companyId": { + "name": "companyId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ownerId": { + "name": "ownerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stage": { + "name": "stage", + "type": "DealStage", + "primaryKey": false, + "notNull": true, + "default": "'DEMO_BOOKED'" + }, + "stageChangedAt": { + "name": "stageChangedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "expectedCloseDate": { + "name": "expectedCloseDate", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "closedAt": { + "name": "closedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "closedReason": { + "name": "closedReason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "baseAmount": { + "name": "baseAmount", + "type": "numeric(24, 4)", + "primaryKey": false, + "notNull": false + }, + "baseCurrency": { + "name": "baseCurrency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fxRate": { + "name": "fxRate", + "type": "numeric(20, 10)", + "primaryKey": false, + "notNull": false + }, + "fxRateAt": { + "name": "fxRateAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "lastActivityAt": { + "name": "lastActivityAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "archivedAt": { + "name": "archivedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "deal_companyId_idx": { + "name": "deal_companyId_idx", + "columns": [ + { + "expression": "companyId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deal_ownerId_idx": { + "name": "deal_ownerId_idx", + "columns": [ + { + "expression": "ownerId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deal_stage_idx": { + "name": "deal_stage_idx", + "columns": [ + { + "expression": "stage", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deal_expectedCloseDate_idx": { + "name": "deal_expectedCloseDate_idx", + "columns": [ + { + "expression": "expectedCloseDate", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deal_lastActivityAt_idx": { + "name": "deal_lastActivityAt_idx", + "columns": [ + { + "expression": "lastActivityAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deal_baseAmount_idx": { + "name": "deal_baseAmount_idx", + "columns": [ + { + "expression": "baseAmount", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deal_currency_idx": { + "name": "deal_currency_idx", + "columns": [ + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deal_archivedAt_idx": { + "name": "deal_archivedAt_idx", + "columns": [ + { + "expression": "archivedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deal_companyId_fkey": { + "name": "deal_companyId_fkey", + "tableFrom": "deal", + "tableTo": "company", + "columnsFrom": ["companyId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "deal_ownerId_fkey": { + "name": "deal_ownerId_fkey", + "tableFrom": "deal", + "tableTo": "user", + "columnsFrom": ["ownerId"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dealContact": { + "name": "dealContact", + "schema": "", + "columns": { + "dealId": { + "name": "dealId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contactId": { + "name": "contactId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "dealContact_contactId_idx": { + "name": "dealContact_contactId_idx", + "columns": [ + { + "expression": "contactId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dealContact_dealId_fkey": { + "name": "dealContact_dealId_fkey", + "tableFrom": "dealContact", + "tableTo": "deal", + "columnsFrom": ["dealId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "dealContact_contactId_fkey": { + "name": "dealContact_contactId_fkey", + "tableFrom": "dealContact", + "tableTo": "contact", + "columnsFrom": ["contactId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "dealContact_pkey": { + "name": "dealContact_pkey", + "columns": ["dealId", "contactId"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.emailMessage": { + "name": "emailMessage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "threadId": { + "name": "threadId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rfcMessageId": { + "name": "rfcMessageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "syncedByUserId": { + "name": "syncedByUserId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gmailMessageId": { + "name": "gmailMessageId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outlookMessageId": { + "name": "outlookMessageId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outlookWebLink": { + "name": "outlookWebLink", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "direction": { + "name": "direction", + "type": "EmailDirection", + "primaryKey": false, + "notNull": true + }, + "fromEmail": { + "name": "fromEmail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fromName": { + "name": "fromName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recipients": { + "name": "recipients", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snippet": { + "name": "snippet", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sentAt": { + "name": "sentAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "emailMessage_rfcMessageId_key": { + "name": "emailMessage_rfcMessageId_key", + "columns": [ + { + "expression": "rfcMessageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emailMessage_threadId_sentAt_idx": { + "name": "emailMessage_threadId_sentAt_idx", + "columns": [ + { + "expression": "threadId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sentAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "emailMessage_threadId_fkey": { + "name": "emailMessage_threadId_fkey", + "tableFrom": "emailMessage", + "tableTo": "emailThread", + "columnsFrom": ["threadId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.emailThread": { + "name": "emailThread", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "rootMessageId": { + "name": "rootMessageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "companyId": { + "name": "companyId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contactId": { + "name": "contactId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "firstMessageAt": { + "name": "firstMessageAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "lastMessageAt": { + "name": "lastMessageAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "messageCount": { + "name": "messageCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "emailThread_rootMessageId_key": { + "name": "emailThread_rootMessageId_key", + "columns": [ + { + "expression": "rootMessageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emailThread_companyId_lastMessageAt_idx": { + "name": "emailThread_companyId_lastMessageAt_idx", + "columns": [ + { + "expression": "companyId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lastMessageAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emailThread_contactId_lastMessageAt_idx": { + "name": "emailThread_contactId_lastMessageAt_idx", + "columns": [ + { + "expression": "contactId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lastMessageAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "emailThread_companyId_fkey": { + "name": "emailThread_companyId_fkey", + "tableFrom": "emailThread", + "tableTo": "company", + "columnsFrom": ["companyId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "emailThread_contactId_fkey": { + "name": "emailThread_contactId_fkey", + "tableFrom": "emailThread", + "tableTo": "contact", + "columnsFrom": ["contactId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.exchangeRate": { + "name": "exchangeRate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "baseCurrency": { + "name": "baseCurrency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quoteCurrency": { + "name": "quoteCurrency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rate": { + "name": "rate", + "type": "numeric(20, 10)", + "primaryKey": false, + "notNull": true + }, + "asOf": { + "name": "asOf", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "RateSource", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "exchangeRate_baseCurrency_quoteCurrency_source_key": { + "name": "exchangeRate_baseCurrency_quoteCurrency_source_key", + "columns": [ + { + "expression": "baseCurrency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "quoteCurrency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "exchangeRate_baseCurrency_quoteCurrency_idx": { + "name": "exchangeRate_baseCurrency_quoteCurrency_idx", + "columns": [ + { + "expression": "baseCurrency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "quoteCurrency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fieldDefinition": { + "name": "fieldDefinition", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "FieldEntity", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "FieldType", + "primaryKey": false, + "notNull": true + }, + "agentFilled": { + "name": "agentFilled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "agentBrief": { + "name": "agentBrief", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "required": { + "name": "required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "showOnSheet": { + "name": "showOnSheet", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "showOnTable": { + "name": "showOnTable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "showOnFilter": { + "name": "showOnFilter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "archivedAt": { + "name": "archivedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "fieldDefinition_entity_key_key": { + "name": "fieldDefinition_entity_key_key", + "columns": [ + { + "expression": "entity", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fieldDefinition_entity_position_idx": { + "name": "fieldDefinition_entity_position_idx", + "columns": [ + { + "expression": "entity", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fieldOption": { + "name": "fieldOption", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "fieldId": { + "name": "fieldId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "archivedAt": { + "name": "archivedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "fieldOption_fieldId_position_idx": { + "name": "fieldOption_fieldId_position_idx", + "columns": [ + { + "expression": "fieldId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fieldOption_fieldId_fkey": { + "name": "fieldOption_fieldId_fkey", + "tableFrom": "fieldOption", + "tableTo": "fieldDefinition", + "columnsFrom": ["fieldId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fieldValue": { + "name": "fieldValue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "fieldId": { + "name": "fieldId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "companyId": { + "name": "companyId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contactId": { + "name": "contactId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dealId": { + "name": "dealId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number": { + "name": "number", + "type": "numeric(24, 4)", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "bool": { + "name": "bool", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "optionId": { + "name": "optionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "fieldValue_fieldId_companyId_key": { + "name": "fieldValue_fieldId_companyId_key", + "columns": [ + { + "expression": "fieldId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "companyId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fieldValue_fieldId_contactId_key": { + "name": "fieldValue_fieldId_contactId_key", + "columns": [ + { + "expression": "fieldId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "contactId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fieldValue_fieldId_dealId_key": { + "name": "fieldValue_fieldId_dealId_key", + "columns": [ + { + "expression": "fieldId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dealId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fieldValue_fieldId_text_idx": { + "name": "fieldValue_fieldId_text_idx", + "columns": [ + { + "expression": "fieldId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "text", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fieldValue_fieldId_number_idx": { + "name": "fieldValue_fieldId_number_idx", + "columns": [ + { + "expression": "fieldId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fieldValue_fieldId_date_idx": { + "name": "fieldValue_fieldId_date_idx", + "columns": [ + { + "expression": "fieldId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fieldValue_companyId_idx": { + "name": "fieldValue_companyId_idx", + "columns": [ + { + "expression": "companyId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fieldValue_contactId_idx": { + "name": "fieldValue_contactId_idx", + "columns": [ + { + "expression": "contactId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fieldValue_dealId_idx": { + "name": "fieldValue_dealId_idx", + "columns": [ + { + "expression": "dealId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fieldValue_optionId_idx": { + "name": "fieldValue_optionId_idx", + "columns": [ + { + "expression": "optionId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fieldValue_userId_idx": { + "name": "fieldValue_userId_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fieldValue_fieldId_fkey": { + "name": "fieldValue_fieldId_fkey", + "tableFrom": "fieldValue", + "tableTo": "fieldDefinition", + "columnsFrom": ["fieldId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "fieldValue_companyId_fkey": { + "name": "fieldValue_companyId_fkey", + "tableFrom": "fieldValue", + "tableTo": "company", + "columnsFrom": ["companyId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "fieldValue_contactId_fkey": { + "name": "fieldValue_contactId_fkey", + "tableFrom": "fieldValue", + "tableTo": "contact", + "columnsFrom": ["contactId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "fieldValue_dealId_fkey": { + "name": "fieldValue_dealId_fkey", + "tableFrom": "fieldValue", + "tableTo": "deal", + "columnsFrom": ["dealId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "fieldValue_optionId_fkey": { + "name": "fieldValue_optionId_fkey", + "tableFrom": "fieldValue", + "tableTo": "fieldOption", + "columnsFrom": ["optionId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "fieldValue_userId_fkey": { + "name": "fieldValue_userId_fkey", + "tableFrom": "fieldValue", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.formSubmission": { + "name": "formSubmission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "visitorId": { + "name": "visitorId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contactId": { + "name": "contactId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fields": { + "name": "fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "firstTouch": { + "name": "firstTouch", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "lastTouch": { + "name": "lastTouch", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dedupeKey": { + "name": "dedupeKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filedAt": { + "name": "filedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "skipReason": { + "name": "skipReason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "formSubmission_dedupeKey_key": { + "name": "formSubmission_dedupeKey_key", + "columns": [ + { + "expression": "dedupeKey", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "formSubmission_contactId_idx": { + "name": "formSubmission_contactId_idx", + "columns": [ + { + "expression": "contactId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "formSubmission_createdAt_idx": { + "name": "formSubmission_createdAt_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "formSubmission_contactId_fkey": { + "name": "formSubmission_contactId_fkey", + "tableFrom": "formSubmission", + "tableTo": "contact", + "columnsFrom": ["contactId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.install": { + "name": "install", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "uuid": { + "name": "uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lastRollupAt": { + "name": "lastRollupAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "install_uuid_key": { + "name": "install_uuid_key", + "columns": [ + { + "expression": "uuid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "inviterId": { + "name": "inviterId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organizationId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organizationId_fkey": { + "name": "invitation_organizationId_fkey", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organizationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "invitation_inviterId_fkey": { + "name": "invitation_inviterId_fkey", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviterId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mailboxSync": { + "name": "mailboxSync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "GoogleSyncStatus", + "primaryKey": false, + "notNull": true, + "default": "'IDLE'" + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastSyncedAt": { + "name": "lastSyncedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "lastError": { + "name": "lastError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retryAfter": { + "name": "retryAfter", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "autoCreate": { + "name": "autoCreate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mailboxSync_userId_source_key": { + "name": "mailboxSync_userId_source_key", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mailboxSync_status_idx": { + "name": "mailboxSync_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mailboxSync_userId_fkey": { + "name": "mailboxSync_userId_fkey", + "tableFrom": "mailboxSync", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcpClientRegistration": { + "name": "mcpClientRegistration", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "platformSlug": { + "name": "platformSlug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadataFingerprint": { + "name": "metadataFingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "clientId": { + "name": "clientId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encryptedClientSecret": { + "name": "encryptedClientSecret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encryptedRegistrationAccessToken": { + "name": "encryptedRegistrationAccessToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "clientIdIssuedAt": { + "name": "clientIdIssuedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "clientSecretExpiresAt": { + "name": "clientSecretExpiresAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mcpClientRegistration_platformSlug_metadataFingerprint_key": { + "name": "mcpClientRegistration_platformSlug_metadataFingerprint_key", + "columns": [ + { + "expression": "platformSlug", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "metadataFingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpClientRegistration_clientSecretExpiresAt_idx": { + "name": "mcpClientRegistration_clientSecretExpiresAt_idx", + "columns": [ + { + "expression": "clientSecretExpiresAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcpConnection": { + "name": "mcpConnection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platformSlug": { + "name": "platformSlug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "McpConnectionStatus", + "primaryKey": false, + "notNull": true, + "default": "'CONNECTING'" + }, + "accessMode": { + "name": "accessMode", + "type": "McpAccessMode", + "primaryKey": false, + "notNull": true + }, + "grantedScopes": { + "name": "grantedScopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "encryptedAccessToken": { + "name": "encryptedAccessToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encryptedRefreshToken": { + "name": "encryptedRefreshToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokenExpiresAt": { + "name": "tokenExpiresAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "registrationId": { + "name": "registrationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connectedById": { + "name": "connectedById", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connectedAt": { + "name": "connectedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "lastSuccessfulCallAt": { + "name": "lastSuccessfulCallAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "lastErrorCode": { + "name": "lastErrorCode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mcpConnection_organizationId_platformSlug_key": { + "name": "mcpConnection_organizationId_platformSlug_key", + "columns": [ + { + "expression": "organizationId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platformSlug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpConnection_status_updatedAt_idx": { + "name": "mcpConnection_status_updatedAt_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcpConnection_organizationId_fkey": { + "name": "mcpConnection_organizationId_fkey", + "tableFrom": "mcpConnection", + "tableTo": "organization", + "columnsFrom": ["organizationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "mcpConnection_registrationId_fkey": { + "name": "mcpConnection_registrationId_fkey", + "tableFrom": "mcpConnection", + "tableTo": "mcpClientRegistration", + "columnsFrom": ["registrationId"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "mcpConnection_connectedById_fkey": { + "name": "mcpConnection_connectedById_fkey", + "tableFrom": "mcpConnection", + "tableTo": "user", + "columnsFrom": ["connectedById"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcpOauthState": { + "name": "mcpOauthState", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "stateHash": { + "name": "stateHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platformSlug": { + "name": "platformSlug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accessMode": { + "name": "accessMode", + "type": "McpAccessMode", + "primaryKey": false, + "notNull": true + }, + "returnPath": { + "name": "returnPath", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encryptedCodeVerifier": { + "name": "encryptedCodeVerifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "registrationId": { + "name": "registrationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "consumedAt": { + "name": "consumedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "mcpOauthState_stateHash_key": { + "name": "mcpOauthState_stateHash_key", + "columns": [ + { + "expression": "stateHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpOauthState_expiresAt_consumedAt_idx": { + "name": "mcpOauthState_expiresAt_consumedAt_idx", + "columns": [ + { + "expression": "expiresAt", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "consumedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcpOauthState_organizationId_fkey": { + "name": "mcpOauthState_organizationId_fkey", + "tableFrom": "mcpOauthState", + "tableTo": "organization", + "columnsFrom": ["organizationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "mcpOauthState_userId_fkey": { + "name": "mcpOauthState_userId_fkey", + "tableFrom": "mcpOauthState", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "mcpOauthState_registrationId_fkey": { + "name": "mcpOauthState_registrationId_fkey", + "tableFrom": "mcpOauthState", + "tableTo": "mcpClientRegistration", + "columnsFrom": ["registrationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcpToolSnapshot": { + "name": "mcpToolSnapshot", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connectionId": { + "name": "connectionId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputSchema": { + "name": "inputSchema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "annotations": { + "name": "annotations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "callKind": { + "name": "callKind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reviewState": { + "name": "reviewState", + "type": "McpToolReviewState", + "primaryKey": false, + "notNull": true, + "default": "'PENDING'" + }, + "lastSeenAt": { + "name": "lastSeenAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "mcpToolSnapshot_connectionId_name_fingerprint_key": { + "name": "mcpToolSnapshot_connectionId_name_fingerprint_key", + "columns": [ + { + "expression": "connectionId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpToolSnapshot_connectionId_name_lastSeenAt_idx": { + "name": "mcpToolSnapshot_connectionId_name_lastSeenAt_idx", + "columns": [ + { + "expression": "connectionId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lastSeenAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpToolSnapshot_reviewState_lastSeenAt_idx": { + "name": "mcpToolSnapshot_reviewState_lastSeenAt_idx", + "columns": [ + { + "expression": "reviewState", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lastSeenAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcpToolSnapshot_connectionId_fkey": { + "name": "mcpToolSnapshot_connectionId_fkey", + "tableFrom": "mcpToolSnapshot", + "tableTo": "mcpConnection", + "columnsFrom": ["connectionId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_userId_key": { + "name": "member_organizationId_userId_key", + "columns": [ + { + "expression": "organizationId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organizationId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organizationId_fkey": { + "name": "member_organizationId_fkey", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organizationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "member_userId_fkey": { + "name": "member_userId_fkey", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organization_slug_key": { + "name": "organization_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rateLimit": { + "name": "rateLimit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lastRequest": { + "name": "lastRequest", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "rateLimit_key_key": { + "name": "rateLimit_key_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.savedView": { + "name": "savedView", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "FieldEntity", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared": { + "name": "shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "filters": { + "name": "filters", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "ownerId": { + "name": "ownerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "savedView_entity_ownerId_name_key": { + "name": "savedView_entity_ownerId_name_key", + "columns": [ + { + "expression": "entity", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ownerId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "savedView_entity_shared_idx": { + "name": "savedView_entity_shared_idx", + "columns": [ + { + "expression": "entity", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shared", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "savedView_ownerId_fkey": { + "name": "savedView_ownerId_fkey", + "tableFrom": "savedView", + "tableTo": "user", + "columnsFrom": ["ownerId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "activeOrganizationId": { + "name": "activeOrganizationId", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_token_key": { + "name": "session_token_key", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_userId_fkey": { + "name": "session_userId_fkey", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slackChannel": { + "name": "slackChannel", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "memberCount": { + "name": "memberCount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "available": { + "name": "available", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "isPrivate": { + "name": "isPrivate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "isMember": { + "name": "isMember", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inviteRequestedAt": { + "name": "inviteRequestedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "classifiedAt": { + "name": "classifiedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "slackChannel_available_name_idx": { + "name": "slackChannel_available_name_idx", + "columns": [ + { + "expression": "available", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slackChannel_updatedAt_idx": { + "name": "slackChannel_updatedAt_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slackInstallation": { + "name": "slackInstallation", + "schema": "", + "columns": { + "installerId": { + "name": "installerId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "teamId": { + "name": "teamId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "teamName": { + "name": "teamName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userToken": { + "name": "userToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userScopes": { + "name": "userScopes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slackMemberMatch": { + "name": "slackMemberMatch", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "crmUserId": { + "name": "crmUserId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slackUserId": { + "name": "slackUserId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slackHandle": { + "name": "slackHandle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slackEmail": { + "name": "slackEmail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "slackMemberMatch_crmUserId_key": { + "name": "slackMemberMatch_crmUserId_key", + "columns": [ + { + "expression": "crmUserId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slackMemberMatch_slackUserId_idx": { + "name": "slackMemberMatch_slackUserId_idx", + "columns": [ + { + "expression": "slackUserId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slackMemberMatch_crmUserId_fkey": { + "name": "slackMemberMatch_crmUserId_fkey", + "tableFrom": "slackMemberMatch", + "tableTo": "user", + "columnsFrom": ["crmUserId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slackWorkspaceGrant": { + "name": "slackWorkspaceGrant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "teamId": { + "name": "teamId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "teamName": { + "name": "teamName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userToken": { + "name": "userToken", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userScopes": { + "name": "userScopes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "slackWorkspaceGrant_teamId_key": { + "name": "slackWorkspaceGrant_teamId_key", + "columns": [ + { + "expression": "teamId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssoProvider": { + "name": "ssoProvider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidcConfig": { + "name": "oidcConfig", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "samlConfig": { + "name": "samlConfig", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ssoProvider_providerId_key": { + "name": "ssoProvider_providerId_key", + "columns": [ + { + "expression": "providerId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ssoProvider_userId_fkey": { + "name": "ssoProvider_userId_fkey", + "tableFrom": "ssoProvider", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.suppressedContact": { + "name": "suppressedContact", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.suppressedDomain": { + "name": "suppressedDomain", + "schema": "", + "columns": { + "domain": { + "name": "domain", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.telemetryCounter": { + "name": "telemetryCounter", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.telemetryMilestone": { + "name": "telemetryMilestone", + "schema": "", + "columns": { + "step": { + "name": "step", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "reachedAt": { + "name": "reachedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trackedDomain": { + "name": "trackedDomain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "DomainScope", + "primaryKey": false, + "notNull": true, + "default": "'EXACT_HOST'" + }, + "pageViews": { + "name": "pageViews", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lastSeenAt": { + "name": "lastSeenAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "trackedDomain_host_key": { + "name": "trackedDomain_host_key", + "columns": [ + { + "expression": "host", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trackedEvent": { + "name": "trackedEvent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "visitorId": { + "name": "visitorId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "referrer": { + "name": "referrer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "medium": { + "name": "medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "campaign": { + "name": "campaign", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurredAt": { + "name": "occurredAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "trackedEvent_visitorId_occurredAt_idx": { + "name": "trackedEvent_visitorId_occurredAt_idx", + "columns": [ + { + "expression": "visitorId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurredAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trackedEvent_occurredAt_idx": { + "name": "trackedEvent_occurredAt_idx", + "columns": [ + { + "expression": "occurredAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trackedEvent_host_occurredAt_idx": { + "name": "trackedEvent_host_occurredAt_idx", + "columns": [ + { + "expression": "host", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurredAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trackedEvent_source_occurredAt_idx": { + "name": "trackedEvent_source_occurredAt_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurredAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trackedPageDaily": { + "name": "trackedPageDaily", + "schema": "", + "columns": { + "day": { + "name": "day", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "visitors": { + "name": "visitors", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "trackedPageDaily_host_day_idx": { + "name": "trackedPageDaily_host_day_idx", + "columns": [ + { + "expression": "host", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "trackedPageDaily_pkey": { + "name": "trackedPageDaily_pkey", + "columns": ["day", "host", "path"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trackedVisitor": { + "name": "trackedVisitor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contactId": { + "name": "contactId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "firstSource": { + "name": "firstSource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "firstMedium": { + "name": "firstMedium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "firstCampaign": { + "name": "firstCampaign", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "firstTerm": { + "name": "firstTerm", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "firstContent": { + "name": "firstContent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "firstReferrer": { + "name": "firstReferrer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "firstLanding": { + "name": "firstLanding", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "firstTouchAt": { + "name": "firstTouchAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "lastSource": { + "name": "lastSource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastMedium": { + "name": "lastMedium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastCampaign": { + "name": "lastCampaign", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastTerm": { + "name": "lastTerm", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastContent": { + "name": "lastContent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastReferrer": { + "name": "lastReferrer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastLanding": { + "name": "lastLanding", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastTouchAt": { + "name": "lastTouchAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "firstSeen": { + "name": "firstSeen", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "lastSeen": { + "name": "lastSeen", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "trackedVisitor_contactId_idx": { + "name": "trackedVisitor_contactId_idx", + "columns": [ + { + "expression": "contactId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trackedVisitor_firstSource_idx": { + "name": "trackedVisitor_firstSource_idx", + "columns": [ + { + "expression": "firstSource", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "trackedVisitor_contactId_fkey": { + "name": "trackedVisitor_contactId_fkey", + "tableFrom": "trackedVisitor", + "tableTo": "contact", + "columnsFrom": ["contactId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trackingCounter": { + "name": "trackingCounter", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "trackingCounter_expiresAt_idx": { + "name": "trackingCounter_expiresAt_idx", + "columns": [ + { + "expression": "expiresAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_email_key": { + "name": "user_email_key", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaceProfile": { + "name": "workspaceProfile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "narrative": { + "name": "narrative", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sections": { + "name": "sections", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sessionId": { + "name": "sessionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refreshedAt": { + "name": "refreshedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.ActivityType": { + "name": "ActivityType", + "schema": "public", + "values": [ + "NOTE", + "CALL", + "EMAIL", + "MEETING", + "TASK", + "STAGE_CHANGE", + "ENRICHMENT" + ] + }, + "public.AgentActionStatus": { + "name": "AgentActionStatus", + "schema": "public", + "values": ["PLANNED", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED"] + }, + "public.AgentBuilderArtifactStatus": { + "name": "AgentBuilderArtifactStatus", + "schema": "public", + "values": ["WRITING", "READY"] + }, + "public.AgentConversationCommandType": { + "name": "AgentConversationCommandType", + "schema": "public", + "values": ["CHAT", "CREATE_AGENT"] + }, + "public.AgentConversationKind": { + "name": "AgentConversationKind", + "schema": "public", + "values": ["RECORD", "BUILDER"] + }, + "public.AgentConversationPermissionMode": { + "name": "AgentConversationPermissionMode", + "schema": "public", + "values": ["MANUAL", "AUTO", "BYPASS"] + }, + "public.AgentConversationShareScope": { + "name": "AgentConversationShareScope", + "schema": "public", + "values": ["WORKSPACE_LINK"] + }, + "public.AgentConversationSubmissionStatus": { + "name": "AgentConversationSubmissionStatus", + "schema": "public", + "values": ["PENDING", "SENDING", "ACCEPTED", "FAILED", "CANCELLED"] + }, + "public.AgentDefinitionStatus": { + "name": "AgentDefinitionStatus", + "schema": "public", + "values": ["DRAFT", "DEPLOYING", "LIVE", "PAUSED", "ARCHIVED", "DELETED"] + }, + "public.AgentResponseRating": { + "name": "AgentResponseRating", + "schema": "public", + "values": ["UP", "DOWN"] + }, + "public.AgentRunStatus": { + "name": "AgentRunStatus", + "schema": "public", + "values": [ + "QUEUED", + "RUNNING", + "WAITING_FOR_APPROVAL", + "SUCCEEDED", + "FAILED", + "CANCELLED" + ] + }, + "public.AgentTriggerType": { + "name": "AgentTriggerType", + "schema": "public", + "values": ["MANUAL", "SCHEDULE", "EVENT", "WEBHOOK"] + }, + "public.AgentVersionStatus": { + "name": "AgentVersionStatus", + "schema": "public", + "values": ["DRAFT", "VALIDATING", "READY", "DEPLOYED", "REJECTED"] + }, + "public.DealStage": { + "name": "DealStage", + "schema": "public", + "values": [ + "DEMO_BOOKED", + "QUALIFIED_TO_BUY", + "UNQUALIFIED_TO_BUY", + "DECISION_MAKER_BOUGHT_IN", + "CONTRACT_SENT", + "CLOSED_WON", + "CLOSED_LOST" + ] + }, + "public.DomainScope": { + "name": "DomainScope", + "schema": "public", + "values": ["SITE_AND_SUBDOMAINS", "EXACT_HOST"] + }, + "public.EmailDirection": { + "name": "EmailDirection", + "schema": "public", + "values": ["INBOUND", "OUTBOUND"] + }, + "public.EnrichmentStatus": { + "name": "EnrichmentStatus", + "schema": "public", + "values": ["PENDING", "RUNNING", "COMPLETE", "FAILED", "SKIPPED"] + }, + "public.FactBand": { + "name": "FactBand", + "schema": "public", + "values": ["VERIFIED", "PROBABLE", "POSSIBLE"] + }, + "public.FactStatus": { + "name": "FactStatus", + "schema": "public", + "values": ["APPLIED", "PROPOSED", "DISMISSED", "SUPERSEDED"] + }, + "public.FieldEntity": { + "name": "FieldEntity", + "schema": "public", + "values": ["COMPANY", "CONTACT", "DEAL"] + }, + "public.FieldType": { + "name": "FieldType", + "schema": "public", + "values": [ + "TEXT", + "LONG_TEXT", + "NUMBER", + "DATE", + "CHECKBOX", + "SELECT", + "URL", + "EMAIL", + "PHONE", + "USER" + ] + }, + "public.GoogleSyncStatus": { + "name": "GoogleSyncStatus", + "schema": "public", + "values": ["IDLE", "RUNNING", "NEEDS_RECONNECT", "FAILED"] + }, + "public.McpAccessMode": { + "name": "McpAccessMode", + "schema": "public", + "values": ["READ_ONLY", "READ_WRITE"] + }, + "public.McpConnectionStatus": { + "name": "McpConnectionStatus", + "schema": "public", + "values": ["CONNECTING", "CONNECTED", "ERROR", "DISCONNECTED"] + }, + "public.McpToolReviewState": { + "name": "McpToolReviewState", + "schema": "public", + "values": ["PENDING", "REVIEWED", "CHANGED", "REMOVED"] + }, + "public.RateSource": { + "name": "RateSource", + "schema": "public", + "values": ["FETCHED", "MANUAL"] + }, + "public.RecordSource": { + "name": "RecordSource", + "schema": "public", + "values": ["MANUAL", "IMPORT", "EMAIL", "CALENDAR", "TRACKING"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json new file mode 100644 index 000000000..e5112b977 --- /dev/null +++ b/packages/db/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1792864561470, + "tag": "0000_baseline", + "breakpoints": true + } + ] +} diff --git a/packages/db/package.json b/packages/db/package.json index 3ae9663a5..56814eed6 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -9,51 +9,57 @@ "./attribution": "./src/attribution.ts", "./blob": "./src/blob.ts", "./client": "./src/client.ts", - "./currency": "./src/currency.ts", "./crm-events": "./src/crm-events.ts", + "./currency": "./src/currency.ts", "./deal-stage": "./src/deal-stage.ts", - "./enums": "./src/generated/prisma/enums.ts", + "./enums": "./src/schema/enums.ts", + "./errors": "./src/errors.ts", "./favicon": "./src/favicon.ts", "./fields": "./src/fields.ts", "./fields-shape": "./src/fields-shape.ts", "./fx": "./src/fx.ts", - "./images": "./src/images.ts", "./idempotency": "./src/idempotency.ts", + "./images": "./src/images.ts", "./json": "./src/json.ts", "./safe-fetch": "./src/safe-fetch.ts", + "./schema": "./src/schema/schema.ts", "./settings": "./src/settings.ts", + "./settings-shape": "./src/settings-shape.ts", "./slack-inventory": "./src/slack-inventory.ts", "./tracking": "./src/tracking.ts", - "./workspace": "./src/workspace.ts" + "./workspace": "./src/workspace.ts", + "./workspace-shape": "./src/workspace-shape.ts" }, "scripts": { - "build": "prisma generate", "check-types": "tsc --noEmit", - "db:deploy": "prisma migrate deploy", - "db:generate": "prisma generate", - "db:migrate": "bun scripts/require-local-db.ts db:migrate && prisma migrate dev", - "db:push": "bun scripts/require-local-db.ts db:push && prisma db push", - "db:reset": "bun scripts/require-local-db.ts db:reset && prisma migrate reset", - "db:seed": "bun scripts/require-local-db.ts db:seed && prisma db seed", - "db:studio": "prisma studio", + "db:check": "bun scripts/check-drift.ts", + "db:deploy": "bun scripts/migrate.ts", + "db:generate": "drizzle-kit generate", + "db:migrate": "bun scripts/require-local-db.ts db:migrate && drizzle-kit generate && bun scripts/migrate.ts", + "db:push": "bun scripts/require-local-db.ts db:push && drizzle-kit push", + "db:reset": "bun scripts/require-local-db.ts db:reset && bun scripts/reset.ts", + "db:seed": "bun scripts/require-local-db.ts db:seed && bun scripts/seed.ts", + "db:studio": "drizzle-kit studio", "db:test": "bun scripts/test-db.ts", "test": "bun test", "dev:prepare": "bun scripts/prepare-dev.ts", "lint": "biome check .", - "postinstall": "prisma generate", - "clean": "rm -rf .turbo node_modules src/generated" + "clean": "rm -rf .turbo node_modules" }, "dependencies": { "@crm/env": "workspace:*", - "@prisma/adapter-pg": "^7.10.1", - "@prisma/client": "^7.10.1", - "@vercel/blob": "^2.6.1" + "@paralleldrive/cuid2": "^3.3.0", + "@vercel/blob": "^2.6.1", + "decimal.js": "^10.6.0", + "drizzle-orm": "^0.45.4", + "pg": "^8.22.0" }, "devDependencies": { "@crm/typescript-config": "workspace:*", + "@types/bun": "^1.4.4", "@types/node": "^24.10.1", - "pg": "^8.22.0", - "prisma": "^7.10.1", + "@types/pg": "^8.23.1", + "drizzle-kit": "^0.31.11", "typescript": "5.9.2" } } diff --git a/packages/db/peek.ts b/packages/db/peek.ts index cdc66cc6b..8cff42fd6 100644 --- a/packages/db/peek.ts +++ b/packages/db/peek.ts @@ -1,24 +1,48 @@ +import { count } from "drizzle-orm"; import { db } from "./src/client"; +import { + agentDefinition, + formSubmission, + trackedDomain, + trackedEvent, +} from "./src/schema/schema"; console.log( "trackedDomain:", - await db.trackedDomain.findMany({ - select: { host: true, scope: true, pageViews: true }, - }), + await db + .select({ + host: trackedDomain.host, + scope: trackedDomain.scope, + pageViews: trackedDomain.pageViews, + }) + .from(trackedDomain), ); console.log( "trackedEvent hosts:", - await db.trackedEvent.groupBy({ by: ["host"], _count: { _all: true } }), + await db + .select({ host: trackedEvent.host, count: count() }) + .from(trackedEvent) + .groupBy(trackedEvent.host), ); console.log( "formSubmission:", - await db.formSubmission.findMany({ - select: { host: true, email: true, filedAt: true }, - }), + await db + .select({ + host: formSubmission.host, + email: formSubmission.email, + filedAt: formSubmission.filedAt, + }) + .from(formSubmission), ); console.log( "agentDefinitions:", - await db.agentDefinition.findMany({ - select: { id: true, name: true, createdAt: true }, - }), + await db + .select({ + id: agentDefinition.id, + name: agentDefinition.name, + createdAt: agentDefinition.createdAt, + }) + .from(agentDefinition), ); + +await db.$client.end(); diff --git a/packages/db/prisma.config.ts b/packages/db/prisma.config.ts deleted file mode 100644 index b0ef75969..000000000 --- a/packages/db/prisma.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import "@crm/env/load"; - -import path from "node:path"; -import { defineConfig, env } from "prisma/config"; - -export default defineConfig({ - schema: path.join("prisma", "schema.prisma"), - migrations: { - path: path.join("prisma", "migrations"), - seed: "bun run prisma/seed.ts", - }, - datasource: { - url: env("DATABASE_URL"), - }, -}); diff --git a/packages/db/prisma/migrations/20260731150000_init/migration.sql b/packages/db/prisma/migrations/20260731150000_init/migration.sql deleted file mode 100644 index 1438d748f..000000000 --- a/packages/db/prisma/migrations/20260731150000_init/migration.sql +++ /dev/null @@ -1,159 +0,0 @@ --- CreateSchema -CREATE SCHEMA IF NOT EXISTS "public"; - --- CreateTable -CREATE TABLE "user" ( - "id" TEXT NOT NULL, - "name" TEXT NOT NULL, - "email" TEXT NOT NULL, - "emailVerified" BOOLEAN NOT NULL DEFAULT false, - "image" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "user_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "session" ( - "id" TEXT NOT NULL, - "expiresAt" TIMESTAMP(3) NOT NULL, - "token" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "ipAddress" TEXT, - "userAgent" TEXT, - "userId" TEXT NOT NULL, - "activeOrganizationId" TEXT, - - CONSTRAINT "session_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "account" ( - "id" TEXT NOT NULL, - "accountId" TEXT NOT NULL, - "providerId" TEXT NOT NULL, - "userId" TEXT NOT NULL, - "accessToken" TEXT, - "refreshToken" TEXT, - "idToken" TEXT, - "accessTokenExpiresAt" TIMESTAMP(3), - "refreshTokenExpiresAt" TIMESTAMP(3), - "scope" TEXT, - "password" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "account_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "verification" ( - "id" TEXT NOT NULL, - "identifier" TEXT NOT NULL, - "value" TEXT NOT NULL, - "expiresAt" TIMESTAMP(3) NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "verification_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "organization" ( - "id" TEXT NOT NULL, - "name" TEXT NOT NULL, - "slug" TEXT NOT NULL, - "logo" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL, - "metadata" TEXT, - - CONSTRAINT "organization_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "member" ( - "id" TEXT NOT NULL, - "organizationId" TEXT NOT NULL, - "userId" TEXT NOT NULL, - "role" TEXT NOT NULL DEFAULT 'member', - "createdAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "member_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "invitation" ( - "id" TEXT NOT NULL, - "organizationId" TEXT NOT NULL, - "email" TEXT NOT NULL, - "role" TEXT, - "status" TEXT NOT NULL DEFAULT 'pending', - "expiresAt" TIMESTAMP(3) NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "inviterId" TEXT NOT NULL, - - CONSTRAINT "invitation_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "rateLimit" ( - "id" TEXT NOT NULL, - "key" TEXT NOT NULL, - "count" INTEGER NOT NULL, - "lastRequest" BIGINT NOT NULL, - - CONSTRAINT "rateLimit_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "user_email_key" ON "user"("email"); - --- CreateIndex -CREATE INDEX "session_userId_idx" ON "session"("userId"); - --- CreateIndex -CREATE UNIQUE INDEX "session_token_key" ON "session"("token"); - --- CreateIndex -CREATE INDEX "account_userId_idx" ON "account"("userId"); - --- CreateIndex -CREATE INDEX "verification_identifier_idx" ON "verification"("identifier"); - --- CreateIndex -CREATE UNIQUE INDEX "organization_slug_key" ON "organization"("slug"); - --- CreateIndex -CREATE INDEX "member_organizationId_idx" ON "member"("organizationId"); - --- CreateIndex -CREATE INDEX "member_userId_idx" ON "member"("userId"); - --- CreateIndex -CREATE INDEX "invitation_organizationId_idx" ON "invitation"("organizationId"); - --- CreateIndex -CREATE INDEX "invitation_email_idx" ON "invitation"("email"); - --- CreateIndex -CREATE UNIQUE INDEX "rateLimit_key_key" ON "rateLimit"("key"); - --- AddForeignKey -ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "member" ADD CONSTRAINT "member_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "member" ADD CONSTRAINT "member_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "invitation" ADD CONSTRAINT "invitation_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "invitation" ADD CONSTRAINT "invitation_inviterId_fkey" FOREIGN KEY ("inviterId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260731160000_remove_organizations/migration.sql b/packages/db/prisma/migrations/20260731160000_remove_organizations/migration.sql deleted file mode 100644 index cf9ced538..000000000 --- a/packages/db/prisma/migrations/20260731160000_remove_organizations/migration.sql +++ /dev/null @@ -1,26 +0,0 @@ --- The `organization` Better Auth plugin has been removed: this is a --- single-tenant internal app, so memberships and invitations no longer exist. - --- DropForeignKey -ALTER TABLE "invitation" DROP CONSTRAINT IF EXISTS "invitation_inviterId_fkey"; - --- DropForeignKey -ALTER TABLE "invitation" DROP CONSTRAINT IF EXISTS "invitation_organizationId_fkey"; - --- DropForeignKey -ALTER TABLE "member" DROP CONSTRAINT IF EXISTS "member_organizationId_fkey"; - --- DropForeignKey -ALTER TABLE "member" DROP CONSTRAINT IF EXISTS "member_userId_fkey"; - --- DropTable -DROP TABLE IF EXISTS "invitation"; - --- DropTable -DROP TABLE IF EXISTS "member"; - --- DropTable -DROP TABLE IF EXISTS "organization"; - --- AlterTable -ALTER TABLE "session" DROP COLUMN IF EXISTS "activeOrganizationId"; diff --git a/packages/db/prisma/migrations/20260731164536_add_crm_models/migration.sql b/packages/db/prisma/migrations/20260731164536_add_crm_models/migration.sql deleted file mode 100644 index a9947b564..000000000 --- a/packages/db/prisma/migrations/20260731164536_add_crm_models/migration.sql +++ /dev/null @@ -1,208 +0,0 @@ --- CreateEnum -CREATE TYPE "DealStage" AS ENUM ('DEMO_BOOKED', 'QUALIFIED_TO_BUY', 'UNQUALIFIED_TO_BUY', 'DECISION_MAKER_BOUGHT_IN', 'CONTRACT_SENT', 'CLOSED_WON', 'CLOSED_LOST'); - --- CreateEnum -CREATE TYPE "ActivityType" AS ENUM ('NOTE', 'CALL', 'EMAIL', 'MEETING', 'TASK', 'STAGE_CHANGE', 'ENRICHMENT'); - --- CreateEnum -CREATE TYPE "EnrichmentStatus" AS ENUM ('PENDING', 'RUNNING', 'COMPLETE', 'FAILED', 'SKIPPED'); - --- CreateTable -CREATE TABLE "company" ( - "id" TEXT NOT NULL, - "name" TEXT NOT NULL, - "domain" TEXT, - "website" TEXT, - "description" TEXT, - "logoUrl" TEXT, - "logoDarkUrl" TEXT, - "iconUrl" TEXT, - "brandColor" TEXT, - "industry" TEXT, - "subIndustry" TEXT, - "city" TEXT, - "stateCode" TEXT, - "country" TEXT, - "countryCode" TEXT, - "phone" TEXT, - "email" TEXT, - "linkedinUrl" TEXT, - "twitterUrl" TEXT, - "githubUrl" TEXT, - "pricingUrl" TEXT, - "careersUrl" TEXT, - "ownerId" TEXT, - "primaryContactId" TEXT, - "enrichmentStatus" "EnrichmentStatus" NOT NULL DEFAULT 'PENDING', - "enrichedAt" TIMESTAMP(3), - "enrichmentError" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "company_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "companyEnrichment" ( - "companyId" TEXT NOT NULL, - "source" TEXT NOT NULL DEFAULT 'context.dev', - "raw" JSONB NOT NULL, - "fetchedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "companyEnrichment_pkey" PRIMARY KEY ("companyId") -); - --- CreateTable -CREATE TABLE "contact" ( - "id" TEXT NOT NULL, - "firstName" TEXT NOT NULL, - "lastName" TEXT, - "email" TEXT, - "phone" TEXT, - "title" TEXT, - "linkedinUrl" TEXT, - "companyId" TEXT, - "ownerId" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "contact_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "deal" ( - "id" TEXT NOT NULL, - "name" TEXT NOT NULL, - "companyId" TEXT NOT NULL, - "ownerId" TEXT NOT NULL, - "stage" "DealStage" NOT NULL DEFAULT 'DEMO_BOOKED', - "stageChangedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "amount" DECIMAL(14,2), - "currency" TEXT NOT NULL DEFAULT 'USD', - "expectedCloseDate" TIMESTAMP(3), - "closedAt" TIMESTAMP(3), - "closedReason" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "deal_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "dealContact" ( - "dealId" TEXT NOT NULL, - "contactId" TEXT NOT NULL, - "role" TEXT, - - CONSTRAINT "dealContact_pkey" PRIMARY KEY ("dealId","contactId") -); - --- CreateTable -CREATE TABLE "activity" ( - "id" TEXT NOT NULL, - "type" "ActivityType" NOT NULL, - "subject" TEXT, - "body" TEXT, - "occurredAt" TIMESTAMP(3), - "dueAt" TIMESTAMP(3), - "completedAt" TIMESTAMP(3), - "companyId" TEXT, - "contactId" TEXT, - "dealId" TEXT, - "createdById" TEXT NOT NULL, - "meta" JSONB, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "activity_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "company_domain_key" ON "company"("domain"); - --- CreateIndex -CREATE UNIQUE INDEX "company_primaryContactId_key" ON "company"("primaryContactId"); - --- CreateIndex -CREATE INDEX "company_ownerId_idx" ON "company"("ownerId"); - --- CreateIndex -CREATE INDEX "company_name_idx" ON "company"("name"); - --- CreateIndex -CREATE UNIQUE INDEX "contact_email_key" ON "contact"("email"); - --- CreateIndex -CREATE INDEX "contact_companyId_idx" ON "contact"("companyId"); - --- CreateIndex -CREATE INDEX "contact_ownerId_idx" ON "contact"("ownerId"); - --- CreateIndex -CREATE INDEX "deal_companyId_idx" ON "deal"("companyId"); - --- CreateIndex -CREATE INDEX "deal_ownerId_idx" ON "deal"("ownerId"); - --- CreateIndex -CREATE INDEX "deal_stage_idx" ON "deal"("stage"); - --- CreateIndex -CREATE INDEX "deal_expectedCloseDate_idx" ON "deal"("expectedCloseDate"); - --- CreateIndex -CREATE INDEX "dealContact_contactId_idx" ON "dealContact"("contactId"); - --- CreateIndex -CREATE INDEX "activity_companyId_createdAt_idx" ON "activity"("companyId", "createdAt"); - --- CreateIndex -CREATE INDEX "activity_dealId_createdAt_idx" ON "activity"("dealId", "createdAt"); - --- CreateIndex -CREATE INDEX "activity_contactId_createdAt_idx" ON "activity"("contactId", "createdAt"); - --- CreateIndex -CREATE INDEX "activity_dueAt_idx" ON "activity"("dueAt"); - --- CreateIndex -CREATE INDEX "activity_createdById_idx" ON "activity"("createdById"); - --- AddForeignKey -ALTER TABLE "company" ADD CONSTRAINT "company_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "company" ADD CONSTRAINT "company_primaryContactId_fkey" FOREIGN KEY ("primaryContactId") REFERENCES "contact"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "companyEnrichment" ADD CONSTRAINT "companyEnrichment_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "company"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "contact" ADD CONSTRAINT "contact_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "company"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "contact" ADD CONSTRAINT "contact_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "deal" ADD CONSTRAINT "deal_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "company"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "deal" ADD CONSTRAINT "deal_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "dealContact" ADD CONSTRAINT "dealContact_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "deal"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "dealContact" ADD CONSTRAINT "dealContact_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "contact"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "activity" ADD CONSTRAINT "activity_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "company"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "activity" ADD CONSTRAINT "activity_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "contact"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "activity" ADD CONSTRAINT "activity_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "deal"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "activity" ADD CONSTRAINT "activity_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260731200000_add_google_sync/migration.sql b/packages/db/prisma/migrations/20260731200000_add_google_sync/migration.sql deleted file mode 100644 index 0d8cbc109..000000000 --- a/packages/db/prisma/migrations/20260731200000_add_google_sync/migration.sql +++ /dev/null @@ -1,192 +0,0 @@ --- CreateEnum -CREATE TYPE "RecordSource" AS ENUM ('MANUAL', 'IMPORT', 'EMAIL', 'CALENDAR'); - --- CreateEnum -CREATE TYPE "GoogleSyncStatus" AS ENUM ('IDLE', 'BACKFILLING', 'RUNNING', 'NEEDS_RECONNECT', 'FAILED'); - --- CreateEnum -CREATE TYPE "EmailDirection" AS ENUM ('INBOUND', 'OUTBOUND'); - --- AlterTable -ALTER TABLE "activity" ADD COLUMN "calendarEventId" TEXT, -ADD COLUMN "emailThreadId" TEXT; - --- AlterTable -ALTER TABLE "company" ADD COLUMN "source" "RecordSource" NOT NULL DEFAULT 'MANUAL'; - --- AlterTable -ALTER TABLE "contact" ADD COLUMN "source" "RecordSource" NOT NULL DEFAULT 'MANUAL'; - --- CreateTable -CREATE TABLE "mailboxSync" ( - "id" TEXT NOT NULL, - "userId" TEXT NOT NULL, - "source" TEXT NOT NULL, - "status" "GoogleSyncStatus" NOT NULL DEFAULT 'IDLE', - "cursor" TEXT, - "backfilledTo" TIMESTAMP(3), - "lastSyncedAt" TIMESTAMP(3), - "lastError" TEXT, - "retryAfter" TIMESTAMP(3), - "autoCreate" BOOLEAN NOT NULL DEFAULT false, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "mailboxSync_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "emailThread" ( - "id" TEXT NOT NULL, - "rootMessageId" TEXT NOT NULL, - "subject" TEXT, - "companyId" TEXT, - "contactId" TEXT, - "firstMessageAt" TIMESTAMP(3) NOT NULL, - "lastMessageAt" TIMESTAMP(3) NOT NULL, - "messageCount" INTEGER NOT NULL DEFAULT 0, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "emailThread_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "emailMessage" ( - "id" TEXT NOT NULL, - "threadId" TEXT NOT NULL, - "rfcMessageId" TEXT NOT NULL, - "syncedByUserId" TEXT, - "gmailMessageId" TEXT, - "direction" "EmailDirection" NOT NULL, - "fromEmail" TEXT NOT NULL, - "fromName" TEXT, - "recipients" JSONB NOT NULL, - "subject" TEXT, - "snippet" TEXT, - "body" TEXT, - "sentAt" TIMESTAMP(3) NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "emailMessage_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "calendarEvent" ( - "id" TEXT NOT NULL, - "iCalUid" TEXT NOT NULL, - "originalStartTime" TIMESTAMP(3) NOT NULL, - "recurringEventId" TEXT, - "title" TEXT, - "description" TEXT, - "location" TEXT, - "conferenceUrl" TEXT, - "startsAt" TIMESTAMP(3) NOT NULL, - "endsAt" TIMESTAMP(3) NOT NULL, - "isAllDay" BOOLEAN NOT NULL DEFAULT false, - "status" TEXT NOT NULL, - "organizerEmail" TEXT, - "companyId" TEXT, - "contactId" TEXT, - "syncedByUserId" TEXT, - "googleEventId" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "calendarEvent_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "calendarAttendee" ( - "id" TEXT NOT NULL, - "eventId" TEXT NOT NULL, - "email" TEXT NOT NULL, - "name" TEXT, - "responseStatus" TEXT, - "isOrganizer" BOOLEAN NOT NULL DEFAULT false, - "contactId" TEXT, - - CONSTRAINT "calendarAttendee_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "suppressedDomain" ( - "domain" TEXT NOT NULL, - "reason" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "suppressedDomain_pkey" PRIMARY KEY ("domain") -); - --- CreateIndex -CREATE INDEX "mailboxSync_status_idx" ON "mailboxSync"("status"); - --- CreateIndex -CREATE UNIQUE INDEX "mailboxSync_userId_source_key" ON "mailboxSync"("userId", "source"); - --- CreateIndex -CREATE UNIQUE INDEX "emailThread_rootMessageId_key" ON "emailThread"("rootMessageId"); - --- CreateIndex -CREATE INDEX "emailThread_companyId_lastMessageAt_idx" ON "emailThread"("companyId", "lastMessageAt"); - --- CreateIndex -CREATE INDEX "emailThread_contactId_lastMessageAt_idx" ON "emailThread"("contactId", "lastMessageAt"); - --- CreateIndex -CREATE UNIQUE INDEX "emailMessage_rfcMessageId_key" ON "emailMessage"("rfcMessageId"); - --- CreateIndex -CREATE INDEX "emailMessage_threadId_sentAt_idx" ON "emailMessage"("threadId", "sentAt"); - --- CreateIndex -CREATE INDEX "calendarEvent_companyId_startsAt_idx" ON "calendarEvent"("companyId", "startsAt"); - --- CreateIndex -CREATE INDEX "calendarEvent_contactId_startsAt_idx" ON "calendarEvent"("contactId", "startsAt"); - --- CreateIndex -CREATE UNIQUE INDEX "calendarEvent_iCalUid_originalStartTime_key" ON "calendarEvent"("iCalUid", "originalStartTime"); - --- CreateIndex -CREATE INDEX "calendarAttendee_contactId_idx" ON "calendarAttendee"("contactId"); - --- CreateIndex -CREATE UNIQUE INDEX "calendarAttendee_eventId_email_key" ON "calendarAttendee"("eventId", "email"); - --- CreateIndex -CREATE UNIQUE INDEX "activity_emailThreadId_key" ON "activity"("emailThreadId"); - --- CreateIndex -CREATE UNIQUE INDEX "activity_calendarEventId_key" ON "activity"("calendarEventId"); - --- AddForeignKey -ALTER TABLE "activity" ADD CONSTRAINT "activity_emailThreadId_fkey" FOREIGN KEY ("emailThreadId") REFERENCES "emailThread"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "activity" ADD CONSTRAINT "activity_calendarEventId_fkey" FOREIGN KEY ("calendarEventId") REFERENCES "calendarEvent"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "mailboxSync" ADD CONSTRAINT "mailboxSync_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "emailThread" ADD CONSTRAINT "emailThread_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "company"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "emailThread" ADD CONSTRAINT "emailThread_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "contact"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "emailMessage" ADD CONSTRAINT "emailMessage_threadId_fkey" FOREIGN KEY ("threadId") REFERENCES "emailThread"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "calendarEvent" ADD CONSTRAINT "calendarEvent_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "company"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "calendarEvent" ADD CONSTRAINT "calendarEvent_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "contact"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "calendarAttendee" ADD CONSTRAINT "calendarAttendee_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "calendarEvent"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "calendarAttendee" ADD CONSTRAINT "calendarAttendee_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "contact"("id") ON DELETE SET NULL ON UPDATE CASCADE; - diff --git a/packages/db/prisma/migrations/20260731210000_forward_only_sync/migration.sql b/packages/db/prisma/migrations/20260731210000_forward_only_sync/migration.sql deleted file mode 100644 index 7d3728b82..000000000 --- a/packages/db/prisma/migrations/20260731210000_forward_only_sync/migration.sql +++ /dev/null @@ -1,19 +0,0 @@ --- Sync is forward-only now, so there is no backfill for a row to be in the --- middle of. Anything mid-import becomes IDLE and carries on from wherever its --- cursor already points. -UPDATE "mailboxSync" SET "status" = 'IDLE' WHERE "status" = 'BACKFILLING'; - --- AlterEnum -BEGIN; -CREATE TYPE "GoogleSyncStatus_new" AS ENUM ('IDLE', 'RUNNING', 'NEEDS_RECONNECT', 'FAILED'); -ALTER TABLE "public"."mailboxSync" ALTER COLUMN "status" DROP DEFAULT; -ALTER TABLE "mailboxSync" ALTER COLUMN "status" TYPE "GoogleSyncStatus_new" USING ("status"::text::"GoogleSyncStatus_new"); -ALTER TYPE "GoogleSyncStatus" RENAME TO "GoogleSyncStatus_old"; -ALTER TYPE "GoogleSyncStatus_new" RENAME TO "GoogleSyncStatus"; -DROP TYPE "public"."GoogleSyncStatus_old"; -ALTER TABLE "mailboxSync" ALTER COLUMN "status" SET DEFAULT 'IDLE'; -COMMIT; - --- AlterTable -ALTER TABLE "mailboxSync" DROP COLUMN "backfilledTo"; - diff --git a/packages/db/prisma/migrations/20260731220000_denormalise_last_activity/migration.sql b/packages/db/prisma/migrations/20260731220000_denormalise_last_activity/migration.sql deleted file mode 100644 index 80214c932..000000000 --- a/packages/db/prisma/migrations/20260731220000_denormalise_last_activity/migration.sql +++ /dev/null @@ -1,33 +0,0 @@ --- AlterTable -ALTER TABLE "company" ADD COLUMN "lastActivityAt" TIMESTAMP(3); - --- AlterTable -ALTER TABLE "contact" ADD COLUMN "lastActivityAt" TIMESTAMP(3); - --- AlterTable -ALTER TABLE "deal" ADD COLUMN "lastActivityAt" TIMESTAMP(3); - --- CreateIndex -CREATE INDEX "company_lastActivityAt_idx" ON "company"("lastActivityAt"); - --- CreateIndex -CREATE INDEX "contact_lastActivityAt_idx" ON "contact"("lastActivityAt"); - --- CreateIndex -CREATE INDEX "deal_lastActivityAt_idx" ON "deal"("lastActivityAt"); - - --- Backfill from what is already there, so the new column is sortable on day one --- rather than after everyone's next activity. `createdAt` (not `occurredAt`) to --- match what the lateral join this replaces was reading. -UPDATE "company" c SET "lastActivityAt" = a.max -FROM (SELECT "companyId" AS id, MAX("createdAt") AS max FROM "activity" WHERE "companyId" IS NOT NULL GROUP BY "companyId") a -WHERE c.id = a.id; - -UPDATE "contact" ct SET "lastActivityAt" = a.max -FROM (SELECT "contactId" AS id, MAX("createdAt") AS max FROM "activity" WHERE "contactId" IS NOT NULL GROUP BY "contactId") a -WHERE ct.id = a.id; - -UPDATE "deal" d SET "lastActivityAt" = a.max -FROM (SELECT "dealId" AS id, MAX("createdAt") AS max FROM "activity" WHERE "dealId" IS NOT NULL GROUP BY "dealId") a -WHERE d.id = a.id; diff --git a/packages/db/prisma/migrations/20260731230000_logo_dark_mode/migration.sql b/packages/db/prisma/migrations/20260731230000_logo_dark_mode/migration.sql deleted file mode 100644 index 3bdec9f73..000000000 --- a/packages/db/prisma/migrations/20260731230000_logo_dark_mode/migration.sql +++ /dev/null @@ -1,4 +0,0 @@ --- AlterTable -ALTER TABLE "company" ADD COLUMN "iconDarkUrl" TEXT, -ADD COLUMN "iconTone" TEXT; - diff --git a/packages/db/prisma/migrations/20260801000000_contact_avatar/migration.sql b/packages/db/prisma/migrations/20260801000000_contact_avatar/migration.sql deleted file mode 100644 index 295e6b26b..000000000 --- a/packages/db/prisma/migrations/20260801000000_contact_avatar/migration.sql +++ /dev/null @@ -1,3 +0,0 @@ --- AlterTable -ALTER TABLE "contact" ADD COLUMN "imageUrl" TEXT; - diff --git a/packages/db/prisma/migrations/20260801120000_contact_summary_and_socials/migration.sql b/packages/db/prisma/migrations/20260801120000_contact_summary_and_socials/migration.sql deleted file mode 100644 index f3222dc9f..000000000 --- a/packages/db/prisma/migrations/20260801120000_contact_summary_and_socials/migration.sql +++ /dev/null @@ -1,6 +0,0 @@ --- AlterTable -ALTER TABLE "contact" ADD COLUMN "githubUrl" TEXT, -ADD COLUMN "summary" TEXT, -ADD COLUMN "summarySourceUrl" TEXT, -ADD COLUMN "summaryUpdatedAt" TIMESTAMP(3), -ADD COLUMN "twitterUrl" TEXT; diff --git a/packages/db/prisma/migrations/20260801130000_contact_socials_checked/migration.sql b/packages/db/prisma/migrations/20260801130000_contact_socials_checked/migration.sql deleted file mode 100644 index 2ee395df4..000000000 --- a/packages/db/prisma/migrations/20260801130000_contact_socials_checked/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "contact" ADD COLUMN "socialsCheckedAt" TIMESTAMP(3); diff --git a/packages/db/prisma/migrations/20260801140000_contact_intelligence/migration.sql b/packages/db/prisma/migrations/20260801140000_contact_intelligence/migration.sql deleted file mode 100644 index c751d2154..000000000 --- a/packages/db/prisma/migrations/20260801140000_contact_intelligence/migration.sql +++ /dev/null @@ -1,137 +0,0 @@ --- CreateEnum -CREATE TYPE "FactBand" AS ENUM ('VERIFIED', 'PROBABLE', 'POSSIBLE'); - --- CreateEnum -CREATE TYPE "FactStatus" AS ENUM ('APPLIED', 'PROPOSED', 'DISMISSED', 'SUPERSEDED'); - --- AlterTable --- --- The summary columns are dropped at the bottom of this file, after their --- contents have been copied into "contactBrief". Dropping them here, where --- Prisma put them, would destroy every summary the agent has already written. -ALTER TABLE "contact" -ADD COLUMN "enrichedAt" TIMESTAMP(3), -ADD COLUMN "enrichmentError" TEXT, -ADD COLUMN "enrichmentStatus" "EnrichmentStatus" NOT NULL DEFAULT 'PENDING'; - --- CreateTable -CREATE TABLE "contactFact" ( - "id" TEXT NOT NULL, - "contactId" TEXT NOT NULL, - "field" TEXT NOT NULL, - "value" TEXT NOT NULL, - "score" DOUBLE PRECISION NOT NULL, - "band" "FactBand" NOT NULL, - "evidence" JSONB NOT NULL, - "method" TEXT NOT NULL, - "sourceUrl" TEXT, - "sessionId" TEXT, - "status" "FactStatus" NOT NULL DEFAULT 'PROPOSED', - "decidedById" TEXT, - "decidedAt" TIMESTAMP(3), - "observedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "supersededAt" TIMESTAMP(3), - - CONSTRAINT "contactFact_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "contactBrief" ( - "contactId" TEXT NOT NULL, - "narrative" TEXT NOT NULL, - "sections" JSONB NOT NULL, - "score" DOUBLE PRECISION NOT NULL, - "sourceUrl" TEXT, - "sessionId" TEXT, - "refreshedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "contactBrief_pkey" PRIMARY KEY ("contactId") -); - --- CreateTable -CREATE TABLE "agentTask" ( - "id" TEXT NOT NULL, - "contactId" TEXT, - "companyId" TEXT, - "kind" TEXT NOT NULL, - "reason" TEXT NOT NULL, - "priority" INTEGER NOT NULL DEFAULT 0, - "budget" INTEGER NOT NULL DEFAULT 4, - "dueAt" TIMESTAMP(3) NOT NULL, - "leasedUntil" TIMESTAMP(3), - "sessionId" TEXT, - "startedAt" TIMESTAMP(3), - "finishedAt" TIMESTAMP(3), - "outcome" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "agentTask_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "agentEvent" ( - "id" TEXT NOT NULL, - "sessionId" TEXT NOT NULL, - "contactId" TEXT, - "type" TEXT NOT NULL, - "data" JSONB NOT NULL, - "emittedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "agentEvent_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX "contactFact_contactId_field_status_idx" ON "contactFact"("contactId", "field", "status"); - --- CreateIndex -CREATE INDEX "contactFact_status_observedAt_idx" ON "contactFact"("status", "observedAt"); - --- CreateIndex -CREATE INDEX "agentTask_dueAt_leasedUntil_idx" ON "agentTask"("dueAt", "leasedUntil"); - --- CreateIndex -CREATE INDEX "agentTask_contactId_idx" ON "agentTask"("contactId"); - --- CreateIndex -CREATE INDEX "agentEvent_sessionId_emittedAt_idx" ON "agentEvent"("sessionId", "emittedAt"); - --- CreateIndex -CREATE INDEX "agentEvent_contactId_emittedAt_idx" ON "agentEvent"("contactId", "emittedAt"); - --- AddForeignKey -ALTER TABLE "contactFact" ADD CONSTRAINT "contactFact_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "contact"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "contactFact" ADD CONSTRAINT "contactFact_decidedById_fkey" FOREIGN KEY ("decidedById") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "contactBrief" ADD CONSTRAINT "contactBrief_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "contact"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- Carry existing summaries over. --- --- They were written by a tool that already demanded a source URL, so they are --- exactly what a brief is; they simply had nowhere structured to live. Scored --- 0.85 — the bottom of VERIFIED — because they were auto-applied under the old --- all-or-nothing rule, and re-deriving a real score needs the evidence that --- rule never recorded. The next refresh replaces this with a scored one. -INSERT INTO "contactBrief" ("contactId", "narrative", "sections", "score", "sourceUrl", "refreshedAt") -SELECT - "id", - "summary", - '{}'::jsonb, - 0.85, - "summarySourceUrl", - COALESCE("summaryUpdatedAt", CURRENT_TIMESTAMP) -FROM "contact" -WHERE "summary" IS NOT NULL; - --- Everything already in the CRM predates this pipeline, so nothing is queued --- for it. Leaving them PENDING would have the sheet poll forever for work that --- was never scheduled; the dispatcher picks them up on its own cadence. -UPDATE "contact" SET "enrichmentStatus" = 'COMPLETE', "enrichedAt" = "updatedAt"; - --- DropColumn -ALTER TABLE "contact" DROP COLUMN "summary", -DROP COLUMN "summarySourceUrl", -DROP COLUMN "summaryUpdatedAt"; - diff --git a/packages/db/prisma/migrations/20260801160000_agent_conversations/migration.sql b/packages/db/prisma/migrations/20260801160000_agent_conversations/migration.sql deleted file mode 100644 index 67b56d010..000000000 --- a/packages/db/prisma/migrations/20260801160000_agent_conversations/migration.sql +++ /dev/null @@ -1,35 +0,0 @@ --- CreateTable -CREATE TABLE "agentConversation" ( - "id" TEXT NOT NULL, - "contactId" TEXT, - "companyId" TEXT, - "userId" TEXT NOT NULL, - "sessionId" TEXT NOT NULL, - "continuationToken" TEXT, - "streamIndex" INTEGER NOT NULL DEFAULT 0, - "title" TEXT, - "messageCount" INTEGER NOT NULL DEFAULT 0, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "lastMessageAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "agentConversation_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "agentConversation_sessionId_key" ON "agentConversation"("sessionId"); - --- CreateIndex -CREATE INDEX "agentConversation_contactId_lastMessageAt_idx" ON "agentConversation"("contactId", "lastMessageAt"); - --- CreateIndex -CREATE INDEX "agentConversation_companyId_lastMessageAt_idx" ON "agentConversation"("companyId", "lastMessageAt"); - --- AddForeignKey -ALTER TABLE "agentConversation" ADD CONSTRAINT "agentConversation_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "contact"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "agentConversation" ADD CONSTRAINT "agentConversation_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "company"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "agentConversation" ADD CONSTRAINT "agentConversation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; - diff --git a/packages/db/prisma/migrations/20260801170000_conversations_on_deals/migration.sql b/packages/db/prisma/migrations/20260801170000_conversations_on_deals/migration.sql deleted file mode 100644 index 4e1555c05..000000000 --- a/packages/db/prisma/migrations/20260801170000_conversations_on_deals/migration.sql +++ /dev/null @@ -1,9 +0,0 @@ --- AlterTable -ALTER TABLE "agentConversation" ADD COLUMN "dealId" TEXT; - --- CreateIndex -CREATE INDEX "agentConversation_dealId_lastMessageAt_idx" ON "agentConversation"("dealId", "lastMessageAt"); - --- AddForeignKey -ALTER TABLE "agentConversation" ADD CONSTRAINT "agentConversation_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "deal"("id") ON DELETE CASCADE ON UPDATE CASCADE; - diff --git a/packages/db/prisma/migrations/20260801180000_task_timeline_position/migration.sql b/packages/db/prisma/migrations/20260801180000_task_timeline_position/migration.sql deleted file mode 100644 index 4b0be7aa9..000000000 --- a/packages/db/prisma/migrations/20260801180000_task_timeline_position/migration.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Tasks had no place on the timeline. --- --- `occurredAt` was written as NULL for TASK rows on the grounds that a task is --- scheduled rather than logged. The timeline orders by it DESC NULLS LAST, so --- every task sank beneath the entire history and stayed there — a task added a --- second ago appeared at the bottom of today, under things from that morning. --- --- The write path now stamps every activity, tasks included. This is the same --- fix for the rows written before it: where a task sits on the timeline is when --- it was written down. `dueAt` is when it is *for*, and stays untouched. -UPDATE "activity" -SET "occurredAt" = "createdAt" -WHERE "occurredAt" IS NULL; diff --git a/packages/db/prisma/migrations/20260801190000_agent_task_attempts/migration.sql b/packages/db/prisma/migrations/20260801190000_agent_task_attempts/migration.sql deleted file mode 100644 index cd0eb94fb..000000000 --- a/packages/db/prisma/migrations/20260801190000_agent_task_attempts/migration.sql +++ /dev/null @@ -1,12 +0,0 @@ --- Bounded retries for the agent's work queue. --- --- `leasedUntil` alone cannot distinguish a run that died (retry it) from one --- that can never finish (stop). Rows in the second case were re-leased every --- ten minutes indefinitely, resuming the same durable session and paying for a --- model turn on each pass. -ALTER TABLE "agentTask" ADD COLUMN "attempts" INTEGER NOT NULL DEFAULT 0; - --- Existing open rows have already been dispatched at least once; several have --- been dispatched many times. Seed them at 1 rather than 0 so the cap counts --- from roughly where they actually are instead of granting a fresh allowance. -UPDATE "agentTask" SET "attempts" = 1 WHERE "startedAt" IS NOT NULL AND "finishedAt" IS NULL; diff --git a/packages/db/prisma/migrations/20260802120000_app_settings/migration.sql b/packages/db/prisma/migrations/20260802120000_app_settings/migration.sql deleted file mode 100644 index 38d93c4b7..000000000 --- a/packages/db/prisma/migrations/20260802120000_app_settings/migration.sql +++ /dev/null @@ -1,19 +0,0 @@ --- Install-wide settings a rep can change, starting with the agent's model. --- --- One row, id pinned to 'app' by `@crm/db/settings`. No unique constraint --- enforces that beyond the primary key, because the id is not derived from --- anything: a second row can only appear if something writes a different --- literal, and every writer goes through `writeAgentModel`. -CREATE TABLE "appSetting" ( - "id" TEXT NOT NULL, - "agentModelId" TEXT, - "agentModelContextWindow" INTEGER, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "appSetting_pkey" PRIMARY KEY ("id") -); - --- Deliberately not seeded. An absent row means "follow the built-in default", --- and writing the current default in here would freeze it: the next time --- DEFAULT_AGENT_MODEL changes, every existing install would keep running the --- old model while reporting itself as unconfigured. diff --git a/packages/db/prisma/migrations/20260803151440_add_workspace_organization/migration.sql b/packages/db/prisma/migrations/20260803151440_add_workspace_organization/migration.sql deleted file mode 100644 index d71015cd4..000000000 --- a/packages/db/prisma/migrations/20260803151440_add_workspace_organization/migration.sql +++ /dev/null @@ -1,70 +0,0 @@ --- AlterTable -ALTER TABLE "session" ADD COLUMN "activeOrganizationId" TEXT; - --- CreateTable -CREATE TABLE "organization" ( - "id" TEXT NOT NULL, - "name" TEXT NOT NULL, - "slug" TEXT NOT NULL, - "logo" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL, - "metadata" TEXT, - "website" TEXT, - - CONSTRAINT "organization_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "member" ( - "id" TEXT NOT NULL, - "organizationId" TEXT NOT NULL, - "userId" TEXT NOT NULL, - "role" TEXT NOT NULL DEFAULT 'member', - "createdAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "member_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "invitation" ( - "id" TEXT NOT NULL, - "organizationId" TEXT NOT NULL, - "email" TEXT NOT NULL, - "role" TEXT, - "status" TEXT NOT NULL DEFAULT 'pending', - "expiresAt" TIMESTAMP(3) NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "inviterId" TEXT NOT NULL, - - CONSTRAINT "invitation_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "organization_slug_key" ON "organization"("slug"); - --- CreateIndex -CREATE INDEX "member_organizationId_idx" ON "member"("organizationId"); - --- CreateIndex -CREATE INDEX "member_userId_idx" ON "member"("userId"); - --- CreateIndex -CREATE UNIQUE INDEX "member_organizationId_userId_key" ON "member"("organizationId", "userId"); - --- CreateIndex -CREATE INDEX "invitation_organizationId_idx" ON "invitation"("organizationId"); - --- CreateIndex -CREATE INDEX "invitation_email_idx" ON "invitation"("email"); - --- AddForeignKey -ALTER TABLE "member" ADD CONSTRAINT "member_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "member" ADD CONSTRAINT "member_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "invitation" ADD CONSTRAINT "invitation_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "invitation" ADD CONSTRAINT "invitation_inviterId_fkey" FOREIGN KEY ("inviterId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260803160044_add_sso_provider/migration.sql b/packages/db/prisma/migrations/20260803160044_add_sso_provider/migration.sql deleted file mode 100644 index ba0b26b7d..000000000 --- a/packages/db/prisma/migrations/20260803160044_add_sso_provider/migration.sql +++ /dev/null @@ -1,19 +0,0 @@ --- CreateTable -CREATE TABLE "ssoProvider" ( - "id" TEXT NOT NULL, - "issuer" TEXT NOT NULL, - "oidcConfig" TEXT, - "samlConfig" TEXT, - "userId" TEXT, - "providerId" TEXT NOT NULL, - "organizationId" TEXT, - "domain" TEXT NOT NULL, - - CONSTRAINT "ssoProvider_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "ssoProvider_providerId_key" ON "ssoProvider"("providerId"); - --- AddForeignKey -ALTER TABLE "ssoProvider" ADD CONSTRAINT "ssoProvider_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260803162518_workspace_profile/migration.sql b/packages/db/prisma/migrations/20260803162518_workspace_profile/migration.sql deleted file mode 100644 index d04ef8809..000000000 --- a/packages/db/prisma/migrations/20260803162518_workspace_profile/migration.sql +++ /dev/null @@ -1,19 +0,0 @@ --- AlterTable -ALTER TABLE "organization" ADD COLUMN "onboardedAt" TIMESTAMP(3); - --- An install that already told us its website has been onboarded; asking again --- would be a form it has already filled in. -UPDATE "organization" SET "onboardedAt" = "createdAt" WHERE "website" IS NOT NULL; - --- CreateTable -CREATE TABLE "workspaceProfile" ( - "id" TEXT NOT NULL, - "website" TEXT NOT NULL, - "narrative" TEXT NOT NULL, - "sections" JSONB NOT NULL, - "sourceUrl" TEXT, - "sessionId" TEXT, - "refreshedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "workspaceProfile_pkey" PRIMARY KEY ("id") -); diff --git a/packages/db/prisma/migrations/20260803190000_workspace_onboarded_metadata/migration.sql b/packages/db/prisma/migrations/20260803190000_workspace_onboarded_metadata/migration.sql deleted file mode 100644 index cdd32e17b..000000000 --- a/packages/db/prisma/migrations/20260803190000_workspace_onboarded_metadata/migration.sql +++ /dev/null @@ -1,19 +0,0 @@ --- Onboarding state moves into the organization plugin's own `metadata` blob. --- A workspace that has a website has answered the form, whether or not the --- column beside it ever recorded that it had. -UPDATE "organization" -SET "metadata" = ( - COALESCE(NULLIF("metadata", '')::jsonb, '{}'::jsonb) - || jsonb_build_object( - 'onboardedAt', - to_char( - COALESCE("onboardedAt", "createdAt") AT TIME ZONE 'UTC', - 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"' - ) - ) - )::text -WHERE "website" IS NOT NULL - AND COALESCE(NULLIF("metadata", '')::jsonb, '{}'::jsonb) -> 'onboardedAt' IS NULL; - --- AlterTable -ALTER TABLE "organization" DROP COLUMN "onboardedAt"; diff --git a/packages/db/prisma/migrations/20260803200000_blank_website_is_not_onboarded/migration.sql b/packages/db/prisma/migrations/20260803200000_blank_website_is_not_onboarded/migration.sql deleted file mode 100644 index 497d15275..000000000 --- a/packages/db/prisma/migrations/20260803200000_blank_website_is_not_onboarded/migration.sql +++ /dev/null @@ -1,12 +0,0 @@ --- The two backfills before this one keyed onboarding on `website IS NOT NULL`, --- which an empty or whitespace string satisfies. A workspace with a blank --- website has plainly not answered the form, and marking it onboarded is how it --- would never be asked — so undo the flag, and make the value itself honest. -UPDATE "organization" -SET "metadata" = ( - (COALESCE(NULLIF("metadata", '')::jsonb, '{}'::jsonb)) - 'onboardedAt' - )::text -WHERE btrim(COALESCE("website", '')) = '' - AND COALESCE(NULLIF("metadata", '')::jsonb, '{}'::jsonb) -> 'onboardedAt' IS NOT NULL; - -UPDATE "organization" SET "website" = NULL WHERE btrim(COALESCE("website", '')) = ''; diff --git a/packages/db/prisma/migrations/20260803210000_agent_builder_foundation/migration.sql b/packages/db/prisma/migrations/20260803210000_agent_builder_foundation/migration.sql deleted file mode 100644 index fd834dd7f..000000000 --- a/packages/db/prisma/migrations/20260803210000_agent_builder_foundation/migration.sql +++ /dev/null @@ -1,294 +0,0 @@ -CREATE TYPE "AgentConversationKind" AS ENUM ('RECORD', 'BUILDER'); -CREATE TYPE "AgentDefinitionStatus" AS ENUM ('DRAFT', 'DEPLOYING', 'LIVE', 'PAUSED', 'ARCHIVED', 'DELETED'); -CREATE TYPE "AgentVersionStatus" AS ENUM ('DRAFT', 'VALIDATING', 'READY', 'DEPLOYED', 'REJECTED'); -CREATE TYPE "AgentTriggerType" AS ENUM ('MANUAL', 'SCHEDULE', 'EVENT', 'WEBHOOK'); -CREATE TYPE "AgentRunStatus" AS ENUM ('QUEUED', 'RUNNING', 'WAITING_FOR_APPROVAL', 'SUCCEEDED', 'FAILED', 'CANCELLED'); -CREATE TYPE "AgentActionStatus" AS ENUM ('PLANNED', 'RUNNING', 'SUCCEEDED', 'FAILED', 'CANCELLED'); -CREATE TYPE "AgentConversationShareScope" AS ENUM ('WORKSPACE_LINK'); -CREATE TYPE "AgentConversationSubmissionStatus" AS ENUM ('PENDING', 'SENDING', 'ACCEPTED', 'FAILED', 'CANCELLED'); - -CREATE TYPE "AgentResponseRating" AS ENUM ('UP', 'DOWN'); - -ALTER TABLE "agentConversation" -ADD COLUMN "kind" "AgentConversationKind" NOT NULL DEFAULT 'RECORD', -ADD COLUMN "agentId" TEXT, -ADD COLUMN "updatedAt" TIMESTAMP(3), -ADD COLUMN "lastAssistantAt" TIMESTAMP(3), -ADD COLUMN "lastReadAt" TIMESTAMP(3); - -ALTER TABLE "agentConversation" -ALTER COLUMN "sessionId" DROP NOT NULL; - -UPDATE "agentConversation" -SET "updatedAt" = "lastMessageAt"; - -ALTER TABLE "agentConversation" -ALTER COLUMN "updatedAt" SET NOT NULL; - -CREATE TABLE "agentDefinition" ( - "id" TEXT NOT NULL, - "name" TEXT NOT NULL, - "description" TEXT, - "status" "AgentDefinitionStatus" NOT NULL DEFAULT 'DRAFT', - "createdById" TEXT NOT NULL, - "currentVersionId" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "archivedAt" TIMESTAMP(3), - "deletedAt" TIMESTAMP(3), - - CONSTRAINT "agentDefinition_pkey" PRIMARY KEY ("id") -); - -CREATE TABLE "agentVersion" ( - "id" TEXT NOT NULL, - "agentId" TEXT NOT NULL, - "number" INTEGER NOT NULL, - "status" "AgentVersionStatus" NOT NULL DEFAULT 'DRAFT', - "instructions" TEXT NOT NULL, - "manifest" JSONB NOT NULL, - "modelId" TEXT NOT NULL, - "sandboxPolicy" JSONB NOT NULL, - "validation" JSONB, - "sourceConversationId" TEXT, - "createdById" TEXT NOT NULL, - "deploymentId" TEXT, - "approvedAt" TIMESTAMP(3), - "deployedAt" TIMESTAMP(3), - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "agentVersion_pkey" PRIMARY KEY ("id") -); - -CREATE TABLE "agentTrigger" ( - "id" TEXT NOT NULL, - "agentId" TEXT NOT NULL, - "versionId" TEXT NOT NULL, - "type" "AgentTriggerType" NOT NULL, - "name" TEXT NOT NULL, - "config" JSONB NOT NULL, - "createdById" TEXT NOT NULL, - "enabled" BOOLEAN NOT NULL DEFAULT false, - "nextRunAt" TIMESTAMP(3), - "lastRunAt" TIMESTAMP(3), - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "agentTrigger_pkey" PRIMARY KEY ("id") -); - -CREATE TABLE "agentRun" ( - "id" TEXT NOT NULL, - "agentId" TEXT NOT NULL, - "versionId" TEXT NOT NULL, - "triggerId" TEXT, - "initiatedById" TEXT, - "triggerType" "AgentTriggerType" NOT NULL, - "status" "AgentRunStatus" NOT NULL DEFAULT 'QUEUED', - "principalId" TEXT, - "sessionId" TEXT, - "idempotencyKey" TEXT NOT NULL, - "correlationId" TEXT NOT NULL, - "input" JSONB, - "result" JSONB, - "summary" TEXT, - "modelId" TEXT, - "inputTokens" INTEGER, - "outputTokens" INTEGER, - "costUsd" DECIMAL(12,6), - "errorCode" TEXT, - "errorMessage" TEXT, - "nextEventSequence" INTEGER NOT NULL DEFAULT 0, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "startedAt" TIMESTAMP(3), - "finishedAt" TIMESTAMP(3), - - CONSTRAINT "agentRun_pkey" PRIMARY KEY ("id") -); - -CREATE TABLE "agentRunEvent" ( - "id" TEXT NOT NULL, - "runId" TEXT NOT NULL, - "sequence" INTEGER NOT NULL, - "type" TEXT NOT NULL, - "data" JSONB NOT NULL, - "emittedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "agentRunEvent_pkey" PRIMARY KEY ("id") -); - -CREATE TABLE "agentAction" ( - "id" TEXT NOT NULL, - "agentId" TEXT NOT NULL, - "runId" TEXT NOT NULL, - "type" TEXT NOT NULL, - "provider" TEXT NOT NULL, - "targetType" TEXT, - "targetId" TEXT, - "targetLabel" TEXT, - "summary" TEXT NOT NULL, - "metadata" JSONB, - "status" "AgentActionStatus" NOT NULL DEFAULT 'PLANNED', - "idempotencyKey" TEXT NOT NULL, - "requestHash" TEXT, - "externalId" TEXT, - "attemptCount" INTEGER NOT NULL DEFAULT 0, - "errorCode" TEXT, - "errorMessage" TEXT, - "plannedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "startedAt" TIMESTAMP(3), - "completedAt" TIMESTAMP(3), - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "agentAction_pkey" PRIMARY KEY ("id") -); - -CREATE TABLE "agentAuditEvent" ( - "id" TEXT NOT NULL, - "agentId" TEXT NOT NULL, - "versionId" TEXT, - "actorUserId" TEXT, - "type" TEXT NOT NULL, - "actorType" TEXT NOT NULL, - "actorId" TEXT, - "summary" TEXT NOT NULL, - "before" JSONB, - "after" JSONB, - "requestId" TEXT, - "emittedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "agentAuditEvent_pkey" PRIMARY KEY ("id") -); - -CREATE TABLE "agentConversationShare" ( - "id" TEXT NOT NULL, - "conversationId" TEXT NOT NULL, - "createdById" TEXT NOT NULL, - "scope" "AgentConversationShareScope" NOT NULL DEFAULT 'WORKSPACE_LINK', - "tokenHash" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "expiresAt" TIMESTAMP(3), - "revokedAt" TIMESTAMP(3), - - CONSTRAINT "agentConversationShare_pkey" PRIMARY KEY ("id") -); - -CREATE TABLE "agentConversationSubmission" ( - "id" TEXT NOT NULL, - "conversationId" TEXT NOT NULL, - "submittedById" TEXT NOT NULL, - "clientRequestId" TEXT NOT NULL, - "message" JSONB NOT NULL, - "status" "AgentConversationSubmissionStatus" NOT NULL DEFAULT 'PENDING', - "attemptCount" INTEGER NOT NULL DEFAULT 0, - "errorCode" TEXT, - "errorMessage" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "sentAt" TIMESTAMP(3), - "acceptedAt" TIMESTAMP(3), - - CONSTRAINT "agentConversationSubmission_pkey" PRIMARY KEY ("id") -); - -CREATE TABLE "agentConversationFeedback" ( - "id" TEXT NOT NULL, - "conversationId" TEXT NOT NULL, - "userId" TEXT NOT NULL, - "messageId" TEXT NOT NULL, - "rating" "AgentResponseRating" NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "agentConversationFeedback_pkey" PRIMARY KEY ("id") -); - -CREATE UNIQUE INDEX "agentDefinition_currentVersionId_key" ON "agentDefinition"("currentVersionId"); -CREATE UNIQUE INDEX "agentDefinition_currentVersionId_id_key" ON "agentDefinition"("currentVersionId", "id"); -CREATE INDEX "agentDefinition_status_updatedAt_idx" ON "agentDefinition"("status", "updatedAt"); -CREATE INDEX "agentDefinition_createdById_createdAt_idx" ON "agentDefinition"("createdById", "createdAt"); - -CREATE UNIQUE INDEX "agentVersion_agentId_number_key" ON "agentVersion"("agentId", "number"); -CREATE UNIQUE INDEX "agentVersion_id_agentId_key" ON "agentVersion"("id", "agentId"); -CREATE INDEX "agentVersion_agentId_createdAt_idx" ON "agentVersion"("agentId", "createdAt"); -CREATE INDEX "agentVersion_status_createdAt_idx" ON "agentVersion"("status", "createdAt"); - -CREATE INDEX "agentTrigger_agentId_enabled_idx" ON "agentTrigger"("agentId", "enabled"); -CREATE INDEX "agentTrigger_enabled_nextRunAt_idx" ON "agentTrigger"("enabled", "nextRunAt"); -CREATE INDEX "agentTrigger_versionId_idx" ON "agentTrigger"("versionId"); -CREATE UNIQUE INDEX "agentTrigger_id_agentId_key" ON "agentTrigger"("id", "agentId"); - -CREATE UNIQUE INDEX "agentRun_sessionId_key" ON "agentRun"("sessionId"); -CREATE UNIQUE INDEX "agentRun_idempotencyKey_key" ON "agentRun"("idempotencyKey"); -CREATE UNIQUE INDEX "agentRun_correlationId_key" ON "agentRun"("correlationId"); -CREATE INDEX "agentRun_agentId_createdAt_idx" ON "agentRun"("agentId", "createdAt"); -CREATE INDEX "agentRun_versionId_createdAt_idx" ON "agentRun"("versionId", "createdAt"); -CREATE INDEX "agentRun_status_createdAt_idx" ON "agentRun"("status", "createdAt"); -CREATE INDEX "agentRun_triggerId_createdAt_idx" ON "agentRun"("triggerId", "createdAt"); -CREATE UNIQUE INDEX "agentRun_id_agentId_key" ON "agentRun"("id", "agentId"); - -CREATE UNIQUE INDEX "agentRunEvent_runId_sequence_key" ON "agentRunEvent"("runId", "sequence"); -CREATE INDEX "agentRunEvent_runId_emittedAt_idx" ON "agentRunEvent"("runId", "emittedAt"); - -CREATE UNIQUE INDEX "agentAction_idempotencyKey_key" ON "agentAction"("idempotencyKey"); -CREATE INDEX "agentAction_agentId_plannedAt_idx" ON "agentAction"("agentId", "plannedAt"); -CREATE INDEX "agentAction_runId_plannedAt_idx" ON "agentAction"("runId", "plannedAt"); -CREATE INDEX "agentAction_provider_externalId_idx" ON "agentAction"("provider", "externalId"); -CREATE INDEX "agentAction_status_plannedAt_idx" ON "agentAction"("status", "plannedAt"); - -CREATE INDEX "agentAuditEvent_agentId_emittedAt_idx" ON "agentAuditEvent"("agentId", "emittedAt"); -CREATE INDEX "agentAuditEvent_versionId_emittedAt_idx" ON "agentAuditEvent"("versionId", "emittedAt"); -CREATE INDEX "agentAuditEvent_actorUserId_emittedAt_idx" ON "agentAuditEvent"("actorUserId", "emittedAt"); -CREATE INDEX "agentAuditEvent_type_emittedAt_idx" ON "agentAuditEvent"("type", "emittedAt"); - -CREATE UNIQUE INDEX "agentAuditEvent_agentId_type_requestId_key" ON "agentAuditEvent"("agentId", "type", "requestId"); - -CREATE UNIQUE INDEX "agentConversationShare_tokenHash_key" ON "agentConversationShare"("tokenHash"); -CREATE INDEX "agentConversationShare_conversationId_revokedAt_idx" ON "agentConversationShare"("conversationId", "revokedAt"); - -CREATE UNIQUE INDEX "agentConversationSubmission_clientRequestId_key" ON "agentConversationSubmission"("clientRequestId"); -CREATE INDEX "agentConversationSubmission_conversationId_createdAt_idx" ON "agentConversationSubmission"("conversationId", "createdAt"); -CREATE INDEX "agentConversationSubmission_status_createdAt_idx" ON "agentConversationSubmission"("status", "createdAt"); - -CREATE UNIQUE INDEX "agentConversationFeedback_conversationId_userId_messageId_key" ON "agentConversationFeedback"("conversationId", "userId", "messageId"); - -CREATE INDEX "agentConversationFeedback_conversationId_createdAt_idx" ON "agentConversationFeedback"("conversationId", "createdAt"); - -CREATE INDEX "agentConversation_userId_kind_lastMessageAt_idx" ON "agentConversation"("userId", "kind", "lastMessageAt"); -CREATE INDEX "agentConversation_agentId_lastMessageAt_idx" ON "agentConversation"("agentId", "lastMessageAt"); - -ALTER TABLE "agentDefinition" ADD CONSTRAINT "agentDefinition_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -ALTER TABLE "agentDefinition" ADD CONSTRAINT "agentDefinition_currentVersionId_id_fkey" FOREIGN KEY ("currentVersionId", "id") REFERENCES "agentVersion"("id", "agentId") ON DELETE RESTRICT ON UPDATE CASCADE; - -ALTER TABLE "agentVersion" ADD CONSTRAINT "agentVersion_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "agentDefinition"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -ALTER TABLE "agentVersion" ADD CONSTRAINT "agentVersion_sourceConversationId_fkey" FOREIGN KEY ("sourceConversationId") REFERENCES "agentConversation"("id") ON DELETE SET NULL ON UPDATE CASCADE; -ALTER TABLE "agentVersion" ADD CONSTRAINT "agentVersion_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - -ALTER TABLE "agentConversation" ADD CONSTRAINT "agentConversation_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "agentDefinition"("id") ON DELETE SET NULL ON UPDATE CASCADE; - -ALTER TABLE "agentTrigger" ADD CONSTRAINT "agentTrigger_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "agentDefinition"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -ALTER TABLE "agentTrigger" ADD CONSTRAINT "agentTrigger_versionId_agentId_fkey" FOREIGN KEY ("versionId", "agentId") REFERENCES "agentVersion"("id", "agentId") ON DELETE RESTRICT ON UPDATE CASCADE; -ALTER TABLE "agentTrigger" ADD CONSTRAINT "agentTrigger_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - -ALTER TABLE "agentRun" ADD CONSTRAINT "agentRun_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "agentDefinition"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -ALTER TABLE "agentRun" ADD CONSTRAINT "agentRun_versionId_agentId_fkey" FOREIGN KEY ("versionId", "agentId") REFERENCES "agentVersion"("id", "agentId") ON DELETE RESTRICT ON UPDATE CASCADE; -ALTER TABLE "agentRun" ADD CONSTRAINT "agentRun_triggerId_agentId_fkey" FOREIGN KEY ("triggerId", "agentId") REFERENCES "agentTrigger"("id", "agentId") ON DELETE RESTRICT ON UPDATE CASCADE; -ALTER TABLE "agentRun" ADD CONSTRAINT "agentRun_initiatedById_fkey" FOREIGN KEY ("initiatedById") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE; - -ALTER TABLE "agentRunEvent" ADD CONSTRAINT "agentRunEvent_runId_fkey" FOREIGN KEY ("runId") REFERENCES "agentRun"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - -ALTER TABLE "agentAction" ADD CONSTRAINT "agentAction_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "agentDefinition"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -ALTER TABLE "agentAction" ADD CONSTRAINT "agentAction_runId_agentId_fkey" FOREIGN KEY ("runId", "agentId") REFERENCES "agentRun"("id", "agentId") ON DELETE RESTRICT ON UPDATE CASCADE; - -ALTER TABLE "agentAuditEvent" ADD CONSTRAINT "agentAuditEvent_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "agentDefinition"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -ALTER TABLE "agentAuditEvent" ADD CONSTRAINT "agentAuditEvent_versionId_agentId_fkey" FOREIGN KEY ("versionId", "agentId") REFERENCES "agentVersion"("id", "agentId") ON DELETE RESTRICT ON UPDATE CASCADE; -ALTER TABLE "agentAuditEvent" ADD CONSTRAINT "agentAuditEvent_actorUserId_fkey" FOREIGN KEY ("actorUserId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE; - -ALTER TABLE "agentConversationShare" ADD CONSTRAINT "agentConversationShare_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "agentConversation"("id") ON DELETE CASCADE ON UPDATE CASCADE; -ALTER TABLE "agentConversationShare" ADD CONSTRAINT "agentConversationShare_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; - -ALTER TABLE "agentConversationSubmission" ADD CONSTRAINT "agentConversationSubmission_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "agentConversation"("id") ON DELETE CASCADE ON UPDATE CASCADE; -ALTER TABLE "agentConversationSubmission" ADD CONSTRAINT "agentConversationSubmission_submittedById_fkey" FOREIGN KEY ("submittedById") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - -ALTER TABLE "agentConversationFeedback" ADD CONSTRAINT "agentConversationFeedback_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "agentConversation"("id") ON DELETE CASCADE ON UPDATE CASCADE; - -ALTER TABLE "agentConversationFeedback" ADD CONSTRAINT "agentConversationFeedback_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260803210000_suppressed_contact/migration.sql b/packages/db/prisma/migrations/20260803210000_suppressed_contact/migration.sql deleted file mode 100644 index e480fb19e..000000000 --- a/packages/db/prisma/migrations/20260803210000_suppressed_contact/migration.sql +++ /dev/null @@ -1,8 +0,0 @@ --- CreateTable -CREATE TABLE "suppressedContact" ( - "email" TEXT NOT NULL, - "reason" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "suppressedContact_pkey" PRIMARY KEY ("email") -); diff --git a/packages/db/prisma/migrations/20260803220000_context_dev_api_key/migration.sql b/packages/db/prisma/migrations/20260803220000_context_dev_api_key/migration.sql deleted file mode 100644 index ed92debbe..000000000 --- a/packages/db/prisma/migrations/20260803220000_context_dev_api_key/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "appSetting" ADD COLUMN "contextDevApiKey" TEXT; diff --git a/packages/db/prisma/migrations/20260804160000_agent_conversation_command_type/migration.sql b/packages/db/prisma/migrations/20260804160000_agent_conversation_command_type/migration.sql deleted file mode 100644 index 3d85d0a3f..000000000 --- a/packages/db/prisma/migrations/20260804160000_agent_conversation_command_type/migration.sql +++ /dev/null @@ -1,8 +0,0 @@ -CREATE TYPE "AgentConversationCommandType" AS ENUM ('CHAT', 'CREATE_AGENT'); - -ALTER TABLE "agentConversationSubmission" -ADD COLUMN "commandType" "AgentConversationCommandType" NOT NULL DEFAULT 'CHAT'; - -UPDATE "agentConversationSubmission" -SET "commandType" = 'CREATE_AGENT' -WHERE "message"->>'text' ~* '^\s*/create\s+agent(?:\s|$)'; diff --git a/packages/db/prisma/migrations/20260804210000_agent_builder_artifacts/migration.sql b/packages/db/prisma/migrations/20260804210000_agent_builder_artifacts/migration.sql deleted file mode 100644 index 91a656974..000000000 --- a/packages/db/prisma/migrations/20260804210000_agent_builder_artifacts/migration.sql +++ /dev/null @@ -1,25 +0,0 @@ -CREATE TYPE "AgentBuilderArtifactStatus" AS ENUM ('WRITING', 'READY'); - -CREATE TABLE "agentBuilderArtifact" ( - "id" TEXT NOT NULL, - "conversationId" TEXT, - "versionId" TEXT, - "path" TEXT NOT NULL, - "language" TEXT NOT NULL, - "content" TEXT NOT NULL, - "previousContent" TEXT, - "revision" INTEGER NOT NULL, - "status" "AgentBuilderArtifactStatus" NOT NULL DEFAULT 'WRITING', - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "agentBuilderArtifact_pkey" PRIMARY KEY ("id"), - CONSTRAINT "agentBuilderArtifact_owner_present" CHECK ("conversationId" IS NOT NULL OR "versionId" IS NOT NULL) -); - -CREATE UNIQUE INDEX "agentBuilderArtifact_conversation_path_revision_key" ON "agentBuilderArtifact"("conversationId", "path", "revision") WHERE "conversationId" IS NOT NULL; -CREATE UNIQUE INDEX "agentBuilderArtifact_version_path_revision_key" ON "agentBuilderArtifact"("versionId", "path", "revision") WHERE "versionId" IS NOT NULL; -CREATE INDEX "agentBuilderArtifact_conversationId_createdAt_idx" ON "agentBuilderArtifact"("conversationId", "createdAt"); -CREATE INDEX "agentBuilderArtifact_versionId_path_idx" ON "agentBuilderArtifact"("versionId", "path"); - -ALTER TABLE "agentBuilderArtifact" ADD CONSTRAINT "agentBuilderArtifact_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "agentConversation"("id") ON DELETE SET NULL ON UPDATE CASCADE; -ALTER TABLE "agentBuilderArtifact" ADD CONSTRAINT "agentBuilderArtifact_versionId_fkey" FOREIGN KEY ("versionId") REFERENCES "agentVersion"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260805120000_deal_description/migration.sql b/packages/db/prisma/migrations/20260805120000_deal_description/migration.sql deleted file mode 100644 index 71b729d7e..000000000 --- a/packages/db/prisma/migrations/20260805120000_deal_description/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "deal" ADD COLUMN "description" TEXT; diff --git a/packages/db/prisma/migrations/20260805130000_builder_submission_concurrency/migration.sql b/packages/db/prisma/migrations/20260805130000_builder_submission_concurrency/migration.sql deleted file mode 100644 index f32edffc3..000000000 --- a/packages/db/prisma/migrations/20260805130000_builder_submission_concurrency/migration.sql +++ /dev/null @@ -1,5 +0,0 @@ -ALTER TABLE "agentConversationSubmission" -ADD COLUMN "inputRequestId" TEXT; - -CREATE UNIQUE INDEX "agentConversationSubmission_conversationId_inputRequestId_key" -ON "agentConversationSubmission"("conversationId", "inputRequestId"); diff --git a/packages/db/prisma/migrations/20260805130000_multi_currency/migration.sql b/packages/db/prisma/migrations/20260805130000_multi_currency/migration.sql deleted file mode 100644 index 7518d9528..000000000 --- a/packages/db/prisma/migrations/20260805130000_multi_currency/migration.sql +++ /dev/null @@ -1,62 +0,0 @@ --- CreateEnum -CREATE TYPE "RateSource" AS ENUM ('FETCHED', 'MANUAL'); - --- AlterTable -ALTER TABLE "appSetting" ADD COLUMN "reportingCurrency" TEXT, -ADD COLUMN "ratesRefreshedAt" TIMESTAMP(3); - --- AlterTable -ALTER TABLE "deal" ADD COLUMN "baseAmount" DECIMAL(18,4), -ADD COLUMN "fxRate" DECIMAL(20,10), -ADD COLUMN "fxRateAt" TIMESTAMP(3); - --- CreateTable -CREATE TABLE "exchangeRate" ( - "id" TEXT NOT NULL, - "baseCurrency" TEXT NOT NULL, - "quoteCurrency" TEXT NOT NULL, - "rate" DECIMAL(20,10) NOT NULL, - "asOf" TIMESTAMP(3) NOT NULL, - "source" "RateSource" NOT NULL, - "provider" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "exchangeRate_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "exchangeRate_baseCurrency_quoteCurrency_source_key" ON "exchangeRate"("baseCurrency", "quoteCurrency", "source"); - --- CreateIndex -CREATE INDEX "exchangeRate_baseCurrency_quoteCurrency_idx" ON "exchangeRate"("baseCurrency", "quoteCurrency"); - --- CreateIndex -CREATE INDEX "deal_baseAmount_idx" ON "deal"("baseAmount"); - --- CreateIndex -CREATE INDEX "deal_currency_idx" ON "deal"("currency"); - --- Backfill: convert the rows we can account for without inventing a rate, which --- is the ones already denominated in the reporting currency. Skipping this would --- drop every existing deal out of every total the moment this migration lands. --- --- Rows in any other currency are deliberately left null rather than counted at --- 1:1 — the currency column has been free text until now, so a deal really can --- be sitting there in EUR, and calling that figure dollars would be worse than --- leaving it out. A null is excluded from every total AND reported by the --- unconverted disclosure, and the first rates refresh picks it up through --- fillMissing(). -UPDATE "deal" -SET "baseAmount" = "amount", - "fxRate" = 1, - "fxRateAt" = CURRENT_TIMESTAMP -WHERE "amount" IS NOT NULL - AND upper(btrim("currency")) = COALESCE( - ( - SELECT upper(btrim("reportingCurrency")) - FROM "appSetting" - WHERE "id" = 'app' AND btrim(COALESCE("reportingCurrency", '')) <> '' - ), - 'USD' - ); diff --git a/packages/db/prisma/migrations/20260805140000_single_active_conversation_share/migration.sql b/packages/db/prisma/migrations/20260805140000_single_active_conversation_share/migration.sql deleted file mode 100644 index 9206d47da..000000000 --- a/packages/db/prisma/migrations/20260805140000_single_active_conversation_share/migration.sql +++ /dev/null @@ -1,19 +0,0 @@ -WITH ranked_shares AS ( - SELECT - id, - ROW_NUMBER() OVER ( - PARTITION BY "conversationId" - ORDER BY "createdAt" DESC, id DESC - ) AS position - FROM "agentConversationShare" - WHERE "revokedAt" IS NULL -) -UPDATE "agentConversationShare" AS share -SET "revokedAt" = NOW() -FROM ranked_shares -WHERE share.id = ranked_shares.id - AND ranked_shares.position > 1; - -CREATE UNIQUE INDEX "agentConversationShare_one_active_per_conversation" -ON "agentConversationShare"("conversationId") -WHERE "revokedAt" IS NULL; diff --git a/packages/db/prisma/migrations/20260805150000_deal_base_currency/migration.sql b/packages/db/prisma/migrations/20260805150000_deal_base_currency/migration.sql deleted file mode 100644 index 096880b42..000000000 --- a/packages/db/prisma/migrations/20260805150000_deal_base_currency/migration.sql +++ /dev/null @@ -1,19 +0,0 @@ --- AlterTable -ALTER TABLE "deal" ALTER COLUMN "baseAmount" TYPE DECIMAL(24,4); - --- AlterTable -ALTER TABLE "deal" ADD COLUMN "baseCurrency" TEXT; - --- Backfill: a converted figure that does not say which currency it is in cannot --- be told apart from a stale one. Every row that already has a baseAmount was --- converted against the current reporting currency, so name it. -UPDATE "deal" -SET "baseCurrency" = COALESCE( - ( - SELECT upper(btrim("reportingCurrency")) - FROM "appSetting" - WHERE "id" = 'app' AND btrim(COALESCE("reportingCurrency", '')) <> '' - ), - 'USD' - ) -WHERE "baseAmount" IS NOT NULL; diff --git a/packages/db/prisma/migrations/20260805160000_telemetry_install/migration.sql b/packages/db/prisma/migrations/20260805160000_telemetry_install/migration.sql deleted file mode 100644 index 11cd9d803..000000000 --- a/packages/db/prisma/migrations/20260805160000_telemetry_install/migration.sql +++ /dev/null @@ -1,58 +0,0 @@ --- The anonymous install identity, in Postgres rather than on disk. --- --- All three processes deploy to Vercel, where the filesystem is ephemeral, so --- the usual ~/.app/telemetry-id file would mint a new UUID on every cold start --- and we would be counting containers instead of installs. --- --- One row, id pinned to 'install' by `@crm/telemetry`, the same singleton --- shape as "appSetting". The row is written here rather than on first boot so --- that the ID exists from the moment the schema does — "createdAt" is then a --- real install date, and the first funnel step is stamped by the statement --- that created the install rather than by whichever process happened to start. --- --- "version" is upserted from the root package.json on every boot, so an --- upgrade is visible without redeploying telemetry code. 'unknown' only --- survives until the API or the agent next starts. -CREATE TABLE "install" ( - "id" TEXT NOT NULL, - "uuid" TEXT NOT NULL, - "version" TEXT NOT NULL, - "lastRollupAt" TIMESTAMP(3), - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "install_pkey" PRIMARY KEY ("id") -); - -CREATE UNIQUE INDEX "install_uuid_key" ON "install"("uuid"); - -INSERT INTO "install" ("id", "uuid", "version", "updatedAt") -VALUES ('install', gen_random_uuid()::text, 'unknown', CURRENT_TIMESTAMP); - --- The setup funnel is one-shot: each step fires once per install, ever. A --- boolean column read and then written races between the API and the agent; --- an insert that reports whether it was the one to land does not. -CREATE TABLE "telemetryMilestone" ( - "step" TEXT NOT NULL, - "reachedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "telemetryMilestone_pkey" PRIMARY KEY ("step") -); - --- Counters for what the daily rollup wants that leaves no other row: today --- that is a session that stopped because its research budget ran out, and --- nothing else. Everything else the rollup reports is already in the schema --- and is counted there. --- --- Drained with DELETE … RETURNING and recreated by the next `upsert`, so a row --- holds only what has arrived since the last rollup. An increment landing --- mid-drain waits on the row lock and then inserts a fresh row, so it is --- counted tomorrow rather than lost. A rollup that fails to send puts the --- counts back. -CREATE TABLE "telemetryCounter" ( - "name" TEXT NOT NULL, - "count" INTEGER NOT NULL DEFAULT 0, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "telemetryCounter_pkey" PRIMARY KEY ("name") -); diff --git a/packages/db/prisma/migrations/20260805220000_conversation_attachments/migration.sql b/packages/db/prisma/migrations/20260805220000_conversation_attachments/migration.sql deleted file mode 100644 index 126811fd5..000000000 --- a/packages/db/prisma/migrations/20260805220000_conversation_attachments/migration.sql +++ /dev/null @@ -1,85 +0,0 @@ -CREATE TABLE "agentConversationAttachment" ( - "id" TEXT NOT NULL, - "submissionId" TEXT NOT NULL, - "name" TEXT NOT NULL, - "mediaType" TEXT NOT NULL, - "size" INTEGER NOT NULL, - "content" BYTEA NOT NULL, - "position" INTEGER NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "agentConversationAttachment_pkey" PRIMARY KEY ("id") -); - -CREATE INDEX "agentConversationAttachment_submissionId_position_idx" -ON "agentConversationAttachment"("submissionId", "position"); - -ALTER TABLE "agentConversationAttachment" -ADD CONSTRAINT "agentConversationAttachment_submissionId_fkey" -FOREIGN KEY ("submissionId") REFERENCES "agentConversationSubmission"("id") -ON DELETE CASCADE ON UPDATE CASCADE; - -WITH legacy_attachments AS ( - SELECT - submission.id AS "submissionId", - attachment.value, - attachment.ordinality, - 'legacy_' || md5(submission.id || ':' || attachment.ordinality::text) AS id - FROM "agentConversationSubmission" AS submission - CROSS JOIN LATERAL jsonb_array_elements( - CASE - WHEN jsonb_typeof(submission.message -> 'attachments') = 'array' - THEN submission.message -> 'attachments' - ELSE '[]'::jsonb - END - ) WITH ORDINALITY AS attachment(value, ordinality) - WHERE attachment.value ? 'contentBase64' - AND attachment.value ->> 'contentBase64' <> '' - AND attachment.value ->> 'contentBase64' ~ '^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$' -) -INSERT INTO "agentConversationAttachment" ( - "id", - "submissionId", - "name", - "mediaType", - "size", - "content", - "position" -) -SELECT - id, - "submissionId", - left(COALESCE(NULLIF(value ->> 'name', ''), 'attachment'), 180), - left(COALESCE(NULLIF(value ->> 'type', ''), 'application/octet-stream'), 120), - octet_length(decode(value ->> 'contentBase64', 'base64')), - decode(value ->> 'contentBase64', 'base64'), - ordinality - 1 -FROM legacy_attachments; - -UPDATE "agentConversationSubmission" AS submission -SET message = jsonb_set( - submission.message, - '{attachments}', - ( - SELECT COALESCE( - jsonb_agg( - CASE - WHEN stored.id IS NOT NULL THEN jsonb_build_object( - 'id', stored.id, - 'name', stored.name, - 'type', stored."mediaType", - 'size', stored.size - ) - ELSE attachment.value - END - ORDER BY attachment.ordinality - ), - '[]'::jsonb - ) - FROM jsonb_array_elements(submission.message -> 'attachments') - WITH ORDINALITY AS attachment(value, ordinality) - LEFT JOIN "agentConversationAttachment" AS stored - ON stored.id = 'legacy_' || md5(submission.id || ':' || attachment.ordinality::text) - ) -) -WHERE jsonb_typeof(submission.message -> 'attachments') = 'array'; diff --git a/packages/db/prisma/migrations/20260806140000_dynamic_fields/migration.sql b/packages/db/prisma/migrations/20260806140000_dynamic_fields/migration.sql deleted file mode 100644 index 5d7a41aed..000000000 --- a/packages/db/prisma/migrations/20260806140000_dynamic_fields/migration.sql +++ /dev/null @@ -1,102 +0,0 @@ --- CreateEnum -CREATE TYPE "FieldEntity" AS ENUM ('COMPANY', 'CONTACT', 'DEAL'); - --- CreateEnum -CREATE TYPE "FieldType" AS ENUM ('TEXT', 'LONG_TEXT', 'NUMBER', 'DATE', 'CHECKBOX', 'SELECT', 'URL', 'EMAIL', 'PHONE', 'USER'); - --- CreateTable -CREATE TABLE "fieldDefinition" ( - "id" TEXT NOT NULL, - "entity" "FieldEntity" NOT NULL, - "key" TEXT NOT NULL, - "label" TEXT NOT NULL, - "type" "FieldType" NOT NULL, - "agentFilled" BOOLEAN NOT NULL DEFAULT true, - "agentBrief" TEXT, - "required" BOOLEAN NOT NULL DEFAULT false, - "showOnSheet" BOOLEAN NOT NULL DEFAULT true, - "showOnTable" BOOLEAN NOT NULL DEFAULT false, - "position" INTEGER NOT NULL, - "archivedAt" TIMESTAMP(3), - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "fieldDefinition_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "fieldOption" ( - "id" TEXT NOT NULL, - "fieldId" TEXT NOT NULL, - "label" TEXT NOT NULL, - "position" INTEGER NOT NULL, - "archivedAt" TIMESTAMP(3), - - CONSTRAINT "fieldOption_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "fieldValue" ( - "id" TEXT NOT NULL, - "fieldId" TEXT NOT NULL, - "companyId" TEXT, - "contactId" TEXT, - "dealId" TEXT, - "text" TEXT, - "number" DECIMAL(24,4), - "date" TIMESTAMP(3), - "bool" BOOLEAN, - "optionId" TEXT, - "userId" TEXT, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "fieldValue_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX "fieldDefinition_entity_position_idx" ON "fieldDefinition"("entity", "position"); - --- CreateIndex -CREATE UNIQUE INDEX "fieldDefinition_entity_key_key" ON "fieldDefinition"("entity", "key"); - --- CreateIndex -CREATE INDEX "fieldOption_fieldId_position_idx" ON "fieldOption"("fieldId", "position"); - --- CreateIndex -CREATE INDEX "fieldValue_fieldId_text_idx" ON "fieldValue"("fieldId", "text"); - --- CreateIndex -CREATE INDEX "fieldValue_fieldId_number_idx" ON "fieldValue"("fieldId", "number"); - --- CreateIndex -CREATE INDEX "fieldValue_fieldId_date_idx" ON "fieldValue"("fieldId", "date"); - --- CreateIndex -CREATE INDEX "fieldValue_optionId_idx" ON "fieldValue"("optionId"); - --- CreateIndex -CREATE UNIQUE INDEX "fieldValue_fieldId_companyId_key" ON "fieldValue"("fieldId", "companyId"); - --- CreateIndex -CREATE UNIQUE INDEX "fieldValue_fieldId_contactId_key" ON "fieldValue"("fieldId", "contactId"); - --- CreateIndex -CREATE UNIQUE INDEX "fieldValue_fieldId_dealId_key" ON "fieldValue"("fieldId", "dealId"); - --- AddForeignKey -ALTER TABLE "fieldOption" ADD CONSTRAINT "fieldOption_fieldId_fkey" FOREIGN KEY ("fieldId") REFERENCES "fieldDefinition"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "fieldValue" ADD CONSTRAINT "fieldValue_fieldId_fkey" FOREIGN KEY ("fieldId") REFERENCES "fieldDefinition"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "fieldValue" ADD CONSTRAINT "fieldValue_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "company"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "fieldValue" ADD CONSTRAINT "fieldValue_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "contact"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "fieldValue" ADD CONSTRAINT "fieldValue_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "deal"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "fieldValue" ADD CONSTRAINT "fieldValue_optionId_fkey" FOREIGN KEY ("optionId") REFERENCES "fieldOption"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260806150000_field_value_record_indexes_and_user_fk/migration.sql b/packages/db/prisma/migrations/20260806150000_field_value_record_indexes_and_user_fk/migration.sql deleted file mode 100644 index b5307323a..000000000 --- a/packages/db/prisma/migrations/20260806150000_field_value_record_indexes_and_user_fk/migration.sql +++ /dev/null @@ -1,18 +0,0 @@ --- CreateIndex -CREATE INDEX "fieldValue_companyId_idx" ON "fieldValue"("companyId"); - --- CreateIndex -CREATE INDEX "fieldValue_contactId_idx" ON "fieldValue"("contactId"); - --- CreateIndex -CREATE INDEX "fieldValue_dealId_idx" ON "fieldValue"("dealId"); - --- CreateIndex -CREATE INDEX "fieldValue_userId_idx" ON "fieldValue"("userId"); - -UPDATE "fieldValue" SET "userId" = NULL -WHERE "userId" IS NOT NULL - AND "userId" NOT IN (SELECT "id" FROM "user"); - --- AddForeignKey -ALTER TABLE "fieldValue" ADD CONSTRAINT "fieldValue_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260806160000_agent_version_model_context_window/migration.sql b/packages/db/prisma/migrations/20260806160000_agent_version_model_context_window/migration.sql deleted file mode 100644 index 09d9297ab..000000000 --- a/packages/db/prisma/migrations/20260806160000_agent_version_model_context_window/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "agentVersion" -ADD COLUMN "modelContextWindowTokens" INTEGER NOT NULL DEFAULT 1000000; diff --git a/packages/db/prisma/migrations/20260807120000_outlook_mail/migration.sql b/packages/db/prisma/migrations/20260807120000_outlook_mail/migration.sql deleted file mode 100644 index 7f6b674a1..000000000 --- a/packages/db/prisma/migrations/20260807120000_outlook_mail/migration.sql +++ /dev/null @@ -1,3 +0,0 @@ --- AlterTable -ALTER TABLE "emailMessage" ADD COLUMN "outlookMessageId" TEXT; -ALTER TABLE "emailMessage" ADD COLUMN "outlookWebLink" TEXT; diff --git a/packages/db/prisma/migrations/20260809120000_slack_member_matches/migration.sql b/packages/db/prisma/migrations/20260809120000_slack_member_matches/migration.sql deleted file mode 100644 index 66394273a..000000000 --- a/packages/db/prisma/migrations/20260809120000_slack_member_matches/migration.sql +++ /dev/null @@ -1,30 +0,0 @@ -CREATE TABLE "slackMemberMatch" ( - "id" TEXT NOT NULL, - "crmUserId" TEXT NOT NULL, - "slackUserId" TEXT, - "slackHandle" TEXT, - "slackEmail" TEXT, - "explicitlyUnmatched" BOOLEAN NOT NULL DEFAULT false, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "slackMemberMatch_pkey" PRIMARY KEY ("id") -); - -CREATE UNIQUE INDEX "slackMemberMatch_crmUserId_key" ON "slackMemberMatch"("crmUserId"); -CREATE INDEX "slackMemberMatch_slackUserId_idx" ON "slackMemberMatch"("slackUserId"); - -ALTER TABLE "slackMemberMatch" ADD CONSTRAINT "slackMemberMatch_crmUserId_fkey" FOREIGN KEY ("crmUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; - -CREATE TABLE "slackChannel" ( - "id" TEXT NOT NULL, - "name" TEXT NOT NULL, - "memberCount" INTEGER, - "available" BOOLEAN NOT NULL DEFAULT true, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "slackChannel_pkey" PRIMARY KEY ("id") -); - -CREATE INDEX "slackChannel_available_name_idx" ON "slackChannel"("available", "name"); diff --git a/packages/db/prisma/migrations/20260810090000_agent_conversation_pending_input/migration.sql b/packages/db/prisma/migrations/20260810090000_agent_conversation_pending_input/migration.sql deleted file mode 100644 index 24d2b9bdc..000000000 --- a/packages/db/prisma/migrations/20260810090000_agent_conversation_pending_input/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "agentConversation" -ADD COLUMN "pendingInputRequest" JSONB; diff --git a/packages/db/prisma/migrations/20260810100000_agent_task_deal_events/migration.sql b/packages/db/prisma/migrations/20260810100000_agent_task_deal_events/migration.sql deleted file mode 100644 index 997e2c199..000000000 --- a/packages/db/prisma/migrations/20260810100000_agent_task_deal_events/migration.sql +++ /dev/null @@ -1,5 +0,0 @@ -ALTER TABLE "agentTask" -ADD COLUMN "dealId" TEXT, -ADD COLUMN "payload" JSONB; - -CREATE INDEX "agentTask_dealId_idx" ON "agentTask"("dealId"); diff --git a/packages/db/prisma/migrations/20260810110000_agent_event_conversation/migration.sql b/packages/db/prisma/migrations/20260810110000_agent_event_conversation/migration.sql deleted file mode 100644 index d17d29d5c..000000000 --- a/packages/db/prisma/migrations/20260810110000_agent_event_conversation/migration.sql +++ /dev/null @@ -1,15 +0,0 @@ -ALTER TABLE "agentEvent" -ADD COLUMN "conversationId" TEXT; - -UPDATE "agentEvent" AS event -SET "conversationId" = conversation.id -FROM "agentConversation" AS conversation -WHERE event."sessionId" = conversation."sessionId"; - -ALTER TABLE "agentEvent" -ADD CONSTRAINT "agentEvent_conversationId_fkey" -FOREIGN KEY ("conversationId") REFERENCES "agentConversation"("id") -ON DELETE CASCADE ON UPDATE CASCADE; - -CREATE INDEX "agentEvent_conversationId_emittedAt_idx" -ON "agentEvent"("conversationId", "emittedAt"); diff --git a/packages/db/prisma/migrations/20260810111000_canonical_builder_tokens/migration.sql b/packages/db/prisma/migrations/20260810111000_canonical_builder_tokens/migration.sql deleted file mode 100644 index 42b38c053..000000000 --- a/packages/db/prisma/migrations/20260810111000_canonical_builder_tokens/migration.sql +++ /dev/null @@ -1,5 +0,0 @@ -UPDATE "agentConversation" -SET "continuationToken" = 'builder:' || id -WHERE kind = 'BUILDER' - AND "continuationToken" IS NOT NULL - AND "continuationToken" LIKE '%builder:' || id; diff --git a/packages/db/prisma/migrations/20260810120000_plural_agent_triggers/migration.sql b/packages/db/prisma/migrations/20260810120000_plural_agent_triggers/migration.sql deleted file mode 100644 index 00f1091df..000000000 --- a/packages/db/prisma/migrations/20260810120000_plural_agent_triggers/migration.sql +++ /dev/null @@ -1,13 +0,0 @@ -UPDATE "agentVersion" -SET manifest = (manifest - 'trigger') || jsonb_build_object( - 'triggers', - jsonb_build_array(manifest -> 'trigger') -) -WHERE manifest ? 'trigger'; - -UPDATE "agentBuilderArtifact" AS artifact -SET content = jsonb_pretty(version.manifest) || E'\n' -FROM "agentVersion" AS version -WHERE artifact."versionId" = version.id - AND artifact.path = 'agent/manifest.json' - AND artifact.status = 'READY'; diff --git a/packages/db/prisma/migrations/20260810120000_website_tracking/migration.sql b/packages/db/prisma/migrations/20260810120000_website_tracking/migration.sql deleted file mode 100644 index 6f3c3344c..000000000 --- a/packages/db/prisma/migrations/20260810120000_website_tracking/migration.sql +++ /dev/null @@ -1,93 +0,0 @@ --- AlterEnum -ALTER TYPE "RecordSource" ADD VALUE 'TRACKING'; - --- CreateEnum -CREATE TYPE "DomainScope" AS ENUM ('SITE_AND_SUBDOMAINS', 'EXACT_HOST'); - --- AlterTable -ALTER TABLE "appSetting" ADD COLUMN "trackingSiteId" TEXT; -ALTER TABLE "appSetting" ADD COLUMN "trackingCrossDomain" BOOLEAN NOT NULL DEFAULT true; -ALTER TABLE "appSetting" ADD COLUMN "trackingLimitToDomains" BOOLEAN NOT NULL DEFAULT true; -ALTER TABLE "appSetting" ADD COLUMN "trackingCookieSubdomains" BOOLEAN NOT NULL DEFAULT false; -ALTER TABLE "appSetting" ADD COLUMN "trackingSecureCookies" BOOLEAN NOT NULL DEFAULT true; -ALTER TABLE "appSetting" ADD COLUMN "trackingHonourDnt" BOOLEAN NOT NULL DEFAULT true; -ALTER TABLE "appSetting" ADD COLUMN "trackingCookieDays" INTEGER NOT NULL DEFAULT 395; -ALTER TABLE "appSetting" ADD COLUMN "trackingConfigHash" TEXT; - --- CreateIndex -CREATE UNIQUE INDEX "appSetting_trackingSiteId_key" ON "appSetting"("trackingSiteId"); - --- CreateTable -CREATE TABLE "trackedDomain" ( - "id" TEXT NOT NULL, - "host" TEXT NOT NULL, - "scope" "DomainScope" NOT NULL DEFAULT 'EXACT_HOST', - "pageViews" INTEGER NOT NULL DEFAULT 0, - "lastSeenAt" TIMESTAMP(3), - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "trackedDomain_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "trackedDomain_host_key" ON "trackedDomain"("host"); - --- CreateTable -CREATE TABLE "trackedVisitor" ( - "id" TEXT NOT NULL, - "contactId" TEXT, - "firstSeen" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "lastSeen" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "trackedVisitor_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX "trackedVisitor_contactId_idx" ON "trackedVisitor"("contactId"); - --- AddForeignKey -ALTER TABLE "trackedVisitor" ADD CONSTRAINT "trackedVisitor_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "contact"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- CreateTable -CREATE TABLE "trackedEvent" ( - "id" TEXT NOT NULL, - "visitorId" TEXT NOT NULL, - "type" TEXT NOT NULL, - "host" TEXT NOT NULL, - "path" TEXT NOT NULL, - "referrer" TEXT, - "label" TEXT, - "occurredAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "trackedEvent_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX "trackedEvent_visitorId_occurredAt_idx" ON "trackedEvent"("visitorId", "occurredAt"); -CREATE INDEX "trackedEvent_occurredAt_idx" ON "trackedEvent"("occurredAt"); -CREATE INDEX "trackedEvent_host_occurredAt_idx" ON "trackedEvent"("host", "occurredAt"); - --- CreateTable -CREATE TABLE "formSubmission" ( - "id" TEXT NOT NULL, - "visitorId" TEXT, - "contactId" TEXT, - "host" TEXT NOT NULL, - "path" TEXT NOT NULL, - "email" TEXT, - "fields" JSONB NOT NULL, - "dedupeKey" TEXT NOT NULL, - "filedAt" TIMESTAMP(3), - "skipReason" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "formSubmission_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "formSubmission_dedupeKey_key" ON "formSubmission"("dedupeKey"); -CREATE INDEX "formSubmission_contactId_idx" ON "formSubmission"("contactId"); -CREATE INDEX "formSubmission_createdAt_idx" ON "formSubmission"("createdAt"); - --- AddForeignKey -ALTER TABLE "formSubmission" ADD CONSTRAINT "formSubmission_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "contact"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260810130000_drop_unused_slack_unmatched/migration.sql b/packages/db/prisma/migrations/20260810130000_drop_unused_slack_unmatched/migration.sql deleted file mode 100644 index 7fae23073..000000000 --- a/packages/db/prisma/migrations/20260810130000_drop_unused_slack_unmatched/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "slackMemberMatch" -DROP COLUMN "explicitlyUnmatched"; diff --git a/packages/db/prisma/migrations/20260810130000_tracking_hardening/migration.sql b/packages/db/prisma/migrations/20260810130000_tracking_hardening/migration.sql deleted file mode 100644 index d419e634e..000000000 --- a/packages/db/prisma/migrations/20260810130000_tracking_hardening/migration.sql +++ /dev/null @@ -1,28 +0,0 @@ --- AlterTable -ALTER TABLE "appSetting" ADD COLUMN "trackingPaused" BOOLEAN NOT NULL DEFAULT false; - --- CreateTable -CREATE TABLE "trackingCounter" ( - "key" TEXT NOT NULL, - "value" INTEGER NOT NULL DEFAULT 0, - "expiresAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "trackingCounter_pkey" PRIMARY KEY ("key") -); - --- CreateIndex -CREATE INDEX "trackingCounter_expiresAt_idx" ON "trackingCounter"("expiresAt"); - --- CreateTable -CREATE TABLE "trackedPageDaily" ( - "day" TIMESTAMP(3) NOT NULL, - "host" TEXT NOT NULL, - "path" TEXT NOT NULL, - "views" INTEGER NOT NULL DEFAULT 0, - "visitors" INTEGER NOT NULL DEFAULT 0, - - CONSTRAINT "trackedPageDaily_pkey" PRIMARY KEY ("day","host","path") -); - --- CreateIndex -CREATE INDEX "trackedPageDaily_host_day_idx" ON "trackedPageDaily"("host", "day"); diff --git a/packages/db/prisma/migrations/20260810140000_source_attribution/migration.sql b/packages/db/prisma/migrations/20260810140000_source_attribution/migration.sql deleted file mode 100644 index 52e9433cc..000000000 --- a/packages/db/prisma/migrations/20260810140000_source_attribution/migration.sql +++ /dev/null @@ -1,32 +0,0 @@ --- AlterTable -ALTER TABLE "trackedEvent" ADD COLUMN "source" TEXT; -ALTER TABLE "trackedEvent" ADD COLUMN "medium" TEXT; -ALTER TABLE "trackedEvent" ADD COLUMN "campaign" TEXT; - --- CreateIndex -CREATE INDEX "trackedEvent_source_occurredAt_idx" ON "trackedEvent"("source", "occurredAt"); - --- AlterTable -ALTER TABLE "formSubmission" ADD COLUMN "firstTouch" JSONB; -ALTER TABLE "formSubmission" ADD COLUMN "lastTouch" JSONB; - --- AlterTable -ALTER TABLE "trackedVisitor" ADD COLUMN "firstSource" TEXT; -ALTER TABLE "trackedVisitor" ADD COLUMN "firstMedium" TEXT; -ALTER TABLE "trackedVisitor" ADD COLUMN "firstCampaign" TEXT; -ALTER TABLE "trackedVisitor" ADD COLUMN "firstTerm" TEXT; -ALTER TABLE "trackedVisitor" ADD COLUMN "firstContent" TEXT; -ALTER TABLE "trackedVisitor" ADD COLUMN "firstReferrer" TEXT; -ALTER TABLE "trackedVisitor" ADD COLUMN "firstLanding" TEXT; -ALTER TABLE "trackedVisitor" ADD COLUMN "firstTouchAt" TIMESTAMP(3); -ALTER TABLE "trackedVisitor" ADD COLUMN "lastSource" TEXT; -ALTER TABLE "trackedVisitor" ADD COLUMN "lastMedium" TEXT; -ALTER TABLE "trackedVisitor" ADD COLUMN "lastCampaign" TEXT; -ALTER TABLE "trackedVisitor" ADD COLUMN "lastTerm" TEXT; -ALTER TABLE "trackedVisitor" ADD COLUMN "lastContent" TEXT; -ALTER TABLE "trackedVisitor" ADD COLUMN "lastReferrer" TEXT; -ALTER TABLE "trackedVisitor" ADD COLUMN "lastLanding" TEXT; -ALTER TABLE "trackedVisitor" ADD COLUMN "lastTouchAt" TIMESTAMP(3); - --- CreateIndex -CREATE INDEX "trackedVisitor_firstSource_idx" ON "trackedVisitor"("firstSource"); diff --git a/packages/db/prisma/migrations/20260811050330_slack_workspace_grant/migration.sql b/packages/db/prisma/migrations/20260811050330_slack_workspace_grant/migration.sql deleted file mode 100644 index 09852ce63..000000000 --- a/packages/db/prisma/migrations/20260811050330_slack_workspace_grant/migration.sql +++ /dev/null @@ -1,20 +0,0 @@ --- AlterTable -ALTER TABLE "slackChannel" ADD COLUMN "inviteRequestedAt" TIMESTAMP(3), -ADD COLUMN "isMember" BOOLEAN NOT NULL DEFAULT false, -ADD COLUMN "isPrivate" BOOLEAN NOT NULL DEFAULT false; - --- CreateTable -CREATE TABLE "slackWorkspaceGrant" ( - "id" TEXT NOT NULL, - "teamId" TEXT NOT NULL, - "teamName" TEXT, - "userToken" TEXT NOT NULL, - "userScopes" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "slackWorkspaceGrant_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "slackWorkspaceGrant_teamId_key" ON "slackWorkspaceGrant"("teamId"); diff --git a/packages/db/prisma/migrations/20260811212311_slack_install_cancel_delivery_task_subject/migration.sql b/packages/db/prisma/migrations/20260811212311_slack_install_cancel_delivery_task_subject/migration.sql deleted file mode 100644 index 0e0481685..000000000 --- a/packages/db/prisma/migrations/20260811212311_slack_install_cancel_delivery_task_subject/migration.sql +++ /dev/null @@ -1,27 +0,0 @@ --- AlterTable -ALTER TABLE "agentRun" ADD COLUMN "cancelDeliveredAt" TIMESTAMP(3), -ADD COLUMN "cancelRequestedAt" TIMESTAMP(3); - --- AlterTable -ALTER TABLE "agentTask" ADD COLUMN "subject" TEXT; - --- AlterTable -ALTER TABLE "slackChannel" ADD COLUMN "classifiedAt" TIMESTAMP(3); - --- CreateTable -CREATE TABLE "slackInstallation" ( - "installerId" TEXT NOT NULL, - "teamId" TEXT NOT NULL, - "teamName" TEXT, - "userToken" TEXT, - "userScopes" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "slackInstallation_pkey" PRIMARY KEY ("installerId") -); - --- CreateIndex -CREATE INDEX "agentTask_kind_subject_idx" ON "agentTask"("kind", "subject") WHERE ("finishedAt" IS NULL); - --- CreateIndex -CREATE INDEX "slackChannel_updatedAt_idx" ON "slackChannel"("updatedAt"); diff --git a/packages/db/prisma/migrations/20260820150251_archive_crm_records/migration.sql b/packages/db/prisma/migrations/20260820150251_archive_crm_records/migration.sql deleted file mode 100644 index 28e5ee77a..000000000 --- a/packages/db/prisma/migrations/20260820150251_archive_crm_records/migration.sql +++ /dev/null @@ -1,20 +0,0 @@ --- AlterTable -ALTER TABLE "appSetting" ADD COLUMN "archiveRetentionDays" INTEGER NOT NULL DEFAULT 180; - --- AlterTable -ALTER TABLE "company" ADD COLUMN "archivedAt" TIMESTAMP(3); - --- AlterTable -ALTER TABLE "contact" ADD COLUMN "archivedAt" TIMESTAMP(3); - --- AlterTable -ALTER TABLE "deal" ADD COLUMN "archivedAt" TIMESTAMP(3); - --- CreateIndex -CREATE INDEX "company_archivedAt_idx" ON "company"("archivedAt"); - --- CreateIndex -CREATE INDEX "contact_archivedAt_idx" ON "contact"("archivedAt"); - --- CreateIndex -CREATE INDEX "deal_archivedAt_idx" ON "deal"("archivedAt"); diff --git a/packages/db/prisma/migrations/20260820150700_list_building_fields/migration.sql b/packages/db/prisma/migrations/20260820150700_list_building_fields/migration.sql deleted file mode 100644 index 8979286d6..000000000 --- a/packages/db/prisma/migrations/20260820150700_list_building_fields/migration.sql +++ /dev/null @@ -1,3 +0,0 @@ --- AlterTable -ALTER TABLE "contact" ADD COLUMN "function" TEXT, -ADD COLUMN "seniority" TEXT; diff --git a/packages/db/prisma/migrations/20260820152910_add_api_keys/migration.sql b/packages/db/prisma/migrations/20260820152910_add_api_keys/migration.sql deleted file mode 100644 index b2bba7c79..000000000 --- a/packages/db/prisma/migrations/20260820152910_add_api_keys/migration.sql +++ /dev/null @@ -1,39 +0,0 @@ --- CreateTable -CREATE TABLE "apikey" ( - "id" TEXT NOT NULL, - "configId" TEXT NOT NULL DEFAULT 'default', - "name" TEXT, - "start" TEXT, - "referenceId" TEXT NOT NULL, - "prefix" TEXT, - "key" TEXT NOT NULL, - "refillInterval" INTEGER, - "refillAmount" INTEGER, - "lastRefillAt" TIMESTAMP(3), - "enabled" BOOLEAN DEFAULT true, - "rateLimitEnabled" BOOLEAN DEFAULT true, - "rateLimitTimeWindow" INTEGER, - "rateLimitMax" INTEGER, - "requestCount" INTEGER DEFAULT 0, - "remaining" INTEGER, - "lastRequest" TIMESTAMP(3), - "expiresAt" TIMESTAMP(3), - "createdAt" TIMESTAMP(3) NOT NULL, - "updatedAt" TIMESTAMP(3) NOT NULL, - "permissions" TEXT, - "metadata" TEXT, - - CONSTRAINT "apikey_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX "apikey_referenceId_idx" ON "apikey"("referenceId"); - --- CreateIndex -CREATE INDEX "apikey_configId_idx" ON "apikey"("configId"); - --- CreateIndex -CREATE INDEX "apikey_key_idx" ON "apikey"("key"); - --- AddForeignKey -ALTER TABLE "apikey" ADD CONSTRAINT "apikey_referenceId_fkey" FOREIGN KEY ("referenceId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260820154345_saved_views/migration.sql b/packages/db/prisma/migrations/20260820154345_saved_views/migration.sql deleted file mode 100644 index a724f2caa..000000000 --- a/packages/db/prisma/migrations/20260820154345_saved_views/migration.sql +++ /dev/null @@ -1,22 +0,0 @@ --- CreateTable -CREATE TABLE "savedView" ( - "id" TEXT NOT NULL, - "entity" "FieldEntity" NOT NULL, - "name" TEXT NOT NULL, - "shared" BOOLEAN NOT NULL DEFAULT false, - "filters" JSONB NOT NULL, - "ownerId" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "savedView_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX "savedView_entity_shared_idx" ON "savedView"("entity", "shared"); - --- CreateIndex -CREATE UNIQUE INDEX "savedView_entity_ownerId_name_key" ON "savedView"("entity", "ownerId", "name"); - --- AddForeignKey -ALTER TABLE "savedView" ADD CONSTRAINT "savedView_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260820154825_field_show_on_filter/migration.sql b/packages/db/prisma/migrations/20260820154825_field_show_on_filter/migration.sql deleted file mode 100644 index e7353a4af..000000000 --- a/packages/db/prisma/migrations/20260820154825_field_show_on_filter/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "fieldDefinition" ADD COLUMN "showOnFilter" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/db/prisma/migrations/20260820160000_backfill_seniority_function/migration.sql b/packages/db/prisma/migrations/20260820160000_backfill_seniority_function/migration.sql deleted file mode 100644 index 2ad69c700..000000000 --- a/packages/db/prisma/migrations/20260820160000_backfill_seniority_function/migration.sql +++ /dev/null @@ -1,30 +0,0 @@ --- DataMigration --- Copies the already-APPLIED seniority/function contactFact values onto the --- contact columns added in 20260820150700_list_building_fields. Those facts --- were recorded before the columns existed, so recordFact's own write path --- never touched them. A contact can have more than one APPLIED fact for the --- same field (re-applied over time); DISTINCT ON picks the most recently --- observed one deterministically, breaking ties by id. -UPDATE "contact" c -SET "seniority" = f."value" -FROM ( - SELECT DISTINCT ON (f."contactId") f."contactId", f."value" - FROM "contactFact" f - WHERE f."field" = 'seniority' - AND f."status" = 'APPLIED' - ORDER BY f."contactId", f."observedAt" DESC, f."id" DESC -) f -WHERE f."contactId" = c."id" - AND c."seniority" IS NULL; - -UPDATE "contact" c -SET "function" = f."value" -FROM ( - SELECT DISTINCT ON (f."contactId") f."contactId", f."value" - FROM "contactFact" f - WHERE f."field" = 'function' - AND f."status" = 'APPLIED' - ORDER BY f."contactId", f."observedAt" DESC, f."id" DESC -) f -WHERE f."contactId" = c."id" - AND c."function" IS NULL; diff --git a/packages/db/prisma/migrations/20260820161500_archive_scoped_uniqueness/migration.sql b/packages/db/prisma/migrations/20260820161500_archive_scoped_uniqueness/migration.sql deleted file mode 100644 index 754562e34..000000000 --- a/packages/db/prisma/migrations/20260820161500_archive_scoped_uniqueness/migration.sql +++ /dev/null @@ -1,11 +0,0 @@ --- DropIndex -DROP INDEX "company_domain_key"; - --- DropIndex -DROP INDEX "contact_email_key"; - --- CreateIndex -CREATE UNIQUE INDEX "company_domain_active_key" ON "company"("domain") WHERE ("archivedAt" IS NULL); - --- CreateIndex -CREATE UNIQUE INDEX "contact_email_active_key" ON "contact"("email") WHERE ("archivedAt" IS NULL); diff --git a/packages/db/prisma/migrations/20261020191459_mcp_integrations/migration.sql b/packages/db/prisma/migrations/20261020191459_mcp_integrations/migration.sql deleted file mode 100644 index 9f3938519..000000000 --- a/packages/db/prisma/migrations/20261020191459_mcp_integrations/migration.sql +++ /dev/null @@ -1,101 +0,0 @@ -CREATE TYPE "McpConnectionStatus" AS ENUM ('CONNECTING', 'CONNECTED', 'ERROR', 'DISCONNECTED'); - -CREATE TYPE "McpAccessMode" AS ENUM ('READ_ONLY', 'READ_WRITE'); - -CREATE TYPE "McpToolReviewState" AS ENUM ('PENDING', 'REVIEWED', 'CHANGED', 'REMOVED'); - -CREATE TABLE "mcpConnection" ( - "id" TEXT NOT NULL, - "organizationId" TEXT NOT NULL, - "platformSlug" TEXT NOT NULL, - "status" "McpConnectionStatus" NOT NULL DEFAULT 'CONNECTING', - "accessMode" "McpAccessMode" NOT NULL, - "grantedScopes" TEXT[], - "encryptedAccessToken" TEXT, - "encryptedRefreshToken" TEXT, - "tokenExpiresAt" TIMESTAMP(3), - "connectedById" TEXT NOT NULL, - "connectedAt" TIMESTAMP(3), - "lastSuccessfulCallAt" TIMESTAMP(3), - "lastErrorCode" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "mcpConnection_pkey" PRIMARY KEY ("id") -); - -CREATE TABLE "mcpClientRegistration" ( - "id" TEXT NOT NULL, - "platformSlug" TEXT NOT NULL, - "metadataFingerprint" TEXT NOT NULL, - "issuer" TEXT NOT NULL, - "clientId" TEXT NOT NULL, - "encryptedClientSecret" TEXT, - "encryptedRegistrationAccessToken" TEXT, - "clientIdIssuedAt" TIMESTAMP(3), - "clientSecretExpiresAt" TIMESTAMP(3), - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "mcpClientRegistration_pkey" PRIMARY KEY ("id") -); - -CREATE TABLE "mcpToolSnapshot" ( - "id" TEXT NOT NULL, - "connectionId" TEXT NOT NULL, - "name" TEXT NOT NULL, - "description" TEXT, - "inputSchema" JSONB NOT NULL, - "annotations" JSONB, - "fingerprint" TEXT NOT NULL, - "callKind" TEXT NOT NULL, - "reviewState" "McpToolReviewState" NOT NULL DEFAULT 'PENDING', - "lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "mcpToolSnapshot_pkey" PRIMARY KEY ("id") -); - -CREATE TABLE "mcpOauthState" ( - "id" TEXT NOT NULL, - "stateHash" TEXT NOT NULL, - "organizationId" TEXT NOT NULL, - "userId" TEXT NOT NULL, - "platformSlug" TEXT NOT NULL, - "accessMode" "McpAccessMode" NOT NULL, - "returnPath" TEXT NOT NULL, - "encryptedCodeVerifier" TEXT NOT NULL, - "expiresAt" TIMESTAMP(3) NOT NULL, - "consumedAt" TIMESTAMP(3), - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "mcpOauthState_pkey" PRIMARY KEY ("id") -); - -CREATE INDEX "mcpConnection_status_updatedAt_idx" ON "mcpConnection"("status", "updatedAt"); - -CREATE UNIQUE INDEX "mcpConnection_organizationId_platformSlug_key" ON "mcpConnection"("organizationId", "platformSlug"); - -CREATE INDEX "mcpClientRegistration_clientSecretExpiresAt_idx" ON "mcpClientRegistration"("clientSecretExpiresAt"); - -CREATE UNIQUE INDEX "mcpClientRegistration_platformSlug_metadataFingerprint_key" ON "mcpClientRegistration"("platformSlug", "metadataFingerprint"); - -CREATE INDEX "mcpToolSnapshot_connectionId_name_lastSeenAt_idx" ON "mcpToolSnapshot"("connectionId", "name", "lastSeenAt"); - -CREATE INDEX "mcpToolSnapshot_reviewState_lastSeenAt_idx" ON "mcpToolSnapshot"("reviewState", "lastSeenAt"); - -CREATE UNIQUE INDEX "mcpToolSnapshot_connectionId_name_fingerprint_key" ON "mcpToolSnapshot"("connectionId", "name", "fingerprint"); - -CREATE UNIQUE INDEX "mcpOauthState_stateHash_key" ON "mcpOauthState"("stateHash"); - -CREATE INDEX "mcpOauthState_expiresAt_consumedAt_idx" ON "mcpOauthState"("expiresAt", "consumedAt"); - -ALTER TABLE "mcpConnection" ADD CONSTRAINT "mcpConnection_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; - -ALTER TABLE "mcpConnection" ADD CONSTRAINT "mcpConnection_connectedById_fkey" FOREIGN KEY ("connectedById") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - -ALTER TABLE "mcpToolSnapshot" ADD CONSTRAINT "mcpToolSnapshot_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "mcpConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE; - -ALTER TABLE "mcpOauthState" ADD CONSTRAINT "mcpOauthState_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; - -ALTER TABLE "mcpOauthState" ADD CONSTRAINT "mcpOauthState_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20261020191833_link_mcp_registration/migration.sql b/packages/db/prisma/migrations/20261020191833_link_mcp_registration/migration.sql deleted file mode 100644 index dace7caef..000000000 --- a/packages/db/prisma/migrations/20261020191833_link_mcp_registration/migration.sql +++ /dev/null @@ -1,7 +0,0 @@ -ALTER TABLE "mcpConnection" ADD COLUMN "registrationId" TEXT NOT NULL; - -ALTER TABLE "mcpOauthState" ADD COLUMN "registrationId" TEXT NOT NULL; - -ALTER TABLE "mcpConnection" ADD CONSTRAINT "mcpConnection_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "mcpClientRegistration"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - -ALTER TABLE "mcpOauthState" ADD CONSTRAINT "mcpOauthState_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "mcpClientRegistration"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20261020221500_agent_event_stream_index/migration.sql b/packages/db/prisma/migrations/20261020221500_agent_event_stream_index/migration.sql deleted file mode 100644 index 9e61d8a36..000000000 --- a/packages/db/prisma/migrations/20261020221500_agent_event_stream_index/migration.sql +++ /dev/null @@ -1,6 +0,0 @@ -ALTER TABLE "agentEvent" -ADD COLUMN "streamIndex" INTEGER, -ADD COLUMN "deliveryIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]; - -CREATE UNIQUE INDEX "agentEvent_sessionId_streamIndex_key" -ON "agentEvent"("sessionId", "streamIndex"); diff --git a/packages/db/prisma/migrations/20261020224500_agent_conversation_permission_mode/migration.sql b/packages/db/prisma/migrations/20261020224500_agent_conversation_permission_mode/migration.sql deleted file mode 100644 index 707d0028a..000000000 --- a/packages/db/prisma/migrations/20261020224500_agent_conversation_permission_mode/migration.sql +++ /dev/null @@ -1,4 +0,0 @@ -CREATE TYPE "AgentConversationPermissionMode" AS ENUM ('MANUAL', 'AUTO', 'BYPASS'); - -ALTER TABLE "agentConversation" -ADD COLUMN "permissionMode" "AgentConversationPermissionMode" NOT NULL DEFAULT 'MANUAL'; diff --git a/packages/db/prisma/migrations/20261020225500_encrypt_builder_chat_events/migration.sql b/packages/db/prisma/migrations/20261020225500_encrypt_builder_chat_events/migration.sql deleted file mode 100644 index d87b7e31c..000000000 --- a/packages/db/prisma/migrations/20261020225500_encrypt_builder_chat_events/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "agentEvent" -ADD COLUMN "encryptedData" TEXT; diff --git a/packages/db/prisma/migrations/20261022153000_plural_builder_input_requests/migration.sql b/packages/db/prisma/migrations/20261022153000_plural_builder_input_requests/migration.sql deleted file mode 100644 index 0998986fc..000000000 --- a/packages/db/prisma/migrations/20261022153000_plural_builder_input_requests/migration.sql +++ /dev/null @@ -1,7 +0,0 @@ -ALTER TABLE "agentConversation" -RENAME COLUMN "pendingInputRequest" TO "pendingInputRequests"; - -UPDATE "agentConversation" -SET "pendingInputRequests" = jsonb_build_array("pendingInputRequests") -WHERE "pendingInputRequests" IS NOT NULL - AND jsonb_typeof("pendingInputRequests") <> 'array'; diff --git a/packages/db/prisma/migrations/migration_lock.toml b/packages/db/prisma/migrations/migration_lock.toml deleted file mode 100644 index 044d57cdb..000000000 --- a/packages/db/prisma/migrations/migration_lock.toml +++ /dev/null @@ -1,3 +0,0 @@ -# Please do not edit this file manually -# It should be added in your version-control system (e.g., Git) -provider = "postgresql" diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma deleted file mode 100644 index 1d8d34407..000000000 --- a/packages/db/prisma/schema.prisma +++ /dev/null @@ -1,1721 +0,0 @@ -datasource db { - provider = "postgresql" -} - -generator client { - provider = "prisma-client" - output = "../src/generated/prisma" - runtime = "nodejs" - moduleFormat = "esm" - generatedFileExtension = "ts" - importFileExtension = "" - previewFeatures = ["partialIndexes"] -} - -model User { - id String @id - name String - email String - emailVerified Boolean @default(false) - image String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - sessions Session[] - accounts Account[] - - ownedCompanies Company[] @relation("CompanyOwner") - ownedContacts Contact[] @relation("ContactOwner") - ownedDeals Deal[] @relation("DealOwner") - activities Activity[] @relation("ActivityAuthor") - mailboxSyncs MailboxSync[] - factDecisions ContactFact[] @relation("FactDecider") - conversations AgentConversation[] @relation("ConversationOwner") - fieldValues FieldValue[] @relation("FieldValueUser") - savedViews SavedView[] @relation("SavedViewOwner") - - createdAgentDefinitions AgentDefinition[] @relation("AgentDefinitionCreator") - createdAgentVersions AgentVersion[] @relation("AgentVersionCreator") - createdAgentTriggers AgentTrigger[] @relation("AgentTriggerCreator") - initiatedAgentRuns AgentRun[] @relation("AgentRunInitiator") - agentAuditEvents AgentAuditEvent[] @relation("AgentAuditActor") - conversationShares AgentConversationShare[] @relation("ConversationShareCreator") - conversationSubmissions AgentConversationSubmission[] @relation("ConversationSubmissionAuthor") - conversationFeedback AgentConversationFeedback[] @relation("ConversationFeedbackAuthor") - slackMemberMatch SlackMemberMatch? - connectedMcpConnections McpConnection[] @relation("McpConnectionUser") - mcpOauthStates McpOauthState[] @relation("McpOauthStateUser") - - members Member[] - invitations Invitation[] - - ssoproviders SsoProvider[] - - apiKeys Apikey[] - - @@unique([email]) - @@map("user") -} - -model SlackMemberMatch { - id String @id @default(cuid()) - crmUserId String @unique - crmUser User @relation(fields: [crmUserId], references: [id], onDelete: Cascade) - slackUserId String? - slackHandle String? - slackEmail String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([slackUserId]) - @@map("slackMemberMatch") -} - -model SlackChannel { - id String @id - name String - memberCount Int? - available Boolean @default(true) - isPrivate Boolean @default(false) - isMember Boolean @default(false) - inviteRequestedAt DateTime? - classifiedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([available, name]) - @@index([updatedAt]) - @@map("slackChannel") -} - -model SlackInstallation { - installerId String @id - teamId String - teamName String? - userToken String? - userScopes String - createdAt DateTime @default(now()) - - @@map("slackInstallation") -} - -model SlackWorkspaceGrant { - id String @id @default(cuid()) - teamId String @unique - teamName String? - userToken String - userScopes String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@map("slackWorkspaceGrant") -} - -model Session { - id String @id - expiresAt DateTime - token String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - ipAddress String? - userAgent String? - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - activeOrganizationId String? - - @@unique([token]) - @@index([userId]) - @@map("session") -} - -model Account { - id String @id - accountId String - providerId String - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - accessToken String? - refreshToken String? - idToken String? - accessTokenExpiresAt DateTime? - refreshTokenExpiresAt DateTime? - scope String? - password String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([userId]) - @@map("account") -} - -enum McpConnectionStatus { - CONNECTING - CONNECTED - ERROR - DISCONNECTED -} - -enum McpAccessMode { - READ_ONLY - READ_WRITE -} - -enum McpToolReviewState { - PENDING - REVIEWED - CHANGED - REMOVED -} - -model McpConnection { - id String @id @default(cuid()) - - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - - platformSlug String - status McpConnectionStatus @default(CONNECTING) - accessMode McpAccessMode - grantedScopes String[] - - encryptedAccessToken String? - encryptedRefreshToken String? - tokenExpiresAt DateTime? - - registrationId String - registration McpClientRegistration @relation(fields: [registrationId], references: [id], onDelete: Restrict) - - connectedById String - connectedBy User @relation("McpConnectionUser", fields: [connectedById], references: [id], onDelete: Restrict) - - connectedAt DateTime? - lastSuccessfulCallAt DateTime? - lastErrorCode String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - toolSnapshots McpToolSnapshot[] - - @@unique([organizationId, platformSlug]) - @@index([status, updatedAt]) - @@map("mcpConnection") -} - -model McpClientRegistration { - id String @id @default(cuid()) - - platformSlug String - metadataFingerprint String - issuer String - clientId String - encryptedClientSecret String? - encryptedRegistrationAccessToken String? - clientIdIssuedAt DateTime? - clientSecretExpiresAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - connections McpConnection[] - oauthStates McpOauthState[] - - @@unique([platformSlug, metadataFingerprint]) - @@index([clientSecretExpiresAt]) - @@map("mcpClientRegistration") -} - -model McpToolSnapshot { - id String @id @default(cuid()) - - connectionId String - connection McpConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade) - - name String - description String? - inputSchema Json - annotations Json? - fingerprint String - callKind String - reviewState McpToolReviewState @default(PENDING) - lastSeenAt DateTime @default(now()) - createdAt DateTime @default(now()) - - @@unique([connectionId, name, fingerprint]) - @@index([connectionId, name, lastSeenAt]) - @@index([reviewState, lastSeenAt]) - @@map("mcpToolSnapshot") -} - -model McpOauthState { - id String @id @default(cuid()) - - stateHash String @unique - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - userId String - user User @relation("McpOauthStateUser", fields: [userId], references: [id], onDelete: Cascade) - - platformSlug String - accessMode McpAccessMode - returnPath String - encryptedCodeVerifier String - registrationId String - registration McpClientRegistration @relation(fields: [registrationId], references: [id], onDelete: Cascade) - expiresAt DateTime - consumedAt DateTime? - createdAt DateTime @default(now()) - - @@index([expiresAt, consumedAt]) - @@map("mcpOauthState") -} - -model Verification { - id String @id - identifier String - value String - expiresAt DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([identifier]) - @@map("verification") -} - -model RateLimit { - id String @id - key String - count Int - lastRequest BigInt - - @@unique([key]) - @@map("rateLimit") -} - -enum DealStage { - DEMO_BOOKED - QUALIFIED_TO_BUY - UNQUALIFIED_TO_BUY - DECISION_MAKER_BOUGHT_IN - CONTRACT_SENT - CLOSED_WON - CLOSED_LOST -} - -enum ActivityType { - NOTE - CALL - EMAIL - MEETING - TASK - STAGE_CHANGE - ENRICHMENT -} - -enum EnrichmentStatus { - PENDING - RUNNING - COMPLETE - FAILED - SKIPPED -} - -enum RecordSource { - MANUAL - IMPORT - EMAIL - CALENDAR - TRACKING -} - -enum AgentConversationKind { - RECORD - BUILDER -} - -enum AgentDefinitionStatus { - DRAFT - DEPLOYING - LIVE - PAUSED - ARCHIVED - DELETED -} - -enum AgentVersionStatus { - DRAFT - VALIDATING - READY - DEPLOYED - REJECTED -} - -enum AgentBuilderArtifactStatus { - WRITING - READY -} - -enum AgentTriggerType { - MANUAL - SCHEDULE - EVENT - WEBHOOK -} - -enum AgentRunStatus { - QUEUED - RUNNING - WAITING_FOR_APPROVAL - SUCCEEDED - FAILED - CANCELLED -} - -enum AgentActionStatus { - PLANNED - RUNNING - SUCCEEDED - FAILED - CANCELLED -} - -enum AgentConversationShareScope { - WORKSPACE_LINK -} - -enum AgentConversationSubmissionStatus { - PENDING - SENDING - ACCEPTED - FAILED - CANCELLED -} - -enum AgentConversationCommandType { - CHAT - CREATE_AGENT -} - -enum AgentConversationPermissionMode { - MANUAL - AUTO - BYPASS -} - -enum AgentResponseRating { - UP - DOWN -} - -model Company { - id String @id @default(cuid()) - name String - domain String? - website String? - description String? - - logoUrl String? - logoDarkUrl String? - iconUrl String? - iconDarkUrl String? - iconTone String? - brandColor String? - - industry String? - subIndustry String? - city String? - stateCode String? - country String? - countryCode String? - - phone String? - email String? - linkedinUrl String? - twitterUrl String? - githubUrl String? - pricingUrl String? - careersUrl String? - - ownerId String? - owner User? @relation("CompanyOwner", fields: [ownerId], references: [id], onDelete: SetNull) - primaryContactId String? @unique - primaryContact Contact? @relation("PrimaryContact", fields: [primaryContactId], references: [id], onDelete: SetNull) - - enrichmentStatus EnrichmentStatus @default(PENDING) - enrichedAt DateTime? - enrichmentError String? - enrichment CompanyEnrichment? - - source RecordSource @default(MANUAL) - - lastActivityAt DateTime? - archivedAt DateTime? - - contacts Contact[] @relation("CompanyContacts") - conversations AgentConversation[] - deals Deal[] - activities Activity[] - emailThreads EmailThread[] - calendarEvents CalendarEvent[] - fieldValues FieldValue[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@unique([domain], map: "company_domain_active_key", where: { archivedAt: null }) - @@index([ownerId]) - @@index([name]) - @@index([lastActivityAt]) - @@index([archivedAt]) - @@map("company") -} - -model CompanyEnrichment { - companyId String @id - company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) - source String @default("context.dev") - raw Json - fetchedAt DateTime @default(now()) - - @@map("companyEnrichment") -} - -model Contact { - id String @id @default(cuid()) - firstName String - lastName String? - email String? - phone String? - title String? - seniority String? - function String? - linkedinUrl String? - twitterUrl String? - githubUrl String? - imageUrl String? - - socialsCheckedAt DateTime? - - enrichmentStatus EnrichmentStatus @default(PENDING) - enrichedAt DateTime? - enrichmentError String? - - brief ContactBrief? - facts ContactFact[] - conversations AgentConversation[] - - companyId String? - company Company? @relation("CompanyContacts", fields: [companyId], references: [id], onDelete: SetNull) - ownerId String? - owner User? @relation("ContactOwner", fields: [ownerId], references: [id], onDelete: SetNull) - primaryOf Company? @relation("PrimaryContact") - - source RecordSource @default(MANUAL) - - lastActivityAt DateTime? - archivedAt DateTime? - - deals DealContact[] - activities Activity[] - emailThreads EmailThread[] - calendarEvents CalendarEvent[] - eventAttendance CalendarAttendee[] - fieldValues FieldValue[] - visitors TrackedVisitor[] @relation("VisitorContact") - submissions FormSubmission[] @relation("SubmissionContact") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@unique([email], map: "contact_email_active_key", where: { archivedAt: null }) - @@index([companyId]) - @@index([ownerId]) - @@index([lastActivityAt]) - @@index([archivedAt]) - @@map("contact") -} - -enum FactBand { - VERIFIED - PROBABLE - POSSIBLE -} - -enum FactStatus { - APPLIED - PROPOSED - DISMISSED - SUPERSEDED -} - -model ContactFact { - id String @id @default(cuid()) - contactId String - contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade) - - field String - value String - - score Float - band FactBand - - evidence Json - - method String - sourceUrl String? - - sessionId String? - - status FactStatus @default(PROPOSED) - decidedById String? - decidedBy User? @relation("FactDecider", fields: [decidedById], references: [id], onDelete: SetNull) - decidedAt DateTime? - - observedAt DateTime @default(now()) - supersededAt DateTime? - - @@index([contactId, field, status]) - @@index([status, observedAt]) - @@map("contactFact") -} - -model ContactBrief { - contactId String @id - contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade) - - narrative String - sections Json - - score Float - sourceUrl String? - sessionId String? - - refreshedAt DateTime @default(now()) - - @@map("contactBrief") -} - -model AgentTask { - id String @id @default(cuid()) - contactId String? - companyId String? - dealId String? - - kind String - reason String - payload Json? - - priority Int @default(0) - budget Int @default(4) - attempts Int @default(0) - - dueAt DateTime - leasedUntil DateTime? - - sessionId String? - startedAt DateTime? - finishedAt DateTime? - outcome String? - - subject String? - - createdAt DateTime @default(now()) - - @@index([dueAt, leasedUntil]) - @@index([contactId]) - @@index([dealId]) - @@index([kind, subject], where: { finishedAt: null }) - @@map("agentTask") -} - -model AgentEvent { - id String @id - sessionId String - streamIndex Int? - deliveryIds String[] @default([]) - contactId String? - conversationId String? - conversation AgentConversation? @relation(fields: [conversationId], references: [id], onDelete: Cascade) - type String - data Json - encryptedData String? - - emittedAt DateTime - - @@index([sessionId, emittedAt]) - @@unique([sessionId, streamIndex]) - @@index([contactId, emittedAt]) - @@index([conversationId, emittedAt]) - @@map("agentEvent") -} - -model AgentConversation { - id String @id @default(cuid()) - - kind AgentConversationKind @default(RECORD) - - contactId String? - contact Contact? @relation(fields: [contactId], references: [id], onDelete: Cascade) - companyId String? - company Company? @relation(fields: [companyId], references: [id], onDelete: Cascade) - dealId String? - deal Deal? @relation(fields: [dealId], references: [id], onDelete: Cascade) - - userId String - user User @relation("ConversationOwner", fields: [userId], references: [id], onDelete: Cascade) - - agentId String? - agent AgentDefinition? @relation(fields: [agentId], references: [id], onDelete: SetNull) - - sessionId String? @unique - continuationToken String? - streamIndex Int @default(0) - pendingInputRequests Json? - - permissionMode AgentConversationPermissionMode @default(MANUAL) - - title String? - messageCount Int @default(0) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - lastMessageAt DateTime @default(now()) - lastAssistantAt DateTime? - lastReadAt DateTime? - - shares AgentConversationShare[] - submissions AgentConversationSubmission[] - feedback AgentConversationFeedback[] - events AgentEvent[] - createdVersions AgentVersion[] @relation("AgentVersionSourceConversation") - builderArtifacts AgentBuilderArtifact[] - - @@index([contactId, lastMessageAt]) - @@index([companyId, lastMessageAt]) - @@index([dealId, lastMessageAt]) - @@index([userId, kind, lastMessageAt]) - @@index([agentId, lastMessageAt]) - @@map("agentConversation") -} - -model AgentConversationFeedback { - id String @id @default(cuid()) - - conversationId String - conversation AgentConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) - - userId String - user User @relation("ConversationFeedbackAuthor", fields: [userId], references: [id], onDelete: Cascade) - - messageId String - rating AgentResponseRating - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@unique([conversationId, userId, messageId]) - @@index([conversationId, createdAt]) - @@map("agentConversationFeedback") -} - -model AgentConversationShare { - id String @id @default(cuid()) - - conversationId String - conversation AgentConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) - - createdById String - createdBy User @relation("ConversationShareCreator", fields: [createdById], references: [id], onDelete: Cascade) - - scope AgentConversationShareScope @default(WORKSPACE_LINK) - tokenHash String @unique - - createdAt DateTime @default(now()) - expiresAt DateTime? - revokedAt DateTime? - - @@unique([conversationId], map: "agentConversationShare_one_active_per_conversation", where: { revokedAt: null }) - @@index([conversationId, revokedAt]) - @@map("agentConversationShare") -} - -model AgentConversationSubmission { - id String @id @default(cuid()) - - conversationId String - conversation AgentConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) - - submittedById String - submittedBy User @relation("ConversationSubmissionAuthor", fields: [submittedById], references: [id], onDelete: Restrict) - - clientRequestId String @unique - inputRequestId String? - commandType AgentConversationCommandType @default(CHAT) - message Json - status AgentConversationSubmissionStatus @default(PENDING) - attemptCount Int @default(0) - - errorCode String? - errorMessage String? - - createdAt DateTime @default(now()) - sentAt DateTime? - acceptedAt DateTime? - - attachments AgentConversationAttachment[] - - @@unique([conversationId, inputRequestId]) - @@index([conversationId, createdAt]) - @@index([status, createdAt]) - @@map("agentConversationSubmission") -} - -model AgentConversationAttachment { - id String @id @default(cuid()) - - submissionId String - submission AgentConversationSubmission @relation(fields: [submissionId], references: [id], onDelete: Cascade) - - name String - mediaType String - size Int - content Bytes - position Int - - createdAt DateTime @default(now()) - - @@index([submissionId, position]) - @@map("agentConversationAttachment") -} - -model AgentDefinition { - id String @id @default(cuid()) - - name String - description String? - status AgentDefinitionStatus @default(DRAFT) - - createdById String - createdBy User @relation("AgentDefinitionCreator", fields: [createdById], references: [id], onDelete: Restrict) - - currentVersionId String? @unique - currentVersion AgentVersion? @relation("CurrentAgentVersion", fields: [currentVersionId, id], references: [id, agentId], onDelete: Restrict) - - versions AgentVersion[] @relation("AgentVersions") - triggers AgentTrigger[] - runs AgentRun[] - actions AgentAction[] - auditEvents AgentAuditEvent[] - conversations AgentConversation[] - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - archivedAt DateTime? - deletedAt DateTime? - - @@unique([currentVersionId, id]) - @@index([status, updatedAt]) - @@index([createdById, createdAt]) - @@map("agentDefinition") -} - -model AgentVersion { - id String @id @default(cuid()) - - agentId String - agent AgentDefinition @relation("AgentVersions", fields: [agentId], references: [id], onDelete: Restrict) - - number Int - status AgentVersionStatus @default(DRAFT) - - instructions String - manifest Json - modelId String - modelContextWindowTokens Int @default(1000000) - sandboxPolicy Json - validation Json? - - sourceConversationId String? - sourceConversation AgentConversation? @relation("AgentVersionSourceConversation", fields: [sourceConversationId], references: [id], onDelete: SetNull) - - createdById String - createdBy User @relation("AgentVersionCreator", fields: [createdById], references: [id], onDelete: Restrict) - - deploymentId String? - approvedAt DateTime? - deployedAt DateTime? - createdAt DateTime @default(now()) - - currentFor AgentDefinition? @relation("CurrentAgentVersion") - triggers AgentTrigger[] - runs AgentRun[] - auditEvents AgentAuditEvent[] - builderArtifacts AgentBuilderArtifact[] - - @@unique([agentId, number]) - @@unique([id, agentId]) - @@index([agentId, createdAt]) - @@index([status, createdAt]) - @@map("agentVersion") -} - -model AgentBuilderArtifact { - id String @id @default(cuid()) - - conversationId String? - conversation AgentConversation? @relation(fields: [conversationId], references: [id], onDelete: SetNull) - - versionId String? - version AgentVersion? @relation(fields: [versionId], references: [id], onDelete: SetNull) - - path String - language String - content String - previousContent String? - revision Int - status AgentBuilderArtifactStatus @default(WRITING) - createdAt DateTime @default(now()) - - @@unique([conversationId, path, revision], map: "agentBuilderArtifact_conversation_path_revision_key", where: { conversationId: { not: null } }) - @@unique([versionId, path, revision], map: "agentBuilderArtifact_version_path_revision_key", where: { versionId: { not: null } }) - @@index([conversationId, createdAt]) - @@index([versionId, path]) - @@map("agentBuilderArtifact") -} - -model AgentTrigger { - id String @id @default(cuid()) - - agentId String - agent AgentDefinition @relation(fields: [agentId], references: [id], onDelete: Restrict) - - versionId String - version AgentVersion @relation(fields: [versionId, agentId], references: [id, agentId], onDelete: Restrict) - - type AgentTriggerType - name String - config Json - - createdById String - createdBy User @relation("AgentTriggerCreator", fields: [createdById], references: [id], onDelete: Restrict) - - enabled Boolean @default(false) - nextRunAt DateTime? - lastRunAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - runs AgentRun[] - - @@unique([id, agentId]) - @@index([agentId, enabled]) - @@index([enabled, nextRunAt]) - @@index([versionId]) - @@map("agentTrigger") -} - -model AgentRun { - id String @id @default(cuid()) - - agentId String - agent AgentDefinition @relation(fields: [agentId], references: [id], onDelete: Restrict) - - versionId String - version AgentVersion @relation(fields: [versionId, agentId], references: [id, agentId], onDelete: Restrict) - - triggerId String? - trigger AgentTrigger? @relation(fields: [triggerId, agentId], references: [id, agentId], onDelete: Restrict) - - initiatedById String? - initiatedBy User? @relation("AgentRunInitiator", fields: [initiatedById], references: [id], onDelete: SetNull) - - triggerType AgentTriggerType - status AgentRunStatus @default(QUEUED) - principalId String? - sessionId String? @unique - idempotencyKey String @unique - correlationId String @unique - - input Json? - result Json? - summary String? - - modelId String? - inputTokens Int? - outputTokens Int? - costUsd Decimal? @db.Decimal(12, 6) - - errorCode String? - errorMessage String? - - nextEventSequence Int @default(0) - - createdAt DateTime @default(now()) - startedAt DateTime? - finishedAt DateTime? - - cancelRequestedAt DateTime? - cancelDeliveredAt DateTime? - - events AgentRunEvent[] - actions AgentAction[] - - @@unique([id, agentId]) - @@index([agentId, createdAt]) - @@index([versionId, createdAt]) - @@index([status, createdAt]) - @@index([triggerId, createdAt]) - @@map("agentRun") -} - -model AgentRunEvent { - id String @id @default(cuid()) - - runId String - run AgentRun @relation(fields: [runId], references: [id], onDelete: Restrict) - - sequence Int - type String - data Json - emittedAt DateTime @default(now()) - - @@unique([runId, sequence]) - @@index([runId, emittedAt]) - @@map("agentRunEvent") -} - -model AgentAction { - id String @id @default(cuid()) - - agentId String - agent AgentDefinition @relation(fields: [agentId], references: [id], onDelete: Restrict) - - runId String - run AgentRun @relation(fields: [runId, agentId], references: [id, agentId], onDelete: Restrict) - - type String - provider String - targetType String? - targetId String? - targetLabel String? - summary String - metadata Json? - - status AgentActionStatus @default(PLANNED) - idempotencyKey String @unique - requestHash String? - externalId String? - attemptCount Int @default(0) - - errorCode String? - errorMessage String? - - plannedAt DateTime @default(now()) - startedAt DateTime? - completedAt DateTime? - updatedAt DateTime @updatedAt - - @@index([agentId, plannedAt]) - @@index([runId, plannedAt]) - @@index([provider, externalId]) - @@index([status, plannedAt]) - @@map("agentAction") -} - -model AgentAuditEvent { - id String @id @default(cuid()) - - agentId String - agent AgentDefinition @relation(fields: [agentId], references: [id], onDelete: Restrict) - - versionId String? - version AgentVersion? @relation(fields: [versionId, agentId], references: [id, agentId], onDelete: Restrict) - - actorUserId String? - actorUser User? @relation("AgentAuditActor", fields: [actorUserId], references: [id], onDelete: SetNull) - - type String - actorType String - actorId String? - summary String - before Json? - after Json? - requestId String? - emittedAt DateTime @default(now()) - - @@unique([agentId, type, requestId]) - @@index([agentId, emittedAt]) - @@index([versionId, emittedAt]) - @@index([actorUserId, emittedAt]) - @@index([type, emittedAt]) - @@map("agentAuditEvent") -} - -model Deal { - id String @id @default(cuid()) - conversations AgentConversation[] - name String - description String? - companyId String - company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) - ownerId String - owner User @relation("DealOwner", fields: [ownerId], references: [id]) - - stage DealStage @default(DEMO_BOOKED) - stageChangedAt DateTime @default(now()) - amount Decimal? @db.Decimal(14, 2) - currency String @default("USD") - expectedCloseDate DateTime? - closedAt DateTime? - closedReason String? - - baseAmount Decimal? @db.Decimal(24, 4) - baseCurrency String? - fxRate Decimal? @db.Decimal(20, 10) - fxRateAt DateTime? - - lastActivityAt DateTime? - archivedAt DateTime? - - contacts DealContact[] - activities Activity[] - fieldValues FieldValue[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([companyId]) - @@index([ownerId]) - @@index([stage]) - @@index([expectedCloseDate]) - @@index([lastActivityAt]) - @@index([baseAmount]) - @@index([currency]) - @@index([archivedAt]) - @@map("deal") -} - -enum RateSource { - FETCHED - MANUAL -} - -model ExchangeRate { - id String @id @default(cuid()) - baseCurrency String - quoteCurrency String - rate Decimal @db.Decimal(20, 10) - asOf DateTime - source RateSource - provider String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@unique([baseCurrency, quoteCurrency, source]) - @@index([baseCurrency, quoteCurrency]) - @@map("exchangeRate") -} - -model DealContact { - dealId String - deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade) - contactId String - contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade) - role String? - - @@id([dealId, contactId]) - @@index([contactId]) - @@map("dealContact") -} - -enum FieldEntity { - COMPANY - CONTACT - DEAL -} - -enum FieldType { - TEXT - LONG_TEXT - NUMBER - DATE - CHECKBOX - SELECT - URL - EMAIL - PHONE - USER -} - -model FieldDefinition { - id String @id @default(cuid()) - entity FieldEntity - key String - label String - type FieldType - - agentFilled Boolean @default(true) - agentBrief String? - - required Boolean @default(false) - showOnSheet Boolean @default(true) - showOnTable Boolean @default(false) - showOnFilter Boolean @default(false) - position Int - - archivedAt DateTime? - - options FieldOption[] - values FieldValue[] - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@unique([entity, key]) - @@index([entity, position]) - @@map("fieldDefinition") -} - -model FieldOption { - id String @id @default(cuid()) - fieldId String - field FieldDefinition @relation(fields: [fieldId], references: [id], onDelete: Cascade) - label String - position Int - - archivedAt DateTime? - values FieldValue[] - - @@index([fieldId, position]) - @@map("fieldOption") -} - -model FieldValue { - id String @id @default(cuid()) - fieldId String - field FieldDefinition @relation(fields: [fieldId], references: [id], onDelete: Cascade) - - companyId String? - company Company? @relation(fields: [companyId], references: [id], onDelete: Cascade) - contactId String? - contact Contact? @relation(fields: [contactId], references: [id], onDelete: Cascade) - dealId String? - deal Deal? @relation(fields: [dealId], references: [id], onDelete: Cascade) - - text String? - number Decimal? @db.Decimal(24, 4) - date DateTime? - bool Boolean? - optionId String? - option FieldOption? @relation(fields: [optionId], references: [id], onDelete: SetNull) - userId String? - user User? @relation("FieldValueUser", fields: [userId], references: [id], onDelete: SetNull) - - updatedAt DateTime @updatedAt - - @@unique([fieldId, companyId]) - @@unique([fieldId, contactId]) - @@unique([fieldId, dealId]) - @@index([fieldId, text]) - @@index([fieldId, number]) - @@index([fieldId, date]) - @@index([companyId]) - @@index([contactId]) - @@index([dealId]) - @@index([optionId]) - @@index([userId]) - @@map("fieldValue") -} - -model SavedView { - id String @id @default(cuid()) - entity FieldEntity - name String - shared Boolean @default(false) - filters Json - - ownerId String - owner User @relation("SavedViewOwner", fields: [ownerId], references: [id], onDelete: Cascade) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@unique([entity, ownerId, name]) - @@index([entity, shared]) - @@map("savedView") -} - -model Activity { - id String @id @default(cuid()) - type ActivityType - subject String? - body String? - - occurredAt DateTime? - dueAt DateTime? - completedAt DateTime? - - companyId String? - company Company? @relation(fields: [companyId], references: [id], onDelete: Cascade) - contactId String? - contact Contact? @relation(fields: [contactId], references: [id], onDelete: Cascade) - dealId String? - deal Deal? @relation(fields: [dealId], references: [id], onDelete: Cascade) - - createdById String - createdBy User @relation("ActivityAuthor", fields: [createdById], references: [id]) - meta Json? - - emailThreadId String? @unique - emailThread EmailThread? @relation(fields: [emailThreadId], references: [id], onDelete: Cascade) - calendarEventId String? @unique - calendarEvent CalendarEvent? @relation(fields: [calendarEventId], references: [id], onDelete: Cascade) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([companyId, createdAt]) - @@index([dealId, createdAt]) - @@index([contactId, createdAt]) - @@index([dueAt]) - @@index([createdById]) - @@map("activity") -} - -enum GoogleSyncStatus { - IDLE - RUNNING - NEEDS_RECONNECT - FAILED -} - -enum EmailDirection { - INBOUND - OUTBOUND -} - -model MailboxSync { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - source String - - status GoogleSyncStatus @default(IDLE) - cursor String? - lastSyncedAt DateTime? - lastError String? - retryAfter DateTime? - autoCreate Boolean @default(false) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@unique([userId, source]) - @@index([status]) - @@map("mailboxSync") -} - -model EmailThread { - id String @id @default(cuid()) - rootMessageId String @unique - subject String? - - companyId String? - company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) - contactId String? - contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull) - - firstMessageAt DateTime - lastMessageAt DateTime - messageCount Int @default(0) - - messages EmailMessage[] - activity Activity? - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([companyId, lastMessageAt]) - @@index([contactId, lastMessageAt]) - @@map("emailThread") -} - -model EmailMessage { - id String @id @default(cuid()) - threadId String - thread EmailThread @relation(fields: [threadId], references: [id], onDelete: Cascade) - - rfcMessageId String @unique - syncedByUserId String? - gmailMessageId String? - outlookMessageId String? - outlookWebLink String? - - direction EmailDirection - fromEmail String - fromName String? - recipients Json - subject String? - snippet String? - body String? - sentAt DateTime - - createdAt DateTime @default(now()) - - @@index([threadId, sentAt]) - @@map("emailMessage") -} - -model CalendarEvent { - id String @id @default(cuid()) - iCalUid String - originalStartTime DateTime - recurringEventId String? - - title String? - description String? - location String? - conferenceUrl String? - startsAt DateTime - endsAt DateTime - isAllDay Boolean @default(false) - status String - organizerEmail String? - - companyId String? - company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) - contactId String? - contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull) - - syncedByUserId String? - googleEventId String? - - attendees CalendarAttendee[] - activity Activity? - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@unique([iCalUid, originalStartTime]) - @@index([companyId, startsAt]) - @@index([contactId, startsAt]) - @@map("calendarEvent") -} - -model CalendarAttendee { - id String @id @default(cuid()) - eventId String - event CalendarEvent @relation(fields: [eventId], references: [id], onDelete: Cascade) - - email String - name String? - responseStatus String? - isOrganizer Boolean @default(false) - contactId String? - contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull) - - @@unique([eventId, email]) - @@index([contactId]) - @@map("calendarAttendee") -} - -model SuppressedDomain { - domain String @id - reason String? - createdAt DateTime @default(now()) - - @@map("suppressedDomain") -} - -model SuppressedContact { - email String @id - reason String? - createdAt DateTime @default(now()) - - @@map("suppressedContact") -} - -model AppSetting { - id String @id - - agentModelId String? - - agentModelContextWindow Int? - - contextDevApiKey String? - - reportingCurrency String? - - ratesRefreshedAt DateTime? - - archiveRetentionDays Int @default(180) - - trackingSiteId String? @unique - trackingCrossDomain Boolean @default(true) - trackingLimitToDomains Boolean @default(true) - trackingCookieSubdomains Boolean @default(false) - trackingSecureCookies Boolean @default(true) - trackingHonourDnt Boolean @default(true) - trackingCookieDays Int @default(395) - trackingConfigHash String? - trackingPaused Boolean @default(false) - - updatedAt DateTime @updatedAt - - @@map("appSetting") -} - -enum DomainScope { - SITE_AND_SUBDOMAINS - EXACT_HOST -} - -model TrackedDomain { - id String @id @default(cuid()) - host String @unique - scope DomainScope @default(EXACT_HOST) - - pageViews Int @default(0) - lastSeenAt DateTime? - - createdAt DateTime @default(now()) - - @@map("trackedDomain") -} - -model TrackedVisitor { - id String @id - - contactId String? - contact Contact? @relation("VisitorContact", fields: [contactId], references: [id], onDelete: SetNull) - - firstSource String? - firstMedium String? - firstCampaign String? - firstTerm String? - firstContent String? - firstReferrer String? - firstLanding String? - firstTouchAt DateTime? - - lastSource String? - lastMedium String? - lastCampaign String? - lastTerm String? - lastContent String? - lastReferrer String? - lastLanding String? - lastTouchAt DateTime? - - firstSeen DateTime @default(now()) - lastSeen DateTime @updatedAt - - @@index([contactId]) - @@index([firstSource]) - @@map("trackedVisitor") -} - -model TrackedEvent { - id String @id @default(cuid()) - - visitorId String - type String - host String - path String - referrer String? - label String? - - source String? - medium String? - campaign String? - - occurredAt DateTime - - @@index([visitorId, occurredAt]) - @@index([occurredAt]) - @@index([host, occurredAt]) - @@index([source, occurredAt]) - @@map("trackedEvent") -} - -model TrackingCounter { - key String @id - value Int @default(0) - expiresAt DateTime - - @@index([expiresAt]) - @@map("trackingCounter") -} - -model TrackedPageDaily { - day DateTime - host String - path String - views Int @default(0) - visitors Int @default(0) - - @@id([day, host, path]) - @@index([host, day]) - @@map("trackedPageDaily") -} - -model FormSubmission { - id String @id @default(cuid()) - - visitorId String? - contactId String? - contact Contact? @relation("SubmissionContact", fields: [contactId], references: [id], onDelete: SetNull) - - host String - path String - email String? - - fields Json - - firstTouch Json? - lastTouch Json? - - dedupeKey String @unique - filedAt DateTime? - skipReason String? - - createdAt DateTime @default(now()) - - @@index([contactId]) - @@index([createdAt]) - @@map("formSubmission") -} - -model Install { - id String @id - - uuid String @unique - version String - - lastRollupAt DateTime? - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@map("install") -} - -model TelemetryMilestone { - step String @id - reachedAt DateTime @default(now()) - - @@map("telemetryMilestone") -} - -model TelemetryCounter { - name String @id - count Int @default(0) - updatedAt DateTime @updatedAt - - @@map("telemetryCounter") -} - -model Organization { - id String @id - name String - slug String - logo String? - createdAt DateTime - metadata String? - website String? - members Member[] - invitations Invitation[] - mcpConnections McpConnection[] - mcpOauthStates McpOauthState[] - - @@unique([slug]) - @@map("organization") -} - -model WorkspaceProfile { - id String @id - - website String - narrative String - sections Json - - sourceUrl String? - sessionId String? - - refreshedAt DateTime @default(now()) - - @@map("workspaceProfile") -} - -model Member { - id String @id - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - role String @default("member") - createdAt DateTime - - @@unique([organizationId, userId]) - @@index([organizationId]) - @@index([userId]) - @@map("member") -} - -model Invitation { - id String @id - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - email String - role String? - status String @default("pending") - expiresAt DateTime - createdAt DateTime @default(now()) - inviterId String - user User @relation(fields: [inviterId], references: [id], onDelete: Cascade) - - @@index([organizationId]) - @@index([email]) - @@map("invitation") -} - -model SsoProvider { - id String @id - issuer String - oidcConfig String? - samlConfig String? - userId String? - user User? @relation(fields: [userId], references: [id], onDelete: Cascade) - providerId String - organizationId String? - domain String - - @@unique([providerId]) - @@map("ssoProvider") -} - -model Apikey { - id String @id - configId String @default("default") - name String? - start String? - referenceId String - user User @relation(fields: [referenceId], references: [id], onDelete: Cascade) - prefix String? - key String - refillInterval Int? - refillAmount Int? - lastRefillAt DateTime? - enabled Boolean? @default(true) - rateLimitEnabled Boolean? @default(true) - rateLimitTimeWindow Int? - rateLimitMax Int? - requestCount Int? @default(0) - remaining Int? - lastRequest DateTime? - expiresAt DateTime? - createdAt DateTime - updatedAt DateTime - permissions String? - metadata String? - - @@index([referenceId]) - @@index([configId]) - @@index([key]) - @@map("apikey") -} diff --git a/packages/db/scripts/check-drift.ts b/packages/db/scripts/check-drift.ts new file mode 100644 index 000000000..d3edfde39 --- /dev/null +++ b/packages/db/scripts/check-drift.ts @@ -0,0 +1,46 @@ +import { cpSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative } from "node:path"; +import { MIGRATIONS_FOLDER } from "./migrations"; + +const directory = dirname(import.meta.dirname); + +export async function schemaHasUnmigratedChanges(): Promise { + const scratch = mkdtempSync(join(tmpdir(), "crm-drizzle-drift-")); + try { + cpSync(MIGRATIONS_FOLDER, scratch, { recursive: true }); + const before = readdirSync(scratch).length; + const child = Bun.spawn( + [ + process.execPath, + "x", + "drizzle-kit", + "generate", + "--dialect", + "postgresql", + "--schema", + "./src/schema/schema.ts", + "--out", + relative(directory, scratch), + "--name", + "drift", + ], + { cwd: directory, env: process.env, stdout: "ignore", stderr: "inherit" }, + ); + const code = await child.exited; + if (code !== 0) throw new Error(`drizzle-kit generate exited with ${code}`); + return readdirSync(scratch).length !== before; + } finally { + rmSync(scratch, { recursive: true, force: true }); + } +} + +if (import.meta.main) { + if (await schemaHasUnmigratedChanges()) { + console.error( + "src/schema/schema.ts has changes that no migration in drizzle/ covers. Run `bun run db:migrate`.", + ); + process.exit(1); + } + console.log("The schema and the migrations agree."); +} diff --git a/packages/db/scripts/migrate.ts b/packages/db/scripts/migrate.ts new file mode 100644 index 000000000..0c5b3d8cf --- /dev/null +++ b/packages/db/scripts/migrate.ts @@ -0,0 +1,7 @@ +import { loadRootEnv } from "@crm/env"; +import { databaseUrl, migrateDatabase } from "./migrations"; + +loadRootEnv(); + +await migrateDatabase(databaseUrl()); +console.log("Database migrations are applied."); diff --git a/packages/db/scripts/migrations.ts b/packages/db/scripts/migrations.ts new file mode 100644 index 000000000..5ee1c4f77 --- /dev/null +++ b/packages/db/scripts/migrations.ts @@ -0,0 +1,130 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { migrate } from "drizzle-orm/node-postgres/migrator"; +import pg from "pg"; + +export const MIGRATIONS_FOLDER = join(dirname(import.meta.dirname), "drizzle"); + +const MIGRATIONS_SCHEMA = "drizzle"; +const MIGRATIONS_TABLE = "__drizzle_migrations"; + +type JournalEntry = { idx: number; when: number; tag: string }; + +export function journal(): JournalEntry[] { + const raw = readFileSync( + join(MIGRATIONS_FOLDER, "meta", "_journal.json"), + "utf8", + ); + return (JSON.parse(raw) as { entries: JournalEntry[] }).entries; +} + +export function migrationHash(tag: string): string { + const sql = readFileSync(join(MIGRATIONS_FOLDER, `${tag}.sql`), "utf8"); + return createHash("sha256").update(sql).digest("hex"); +} + +function legacyPrismaMigrations(): string[] { + return readFileSync( + join(MIGRATIONS_FOLDER, "legacy-prisma-migrations.txt"), + "utf8", + ) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + +async function tableExists( + client: pg.Client, + schema: string, + table: string, +): Promise { + const result = await client.query( + "SELECT 1 FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2", + [schema, table], + ); + return (result.rowCount ?? 0) > 0; +} + +export async function appliedHashes( + client: pg.Client, +): Promise { + if (!(await tableExists(client, MIGRATIONS_SCHEMA, MIGRATIONS_TABLE))) + return null; + const rows = await client.query<{ hash: string }>( + `SELECT hash FROM "${MIGRATIONS_SCHEMA}"."${MIGRATIONS_TABLE}" ORDER BY created_at`, + ); + return rows.rows.map((row) => row.hash); +} + +async function adoptPrismaDatabase(client: pg.Client): Promise { + if (!(await tableExists(client, "public", "_prisma_migrations"))) return; + if ((await appliedHashes(client)) !== null) return; + + const applied = await client.query<{ migration_name: string }>( + "SELECT migration_name FROM _prisma_migrations WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL", + ); + const done = new Set(applied.rows.map((row) => row.migration_name)); + const missing = legacyPrismaMigrations().filter((name) => !done.has(name)); + + if (missing.length > 0) { + throw new Error( + [ + `This database was created by Prisma and is missing ${missing.length} of its migrations, starting with ${missing[0]}.`, + "The Drizzle baseline equals the schema after the last Prisma migration.", + "Apply the remaining Prisma migrations from the last Prisma release first, then run this again.", + ].join("\n"), + ); + } + + const [baseline] = journal(); + if (!baseline) + throw new Error("drizzle/meta/_journal.json lists no migrations."); + await client.query("BEGIN"); + try { + await client.query(`CREATE SCHEMA IF NOT EXISTS "${MIGRATIONS_SCHEMA}"`); + await client.query( + `CREATE TABLE IF NOT EXISTS "${MIGRATIONS_SCHEMA}"."${MIGRATIONS_TABLE}" (id SERIAL PRIMARY KEY, hash text NOT NULL, created_at bigint)`, + ); + await client.query( + `INSERT INTO "${MIGRATIONS_SCHEMA}"."${MIGRATIONS_TABLE}" (hash, created_at) VALUES ($1, $2)`, + [migrationHash(baseline.tag), baseline.when], + ); + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } + console.log( + " adopted the existing Prisma schema as Drizzle migration 0000_baseline", + ); +} + +export async function migrateDatabase(url: string): Promise { + const client = new pg.Client({ connectionString: url }); + await client.connect(); + try { + await adoptPrismaDatabase(client); + await migrate(drizzle({ client }), { + migrationsFolder: MIGRATIONS_FOLDER, + migrationsSchema: MIGRATIONS_SCHEMA, + migrationsTable: MIGRATIONS_TABLE, + }); + } finally { + await client.end(); + } +} + +export function databaseUrl(): string { + const url = + process.env.DIRECT_DATABASE_URL || + process.env.POSTGRES_URL_NON_POOLING || + process.env.DATABASE_URL; + if (!url) { + throw new Error( + "DATABASE_URL is not set. Copy .env.example to .env at the root of the repo and fill it in.", + ); + } + return url; +} diff --git a/packages/db/scripts/prepare-dev.ts b/packages/db/scripts/prepare-dev.ts index 7ff09458c..1f6b573f3 100644 --- a/packages/db/scripts/prepare-dev.ts +++ b/packages/db/scripts/prepare-dev.ts @@ -1,52 +1,32 @@ import { join } from "node:path"; +import { loadRootEnv } from "@crm/env"; +import { schemaHasUnmigratedChanges } from "./check-drift"; +import { databaseUrl, migrateDatabase } from "./migrations"; -const directory = join(import.meta.dir, ".."); - -await run([process.execPath, "scripts/require-local-db.ts", "dev"]); -const [migration, generation] = await Promise.all([ - execute([process.execPath, "run", "prisma", "migrate", "deploy"]), - execute([process.execPath, "run", "prisma", "generate"]), -]); - -if (migration !== 0) process.exit(migration); -if (generation !== 0) process.exit(generation); - -const drift = await execute([ - process.execPath, - "run", - "prisma", - "migrate", - "diff", - "--from-config-datasource", - "--to-schema", - "prisma/schema.prisma", - "--exit-code", -]); - -if (drift === 2) { - console.error( - "The database and Prisma schema differ. Reconcile the migration history with `bun run db:migrate` before starting dev.", - ); - process.exit(1); -} +loadRootEnv(); -if (drift !== 0) process.exit(drift); - -console.log("Database migrations and generated Prisma client are ready."); - -async function run(command: string[]): Promise { - const exitCode = await execute(command); - if (exitCode !== 0) process.exit(exitCode); -} +const directory = join(import.meta.dir, ".."); -async function execute(command: string[]): Promise { - const child = Bun.spawn(command, { +const guard = Bun.spawn( + [process.execPath, "scripts/require-local-db.ts", "dev"], + { cwd: directory, env: process.env, stdin: "inherit", stdout: "inherit", stderr: "inherit", - }); + }, +); +const guardCode = await guard.exited; +if (guardCode !== 0) process.exit(guardCode); + +await migrateDatabase(databaseUrl()); - return child.exited; +if (await schemaHasUnmigratedChanges()) { + console.error( + "src/schema/schema.ts has changes that no migration covers. Create one with `bun run db:migrate` before starting dev.", + ); + process.exit(1); } + +console.log("Database migrations are applied and match the Drizzle schema."); diff --git a/packages/db/scripts/reset.ts b/packages/db/scripts/reset.ts new file mode 100644 index 000000000..b719f0eb0 --- /dev/null +++ b/packages/db/scripts/reset.ts @@ -0,0 +1,29 @@ +import { loadRootEnv } from "@crm/env"; +import pg from "pg"; +import { databaseUrl, migrateDatabase } from "./migrations"; + +loadRootEnv(); + +const url = databaseUrl(); +const client = new pg.Client({ connectionString: url }); +await client.connect(); +try { + await client.query('DROP SCHEMA IF EXISTS "drizzle" CASCADE'); + await client.query("DROP SCHEMA IF EXISTS public CASCADE"); + await client.query("CREATE SCHEMA public"); +} finally { + await client.end(); +} + +await migrateDatabase(url); +console.log("Database reset and migrated."); + +if (!process.argv.includes("--skip-seed")) { + const seed = Bun.spawn([process.execPath, "scripts/seed.ts"], { + cwd: `${import.meta.dir}/..`, + env: process.env, + stdout: "inherit", + stderr: "inherit", + }); + process.exit(await seed.exited); +} diff --git a/packages/db/prisma/seed.ts b/packages/db/scripts/seed.ts similarity index 79% rename from packages/db/prisma/seed.ts rename to packages/db/scripts/seed.ts index 556f2e2eb..83f8bf8be 100644 --- a/packages/db/prisma/seed.ts +++ b/packages/db/scripts/seed.ts @@ -1,3 +1,5 @@ +import { Decimal } from "decimal.js"; +import { and, eq, isNull, sql } from "drizzle-orm"; import { mirror } from "../src/blob"; import { db } from "../src/client"; import { DEFAULT_REPORTING_CURRENCY } from "../src/currency"; @@ -9,7 +11,20 @@ import { type FieldEntity, FieldType, RateSource, -} from "../src/generated/prisma/enums"; +} from "../src/schema/enums"; +import { + activity, + appSetting, + company as companyTable, + contact as contactTable, + dealContact, + deal as dealTable, + exchangeRate, + fieldDefinition, + fieldOption, + fieldValue, + user, +} from "../src/schema/schema"; import { readReportingCurrency, SETTINGS_ID } from "../src/settings"; function makeRandom(seed: number): () => number { @@ -341,7 +356,7 @@ function slug(value: string): string { } async function seedOwners(): Promise { - const existing = await db.user.findMany({ select: { id: true } }); + const existing = await db.select({ id: user.id }).from(user); if (existing.length > 0) { console.log(`Using ${existing.length} existing user(s) as owners.`); @@ -351,18 +366,21 @@ async function seedOwners(): Promise { console.log("No users yet — creating placeholder sales reps."); const created = await Promise.all( OWNERS.map((owner) => - db.user.upsert({ - where: { email: owner.email }, - create: { + db + .insert(user) + .values({ id: `seed-${slug(owner.name)}`, name: owner.name, email: owner.email, emailVerified: true, updatedAt: new Date(), - }, - update: {}, - select: { id: true }, - }), + }) + .onConflictDoUpdate({ + target: user.email, + set: { email: sql`${user.email}` }, + }) + .returning({ id: user.id }) + .then(([row]) => required(row, "user")), ), ); @@ -375,9 +393,9 @@ async function seedCompanies( const companies = []; for (const company of COMPANIES) { - const row = await db.company.upsert({ - where: { domain: company.domain }, - create: { + const row = await db + .insert(companyTable) + .values({ name: company.name, domain: company.domain, website: `https://${company.domain}`, @@ -387,10 +405,19 @@ async function seedCompanies( countryCode: company.countryCode, ownerId: pick(ownerIds), createdAt: daysFromNow(-integer(30, 400), 12), - }, - update: {}, - select: { id: true, name: true, domain: true, iconUrl: true }, - }); + }) + .onConflictDoUpdate({ + target: companyTable.domain, + targetWhere: isNull(companyTable.archivedAt), + set: { domain: sql`${companyTable.domain}` }, + }) + .returning({ + id: companyTable.id, + name: companyTable.name, + domain: companyTable.domain, + iconUrl: companyTable.iconUrl, + }) + .then(([inserted]) => required(inserted, "company")); companies.push({ ...row, domain: row.domain ?? company.domain }); } @@ -415,10 +442,12 @@ async function seedIcons( const iconUrl = (await mirror(source, `companies/${company.id}/icon`)) ?? source; - await db.company.updateMany({ - where: { id: company.id, iconUrl: null }, - data: { iconUrl }, - }); + await db + .update(companyTable) + .set({ iconUrl }) + .where( + and(eq(companyTable.id, company.id), isNull(companyTable.iconUrl)), + ); resolved += 1; } @@ -461,26 +490,45 @@ async function upsertField( options: readonly string[] = [], ): Promise { const key = fieldKeyFromLabel(label); - const definition = await db.fieldDefinition.upsert({ - where: { entity_key: { entity, key } }, - create: { - entity, - key, - label, - type, - showOnTable: false, - showOnFilter: false, - agentFilled: type !== "USER" && type !== "NUMBER", - position, - options: { - create: options.map((optionLabel, index) => ({ + const definition = await db.transaction(async (tx) => { + const [inserted] = await tx + .insert(fieldDefinition) + .values({ + entity, + key, + label, + type, + showOnTable: false, + showOnFilter: false, + agentFilled: type !== "USER" && type !== "NUMBER", + position, + }) + .onConflictDoNothing({ + target: [fieldDefinition.entity, fieldDefinition.key], + }) + .returning({ id: fieldDefinition.id, key: fieldDefinition.key }); + + if (inserted && options.length > 0) { + await tx.insert(fieldOption).values( + options.map((optionLabel, index) => ({ + fieldId: inserted.id, label: optionLabel, position: index, })), - }, - }, - update: {}, - include: { options: true }, + ); + } + + return required( + await tx.query.fieldDefinition.findFirst({ + where: and( + eq(fieldDefinition.entity, entity), + eq(fieldDefinition.key, key), + ), + columns: { id: true, key: true }, + with: { options: true }, + }), + "field definition", + ); }); return { @@ -542,111 +590,63 @@ async function seedCompanyFieldValues( const accountType = pick(ACCOUNT_TYPES); await Promise.all([ - db.fieldValue.upsert({ - where: { - fieldId_companyId: { - fieldId: fields.accountType.id, - companyId: company.id, - }, - }, - create: { - fieldId: fields.accountType.id, - companyId: company.id, - optionId: optionIdFor(fields.accountType, accountType), - }, - update: {}, + seedFieldValue({ + fieldId: fields.accountType.id, + companyId: company.id, + optionId: optionIdFor(fields.accountType, accountType), }), - db.fieldValue.upsert({ - where: { - fieldId_companyId: { - fieldId: fields.segment.id, - companyId: company.id, - }, - }, - create: { - fieldId: fields.segment.id, - companyId: company.id, - optionId: optionIdFor(fields.segment, pick(SEGMENT_TIERS)), - }, - update: {}, + seedFieldValue({ + fieldId: fields.segment.id, + companyId: company.id, + optionId: optionIdFor(fields.segment, pick(SEGMENT_TIERS)), }), - db.fieldValue.upsert({ - where: { - fieldId_companyId: { - fieldId: fields.territory.id, - companyId: company.id, - }, - }, - create: { - fieldId: fields.territory.id, - companyId: company.id, - optionId: optionIdFor(fields.territory, pick(TERRITORIES)), - }, - update: {}, + seedFieldValue({ + fieldId: fields.territory.id, + companyId: company.id, + optionId: optionIdFor(fields.territory, pick(TERRITORIES)), }), - db.fieldValue.upsert({ - where: { - fieldId_companyId: { - fieldId: fields.lifecycleStage.id, - companyId: company.id, - }, - }, - create: { - fieldId: fields.lifecycleStage.id, - companyId: company.id, - optionId: optionIdFor( - fields.lifecycleStage, - accountType === "Customer" ? "Customer" : pick(LIFECYCLE_STAGES), - ), - }, - update: {}, + seedFieldValue({ + fieldId: fields.lifecycleStage.id, + companyId: company.id, + optionId: optionIdFor( + fields.lifecycleStage, + accountType === "Customer" ? "Customer" : pick(LIFECYCLE_STAGES), + ), }), - db.fieldValue.upsert({ - where: { - fieldId_companyId: { - fieldId: fields.leadSource.id, - companyId: company.id, - }, - }, - create: { - fieldId: fields.leadSource.id, - companyId: company.id, - optionId: optionIdFor(fields.leadSource, pick(LEAD_SOURCES)), - }, - update: {}, + seedFieldValue({ + fieldId: fields.leadSource.id, + companyId: company.id, + optionId: optionIdFor(fields.leadSource, pick(LEAD_SOURCES)), }), - db.fieldValue.upsert({ - where: { - fieldId_companyId: { - fieldId: fields.icpFitScore.id, - companyId: company.id, - }, - }, - create: { - fieldId: fields.icpFitScore.id, - companyId: company.id, - number: integer(40, 95), - }, - update: {}, + seedFieldValue({ + fieldId: fields.icpFitScore.id, + companyId: company.id, + number: new Decimal(integer(40, 95)), }), - db.fieldValue.upsert({ - where: { - fieldId_companyId: { - fieldId: fields.bdrOwner.id, - companyId: company.id, - }, - }, - create: { - fieldId: fields.bdrOwner.id, - companyId: company.id, - userId: pick(ownerIds), - }, - update: {}, + seedFieldValue({ + fieldId: fields.bdrOwner.id, + companyId: company.id, + userId: pick(ownerIds), }), ]); } } +function seedFieldValue(values: typeof fieldValue.$inferInsert) { + return db + .insert(fieldValue) + .values(values) + .onConflictDoNothing({ + target: [fieldValue.fieldId, fieldValue.companyId], + }); +} + +function required(value: T | undefined, what: string): T { + if (value === undefined) + throw new Error(`The seed could not read back the ${what} it wrote.`); + return value; +} + type SeededContact = { id: string; companyId: string }; async function seedContacts( @@ -664,9 +664,9 @@ async function seedContacts( if (used.has(email)) continue; used.add(email); - const contact = await db.contact.upsert({ - where: { email }, - create: { + const contact = await db + .insert(contactTable) + .values({ firstName, lastName, email, @@ -675,10 +675,14 @@ async function seedContacts( companyId: company.id, ownerId: pick(ownerIds), createdAt: daysFromNow(-integer(10, 300), 12), - }, - update: {}, - select: { id: true }, - }); + }) + .onConflictDoUpdate({ + target: contactTable.email, + targetWhere: isNull(contactTable.archivedAt), + set: { email: sql`${contactTable.email}` }, + }) + .returning({ id: contactTable.id }) + .then(([row]) => required(row, "contact")); contacts.push({ id: contact.id, companyId: company.id }); } @@ -687,10 +691,10 @@ async function seedContacts( for (const company of companies) { const first = contacts.find((contact) => contact.companyId === company.id); if (!first) continue; - await db.company.update({ - where: { id: company.id }, - data: { primaryContactId: first.id }, - }); + await db + .update(companyTable) + .set({ primaryContactId: first.id }) + .where(eq(companyTable.id, company.id)); } return contacts; @@ -720,15 +724,10 @@ let seedBase = "USD"; async function seedRates(): Promise { const asOf = daysFromNow(-1); - await db.appSetting.upsert({ - where: { id: SETTINGS_ID }, - create: { - id: SETTINGS_ID, - reportingCurrency: DEFAULT_REPORTING_CURRENCY, - }, - update: {}, - select: { id: true }, - }); + await db + .insert(appSetting) + .values({ id: SETTINGS_ID, reportingCurrency: DEFAULT_REPORTING_CURRENCY }) + .onConflictDoNothing({ target: appSetting.id }); seedBase = await readReportingCurrency(db); @@ -740,24 +739,24 @@ async function seedRates(): Promise { } for (const [quoteCurrency, rate] of Object.entries(SEED_RATES)) { - await db.exchangeRate.upsert({ - where: { - baseCurrency_quoteCurrency_source: { - baseCurrency: "USD", - quoteCurrency, - source: RateSource.FETCHED, - }, - }, - create: { + await db + .insert(exchangeRate) + .values({ baseCurrency: "USD", quoteCurrency, - rate, + rate: new Decimal(rate), asOf, source: RateSource.FETCHED, provider: "seed", - }, - update: { rate, asOf, provider: "seed" }, - }); + }) + .onConflictDoUpdate({ + target: [ + exchangeRate.baseCurrency, + exchangeRate.quoteCurrency, + exchangeRate.source, + ], + set: { rate: new Decimal(rate), asOf, provider: "seed" }, + }); } return Object.keys(SEED_RATES).length; @@ -776,11 +775,11 @@ function money(usdAmount: number, currency: string) { : null; return { - amount, + amount: new Decimal(amount), currency, - baseAmount: converted?.baseAmount ?? null, + baseAmount: converted ? new Decimal(converted.baseAmount) : null, baseCurrency: converted ? seedBase : null, - fxRate: converted?.fxRate ?? null, + fxRate: converted ? new Decimal(converted.fxRate) : null, }; } @@ -809,9 +808,9 @@ async function seedDeals( 12, ); - await db.deal.upsert({ - where: { id }, - create: { + await db + .insert(dealTable) + .values({ id, name: n === 0 @@ -846,23 +845,23 @@ async function seedDeals( ? pick(LOST_REASONS) : null, createdAt, - }, - update: {}, - }); + }) + .onConflictDoNothing({ target: dealTable.id }); const companyContacts = contacts.filter( (contact) => contact.companyId === company.id, ); for (const contact of companyContacts.slice(0, integer(1, 2))) { - await db.dealContact.upsert({ - where: { dealId_contactId: { dealId: id, contactId: contact.id } }, - create: { + await db + .insert(dealContact) + .values({ dealId: id, contactId: contact.id, role: chance(0.5) ? "Champion" : "Decision maker", - }, - update: {}, - }); + }) + .onConflictDoNothing({ + target: [dealContact.dealId, dealContact.contactId], + }); } deals.push({ id, companyId: company.id, ownerId, closed }); @@ -878,7 +877,7 @@ async function seedActivities( deals: SeededDeal[], ownerIds: string[], ): Promise { - const existing = await db.activity.count(); + const existing = await db.$count(activity); if (existing > 0) { console.log(`Activities already seeded (${existing}) — skipping.`); return existing; @@ -987,7 +986,7 @@ async function seedActivities( }); } - await db.activity.createMany({ data: rows }); + await db.insert(activity).values(rows); return rows.length; } @@ -1014,5 +1013,5 @@ main() process.exitCode = 1; }) .finally(async () => { - await db.$disconnect(); + await db.$client.end(); }); diff --git a/packages/db/scripts/test-db.ts b/packages/db/scripts/test-db.ts index 7deb36e5f..d594441f1 100644 --- a/packages/db/scripts/test-db.ts +++ b/packages/db/scripts/test-db.ts @@ -1,10 +1,10 @@ -import { spawnSync } from "node:child_process"; -import { existsSync, readdirSync } from "node:fs"; -import { dirname, join } from "node:path"; import pg from "pg"; - -const SCHEMA = join(dirname(import.meta.dirname), "prisma", "schema.prisma"); -const MIGRATIONS = join(dirname(import.meta.dirname), "prisma", "migrations"); +import { + appliedHashes, + journal, + migrateDatabase, + migrationHash, +} from "./migrations"; const url = resolve(); @@ -26,7 +26,7 @@ if (!name.endsWith("_test")) { } await create(url, name, process.argv.includes("--reset")); -migrate(url); +await migrateDatabase(url); if (!process.env.TEST_DATABASE_URL) { console.log( @@ -99,28 +99,6 @@ async function drop(client: pg.Client, database: string): Promise { } async function stale(target: string, database: string): Promise { - const applied = await appliedMigrations(target); - - if (applied === null) return null; - - const onDisk = new Set( - existsSync(MIGRATIONS) - ? readdirSync(MIGRATIONS, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - : [], - ); - - const foreign = applied.filter((migration) => !onDisk.has(migration)); - - if (foreign.length > 0) { - return `${database} holds ${foreign.length} migration(s) this branch does not have, starting with ${foreign[0]}`; - } - - return drifted(target) ? `${database} no longer matches schema.prisma` : null; -} - -async function appliedMigrations(target: string): Promise { const client = new pg.Client({ connectionString: target }); try { @@ -130,52 +108,24 @@ async function appliedMigrations(target: string): Promise { } try { - const rows = await client.query<{ migration_name: string }>( - `SELECT migration_name FROM _prisma_migrations WHERE finished_at IS NOT NULL`, - ); - return rows.rows.map((row) => row.migration_name); - } catch { - return null; - } finally { - await client.end(); - } -} + const applied = await appliedHashes(client); + if (applied === null) return null; -function drifted(target: string): boolean { - const result = spawnSync( - "prisma", - [ - "migrate", - "diff", - "--from-config-datasource", - "--to-schema", - SCHEMA, - "--exit-code", - ], - { stdio: "ignore", env: { ...process.env, DATABASE_URL: target } }, - ); + const onDisk = journal().map((entry) => migrationHash(entry.tag)); + const known = new Set(onDisk); + const foreign = applied.filter((hash) => !known.has(hash)); - return result.status === 2; -} - -function migrate(target: string): void { - const result = spawnSync("prisma", ["migrate", "deploy"], { - stdio: "inherit", - env: { ...process.env, DATABASE_URL: target }, - }); + if (foreign.length > 0) { + return `${database} holds ${foreign.length} migration(s) this branch does not have`; + } - if (result.error) { - fail([ - "Could not run prisma migrate deploy.", - "Run this through the package script, which puts prisma on PATH:", - "", - " bun run db:test", - "", - result.error.message, - ]); + const changed = applied.some((hash, index) => onDisk[index] !== hash); + return changed + ? `${database} was migrated from different migration files` + : null; + } finally { + await client.end(); } - - if (result.status !== 0) process.exit(result.status ?? 1); } function resolve(): string | null { diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index db092b1ad..3da6d046b 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -1,7 +1,13 @@ import "@crm/env/load"; -import { PrismaPg } from "@prisma/adapter-pg"; -import { type Prisma, PrismaClient } from "./generated/prisma/client"; +import type { ExtractTablesWithRelations } from "drizzle-orm"; +import type { NodePgQueryResultHKT } from "drizzle-orm/node-postgres"; +import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; +import type { PgTransaction } from "drizzle-orm/pg-core"; +import pg, { Pool } from "pg"; +import * as schema from "./schema/schema"; + +pg.defaults.parseInputDatesAsUTC = true; const connectionString = process.env.NODE_ENV === "test" ? testDatabase() : liveDatabase(); @@ -57,18 +63,20 @@ function databaseName(url: string): string { } } -export interface PrismaLogRecord { - level: Prisma.LogLevel; +export type DbLogLevel = "query" | "info" | "warn" | "error"; + +export interface DbLogRecord { + level: DbLogLevel; message: string; target: string; durationMs?: number; } -export type PrismaLogSink = (record: PrismaLogRecord) => void; +export type DbLogSink = (record: DbLogRecord) => void; -const consoleSink: PrismaLogSink = ({ level, message, target, durationMs }) => { +const consoleSink: DbLogSink = ({ level, message, target, durationMs }) => { const suffix = durationMs === undefined ? "" : ` (+${durationMs}ms)`; - const line = `[prisma:${level}] ${message}${suffix} [${target}]`; + const line = `[db:${level}] ${message}${suffix} [${target}]`; if (level === "error") { console.error(line); @@ -79,55 +87,60 @@ const consoleSink: PrismaLogSink = ({ level, message, target, durationMs }) => { } }; -let sink: PrismaLogSink = consoleSink; +let sink: DbLogSink = consoleSink; -export function setPrismaLogSink(next: PrismaLogSink | null): void { +export function setDbLogSink(next: DbLogSink | null): void { sink = next ?? consoleSink; } -const logQueries = process.env.PRISMA_LOG_QUERIES === "true"; - -const logDefinitions: Prisma.LogDefinition[] = [ - { level: "warn", emit: "event" }, - { level: "error", emit: "event" }, - ...(logQueries - ? ([ - { level: "query", emit: "event" }, - { level: "info", emit: "event" }, - ] satisfies Prisma.LogDefinition[]) - : []), -]; - -const createPrismaClient = () => { - const client = new PrismaClient({ - adapter: new PrismaPg({ connectionString }), - log: logDefinitions, - }); +const logQueries = process.env.DB_LOG_QUERIES === "true"; - client.$on("error", ({ message, target }) => { - sink({ level: "error", message, target }); - }); - client.$on("warn", ({ message, target }) => { - sink({ level: "warn", message, target }); - }); - client.$on("info", ({ message, target }) => { - sink({ level: "info", message, target }); - }); - client.$on("query", ({ query, duration, target }) => { - sink({ level: "query", message: query, target, durationMs: duration }); +type Schema = typeof schema; + +class ReopeningPool extends Pool { + override async end(): Promise { + if (this.ending) return; + await super.end(); + Object.assign(this, { ending: false, ended: false }); + } +} + +const createDb = () => { + const pool = new ReopeningPool({ connectionString }); + + pool.on("error", (error) => { + sink({ level: "error", message: error.message, target: "pg.pool" }); }); - return client; + return drizzle({ + client: pool, + schema, + logger: logQueries + ? { + logQuery(query) { + sink({ level: "query", message: query, target: "drizzle" }); + }, + } + : false, + }); }; declare global { - var prisma: ReturnType | undefined; + var crmDb: (NodePgDatabase & { $client: Pool }) | undefined; } -export const db = globalThis.prisma ?? createPrismaClient(); +export const db = globalThis.crmDb ?? createDb(); if (process.env.NODE_ENV !== "production") { - globalThis.prisma = db; + globalThis.crmDb = db; } export type Db = typeof db; + +export type Tx = PgTransaction< + NodePgQueryResultHKT, + Schema, + ExtractTablesWithRelations +>; + +export type Executor = Db | Tx; diff --git a/packages/db/src/deal-stage.ts b/packages/db/src/deal-stage.ts index 99f32fb39..115de9fa1 100644 --- a/packages/db/src/deal-stage.ts +++ b/packages/db/src/deal-stage.ts @@ -1,4 +1,4 @@ -import { DealStage } from "./generated/prisma/enums"; +import { DealStage } from "./schema/enums"; export const OPEN_DEAL_STAGES = [ DealStage.DEMO_BOOKED, diff --git a/packages/db/src/errors.ts b/packages/db/src/errors.ts new file mode 100644 index 000000000..4a2539e99 --- /dev/null +++ b/packages/db/src/errors.ts @@ -0,0 +1,26 @@ +import { DatabaseError } from "pg"; + +const PG_ERROR = { + uniqueViolation: "23505", + foreignKeyViolation: "23503", +} as const; + +const MAX_CAUSE_DEPTH = 5; + +export function pgErrorCode(cause: unknown): string | undefined { + let current = cause; + for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth++) { + if (current instanceof DatabaseError) return current.code; + if (!(current instanceof Error)) return undefined; + current = current.cause; + } + return undefined; +} + +export function isUniqueViolation(cause: unknown): boolean { + return pgErrorCode(cause) === PG_ERROR.uniqueViolation; +} + +export function isForeignKeyViolation(cause: unknown): boolean { + return pgErrorCode(cause) === PG_ERROR.foreignKeyViolation; +} diff --git a/packages/db/src/fields.ts b/packages/db/src/fields.ts index 58dee31b9..85d32194e 100644 --- a/packages/db/src/fields.ts +++ b/packages/db/src/fields.ts @@ -1,3 +1,6 @@ +import { Decimal } from "decimal.js"; +import { and, eq, inArray } from "drizzle-orm"; +import type { Executor } from "./client"; import { columnFor, FieldValueError, @@ -6,13 +9,14 @@ import { recordColumn, typeLabel, } from "./fields-shape"; -import { Prisma } from "./generated/prisma/client"; -import type { FieldEntity, FieldType } from "./generated/prisma/enums"; +import type { FieldEntity, FieldType } from "./schema/enums"; import type { - FieldDefinitionModel, - FieldOptionModel, - FieldValueModel, -} from "./generated/prisma/models"; + FieldDefinition as FieldDefinitionModel, + FieldOption as FieldOptionModel, + FieldValue as FieldValueModel, + NewFieldValue, +} from "./schema/models"; +import { fieldValue, user } from "./schema/schema"; export * from "./fields-shape"; @@ -141,7 +145,7 @@ export function coerceValue( ); } - return { number: new Prisma.Decimal(parsed) }; + return { number: new Decimal(parsed) }; } case "DATE": { @@ -233,25 +237,8 @@ export function attachValues( }); } -export type FieldWriter = { - fieldValue: { - deleteMany(args: { where: Record }): Promise; - upsert(args: { - where: Record; - create: Record; - update: Record; - }): Promise; - }; - user: { - findMany(args: { - where: { id: { in: string[] } }; - select: { id: true }; - }): Promise<{ id: string }[]>; - }; -}; - export async function writeValues( - tx: FieldWriter, + tx: Executor, entity: FieldEntity, recordId: string, definitions: FieldDefinitionWithOptions[], @@ -280,19 +267,25 @@ export async function writeValues( for (const { definition, data, stored } of writes) { if (stored === null || stored === undefined) { - await tx.fieldValue.deleteMany({ - where: { fieldId: definition.id, [column]: recordId }, - }); + await tx + .delete(fieldValue) + .where( + and( + eq(fieldValue.fieldId, definition.id), + eq(fieldValue[column], recordId), + ), + ); continue; } - await tx.fieldValue.upsert({ - where: { - [`fieldId_${column}`]: { fieldId: definition.id, [column]: recordId }, - }, - create: { fieldId: definition.id, [column]: recordId, ...data }, - update: data, - }); + const values = data as Partial; + await tx + .insert(fieldValue) + .values({ fieldId: definition.id, [column]: recordId, ...values }) + .onConflictDoUpdate({ + target: [fieldValue.fieldId, fieldValue[column]], + set: values, + }); } } @@ -302,7 +295,7 @@ type PendingWrite = { }; async function assertUsersExist( - tx: FieldWriter, + tx: Executor, writes: PendingWrite[], ): Promise { const wanted = writes.filter( @@ -312,10 +305,10 @@ async function assertUsersExist( if (wanted.length === 0) return; - const known = await tx.user.findMany({ - where: { id: { in: [...new Set(wanted.map((write) => write.stored))] } }, - select: { id: true }, - }); + const known = await tx + .select({ id: user.id }) + .from(user) + .where(inArray(user.id, [...new Set(wanted.map((write) => write.stored))])); const found = new Set(known.map((row) => row.id)); diff --git a/packages/db/src/fx.ts b/packages/db/src/fx.ts index d6b46f25f..86bb098db 100644 --- a/packages/db/src/fx.ts +++ b/packages/db/src/fx.ts @@ -1,29 +1,31 @@ -import type { Db } from "./client"; +import { Decimal } from "decimal.js"; +import { and, eq } from "drizzle-orm"; +import type { Executor } from "./client"; import { minorUnitsOf, normalizeCurrency } from "./currency"; -import { Prisma } from "./generated/prisma/client"; -import { RateSource } from "./generated/prisma/enums"; +import { RateSource } from "./schema/enums"; +import { exchangeRate } from "./schema/schema"; export type RateOrigin = "IDENTITY" | "MANUAL" | "FETCHED"; export interface ResolvedRate { - rate: Prisma.Decimal; + rate: Decimal; asOf: Date; origin: RateOrigin; provider: string | null; } export interface Conversion { - baseAmount: Prisma.Decimal; + baseAmount: Decimal; baseCurrency: string; - fxRate: Prisma.Decimal; + fxRate: Decimal; fxRateAt: Date; origin: RateOrigin; } -export const ONE = new Prisma.Decimal(1); +export const ONE = new Decimal(1); export async function resolveRate( - db: Db, + db: Executor, base: string, quote: string, now: Date = new Date(), @@ -37,10 +39,20 @@ export async function resolveRate( return { rate: ONE, asOf: now, origin: "IDENTITY", provider: null }; } - const rows = await db.exchangeRate.findMany({ - where: { baseCurrency, quoteCurrency }, - select: { rate: true, asOf: true, source: true, provider: true }, - }); + const rows = await db + .select({ + rate: exchangeRate.rate, + asOf: exchangeRate.asOf, + source: exchangeRate.source, + provider: exchangeRate.provider, + }) + .from(exchangeRate) + .where( + and( + eq(exchangeRate.baseCurrency, baseCurrency), + eq(exchangeRate.quoteCurrency, quoteCurrency), + ), + ); const manual = rows.find((row) => row.source === RateSource.MANUAL); const chosen = @@ -57,7 +69,7 @@ export async function resolveRate( } export function applyRate( - amount: Prisma.Decimal, + amount: Decimal, rate: ResolvedRate, base: string, ): Conversion { @@ -75,8 +87,8 @@ export function applyRate( } export async function convertToBase( - db: Db, - amount: Prisma.Decimal | null, + db: Executor, + amount: Decimal | null, from: string, base: string, now: Date = new Date(), diff --git a/packages/db/src/idempotency.ts b/packages/db/src/idempotency.ts index bcd563df2..a4807488c 100644 --- a/packages/db/src/idempotency.ts +++ b/packages/db/src/idempotency.ts @@ -1,26 +1,30 @@ -import { Prisma } from "./generated/prisma/client"; +import { sql } from "drizzle-orm"; +import type { Executor } from "./client"; export async function lockIdempotencyKey( - tx: Prisma.TransactionClient, + tx: Executor, key: string, ): Promise { - await tx.$queryRaw>` - SELECT pg_advisory_xact_lock(hashtextextended(${key}, 0)) IS NULL AS locked - `; + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${key}, 0)) IS NULL AS locked`, + ); } export async function lockIdempotencyKeys( - tx: Prisma.TransactionClient, + tx: Executor, keys: string[], ): Promise { const ordered = [...new Set(keys)].sort(); if (ordered.length === 0) return; - await tx.$queryRaw>` + await tx.execute(sql` SELECT pg_advisory_xact_lock(hashtextextended(requested.key, 0)) IS NULL AS locked FROM ( - VALUES ${Prisma.join(ordered.map((key) => Prisma.sql`(${key})`))} + VALUES ${sql.join( + ordered.map((key) => sql`(${key})`), + sql`, `, + )} ) AS requested(key) ORDER BY requested.key - `; + `); } diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 2dcfa2192..fa72c5e17 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,17 +1,94 @@ +export { Decimal } from "decimal.js"; +export { + type AnyColumn, + aliasedTable, + and, + arrayContains, + arrayOverlaps, + asc, + avg, + between, + type Column, + count, + countDistinct, + desc, + eq, + exists, + getTableColumns, + getTableName, + gt, + gte, + ilike, + inArray, + isNotNull, + isNull, + like, + lt, + lte, + max, + min, + ne, + not, + notExists, + notIlike, + notInArray, + or, + QueryPromise, + type SQL, + type SQLWrapper, + sql, + sum, +} from "drizzle-orm"; +export { + alias, + type PgColumn, + PgDialect, + type PgTable, +} from "drizzle-orm/pg-core"; export { type Db, + type DbLogLevel, + type DbLogRecord, + type DbLogSink, db, - type PrismaLogRecord, - type PrismaLogSink, - setPrismaLogSink, + type Executor, + setDbLogSink, + type Tx, } from "./client"; -export { Prisma, PrismaClient } from "./generated/prisma/client"; -export * from "./generated/prisma/enums"; -export type * from "./generated/prisma/models"; +export { + isForeignKeyViolation, + isUniqueViolation, + pgErrorCode, +} from "./errors"; export type { ContactBriefSections, FactEvidence, + InputJsonObject, + InputJsonValue, JsonObject, JsonValue, WorkspaceProfileSections, } from "./json"; +export { + hasValues, + type RelatedMatch, + relatedCount, + whereEq, +} from "./related"; +export { + firstRow, + orNull, + orThrow, + queryRows, + requireRow, +} from "./rows"; +export * from "./schema/enums"; +export type * from "./schema/models"; +export * from "./schema/schema"; +export { + contains, + endsWith, + equalsInsensitive, + escapeLike, + startsWith, +} from "./text-filters"; diff --git a/packages/db/src/json.ts b/packages/db/src/json.ts index 9c1664c62..779e7af57 100644 --- a/packages/db/src/json.ts +++ b/packages/db/src/json.ts @@ -1,8 +1,12 @@ -import type { Prisma } from "./generated/prisma/client"; - -export type JsonValue = Prisma.JsonValue; - -export type JsonObject = Prisma.JsonObject; +import type { JsonObject, JsonValue } from "./schema/json"; + +export type { + InputJsonObject, + InputJsonValue, + JsonArray, + JsonObject, + JsonValue, +} from "./schema/json"; export function jsonObject(value: JsonValue | undefined): JsonObject { return isJsonObject(value) ? value : {}; diff --git a/packages/db/src/related.ts b/packages/db/src/related.ts new file mode 100644 index 000000000..d89fedd6f --- /dev/null +++ b/packages/db/src/related.ts @@ -0,0 +1,43 @@ +import { + eq, + getTableName, + isNull, + type SQL, + type SQLWrapper, + sql, +} from "drizzle-orm"; +import type { PgColumn, PgTable } from "drizzle-orm/pg-core"; + +export type RelatedMatch = { + column: PgColumn; + value: string | number | boolean; +}; + +export function relatedCount( + table: PgTable, + foreignKey: PgColumn, + parent: SQLWrapper, + matches: RelatedMatch[] = [], +): SQL { + const related = sql.identifier("related"); + const filters = matches.map( + (match) => + sql` and ${related}.${sql.identifier(match.column.name)} = ${match.value}`, + ); + return sql`(select count(*)::int from ${sql.identifier(getTableName(table))} as ${related} where ${related}.${sql.identifier(foreignKey.name)} = ${parent}${sql.join(filters)})`.mapWith( + Number, + ); +} + +export function hasValues(set: Partial>): boolean { + return Object.values(set).some((value) => value !== undefined); +} + +export function whereEq( + column: Column, + value: Column["_"]["data"] | null | undefined, +): SQL | undefined { + if (value === undefined) return undefined; + if (value === null) return isNull(column); + return eq(column, value); +} diff --git a/packages/db/src/rows.ts b/packages/db/src/rows.ts new file mode 100644 index 000000000..6b74d73ca --- /dev/null +++ b/packages/db/src/rows.ts @@ -0,0 +1,58 @@ +import type { SQL } from "drizzle-orm"; +import type { QueryResult } from "pg"; + +export async function firstRow( + rows: PromiseLike, +): Promise { + const [row] = await rows; + return row ?? null; +} + +export async function requireRow( + rows: PromiseLike, + message = "The query returned no row.", +): Promise { + const [row] = await rows; + if (row === undefined) throw new Error(message); + return row; +} + +export function orNull(row: Row | undefined): Row | null { + return row ?? null; +} + +export function orThrow(row: Row | undefined): Row { + if (row === undefined) throw new Error("No record matched the query."); + return row; +} + +const TIMESTAMP = 1114; +const TIMESTAMPTZ = 1184; + +function stamp(value: string, typeId: number): Date { + const iso = value.replace(" ", "T"); + if (typeId === TIMESTAMP) return new Date(`${iso}Z`); + return new Date(/[+-]\d\d$/.test(iso) ? `${iso}:00` : iso); +} + +export async function queryRows( + executor: { execute: (query: SQL) => PromiseLike }, + query: SQL, +): Promise { + const result = await executor.execute(query); + const stamps = result.fields.filter( + (field) => + field.dataTypeID === TIMESTAMP || field.dataTypeID === TIMESTAMPTZ, + ); + if (stamps.length === 0) return result.rows as Row[]; + return result.rows.map((row) => { + const parsed = { ...row }; + for (const field of stamps) { + const value = parsed[field.name]; + if (value !== null && value !== undefined) { + parsed[field.name] = stamp(String(value), field.dataTypeID); + } + } + return parsed; + }); +} diff --git a/packages/db/src/schema/columns.ts b/packages/db/src/schema/columns.ts new file mode 100644 index 000000000..848566773 --- /dev/null +++ b/packages/db/src/schema/columns.ts @@ -0,0 +1,37 @@ +import { Decimal } from "decimal.js"; +import { customType } from "drizzle-orm/pg-core"; + +export const decimal = customType<{ + data: Decimal; + driverData: string | number; + config: { precision: number; scale: number }; +}>({ + dataType(config) { + return config ? `numeric(${config.precision}, ${config.scale})` : "numeric"; + }, + fromDriver(value) { + return new Decimal(value); + }, + toDriver(value) { + return new Decimal(value).toString(); + }, +}); + +export const bytea = customType<{ + data: Uint8Array; + driverData: Buffer | string; +}>({ + dataType() { + return "bytea"; + }, + fromDriver(value) { + if (Buffer.isBuffer(value)) return new Uint8Array(value); + const hex = String(value); + return new Uint8Array( + Buffer.from(hex.startsWith("\\x") ? hex.slice(2) : hex, "hex"), + ); + }, + toDriver(value) { + return Buffer.from(value); + }, +}); diff --git a/packages/db/src/schema/enums.ts b/packages/db/src/schema/enums.ts new file mode 100644 index 000000000..eb580d508 --- /dev/null +++ b/packages/db/src/schema/enums.ts @@ -0,0 +1,258 @@ +export const McpConnectionStatus = { + CONNECTING: "CONNECTING", + CONNECTED: "CONNECTED", + ERROR: "ERROR", + DISCONNECTED: "DISCONNECTED", +} as const; + +export type McpConnectionStatus = + (typeof McpConnectionStatus)[keyof typeof McpConnectionStatus]; + +export const McpAccessMode = { + READ_ONLY: "READ_ONLY", + READ_WRITE: "READ_WRITE", +} as const; + +export type McpAccessMode = (typeof McpAccessMode)[keyof typeof McpAccessMode]; + +export const McpToolReviewState = { + PENDING: "PENDING", + REVIEWED: "REVIEWED", + CHANGED: "CHANGED", + REMOVED: "REMOVED", +} as const; + +export type McpToolReviewState = + (typeof McpToolReviewState)[keyof typeof McpToolReviewState]; + +export const DealStage = { + DEMO_BOOKED: "DEMO_BOOKED", + QUALIFIED_TO_BUY: "QUALIFIED_TO_BUY", + UNQUALIFIED_TO_BUY: "UNQUALIFIED_TO_BUY", + DECISION_MAKER_BOUGHT_IN: "DECISION_MAKER_BOUGHT_IN", + CONTRACT_SENT: "CONTRACT_SENT", + CLOSED_WON: "CLOSED_WON", + CLOSED_LOST: "CLOSED_LOST", +} as const; + +export type DealStage = (typeof DealStage)[keyof typeof DealStage]; + +export const ActivityType = { + NOTE: "NOTE", + CALL: "CALL", + EMAIL: "EMAIL", + MEETING: "MEETING", + TASK: "TASK", + STAGE_CHANGE: "STAGE_CHANGE", + ENRICHMENT: "ENRICHMENT", +} as const; + +export type ActivityType = (typeof ActivityType)[keyof typeof ActivityType]; + +export const EnrichmentStatus = { + PENDING: "PENDING", + RUNNING: "RUNNING", + COMPLETE: "COMPLETE", + FAILED: "FAILED", + SKIPPED: "SKIPPED", +} as const; + +export type EnrichmentStatus = + (typeof EnrichmentStatus)[keyof typeof EnrichmentStatus]; + +export const RecordSource = { + MANUAL: "MANUAL", + IMPORT: "IMPORT", + EMAIL: "EMAIL", + CALENDAR: "CALENDAR", + TRACKING: "TRACKING", +} as const; + +export type RecordSource = (typeof RecordSource)[keyof typeof RecordSource]; + +export const AgentConversationKind = { + RECORD: "RECORD", + BUILDER: "BUILDER", +} as const; + +export type AgentConversationKind = + (typeof AgentConversationKind)[keyof typeof AgentConversationKind]; + +export const AgentDefinitionStatus = { + DRAFT: "DRAFT", + DEPLOYING: "DEPLOYING", + LIVE: "LIVE", + PAUSED: "PAUSED", + ARCHIVED: "ARCHIVED", + DELETED: "DELETED", +} as const; + +export type AgentDefinitionStatus = + (typeof AgentDefinitionStatus)[keyof typeof AgentDefinitionStatus]; + +export const AgentVersionStatus = { + DRAFT: "DRAFT", + VALIDATING: "VALIDATING", + READY: "READY", + DEPLOYED: "DEPLOYED", + REJECTED: "REJECTED", +} as const; + +export type AgentVersionStatus = + (typeof AgentVersionStatus)[keyof typeof AgentVersionStatus]; + +export const AgentBuilderArtifactStatus = { + WRITING: "WRITING", + READY: "READY", +} as const; + +export type AgentBuilderArtifactStatus = + (typeof AgentBuilderArtifactStatus)[keyof typeof AgentBuilderArtifactStatus]; + +export const AgentTriggerType = { + MANUAL: "MANUAL", + SCHEDULE: "SCHEDULE", + EVENT: "EVENT", + WEBHOOK: "WEBHOOK", +} as const; + +export type AgentTriggerType = + (typeof AgentTriggerType)[keyof typeof AgentTriggerType]; + +export const AgentRunStatus = { + QUEUED: "QUEUED", + RUNNING: "RUNNING", + WAITING_FOR_APPROVAL: "WAITING_FOR_APPROVAL", + SUCCEEDED: "SUCCEEDED", + FAILED: "FAILED", + CANCELLED: "CANCELLED", +} as const; + +export type AgentRunStatus = + (typeof AgentRunStatus)[keyof typeof AgentRunStatus]; + +export const AgentActionStatus = { + PLANNED: "PLANNED", + RUNNING: "RUNNING", + SUCCEEDED: "SUCCEEDED", + FAILED: "FAILED", + CANCELLED: "CANCELLED", +} as const; + +export type AgentActionStatus = + (typeof AgentActionStatus)[keyof typeof AgentActionStatus]; + +export const AgentConversationShareScope = { + WORKSPACE_LINK: "WORKSPACE_LINK", +} as const; + +export type AgentConversationShareScope = + (typeof AgentConversationShareScope)[keyof typeof AgentConversationShareScope]; + +export const AgentConversationSubmissionStatus = { + PENDING: "PENDING", + SENDING: "SENDING", + ACCEPTED: "ACCEPTED", + FAILED: "FAILED", + CANCELLED: "CANCELLED", +} as const; + +export type AgentConversationSubmissionStatus = + (typeof AgentConversationSubmissionStatus)[keyof typeof AgentConversationSubmissionStatus]; + +export const AgentConversationCommandType = { + CHAT: "CHAT", + CREATE_AGENT: "CREATE_AGENT", +} as const; + +export type AgentConversationCommandType = + (typeof AgentConversationCommandType)[keyof typeof AgentConversationCommandType]; + +export const AgentConversationPermissionMode = { + MANUAL: "MANUAL", + AUTO: "AUTO", + BYPASS: "BYPASS", +} as const; + +export type AgentConversationPermissionMode = + (typeof AgentConversationPermissionMode)[keyof typeof AgentConversationPermissionMode]; + +export const AgentResponseRating = { + UP: "UP", + DOWN: "DOWN", +} as const; + +export type AgentResponseRating = + (typeof AgentResponseRating)[keyof typeof AgentResponseRating]; + +export const FactBand = { + VERIFIED: "VERIFIED", + PROBABLE: "PROBABLE", + POSSIBLE: "POSSIBLE", +} as const; + +export type FactBand = (typeof FactBand)[keyof typeof FactBand]; + +export const FactStatus = { + APPLIED: "APPLIED", + PROPOSED: "PROPOSED", + DISMISSED: "DISMISSED", + SUPERSEDED: "SUPERSEDED", +} as const; + +export type FactStatus = (typeof FactStatus)[keyof typeof FactStatus]; + +export const RateSource = { + FETCHED: "FETCHED", + MANUAL: "MANUAL", +} as const; + +export type RateSource = (typeof RateSource)[keyof typeof RateSource]; + +export const FieldEntity = { + COMPANY: "COMPANY", + CONTACT: "CONTACT", + DEAL: "DEAL", +} as const; + +export type FieldEntity = (typeof FieldEntity)[keyof typeof FieldEntity]; + +export const FieldType = { + TEXT: "TEXT", + LONG_TEXT: "LONG_TEXT", + NUMBER: "NUMBER", + DATE: "DATE", + CHECKBOX: "CHECKBOX", + SELECT: "SELECT", + URL: "URL", + EMAIL: "EMAIL", + PHONE: "PHONE", + USER: "USER", +} as const; + +export type FieldType = (typeof FieldType)[keyof typeof FieldType]; + +export const GoogleSyncStatus = { + IDLE: "IDLE", + RUNNING: "RUNNING", + NEEDS_RECONNECT: "NEEDS_RECONNECT", + FAILED: "FAILED", +} as const; + +export type GoogleSyncStatus = + (typeof GoogleSyncStatus)[keyof typeof GoogleSyncStatus]; + +export const EmailDirection = { + INBOUND: "INBOUND", + OUTBOUND: "OUTBOUND", +} as const; + +export type EmailDirection = + (typeof EmailDirection)[keyof typeof EmailDirection]; + +export const DomainScope = { + SITE_AND_SUBDOMAINS: "SITE_AND_SUBDOMAINS", + EXACT_HOST: "EXACT_HOST", +} as const; + +export type DomainScope = (typeof DomainScope)[keyof typeof DomainScope]; diff --git a/packages/db/src/schema/json.ts b/packages/db/src/schema/json.ts new file mode 100644 index 000000000..8277f3d97 --- /dev/null +++ b/packages/db/src/schema/json.ts @@ -0,0 +1,22 @@ +export type JsonObject = { [key in string]?: JsonValue }; + +export type JsonArray = JsonValue[]; + +export type JsonValue = + | string + | number + | boolean + | JsonObject + | JsonArray + | null; + +export type InputJsonObject = { readonly [key: string]: InputJsonValue | null }; + +export type InputJsonArray = ReadonlyArray; + +export type InputJsonValue = + | string + | number + | boolean + | InputJsonObject + | InputJsonArray; diff --git a/packages/db/src/schema/models.ts b/packages/db/src/schema/models.ts new file mode 100644 index 000000000..d400e7e06 --- /dev/null +++ b/packages/db/src/schema/models.ts @@ -0,0 +1,145 @@ +import type * as schema from "./schema"; + +export type User = typeof schema.user.$inferSelect; +export type NewUser = typeof schema.user.$inferInsert; +export type SlackMemberMatch = typeof schema.slackMemberMatch.$inferSelect; +export type NewSlackMemberMatch = typeof schema.slackMemberMatch.$inferInsert; +export type SlackChannel = typeof schema.slackChannel.$inferSelect; +export type NewSlackChannel = typeof schema.slackChannel.$inferInsert; +export type SlackInstallation = typeof schema.slackInstallation.$inferSelect; +export type NewSlackInstallation = typeof schema.slackInstallation.$inferInsert; +export type SlackWorkspaceGrant = + typeof schema.slackWorkspaceGrant.$inferSelect; +export type NewSlackWorkspaceGrant = + typeof schema.slackWorkspaceGrant.$inferInsert; +export type Session = typeof schema.session.$inferSelect; +export type NewSession = typeof schema.session.$inferInsert; +export type Account = typeof schema.account.$inferSelect; +export type NewAccount = typeof schema.account.$inferInsert; +export type McpConnection = typeof schema.mcpConnection.$inferSelect; +export type NewMcpConnection = typeof schema.mcpConnection.$inferInsert; +export type McpClientRegistration = + typeof schema.mcpClientRegistration.$inferSelect; +export type NewMcpClientRegistration = + typeof schema.mcpClientRegistration.$inferInsert; +export type McpToolSnapshot = typeof schema.mcpToolSnapshot.$inferSelect; +export type NewMcpToolSnapshot = typeof schema.mcpToolSnapshot.$inferInsert; +export type McpOauthState = typeof schema.mcpOauthState.$inferSelect; +export type NewMcpOauthState = typeof schema.mcpOauthState.$inferInsert; +export type Verification = typeof schema.verification.$inferSelect; +export type NewVerification = typeof schema.verification.$inferInsert; +export type RateLimit = typeof schema.rateLimit.$inferSelect; +export type NewRateLimit = typeof schema.rateLimit.$inferInsert; +export type Company = typeof schema.company.$inferSelect; +export type NewCompany = typeof schema.company.$inferInsert; +export type CompanyEnrichment = typeof schema.companyEnrichment.$inferSelect; +export type NewCompanyEnrichment = typeof schema.companyEnrichment.$inferInsert; +export type Contact = typeof schema.contact.$inferSelect; +export type NewContact = typeof schema.contact.$inferInsert; +export type ContactFact = typeof schema.contactFact.$inferSelect; +export type NewContactFact = typeof schema.contactFact.$inferInsert; +export type ContactBrief = typeof schema.contactBrief.$inferSelect; +export type NewContactBrief = typeof schema.contactBrief.$inferInsert; +export type AgentTask = typeof schema.agentTask.$inferSelect; +export type NewAgentTask = typeof schema.agentTask.$inferInsert; +export type AgentEvent = typeof schema.agentEvent.$inferSelect; +export type NewAgentEvent = typeof schema.agentEvent.$inferInsert; +export type AgentConversation = typeof schema.agentConversation.$inferSelect; +export type NewAgentConversation = typeof schema.agentConversation.$inferInsert; +export type AgentConversationFeedback = + typeof schema.agentConversationFeedback.$inferSelect; +export type NewAgentConversationFeedback = + typeof schema.agentConversationFeedback.$inferInsert; +export type AgentConversationShare = + typeof schema.agentConversationShare.$inferSelect; +export type NewAgentConversationShare = + typeof schema.agentConversationShare.$inferInsert; +export type AgentConversationSubmission = + typeof schema.agentConversationSubmission.$inferSelect; +export type NewAgentConversationSubmission = + typeof schema.agentConversationSubmission.$inferInsert; +export type AgentConversationAttachment = + typeof schema.agentConversationAttachment.$inferSelect; +export type NewAgentConversationAttachment = + typeof schema.agentConversationAttachment.$inferInsert; +export type AgentDefinition = typeof schema.agentDefinition.$inferSelect; +export type NewAgentDefinition = typeof schema.agentDefinition.$inferInsert; +export type AgentVersion = typeof schema.agentVersion.$inferSelect; +export type NewAgentVersion = typeof schema.agentVersion.$inferInsert; +export type AgentBuilderArtifact = + typeof schema.agentBuilderArtifact.$inferSelect; +export type NewAgentBuilderArtifact = + typeof schema.agentBuilderArtifact.$inferInsert; +export type AgentTrigger = typeof schema.agentTrigger.$inferSelect; +export type NewAgentTrigger = typeof schema.agentTrigger.$inferInsert; +export type AgentRun = typeof schema.agentRun.$inferSelect; +export type NewAgentRun = typeof schema.agentRun.$inferInsert; +export type AgentRunEvent = typeof schema.agentRunEvent.$inferSelect; +export type NewAgentRunEvent = typeof schema.agentRunEvent.$inferInsert; +export type AgentAction = typeof schema.agentAction.$inferSelect; +export type NewAgentAction = typeof schema.agentAction.$inferInsert; +export type AgentAuditEvent = typeof schema.agentAuditEvent.$inferSelect; +export type NewAgentAuditEvent = typeof schema.agentAuditEvent.$inferInsert; +export type Deal = typeof schema.deal.$inferSelect; +export type NewDeal = typeof schema.deal.$inferInsert; +export type ExchangeRate = typeof schema.exchangeRate.$inferSelect; +export type NewExchangeRate = typeof schema.exchangeRate.$inferInsert; +export type DealContact = typeof schema.dealContact.$inferSelect; +export type NewDealContact = typeof schema.dealContact.$inferInsert; +export type FieldDefinition = typeof schema.fieldDefinition.$inferSelect; +export type NewFieldDefinition = typeof schema.fieldDefinition.$inferInsert; +export type FieldOption = typeof schema.fieldOption.$inferSelect; +export type NewFieldOption = typeof schema.fieldOption.$inferInsert; +export type FieldValue = typeof schema.fieldValue.$inferSelect; +export type NewFieldValue = typeof schema.fieldValue.$inferInsert; +export type SavedView = typeof schema.savedView.$inferSelect; +export type NewSavedView = typeof schema.savedView.$inferInsert; +export type Activity = typeof schema.activity.$inferSelect; +export type NewActivity = typeof schema.activity.$inferInsert; +export type MailboxSync = typeof schema.mailboxSync.$inferSelect; +export type NewMailboxSync = typeof schema.mailboxSync.$inferInsert; +export type EmailThread = typeof schema.emailThread.$inferSelect; +export type NewEmailThread = typeof schema.emailThread.$inferInsert; +export type EmailMessage = typeof schema.emailMessage.$inferSelect; +export type NewEmailMessage = typeof schema.emailMessage.$inferInsert; +export type CalendarEvent = typeof schema.calendarEvent.$inferSelect; +export type NewCalendarEvent = typeof schema.calendarEvent.$inferInsert; +export type CalendarAttendee = typeof schema.calendarAttendee.$inferSelect; +export type NewCalendarAttendee = typeof schema.calendarAttendee.$inferInsert; +export type SuppressedDomain = typeof schema.suppressedDomain.$inferSelect; +export type NewSuppressedDomain = typeof schema.suppressedDomain.$inferInsert; +export type SuppressedContact = typeof schema.suppressedContact.$inferSelect; +export type NewSuppressedContact = typeof schema.suppressedContact.$inferInsert; +export type AppSetting = typeof schema.appSetting.$inferSelect; +export type NewAppSetting = typeof schema.appSetting.$inferInsert; +export type TrackedDomain = typeof schema.trackedDomain.$inferSelect; +export type NewTrackedDomain = typeof schema.trackedDomain.$inferInsert; +export type TrackedVisitor = typeof schema.trackedVisitor.$inferSelect; +export type NewTrackedVisitor = typeof schema.trackedVisitor.$inferInsert; +export type TrackedEvent = typeof schema.trackedEvent.$inferSelect; +export type NewTrackedEvent = typeof schema.trackedEvent.$inferInsert; +export type TrackingCounter = typeof schema.trackingCounter.$inferSelect; +export type NewTrackingCounter = typeof schema.trackingCounter.$inferInsert; +export type TrackedPageDaily = typeof schema.trackedPageDaily.$inferSelect; +export type NewTrackedPageDaily = typeof schema.trackedPageDaily.$inferInsert; +export type FormSubmission = typeof schema.formSubmission.$inferSelect; +export type NewFormSubmission = typeof schema.formSubmission.$inferInsert; +export type Install = typeof schema.install.$inferSelect; +export type NewInstall = typeof schema.install.$inferInsert; +export type TelemetryMilestone = typeof schema.telemetryMilestone.$inferSelect; +export type NewTelemetryMilestone = + typeof schema.telemetryMilestone.$inferInsert; +export type TelemetryCounter = typeof schema.telemetryCounter.$inferSelect; +export type NewTelemetryCounter = typeof schema.telemetryCounter.$inferInsert; +export type Organization = typeof schema.organization.$inferSelect; +export type NewOrganization = typeof schema.organization.$inferInsert; +export type WorkspaceProfile = typeof schema.workspaceProfile.$inferSelect; +export type NewWorkspaceProfile = typeof schema.workspaceProfile.$inferInsert; +export type Member = typeof schema.member.$inferSelect; +export type NewMember = typeof schema.member.$inferInsert; +export type Invitation = typeof schema.invitation.$inferSelect; +export type NewInvitation = typeof schema.invitation.$inferInsert; +export type SsoProvider = typeof schema.ssoProvider.$inferSelect; +export type NewSsoProvider = typeof schema.ssoProvider.$inferInsert; +export type Apikey = typeof schema.apikey.$inferSelect; +export type NewApikey = typeof schema.apikey.$inferInsert; diff --git a/packages/db/src/schema/schema.ts b/packages/db/src/schema/schema.ts new file mode 100644 index 000000000..2fa2996cf --- /dev/null +++ b/packages/db/src/schema/schema.ts @@ -0,0 +1,3160 @@ +import { createId } from "@paralleldrive/cuid2"; +import { relations, sql } from "drizzle-orm"; +import { + bigint, + boolean, + check, + doublePrecision, + foreignKey, + index, + integer, + jsonb, + type PgTableExtraConfigValue, + pgEnum, + pgTable, + primaryKey, + text, + timestamp, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import { bytea, decimal } from "./columns"; +import * as enums from "./enums"; +import type { JsonValue } from "./json"; + +export const mcpConnectionStatusEnum = pgEnum( + "McpConnectionStatus", + enums.McpConnectionStatus, +); +export const mcpAccessModeEnum = pgEnum("McpAccessMode", enums.McpAccessMode); +export const mcpToolReviewStateEnum = pgEnum( + "McpToolReviewState", + enums.McpToolReviewState, +); +export const dealStageEnum = pgEnum("DealStage", enums.DealStage); +export const activityTypeEnum = pgEnum("ActivityType", enums.ActivityType); +export const enrichmentStatusEnum = pgEnum( + "EnrichmentStatus", + enums.EnrichmentStatus, +); +export const recordSourceEnum = pgEnum("RecordSource", enums.RecordSource); +export const agentConversationKindEnum = pgEnum( + "AgentConversationKind", + enums.AgentConversationKind, +); +export const agentDefinitionStatusEnum = pgEnum( + "AgentDefinitionStatus", + enums.AgentDefinitionStatus, +); +export const agentVersionStatusEnum = pgEnum( + "AgentVersionStatus", + enums.AgentVersionStatus, +); +export const agentBuilderArtifactStatusEnum = pgEnum( + "AgentBuilderArtifactStatus", + enums.AgentBuilderArtifactStatus, +); +export const agentTriggerTypeEnum = pgEnum( + "AgentTriggerType", + enums.AgentTriggerType, +); +export const agentRunStatusEnum = pgEnum( + "AgentRunStatus", + enums.AgentRunStatus, +); +export const agentActionStatusEnum = pgEnum( + "AgentActionStatus", + enums.AgentActionStatus, +); +export const agentConversationShareScopeEnum = pgEnum( + "AgentConversationShareScope", + enums.AgentConversationShareScope, +); +export const agentConversationSubmissionStatusEnum = pgEnum( + "AgentConversationSubmissionStatus", + enums.AgentConversationSubmissionStatus, +); +export const agentConversationCommandTypeEnum = pgEnum( + "AgentConversationCommandType", + enums.AgentConversationCommandType, +); +export const agentConversationPermissionModeEnum = pgEnum( + "AgentConversationPermissionMode", + enums.AgentConversationPermissionMode, +); +export const agentResponseRatingEnum = pgEnum( + "AgentResponseRating", + enums.AgentResponseRating, +); +export const factBandEnum = pgEnum("FactBand", enums.FactBand); +export const factStatusEnum = pgEnum("FactStatus", enums.FactStatus); +export const rateSourceEnum = pgEnum("RateSource", enums.RateSource); +export const fieldEntityEnum = pgEnum("FieldEntity", enums.FieldEntity); +export const fieldTypeEnum = pgEnum("FieldType", enums.FieldType); +export const googleSyncStatusEnum = pgEnum( + "GoogleSyncStatus", + enums.GoogleSyncStatus, +); +export const emailDirectionEnum = pgEnum( + "EmailDirection", + enums.EmailDirection, +); +export const domainScopeEnum = pgEnum("DomainScope", enums.DomainScope); + +export const user = pgTable( + "user", + { + id: text().primaryKey(), + name: text().notNull(), + email: text().notNull(), + emailVerified: boolean().notNull().default(false), + image: text(), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + updatedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("user_email_key").on(table.email), + ], +); + +export const slackMemberMatch = pgTable( + "slackMemberMatch", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + crmUserId: text().notNull(), + slackUserId: text(), + slackHandle: text(), + slackEmail: text(), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + updatedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("slackMemberMatch_crmUserId_key").on(table.crmUserId), + index("slackMemberMatch_slackUserId_idx").on(table.slackUserId), + foreignKey({ + name: "slackMemberMatch_crmUserId_fkey", + columns: [table.crmUserId], + foreignColumns: [user.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + ], +); + +export const slackChannel = pgTable( + "slackChannel", + { + id: text().primaryKey(), + name: text().notNull(), + memberCount: integer(), + available: boolean().notNull().default(true), + isPrivate: boolean().notNull().default(false), + isMember: boolean().notNull().default(false), + inviteRequestedAt: timestamp({ precision: 3, mode: "date" }), + classifiedAt: timestamp({ precision: 3, mode: "date" }), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + updatedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + index("slackChannel_available_name_idx").on(table.available, table.name), + index("slackChannel_updatedAt_idx").on(table.updatedAt), + ], +); + +export const slackInstallation = pgTable("slackInstallation", { + installerId: text().primaryKey(), + teamId: text().notNull(), + teamName: text(), + userToken: text(), + userScopes: text().notNull(), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), +}); + +export const slackWorkspaceGrant = pgTable( + "slackWorkspaceGrant", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + teamId: text().notNull(), + teamName: text(), + userToken: text().notNull(), + userScopes: text().notNull(), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + updatedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("slackWorkspaceGrant_teamId_key").on(table.teamId), + ], +); + +export const session = pgTable( + "session", + { + id: text().primaryKey(), + expiresAt: timestamp({ precision: 3, mode: "date" }).notNull(), + token: text().notNull(), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + updatedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), + ipAddress: text(), + userAgent: text(), + userId: text().notNull(), + activeOrganizationId: text(), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("session_token_key").on(table.token), + index("session_userId_idx").on(table.userId), + foreignKey({ + name: "session_userId_fkey", + columns: [table.userId], + foreignColumns: [user.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + ], +); + +export const account = pgTable( + "account", + { + id: text().primaryKey(), + accountId: text().notNull(), + providerId: text().notNull(), + userId: text().notNull(), + accessToken: text(), + refreshToken: text(), + idToken: text(), + accessTokenExpiresAt: timestamp({ precision: 3, mode: "date" }), + refreshTokenExpiresAt: timestamp({ precision: 3, mode: "date" }), + scope: text(), + password: text(), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + updatedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + index("account_userId_idx").on(table.userId), + foreignKey({ + name: "account_userId_fkey", + columns: [table.userId], + foreignColumns: [user.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + ], +); + +export const mcpConnection = pgTable( + "mcpConnection", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + organizationId: text().notNull(), + platformSlug: text().notNull(), + status: mcpConnectionStatusEnum().notNull().default("CONNECTING"), + accessMode: mcpAccessModeEnum().notNull(), + grantedScopes: text().array(), + encryptedAccessToken: text(), + encryptedRefreshToken: text(), + tokenExpiresAt: timestamp({ precision: 3, mode: "date" }), + registrationId: text().notNull(), + connectedById: text().notNull(), + connectedAt: timestamp({ precision: 3, mode: "date" }), + lastSuccessfulCallAt: timestamp({ precision: 3, mode: "date" }), + lastErrorCode: text(), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + updatedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("mcpConnection_organizationId_platformSlug_key").on( + table.organizationId, + table.platformSlug, + ), + index("mcpConnection_status_updatedAt_idx").on( + table.status, + table.updatedAt, + ), + foreignKey({ + name: "mcpConnection_organizationId_fkey", + columns: [table.organizationId], + foreignColumns: [organization.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + foreignKey({ + name: "mcpConnection_registrationId_fkey", + columns: [table.registrationId], + foreignColumns: [mcpClientRegistration.id], + }) + .onDelete("restrict") + .onUpdate("cascade"), + foreignKey({ + name: "mcpConnection_connectedById_fkey", + columns: [table.connectedById], + foreignColumns: [user.id], + }) + .onDelete("restrict") + .onUpdate("cascade"), + ], +); + +export const mcpClientRegistration = pgTable( + "mcpClientRegistration", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + platformSlug: text().notNull(), + metadataFingerprint: text().notNull(), + issuer: text().notNull(), + clientId: text().notNull(), + encryptedClientSecret: text(), + encryptedRegistrationAccessToken: text(), + clientIdIssuedAt: timestamp({ precision: 3, mode: "date" }), + clientSecretExpiresAt: timestamp({ precision: 3, mode: "date" }), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + updatedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex( + "mcpClientRegistration_platformSlug_metadataFingerprint_key", + ).on(table.platformSlug, table.metadataFingerprint), + index("mcpClientRegistration_clientSecretExpiresAt_idx").on( + table.clientSecretExpiresAt, + ), + ], +); + +export const mcpToolSnapshot = pgTable( + "mcpToolSnapshot", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + connectionId: text().notNull(), + name: text().notNull(), + description: text(), + inputSchema: jsonb().$type().notNull(), + annotations: jsonb().$type(), + fingerprint: text().notNull(), + callKind: text().notNull(), + reviewState: mcpToolReviewStateEnum().notNull().default("PENDING"), + lastSeenAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("mcpToolSnapshot_connectionId_name_fingerprint_key").on( + table.connectionId, + table.name, + table.fingerprint, + ), + index("mcpToolSnapshot_connectionId_name_lastSeenAt_idx").on( + table.connectionId, + table.name, + table.lastSeenAt, + ), + index("mcpToolSnapshot_reviewState_lastSeenAt_idx").on( + table.reviewState, + table.lastSeenAt, + ), + foreignKey({ + name: "mcpToolSnapshot_connectionId_fkey", + columns: [table.connectionId], + foreignColumns: [mcpConnection.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + ], +); + +export const mcpOauthState = pgTable( + "mcpOauthState", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + stateHash: text().notNull(), + organizationId: text().notNull(), + userId: text().notNull(), + platformSlug: text().notNull(), + accessMode: mcpAccessModeEnum().notNull(), + returnPath: text().notNull(), + encryptedCodeVerifier: text().notNull(), + registrationId: text().notNull(), + expiresAt: timestamp({ precision: 3, mode: "date" }).notNull(), + consumedAt: timestamp({ precision: 3, mode: "date" }), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("mcpOauthState_stateHash_key").on(table.stateHash), + index("mcpOauthState_expiresAt_consumedAt_idx").on( + table.expiresAt, + table.consumedAt, + ), + foreignKey({ + name: "mcpOauthState_organizationId_fkey", + columns: [table.organizationId], + foreignColumns: [organization.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + foreignKey({ + name: "mcpOauthState_userId_fkey", + columns: [table.userId], + foreignColumns: [user.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + foreignKey({ + name: "mcpOauthState_registrationId_fkey", + columns: [table.registrationId], + foreignColumns: [mcpClientRegistration.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + ], +); + +export const verification = pgTable( + "verification", + { + id: text().primaryKey(), + identifier: text().notNull(), + value: text().notNull(), + expiresAt: timestamp({ precision: 3, mode: "date" }).notNull(), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + updatedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + index("verification_identifier_idx").on(table.identifier), + ], +); + +export const rateLimit = pgTable( + "rateLimit", + { + id: text().primaryKey(), + key: text().notNull(), + count: integer().notNull(), + lastRequest: bigint({ mode: "number" }).notNull(), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("rateLimit_key_key").on(table.key), + ], +); + +export const company = pgTable( + "company", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + name: text().notNull(), + domain: text(), + website: text(), + description: text(), + logoUrl: text(), + logoDarkUrl: text(), + iconUrl: text(), + iconDarkUrl: text(), + iconTone: text(), + brandColor: text(), + industry: text(), + subIndustry: text(), + city: text(), + stateCode: text(), + country: text(), + countryCode: text(), + phone: text(), + email: text(), + linkedinUrl: text(), + twitterUrl: text(), + githubUrl: text(), + pricingUrl: text(), + careersUrl: text(), + ownerId: text(), + primaryContactId: text(), + enrichmentStatus: enrichmentStatusEnum().notNull().default("PENDING"), + enrichedAt: timestamp({ precision: 3, mode: "date" }), + enrichmentError: text(), + source: recordSourceEnum().notNull().default("MANUAL"), + lastActivityAt: timestamp({ precision: 3, mode: "date" }), + archivedAt: timestamp({ precision: 3, mode: "date" }), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + updatedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("company_primaryContactId_key").on(table.primaryContactId), + uniqueIndex("company_domain_active_key") + .on(table.domain) + .where(sql`"archivedAt" IS NULL`), + index("company_ownerId_idx").on(table.ownerId), + index("company_name_idx").on(table.name), + index("company_lastActivityAt_idx").on(table.lastActivityAt), + index("company_archivedAt_idx").on(table.archivedAt), + foreignKey({ + name: "company_ownerId_fkey", + columns: [table.ownerId], + foreignColumns: [user.id], + }) + .onDelete("set null") + .onUpdate("cascade"), + foreignKey({ + name: "company_primaryContactId_fkey", + columns: [table.primaryContactId], + foreignColumns: [contact.id], + }) + .onDelete("set null") + .onUpdate("cascade"), + ], +); + +export const companyEnrichment = pgTable( + "companyEnrichment", + { + companyId: text().primaryKey(), + source: text().notNull().default("context.dev"), + raw: jsonb().$type().notNull(), + fetchedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + foreignKey({ + name: "companyEnrichment_companyId_fkey", + columns: [table.companyId], + foreignColumns: [company.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + ], +); + +export const contact = pgTable( + "contact", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + firstName: text().notNull(), + lastName: text(), + email: text(), + phone: text(), + title: text(), + seniority: text(), + function: text(), + linkedinUrl: text(), + twitterUrl: text(), + githubUrl: text(), + imageUrl: text(), + socialsCheckedAt: timestamp({ precision: 3, mode: "date" }), + enrichmentStatus: enrichmentStatusEnum().notNull().default("PENDING"), + enrichedAt: timestamp({ precision: 3, mode: "date" }), + enrichmentError: text(), + companyId: text(), + ownerId: text(), + source: recordSourceEnum().notNull().default("MANUAL"), + lastActivityAt: timestamp({ precision: 3, mode: "date" }), + archivedAt: timestamp({ precision: 3, mode: "date" }), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + updatedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("contact_email_active_key") + .on(table.email) + .where(sql`"archivedAt" IS NULL`), + index("contact_companyId_idx").on(table.companyId), + index("contact_ownerId_idx").on(table.ownerId), + index("contact_lastActivityAt_idx").on(table.lastActivityAt), + index("contact_archivedAt_idx").on(table.archivedAt), + foreignKey({ + name: "contact_companyId_fkey", + columns: [table.companyId], + foreignColumns: [company.id], + }) + .onDelete("set null") + .onUpdate("cascade"), + foreignKey({ + name: "contact_ownerId_fkey", + columns: [table.ownerId], + foreignColumns: [user.id], + }) + .onDelete("set null") + .onUpdate("cascade"), + ], +); + +export const contactFact = pgTable( + "contactFact", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + contactId: text().notNull(), + field: text().notNull(), + value: text().notNull(), + score: doublePrecision().notNull(), + band: factBandEnum().notNull(), + evidence: jsonb().$type().notNull(), + method: text().notNull(), + sourceUrl: text(), + sessionId: text(), + status: factStatusEnum().notNull().default("PROPOSED"), + decidedById: text(), + decidedAt: timestamp({ precision: 3, mode: "date" }), + observedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + supersededAt: timestamp({ precision: 3, mode: "date" }), + }, + (table): PgTableExtraConfigValue[] => [ + index("contactFact_contactId_field_status_idx").on( + table.contactId, + table.field, + table.status, + ), + index("contactFact_status_observedAt_idx").on( + table.status, + table.observedAt, + ), + foreignKey({ + name: "contactFact_contactId_fkey", + columns: [table.contactId], + foreignColumns: [contact.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + foreignKey({ + name: "contactFact_decidedById_fkey", + columns: [table.decidedById], + foreignColumns: [user.id], + }) + .onDelete("set null") + .onUpdate("cascade"), + ], +); + +export const contactBrief = pgTable( + "contactBrief", + { + contactId: text().primaryKey(), + narrative: text().notNull(), + sections: jsonb().$type().notNull(), + score: doublePrecision().notNull(), + sourceUrl: text(), + sessionId: text(), + refreshedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + foreignKey({ + name: "contactBrief_contactId_fkey", + columns: [table.contactId], + foreignColumns: [contact.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + ], +); + +export const agentTask = pgTable( + "agentTask", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + contactId: text(), + companyId: text(), + dealId: text(), + kind: text().notNull(), + reason: text().notNull(), + payload: jsonb().$type(), + priority: integer().notNull().default(0), + budget: integer().notNull().default(4), + attempts: integer().notNull().default(0), + dueAt: timestamp({ precision: 3, mode: "date" }).notNull(), + leasedUntil: timestamp({ precision: 3, mode: "date" }), + sessionId: text(), + startedAt: timestamp({ precision: 3, mode: "date" }), + finishedAt: timestamp({ precision: 3, mode: "date" }), + outcome: text(), + subject: text(), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + index("agentTask_dueAt_leasedUntil_idx").on(table.dueAt, table.leasedUntil), + index("agentTask_contactId_idx").on(table.contactId), + index("agentTask_dealId_idx").on(table.dealId), + index("agentTask_kind_subject_idx") + .on(table.kind, table.subject) + .where(sql`"finishedAt" IS NULL`), + ], +); + +export const agentEvent = pgTable( + "agentEvent", + { + id: text().primaryKey(), + sessionId: text().notNull(), + streamIndex: integer(), + deliveryIds: text().array().notNull().default(sql`ARRAY[]::text[]`), + contactId: text(), + conversationId: text(), + type: text().notNull(), + data: jsonb().$type().notNull(), + encryptedData: text(), + emittedAt: timestamp({ precision: 3, mode: "date" }).notNull(), + }, + (table): PgTableExtraConfigValue[] => [ + index("agentEvent_sessionId_emittedAt_idx").on( + table.sessionId, + table.emittedAt, + ), + uniqueIndex("agentEvent_sessionId_streamIndex_key").on( + table.sessionId, + table.streamIndex, + ), + index("agentEvent_contactId_emittedAt_idx").on( + table.contactId, + table.emittedAt, + ), + index("agentEvent_conversationId_emittedAt_idx").on( + table.conversationId, + table.emittedAt, + ), + foreignKey({ + name: "agentEvent_conversationId_fkey", + columns: [table.conversationId], + foreignColumns: [agentConversation.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + ], +); + +export const agentConversation = pgTable( + "agentConversation", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + kind: agentConversationKindEnum().notNull().default("RECORD"), + contactId: text(), + companyId: text(), + dealId: text(), + userId: text().notNull(), + agentId: text(), + sessionId: text(), + continuationToken: text(), + streamIndex: integer().notNull().default(0), + pendingInputRequests: jsonb().$type(), + permissionMode: agentConversationPermissionModeEnum() + .notNull() + .default("MANUAL"), + title: text(), + messageCount: integer().notNull().default(0), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + updatedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), + lastMessageAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + lastAssistantAt: timestamp({ precision: 3, mode: "date" }), + lastReadAt: timestamp({ precision: 3, mode: "date" }), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("agentConversation_sessionId_key").on(table.sessionId), + index("agentConversation_contactId_lastMessageAt_idx").on( + table.contactId, + table.lastMessageAt, + ), + index("agentConversation_companyId_lastMessageAt_idx").on( + table.companyId, + table.lastMessageAt, + ), + index("agentConversation_dealId_lastMessageAt_idx").on( + table.dealId, + table.lastMessageAt, + ), + index("agentConversation_userId_kind_lastMessageAt_idx").on( + table.userId, + table.kind, + table.lastMessageAt, + ), + index("agentConversation_agentId_lastMessageAt_idx").on( + table.agentId, + table.lastMessageAt, + ), + foreignKey({ + name: "agentConversation_contactId_fkey", + columns: [table.contactId], + foreignColumns: [contact.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + foreignKey({ + name: "agentConversation_companyId_fkey", + columns: [table.companyId], + foreignColumns: [company.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + foreignKey({ + name: "agentConversation_dealId_fkey", + columns: [table.dealId], + foreignColumns: [deal.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + foreignKey({ + name: "agentConversation_userId_fkey", + columns: [table.userId], + foreignColumns: [user.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + foreignKey({ + name: "agentConversation_agentId_fkey", + columns: [table.agentId], + foreignColumns: [agentDefinition.id], + }) + .onDelete("set null") + .onUpdate("cascade"), + ], +); + +export const agentConversationFeedback = pgTable( + "agentConversationFeedback", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + conversationId: text().notNull(), + userId: text().notNull(), + messageId: text().notNull(), + rating: agentResponseRatingEnum().notNull(), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + updatedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex( + "agentConversationFeedback_conversationId_userId_messageId_key", + ).on(table.conversationId, table.userId, table.messageId), + index("agentConversationFeedback_conversationId_createdAt_idx").on( + table.conversationId, + table.createdAt, + ), + foreignKey({ + name: "agentConversationFeedback_conversationId_fkey", + columns: [table.conversationId], + foreignColumns: [agentConversation.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + foreignKey({ + name: "agentConversationFeedback_userId_fkey", + columns: [table.userId], + foreignColumns: [user.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + ], +); + +export const agentConversationShare = pgTable( + "agentConversationShare", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + conversationId: text().notNull(), + createdById: text().notNull(), + scope: agentConversationShareScopeEnum() + .notNull() + .default("WORKSPACE_LINK"), + tokenHash: text().notNull(), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + expiresAt: timestamp({ precision: 3, mode: "date" }), + revokedAt: timestamp({ precision: 3, mode: "date" }), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("agentConversationShare_tokenHash_key").on(table.tokenHash), + uniqueIndex("agentConversationShare_one_active_per_conversation") + .on(table.conversationId) + .where(sql`"revokedAt" IS NULL`), + index("agentConversationShare_conversationId_revokedAt_idx").on( + table.conversationId, + table.revokedAt, + ), + foreignKey({ + name: "agentConversationShare_conversationId_fkey", + columns: [table.conversationId], + foreignColumns: [agentConversation.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + foreignKey({ + name: "agentConversationShare_createdById_fkey", + columns: [table.createdById], + foreignColumns: [user.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + ], +); + +export const agentConversationSubmission = pgTable( + "agentConversationSubmission", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + conversationId: text().notNull(), + submittedById: text().notNull(), + clientRequestId: text().notNull(), + inputRequestId: text(), + commandType: agentConversationCommandTypeEnum().notNull().default("CHAT"), + message: jsonb().$type().notNull(), + status: agentConversationSubmissionStatusEnum() + .notNull() + .default("PENDING"), + attemptCount: integer().notNull().default(0), + errorCode: text(), + errorMessage: text(), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + sentAt: timestamp({ precision: 3, mode: "date" }), + acceptedAt: timestamp({ precision: 3, mode: "date" }), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("agentConversationSubmission_clientRequestId_key").on( + table.clientRequestId, + ), + uniqueIndex( + "agentConversationSubmission_conversationId_inputRequestId_key", + ).on(table.conversationId, table.inputRequestId), + index("agentConversationSubmission_conversationId_createdAt_idx").on( + table.conversationId, + table.createdAt, + ), + index("agentConversationSubmission_status_createdAt_idx").on( + table.status, + table.createdAt, + ), + foreignKey({ + name: "agentConversationSubmission_conversationId_fkey", + columns: [table.conversationId], + foreignColumns: [agentConversation.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + foreignKey({ + name: "agentConversationSubmission_submittedById_fkey", + columns: [table.submittedById], + foreignColumns: [user.id], + }) + .onDelete("restrict") + .onUpdate("cascade"), + ], +); + +export const agentConversationAttachment = pgTable( + "agentConversationAttachment", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + submissionId: text().notNull(), + name: text().notNull(), + mediaType: text().notNull(), + size: integer().notNull(), + content: bytea().notNull(), + position: integer().notNull(), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + index("agentConversationAttachment_submissionId_position_idx").on( + table.submissionId, + table.position, + ), + foreignKey({ + name: "agentConversationAttachment_submissionId_fkey", + columns: [table.submissionId], + foreignColumns: [agentConversationSubmission.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + ], +); + +export const agentDefinition = pgTable( + "agentDefinition", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + name: text().notNull(), + description: text(), + status: agentDefinitionStatusEnum().notNull().default("DRAFT"), + createdById: text().notNull(), + currentVersionId: text(), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + updatedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), + archivedAt: timestamp({ precision: 3, mode: "date" }), + deletedAt: timestamp({ precision: 3, mode: "date" }), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("agentDefinition_currentVersionId_key").on( + table.currentVersionId, + ), + uniqueIndex("agentDefinition_currentVersionId_id_key").on( + table.currentVersionId, + table.id, + ), + index("agentDefinition_status_updatedAt_idx").on( + table.status, + table.updatedAt, + ), + index("agentDefinition_createdById_createdAt_idx").on( + table.createdById, + table.createdAt, + ), + foreignKey({ + name: "agentDefinition_createdById_fkey", + columns: [table.createdById], + foreignColumns: [user.id], + }) + .onDelete("restrict") + .onUpdate("cascade"), + foreignKey({ + name: "agentDefinition_currentVersionId_id_fkey", + columns: [table.currentVersionId, table.id], + foreignColumns: [agentVersion.id, agentVersion.agentId], + }) + .onDelete("restrict") + .onUpdate("cascade"), + ], +); + +export const agentVersion = pgTable( + "agentVersion", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + agentId: text().notNull(), + number: integer().notNull(), + status: agentVersionStatusEnum().notNull().default("DRAFT"), + instructions: text().notNull(), + manifest: jsonb().$type().notNull(), + modelId: text().notNull(), + modelContextWindowTokens: integer().notNull().default(1000000), + sandboxPolicy: jsonb().$type().notNull(), + validation: jsonb().$type(), + sourceConversationId: text(), + createdById: text().notNull(), + deploymentId: text(), + approvedAt: timestamp({ precision: 3, mode: "date" }), + deployedAt: timestamp({ precision: 3, mode: "date" }), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("agentVersion_agentId_number_key").on( + table.agentId, + table.number, + ), + uniqueIndex("agentVersion_id_agentId_key").on(table.id, table.agentId), + index("agentVersion_agentId_createdAt_idx").on( + table.agentId, + table.createdAt, + ), + index("agentVersion_status_createdAt_idx").on( + table.status, + table.createdAt, + ), + foreignKey({ + name: "agentVersion_agentId_fkey", + columns: [table.agentId], + foreignColumns: [agentDefinition.id], + }) + .onDelete("restrict") + .onUpdate("cascade"), + foreignKey({ + name: "agentVersion_sourceConversationId_fkey", + columns: [table.sourceConversationId], + foreignColumns: [agentConversation.id], + }) + .onDelete("set null") + .onUpdate("cascade"), + foreignKey({ + name: "agentVersion_createdById_fkey", + columns: [table.createdById], + foreignColumns: [user.id], + }) + .onDelete("restrict") + .onUpdate("cascade"), + ], +); + +export const agentBuilderArtifact = pgTable( + "agentBuilderArtifact", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + conversationId: text(), + versionId: text(), + path: text().notNull(), + language: text().notNull(), + content: text().notNull(), + previousContent: text(), + revision: integer().notNull(), + status: agentBuilderArtifactStatusEnum().notNull().default("WRITING"), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("agentBuilderArtifact_conversation_path_revision_key") + .on(table.conversationId, table.path, table.revision) + .where(sql`"conversationId" IS NOT NULL`), + uniqueIndex("agentBuilderArtifact_version_path_revision_key") + .on(table.versionId, table.path, table.revision) + .where(sql`"versionId" IS NOT NULL`), + index("agentBuilderArtifact_conversationId_createdAt_idx").on( + table.conversationId, + table.createdAt, + ), + index("agentBuilderArtifact_versionId_path_idx").on( + table.versionId, + table.path, + ), + foreignKey({ + name: "agentBuilderArtifact_conversationId_fkey", + columns: [table.conversationId], + foreignColumns: [agentConversation.id], + }) + .onDelete("set null") + .onUpdate("cascade"), + foreignKey({ + name: "agentBuilderArtifact_versionId_fkey", + columns: [table.versionId], + foreignColumns: [agentVersion.id], + }) + .onDelete("set null") + .onUpdate("cascade"), + check( + "agentBuilderArtifact_owner_present", + sql`${table.conversationId} IS NOT NULL OR ${table.versionId} IS NOT NULL`, + ), + ], +); + +export const agentTrigger = pgTable( + "agentTrigger", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + agentId: text().notNull(), + versionId: text().notNull(), + type: agentTriggerTypeEnum().notNull(), + name: text().notNull(), + config: jsonb().$type().notNull(), + createdById: text().notNull(), + enabled: boolean().notNull().default(false), + nextRunAt: timestamp({ precision: 3, mode: "date" }), + lastRunAt: timestamp({ precision: 3, mode: "date" }), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + updatedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("agentTrigger_id_agentId_key").on(table.id, table.agentId), + index("agentTrigger_agentId_enabled_idx").on(table.agentId, table.enabled), + index("agentTrigger_enabled_nextRunAt_idx").on( + table.enabled, + table.nextRunAt, + ), + index("agentTrigger_versionId_idx").on(table.versionId), + foreignKey({ + name: "agentTrigger_agentId_fkey", + columns: [table.agentId], + foreignColumns: [agentDefinition.id], + }) + .onDelete("restrict") + .onUpdate("cascade"), + foreignKey({ + name: "agentTrigger_versionId_agentId_fkey", + columns: [table.versionId, table.agentId], + foreignColumns: [agentVersion.id, agentVersion.agentId], + }) + .onDelete("restrict") + .onUpdate("cascade"), + foreignKey({ + name: "agentTrigger_createdById_fkey", + columns: [table.createdById], + foreignColumns: [user.id], + }) + .onDelete("restrict") + .onUpdate("cascade"), + ], +); + +export const agentRun = pgTable( + "agentRun", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + agentId: text().notNull(), + versionId: text().notNull(), + triggerId: text(), + initiatedById: text(), + triggerType: agentTriggerTypeEnum().notNull(), + status: agentRunStatusEnum().notNull().default("QUEUED"), + principalId: text(), + sessionId: text(), + idempotencyKey: text().notNull(), + correlationId: text().notNull(), + input: jsonb().$type(), + result: jsonb().$type(), + summary: text(), + modelId: text(), + inputTokens: integer(), + outputTokens: integer(), + costUsd: decimal({ precision: 12, scale: 6 }), + errorCode: text(), + errorMessage: text(), + nextEventSequence: integer().notNull().default(0), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + startedAt: timestamp({ precision: 3, mode: "date" }), + finishedAt: timestamp({ precision: 3, mode: "date" }), + cancelRequestedAt: timestamp({ precision: 3, mode: "date" }), + cancelDeliveredAt: timestamp({ precision: 3, mode: "date" }), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("agentRun_sessionId_key").on(table.sessionId), + uniqueIndex("agentRun_idempotencyKey_key").on(table.idempotencyKey), + uniqueIndex("agentRun_correlationId_key").on(table.correlationId), + uniqueIndex("agentRun_id_agentId_key").on(table.id, table.agentId), + index("agentRun_agentId_createdAt_idx").on(table.agentId, table.createdAt), + index("agentRun_versionId_createdAt_idx").on( + table.versionId, + table.createdAt, + ), + index("agentRun_status_createdAt_idx").on(table.status, table.createdAt), + index("agentRun_triggerId_createdAt_idx").on( + table.triggerId, + table.createdAt, + ), + foreignKey({ + name: "agentRun_agentId_fkey", + columns: [table.agentId], + foreignColumns: [agentDefinition.id], + }) + .onDelete("restrict") + .onUpdate("cascade"), + foreignKey({ + name: "agentRun_versionId_agentId_fkey", + columns: [table.versionId, table.agentId], + foreignColumns: [agentVersion.id, agentVersion.agentId], + }) + .onDelete("restrict") + .onUpdate("cascade"), + foreignKey({ + name: "agentRun_triggerId_agentId_fkey", + columns: [table.triggerId, table.agentId], + foreignColumns: [agentTrigger.id, agentTrigger.agentId], + }) + .onDelete("restrict") + .onUpdate("cascade"), + foreignKey({ + name: "agentRun_initiatedById_fkey", + columns: [table.initiatedById], + foreignColumns: [user.id], + }) + .onDelete("set null") + .onUpdate("cascade"), + ], +); + +export const agentRunEvent = pgTable( + "agentRunEvent", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + runId: text().notNull(), + sequence: integer().notNull(), + type: text().notNull(), + data: jsonb().$type().notNull(), + emittedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("agentRunEvent_runId_sequence_key").on( + table.runId, + table.sequence, + ), + index("agentRunEvent_runId_emittedAt_idx").on(table.runId, table.emittedAt), + foreignKey({ + name: "agentRunEvent_runId_fkey", + columns: [table.runId], + foreignColumns: [agentRun.id], + }) + .onDelete("restrict") + .onUpdate("cascade"), + ], +); + +export const agentAction = pgTable( + "agentAction", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + agentId: text().notNull(), + runId: text().notNull(), + type: text().notNull(), + provider: text().notNull(), + targetType: text(), + targetId: text(), + targetLabel: text(), + summary: text().notNull(), + metadata: jsonb().$type(), + status: agentActionStatusEnum().notNull().default("PLANNED"), + idempotencyKey: text().notNull(), + requestHash: text(), + externalId: text(), + attemptCount: integer().notNull().default(0), + errorCode: text(), + errorMessage: text(), + plannedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + startedAt: timestamp({ precision: 3, mode: "date" }), + completedAt: timestamp({ precision: 3, mode: "date" }), + updatedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("agentAction_idempotencyKey_key").on(table.idempotencyKey), + index("agentAction_agentId_plannedAt_idx").on( + table.agentId, + table.plannedAt, + ), + index("agentAction_runId_plannedAt_idx").on(table.runId, table.plannedAt), + index("agentAction_provider_externalId_idx").on( + table.provider, + table.externalId, + ), + index("agentAction_status_plannedAt_idx").on(table.status, table.plannedAt), + foreignKey({ + name: "agentAction_agentId_fkey", + columns: [table.agentId], + foreignColumns: [agentDefinition.id], + }) + .onDelete("restrict") + .onUpdate("cascade"), + foreignKey({ + name: "agentAction_runId_agentId_fkey", + columns: [table.runId, table.agentId], + foreignColumns: [agentRun.id, agentRun.agentId], + }) + .onDelete("restrict") + .onUpdate("cascade"), + ], +); + +export const agentAuditEvent = pgTable( + "agentAuditEvent", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + agentId: text().notNull(), + versionId: text(), + actorUserId: text(), + type: text().notNull(), + actorType: text().notNull(), + actorId: text(), + summary: text().notNull(), + before: jsonb().$type(), + after: jsonb().$type(), + requestId: text(), + emittedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("agentAuditEvent_agentId_type_requestId_key").on( + table.agentId, + table.type, + table.requestId, + ), + index("agentAuditEvent_agentId_emittedAt_idx").on( + table.agentId, + table.emittedAt, + ), + index("agentAuditEvent_versionId_emittedAt_idx").on( + table.versionId, + table.emittedAt, + ), + index("agentAuditEvent_actorUserId_emittedAt_idx").on( + table.actorUserId, + table.emittedAt, + ), + index("agentAuditEvent_type_emittedAt_idx").on(table.type, table.emittedAt), + foreignKey({ + name: "agentAuditEvent_agentId_fkey", + columns: [table.agentId], + foreignColumns: [agentDefinition.id], + }) + .onDelete("restrict") + .onUpdate("cascade"), + foreignKey({ + name: "agentAuditEvent_versionId_agentId_fkey", + columns: [table.versionId, table.agentId], + foreignColumns: [agentVersion.id, agentVersion.agentId], + }) + .onDelete("restrict") + .onUpdate("cascade"), + foreignKey({ + name: "agentAuditEvent_actorUserId_fkey", + columns: [table.actorUserId], + foreignColumns: [user.id], + }) + .onDelete("set null") + .onUpdate("cascade"), + ], +); + +export const deal = pgTable( + "deal", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + name: text().notNull(), + description: text(), + companyId: text().notNull(), + ownerId: text().notNull(), + stage: dealStageEnum().notNull().default("DEMO_BOOKED"), + stageChangedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + amount: decimal({ precision: 14, scale: 2 }), + currency: text().notNull().default("USD"), + expectedCloseDate: timestamp({ precision: 3, mode: "date" }), + closedAt: timestamp({ precision: 3, mode: "date" }), + closedReason: text(), + baseAmount: decimal({ precision: 24, scale: 4 }), + baseCurrency: text(), + fxRate: decimal({ precision: 20, scale: 10 }), + fxRateAt: timestamp({ precision: 3, mode: "date" }), + lastActivityAt: timestamp({ precision: 3, mode: "date" }), + archivedAt: timestamp({ precision: 3, mode: "date" }), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + updatedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + index("deal_companyId_idx").on(table.companyId), + index("deal_ownerId_idx").on(table.ownerId), + index("deal_stage_idx").on(table.stage), + index("deal_expectedCloseDate_idx").on(table.expectedCloseDate), + index("deal_lastActivityAt_idx").on(table.lastActivityAt), + index("deal_baseAmount_idx").on(table.baseAmount), + index("deal_currency_idx").on(table.currency), + index("deal_archivedAt_idx").on(table.archivedAt), + foreignKey({ + name: "deal_companyId_fkey", + columns: [table.companyId], + foreignColumns: [company.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + foreignKey({ + name: "deal_ownerId_fkey", + columns: [table.ownerId], + foreignColumns: [user.id], + }) + .onDelete("restrict") + .onUpdate("cascade"), + ], +); + +export const exchangeRate = pgTable( + "exchangeRate", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + baseCurrency: text().notNull(), + quoteCurrency: text().notNull(), + rate: decimal({ precision: 20, scale: 10 }).notNull(), + asOf: timestamp({ precision: 3, mode: "date" }).notNull(), + source: rateSourceEnum().notNull(), + provider: text(), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + updatedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("exchangeRate_baseCurrency_quoteCurrency_source_key").on( + table.baseCurrency, + table.quoteCurrency, + table.source, + ), + index("exchangeRate_baseCurrency_quoteCurrency_idx").on( + table.baseCurrency, + table.quoteCurrency, + ), + ], +); + +export const dealContact = pgTable( + "dealContact", + { + dealId: text().notNull(), + contactId: text().notNull(), + role: text(), + }, + (table): PgTableExtraConfigValue[] => [ + primaryKey({ + name: "dealContact_pkey", + columns: [table.dealId, table.contactId], + }), + index("dealContact_contactId_idx").on(table.contactId), + foreignKey({ + name: "dealContact_dealId_fkey", + columns: [table.dealId], + foreignColumns: [deal.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + foreignKey({ + name: "dealContact_contactId_fkey", + columns: [table.contactId], + foreignColumns: [contact.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + ], +); + +export const fieldDefinition = pgTable( + "fieldDefinition", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + entity: fieldEntityEnum().notNull(), + key: text().notNull(), + label: text().notNull(), + type: fieldTypeEnum().notNull(), + agentFilled: boolean().notNull().default(true), + agentBrief: text(), + required: boolean().notNull().default(false), + showOnSheet: boolean().notNull().default(true), + showOnTable: boolean().notNull().default(false), + showOnFilter: boolean().notNull().default(false), + position: integer().notNull(), + archivedAt: timestamp({ precision: 3, mode: "date" }), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + updatedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("fieldDefinition_entity_key_key").on(table.entity, table.key), + index("fieldDefinition_entity_position_idx").on( + table.entity, + table.position, + ), + ], +); + +export const fieldOption = pgTable( + "fieldOption", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + fieldId: text().notNull(), + label: text().notNull(), + position: integer().notNull(), + archivedAt: timestamp({ precision: 3, mode: "date" }), + }, + (table): PgTableExtraConfigValue[] => [ + index("fieldOption_fieldId_position_idx").on(table.fieldId, table.position), + foreignKey({ + name: "fieldOption_fieldId_fkey", + columns: [table.fieldId], + foreignColumns: [fieldDefinition.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + ], +); + +export const fieldValue = pgTable( + "fieldValue", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + fieldId: text().notNull(), + companyId: text(), + contactId: text(), + dealId: text(), + text: text(), + number: decimal({ precision: 24, scale: 4 }), + date: timestamp({ precision: 3, mode: "date" }), + bool: boolean(), + optionId: text(), + userId: text(), + updatedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("fieldValue_fieldId_companyId_key").on( + table.fieldId, + table.companyId, + ), + uniqueIndex("fieldValue_fieldId_contactId_key").on( + table.fieldId, + table.contactId, + ), + uniqueIndex("fieldValue_fieldId_dealId_key").on( + table.fieldId, + table.dealId, + ), + index("fieldValue_fieldId_text_idx").on(table.fieldId, table.text), + index("fieldValue_fieldId_number_idx").on(table.fieldId, table.number), + index("fieldValue_fieldId_date_idx").on(table.fieldId, table.date), + index("fieldValue_companyId_idx").on(table.companyId), + index("fieldValue_contactId_idx").on(table.contactId), + index("fieldValue_dealId_idx").on(table.dealId), + index("fieldValue_optionId_idx").on(table.optionId), + index("fieldValue_userId_idx").on(table.userId), + foreignKey({ + name: "fieldValue_fieldId_fkey", + columns: [table.fieldId], + foreignColumns: [fieldDefinition.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + foreignKey({ + name: "fieldValue_companyId_fkey", + columns: [table.companyId], + foreignColumns: [company.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + foreignKey({ + name: "fieldValue_contactId_fkey", + columns: [table.contactId], + foreignColumns: [contact.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + foreignKey({ + name: "fieldValue_dealId_fkey", + columns: [table.dealId], + foreignColumns: [deal.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + foreignKey({ + name: "fieldValue_optionId_fkey", + columns: [table.optionId], + foreignColumns: [fieldOption.id], + }) + .onDelete("set null") + .onUpdate("cascade"), + foreignKey({ + name: "fieldValue_userId_fkey", + columns: [table.userId], + foreignColumns: [user.id], + }) + .onDelete("set null") + .onUpdate("cascade"), + ], +); + +export const savedView = pgTable( + "savedView", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + entity: fieldEntityEnum().notNull(), + name: text().notNull(), + shared: boolean().notNull().default(false), + filters: jsonb().$type().notNull(), + ownerId: text().notNull(), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + updatedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("savedView_entity_ownerId_name_key").on( + table.entity, + table.ownerId, + table.name, + ), + index("savedView_entity_shared_idx").on(table.entity, table.shared), + foreignKey({ + name: "savedView_ownerId_fkey", + columns: [table.ownerId], + foreignColumns: [user.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + ], +); + +export const activity = pgTable( + "activity", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + type: activityTypeEnum().notNull(), + subject: text(), + body: text(), + occurredAt: timestamp({ precision: 3, mode: "date" }), + dueAt: timestamp({ precision: 3, mode: "date" }), + completedAt: timestamp({ precision: 3, mode: "date" }), + companyId: text(), + contactId: text(), + dealId: text(), + createdById: text().notNull(), + meta: jsonb().$type(), + emailThreadId: text(), + calendarEventId: text(), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + updatedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("activity_emailThreadId_key").on(table.emailThreadId), + uniqueIndex("activity_calendarEventId_key").on(table.calendarEventId), + index("activity_companyId_createdAt_idx").on( + table.companyId, + table.createdAt, + ), + index("activity_dealId_createdAt_idx").on(table.dealId, table.createdAt), + index("activity_contactId_createdAt_idx").on( + table.contactId, + table.createdAt, + ), + index("activity_dueAt_idx").on(table.dueAt), + index("activity_createdById_idx").on(table.createdById), + foreignKey({ + name: "activity_companyId_fkey", + columns: [table.companyId], + foreignColumns: [company.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + foreignKey({ + name: "activity_contactId_fkey", + columns: [table.contactId], + foreignColumns: [contact.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + foreignKey({ + name: "activity_dealId_fkey", + columns: [table.dealId], + foreignColumns: [deal.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + foreignKey({ + name: "activity_createdById_fkey", + columns: [table.createdById], + foreignColumns: [user.id], + }) + .onDelete("restrict") + .onUpdate("cascade"), + foreignKey({ + name: "activity_emailThreadId_fkey", + columns: [table.emailThreadId], + foreignColumns: [emailThread.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + foreignKey({ + name: "activity_calendarEventId_fkey", + columns: [table.calendarEventId], + foreignColumns: [calendarEvent.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + ], +); + +export const mailboxSync = pgTable( + "mailboxSync", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + userId: text().notNull(), + source: text().notNull(), + status: googleSyncStatusEnum().notNull().default("IDLE"), + cursor: text(), + lastSyncedAt: timestamp({ precision: 3, mode: "date" }), + lastError: text(), + retryAfter: timestamp({ precision: 3, mode: "date" }), + autoCreate: boolean().notNull().default(false), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + updatedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("mailboxSync_userId_source_key").on(table.userId, table.source), + index("mailboxSync_status_idx").on(table.status), + foreignKey({ + name: "mailboxSync_userId_fkey", + columns: [table.userId], + foreignColumns: [user.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + ], +); + +export const emailThread = pgTable( + "emailThread", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + rootMessageId: text().notNull(), + subject: text(), + companyId: text(), + contactId: text(), + firstMessageAt: timestamp({ precision: 3, mode: "date" }).notNull(), + lastMessageAt: timestamp({ precision: 3, mode: "date" }).notNull(), + messageCount: integer().notNull().default(0), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + updatedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("emailThread_rootMessageId_key").on(table.rootMessageId), + index("emailThread_companyId_lastMessageAt_idx").on( + table.companyId, + table.lastMessageAt, + ), + index("emailThread_contactId_lastMessageAt_idx").on( + table.contactId, + table.lastMessageAt, + ), + foreignKey({ + name: "emailThread_companyId_fkey", + columns: [table.companyId], + foreignColumns: [company.id], + }) + .onDelete("set null") + .onUpdate("cascade"), + foreignKey({ + name: "emailThread_contactId_fkey", + columns: [table.contactId], + foreignColumns: [contact.id], + }) + .onDelete("set null") + .onUpdate("cascade"), + ], +); + +export const emailMessage = pgTable( + "emailMessage", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + threadId: text().notNull(), + rfcMessageId: text().notNull(), + syncedByUserId: text(), + gmailMessageId: text(), + outlookMessageId: text(), + outlookWebLink: text(), + direction: emailDirectionEnum().notNull(), + fromEmail: text().notNull(), + fromName: text(), + recipients: jsonb().$type().notNull(), + subject: text(), + snippet: text(), + body: text(), + sentAt: timestamp({ precision: 3, mode: "date" }).notNull(), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("emailMessage_rfcMessageId_key").on(table.rfcMessageId), + index("emailMessage_threadId_sentAt_idx").on(table.threadId, table.sentAt), + foreignKey({ + name: "emailMessage_threadId_fkey", + columns: [table.threadId], + foreignColumns: [emailThread.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + ], +); + +export const calendarEvent = pgTable( + "calendarEvent", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + iCalUid: text().notNull(), + originalStartTime: timestamp({ precision: 3, mode: "date" }).notNull(), + recurringEventId: text(), + title: text(), + description: text(), + location: text(), + conferenceUrl: text(), + startsAt: timestamp({ precision: 3, mode: "date" }).notNull(), + endsAt: timestamp({ precision: 3, mode: "date" }).notNull(), + isAllDay: boolean().notNull().default(false), + status: text().notNull(), + organizerEmail: text(), + companyId: text(), + contactId: text(), + syncedByUserId: text(), + googleEventId: text(), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + updatedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("calendarEvent_iCalUid_originalStartTime_key").on( + table.iCalUid, + table.originalStartTime, + ), + index("calendarEvent_companyId_startsAt_idx").on( + table.companyId, + table.startsAt, + ), + index("calendarEvent_contactId_startsAt_idx").on( + table.contactId, + table.startsAt, + ), + foreignKey({ + name: "calendarEvent_companyId_fkey", + columns: [table.companyId], + foreignColumns: [company.id], + }) + .onDelete("set null") + .onUpdate("cascade"), + foreignKey({ + name: "calendarEvent_contactId_fkey", + columns: [table.contactId], + foreignColumns: [contact.id], + }) + .onDelete("set null") + .onUpdate("cascade"), + ], +); + +export const calendarAttendee = pgTable( + "calendarAttendee", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + eventId: text().notNull(), + email: text().notNull(), + name: text(), + responseStatus: text(), + isOrganizer: boolean().notNull().default(false), + contactId: text(), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("calendarAttendee_eventId_email_key").on( + table.eventId, + table.email, + ), + index("calendarAttendee_contactId_idx").on(table.contactId), + foreignKey({ + name: "calendarAttendee_eventId_fkey", + columns: [table.eventId], + foreignColumns: [calendarEvent.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + foreignKey({ + name: "calendarAttendee_contactId_fkey", + columns: [table.contactId], + foreignColumns: [contact.id], + }) + .onDelete("set null") + .onUpdate("cascade"), + ], +); + +export const suppressedDomain = pgTable("suppressedDomain", { + domain: text().primaryKey(), + reason: text(), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), +}); + +export const suppressedContact = pgTable("suppressedContact", { + email: text().primaryKey(), + reason: text(), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), +}); + +export const appSetting = pgTable( + "appSetting", + { + id: text().primaryKey(), + agentModelId: text(), + agentModelContextWindow: integer(), + contextDevApiKey: text(), + reportingCurrency: text(), + ratesRefreshedAt: timestamp({ precision: 3, mode: "date" }), + archiveRetentionDays: integer().notNull().default(180), + trackingSiteId: text(), + trackingCrossDomain: boolean().notNull().default(true), + trackingLimitToDomains: boolean().notNull().default(true), + trackingCookieSubdomains: boolean().notNull().default(false), + trackingSecureCookies: boolean().notNull().default(true), + trackingHonourDnt: boolean().notNull().default(true), + trackingCookieDays: integer().notNull().default(395), + trackingConfigHash: text(), + trackingPaused: boolean().notNull().default(false), + updatedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("appSetting_trackingSiteId_key").on(table.trackingSiteId), + ], +); + +export const trackedDomain = pgTable( + "trackedDomain", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + host: text().notNull(), + scope: domainScopeEnum().notNull().default("EXACT_HOST"), + pageViews: integer().notNull().default(0), + lastSeenAt: timestamp({ precision: 3, mode: "date" }), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("trackedDomain_host_key").on(table.host), + ], +); + +export const trackedVisitor = pgTable( + "trackedVisitor", + { + id: text().primaryKey(), + contactId: text(), + firstSource: text(), + firstMedium: text(), + firstCampaign: text(), + firstTerm: text(), + firstContent: text(), + firstReferrer: text(), + firstLanding: text(), + firstTouchAt: timestamp({ precision: 3, mode: "date" }), + lastSource: text(), + lastMedium: text(), + lastCampaign: text(), + lastTerm: text(), + lastContent: text(), + lastReferrer: text(), + lastLanding: text(), + lastTouchAt: timestamp({ precision: 3, mode: "date" }), + firstSeen: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + lastSeen: timestamp({ precision: 3, mode: "date" }) + .notNull() + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + index("trackedVisitor_contactId_idx").on(table.contactId), + index("trackedVisitor_firstSource_idx").on(table.firstSource), + foreignKey({ + name: "trackedVisitor_contactId_fkey", + columns: [table.contactId], + foreignColumns: [contact.id], + }) + .onDelete("set null") + .onUpdate("cascade"), + ], +); + +export const trackedEvent = pgTable( + "trackedEvent", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + visitorId: text().notNull(), + type: text().notNull(), + host: text().notNull(), + path: text().notNull(), + referrer: text(), + label: text(), + source: text(), + medium: text(), + campaign: text(), + occurredAt: timestamp({ precision: 3, mode: "date" }).notNull(), + }, + (table): PgTableExtraConfigValue[] => [ + index("trackedEvent_visitorId_occurredAt_idx").on( + table.visitorId, + table.occurredAt, + ), + index("trackedEvent_occurredAt_idx").on(table.occurredAt), + index("trackedEvent_host_occurredAt_idx").on(table.host, table.occurredAt), + index("trackedEvent_source_occurredAt_idx").on( + table.source, + table.occurredAt, + ), + ], +); + +export const trackingCounter = pgTable( + "trackingCounter", + { + key: text().primaryKey(), + value: integer().notNull().default(0), + expiresAt: timestamp({ precision: 3, mode: "date" }).notNull(), + }, + (table): PgTableExtraConfigValue[] => [ + index("trackingCounter_expiresAt_idx").on(table.expiresAt), + ], +); + +export const trackedPageDaily = pgTable( + "trackedPageDaily", + { + day: timestamp({ precision: 3, mode: "date" }).notNull(), + host: text().notNull(), + path: text().notNull(), + views: integer().notNull().default(0), + visitors: integer().notNull().default(0), + }, + (table): PgTableExtraConfigValue[] => [ + primaryKey({ + name: "trackedPageDaily_pkey", + columns: [table.day, table.host, table.path], + }), + index("trackedPageDaily_host_day_idx").on(table.host, table.day), + ], +); + +export const formSubmission = pgTable( + "formSubmission", + { + id: text() + .primaryKey() + .$defaultFn(() => createId()), + visitorId: text(), + contactId: text(), + host: text().notNull(), + path: text().notNull(), + email: text(), + fields: jsonb().$type().notNull(), + firstTouch: jsonb().$type(), + lastTouch: jsonb().$type(), + dedupeKey: text().notNull(), + filedAt: timestamp({ precision: 3, mode: "date" }), + skipReason: text(), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("formSubmission_dedupeKey_key").on(table.dedupeKey), + index("formSubmission_contactId_idx").on(table.contactId), + index("formSubmission_createdAt_idx").on(table.createdAt), + foreignKey({ + name: "formSubmission_contactId_fkey", + columns: [table.contactId], + foreignColumns: [contact.id], + }) + .onDelete("set null") + .onUpdate("cascade"), + ], +); + +export const install = pgTable( + "install", + { + id: text().primaryKey(), + uuid: text().notNull(), + version: text().notNull(), + lastRollupAt: timestamp({ precision: 3, mode: "date" }), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + updatedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("install_uuid_key").on(table.uuid), + ], +); + +export const telemetryMilestone = pgTable("telemetryMilestone", { + step: text().primaryKey(), + reachedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), +}); + +export const telemetryCounter = pgTable("telemetryCounter", { + name: text().primaryKey(), + count: integer().notNull().default(0), + updatedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), +}); + +export const organization = pgTable( + "organization", + { + id: text().primaryKey(), + name: text().notNull(), + slug: text().notNull(), + logo: text(), + createdAt: timestamp({ precision: 3, mode: "date" }).notNull(), + metadata: text(), + website: text(), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("organization_slug_key").on(table.slug), + ], +); + +export const workspaceProfile = pgTable("workspaceProfile", { + id: text().primaryKey(), + website: text().notNull(), + narrative: text().notNull(), + sections: jsonb().$type().notNull(), + sourceUrl: text(), + sessionId: text(), + refreshedAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), +}); + +export const member = pgTable( + "member", + { + id: text().primaryKey(), + organizationId: text().notNull(), + userId: text().notNull(), + role: text().notNull().default("member"), + createdAt: timestamp({ precision: 3, mode: "date" }).notNull(), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("member_organizationId_userId_key").on( + table.organizationId, + table.userId, + ), + index("member_organizationId_idx").on(table.organizationId), + index("member_userId_idx").on(table.userId), + foreignKey({ + name: "member_organizationId_fkey", + columns: [table.organizationId], + foreignColumns: [organization.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + foreignKey({ + name: "member_userId_fkey", + columns: [table.userId], + foreignColumns: [user.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + ], +); + +export const invitation = pgTable( + "invitation", + { + id: text().primaryKey(), + organizationId: text().notNull(), + email: text().notNull(), + role: text(), + status: text().notNull().default("pending"), + expiresAt: timestamp({ precision: 3, mode: "date" }).notNull(), + createdAt: timestamp({ precision: 3, mode: "date" }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => new Date()), + inviterId: text().notNull(), + }, + (table): PgTableExtraConfigValue[] => [ + index("invitation_organizationId_idx").on(table.organizationId), + index("invitation_email_idx").on(table.email), + foreignKey({ + name: "invitation_organizationId_fkey", + columns: [table.organizationId], + foreignColumns: [organization.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + foreignKey({ + name: "invitation_inviterId_fkey", + columns: [table.inviterId], + foreignColumns: [user.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + ], +); + +export const ssoProvider = pgTable( + "ssoProvider", + { + id: text().primaryKey(), + issuer: text().notNull(), + oidcConfig: text(), + samlConfig: text(), + userId: text(), + providerId: text().notNull(), + organizationId: text(), + domain: text().notNull(), + }, + (table): PgTableExtraConfigValue[] => [ + uniqueIndex("ssoProvider_providerId_key").on(table.providerId), + foreignKey({ + name: "ssoProvider_userId_fkey", + columns: [table.userId], + foreignColumns: [user.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + ], +); + +export const apikey = pgTable( + "apikey", + { + id: text().primaryKey(), + configId: text().notNull().default("default"), + name: text(), + start: text(), + referenceId: text().notNull(), + prefix: text(), + key: text().notNull(), + refillInterval: integer(), + refillAmount: integer(), + lastRefillAt: timestamp({ precision: 3, mode: "date" }), + enabled: boolean().default(true), + rateLimitEnabled: boolean().default(true), + rateLimitTimeWindow: integer(), + rateLimitMax: integer(), + requestCount: integer().default(0), + remaining: integer(), + lastRequest: timestamp({ precision: 3, mode: "date" }), + expiresAt: timestamp({ precision: 3, mode: "date" }), + createdAt: timestamp({ precision: 3, mode: "date" }).notNull(), + updatedAt: timestamp({ precision: 3, mode: "date" }).notNull(), + permissions: text(), + metadata: text(), + }, + (table): PgTableExtraConfigValue[] => [ + index("apikey_referenceId_idx").on(table.referenceId), + index("apikey_configId_idx").on(table.configId), + index("apikey_key_idx").on(table.key), + foreignKey({ + name: "apikey_referenceId_fkey", + columns: [table.referenceId], + foreignColumns: [user.id], + }) + .onDelete("cascade") + .onUpdate("cascade"), + ], +); + +export const userRelations = relations(user, ({ one, many }) => ({ + sessions: many(session), + accounts: many(account), + ownedCompanies: many(company), + ownedContacts: many(contact), + ownedDeals: many(deal), + activities: many(activity), + mailboxSyncs: many(mailboxSync), + factDecisions: many(contactFact), + conversations: many(agentConversation), + fieldValues: many(fieldValue), + savedViews: many(savedView), + createdAgentDefinitions: many(agentDefinition), + createdAgentVersions: many(agentVersion), + createdAgentTriggers: many(agentTrigger), + initiatedAgentRuns: many(agentRun), + agentAuditEvents: many(agentAuditEvent), + conversationShares: many(agentConversationShare), + conversationSubmissions: many(agentConversationSubmission), + conversationFeedback: many(agentConversationFeedback), + slackMemberMatch: one(slackMemberMatch), + connectedMcpConnections: many(mcpConnection), + mcpOauthStates: many(mcpOauthState), + members: many(member), + invitations: many(invitation), + ssoproviders: many(ssoProvider), + apiKeys: many(apikey), +})); + +export const slackMemberMatchRelations = relations( + slackMemberMatch, + ({ one }) => ({ + crmUser: one(user, { + fields: [slackMemberMatch.crmUserId], + references: [user.id], + }), + }), +); + +export const sessionRelations = relations(session, ({ one }) => ({ + user: one(user, { fields: [session.userId], references: [user.id] }), +})); + +export const accountRelations = relations(account, ({ one }) => ({ + user: one(user, { fields: [account.userId], references: [user.id] }), +})); + +export const mcpConnectionRelations = relations( + mcpConnection, + ({ one, many }) => ({ + organization: one(organization, { + fields: [mcpConnection.organizationId], + references: [organization.id], + }), + registration: one(mcpClientRegistration, { + fields: [mcpConnection.registrationId], + references: [mcpClientRegistration.id], + }), + connectedBy: one(user, { + fields: [mcpConnection.connectedById], + references: [user.id], + }), + toolSnapshots: many(mcpToolSnapshot), + }), +); + +export const mcpClientRegistrationRelations = relations( + mcpClientRegistration, + ({ many }) => ({ + connections: many(mcpConnection), + oauthStates: many(mcpOauthState), + }), +); + +export const mcpToolSnapshotRelations = relations( + mcpToolSnapshot, + ({ one }) => ({ + connection: one(mcpConnection, { + fields: [mcpToolSnapshot.connectionId], + references: [mcpConnection.id], + }), + }), +); + +export const mcpOauthStateRelations = relations(mcpOauthState, ({ one }) => ({ + organization: one(organization, { + fields: [mcpOauthState.organizationId], + references: [organization.id], + }), + user: one(user, { fields: [mcpOauthState.userId], references: [user.id] }), + registration: one(mcpClientRegistration, { + fields: [mcpOauthState.registrationId], + references: [mcpClientRegistration.id], + }), +})); + +export const companyRelations = relations(company, ({ one, many }) => ({ + owner: one(user, { fields: [company.ownerId], references: [user.id] }), + primaryContact: one(contact, { + fields: [company.primaryContactId], + references: [contact.id], + relationName: "PrimaryContact", + }), + enrichment: one(companyEnrichment), + contacts: many(contact, { relationName: "CompanyContacts" }), + conversations: many(agentConversation), + deals: many(deal), + activities: many(activity), + emailThreads: many(emailThread), + calendarEvents: many(calendarEvent), + fieldValues: many(fieldValue), +})); + +export const companyEnrichmentRelations = relations( + companyEnrichment, + ({ one }) => ({ + company: one(company, { + fields: [companyEnrichment.companyId], + references: [company.id], + }), + }), +); + +export const contactRelations = relations(contact, ({ one, many }) => ({ + brief: one(contactBrief), + facts: many(contactFact), + conversations: many(agentConversation), + company: one(company, { + fields: [contact.companyId], + references: [company.id], + relationName: "CompanyContacts", + }), + owner: one(user, { fields: [contact.ownerId], references: [user.id] }), + primaryOf: one(company, { + fields: [contact.id], + references: [company.primaryContactId], + relationName: "PrimaryContact", + }), + deals: many(dealContact), + activities: many(activity), + emailThreads: many(emailThread), + calendarEvents: many(calendarEvent), + eventAttendance: many(calendarAttendee), + fieldValues: many(fieldValue), + visitors: many(trackedVisitor), + submissions: many(formSubmission), +})); + +export const contactFactRelations = relations(contactFact, ({ one }) => ({ + contact: one(contact, { + fields: [contactFact.contactId], + references: [contact.id], + }), + decidedBy: one(user, { + fields: [contactFact.decidedById], + references: [user.id], + }), +})); + +export const contactBriefRelations = relations(contactBrief, ({ one }) => ({ + contact: one(contact, { + fields: [contactBrief.contactId], + references: [contact.id], + }), +})); + +export const agentEventRelations = relations(agentEvent, ({ one }) => ({ + conversation: one(agentConversation, { + fields: [agentEvent.conversationId], + references: [agentConversation.id], + }), +})); + +export const agentConversationRelations = relations( + agentConversation, + ({ one, many }) => ({ + contact: one(contact, { + fields: [agentConversation.contactId], + references: [contact.id], + }), + company: one(company, { + fields: [agentConversation.companyId], + references: [company.id], + }), + deal: one(deal, { + fields: [agentConversation.dealId], + references: [deal.id], + }), + user: one(user, { + fields: [agentConversation.userId], + references: [user.id], + }), + agent: one(agentDefinition, { + fields: [agentConversation.agentId], + references: [agentDefinition.id], + }), + shares: many(agentConversationShare), + submissions: many(agentConversationSubmission), + feedback: many(agentConversationFeedback), + events: many(agentEvent), + createdVersions: many(agentVersion), + builderArtifacts: many(agentBuilderArtifact), + }), +); + +export const agentConversationFeedbackRelations = relations( + agentConversationFeedback, + ({ one }) => ({ + conversation: one(agentConversation, { + fields: [agentConversationFeedback.conversationId], + references: [agentConversation.id], + }), + user: one(user, { + fields: [agentConversationFeedback.userId], + references: [user.id], + }), + }), +); + +export const agentConversationShareRelations = relations( + agentConversationShare, + ({ one }) => ({ + conversation: one(agentConversation, { + fields: [agentConversationShare.conversationId], + references: [agentConversation.id], + }), + createdBy: one(user, { + fields: [agentConversationShare.createdById], + references: [user.id], + }), + }), +); + +export const agentConversationSubmissionRelations = relations( + agentConversationSubmission, + ({ one, many }) => ({ + conversation: one(agentConversation, { + fields: [agentConversationSubmission.conversationId], + references: [agentConversation.id], + }), + submittedBy: one(user, { + fields: [agentConversationSubmission.submittedById], + references: [user.id], + }), + attachments: many(agentConversationAttachment), + }), +); + +export const agentConversationAttachmentRelations = relations( + agentConversationAttachment, + ({ one }) => ({ + submission: one(agentConversationSubmission, { + fields: [agentConversationAttachment.submissionId], + references: [agentConversationSubmission.id], + }), + }), +); + +export const agentDefinitionRelations = relations( + agentDefinition, + ({ one, many }) => ({ + createdBy: one(user, { + fields: [agentDefinition.createdById], + references: [user.id], + }), + currentVersion: one(agentVersion, { + fields: [agentDefinition.currentVersionId, agentDefinition.id], + references: [agentVersion.id, agentVersion.agentId], + relationName: "CurrentAgentVersion", + }), + versions: many(agentVersion, { relationName: "AgentVersions" }), + triggers: many(agentTrigger), + runs: many(agentRun), + actions: many(agentAction), + auditEvents: many(agentAuditEvent), + conversations: many(agentConversation), + }), +); + +export const agentVersionRelations = relations( + agentVersion, + ({ one, many }) => ({ + agent: one(agentDefinition, { + fields: [agentVersion.agentId], + references: [agentDefinition.id], + relationName: "AgentVersions", + }), + sourceConversation: one(agentConversation, { + fields: [agentVersion.sourceConversationId], + references: [agentConversation.id], + }), + createdBy: one(user, { + fields: [agentVersion.createdById], + references: [user.id], + }), + currentFor: one(agentDefinition, { + fields: [agentVersion.id, agentVersion.agentId], + references: [agentDefinition.currentVersionId, agentDefinition.id], + relationName: "CurrentAgentVersion", + }), + triggers: many(agentTrigger), + runs: many(agentRun), + auditEvents: many(agentAuditEvent), + builderArtifacts: many(agentBuilderArtifact), + }), +); + +export const agentBuilderArtifactRelations = relations( + agentBuilderArtifact, + ({ one }) => ({ + conversation: one(agentConversation, { + fields: [agentBuilderArtifact.conversationId], + references: [agentConversation.id], + }), + version: one(agentVersion, { + fields: [agentBuilderArtifact.versionId], + references: [agentVersion.id], + }), + }), +); + +export const agentTriggerRelations = relations( + agentTrigger, + ({ one, many }) => ({ + agent: one(agentDefinition, { + fields: [agentTrigger.agentId], + references: [agentDefinition.id], + }), + version: one(agentVersion, { + fields: [agentTrigger.versionId, agentTrigger.agentId], + references: [agentVersion.id, agentVersion.agentId], + }), + createdBy: one(user, { + fields: [agentTrigger.createdById], + references: [user.id], + }), + runs: many(agentRun), + }), +); + +export const agentRunRelations = relations(agentRun, ({ one, many }) => ({ + agent: one(agentDefinition, { + fields: [agentRun.agentId], + references: [agentDefinition.id], + }), + version: one(agentVersion, { + fields: [agentRun.versionId, agentRun.agentId], + references: [agentVersion.id, agentVersion.agentId], + }), + trigger: one(agentTrigger, { + fields: [agentRun.triggerId, agentRun.agentId], + references: [agentTrigger.id, agentTrigger.agentId], + }), + initiatedBy: one(user, { + fields: [agentRun.initiatedById], + references: [user.id], + }), + events: many(agentRunEvent), + actions: many(agentAction), +})); + +export const agentRunEventRelations = relations(agentRunEvent, ({ one }) => ({ + run: one(agentRun, { + fields: [agentRunEvent.runId], + references: [agentRun.id], + }), +})); + +export const agentActionRelations = relations(agentAction, ({ one }) => ({ + agent: one(agentDefinition, { + fields: [agentAction.agentId], + references: [agentDefinition.id], + }), + run: one(agentRun, { + fields: [agentAction.runId, agentAction.agentId], + references: [agentRun.id, agentRun.agentId], + }), +})); + +export const agentAuditEventRelations = relations( + agentAuditEvent, + ({ one }) => ({ + agent: one(agentDefinition, { + fields: [agentAuditEvent.agentId], + references: [agentDefinition.id], + }), + version: one(agentVersion, { + fields: [agentAuditEvent.versionId, agentAuditEvent.agentId], + references: [agentVersion.id, agentVersion.agentId], + }), + actorUser: one(user, { + fields: [agentAuditEvent.actorUserId], + references: [user.id], + }), + }), +); + +export const dealRelations = relations(deal, ({ one, many }) => ({ + conversations: many(agentConversation), + company: one(company, { fields: [deal.companyId], references: [company.id] }), + owner: one(user, { fields: [deal.ownerId], references: [user.id] }), + contacts: many(dealContact), + activities: many(activity), + fieldValues: many(fieldValue), +})); + +export const dealContactRelations = relations(dealContact, ({ one }) => ({ + deal: one(deal, { fields: [dealContact.dealId], references: [deal.id] }), + contact: one(contact, { + fields: [dealContact.contactId], + references: [contact.id], + }), +})); + +export const fieldDefinitionRelations = relations( + fieldDefinition, + ({ many }) => ({ + options: many(fieldOption), + values: many(fieldValue), + }), +); + +export const fieldOptionRelations = relations(fieldOption, ({ one, many }) => ({ + field: one(fieldDefinition, { + fields: [fieldOption.fieldId], + references: [fieldDefinition.id], + }), + values: many(fieldValue), +})); + +export const fieldValueRelations = relations(fieldValue, ({ one }) => ({ + field: one(fieldDefinition, { + fields: [fieldValue.fieldId], + references: [fieldDefinition.id], + }), + company: one(company, { + fields: [fieldValue.companyId], + references: [company.id], + }), + contact: one(contact, { + fields: [fieldValue.contactId], + references: [contact.id], + }), + deal: one(deal, { fields: [fieldValue.dealId], references: [deal.id] }), + option: one(fieldOption, { + fields: [fieldValue.optionId], + references: [fieldOption.id], + }), + user: one(user, { fields: [fieldValue.userId], references: [user.id] }), +})); + +export const savedViewRelations = relations(savedView, ({ one }) => ({ + owner: one(user, { fields: [savedView.ownerId], references: [user.id] }), +})); + +export const activityRelations = relations(activity, ({ one }) => ({ + company: one(company, { + fields: [activity.companyId], + references: [company.id], + }), + contact: one(contact, { + fields: [activity.contactId], + references: [contact.id], + }), + deal: one(deal, { fields: [activity.dealId], references: [deal.id] }), + createdBy: one(user, { + fields: [activity.createdById], + references: [user.id], + }), + emailThread: one(emailThread, { + fields: [activity.emailThreadId], + references: [emailThread.id], + }), + calendarEvent: one(calendarEvent, { + fields: [activity.calendarEventId], + references: [calendarEvent.id], + }), +})); + +export const mailboxSyncRelations = relations(mailboxSync, ({ one }) => ({ + user: one(user, { fields: [mailboxSync.userId], references: [user.id] }), +})); + +export const emailThreadRelations = relations(emailThread, ({ one, many }) => ({ + company: one(company, { + fields: [emailThread.companyId], + references: [company.id], + }), + contact: one(contact, { + fields: [emailThread.contactId], + references: [contact.id], + }), + messages: many(emailMessage), + activity: one(activity), +})); + +export const emailMessageRelations = relations(emailMessage, ({ one }) => ({ + thread: one(emailThread, { + fields: [emailMessage.threadId], + references: [emailThread.id], + }), +})); + +export const calendarEventRelations = relations( + calendarEvent, + ({ one, many }) => ({ + company: one(company, { + fields: [calendarEvent.companyId], + references: [company.id], + }), + contact: one(contact, { + fields: [calendarEvent.contactId], + references: [contact.id], + }), + attendees: many(calendarAttendee), + activity: one(activity), + }), +); + +export const calendarAttendeeRelations = relations( + calendarAttendee, + ({ one }) => ({ + event: one(calendarEvent, { + fields: [calendarAttendee.eventId], + references: [calendarEvent.id], + }), + contact: one(contact, { + fields: [calendarAttendee.contactId], + references: [contact.id], + }), + }), +); + +export const trackedVisitorRelations = relations(trackedVisitor, ({ one }) => ({ + contact: one(contact, { + fields: [trackedVisitor.contactId], + references: [contact.id], + }), +})); + +export const formSubmissionRelations = relations(formSubmission, ({ one }) => ({ + contact: one(contact, { + fields: [formSubmission.contactId], + references: [contact.id], + }), +})); + +export const organizationRelations = relations(organization, ({ many }) => ({ + members: many(member), + invitations: many(invitation), + mcpConnections: many(mcpConnection), + mcpOauthStates: many(mcpOauthState), +})); + +export const memberRelations = relations(member, ({ one }) => ({ + organization: one(organization, { + fields: [member.organizationId], + references: [organization.id], + }), + user: one(user, { fields: [member.userId], references: [user.id] }), +})); + +export const invitationRelations = relations(invitation, ({ one }) => ({ + organization: one(organization, { + fields: [invitation.organizationId], + references: [organization.id], + }), + user: one(user, { fields: [invitation.inviterId], references: [user.id] }), +})); + +export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({ + user: one(user, { fields: [ssoProvider.userId], references: [user.id] }), +})); + +export const apikeyRelations = relations(apikey, ({ one }) => ({ + user: one(user, { fields: [apikey.referenceId], references: [user.id] }), +})); diff --git a/packages/db/src/settings-shape.ts b/packages/db/src/settings-shape.ts new file mode 100644 index 000000000..2ea7898ae --- /dev/null +++ b/packages/db/src/settings-shape.ts @@ -0,0 +1,27 @@ +export const SETTINGS_ID = "app"; + +export const DEFAULT_AGENT_MODEL = { + id: "zai/glm-5.2-fast", + contextWindowTokens: 1_000_000, +} as const; + +export interface AgentModelSetting { + id: string; + contextWindowTokens: number; + isDefault: boolean; +} + +export const CONTEXT_DEV_SIGNUP_URL = "https://link.context.dev/crm"; + +export const CONTEXT_DEV_DISCOUNT_CODE = "CRM"; + +export const DEFAULT_ARCHIVE_RETENTION_DAYS = 180; + +export const MIN_ARCHIVE_RETENTION_DAYS = 1; + +export const MAX_ARCHIVE_RETENTION_DAYS = 3650; + +export function maskKey(key: string): string { + const trimmed = key.trim(); + return trimmed.length > 4 ? `••••${trimmed.slice(-4)}` : "••••"; +} diff --git a/packages/db/src/settings.ts b/packages/db/src/settings.ts index 2959bf05b..23c8ba416 100644 --- a/packages/db/src/settings.ts +++ b/packages/db/src/settings.ts @@ -1,28 +1,44 @@ -import type { Db } from "./client"; +import { eq } from "drizzle-orm"; +import type { Executor } from "./client"; import { DEFAULT_REPORTING_CURRENCY, isCurrencyCode, normalizeCurrency, } from "./currency"; +import type { NewAppSetting } from "./schema/models"; +import { appSetting } from "./schema/schema"; +import { + type AgentModelSetting, + DEFAULT_AGENT_MODEL, + DEFAULT_ARCHIVE_RETENTION_DAYS, + MAX_ARCHIVE_RETENTION_DAYS, + MIN_ARCHIVE_RETENTION_DAYS, + SETTINGS_ID, +} from "./settings-shape"; + +export * from "./settings-shape"; + +async function readSettings(db: Executor) { + const [row] = await db + .select() + .from(appSetting) + .where(eq(appSetting.id, SETTINGS_ID)) + .limit(1); + return row; +} -export const SETTINGS_ID = "app"; - -export const DEFAULT_AGENT_MODEL = { - id: "zai/glm-5.2-fast", - contextWindowTokens: 1_000_000, -} as const; - -export interface AgentModelSetting { - id: string; - contextWindowTokens: number; - isDefault: boolean; +async function writeSettings( + db: Executor, + fields: Omit, "id">, +): Promise { + await db + .insert(appSetting) + .values({ id: SETTINGS_ID, ...fields }) + .onConflictDoUpdate({ target: appSetting.id, set: fields }); } -export async function readAgentModel(db: Db): Promise { - const row = await db.appSetting.findUnique({ - where: { id: SETTINGS_ID }, - select: { agentModelId: true, agentModelContextWindow: true }, - }); +export async function readAgentModel(db: Executor): Promise { + const row = await readSettings(db); if (!row?.agentModelId) { return { ...DEFAULT_AGENT_MODEL, isDefault: true }; @@ -37,7 +53,7 @@ export async function readAgentModel(db: Db): Promise { } export async function writeAgentModel( - db: Db, + db: Executor, model: { id: string; contextWindowTokens: number } | null, ): Promise { const fields = { @@ -45,41 +61,26 @@ export async function writeAgentModel( agentModelContextWindow: model?.contextWindowTokens ?? null, }; - await db.appSetting.upsert({ - where: { id: SETTINGS_ID }, - create: { id: SETTINGS_ID, ...fields }, - update: fields, - }); + await writeSettings(db, fields); } -export const CONTEXT_DEV_SIGNUP_URL = "https://link.context.dev/crm"; - -export const CONTEXT_DEV_DISCOUNT_CODE = "CRM"; - -export async function readContextDevKey(db: Db): Promise { - const row = await db.appSetting.findUnique({ - where: { id: SETTINGS_ID }, - select: { contextDevApiKey: true }, - }); +export async function readContextDevKey(db: Executor): Promise { + const row = await readSettings(db); return row?.contextDevApiKey?.trim() || null; } -export async function writeContextDevKey(db: Db, key: string): Promise { +export async function writeContextDevKey( + db: Executor, + key: string, +): Promise { const contextDevApiKey = key.trim(); - await db.appSetting.upsert({ - where: { id: SETTINGS_ID }, - create: { id: SETTINGS_ID, contextDevApiKey }, - update: { contextDevApiKey }, - }); + await writeSettings(db, { contextDevApiKey }); } -export async function readReportingCurrency(db: Db): Promise { - const row = await db.appSetting.findUnique({ - where: { id: SETTINGS_ID }, - select: { reportingCurrency: true }, - }); +export async function readReportingCurrency(db: Executor): Promise { + const row = await readSettings(db); const stored = normalizeCurrency(row?.reportingCurrency); @@ -87,57 +88,37 @@ export async function readReportingCurrency(db: Db): Promise { } export async function writeReportingCurrency( - db: Db, + db: Executor, code: string, ): Promise { const reportingCurrency = normalizeCurrency(code); - await db.appSetting.upsert({ - where: { id: SETTINGS_ID }, - create: { id: SETTINGS_ID, reportingCurrency }, - update: { reportingCurrency }, - }); + await writeSettings(db, { reportingCurrency }); return reportingCurrency; } -export async function readRatesRefreshedAt(db: Db): Promise { - const row = await db.appSetting.findUnique({ - where: { id: SETTINGS_ID }, - select: { ratesRefreshedAt: true }, - }); +export async function readRatesRefreshedAt(db: Executor): Promise { + const row = await readSettings(db); return row?.ratesRefreshedAt ?? null; } export async function writeRatesRefreshedAt( - db: Db, + db: Executor, ratesRefreshedAt: Date, ): Promise { - await db.appSetting.upsert({ - where: { id: SETTINGS_ID }, - create: { id: SETTINGS_ID, ratesRefreshedAt }, - update: { ratesRefreshedAt }, - }); + await writeSettings(db, { ratesRefreshedAt }); } -export const DEFAULT_ARCHIVE_RETENTION_DAYS = 180; - -export const MIN_ARCHIVE_RETENTION_DAYS = 1; - -export const MAX_ARCHIVE_RETENTION_DAYS = 3650; - -export async function readArchiveRetentionDays(db: Db): Promise { - const row = await db.appSetting.findUnique({ - where: { id: SETTINGS_ID }, - select: { archiveRetentionDays: true }, - }); +export async function readArchiveRetentionDays(db: Executor): Promise { + const row = await readSettings(db); return row?.archiveRetentionDays ?? DEFAULT_ARCHIVE_RETENTION_DAYS; } export async function writeArchiveRetentionDays( - db: Db, + db: Executor, days: number, ): Promise { const archiveRetentionDays = Math.min( @@ -145,16 +126,7 @@ export async function writeArchiveRetentionDays( MAX_ARCHIVE_RETENTION_DAYS, ); - await db.appSetting.upsert({ - where: { id: SETTINGS_ID }, - create: { id: SETTINGS_ID, archiveRetentionDays }, - update: { archiveRetentionDays }, - }); + await writeSettings(db, { archiveRetentionDays }); return archiveRetentionDays; } - -export function maskKey(key: string): string { - const trimmed = key.trim(); - return trimmed.length > 4 ? `••••${trimmed.slice(-4)}` : "••••"; -} diff --git a/packages/db/src/slack-inventory.ts b/packages/db/src/slack-inventory.ts index ae4aaa0cb..c60d7c0c4 100644 --- a/packages/db/src/slack-inventory.ts +++ b/packages/db/src/slack-inventory.ts @@ -1,6 +1,8 @@ +import { and, eq, gt, isNull, or } from "drizzle-orm"; import { PRIORITY } from "./agent-tasks"; import { db } from "./client"; import { lockIdempotencyKey } from "./idempotency"; +import { agentTask } from "./schema/schema"; const MINUTE_MS = 60_000; @@ -16,26 +18,27 @@ export async function queueSlackInventorySync(reason: string): Promise { const since = new Date(Date.now() - SLACK_INVENTORY.throttleMs); try { - await db.$transaction(async (tx) => { + await db.transaction(async (tx) => { await lockIdempotencyKey(tx, SLACK_INVENTORY.lock); - const recent = await tx.agentTask.findFirst({ - where: { - kind: SLACK_INVENTORY.kind, - OR: [{ finishedAt: null }, { createdAt: { gt: since } }], - }, - select: { id: true }, - }); + const [recent] = await tx + .select({ id: agentTask.id }) + .from(agentTask) + .where( + and( + eq(agentTask.kind, SLACK_INVENTORY.kind), + or(isNull(agentTask.finishedAt), gt(agentTask.createdAt, since)), + ), + ) + .limit(1); if (recent) return; - await tx.agentTask.create({ - data: { - kind: SLACK_INVENTORY.kind, - reason, - priority: SLACK_INVENTORY.priority, - budget: SLACK_INVENTORY.budget, - dueAt: new Date(), - }, + await tx.insert(agentTask).values({ + kind: SLACK_INVENTORY.kind, + reason, + priority: SLACK_INVENTORY.priority, + budget: SLACK_INVENTORY.budget, + dueAt: new Date(), }); }); } catch { diff --git a/packages/db/src/text-filters.ts b/packages/db/src/text-filters.ts new file mode 100644 index 000000000..f268f3014 --- /dev/null +++ b/packages/db/src/text-filters.ts @@ -0,0 +1,41 @@ +import { type Column, ilike, like, type SQL } from "drizzle-orm"; + +type TextColumn = Column | SQL; + +type TextMatch = { insensitive?: boolean }; + +export function escapeLike(value: string): string { + return value.replace(/[\\%_]/g, (char) => `\\${char}`); +} + +function match(column: TextColumn, pattern: string, options: TextMatch): SQL { + return options.insensitive ? ilike(column, pattern) : like(column, pattern); +} + +export function contains( + column: TextColumn, + value: string, + options: TextMatch = {}, +): SQL { + return match(column, `%${escapeLike(value)}%`, options); +} + +export function startsWith( + column: TextColumn, + value: string, + options: TextMatch = {}, +): SQL { + return match(column, `${escapeLike(value)}%`, options); +} + +export function endsWith( + column: TextColumn, + value: string, + options: TextMatch = {}, +): SQL { + return match(column, `%${escapeLike(value)}`, options); +} + +export function equalsInsensitive(column: TextColumn, value: string): SQL { + return ilike(column, escapeLike(value)); +} diff --git a/packages/db/src/tracking.ts b/packages/db/src/tracking.ts index dc3c6fea2..618851ed5 100644 --- a/packages/db/src/tracking.ts +++ b/packages/db/src/tracking.ts @@ -1,5 +1,7 @@ import { createHash, randomBytes } from "node:crypto"; -import type { Db } from "./client"; +import { asc, eq } from "drizzle-orm"; +import type { Executor } from "./client"; +import { appSetting, trackedDomain } from "./schema/schema"; import { SETTINGS_ID } from "./settings"; export const SITE_ID_PREFIX = "cmp_"; @@ -252,26 +254,28 @@ export function dedupeKey(parts: { } export async function readTrackingConfig( - db: Db, + db: Executor, ): Promise { const [settings, domains] = await Promise.all([ - db.appSetting.findUnique({ - where: { id: SETTINGS_ID }, - select: { - trackingSiteId: true, - trackingCrossDomain: true, - trackingLimitToDomains: true, - trackingCookieSubdomains: true, - trackingSecureCookies: true, - trackingHonourDnt: true, - trackingCookieDays: true, - trackingPaused: true, - }, - }), - db.trackedDomain.findMany({ - select: { host: true, scope: true }, - orderBy: { host: "asc" }, - }), + db + .select({ + trackingSiteId: appSetting.trackingSiteId, + trackingCrossDomain: appSetting.trackingCrossDomain, + trackingLimitToDomains: appSetting.trackingLimitToDomains, + trackingCookieSubdomains: appSetting.trackingCookieSubdomains, + trackingSecureCookies: appSetting.trackingSecureCookies, + trackingHonourDnt: appSetting.trackingHonourDnt, + trackingCookieDays: appSetting.trackingCookieDays, + trackingPaused: appSetting.trackingPaused, + }) + .from(appSetting) + .where(eq(appSetting.id, SETTINGS_ID)) + .limit(1) + .then((rows) => rows[0]), + db + .select({ host: trackedDomain.host, scope: trackedDomain.scope }) + .from(trackedDomain) + .orderBy(asc(trackedDomain.host)), ]); if (!isSiteId(settings?.trackingSiteId)) return null; diff --git a/packages/db/src/workspace-shape.ts b/packages/db/src/workspace-shape.ts new file mode 100644 index 000000000..3ab1d1f38 --- /dev/null +++ b/packages/db/src/workspace-shape.ts @@ -0,0 +1,144 @@ +import { + type JsonObject, + type JsonValue, + jsonObject, + jsonText, + type WorkspaceProfileSections, +} from "./json"; + +export const WORKSPACE_ID = "workspace"; + +export const DEFAULT_WORKSPACE_SLUG = "workspace"; + +export const MAX_SLUG = 48; + +export const RESERVED_SLUGS: readonly string[] = [ + "_next", + "api", + "agent", + "agents", + "chat", + "companies", + "contacts", + "deals", + "eve", + "grant-access", + "onboarding", + "settings", + "sign-in", +]; + +export function workspaceSlug(name: string): string { + const base = name + .normalize("NFKD") + .replace(/\p{M}/gu, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .slice(0, MAX_SLUG) + .replace(/^-+|-+$/g, ""); + + if (!base) return DEFAULT_WORKSPACE_SLUG; + + return RESERVED_SLUGS.includes(base) ? `${base}-crm` : base; +} + +export const MAX_NARRATIVE = 320; + +export const MAX_LINE = 140; + +export function isOnboarded(metadata: string | null): boolean { + return jsonText(readMetadata(metadata).onboardedAt) !== undefined; +} + +export function markOnboarded(metadata: string | null, at: Date): string { + const current = readMetadata(metadata); + + return JSON.stringify( + jsonText(current.onboardedAt) === undefined + ? { ...current, onboardedAt: at.toISOString() } + : current, + ); +} + +function readMetadata(metadata: string | null): JsonObject { + if (!metadata) return {}; + + try { + const parsed: JsonValue = JSON.parse(metadata); + + return jsonObject(parsed); + } catch { + return {}; + } +} + +export type WorkspaceProfile = { + website: string; + narrative: string; + sections: WorkspaceProfileSections; + sourceUrl: string | null; + refreshedAt: Date; +}; + +export type WorkspaceIdentity = { + name: string; + website: string | null; + profile: WorkspaceProfile | null; +}; + +export function websiteUrl(website: string | null | undefined): string | null { + const trimmed = website?.trim(); + if (!trimmed) return null; + + const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(trimmed)?.[1]; + if (scheme && !/^https?$/i.test(scheme)) return null; + + let url: URL; + try { + url = new URL(scheme ? trimmed : `https://${trimmed}`); + } catch { + return null; + } + + if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(url.hostname)) return null; + + const path = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/, ""); + + return `${url.protocol}//${url.hostname}${path}`; +} + +export function profileOf( + profile: WorkspaceProfile | null, + website: string | null, +): WorkspaceProfile | null { + if (!profile || !website || profile.website !== website) return null; + + return profile; +} + +export function trimSections( + sections: WorkspaceProfileSections, +): WorkspaceProfileSections { + const trimmed: WorkspaceProfileSections = {}; + + const sells = clamp(sections.sells, MAX_LINE); + if (sells) trimmed.sells = sells; + + const sellsTo = clamp(sections.sellsTo, MAX_LINE); + if (sellsTo) trimmed.sellsTo = sellsTo; + + const edge = clamp(sections.edge, MAX_LINE); + if (edge) trimmed.edge = edge; + + return trimmed; +} + +export function clamp( + value: string | undefined, + max: number, +): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) return undefined; + + return trimmed.length <= max ? trimmed : `${trimmed.slice(0, max - 1)}…`; +} diff --git a/packages/db/src/workspace.ts b/packages/db/src/workspace.ts index b49c748c3..7c1c15c66 100644 --- a/packages/db/src/workspace.ts +++ b/packages/db/src/workspace.ts @@ -1,149 +1,56 @@ -import type { Db } from "./client"; +import { eq } from "drizzle-orm"; +import type { Executor } from "./client"; import { - type JsonObject, type JsonValue, jsonObject, jsonText, type WorkspaceProfileSections, } from "./json"; - -export const WORKSPACE_ID = "workspace"; - -export const DEFAULT_WORKSPACE_SLUG = "workspace"; - -export const MAX_SLUG = 48; - -export const RESERVED_SLUGS: readonly string[] = [ - "_next", - "api", - "agent", - "agents", - "chat", - "companies", - "contacts", - "deals", - "eve", - "grant-access", - "onboarding", - "settings", - "sign-in", -]; - -export function workspaceSlug(name: string): string { - const base = name - .normalize("NFKD") - .replace(/\p{M}/gu, "") - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .slice(0, MAX_SLUG) - .replace(/^-+|-+$/g, ""); - - if (!base) return DEFAULT_WORKSPACE_SLUG; - - return RESERVED_SLUGS.includes(base) ? `${base}-crm` : base; -} - -export const MAX_NARRATIVE = 320; - -export const MAX_LINE = 140; - -export function isOnboarded(metadata: string | null): boolean { - return jsonText(readMetadata(metadata).onboardedAt) !== undefined; -} - -export function markOnboarded(metadata: string | null, at: Date): string { - const current = readMetadata(metadata); - - return JSON.stringify( - jsonText(current.onboardedAt) === undefined - ? { ...current, onboardedAt: at.toISOString() } - : current, - ); -} - -function readMetadata(metadata: string | null): JsonObject { - if (!metadata) return {}; - - try { - const parsed: JsonValue = JSON.parse(metadata); - - return jsonObject(parsed); - } catch { - return {}; - } -} - -export type WorkspaceProfile = { - website: string; - narrative: string; - sections: WorkspaceProfileSections; - sourceUrl: string | null; - refreshedAt: Date; -}; - -export type WorkspaceIdentity = { - name: string; - website: string | null; - profile: WorkspaceProfile | null; +import { organization, workspaceProfile } from "./schema/schema"; +import { + clamp, + MAX_NARRATIVE, + profileOf, + trimSections, + WORKSPACE_ID, + type WorkspaceIdentity, + type WorkspaceProfile, +} from "./workspace-shape"; + +export * from "./workspace-shape"; + +const PROFILE_COLUMNS = { + website: workspaceProfile.website, + narrative: workspaceProfile.narrative, + sections: workspaceProfile.sections, + sourceUrl: workspaceProfile.sourceUrl, + refreshedAt: workspaceProfile.refreshedAt, }; export async function readWorkspaceProfile( - db: Db, + db: Executor, ): Promise { - const row = await db.workspaceProfile.findUnique({ - where: { id: WORKSPACE_ID }, - select: { - website: true, - narrative: true, - sections: true, - sourceUrl: true, - refreshedAt: true, - }, - }); + const [row] = await db + .select(PROFILE_COLUMNS) + .from(workspaceProfile) + .where(eq(workspaceProfile.id, WORKSPACE_ID)) + .limit(1); if (!row) return null; return { ...row, sections: readSections(row.sections) }; } -export function websiteUrl(website: string | null | undefined): string | null { - const trimmed = website?.trim(); - if (!trimmed) return null; - - const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(trimmed)?.[1]; - if (scheme && !/^https?$/i.test(scheme)) return null; - - let url: URL; - try { - url = new URL(scheme ? trimmed : `https://${trimmed}`); - } catch { - return null; - } - - if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(url.hostname)) return null; - - const path = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/, ""); - - return `${url.protocol}//${url.hostname}${path}`; -} - -export function profileOf( - profile: WorkspaceProfile | null, - website: string | null, -): WorkspaceProfile | null { - if (!profile || !website || profile.website !== website) return null; - - return profile; -} - export async function readWorkspaceIdentity( - db: Db, + db: Executor, ): Promise { const [workspace, profile] = await Promise.all([ - db.organization.findUnique({ - where: { id: WORKSPACE_ID }, - select: { name: true, website: true }, - }), + db + .select({ name: organization.name, website: organization.website }) + .from(organization) + .where(eq(organization.id, WORKSPACE_ID)) + .limit(1) + .then((rows) => rows[0]), readWorkspaceProfile(db), ]); @@ -157,7 +64,7 @@ export async function readWorkspaceIdentity( } export async function writeWorkspaceProfile( - db: Db, + db: Executor, input: { website: string; narrative: string; @@ -175,44 +82,15 @@ export async function writeWorkspaceProfile( refreshedAt: new Date(), }; - const row = await db.workspaceProfile.upsert({ - where: { id: WORKSPACE_ID }, - create: { id: WORKSPACE_ID, ...fields }, - update: fields, - select: { - website: true, - narrative: true, - sections: true, - sourceUrl: true, - refreshedAt: true, - }, - }); - - return { ...row, sections: readSections(row.sections) }; -} - -export function trimSections( - sections: WorkspaceProfileSections, -): WorkspaceProfileSections { - const trimmed: WorkspaceProfileSections = {}; - - const sells = clamp(sections.sells, MAX_LINE); - if (sells) trimmed.sells = sells; + const [row] = await db + .insert(workspaceProfile) + .values({ id: WORKSPACE_ID, ...fields }) + .onConflictDoUpdate({ target: workspaceProfile.id, set: fields }) + .returning(PROFILE_COLUMNS); - const sellsTo = clamp(sections.sellsTo, MAX_LINE); - if (sellsTo) trimmed.sellsTo = sellsTo; + if (!row) throw new Error("The workspace profile upsert returned no row."); - const edge = clamp(sections.edge, MAX_LINE); - if (edge) trimmed.edge = edge; - - return trimmed; -} - -function clamp(value: string | undefined, max: number): string | undefined { - const trimmed = value?.trim(); - if (!trimmed) return undefined; - - return trimmed.length <= max ? trimmed : `${trimmed.slice(0, max - 1)}…`; + return { ...row, sections: readSections(row.sections) }; } function readSections(value: JsonValue): WorkspaceProfileSections { diff --git a/packages/db/test/currency.spec.ts b/packages/db/test/currency.spec.ts index de9043ab3..a2dd5f9fc 100644 --- a/packages/db/test/currency.spec.ts +++ b/packages/db/test/currency.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import { Decimal } from "decimal.js"; import type { Db } from "../src/client"; import { CURRENCIES, @@ -9,11 +10,10 @@ import { normalizeCurrency, } from "../src/currency"; import { applyRate, type ResolvedRate, resolveRate } from "../src/fx"; -import { Prisma } from "../src/generated/prisma/client"; -import { RateSource } from "../src/generated/prisma/enums"; +import { RateSource } from "../src/schema/enums"; type Row = { - rate: Prisma.Decimal; + rate: Decimal; asOf: Date; source: RateSource; provider: string | null; @@ -25,12 +25,14 @@ function fakeDb(rows: Row[]): FakeDb { const state = { calls: 0 }; const db = { - exchangeRate: { - findMany: async () => { - state.calls += 1; - return rows; - }, - }, + select: () => ({ + from: () => ({ + where: async () => { + state.calls += 1; + return rows; + }, + }), + }), } as unknown as Db; return { @@ -47,7 +49,7 @@ function row( provider: string | null = null, ): Row { return { - rate: new Prisma.Decimal(rate), + rate: new Decimal(rate), asOf: new Date("2026-08-01T00:00:00.000Z"), source, provider, @@ -165,27 +167,27 @@ describe("resolveRate", () => { describe("applyRate", () => { const rate = (value: string): ResolvedRate => ({ - rate: new Prisma.Decimal(value), + rate: new Decimal(value), asOf: new Date("2026-08-01T00:00:00.000Z"), origin: "FETCHED", provider: null, }); it("converts into the reporting currency", () => { - const converted = applyRate(new Prisma.Decimal(1000), rate("1.09"), "USD"); + const converted = applyRate(new Decimal(1000), rate("1.09"), "USD"); expect(converted.baseAmount.toNumber()).toBe(1090); }); it("rounds to the reporting currency's own decimals, not to two", () => { - const yen = applyRate(new Prisma.Decimal(100), rate("150.555"), "JPY"); + const yen = applyRate(new Decimal(100), rate("150.555"), "JPY"); expect(yen.baseAmount.decimalPlaces()).toBe(0); - const dollars = applyRate(new Prisma.Decimal(1000), rate("0.0067"), "USD"); + const dollars = applyRate(new Decimal(1000), rate("0.0067"), "USD"); expect(dollars.baseAmount.toNumber()).toBe(6.7); }); it("carries the rate and its date onto the record, so a total is reproducible", () => { - const converted = applyRate(new Prisma.Decimal(1), rate("1.23"), "USD"); + const converted = applyRate(new Decimal(1), rate("1.23"), "USD"); expect(converted.fxRate.toNumber()).toBe(1.23); expect(converted.fxRateAt.toISOString()).toBe("2026-08-01T00:00:00.000Z"); expect(converted.origin).toBe("FETCHED"); diff --git a/packages/db/test/fields.spec.ts b/packages/db/test/fields.spec.ts index a5e6b92e4..f2c086b9c 100644 --- a/packages/db/test/fields.spec.ts +++ b/packages/db/test/fields.spec.ts @@ -10,7 +10,7 @@ import { recordColumn, serializeField, } from "../src/fields"; -import type { FieldValueModel } from "../src/generated/prisma/models"; +import type { FieldValue as FieldValueModel } from "../src/schema/models"; function definition( over: Partial = {}, diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json index df18351ab..4d06b4e56 100644 --- a/packages/db/tsconfig.json +++ b/packages/db/tsconfig.json @@ -2,8 +2,8 @@ "$schema": "https://json.schemastore.org/tsconfig", "extends": "@crm/typescript-config/internal-package.json", "compilerOptions": { - "types": ["node"] + "types": ["bun", "node"] }, - "include": ["src/**/*.ts", "prisma/**/*.ts", "prisma.config.ts"], + "include": ["src/**/*.ts", "scripts/**/*.ts", "drizzle.config.ts"], "exclude": ["node_modules"] } diff --git a/packages/db/turbo.json b/packages/db/turbo.json index 76cf20afe..2b3ba2e16 100644 --- a/packages/db/turbo.json +++ b/packages/db/turbo.json @@ -2,31 +2,36 @@ "$schema": "https://turborepo.dev/schema.json", "extends": ["//"], "tasks": { - "build": { - "inputs": ["$TURBO_DEFAULT$", "prisma/**"], - "outputs": ["src/generated/**"], - "passThroughEnv": ["DATABASE_URL"] - }, "dev:prepare": { "cache": false, "inputs": [ - "prisma/schema.prisma", - "prisma/migrations/**", - "prisma.config.ts", + "src/schema/**", + "drizzle/**", + "drizzle.config.ts", "scripts/prepare-dev.ts", + "scripts/migrations.ts", + "scripts/check-drift.ts", "scripts/require-local-db.ts" ], - "outputs": ["src/generated/**"], - "passThroughEnv": ["ALLOW_REMOTE_DB", "DATABASE_URL"] + "passThroughEnv": [ + "ALLOW_REMOTE_DB", + "DATABASE_URL", + "DIRECT_DATABASE_URL", + "POSTGRES_URL_NON_POOLING" + ] }, "db:generate": { - "inputs": ["$TURBO_DEFAULT$", "prisma/**"], - "outputs": ["src/generated/**"], + "cache": false, + "interactive": true, "passThroughEnv": ["DATABASE_URL"] }, "db:deploy": { "cache": false, - "passThroughEnv": ["DATABASE_URL"] + "passThroughEnv": [ + "DATABASE_URL", + "DIRECT_DATABASE_URL", + "POSTGRES_URL_NON_POOLING" + ] }, "db:migrate": { "cache": false, @@ -53,8 +58,11 @@ "passThroughEnv": ["DATABASE_URL"] }, "check-types": { - "dependsOn": ["$TURBO_EXTENDS$", "build"], + "dependsOn": ["$TURBO_EXTENDS$"], "passThroughEnv": ["DATABASE_URL"] + }, + "db:check": { + "inputs": ["src/schema/**", "drizzle/**"] } } } diff --git a/packages/telemetry/src/install.ts b/packages/telemetry/src/install.ts index de2a5a593..6c8540e54 100644 --- a/packages/telemetry/src/install.ts +++ b/packages/telemetry/src/install.ts @@ -1,7 +1,15 @@ import "@crm/env/load"; import { createHash } from "node:crypto"; -import { db } from "@crm/db"; +import { + db, + eq, + gt, + install, + sql, + telemetryCounter, + telemetryMilestone, +} from "@crm/db"; import { crmVersion } from "./version"; export const INSTALL_ID = "install"; @@ -13,12 +21,12 @@ export type Install = { lastRollupAt: Date | null; }; -const SELECT = { - uuid: true, - version: true, - createdAt: true, - lastRollupAt: true, -} as const; +const FIELDS = { + uuid: install.uuid, + version: install.version, + createdAt: install.createdAt, + lastRollupAt: install.lastRollupAt, +}; let cached: Install | null = null; @@ -31,15 +39,16 @@ export async function readInstall(): Promise { if (missingSince && Date.now() - missingSince < MISSING_FOR_MS) return null; try { - const row = await db.install.findUnique({ - where: { id: INSTALL_ID }, - select: SELECT, - }); + const [row] = await db + .select(FIELDS) + .from(install) + .where(eq(install.id, INSTALL_ID)) + .limit(1); if (row) cached = row; missingSince = row ? 0 : Date.now(); - return row; + return row ?? null; } catch { missingSince = Date.now(); return null; @@ -57,11 +66,12 @@ export async function syncVersion( if (!version) return readInstall(); try { - const row = await db.install.update({ - where: { id: INSTALL_ID }, - data: { version }, - select: SELECT, - }); + const [row] = await db + .update(install) + .set({ version }) + .where(eq(install.id, INSTALL_ID)) + .returning(FIELDS); + if (!row) return readInstall(); cached = row; missingSince = 0; @@ -87,12 +97,13 @@ export type Milestone = (typeof MILESTONES)[number]; export async function reachMilestone(step: Milestone): Promise { try { - const { count } = await db.telemetryMilestone.createMany({ - data: [{ step }], - skipDuplicates: true, - }); + const inserted = await db + .insert(telemetryMilestone) + .values({ step }) + .onConflictDoNothing() + .returning({ step: telemetryMilestone.step }); - return count === 1; + return inserted.length === 1; } catch { return false; } @@ -100,15 +111,17 @@ export async function reachMilestone(step: Milestone): Promise { export async function forgetMilestone(step: Milestone): Promise { try { - await db.telemetryMilestone.delete({ where: { step } }); + await db + .delete(telemetryMilestone) + .where(eq(telemetryMilestone.step, step)); } catch {} } export async function reachedMilestones(): Promise { try { - const rows = await db.telemetryMilestone.findMany({ - select: { step: true }, - }); + const rows = await db + .select({ step: telemetryMilestone.step }) + .from(telemetryMilestone); const reached = new Set(rows.map((row) => row.step)); return MILESTONES.filter((step) => reached.has(step)); @@ -123,28 +136,32 @@ export const COUNTERS = { export async function bumpCounter(name: string, by = 1): Promise { try { - await db.telemetryCounter.upsert({ - where: { name }, - create: { name, count: by }, - update: { count: { increment: by } }, - }); + await db + .insert(telemetryCounter) + .values({ name, count: by }) + .onConflictDoUpdate({ + target: telemetryCounter.name, + set: { count: sql`${telemetryCounter.count} + ${by}` }, + }); } catch { try { - await db.telemetryCounter.update({ - where: { name }, - data: { count: { increment: by } }, - }); + await db + .update(telemetryCounter) + .set({ count: sql`${telemetryCounter.count} + ${by}` }) + .where(eq(telemetryCounter.name, name)); } catch {} } } export async function drainCounters(): Promise> { try { - const rows = await db.$queryRaw<{ name: string; count: number }[]>` - DELETE FROM "telemetryCounter" - WHERE "count" > 0 - RETURNING "name", "count"; - `; + const rows = await db + .delete(telemetryCounter) + .where(gt(telemetryCounter.count, 0)) + .returning({ + name: telemetryCounter.name, + count: telemetryCounter.count, + }); return Object.fromEntries(rows.map((row) => [row.name, row.count])); } catch { @@ -171,13 +188,12 @@ export async function claimRollup( force = false, ): Promise { try { - return await db.$transaction(async (tx): Promise => { - const locked = await tx.$queryRaw<{ lastRollupAt: Date | null }[]>` - SELECT "lastRollupAt" - FROM "install" - WHERE "id" = ${INSTALL_ID} - FOR UPDATE; - `; + return await db.transaction(async (tx): Promise => { + const locked = await tx + .select({ lastRollupAt: install.lastRollupAt }) + .from(install) + .where(eq(install.id, INSTALL_ID)) + .for("update"); if (locked.length === 0) { return { claimed: false, reason: "no install row" }; @@ -188,11 +204,12 @@ export async function claimRollup( return { claimed: false, reason: "already sent today" }; } - cached = await tx.install.update({ - where: { id: INSTALL_ID }, - data: { lastRollupAt: at }, - select: SELECT, - }); + const [row] = await tx + .update(install) + .set({ lastRollupAt: at }) + .where(eq(install.id, INSTALL_ID)) + .returning(FIELDS); + cached = row ?? null; return { claimed: true, previous }; }); @@ -203,11 +220,12 @@ export async function claimRollup( export async function releaseRollup(previous: Date | null): Promise { try { - cached = await db.install.update({ - where: { id: INSTALL_ID }, - data: { lastRollupAt: previous }, - select: SELECT, - }); + const [row] = await db + .update(install) + .set({ lastRollupAt: previous }) + .where(eq(install.id, INSTALL_ID)) + .returning(FIELDS); + cached = row ?? null; } catch {} } diff --git a/packages/telemetry/test/client.integration.spec.ts b/packages/telemetry/test/client.integration.spec.ts index 3693287e0..347fab082 100644 --- a/packages/telemetry/test/client.integration.spec.ts +++ b/packages/telemetry/test/client.integration.spec.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { db } from "@crm/db"; +import { db, eq, telemetryMilestone } from "@crm/db"; import { type JsonObject, type JsonValue, @@ -10,6 +10,7 @@ import { captureNow, resetTelemetryClient } from "../src/client"; import { milestone } from "../src/events"; import { forgetInstall, readInstall, stableUuid } from "../src/install"; import { POSTHOG_HOST } from "../src/project"; +import { first, one } from "./rows"; const real = { fetch: globalThis.fetch, @@ -175,18 +176,31 @@ describe("milestone", () => { let existing: { step: string; reachedAt: Date } | null = null; beforeEach(async () => { - existing = await db.telemetryMilestone.findUnique({ where: { step } }); - await db.telemetryMilestone.deleteMany({ where: { step } }); + existing = await first( + db + .select() + .from(telemetryMilestone) + .where(eq(telemetryMilestone.step, step)) + .limit(1), + ); + await db + .delete(telemetryMilestone) + .where(eq(telemetryMilestone.step, step)); }); afterEach(async () => { - await db.telemetryMilestone.deleteMany({ where: { step } }); - if (existing) await db.telemetryMilestone.create({ data: existing }); + await db + .delete(telemetryMilestone) + .where(eq(telemetryMilestone.step, step)); + if (existing) + await one(db.insert(telemetryMilestone).values(existing).returning()); }); it("records the step once the event has left", async () => { expect(await milestone(step)).toBe(true); - expect(await db.telemetryMilestone.count({ where: { step } })).toBe(1); + expect( + await db.$count(telemetryMilestone, eq(telemetryMilestone.step, step)), + ).toBe(1); }); it("leaves the step unreached when the send fails", async () => { @@ -195,7 +209,9 @@ describe("milestone", () => { }) as typeof fetch; expect(await milestone(step)).toBe(false); - expect(await db.telemetryMilestone.count({ where: { step } })).toBe(0); + expect( + await db.$count(telemetryMilestone, eq(telemetryMilestone.step, step)), + ).toBe(0); }); it("sends the step again on the attempt after a failed send", async () => { @@ -210,7 +226,9 @@ describe("milestone", () => { expect(await milestone(step)).toBe(true); expect(eventOf(calls[0]?.body).event).toBe(step); - expect(await db.telemetryMilestone.count({ where: { step } })).toBe(1); + expect( + await db.$count(telemetryMilestone, eq(telemetryMilestone.step, step)), + ).toBe(1); }); it("sends once when two processes sweep at the same instant", async () => { @@ -221,7 +239,9 @@ describe("milestone", () => { expect([first, second].filter(Boolean).length).toBe(1); expect(sentSteps()).toEqual([step]); - expect(await db.telemetryMilestone.count({ where: { step } })).toBe(1); + expect( + await db.$count(telemetryMilestone, eq(telemetryMilestone.step, step)), + ).toBe(1); }); it("carries an id that stays the same across a resent step", async () => { @@ -245,7 +265,9 @@ describe("milestone", () => { resetTelemetryClient(); expect(await milestone(step)).toBe(false); - expect(await db.telemetryMilestone.count({ where: { step } })).toBe(0); + expect( + await db.$count(telemetryMilestone, eq(telemetryMilestone.step, step)), + ).toBe(0); expect(calls.length).toBe(0); }); }); diff --git a/packages/telemetry/test/install.integration.spec.ts b/packages/telemetry/test/install.integration.spec.ts index 75f63b40f..501dac634 100644 --- a/packages/telemetry/test/install.integration.spec.ts +++ b/packages/telemetry/test/install.integration.spec.ts @@ -1,6 +1,12 @@ import { afterEach, describe, expect, it } from "bun:test"; import { join } from "node:path"; -import { db } from "@crm/db"; +import { + db, + eq, + install as installTable, + telemetryCounter, + telemetryMilestone, +} from "@crm/db"; import { bumpCounter, drainCounters, @@ -18,7 +24,7 @@ const PRINT_UUID = [ 'import { readInstall } from "./src/install";', "const install = await readInstall();", 'process.stdout.write(install?.uuid ?? "none");', - "await db.$disconnect();", + "await db.$client.end();", "process.exit(0);", ].join(" "); @@ -41,7 +47,7 @@ async function uuidFromNewProcess(): Promise { describe("the install row", () => { it("is a single row the migration already wrote", async () => { - const rows = await db.install.findMany({ select: { id: true } }); + const rows = await db.select({ id: installTable.id }).from(installTable); expect(rows).toEqual([{ id: INSTALL_ID }]); }); @@ -81,11 +87,15 @@ describe("milestones", () => { const step = "first_fact_applied" as const; afterEach(async () => { - await db.telemetryMilestone.deleteMany({ where: { step } }); + await db + .delete(telemetryMilestone) + .where(eq(telemetryMilestone.step, step)); }); it("reports the first arrival and nothing after it", async () => { - await db.telemetryMilestone.deleteMany({ where: { step } }); + await db + .delete(telemetryMilestone) + .where(eq(telemetryMilestone.step, step)); expect(await reachMilestone(step)).toBe(true); expect(await reachMilestone(step)).toBe(false); @@ -97,11 +107,11 @@ describe("counters", () => { const name = "budget_exhausted"; afterEach(async () => { - await db.telemetryCounter.deleteMany({ where: { name } }); + await db.delete(telemetryCounter).where(eq(telemetryCounter.name, name)); }); it("adds up and then drains to nothing", async () => { - await db.telemetryCounter.deleteMany({ where: { name } }); + await db.delete(telemetryCounter).where(eq(telemetryCounter.name, name)); await bumpCounter(name); await bumpCounter(name); @@ -112,7 +122,7 @@ describe("counters", () => { }); it("puts back what a rollup could not send", async () => { - await db.telemetryCounter.deleteMany({ where: { name } }); + await db.delete(telemetryCounter).where(eq(telemetryCounter.name, name)); await bumpCounter(name, 2); const drained = await drainCounters(); diff --git a/packages/telemetry/test/rows.ts b/packages/telemetry/test/rows.ts new file mode 100644 index 000000000..484eaab14 --- /dev/null +++ b/packages/telemetry/test/rows.ts @@ -0,0 +1,12 @@ +export async function one(rows: PromiseLike): Promise { + const [row] = await rows; + if (row === undefined) throw new Error("The query returned no row."); + return row; +} + +export async function first( + rows: PromiseLike, +): Promise { + const [row] = await rows; + return row ?? null; +} diff --git a/skills-lock.json b/skills-lock.json index 7a4093828..041b452ae 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -97,12 +97,6 @@ "skillPath": "skills/posthog-instrumentation/SKILL.md", "computedHash": "a9a30a63accd756ef41009f987ac75812ab1e855b30ea5d6153e0fed16ebf22d" }, - "prisma-database-setup": { - "source": "prisma/skills", - "sourceType": "github", - "skillPath": "prisma-database-setup/SKILL.md", - "computedHash": "6a991a2530a6da7131e3fc71c353e8278702d0146b8b6e1d2fdfc2d3d0a21bd2" - }, "seo-audit": { "source": "coreyhaines31/marketingskills", "sourceType": "github", diff --git a/turbo.json b/turbo.json index b271bf13b..69a0b4202 100644 --- a/turbo.json +++ b/turbo.json @@ -22,7 +22,7 @@ "REDIS_URL", "CACHE_TTL_MS", "PORT", - "PRISMA_LOG_QUERIES", + "DB_LOG_QUERIES", "PERPLEXITY_API_KEY", "GITHUB_TOKEN", "BLOB_READ_WRITE_TOKEN",