diff --git a/adrs/zayn-scales-agency-crm.md b/adrs/zayn-scales-agency-crm.md new file mode 100644 index 000000000..c2f0ac7fc --- /dev/null +++ b/adrs/zayn-scales-agency-crm.md @@ -0,0 +1,145 @@ +# Zayn Scales agency CRM — first pass + +Fork target: an internal-only, agency-workflow CRM in the spirit of +GoHighLevel, built on top of the existing agent-first Comp AI CRM. + +## What shipped + +### New Prisma models (migration `20260808120000_zayn_scales_agency`) + +- **ClientAccount** — first-class agency-client entity. Every existing + record type gains an optional `clientAccountId` so contacts, companies, + deals, forms and workflows can be filtered by which agency client they + belong to, without breaking the singleton-workspace model. +- **SmsThread / SmsMessage** — unified inbound + outbound SMS with + Twilio. Threads keyed on `(ourNumber, theirNumber)`; unread count on + the thread; every send/receive optionally files an `SMS` activity. +- **FormDefinition / FormField / FormSubmission** — lead-capture forms + with a public submit endpoint that finds-or-creates a contact and + files a `FORM_SUBMISSION` activity. +- **WorkflowDefinition / WorkflowRun** — trigger + step-list automation + scaffold. Trigger kinds: `CONTACT_CREATED`, `DEAL_STAGE_CHANGED`, + `FORM_SUBMITTED`, `SMS_RECEIVED`, `SCHEDULE`, `MANUAL`. Steps are a + JSON list (send_sms, send_email, add_tag, wait, agent_task, + notify_slack). The runner is stubbed — rows enqueue but a worker + hasn't been wired up yet (see "Not done" below). +- **BookingLink / Booking** — booking-link scaffold, no UI yet. +- Extended `ActivityType` with `SMS`, `FORM_SUBMISSION`, `WORKFLOW`. +- Added `Deal.boardOrder` (int) and `Deal.tags` (string[]) to support + Kanban. +- Added `Contact.tags` and `Company.tags`. + +### New API modules (Nest + tRPC) + +- `client-accounts/` — `list`, `byId`, `create`, `update`, `delete`, + `options`. Adds facet counts for status, includes open-deal count per + client. +- `sms/` — `list`, `thread`, `send`, `markRead` + a public + `POST /internal/sms/twilio/inbound` webhook with X-Twilio-Signature + HMAC-SHA1 verification. `TwilioClient` is capability-off when unset. +- `forms/` — `list`, `byId`, `create`, `update`, `delete`, `submissions` + + a public `GET/POST /public/forms/:slug` for the embedded form + runtime. +- `workflows/` — `list`, `byId`, `create`, `update`, `delete`, `runNow`. +- `deals/` — new `board` query and `reorder` mutation for Kanban + drag-and-drop. + +Every router follows the existing pattern (thin router, service does +work, contracts in a separate file, all gated by `AuthMiddleware` except +the two intentionally-public controllers). + +### New app pages + +- `/clients` — card grid with status tabs (Active / Onboarding / Paused + / Churned), search, KPIs per client. `/clients/[clientId]` detail with + quick-link cards to the client's deals, contacts and workflows. +- `/deals?view=board` — Kanban board with native drag-drop between + stage columns. Reorder writes back through `deals.reorder`, which + cascades to `deals.setStage` when the stage changes. +- `/inbox` — two-column SMS conversation UI. Reply composer with + Cmd/Ctrl-Enter to send. Unread count filter. Falls back to a helpful + message when Twilio isn't configured. +- `/forms` — card grid, copy-public-URL, publish / unpublish, delete. + New-form sheet with an inline field editor. +- `/workflows` — card grid with status + trigger + step count + run + count. New-workflow sheet with a step-list editor (SMS / email / tag + / wait / agent task). +- Overview dashboard adds an agency KPI strip on top: Active clients, + Unread inbox, Live workflows, Published forms — each a clickable + card that navigates into that section. + +Sidebar rail extended with Clients, Inbox, Forms, Workflows. + +### Env additions + +- `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_FROM_NUMBER`, + `TWILIO_MESSAGING_SERVICE_SID` — all optional, all declared in + `env.validation.ts`. + +## Design decisions worth recording + +- **Sub-accounts are `ClientAccount`, not a tenancy plugin.** The + existing "singleton workspace" model is preserved. `ClientAccount` + groups existing records by which agency client they belong to. No + `WORKSPACE_ID` per client, no per-client auth. This keeps the + intelligence rules from `docs/api.md` intact. +- **Twilio client is capability-off, never throws.** Follows the + existing `capabilities.ts` pattern from the agent — if the keys + aren't set, the SMS pages render a "not configured" state and the + send mutation returns a 503. +- **Public form submits don't require auth.** The `FormsController` + uses `@AllowAnonymous()` and IP + user-agent are captured. +- **The workflow runner is a scaffold.** The `WorkflowRun` model, + enqueue path, and step schema are in place, but no worker loop + processes queued runs yet. This is the natural next thing to build — + the pattern to follow is `apps/agent/agent/schedules/dispatch.ts`. +- **Kanban uses native HTML5 drag-drop.** No dnd-kit dependency added; + the six existing stages are enough columns to not need virtualization. +- **All new UI uses `packages/ui` primitives.** No inline shadcn + overrides. `EntityLogo`/`PersonAvatar` weren't reused for client + logos because those URLs aren't in the `next/image` allowlist — + `` with a biome-ignore is used instead. Adding client-logo hosts + to `next.config.ts` is the follow-up if we want optimization. +- **tRPC codegen is committed.** The generated `apps/api/src/generated/server.ts` + was regenerated locally with `bun run --filter=api trpc:generate` + after each router change; it must not be regenerated at Vercel build + time (GLIBC version issue documented in `docs/api.md`). + +## Not done — the honest list + +- **No workflow worker.** `WorkflowRun` rows queue but nothing + processes them. Pattern: extend `apps/agent/agent/schedules/dispatch.ts` + or add a fourth Nest cron controller that leases `QUEUED` rows, + interprets the step list, and applies actions. +- **No form-submit → workflow trigger.** `formDefinition.workflowIdOnSubmit` + is stored but not fired. +- **Forms have no edit UI yet** — you can create/publish/delete but not + edit fields after creation. `forms.update` handles it on the API + side. +- **Booking links** — model exists, no UI. The pattern to follow is + the existing `google/calendar` sync. +- **No agent tools yet for SMS / forms / workflows.** The agent could + send SMS, look at inbox threads and trigger workflows — those are + new tools in `apps/agent/agent/tools/`. +- **Twilio webhook signature URL** — uses `API_URL` env var; if the + webhook lands via a different hostname (e.g. a Twilio-specific + Cloudflare Tunnel), the signature check will fail. Document this at + deploy time. +- **Kanban drop always appends to end of column.** Drop-between-cards + and same-column reorder aren't implemented; the `boardOrder` column + is set to `col.deals.length` on drop. + +## Verified + +- `bun run --filter=api check-types` — passes +- `bun run --filter=app check-types` — passes +- `bun run --filter=api lint` — passes (4 warnings, all pre-existing) +- `bun run --filter=app lint` — passes (1 warning, pre-existing) +- `bun run --filter=app test` — 119 pass, 0 fail +- `bun run --filter=api test` — DB-dependent tests fail in this + container (no Postgres running); non-DB tests pass. Same as before + this branch. + +Migration was written by hand and not applied against a live DB in +this session. `bun run db:migrate` on a real Postgres is the next step +before running the agent or app against real data. diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 723ff44f0..d2763930d 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -7,6 +7,7 @@ import { AgentModule } from "./agent/agent.module"; import { AuthModule } from "./auth/auth.module"; import { BackfillModule } from "./backfill/backfill.module"; import { AppCacheModule } from "./cache/cache.module"; +import { ClientAccountsModule } from "./client-accounts/client-accounts.module"; import { CompaniesModule } from "./companies/companies.module"; import { validateEnv } from "./config/env.validation"; import { ContactsModule } from "./contacts/contacts.module"; @@ -17,6 +18,7 @@ import { DashboardModule } from "./dashboard/dashboard.module"; import { DatabaseModule } from "./database/database.module"; import { DealsModule } from "./deals/deals.module"; import { FieldsModule } from "./fields/fields.module"; +import { FormsModule } from "./forms/forms.module"; import { GoogleModule } from "./google/google.module"; import { HealthModule } from "./health/health.module"; import { LoggingModule } from "./logging/logging.module"; @@ -25,11 +27,13 @@ import { MailboxModule } from "./mailbox/mailbox.module"; import { MicrosoftModule } from "./microsoft/microsoft.module"; import { SearchModule } from "./search/search.module"; import { SettingsModule } from "./settings/settings.module"; +import { SmsModule } from "./sms/sms.module"; import { SsoModule } from "./sso/sso.module"; import { SyncModule } from "./sync/sync.module"; import { TelemetryModule } from "./telemetry/telemetry.module"; import { TrpcModule } from "./trpc/trpc.module"; import { UsersModule } from "./users/users.module"; +import { WorkflowsModule } from "./workflows/workflows.module"; import { WorkspaceModule } from "./workspace/workspace.module"; @Module({ @@ -67,6 +71,10 @@ import { WorkspaceModule } from "./workspace/workspace.module"; SsoModule, BackfillModule, TelemetryModule, + ClientAccountsModule, + SmsModule, + FormsModule, + WorkflowsModule, ], }) export class AppModule {} diff --git a/apps/api/src/client-accounts/client-accounts.contracts.ts b/apps/api/src/client-accounts/client-accounts.contracts.ts new file mode 100644 index 000000000..68a874166 --- /dev/null +++ b/apps/api/src/client-accounts/client-accounts.contracts.ts @@ -0,0 +1,49 @@ +import { ClientAccountStatus } from "@crm/db"; +import { z } from "zod"; +import { listInput } from "../trpc/list-input"; + +const statusEnum = z.enum( + Object.values(ClientAccountStatus) as [ + ClientAccountStatus, + ...ClientAccountStatus[], + ], +); + +export const clientAccountListInput = listInput.extend({ + status: z.string().default("all"), +}); + +export type ClientAccountListInput = z.infer; + +export const clientAccountCreateInput = z.object({ + name: z.string().trim().min(1, "A client needs a name."), + slug: z + .string() + .trim() + .min(1) + .regex(/^[a-z0-9-]+$/i, "Lowercase letters, numbers and dashes only.") + .optional(), + status: statusEnum.optional(), + logoUrl: z.string().url().nullable().optional(), + brandColor: z.string().nullable().optional(), + website: z.string().url().nullable().optional(), + industry: z.string().nullable().optional(), + timezone: z.string().nullable().optional(), + monthlyRetainerCents: z.number().int().nonnegative().nullable().optional(), + currency: z.string().length(3).optional(), + tags: z.array(z.string()).optional(), + notes: z.string().nullable().optional(), +}); + +export type ClientAccountCreateInput = z.infer; + +const clientAccountUpdateInput = clientAccountCreateInput.partial(); + +export const clientAccountUpdateArgs = z.object({ + id: z.string(), + data: clientAccountUpdateInput, +}); + +export const clientAccountIdInput = z.object({ id: z.string() }); + +export const clientAccountStatsInput = z.object({ id: z.string() }); diff --git a/apps/api/src/client-accounts/client-accounts.module.ts b/apps/api/src/client-accounts/client-accounts.module.ts new file mode 100644 index 000000000..385382533 --- /dev/null +++ b/apps/api/src/client-accounts/client-accounts.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { TrpcModule } from "../trpc/trpc.module"; +import { ClientAccountsRouter } from "./client-accounts.router"; +import { ClientAccountsService } from "./client-accounts.service"; + +@Module({ + imports: [TrpcModule], + providers: [ClientAccountsService, ClientAccountsRouter], + exports: [ClientAccountsService], +}) +export class ClientAccountsModule {} diff --git a/apps/api/src/client-accounts/client-accounts.router.ts b/apps/api/src/client-accounts/client-accounts.router.ts new file mode 100644 index 000000000..9841814fd --- /dev/null +++ b/apps/api/src/client-accounts/client-accounts.router.ts @@ -0,0 +1,50 @@ +import { Inject } from "@nestjs/common"; +import { Input, Mutation, Query, Router, UseMiddlewares } from "nestjs-trpc"; +import type { z } from "zod"; +import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; +import { + clientAccountCreateInput, + clientAccountIdInput, + clientAccountListInput, + clientAccountUpdateArgs, +} from "./client-accounts.contracts"; +import { ClientAccountsService } from "./client-accounts.service"; + +@Router({ alias: "clientAccounts" }) +@UseMiddlewares(AuthMiddleware) +export class ClientAccountsRouter { + constructor( + @Inject(ClientAccountsService) + private readonly clients: ClientAccountsService, + ) {} + + @Query({ input: clientAccountListInput }) + async list(@Input() input: z.infer) { + return this.clients.list(input); + } + + @Query({ input: clientAccountIdInput }) + async byId(@Input("id") id: string) { + return this.clients.byId(id); + } + + @Query() + async options() { + return this.clients.options(); + } + + @Mutation({ input: clientAccountCreateInput }) + async create(@Input() input: z.infer) { + return this.clients.create(input); + } + + @Mutation({ input: clientAccountUpdateArgs }) + async update(@Input() input: z.infer) { + return this.clients.update(input.id, input.data); + } + + @Mutation({ input: clientAccountIdInput }) + async delete(@Input("id") id: string) { + return this.clients.delete(id); + } +} diff --git a/apps/api/src/client-accounts/client-accounts.service.ts b/apps/api/src/client-accounts/client-accounts.service.ts new file mode 100644 index 000000000..5d317ae19 --- /dev/null +++ b/apps/api/src/client-accounts/client-accounts.service.ts @@ -0,0 +1,336 @@ +import { + ClientAccountStatus, + type Db, + type Prisma, + Prisma as PrismaNamespace, +} from "@crm/db"; +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { blankToNull } from "../crm/values"; +import { InjectDatabase } from "../database/database.constants"; +import { + FACET_ALL, + type ListResult, + paginate, + resolveOrderBy, +} from "../trpc/list-input"; +import type { + ClientAccountCreateInput, + ClientAccountListInput, +} from "./client-accounts.contracts"; + +function optional(value: string | null | undefined): string | null { + if (value === null || value === undefined) return null; + return blankToNull(value); +} + +export type ClientAccountRow = { + id: string; + name: string; + slug: string; + status: ClientAccountStatus; + logoUrl: string | null; + brandColor: string | null; + website: string | null; + industry: string | null; + monthlyRetainerCents: string | null; + currency: string; + tags: string[]; + companyCount: number; + contactCount: number; + openDealCount: number; + createdAt: string; + updatedAt: string; +}; + +const SORTABLE: Record< + string, + (dir: Prisma.SortOrder) => Prisma.ClientAccountOrderByWithRelationInput[] +> = { + name: (dir) => [{ name: dir }], + status: (dir) => [{ status: dir }, { name: "asc" }], + createdAt: (dir) => [{ createdAt: dir }], + updatedAt: (dir) => [{ updatedAt: dir }], +}; + +const SLUG_RESERVED = new Set([ + "api", + "app", + "admin", + "settings", + "sign-in", + "onboarding", + "eve", + "agents", +]); + +@Injectable() +export class ClientAccountsService { + private readonly logger = new Logger(ClientAccountsService.name); + + constructor(@InjectDatabase() private readonly db: Db) {} + + async list( + input: ClientAccountListInput, + ): Promise> { + const where = this.buildWhere(input); + const { skip, take } = paginate(input); + const orderBy = resolveOrderBy(input, SORTABLE, [{ name: "asc" }]); + + const [rows, total, statusGroups] = await Promise.all([ + this.db.clientAccount.findMany({ + where, + orderBy, + skip, + take, + include: { + _count: { + select: { + companies: true, + contacts: true, + deals: true, + }, + }, + }, + }), + this.db.clientAccount.count({ where }), + this.db.clientAccount.groupBy({ + by: ["status"], + _count: { _all: true }, + }), + ]); + + const openDealCounts = await this.db.deal.groupBy({ + by: ["clientAccountId"], + where: { + clientAccountId: { in: rows.map((r) => r.id) }, + stage: { + in: [ + "DEMO_BOOKED", + "QUALIFIED_TO_BUY", + "DECISION_MAKER_BOUGHT_IN", + "CONTRACT_SENT", + ], + }, + }, + _count: { _all: true }, + }); + const openDealByClient: Record = {}; + for (const row of openDealCounts) { + if (row.clientAccountId) + openDealByClient[row.clientAccountId] = row._count._all; + } + + const statusFacet: Record = {}; + for (const g of statusGroups) { + statusFacet[g.status] = g._count._all; + } + const facetCounts: Record> = { + status: statusFacet, + }; + + return { + rows: rows.map((row) => this.toRow(row, openDealByClient[row.id] ?? 0)), + total, + facetCounts, + }; + } + + async byId(id: string) { + const row = await this.db.clientAccount.findUnique({ + where: { id }, + include: { + _count: { + select: { + companies: true, + contacts: true, + deals: true, + forms: true, + workflows: true, + }, + }, + }, + }); + if (!row) throw new NotFoundException("Client not found"); + return { + ...this.toRow(row as unknown as Parameters[0], 0), + notes: row.notes, + timezone: row.timezone, + startedAt: row.startedAt?.toISOString() ?? null, + churnedAt: row.churnedAt?.toISOString() ?? null, + counts: row._count, + }; + } + + async create(input: ClientAccountCreateInput) { + const slug = await this.uniqueSlug(input.slug ?? input.name); + return this.db.clientAccount.create({ + data: { + name: input.name, + slug, + status: input.status ?? ClientAccountStatus.ACTIVE, + logoUrl: optional(input.logoUrl), + brandColor: optional(input.brandColor), + website: optional(input.website), + industry: optional(input.industry), + timezone: optional(input.timezone), + monthlyRetainerCents: input.monthlyRetainerCents ?? null, + currency: (input.currency ?? "USD").toUpperCase(), + tags: input.tags ?? [], + notes: optional(input.notes), + }, + }); + } + + async update(id: string, input: Partial) { + const existing = await this.db.clientAccount.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException("Client not found"); + const data: Prisma.ClientAccountUpdateInput = {}; + if (input.name !== undefined) data.name = input.name; + if (input.slug !== undefined && input.slug !== existing.slug) { + data.slug = await this.uniqueSlug(input.slug, id); + } + if (input.status !== undefined) data.status = input.status; + if (input.logoUrl !== undefined) data.logoUrl = optional(input.logoUrl); + if (input.brandColor !== undefined) + data.brandColor = optional(input.brandColor); + if (input.website !== undefined) data.website = optional(input.website); + if (input.industry !== undefined) data.industry = optional(input.industry); + if (input.timezone !== undefined) data.timezone = optional(input.timezone); + if (input.monthlyRetainerCents !== undefined) + data.monthlyRetainerCents = input.monthlyRetainerCents; + if (input.currency !== undefined) + data.currency = input.currency.toUpperCase(); + if (input.tags !== undefined) data.tags = input.tags; + if (input.notes !== undefined) data.notes = optional(input.notes); + if (input.status === ClientAccountStatus.CHURNED && !existing.churnedAt) { + data.churnedAt = new Date(); + } + if (input.status === ClientAccountStatus.ACTIVE && !existing.startedAt) { + data.startedAt = new Date(); + } + return this.db.clientAccount.update({ where: { id }, data }); + } + + async delete(id: string) { + try { + await this.db.clientAccount.delete({ where: { id } }); + return { id }; + } catch (err) { + if ( + err instanceof PrismaNamespace.PrismaClientKnownRequestError && + err.code === "P2025" + ) { + throw new NotFoundException("Client not found"); + } + throw err; + } + } + + async options() { + return this.db.clientAccount.findMany({ + where: { status: { not: ClientAccountStatus.CHURNED } }, + orderBy: { name: "asc" }, + select: { + id: true, + name: true, + logoUrl: true, + brandColor: true, + status: true, + }, + }); + } + + private toRow( + row: { + id: string; + name: string; + slug: string; + status: ClientAccountStatus; + logoUrl: string | null; + brandColor: string | null; + website: string | null; + industry: string | null; + monthlyRetainerCents: bigint | null; + currency: string; + tags: string[]; + createdAt: Date; + updatedAt: Date; + _count: { companies: number; contacts: number; deals: number }; + }, + openDealCount: number, + ): ClientAccountRow { + return { + id: row.id, + name: row.name, + slug: row.slug, + status: row.status, + logoUrl: row.logoUrl, + brandColor: row.brandColor, + website: row.website, + industry: row.industry, + monthlyRetainerCents: + row.monthlyRetainerCents === null + ? null + : row.monthlyRetainerCents.toString(), + currency: row.currency, + tags: row.tags, + companyCount: row._count.companies, + contactCount: row._count.contacts, + openDealCount, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + } + + private buildWhere( + input: ClientAccountListInput, + ): Prisma.ClientAccountWhereInput { + const clauses: Prisma.ClientAccountWhereInput[] = []; + if (input.q.trim()) { + clauses.push({ + OR: [ + { name: { contains: input.q, mode: "insensitive" } }, + { slug: { contains: input.q, mode: "insensitive" } }, + { industry: { contains: input.q, mode: "insensitive" } }, + ], + }); + } + if (input.status !== FACET_ALL) { + const enumValues = Object.values(ClientAccountStatus) as string[]; + if (enumValues.includes(input.status)) { + clauses.push({ status: input.status as ClientAccountStatus }); + } + } + return clauses.length ? { AND: clauses } : {}; + } + + private async uniqueSlug(candidate: string, excludeId?: string) { + let base = candidate + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + if (!base) base = "client"; + if (SLUG_RESERVED.has(base)) base = `${base}-1`; + let slug = base; + let n = 1; + while (true) { + const clash = await this.db.clientAccount.findFirst({ + where: { + slug, + id: excludeId ? { not: excludeId } : undefined, + }, + select: { id: true }, + }); + if (!clash) return slug; + n += 1; + slug = `${base}-${n}`; + if (n > 200) throw new BadRequestException("Could not derive slug"); + } + } +} diff --git a/apps/api/src/config/env.validation.ts b/apps/api/src/config/env.validation.ts index 06c76c4bc..5e4943806 100644 --- a/apps/api/src/config/env.validation.ts +++ b/apps/api/src/config/env.validation.ts @@ -118,6 +118,22 @@ export class EnvironmentVariables { @IsOptional() @IsString() CRM_TELEMETRY_DISABLED?: string; + + @IsOptional() + @IsString() + TWILIO_ACCOUNT_SID?: string; + + @IsOptional() + @IsString() + TWILIO_AUTH_TOKEN?: string; + + @IsOptional() + @IsString() + TWILIO_FROM_NUMBER?: string; + + @IsOptional() + @IsString() + TWILIO_MESSAGING_SERVICE_SID?: string; } export function validateEnv( diff --git a/apps/api/src/deals/deals.contracts.ts b/apps/api/src/deals/deals.contracts.ts index ef8ee4326..77ff76fca 100644 --- a/apps/api/src/deals/deals.contracts.ts +++ b/apps/api/src/deals/deals.contracts.ts @@ -70,6 +70,21 @@ export const dealUpdateArgs = z.object({ export const dealIdInput = z.object({ id: z.string() }); +export const dealBoardInput = z.object({ + owner: z.string().default("all"), + clientAccountId: z.string().default("all"), +}); + +export type DealBoardInput = z.infer; + +export const dealReorderInput = z.object({ + id: z.string(), + stage: stageEnum, + orderInStage: z.number().int().min(0), +}); + +export type DealReorderInput = z.infer; + export const setStageInput = z.object({ id: z.string(), stage: stageEnum, diff --git a/apps/api/src/deals/deals.router.ts b/apps/api/src/deals/deals.router.ts index ccb3f8504..94d36b849 100644 --- a/apps/api/src/deals/deals.router.ts +++ b/apps/api/src/deals/deals.router.ts @@ -12,6 +12,7 @@ import type { AuthedTrpcContext } from "../trpc/context.types"; import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; import { dealAttachContactInput, + dealBoardInput, dealBulkInput, dealBulkOwnerInput, dealBulkStageInput, @@ -21,6 +22,7 @@ import { dealDetachContactInput, dealIdInput, dealListInput, + dealReorderInput, dealUpdateArgs, setStageInput, } from "./deals.contracts"; @@ -36,6 +38,19 @@ export class DealsRouter { return this.deals.list(input); } + @Query({ input: dealBoardInput }) + async board(@Input() input: z.infer) { + return this.deals.board(input); + } + + @Mutation({ input: dealReorderInput }) + async reorder( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.deals.reorder(input, ctx.user.id); + } + @Query({ input: dealIdInput }) async byId(@Input("id") id: string) { return this.deals.byId(id); diff --git a/apps/api/src/deals/deals.service.ts b/apps/api/src/deals/deals.service.ts index 2dbcc5a38..e9df58709 100644 --- a/apps/api/src/deals/deals.service.ts +++ b/apps/api/src/deals/deals.service.ts @@ -37,18 +37,21 @@ import { FACET_ALL, FACET_UNASSIGNED, type ListResult, + ownerFilter, paginate, resolveOrderBy, } from "../trpc/list-input"; import type { ClosingWindow, DealAttachContactInput, + DealBoardInput, DealBulkOwnerInput, DealBulkStageInput, DealContactRoleInput, DealCreateInput, DealDetachContactInput, DealListInput, + DealReorderInput, DealUpdateInput, SetStageInput, } from "./deals.contracts"; @@ -362,6 +365,111 @@ export class DealsService { return { id, name: deleted.name }; } + async board(input: DealBoardInput) { + const where: Prisma.DealWhereInput = {}; + if (input.owner !== FACET_ALL) { + const f = ownerFilter(input.owner); + if (f) Object.assign(where, f); + } + if (input.clientAccountId !== FACET_ALL) { + where.clientAccountId = input.clientAccountId; + } + const rows = await this.db.deal.findMany({ + where, + orderBy: [{ stage: "asc" }, { boardOrder: "asc" }, { createdAt: "desc" }], + select: { + id: true, + name: true, + stage: true, + boardOrder: true, + amount: true, + currency: true, + baseAmount: true, + expectedCloseDate: true, + company: { select: COMPANY_SELECT }, + owner: { select: OWNER_SELECT }, + tags: true, + clientAccountId: true, + }, + }); + const base = await this.conversion.reportingCurrency(); + const columns: Record< + string, + { + stage: string; + total: number; + valueCents: number | null; + deals: Array<{ + id: string; + name: string; + stage: string; + amountCents: number | null; + baseAmountCents: number | null; + currency: string; + company: (typeof rows)[number]["company"]; + owner: (typeof rows)[number]["owner"]; + expectedCloseDate: string | null; + tags: string[]; + }>; + } + > = {}; + for (const row of rows) { + const col = columns[row.stage] ?? { + stage: row.stage, + total: 0, + valueCents: 0, + deals: [], + }; + col.total += 1; + const bc = toCents(row.baseAmount); + if (bc !== null) col.valueCents = (col.valueCents ?? 0) + bc; + col.deals.push({ + id: row.id, + name: row.name, + stage: row.stage, + amountCents: toCents(row.amount), + baseAmountCents: toCents(row.baseAmount), + currency: row.currency, + company: row.company, + owner: row.owner, + expectedCloseDate: row.expectedCloseDate?.toISOString() ?? null, + tags: row.tags, + }); + columns[row.stage] = col; + } + return { + columns, + reportingCurrency: base, + }; + } + + async reorder(input: DealReorderInput, actingUserId: string) { + const deal = await this.db.deal.findUnique({ + where: { id: input.id }, + select: { id: true, stage: true, boardOrder: true }, + }); + if (!deal) throw new NotFoundException(`No deal with id ${input.id}.`); + if (deal.stage !== input.stage) { + await this.setStage( + { + id: input.id, + stage: input.stage, + closedReason: LOSING.has(input.stage) ? "moved on board" : undefined, + }, + actingUserId, + ); + } + await this.db.deal.update({ + where: { id: input.id }, + data: { boardOrder: input.orderInStage }, + }); + return { + id: input.id, + stage: input.stage, + orderInStage: input.orderInStage, + }; + } + async setStage(input: SetStageInput, actingUserId: string) { const deal = await this.db.deal.findUnique({ where: { id: input.id }, diff --git a/apps/api/src/forms/forms.contracts.ts b/apps/api/src/forms/forms.contracts.ts new file mode 100644 index 000000000..5f96f8fe5 --- /dev/null +++ b/apps/api/src/forms/forms.contracts.ts @@ -0,0 +1,70 @@ +import { FormFieldType, FormStatus } from "@crm/db"; +import { z } from "zod"; +import { listInput } from "../trpc/list-input"; + +const statusEnum = z.enum( + Object.values(FormStatus) as [FormStatus, ...FormStatus[]], +); +const typeEnum = z.enum( + Object.values(FormFieldType) as [FormFieldType, ...FormFieldType[]], +); + +export const formListInput = listInput.extend({ + status: z.string().default("all"), + clientAccountId: z.string().default("all"), +}); + +export type FormListInput = z.infer; + +export const formFieldInput = z.object({ + key: z + .string() + .trim() + .min(1) + .regex(/^[a-z][a-z0-9_]*$/i, "lowercase letters, numbers, underscores"), + label: z.string().trim().min(1), + type: typeEnum, + required: z.boolean().default(false), + placeholder: z.string().nullable().optional(), + helpText: z.string().nullable().optional(), + options: z.array(z.string()).default([]), + position: z.number().int().min(0), +}); + +export type FormFieldInput = z.infer; + +export const formCreateInput = z.object({ + name: z.string().trim().min(1), + slug: z.string().trim().min(1).optional(), + description: z.string().nullable().optional(), + status: statusEnum.optional(), + redirectUrl: z.string().url().nullable().optional(), + submitButtonLabel: z.string().optional(), + successMessage: z.string().optional(), + clientAccountId: z.string().nullable().optional(), + createDeal: z.boolean().optional(), + dealStage: z.string().nullable().optional(), + tagsToApply: z.array(z.string()).optional(), + workflowIdOnSubmit: z.string().nullable().optional(), + fields: z.array(formFieldInput).default([]), +}); + +export type FormCreateInput = z.infer; + +export const formUpdateArgs = z.object({ + id: z.string(), + data: formCreateInput.partial().extend({ + fields: z.array(formFieldInput).optional(), + }), +}); + +export const formIdInput = z.object({ id: z.string() }); + +export const formSubmissionListInput = listInput.extend({ + formId: z.string(), +}); + +export const formPublicSubmitInput = z.object({ + slug: z.string(), + data: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])), +}); diff --git a/apps/api/src/forms/forms.controller.ts b/apps/api/src/forms/forms.controller.ts new file mode 100644 index 000000000..ee75e686f --- /dev/null +++ b/apps/api/src/forms/forms.controller.ts @@ -0,0 +1,64 @@ +import { + Body, + Controller, + Get, + Headers, + Ip, + NotFoundException, + Param, + Post, +} from "@nestjs/common"; +import { AllowAnonymous } from "@thallesp/nestjs-better-auth"; +import { FormsService } from "./forms.service"; + +type PublicSubmitBody = { + data?: Record; +}; + +@Controller("public/forms") +export class FormsController { + constructor(private readonly forms: FormsService) {} + + @Get(":slug") + @AllowAnonymous() + async get(@Param("slug") slug: string) { + const form = await this.forms.bySlug(slug); + if (!form || form.status !== "PUBLISHED") { + throw new NotFoundException("Form not found"); + } + return { + id: form.id, + name: form.name, + slug: form.slug, + description: form.description, + submitButtonLabel: form.submitButtonLabel, + fields: form.fields.map((f) => ({ + key: f.key, + label: f.label, + type: f.type, + required: f.required, + placeholder: f.placeholder, + helpText: f.helpText, + options: f.options, + })), + }; + } + + @Post(":slug/submit") + @AllowAnonymous() + async submit( + @Param("slug") slug: string, + @Body() body: PublicSubmitBody, + @Ip() ip: string, + @Headers("user-agent") userAgent?: string, + @Headers("referer") referrer?: string, + ) { + return this.forms.publicSubmit({ + slug, + data: body?.data ?? {}, + ipAddress: ip, + userAgent, + referrer, + }); + } +} diff --git a/apps/api/src/forms/forms.module.ts b/apps/api/src/forms/forms.module.ts new file mode 100644 index 000000000..ad130c454 --- /dev/null +++ b/apps/api/src/forms/forms.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { TrpcModule } from "../trpc/trpc.module"; +import { FormsController } from "./forms.controller"; +import { FormsRouter } from "./forms.router"; +import { FormsService } from "./forms.service"; + +@Module({ + imports: [TrpcModule], + controllers: [FormsController], + providers: [FormsService, FormsRouter], + exports: [FormsService], +}) +export class FormsModule {} diff --git a/apps/api/src/forms/forms.router.ts b/apps/api/src/forms/forms.router.ts new file mode 100644 index 000000000..5848851bc --- /dev/null +++ b/apps/api/src/forms/forms.router.ts @@ -0,0 +1,48 @@ +import { Inject } from "@nestjs/common"; +import { Input, Mutation, Query, Router, UseMiddlewares } from "nestjs-trpc"; +import type { z } from "zod"; +import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; +import { + formCreateInput, + formIdInput, + formListInput, + formSubmissionListInput, + formUpdateArgs, +} from "./forms.contracts"; +import { FormsService } from "./forms.service"; + +@Router({ alias: "forms" }) +@UseMiddlewares(AuthMiddleware) +export class FormsRouter { + constructor(@Inject(FormsService) private readonly forms: FormsService) {} + + @Query({ input: formListInput }) + async list(@Input() input: z.infer) { + return this.forms.list(input); + } + + @Query({ input: formIdInput }) + async byId(@Input("id") id: string) { + return this.forms.byId(id); + } + + @Mutation({ input: formCreateInput }) + async create(@Input() input: z.infer) { + return this.forms.create(input); + } + + @Mutation({ input: formUpdateArgs }) + async update(@Input() input: z.infer) { + return this.forms.update(input.id, input.data); + } + + @Mutation({ input: formIdInput }) + async delete(@Input("id") id: string) { + return this.forms.delete(id); + } + + @Query({ input: formSubmissionListInput }) + async submissions(@Input() input: z.infer) { + return this.forms.submissions(input.formId, input.page, input.pageSize); + } +} diff --git a/apps/api/src/forms/forms.service.ts b/apps/api/src/forms/forms.service.ts new file mode 100644 index 000000000..bfe01e27f --- /dev/null +++ b/apps/api/src/forms/forms.service.ts @@ -0,0 +1,341 @@ +import { ActivityType, type Db, FormStatus, type Prisma } from "@crm/db"; +import { + BadRequestException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { InjectDatabase } from "../database/database.constants"; +import { FACET_ALL, type ListResult, paginate } from "../trpc/list-input"; +import type { + FormCreateInput, + FormFieldInput, + FormListInput, +} from "./forms.contracts"; + +type SubmissionData = Record; + +function normalize(input: string): string { + return input + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +@Injectable() +export class FormsService { + constructor(@InjectDatabase() private readonly db: Db) {} + + async list(input: FormListInput): Promise> { + const where: Prisma.FormDefinitionWhereInput = {}; + if (input.q.trim()) { + where.OR = [ + { name: { contains: input.q, mode: "insensitive" } }, + { slug: { contains: input.q, mode: "insensitive" } }, + ]; + } + if (input.status !== FACET_ALL) where.status = input.status as FormStatus; + if (input.clientAccountId !== FACET_ALL) + where.clientAccountId = input.clientAccountId; + + const { skip, take } = paginate(input); + const [rows, total, statusGroups] = await Promise.all([ + this.db.formDefinition.findMany({ + where, + orderBy: { updatedAt: "desc" }, + skip, + take, + include: { + _count: { select: { submissions: true, fields: true } }, + clientAccount: { select: { id: true, name: true } }, + }, + }), + this.db.formDefinition.count({ where }), + this.db.formDefinition.groupBy({ + by: ["status"], + _count: { _all: true }, + }), + ]); + + const statusFacet: Record = {}; + for (const g of statusGroups) statusFacet[g.status] = g._count._all; + const facetCounts: Record> = { + status: statusFacet, + }; + + return { + rows: rows.map((r) => ({ + id: r.id, + name: r.name, + slug: r.slug, + status: r.status, + description: r.description, + fieldCount: r._count.fields, + submissionCount: r._count.submissions, + clientAccount: r.clientAccount, + createdAt: r.createdAt.toISOString(), + updatedAt: r.updatedAt.toISOString(), + })), + total, + facetCounts, + }; + } + + async byId(id: string) { + const form = await this.db.formDefinition.findUnique({ + where: { id }, + include: { fields: { orderBy: { position: "asc" } } }, + }); + if (!form) throw new NotFoundException("Form not found"); + return form; + } + + async bySlug(slug: string) { + return this.db.formDefinition.findUnique({ + where: { slug }, + include: { fields: { orderBy: { position: "asc" } } }, + }); + } + + async create(input: FormCreateInput) { + const slug = await this.uniqueSlug(input.slug ?? input.name); + return this.db.formDefinition.create({ + data: { + name: input.name, + slug, + description: input.description ?? null, + status: input.status ?? FormStatus.DRAFT, + redirectUrl: input.redirectUrl ?? null, + submitButtonLabel: input.submitButtonLabel ?? "Submit", + successMessage: input.successMessage ?? "Thanks — we'll be in touch.", + clientAccountId: input.clientAccountId ?? null, + createDeal: input.createDeal ?? true, + dealStage: input.dealStage ?? null, + tagsToApply: input.tagsToApply ?? [], + workflowIdOnSubmit: input.workflowIdOnSubmit ?? null, + fields: { + create: input.fields.map((f) => this.fieldCreateData(f)), + }, + }, + include: { fields: true }, + }); + } + + async update(id: string, data: Partial) { + const existing = await this.db.formDefinition.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException("Form not found"); + + return this.db.$transaction(async (tx) => { + const patch: Prisma.FormDefinitionUpdateInput = {}; + if (data.name !== undefined) patch.name = data.name; + if (data.slug !== undefined && data.slug !== existing.slug) { + patch.slug = await this.uniqueSlug(data.slug, id); + } + if (data.description !== undefined) patch.description = data.description; + if (data.status !== undefined) patch.status = data.status; + if (data.redirectUrl !== undefined) patch.redirectUrl = data.redirectUrl; + if (data.submitButtonLabel !== undefined) + patch.submitButtonLabel = data.submitButtonLabel; + if (data.successMessage !== undefined) + patch.successMessage = data.successMessage; + if (data.clientAccountId !== undefined) { + patch.clientAccount = data.clientAccountId + ? { connect: { id: data.clientAccountId } } + : { disconnect: true }; + } + if (data.createDeal !== undefined) patch.createDeal = data.createDeal; + if (data.dealStage !== undefined) patch.dealStage = data.dealStage; + if (data.tagsToApply !== undefined) patch.tagsToApply = data.tagsToApply; + if (data.workflowIdOnSubmit !== undefined) + patch.workflowIdOnSubmit = data.workflowIdOnSubmit; + + await tx.formDefinition.update({ where: { id }, data: patch }); + + if (data.fields) { + await tx.formField.deleteMany({ where: { formId: id } }); + await tx.formField.createMany({ + data: data.fields.map((f, i) => ({ + formId: id, + ...this.fieldCreateData({ ...f, position: f.position ?? i }), + })), + }); + } + + return tx.formDefinition.findUnique({ + where: { id }, + include: { fields: { orderBy: { position: "asc" } } }, + }); + }); + } + + async delete(id: string) { + try { + await this.db.formDefinition.delete({ where: { id } }); + return { id }; + } catch { + throw new NotFoundException("Form not found"); + } + } + + async submissions(formId: string, page: number, pageSize: number) { + const { skip, take } = paginate({ page, pageSize }); + const [rows, total] = await Promise.all([ + this.db.formSubmission.findMany({ + where: { formId }, + orderBy: { createdAt: "desc" }, + skip, + take, + include: { + contact: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + }, + }), + this.db.formSubmission.count({ where: { formId } }), + ]); + return { + rows: rows.map((r) => ({ + id: r.id, + data: r.data as SubmissionData, + createdAt: r.createdAt.toISOString(), + contact: r.contact, + })), + total, + }; + } + + async publicSubmit(input: { + slug: string; + data: SubmissionData; + ipAddress?: string; + userAgent?: string; + referrer?: string; + }) { + const form = await this.db.formDefinition.findUnique({ + where: { slug: input.slug }, + include: { fields: { orderBy: { position: "asc" } } }, + }); + if (!form || form.status !== FormStatus.PUBLISHED) { + throw new NotFoundException("Form not found or not published"); + } + + for (const field of form.fields) { + if (field.required && !input.data[field.key]) { + throw new BadRequestException(`Missing required field: ${field.label}`); + } + } + + const email = (input.data.email as string | undefined) + ?.toLowerCase() + .trim(); + const first = (input.data.firstName ?? + input.data.first_name ?? + "") as string; + const last = (input.data.lastName ?? input.data.last_name ?? "") as string; + const phone = (input.data.phone as string | undefined) ?? null; + const name = (input.data.name as string | undefined) ?? ""; + + let contactId: string | null = null; + if (email) { + const existing = await this.db.contact.findUnique({ + where: { email }, + }); + if (existing) { + contactId = existing.id; + } else { + const parts = name ? name.split(" ") : [first, last].filter(Boolean); + const created = await this.db.contact.create({ + data: { + firstName: first || parts[0] || "Lead", + lastName: last || parts.slice(1).join(" ") || null, + email, + phone: phone || null, + clientAccountId: form.clientAccountId, + tags: form.tagsToApply, + source: "MANUAL", + }, + }); + contactId = created.id; + } + } + + const submission = await this.db.formSubmission.create({ + data: { + formId: form.id, + data: input.data as Prisma.InputJsonValue, + contactId, + ipAddress: input.ipAddress ?? null, + userAgent: input.userAgent ?? null, + referrer: input.referrer ?? null, + }, + }); + + if (contactId) { + await this.db.activity + .create({ + data: { + type: ActivityType.FORM_SUBMISSION, + subject: `Submitted ${form.name}`, + body: JSON.stringify(input.data, null, 2), + contactId, + createdById: contactId, + meta: { formId: form.id, submissionId: submission.id }, + }, + }) + .catch(() => undefined); + } + + return { + id: submission.id, + redirectUrl: form.redirectUrl, + message: form.successMessage, + }; + } + + private fieldCreateData(f: FormFieldInput) { + return { + key: f.key, + label: f.label, + type: f.type, + required: f.required, + placeholder: f.placeholder ?? null, + helpText: f.helpText ?? null, + options: f.options, + position: f.position, + }; + } + + private async uniqueSlug(candidate: string, excludeId?: string) { + const base = normalize(candidate) || "form"; + let slug = base; + let n = 1; + while (true) { + const clash = await this.db.formDefinition.findFirst({ + where: { slug, id: excludeId ? { not: excludeId } : undefined }, + select: { id: true }, + }); + if (!clash) return slug; + n += 1; + slug = `${base}-${n}`; + if (n > 200) throw new BadRequestException("Could not derive slug"); + } + } +} + +export type FormRow = { + id: string; + name: string; + slug: string; + status: FormStatus; + description: string | null; + fieldCount: number; + submissionCount: number; + clientAccount: { id: string; name: string } | null; + createdAt: string; + updatedAt: string; +}; diff --git a/apps/api/src/generated/server.ts b/apps/api/src/generated/server.ts index 695f1d6b4..c23252918 100644 --- a/apps/api/src/generated/server.ts +++ b/apps/api/src/generated/server.ts @@ -15,20 +15,25 @@ const t = initTRPC.create(); const publicProcedure = t.procedure; import { timelineInput, timelineCountsInput, myTasksInput, activityCreateInput, completeInput } from "../activities/activities.contracts"; import { agentIdInput, agentHistoryInput, agentUpdateInput, agentDeployInput, agentRunNowInput } from "../agent/agents.contracts"; +import { clientAccountListInput, clientAccountIdInput, clientAccountCreateInput, clientAccountUpdateArgs } from "../client-accounts/client-accounts.contracts"; import { companyListInput, companyIdInput, companyOptionsInput, companyCreateInput, companyUpdateArgs, companyBulkOwnerInput, companyBulkInput, setPrimaryContactInput } from "../companies/companies.contracts"; import { contactListInput, contactIdInput, contactCreateInput, contactUpdateArgs, contactBulkOwnerInput, contactBulkCompanyInput, contactBulkInput, factDecisionInput } from "../contacts/contacts.contracts"; import { conversationListInput, builderResourceSearchInput, conversationIdInput, conversationEventsInput, conversationSaveInput, builderConversationCreateInput, builderConversationSubmitInput, builderQuestionResponseInput, builderResponseRatingInput, sharedConversationInput } from "../conversations/conversations.contracts"; import { setReportingCurrencyInput, setManualRateInput, removeManualRateInput } from "../currency/currency.contracts"; import { dashboardSummaryInput } from "../dashboard/dashboard.contracts"; -import { dealListInput, dealIdInput, dealCreateInput, dealUpdateArgs, setStageInput, dealContactsInput, dealAttachContactInput, dealDetachContactInput, dealContactRoleInput, dealBulkOwnerInput, dealBulkStageInput, dealBulkInput } from "../deals/deals.contracts"; +import { dealListInput, dealBoardInput, dealReorderInput, dealIdInput, dealCreateInput, dealUpdateArgs, setStageInput, dealContactsInput, dealAttachContactInput, dealDetachContactInput, dealContactRoleInput, dealBulkOwnerInput, dealBulkStageInput, dealBulkInput } from "../deals/deals.contracts"; import { fieldListInput, fieldByKeyInput, fieldIdInput, fieldCreateInput, fieldUpdateArgs, fieldReorderInput } from "../fields/fields.contracts"; +import { formListInput, formIdInput, formCreateInput, formUpdateArgs, formSubmissionListInput } from "../forms/forms.contracts"; import { setAutoCreateInput, suppressDomainInput, threadInput, calendarEventInput } from "../google/google.contracts"; import { setOutlookAutoCreateInput } from "../microsoft/microsoft.contracts"; import { setAgentModelInput, setResearchKeyInput } from "../settings/settings.contracts"; +import { smsThreadListInput, smsThreadIdInput, smsSendInput, smsMarkReadInput } from "../sms/sms.contracts"; import { ssoProviderListInput, registerSsoProviderInput, deleteSsoProviderInput } from "../sso/sso.contracts"; +import { workflowListInput, workflowIdInput, workflowCreateInput, workflowUpdateArgs, workflowRunInput } from "../workflows/workflows.contracts"; import { memberListInput, updateWorkspaceInput, setMemberRoleInput } from "../workspace/workspace.contracts"; import type { ActivitiesRouter } from "../activities/activities.router"; import type { AgentsRouter } from "../agent/agents.router"; +import type { ClientAccountsRouter } from "../client-accounts/client-accounts.router"; import type { CompaniesRouter } from "../companies/companies.router"; import type { ContactsRouter } from "../contacts/contacts.router"; import type { ConversationsRouter } from "../conversations/conversations.router"; @@ -36,12 +41,15 @@ import type { CurrencyRouter } from "../currency/currency.router"; import type { DashboardRouter } from "../dashboard/dashboard.router"; import type { DealsRouter } from "../deals/deals.router"; import type { FieldsRouter } from "../fields/fields.router"; +import type { FormsRouter } from "../forms/forms.router"; import type { GoogleRouter } from "../google/google.router"; import type { MicrosoftRouter } from "../microsoft/microsoft.router"; import type { SearchRouter } from "../search/search.router"; import type { SettingsRouter } from "../settings/settings.router"; +import type { SmsRouter } from "../sms/sms.router"; import type { SsoRouter } from "../sso/sso.router"; import type { UsersRouter } from "../users/users.router"; +import type { WorkflowsRouter } from "../workflows/workflows.router"; import type { WorkspaceRouter } from "../workspace/workspace.router"; const appRouter = t.router({ @@ -99,6 +107,25 @@ const appRouter = t.router({ .input(agentRunNowInput) .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) }), + clientAccounts: t.router({ + list: publicProcedure + .input(clientAccountListInput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + byId: publicProcedure + .input(clientAccountIdInput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + options: publicProcedure + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + create: publicProcedure + .input(clientAccountCreateInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + update: publicProcedure + .input(clientAccountUpdateArgs) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + delete: publicProcedure + .input(clientAccountIdInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) + }), companies: t.router({ list: publicProcedure .input(companyListInput) @@ -245,6 +272,12 @@ const appRouter = t.router({ list: publicProcedure .input(dealListInput) .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + board: publicProcedure + .input(dealBoardInput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + reorder: publicProcedure + .input(dealReorderInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), byId: publicProcedure .input(dealIdInput) .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), @@ -314,6 +347,26 @@ const appRouter = t.router({ .input(fieldIdInput) .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) }), + forms: t.router({ + list: publicProcedure + .input(formListInput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + byId: publicProcedure + .input(formIdInput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + create: publicProcedure + .input(formCreateInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + update: publicProcedure + .input(formUpdateArgs) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + delete: publicProcedure + .input(formIdInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + submissions: publicProcedure + .input(formSubmissionListInput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) + }), google: t.router({ status: publicProcedure .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), @@ -368,6 +421,20 @@ const appRouter = t.router({ .input(setResearchKeyInput) .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) }), + sms: t.router({ + list: publicProcedure + .input(smsThreadListInput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + thread: publicProcedure + .input(smsThreadIdInput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + send: publicProcedure + .input(smsSendInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + markRead: publicProcedure + .input(smsMarkReadInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) + }), sso: t.router({ signInOptions: publicProcedure .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), @@ -389,6 +456,26 @@ const appRouter = t.router({ list: publicProcedure .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) }), + workflows: t.router({ + list: publicProcedure + .input(workflowListInput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + byId: publicProcedure + .input(workflowIdInput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + create: publicProcedure + .input(workflowCreateInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + update: publicProcedure + .input(workflowUpdateArgs) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + delete: publicProcedure + .input(workflowIdInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + runNow: publicProcedure + .input(workflowRunInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) + }), workspace: t.router({ get: publicProcedure .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), diff --git a/apps/api/src/sms/sms.contracts.ts b/apps/api/src/sms/sms.contracts.ts new file mode 100644 index 000000000..bb50ff3f2 --- /dev/null +++ b/apps/api/src/sms/sms.contracts.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; +import { listInput } from "../trpc/list-input"; + +export const smsThreadListInput = listInput.extend({ + unread: z.enum(["all", "unread"]).default("all"), + clientAccountId: z.string().default("all"), +}); + +export type SmsThreadListInput = z.infer; + +export const smsThreadIdInput = z.object({ id: z.string() }); + +export const smsSendInput = z.object({ + to: z.string().trim().min(4), + body: z.string().trim().min(1).max(1600), + contactId: z.string().optional(), + clientAccountId: z.string().optional(), +}); + +export type SmsSendInput = z.infer; + +export const smsMarkReadInput = z.object({ threadId: z.string() }); diff --git a/apps/api/src/sms/sms.controller.ts b/apps/api/src/sms/sms.controller.ts new file mode 100644 index 000000000..7df4f8115 --- /dev/null +++ b/apps/api/src/sms/sms.controller.ts @@ -0,0 +1,70 @@ +import { + Body, + Controller, + ForbiddenException, + Headers, + Logger, + Post, + Req, + ServiceUnavailableException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { AllowAnonymous } from "@thallesp/nestjs-better-auth"; +import type { Request } from "express"; +import type { EnvironmentVariables } from "../config/env.validation"; +import { SmsService } from "./sms.service"; +import { TwilioClient } from "./twilio.client"; + +type TwilioInboundBody = { + MessageSid?: string; + From?: string; + To?: string; + Body?: string; + NumMedia?: string; +}; + +@Controller("internal/sms") +export class SmsController { + private readonly logger = new Logger(SmsController.name); + private readonly publicUrl: string | undefined; + + constructor( + private readonly sms: SmsService, + private readonly twilio: TwilioClient, + config: ConfigService, + ) { + this.publicUrl = config.get("API_URL", { infer: true }); + } + + @Post("twilio/inbound") + @AllowAnonymous() + async twilioInbound( + @Headers("x-twilio-signature") signature: string | undefined, + @Body() body: TwilioInboundBody, + @Req() req: Request, + ) { + if (!this.twilio.enabled) { + throw new ServiceUnavailableException("Twilio is not configured."); + } + const url = `${this.publicUrl ?? `${req.protocol}://${req.get("host")}`}/internal/sms/twilio/inbound`; + const params: Record = {}; + for (const [k, v] of Object.entries(body ?? {})) { + if (typeof v === "string") params[k] = v; + } + const valid = this.twilio.validateSignature(signature, url, params); + if (!valid) { + this.logger.warn({ message: "Invalid Twilio signature on inbound SMS" }); + throw new ForbiddenException(); + } + if (!body.MessageSid || !body.From || !body.To || !body.Body) { + return { ok: true, ignored: "missing-fields" }; + } + await this.sms.handleInbound({ + messageSid: body.MessageSid, + from: body.From, + to: body.To, + body: body.Body, + }); + return { ok: true }; + } +} diff --git a/apps/api/src/sms/sms.module.ts b/apps/api/src/sms/sms.module.ts new file mode 100644 index 000000000..474323669 --- /dev/null +++ b/apps/api/src/sms/sms.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; +import { TrpcModule } from "../trpc/trpc.module"; +import { SmsController } from "./sms.controller"; +import { SmsRouter } from "./sms.router"; +import { SmsService } from "./sms.service"; +import { TwilioClient } from "./twilio.client"; + +@Module({ + imports: [TrpcModule], + controllers: [SmsController], + providers: [SmsService, SmsRouter, TwilioClient], + exports: [SmsService, TwilioClient], +}) +export class SmsModule {} diff --git a/apps/api/src/sms/sms.router.ts b/apps/api/src/sms/sms.router.ts new file mode 100644 index 000000000..4448db508 --- /dev/null +++ b/apps/api/src/sms/sms.router.ts @@ -0,0 +1,48 @@ +import { Inject } from "@nestjs/common"; +import { + Ctx, + Input, + Mutation, + Query, + Router, + UseMiddlewares, +} from "nestjs-trpc"; +import type { z } from "zod"; +import type { AuthedTrpcContext } from "../trpc/context.types"; +import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; +import { + smsMarkReadInput, + smsSendInput, + smsThreadIdInput, + smsThreadListInput, +} from "./sms.contracts"; +import { SmsService } from "./sms.service"; + +@Router({ alias: "sms" }) +@UseMiddlewares(AuthMiddleware) +export class SmsRouter { + constructor(@Inject(SmsService) private readonly sms: SmsService) {} + + @Query({ input: smsThreadListInput }) + async list(@Input() input: z.infer) { + return this.sms.list(input); + } + + @Query({ input: smsThreadIdInput }) + async thread(@Input("id") id: string) { + return this.sms.thread(id); + } + + @Mutation({ input: smsSendInput }) + async send( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.sms.send(input, ctx.user.id); + } + + @Mutation({ input: smsMarkReadInput }) + async markRead(@Input("threadId") threadId: string) { + return this.sms.markRead(threadId); + } +} diff --git a/apps/api/src/sms/sms.service.ts b/apps/api/src/sms/sms.service.ts new file mode 100644 index 000000000..894a1f552 --- /dev/null +++ b/apps/api/src/sms/sms.service.ts @@ -0,0 +1,332 @@ +import { + ActivityType, + type Db, + type Prisma, + SmsDirection, + SmsStatus, +} from "@crm/db"; +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { InjectDatabase } from "../database/database.constants"; +import { FACET_ALL, type ListResult, paginate } from "../trpc/list-input"; +import type { SmsSendInput, SmsThreadListInput } from "./sms.contracts"; +import { TwilioClient } from "./twilio.client"; + +export type SmsThreadRow = { + id: string; + ourNumber: string; + theirNumber: string; + lastMessageAt: string; + lastPreview: string | null; + unreadCount: number; + contact: { + id: string; + firstName: string; + lastName: string | null; + imageUrl: string | null; + } | null; + clientAccountId: string | null; +}; + +function normalizeNumber(input: string): string { + const trimmed = input.trim(); + if (trimmed.startsWith("+")) + return `+${trimmed.slice(1).replace(/[^0-9]/g, "")}`; + const digits = trimmed.replace(/[^0-9]/g, ""); + return `+${digits}`; +} + +@Injectable() +export class SmsService { + private readonly logger = new Logger(SmsService.name); + + constructor( + @InjectDatabase() private readonly db: Db, + private readonly twilio: TwilioClient, + ) {} + + async list(input: SmsThreadListInput): Promise> { + const where: Prisma.SmsThreadWhereInput = {}; + if (input.q.trim()) { + where.OR = [ + { theirNumber: { contains: input.q } }, + { lastPreview: { contains: input.q, mode: "insensitive" } }, + { + contact: { + OR: [ + { firstName: { contains: input.q, mode: "insensitive" } }, + { lastName: { contains: input.q, mode: "insensitive" } }, + ], + }, + }, + ]; + } + if (input.unread === "unread") where.unreadCount = { gt: 0 }; + if (input.clientAccountId !== FACET_ALL) { + where.clientAccountId = input.clientAccountId; + } + const { skip, take } = paginate(input); + const [rows, total, unreadCount] = await Promise.all([ + this.db.smsThread.findMany({ + where, + orderBy: { lastMessageAt: "desc" }, + skip, + take, + include: { + contact: { + select: { + id: true, + firstName: true, + lastName: true, + imageUrl: true, + }, + }, + }, + }), + this.db.smsThread.count({ where }), + this.db.smsThread.count({ where: { unreadCount: { gt: 0 } } }), + ]); + + return { + rows: rows.map((row) => ({ + id: row.id, + ourNumber: row.ourNumber, + theirNumber: row.theirNumber, + lastMessageAt: row.lastMessageAt.toISOString(), + lastPreview: row.lastPreview, + unreadCount: row.unreadCount, + contact: row.contact, + clientAccountId: row.clientAccountId, + })), + total, + facetCounts: { + unread: { unread: unreadCount, all: total }, + }, + }; + } + + async thread(id: string) { + const thread = await this.db.smsThread.findUnique({ + where: { id }, + include: { + contact: { + select: { + id: true, + firstName: true, + lastName: true, + imageUrl: true, + email: true, + }, + }, + messages: { orderBy: { sentAt: "asc" } }, + }, + }); + if (!thread) throw new NotFoundException("Thread not found"); + return { + id: thread.id, + ourNumber: thread.ourNumber, + theirNumber: thread.theirNumber, + lastMessageAt: thread.lastMessageAt.toISOString(), + unreadCount: thread.unreadCount, + contact: thread.contact, + clientAccountId: thread.clientAccountId, + messages: thread.messages.map((msg) => ({ + id: msg.id, + direction: msg.direction, + body: msg.body, + status: msg.status, + sentAt: msg.sentAt.toISOString(), + errorMessage: msg.errorMessage, + })), + }; + } + + async send(input: SmsSendInput, userId: string) { + if (!this.twilio.enabled) { + throw new BadRequestException("Twilio is not configured."); + } + const to = normalizeNumber(input.to); + const from = this.twilio.fromNumber ?? ""; + if (!from) { + throw new BadRequestException( + "TWILIO_FROM_NUMBER is not configured for outbound SMS.", + ); + } + + let contactId = input.contactId ?? null; + if (!contactId) { + const existing = await this.db.contact.findFirst({ + where: { phone: to }, + select: { id: true }, + }); + contactId = existing?.id ?? null; + } + + const thread = await this.upsertThread({ + ourNumber: from, + theirNumber: to, + contactId, + clientAccountId: input.clientAccountId ?? null, + }); + + const message = await this.db.smsMessage.create({ + data: { + threadId: thread.id, + direction: SmsDirection.OUTBOUND, + status: SmsStatus.QUEUED, + body: input.body, + }, + }); + + try { + const result = await this.twilio.send({ to, body: input.body, from }); + await this.db.smsMessage.update({ + where: { id: message.id }, + data: { + providerSid: result.sid, + status: SmsStatus.SENT, + sentAt: new Date(), + }, + }); + await this.db.smsThread.update({ + where: { id: thread.id }, + data: { + lastMessageAt: new Date(), + lastPreview: input.body.slice(0, 160), + }, + }); + if (contactId) { + await this.db.activity.create({ + data: { + type: ActivityType.SMS, + subject: `SMS to ${to}`, + body: input.body, + contactId, + createdById: userId, + meta: { direction: "OUTBOUND", threadId: thread.id }, + }, + }); + } + return { id: message.id, sid: result.sid, status: result.status }; + } catch (err) { + await this.db.smsMessage.update({ + where: { id: message.id }, + data: { + status: SmsStatus.FAILED, + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); + throw err; + } + } + + async markRead(threadId: string) { + await this.db.smsThread.update({ + where: { id: threadId }, + data: { unreadCount: 0 }, + }); + return { ok: true }; + } + + async handleInbound(params: { + messageSid: string; + from: string; + to: string; + body: string; + receivedAt?: Date; + }) { + const our = normalizeNumber(params.to); + const their = normalizeNumber(params.from); + const existing = await this.db.smsMessage.findUnique({ + where: { providerSid: params.messageSid }, + }); + if (existing) return existing; + + const contact = await this.db.contact.findFirst({ + where: { phone: their }, + select: { id: true, clientAccountId: true }, + }); + + const thread = await this.upsertThread({ + ourNumber: our, + theirNumber: their, + contactId: contact?.id ?? null, + clientAccountId: contact?.clientAccountId ?? null, + }); + + const message = await this.db.smsMessage.create({ + data: { + threadId: thread.id, + direction: SmsDirection.INBOUND, + status: SmsStatus.RECEIVED, + body: params.body, + providerSid: params.messageSid, + sentAt: params.receivedAt ?? new Date(), + }, + }); + + await this.db.smsThread.update({ + where: { id: thread.id }, + data: { + lastMessageAt: message.sentAt, + lastPreview: params.body.slice(0, 160), + unreadCount: { increment: 1 }, + }, + }); + + if (contact) { + await this.db.activity + .create({ + data: { + type: ActivityType.SMS, + subject: `SMS from ${their}`, + body: params.body, + contactId: contact.id, + createdById: (await this.systemUserId()) ?? contact.id, + meta: { direction: "INBOUND", threadId: thread.id }, + }, + }) + .catch(() => undefined); + } + + return message; + } + + private async systemUserId(): Promise { + const user = await this.db.user.findFirst({ + orderBy: { createdAt: "asc" }, + select: { id: true }, + }); + return user?.id ?? null; + } + + private async upsertThread(input: { + ourNumber: string; + theirNumber: string; + contactId: string | null; + clientAccountId: string | null; + }) { + return this.db.smsThread.upsert({ + where: { + ourNumber_theirNumber: { + ourNumber: input.ourNumber, + theirNumber: input.theirNumber, + }, + }, + create: { + ourNumber: input.ourNumber, + theirNumber: input.theirNumber, + contactId: input.contactId, + clientAccountId: input.clientAccountId, + }, + update: { + contactId: input.contactId ?? undefined, + clientAccountId: input.clientAccountId ?? undefined, + }, + }); + } +} diff --git a/apps/api/src/sms/twilio.client.ts b/apps/api/src/sms/twilio.client.ts new file mode 100644 index 000000000..1ab170ff0 --- /dev/null +++ b/apps/api/src/sms/twilio.client.ts @@ -0,0 +1,118 @@ +import { createHmac } from "node:crypto"; +import { + Injectable, + Logger, + ServiceUnavailableException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import type { EnvironmentVariables } from "../config/env.validation"; + +export type TwilioConfig = { + accountSid: string; + authToken: string; + fromNumber?: string; + messagingServiceSid?: string; +}; + +export type TwilioSendResult = { + sid: string; + status: string; +}; + +@Injectable() +export class TwilioClient { + private readonly logger = new Logger(TwilioClient.name); + private readonly config: TwilioConfig | null; + + constructor(config: ConfigService) { + const accountSid = config.get("TWILIO_ACCOUNT_SID", { infer: true }); + const authToken = config.get("TWILIO_AUTH_TOKEN", { infer: true }); + if (accountSid && authToken) { + this.config = { + accountSid, + authToken, + fromNumber: config.get("TWILIO_FROM_NUMBER", { infer: true }), + messagingServiceSid: config.get("TWILIO_MESSAGING_SERVICE_SID", { + infer: true, + }), + }; + } else { + this.config = null; + } + } + + get enabled(): boolean { + return this.config !== null; + } + + get fromNumber(): string | undefined { + return this.config?.fromNumber; + } + + async send(input: { + to: string; + body: string; + from?: string; + }): Promise { + if (!this.config) { + throw new ServiceUnavailableException( + "Twilio is not configured. Set TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN in the root .env.", + ); + } + const from = input.from ?? this.config.fromNumber; + const useService = !from && this.config.messagingServiceSid; + if (!from && !useService) { + throw new ServiceUnavailableException( + "No TWILIO_FROM_NUMBER or TWILIO_MESSAGING_SERVICE_SID configured.", + ); + } + + const params = new URLSearchParams(); + params.set("To", input.to); + params.set("Body", input.body); + if (useService && this.config.messagingServiceSid) { + params.set("MessagingServiceSid", this.config.messagingServiceSid); + } else if (from) { + params.set("From", from); + } + + const url = `https://api.twilio.com/2010-04-01/Accounts/${this.config.accountSid}/Messages.json`; + const auth = Buffer.from( + `${this.config.accountSid}:${this.config.authToken}`, + ).toString("base64"); + + const res = await fetch(url, { + method: "POST", + headers: { + Authorization: `Basic ${auth}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params.toString(), + }); + + if (!res.ok) { + const text = await res.text(); + this.logger.error({ message: "Twilio send failed", status: res.status }); + throw new Error(`Twilio send failed: ${res.status} ${text}`); + } + const json = (await res.json()) as { sid: string; status: string }; + return { sid: json.sid, status: json.status }; + } + + validateSignature( + signature: string | undefined, + url: string, + params: Record, + ): boolean { + if (!signature || !this.config) return false; + const sortedKeys = Object.keys(params).sort(); + let data = url; + for (const key of sortedKeys) { + data += key + params[key]; + } + const computed = createHmac("sha1", this.config.authToken) + .update(data, "utf8") + .digest("base64"); + return computed === signature; + } +} diff --git a/apps/api/src/workflows/workflows.contracts.ts b/apps/api/src/workflows/workflows.contracts.ts new file mode 100644 index 000000000..1049b4111 --- /dev/null +++ b/apps/api/src/workflows/workflows.contracts.ts @@ -0,0 +1,76 @@ +import { WorkflowStatus, WorkflowTriggerKind } from "@crm/db"; +import { z } from "zod"; +import { listInput } from "../trpc/list-input"; + +const statusEnum = z.enum( + Object.values(WorkflowStatus) as [WorkflowStatus, ...WorkflowStatus[]], +); +const triggerEnum = z.enum( + Object.values(WorkflowTriggerKind) as [ + WorkflowTriggerKind, + ...WorkflowTriggerKind[], + ], +); + +export const WORKFLOW_ACTIONS = [ + "send_sms", + "send_email", + "set_stage", + "add_tag", + "wait", + "notify_slack", + "agent_task", +] as const; + +export const workflowStepInput = z.object({ + action: z.enum(WORKFLOW_ACTIONS), + to: z.string().optional(), + body: z.string().optional(), + subject: z.string().optional(), + stage: z.string().optional(), + tag: z.string().optional(), + minutes: z.number().int().positive().optional(), + message: z.string().optional(), + prompt: z.string().optional(), +}); + +export type WorkflowStep = z.infer; + +export const workflowListInput = listInput.extend({ + status: z.string().default("all"), + trigger: z.string().default("all"), +}); + +export type WorkflowListInput = z.infer; + +export const workflowCreateInput = z.object({ + name: z.string().trim().min(1), + description: z.string().nullable().optional(), + status: statusEnum.optional(), + triggerKind: triggerEnum, + triggerConfig: z.unknown().optional(), + steps: z.array(workflowStepInput).optional(), + clientAccountId: z.string().nullable().optional(), +}); + +export const workflowUpdateInput = z.object({ + name: z.string().trim().min(1).optional(), + description: z.string().nullable().optional(), + status: statusEnum.optional(), + triggerKind: triggerEnum.optional(), + triggerConfig: z.unknown().optional(), + steps: z.array(workflowStepInput).optional(), + clientAccountId: z.string().nullable().optional(), +}); + +export const workflowUpdateArgs = z.object({ + id: z.string(), + data: workflowUpdateInput, +}); + +export const workflowIdInput = z.object({ id: z.string() }); + +export const workflowRunInput = z.object({ + id: z.string(), + context: z.unknown().optional(), +}); diff --git a/apps/api/src/workflows/workflows.module.ts b/apps/api/src/workflows/workflows.module.ts new file mode 100644 index 000000000..8cedd715b --- /dev/null +++ b/apps/api/src/workflows/workflows.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { TrpcModule } from "../trpc/trpc.module"; +import { WorkflowsRouter } from "./workflows.router"; +import { WorkflowsService } from "./workflows.service"; + +@Module({ + imports: [TrpcModule], + providers: [WorkflowsService, WorkflowsRouter], + exports: [WorkflowsService], +}) +export class WorkflowsModule {} diff --git a/apps/api/src/workflows/workflows.router.ts b/apps/api/src/workflows/workflows.router.ts new file mode 100644 index 000000000..7a0cfb993 --- /dev/null +++ b/apps/api/src/workflows/workflows.router.ts @@ -0,0 +1,50 @@ +import { Inject } from "@nestjs/common"; +import { Input, Mutation, Query, Router, UseMiddlewares } from "nestjs-trpc"; +import type { z } from "zod"; +import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; +import { + workflowCreateInput, + workflowIdInput, + workflowListInput, + workflowRunInput, + workflowUpdateArgs, +} from "./workflows.contracts"; +import { WorkflowsService } from "./workflows.service"; + +@Router({ alias: "workflows" }) +@UseMiddlewares(AuthMiddleware) +export class WorkflowsRouter { + constructor( + @Inject(WorkflowsService) private readonly workflows: WorkflowsService, + ) {} + + @Query({ input: workflowListInput }) + async list(@Input() input: z.infer) { + return this.workflows.list(input); + } + + @Query({ input: workflowIdInput }) + async byId(@Input("id") id: string) { + return this.workflows.byId(id); + } + + @Mutation({ input: workflowCreateInput }) + async create(@Input() input: z.infer) { + return this.workflows.create(input); + } + + @Mutation({ input: workflowUpdateArgs }) + async update(@Input() input: z.infer) { + return this.workflows.update(input.id, input.data); + } + + @Mutation({ input: workflowIdInput }) + async delete(@Input("id") id: string) { + return this.workflows.delete(id); + } + + @Mutation({ input: workflowRunInput }) + async runNow(@Input() input: z.infer) { + return this.workflows.enqueueRun(input.id, input.context); + } +} diff --git a/apps/api/src/workflows/workflows.service.ts b/apps/api/src/workflows/workflows.service.ts new file mode 100644 index 000000000..9efd4df75 --- /dev/null +++ b/apps/api/src/workflows/workflows.service.ts @@ -0,0 +1,192 @@ +import { + type Db, + type Prisma, + WorkflowRunStatus, + WorkflowStatus, + WorkflowTriggerKind, +} from "@crm/db"; +import { Injectable, NotFoundException } from "@nestjs/common"; +import { InjectDatabase } from "../database/database.constants"; +import { FACET_ALL, type ListResult, paginate } from "../trpc/list-input"; +import type { WorkflowListInput, WorkflowStep } from "./workflows.contracts"; + +type WorkflowCreate = { + name: string; + description?: string | null; + status?: WorkflowStatus; + triggerKind: WorkflowTriggerKind; + triggerConfig?: unknown; + steps?: WorkflowStep[]; + clientAccountId?: string | null; +}; + +export type WorkflowRow = { + id: string; + name: string; + description: string | null; + status: WorkflowStatus; + triggerKind: WorkflowTriggerKind; + stepCount: number; + runCount: number; + lastRunAt: string | null; + clientAccount: { id: string; name: string } | null; + createdAt: string; + updatedAt: string; +}; + +@Injectable() +export class WorkflowsService { + constructor(@InjectDatabase() private readonly db: Db) {} + + async list(input: WorkflowListInput): Promise> { + const where: Prisma.WorkflowDefinitionWhereInput = {}; + if (input.q.trim()) { + where.OR = [ + { name: { contains: input.q, mode: "insensitive" } }, + { description: { contains: input.q, mode: "insensitive" } }, + ]; + } + if (input.status !== FACET_ALL) + where.status = input.status as WorkflowStatus; + if (input.trigger !== FACET_ALL) + where.triggerKind = input.trigger as WorkflowTriggerKind; + + const { skip, take } = paginate(input); + const [rows, total, statusGroups, triggerGroups] = await Promise.all([ + this.db.workflowDefinition.findMany({ + where, + orderBy: { updatedAt: "desc" }, + skip, + take, + include: { + clientAccount: { select: { id: true, name: true } }, + }, + }), + this.db.workflowDefinition.count({ where }), + this.db.workflowDefinition.groupBy({ + by: ["status"], + _count: { _all: true }, + }), + this.db.workflowDefinition.groupBy({ + by: ["triggerKind"], + _count: { _all: true }, + }), + ]); + + const statusFacet: Record = {}; + const triggerFacet: Record = {}; + for (const g of statusGroups) statusFacet[g.status] = g._count._all; + for (const g of triggerGroups) triggerFacet[g.triggerKind] = g._count._all; + const facetCounts: Record> = { + status: statusFacet, + trigger: triggerFacet, + }; + + return { + rows: rows.map((r) => ({ + id: r.id, + name: r.name, + description: r.description, + status: r.status, + triggerKind: r.triggerKind, + stepCount: Array.isArray(r.steps) ? r.steps.length : 0, + runCount: r.runCount, + lastRunAt: r.lastRunAt?.toISOString() ?? null, + clientAccount: r.clientAccount, + createdAt: r.createdAt.toISOString(), + updatedAt: r.updatedAt.toISOString(), + })), + total, + facetCounts, + }; + } + + async byId(id: string) { + const row = await this.db.workflowDefinition.findUnique({ + where: { id }, + include: { + clientAccount: { select: { id: true, name: true } }, + runs: { orderBy: { createdAt: "desc" }, take: 20 }, + }, + }); + if (!row) throw new NotFoundException("Workflow not found"); + return row; + } + + async create(input: WorkflowCreate): Promise<{ id: string; name: string }> { + const row = await this.db.workflowDefinition.create({ + data: { + name: input.name, + description: input.description ?? null, + status: input.status ?? WorkflowStatus.DRAFT, + triggerKind: input.triggerKind, + triggerConfig: (input.triggerConfig ?? {}) as Prisma.InputJsonValue, + steps: (input.steps ?? []) as unknown as Prisma.InputJsonValue, + clientAccountId: input.clientAccountId ?? null, + }, + select: { id: true, name: true }, + }); + return row; + } + + async update( + id: string, + data: Partial, + ): Promise<{ id: string }> { + const patch: Prisma.WorkflowDefinitionUpdateInput = {}; + if (data.name !== undefined) patch.name = data.name; + if (data.description !== undefined) patch.description = data.description; + if (data.status !== undefined) patch.status = data.status; + if (data.triggerKind !== undefined) patch.triggerKind = data.triggerKind; + if (data.triggerConfig !== undefined) + patch.triggerConfig = (data.triggerConfig ?? {}) as Prisma.InputJsonValue; + if (data.steps !== undefined) + patch.steps = data.steps as unknown as Prisma.InputJsonValue; + if (data.clientAccountId !== undefined) { + patch.clientAccount = data.clientAccountId + ? { connect: { id: data.clientAccountId } } + : { disconnect: true }; + } + await this.db.workflowDefinition.update({ where: { id }, data: patch }); + return { id }; + } + + async delete(id: string) { + try { + await this.db.workflowDefinition.delete({ where: { id } }); + return { id }; + } catch { + throw new NotFoundException("Workflow not found"); + } + } + + async enqueueRun(workflowId: string, triggerData: unknown) { + const workflow = await this.db.workflowDefinition.findUnique({ + where: { id: workflowId }, + }); + if (!workflow || workflow.status !== WorkflowStatus.ACTIVE) return null; + return this.db.workflowRun.create({ + data: { + workflowId, + triggerData: (triggerData ?? {}) as Prisma.InputJsonValue, + status: WorkflowRunStatus.QUEUED, + }, + }); + } + + async recentRuns(workflowId: string, limit = 20) { + const runs = await this.db.workflowRun.findMany({ + where: { workflowId }, + orderBy: { createdAt: "desc" }, + take: limit, + }); + return runs.map((r) => ({ + id: r.id, + status: r.status, + startedAt: r.startedAt?.toISOString() ?? null, + finishedAt: r.finishedAt?.toISOString() ?? null, + errorMessage: r.errorMessage, + createdAt: r.createdAt.toISOString(), + })); + } +} diff --git a/apps/app/app/(app)/[slug]/agency-kpis.tsx b/apps/app/app/(app)/[slug]/agency-kpis.tsx new file mode 100644 index 000000000..014519683 --- /dev/null +++ b/apps/app/app/(app)/[slug]/agency-kpis.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { Card, CardContent } from "@crm/ui/components/card"; +import { Skeleton } from "@crm/ui/components/skeleton"; +import { useQuery } from "@tanstack/react-query"; +import Link from "next/link"; +import { useTRPC } from "@/lib/trpc/client"; +import { useWorkspaceUrl } from "@/lib/use-workspace-url"; + +export function AgencyKpis() { + const trpc = useTRPC(); + const url = useWorkspaceUrl(); + const clients = useQuery( + trpc.clientAccounts.list.queryOptions({ + q: "", + status: "ACTIVE", + sort: "name", + dir: "asc", + page: 1, + pageSize: 1, + }), + ); + const inbox = useQuery( + trpc.sms.list.queryOptions({ + q: "", + unread: "unread", + clientAccountId: "all", + sort: "", + dir: "asc", + page: 1, + pageSize: 1, + }), + ); + const workflows = useQuery( + trpc.workflows.list.queryOptions({ + q: "", + status: "ACTIVE", + trigger: "all", + sort: "", + dir: "asc", + page: 1, + pageSize: 1, + }), + ); + const forms = useQuery( + trpc.forms.list.queryOptions({ + q: "", + status: "PUBLISHED", + clientAccountId: "all", + sort: "", + dir: "asc", + page: 1, + pageSize: 1, + }), + ); + + return ( +
+ + 0 ? "attention" : "default" + } + /> + + +
+ ); +} + +function KpiCard({ + label, + value, + loading, + href, + tone = "default", +}: { + label: string; + value: number | undefined; + loading: boolean; + href: string; + tone?: "default" | "attention"; +}) { + const inner = ( + 0 + ? "border-primary/60 bg-primary/5" + : "" + }`} + > + +
+ {label} +
+
+ {loading ? ( + + ) : ( + (value ?? 0).toLocaleString() + )} +
+
+
+ ); + return ( + + {inner} + + ); +} diff --git a/apps/app/app/(app)/[slug]/clients/[clientId]/client-detail.tsx b/apps/app/app/(app)/[slug]/clients/[clientId]/client-detail.tsx new file mode 100644 index 000000000..904979953 --- /dev/null +++ b/apps/app/app/(app)/[slug]/clients/[clientId]/client-detail.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { Badge } from "@crm/ui/components/badge"; +import { Button } from "@crm/ui/components/button"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@crm/ui/components/card"; +import { Skeleton } from "@crm/ui/components/skeleton"; +import { formatMoney } from "@crm/ui/lib/format"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import Link from "next/link"; +import { toast } from "sonner"; +import { useTRPC } from "@/lib/trpc/client"; +import { useWorkspaceUrl } from "@/lib/use-workspace-url"; + +export function ClientDetail({ clientId }: { clientId: string }) { + const trpc = useTRPC(); + const url = useWorkspaceUrl(); + const queryClient = useQueryClient(); + const { data, isLoading } = useQuery( + trpc.clientAccounts.byId.queryOptions({ id: clientId }), + ); + const remove = useMutation( + trpc.clientAccounts.delete.mutationOptions({ + onSuccess: async () => { + toast.success("Client archived"); + await queryClient.invalidateQueries({ + queryKey: trpc.clientAccounts.list.queryKey(), + }); + window.history.back(); + }, + onError: (err) => toast.error(err.message), + }), + ); + + if (isLoading || !data) return ; + + return ( +
+
+ + ← All clients + +
+
+ {data.logoUrl ? ( + // biome-ignore lint/performance/noImgElement: external client logo URLs are not allowlisted for next/image + + ) : ( +
+ {data.name.charAt(0).toUpperCase()} +
+ )} +
+

{data.name}

+
+ {data.status.toLowerCase()} + {data.industry && {data.industry}} + {data.website && ( + + {data.website.replace(/^https?:\/\//, "")} + + )} +
+
+ +
+ +
+ + + + +
+ + + + Notes + + + {data.notes ? ( +

{data.notes}

+ ) : ( +

+ No notes yet. Add one from the edit sheet. +

+ )} +
+
+ +
+ + + +
+
+ ); +} + +function Kpi({ label, value }: { label: string; value: number | string }) { + return ( + + +
+ {label} +
+
{value}
+
+
+ ); +} + +function QuickCard({ + href, + title, + body, +}: { + href: string; + title: string; + body: string; +}) { + return ( + +
{title}
+
{body}
+ + ); +} diff --git a/apps/app/app/(app)/[slug]/clients/[clientId]/page.tsx b/apps/app/app/(app)/[slug]/clients/[clientId]/page.tsx new file mode 100644 index 000000000..39f05be7b --- /dev/null +++ b/apps/app/app/(app)/[slug]/clients/[clientId]/page.tsx @@ -0,0 +1,44 @@ +import { notFound } from "next/navigation"; +import { Suspense } from "react"; +import { + PageShell, + PageShellContent, + PageShellLoading, +} from "@/components/page-shell"; +import { requireSession } from "@/lib/session"; +import { HydrateClient } from "@/lib/trpc/hydrate"; +import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; +import { ClientDetail } from "./client-detail"; + +export default async function ClientPage({ + params, +}: { + params: Promise<{ slug: string; clientId: string }>; +}) { + const { clientId } = await params; + if (!clientId) notFound(); + + return ( + + + }> + + + + + ); +} + +async function Load({ clientId }: { clientId: string }) { + await requireSession(); + const trpc = getServerTrpc(); + const queryClient = getServerQueryClient(); + await queryClient.prefetchQuery( + trpc.clientAccounts.byId.queryOptions({ id: clientId }), + ); + return ( + + + + ); +} diff --git a/apps/app/app/(app)/[slug]/clients/clients-search-params.ts b/apps/app/app/(app)/[slug]/clients/clients-search-params.ts new file mode 100644 index 000000000..1168bdec1 --- /dev/null +++ b/apps/app/app/(app)/[slug]/clients/clients-search-params.ts @@ -0,0 +1,7 @@ +import { createListSearchParams } from "@/components/data-table/list-search-params"; + +export const clientsSearchParams = createListSearchParams({ + defaultSort: "name", + defaultDir: "asc", + tabId: "status", +}); diff --git a/apps/app/app/(app)/[slug]/clients/clients-table.tsx b/apps/app/app/(app)/[slug]/clients/clients-table.tsx new file mode 100644 index 000000000..c227be17a --- /dev/null +++ b/apps/app/app/(app)/[slug]/clients/clients-table.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { Badge } from "@crm/ui/components/badge"; +import { Button } from "@crm/ui/components/button"; +import { Card, CardContent } from "@crm/ui/components/card"; +import { Skeleton } from "@crm/ui/components/skeleton"; +import { formatMoney } from "@crm/ui/lib/format"; +import { useQuery } from "@tanstack/react-query"; +import Link from "next/link"; +import { useState } from "react"; +import { useTRPC } from "@/lib/trpc/client"; +import { useWorkspaceUrl } from "@/lib/use-workspace-url"; +import { CreateClientSheet } from "./create-client-sheet"; + +const STATUS_TONE: Record = { + ACTIVE: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", + ONBOARDING: "bg-sky-500/10 text-sky-700 dark:text-sky-300", + PAUSED: "bg-amber-500/10 text-amber-700 dark:text-amber-300", + CHURNED: "bg-muted text-muted-foreground", +}; + +export function ClientsTable() { + const trpc = useTRPC(); + const [status, setStatus] = useState("all"); + const [q, setQ] = useState(""); + + const { data, isLoading } = useQuery( + trpc.clientAccounts.list.queryOptions({ + q, + status, + sort: "name", + dir: "asc", + page: 1, + pageSize: 50, + }), + ); + + const facetCounts = data?.facetCounts.status ?? {}; + const total = data?.total ?? 0; + + const tabs = [ + { id: "all", label: "All", count: total }, + { id: "ACTIVE", label: "Active", count: facetCounts.ACTIVE ?? 0 }, + { + id: "ONBOARDING", + label: "Onboarding", + count: facetCounts.ONBOARDING ?? 0, + }, + { id: "PAUSED", label: "Paused", count: facetCounts.PAUSED ?? 0 }, + { id: "CHURNED", label: "Churned", count: facetCounts.CHURNED ?? 0 }, + ]; + + return ( +
+
+
+ {tabs.map((tab) => ( + + ))} +
+ setQ(e.target.value)} + placeholder="Search clients…" + className="ml-auto h-9 w-64 max-w-full rounded-md border bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> +
+ + {isLoading ? ( +
+ {["a", "b", "c", "d", "e", "f"].map((k) => ( + + ))} +
+ ) : data?.rows.length === 0 ? ( + + ) : ( +
+ {data?.rows.map((row) => ( + + ))} +
+ )} +
+ ); +} + +function EmptyState() { + return ( + + +

No clients yet

+

+ A client is a business you serve. Every contact, deal and form can + belong to one, so filtering by client is one click away. +

+ Add your first client} + /> +
+
+ ); +} + +function ClientCard({ + row, +}: { + row: { + id: string; + name: string; + slug: string; + status: string; + logoUrl: string | null; + brandColor: string | null; + website: string | null; + industry: string | null; + monthlyRetainerCents: string | null; + currency: string; + tags: string[]; + companyCount: number; + contactCount: number; + openDealCount: number; + }; +}) { + const url = useWorkspaceUrl(); + const retainer = + row.monthlyRetainerCents !== null + ? formatMoney(Number(row.monthlyRetainerCents), row.currency) + : null; + + return ( + +
+ {row.logoUrl ? ( + // biome-ignore lint/performance/noImgElement: external client logo URLs are not allowlisted for next/image + + ) : ( +
+ {row.name.charAt(0).toUpperCase()} +
+ )} +
+
+

+ {row.name} +

+ + {row.status.toLowerCase()} + +
+

+ {row.industry ?? row.website ?? row.slug} +

+
+
+ +
+ + + +
+ + {retainer && ( +
+ + Monthly retainer + + {retainer} +
+ )} + + ); +} + +function Stat({ label, value }: { label: string; value: number }) { + return ( +
+
{value}
+
{label}
+
+ ); +} diff --git a/apps/app/app/(app)/[slug]/clients/create-client-sheet.tsx b/apps/app/app/(app)/[slug]/clients/create-client-sheet.tsx new file mode 100644 index 000000000..bbd1e00fe --- /dev/null +++ b/apps/app/app/(app)/[slug]/clients/create-client-sheet.tsx @@ -0,0 +1,194 @@ +"use client"; + +import { Button } from "@crm/ui/components/button"; +import { Input } from "@crm/ui/components/input"; +import { Label } from "@crm/ui/components/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@crm/ui/components/select"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@crm/ui/components/sheet"; +import { Textarea } from "@crm/ui/components/textarea"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { useTRPC } from "@/lib/trpc/client"; + +const STATUSES = [ + { value: "ACTIVE", label: "Active" }, + { value: "ONBOARDING", label: "Onboarding" }, + { value: "PAUSED", label: "Paused" }, + { value: "CHURNED", label: "Churned" }, +] as const; + +export function CreateClientSheet({ trigger }: { trigger?: ReactNode }) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [name, setName] = useState(""); + const [status, setStatus] = useState("ACTIVE"); + const [industry, setIndustry] = useState(""); + const [website, setWebsite] = useState(""); + const [retainer, setRetainer] = useState(""); + const [notes, setNotes] = useState(""); + + const mutation = useMutation( + trpc.clientAccounts.create.mutationOptions({ + onSuccess: async () => { + toast.success("Client added"); + setOpen(false); + setName(""); + setIndustry(""); + setWebsite(""); + setRetainer(""); + setNotes(""); + await queryClient.invalidateQueries({ + queryKey: trpc.clientAccounts.list.queryKey(), + }); + await queryClient.invalidateQueries({ + queryKey: trpc.clientAccounts.options.queryKey(), + }); + }, + onError: (err) => toast.error(err.message), + }), + ); + + function submit() { + if (!name.trim()) return; + const cents = retainer ? Math.round(Number(retainer) * 100) : undefined; + mutation.mutate({ + name: name.trim(), + status: status as "ACTIVE" | "ONBOARDING" | "PAUSED" | "CHURNED", + industry: industry.trim() || undefined, + website: website.trim() || undefined, + monthlyRetainerCents: Number.isFinite(cents) ? cents : undefined, + notes: notes.trim() || undefined, + }); + } + + return ( + + + {trigger ?? } + + + + New client + + A client is the top-level container for a business you serve. + + +
{ + e.preventDefault(); + submit(); + }} + > + + setName(e.target.value)} + placeholder="Acme Co" + autoFocus + /> + + + + + + setIndustry(e.target.value)} + placeholder="e.g. Roofing, SaaS, Restaurant" + /> + + + setWebsite(e.target.value)} + placeholder="https://example.com" + /> + + + setRetainer(e.target.value)} + placeholder="0.00" + /> + + +