From 3be7bbd69fe8b814ee2599a29b33e73f02a66245 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:08:18 -0400 Subject: [PATCH 1/2] feat(agent): scope field backfill tasks to records missing values (#163) Co-authored-by: Lewis Carhart --- apps/agent/agent/instructions/task.ts | 5 +- apps/agent/agent/lib/blank-facts.ts | 4 + apps/agent/agent/lib/dispatch.ts | 19 +- apps/agent/agent/lib/facts.ts | 6 +- apps/agent/agent/lib/fields.ts | 34 +- apps/agent/agent/lib/focus.ts | 12 +- apps/agent/agent/lib/preamble.ts | 26 +- apps/agent/agent/lib/stale-tasks.ts | 33 + apps/api/package.json | 2 + .../src/activities/activities.contracts.ts | 106 +++ apps/api/src/activities/activities.router.ts | 36 +- apps/api/src/activities/activities.service.ts | 19 +- apps/api/src/agent/agent-dispatch.config.ts | 1 + apps/api/src/agent/agent-trigger.service.ts | 148 +++-- apps/api/src/agent/agents.contracts.ts | 306 +++++++++ apps/api/src/agent/agents.router.ts | 119 +++- apps/api/src/api-keys/api-keys.contracts.ts | 47 ++ apps/api/src/api-keys/api-keys.module.ts | 11 + apps/api/src/api-keys/api-keys.router.ts | 72 ++ apps/api/src/api-keys/api-keys.service.ts | 181 +++++ apps/api/src/app.module.ts | 6 + apps/api/src/archive/archive-config.ts | 5 + .../archive/archive-retention.controller.ts | 116 ++++ apps/api/src/archive/archive.module.ts | 11 + apps/api/src/auth/auth.controller.ts | 20 +- apps/api/src/companies/companies.contracts.ts | 206 +++++- apps/api/src/companies/companies.router.ts | 128 +++- apps/api/src/companies/companies.service.ts | 264 +++++--- .../companies/company-directory.service.ts | 4 +- apps/api/src/contacts/contacts.contracts.ts | 229 ++++++- apps/api/src/contacts/contacts.router.ts | 118 +++- apps/api/src/contacts/contacts.service.ts | 263 ++++++-- .../conversation-attachments.controller.ts | 20 +- .../conversations/conversations.contracts.ts | 272 ++++++++ .../src/conversations/conversations.router.ts | 114 +++- .../conversations/conversations.service.ts | 30 +- apps/api/src/create-app.ts | 82 +++ apps/api/src/crm/bulk.ts | 13 +- apps/api/src/currency/currency.contracts.ts | 45 ++ apps/api/src/currency/currency.router.ts | 30 +- apps/api/src/currency/currency.service.ts | 37 +- apps/api/src/currency/rates.controller.ts | 22 + apps/api/src/dashboard/dashboard.contracts.ts | 104 +++ apps/api/src/dashboard/dashboard.router.ts | 12 +- apps/api/src/deals/deals.contracts.ts | 182 ++++- apps/api/src/deals/deals.router.ts | 134 +++- apps/api/src/deals/deals.service.ts | 172 ++++- apps/api/src/enrichment/enrichment.router.ts | 52 +- apps/api/src/fields/fields-config.ts | 5 + apps/api/src/fields/fields.contracts.ts | 45 ++ apps/api/src/fields/fields.router.ts | 78 ++- apps/api/src/fields/fields.service.ts | 224 ++++++- apps/api/src/generated/server.ts | 629 +++++++++++++----- .../src/google/google-connection.service.ts | 36 +- apps/api/src/google/google.contracts.ts | 131 ++++ apps/api/src/google/google.router.ts | 51 +- apps/api/src/health/health.controller.ts | 16 + .../microsoft/microsoft-connection.service.ts | 57 +- apps/api/src/microsoft/microsoft.contracts.ts | 37 ++ apps/api/src/microsoft/microsoft.router.ts | 34 +- .../src/saved-views/saved-views.contracts.ts | 52 ++ .../api/src/saved-views/saved-views.module.ts | 11 + .../api/src/saved-views/saved-views.router.ts | 76 +++ .../src/saved-views/saved-views.service.ts | 126 ++++ apps/api/src/search/search.router.ts | 20 +- apps/api/src/settings/settings.contracts.ts | 62 ++ apps/api/src/settings/settings.router.ts | 57 +- apps/api/src/settings/settings.service.ts | 46 +- .../api/src/slack/slack-connection.service.ts | 30 +- apps/api/src/slack/slack.contracts.ts | 102 ++- apps/api/src/slack/slack.router.ts | 46 +- apps/api/src/sso/sso.contracts.ts | 42 ++ apps/api/src/sso/sso.router.ts | 36 +- apps/api/src/sso/sso.service.ts | 29 +- apps/api/src/sync/sync.controller.ts | 25 + .../api/src/telemetry/telemetry.controller.ts | 20 + .../src/tracking/tracking-filing.service.ts | 8 +- apps/api/src/tracking/tracking.contracts.ts | 107 +++ apps/api/src/tracking/tracking.controller.ts | 39 ++ apps/api/src/tracking/tracking.router.ts | 70 +- apps/api/src/tracking/tracking.service.ts | 95 +-- apps/api/src/trpc/list-input.ts | 63 +- .../middlewares/session-only.middleware.ts | 20 + apps/api/src/trpc/openapi.ts | 21 + apps/api/src/trpc/trpc.context.ts | 19 +- apps/api/src/trpc/trpc.module.ts | 4 +- apps/api/src/users/users.router.ts | 16 +- apps/api/src/workspace/workspace.contracts.ts | 34 +- apps/api/src/workspace/workspace.router.ts | 29 +- apps/api/src/workspace/workspace.service.ts | 29 +- apps/api/test/bulk.spec.ts | 37 +- .../test/currency-totals.integration.spec.ts | 8 +- apps/api/test/fields.spec.ts | 115 +++- apps/api/test/logging.spec.ts | 7 +- apps/api/test/record-delete.spec.ts | 16 +- apps/api/vercel.json | 4 + .../companies/companies-bulk-actions.tsx | 119 +++- .../companies/companies-search-params.ts | 2 +- .../[slug]/companies/companies-table.tsx | 69 +- .../[slug]/contacts/contacts-bulk-actions.tsx | 170 +++-- .../[slug]/contacts/contacts-search-params.ts | 9 +- .../(app)/[slug]/contacts/contacts-table.tsx | 111 +++- .../(app)/[slug]/deals/deals-bulk-actions.tsx | 88 ++- .../app/(app)/[slug]/deals/deals-table.tsx | 77 ++- .../api-keys/api-keys-search-params.ts | 6 + .../settings/api-keys/api-keys-table.tsx | 194 ++++++ .../api-keys/create-api-key-sheet.tsx | 193 ++++++ .../api-keys/created-api-key-dialog.tsx | 56 ++ .../(app)/[slug]/settings/api-keys/page.tsx | 71 ++ .../[slug]/settings/archive-retention.tsx | 109 +++ .../[slug]/settings/{sso => }/copy-value.tsx | 0 apps/app/app/(app)/[slug]/settings/page.tsx | 3 + .../[slug]/settings/settings-sidebar.tsx | 1 + .../settings/sso/add-sso-provider-sheet.tsx | 2 +- .../(app)/[slug]/settings/sso/sso-table.tsx | 2 +- .../agent-builder/team-agent-detail.tsx | 3 +- apps/app/components/app-icon-rail.tsx | 18 +- apps/app/components/crm/company-picker.tsx | 4 +- .../components/crm/fields/field-editor.tsx | 16 + .../components/crm/fields/field-facets.tsx | 44 ++ apps/app/components/crm/fields/fields-copy.ts | 14 + .../app/components/crm/fields/fields-list.tsx | 67 ++ .../crm/record-sheet/company-sheet.tsx | 1 + .../crm/record-sheet/contact-sheet.tsx | 1 + .../crm/record-sheet/deal-sheet.tsx | 1 + .../crm/record-sheet/record-actions.tsx | 110 ++- .../data-table/list-search-params.ts | 78 ++- .../data-table/saved-views-menu.tsx | 207 ++++++ .../components/data-table/use-table-query.ts | 175 ++++- apps/app/lib/activity-recency.ts | 5 + apps/app/lib/trpc/cache.ts | 33 +- bun.lock | 69 +- docs/api.md | 42 +- docs/list-building-roadmap.md | 147 ++++ packages/auth/package.json | 1 + packages/auth/src/api-keys.ts | 9 + packages/auth/src/auth.ts | 16 + packages/auth/src/client.ts | 3 +- packages/auth/src/cookies.ts | 1 + packages/auth/src/index.ts | 9 +- .../migration.sql | 20 + .../migration.sql | 3 + .../20260820152910_add_api_keys/migration.sql | 39 ++ .../20260820154345_saved_views/migration.sql | 22 + .../migration.sql | 2 + .../migration.sql | 30 + .../migration.sql | 11 + packages/db/prisma/schema.prisma | 125 +++- packages/db/prisma/seed.ts | 230 ++++++- packages/db/src/fields.ts | 2 + packages/db/src/settings.ts | 33 + packages/ui/src/components/combobox.tsx | 6 +- packages/ui/src/components/data-table.tsx | 268 ++++---- packages/ui/src/components/icons/bot.tsx | 39 ++ packages/ui/src/lib/table-query.ts | 6 +- packages/validation/package.json | 5 +- packages/validation/src/field-backfill.ts | 8 + packages/validation/src/field-templates.ts | 39 ++ packages/validation/src/saved-view.ts | 16 + turbo.json | 36 - 160 files changed, 8676 insertions(+), 1362 deletions(-) create mode 100644 apps/api/src/api-keys/api-keys.contracts.ts create mode 100644 apps/api/src/api-keys/api-keys.module.ts create mode 100644 apps/api/src/api-keys/api-keys.router.ts create mode 100644 apps/api/src/api-keys/api-keys.service.ts create mode 100644 apps/api/src/archive/archive-config.ts create mode 100644 apps/api/src/archive/archive-retention.controller.ts create mode 100644 apps/api/src/archive/archive.module.ts create mode 100644 apps/api/src/fields/fields-config.ts create mode 100644 apps/api/src/saved-views/saved-views.contracts.ts create mode 100644 apps/api/src/saved-views/saved-views.module.ts create mode 100644 apps/api/src/saved-views/saved-views.router.ts create mode 100644 apps/api/src/saved-views/saved-views.service.ts create mode 100644 apps/api/src/trpc/middlewares/session-only.middleware.ts create mode 100644 apps/api/src/trpc/openapi.ts create mode 100644 apps/app/app/(app)/[slug]/settings/api-keys/api-keys-search-params.ts create mode 100644 apps/app/app/(app)/[slug]/settings/api-keys/api-keys-table.tsx create mode 100644 apps/app/app/(app)/[slug]/settings/api-keys/create-api-key-sheet.tsx create mode 100644 apps/app/app/(app)/[slug]/settings/api-keys/created-api-key-dialog.tsx create mode 100644 apps/app/app/(app)/[slug]/settings/api-keys/page.tsx create mode 100644 apps/app/app/(app)/[slug]/settings/archive-retention.tsx rename apps/app/app/(app)/[slug]/settings/{sso => }/copy-value.tsx (100%) create mode 100644 apps/app/components/crm/fields/field-facets.tsx create mode 100644 apps/app/components/data-table/saved-views-menu.tsx create mode 100644 apps/app/lib/activity-recency.ts create mode 100644 docs/list-building-roadmap.md create mode 100644 packages/auth/src/api-keys.ts create mode 100644 packages/db/prisma/migrations/20260820150251_archive_crm_records/migration.sql create mode 100644 packages/db/prisma/migrations/20260820150700_list_building_fields/migration.sql create mode 100644 packages/db/prisma/migrations/20260820152910_add_api_keys/migration.sql create mode 100644 packages/db/prisma/migrations/20260820154345_saved_views/migration.sql create mode 100644 packages/db/prisma/migrations/20260820154825_field_show_on_filter/migration.sql create mode 100644 packages/db/prisma/migrations/20260820160000_backfill_seniority_function/migration.sql create mode 100644 packages/db/prisma/migrations/20260820161500_archive_scoped_uniqueness/migration.sql create mode 100644 packages/ui/src/components/icons/bot.tsx create mode 100644 packages/validation/src/field-backfill.ts create mode 100644 packages/validation/src/field-templates.ts create mode 100644 packages/validation/src/saved-view.ts diff --git a/apps/agent/agent/instructions/task.ts b/apps/agent/agent/instructions/task.ts index 36a15096b..045eba565 100644 --- a/apps/agent/agent/instructions/task.ts +++ b/apps/agent/agent/instructions/task.ts @@ -34,6 +34,8 @@ export default defineDynamic({ if (budget) setBudget(budget); + const fieldKeys = attributeText.parse(attributes.fieldKeys); + const { markdown, focus } = await sessionPreamble( { contactId: attributeText.parse(attributes.contactId), @@ -45,10 +47,11 @@ export default defineDynamic({ kind, reason: attributeText.parse(attributes.reason), budget, + fieldKeys: fieldKeys ? fieldKeys.split(",") : null, }, ); - focusOn({ ...focus, sessionId: ctx.session.id }); + focusOn({ ...focus, sessionId: ctx.session.id, taskKind: kind }); return defineInstructions({ markdown: `${RESEARCH_INSTRUCTIONS}\n\n${markdown}`, diff --git a/apps/agent/agent/lib/blank-facts.ts b/apps/agent/agent/lib/blank-facts.ts index f2088c016..1923c0a93 100644 --- a/apps/agent/agent/lib/blank-facts.ts +++ b/apps/agent/agent/lib/blank-facts.ts @@ -18,6 +18,8 @@ const CONTACT_SELECT = { firstName: true, lastName: true, title: true, + seniority: true, + function: true, linkedinUrl: true, twitterUrl: true, githubUrl: true, @@ -137,6 +139,8 @@ type Proposal = { firstName: string; lastName: string | null; title: string | null; + seniority: string | null; + function: string | null; linkedinUrl: string | null; twitterUrl: string | null; githubUrl: string | null; diff --git a/apps/agent/agent/lib/dispatch.ts b/apps/agent/agent/lib/dispatch.ts index 029dbd15d..c2157b008 100644 --- a/apps/agent/agent/lib/dispatch.ts +++ b/apps/agent/agent/lib/dispatch.ts @@ -1,4 +1,5 @@ import { EnrichmentStatus } from "@crm/db"; +import { fieldBackfillPayload } from "@crm/validation/field-backfill"; import { APP_AUTH, type AppAuth } from "./app-auth"; import { brandOutcome, runBrand } from "./brand"; import { queueEventAgentRuns } from "./custom-agent-dispatch"; @@ -243,6 +244,11 @@ export function taskAuth(task: LeasedTask, base: AppAuth = APP_AUTH): AppAuth { if (task.companyId) records.companyId = task.companyId; if (task.dealId) records.dealId = task.dealId; + if (task.kind === "field-backfill") { + const parsed = fieldBackfillPayload.safeParse(task.payload); + if (parsed.success) records.fieldKeys = parsed.data.keys.join(","); + } + return { ...base, attributes: { @@ -388,10 +394,14 @@ export function brief(task: LeasedTask): string { ? `This is attempt ${task.attempts}; the earlier one did not finish. Carry on from what is already in this thread rather than starting again. ` : ""; - return again + work(task.kind, task.reason); + return again + work(task.kind, task.reason, task.payload); } -function work(kind: string, reason: string): string { +function work( + kind: string, + reason: string, + payload: LeasedTask["payload"], +): string { switch (kind) { case "identify": return "Work out who this contact actually is, and record what you find. Read what we already have before spending anything."; @@ -404,6 +414,11 @@ function work(kind: string, reason: string): string { return "This company's brand, industry, location and links are filled in separately and may already be there. Read the account, fill anything still missing, and write a brief if there is something worth saying."; case "workspace-profile": return "Write the profile of the company you work for, so that every other session knows who we are. Read our own site and keep it short."; + case "field-backfill": { + const parsed = fieldBackfillPayload.safeParse(payload); + const keys = parsed.success ? parsed.data.keys.join(", ") : reason; + return `This record is missing a value for the custom field(s) ${keys}. Call list_fields for this record's type, read each field's brief, and call set_field_value only where you find real evidence — leave it blank rather than guess.`; + } default: return `Handle this: ${reason}`; } diff --git a/apps/agent/agent/lib/facts.ts b/apps/agent/agent/lib/facts.ts index 10eef6992..ff92b1a30 100644 --- a/apps/agent/agent/lib/facts.ts +++ b/apps/agent/agent/lib/facts.ts @@ -10,8 +10,8 @@ const FIELDS = { twitterUrl: { column: "twitterUrl" }, githubUrl: { column: "githubUrl" }, employer: { column: null }, - seniority: { column: null }, - function: { column: null }, + seniority: { column: "seniority" }, + function: { column: "function" }, location: { column: null }, tenure: { column: null }, } as const; @@ -96,6 +96,8 @@ export async function recordFact( firstName: true, lastName: true, title: true, + seniority: true, + function: true, linkedinUrl: true, twitterUrl: true, githubUrl: true, diff --git a/apps/agent/agent/lib/fields.ts b/apps/agent/agent/lib/fields.ts index 39d2b97d5..8f0b45f7d 100644 --- a/apps/agent/agent/lib/fields.ts +++ b/apps/agent/agent/lib/fields.ts @@ -6,12 +6,15 @@ import { FieldValueError, fieldKeyFromLabel, type RecordField, + readValue, recordColumn, type SerializedField, serializeField, usesOptions, writeValues, } from "@crm/db/fields"; +import { lockIdempotencyKey } from "@crm/db/idempotency"; +import { currentFocus } from "./focus"; const WITH_OPTIONS = { options: { orderBy: { position: "asc" } } } as const; @@ -75,9 +78,34 @@ export async function writeField(input: { }; } + const isBackfill = currentFocus().taskKind === "field-backfill"; + try { - await writeValues(db, input.entity, input.recordId, definitions, { - [input.key]: input.value, + return await db.$transaction(async (tx) => { + if (isBackfill) { + const column = recordColumn(input.entity); + await lockIdempotencyKey( + tx, + `field-value:${definition.id}:${input.recordId}`, + ); + + const row = await tx.fieldValue.findFirst({ + where: { fieldId: definition.id, [column]: input.recordId }, + }); + + if (readValue(definition, row ?? undefined) !== null) { + return { + written: false, + reason: `"${input.key}" already has a value on this record. Someone filled it since this task was queued — leave it as is.`, + }; + } + } + + await writeValues(tx, input.entity, input.recordId, definitions, { + [input.key]: input.value, + }); + + return { written: true, key: input.key, value: input.value }; }); } catch (error) { if (error instanceof FieldValueError) { @@ -86,8 +114,6 @@ export async function writeField(input: { throw error; } - - return { written: true, key: input.key, value: input.value }; } export async function createField(input: { diff --git a/apps/agent/agent/lib/focus.ts b/apps/agent/agent/lib/focus.ts index 2217c237e..f3f39dc5a 100644 --- a/apps/agent/agent/lib/focus.ts +++ b/apps/agent/agent/lib/focus.ts @@ -5,6 +5,7 @@ export const focus = defineState("crm.focus", () => ({ contactId: null as string | null, companyId: null as string | null, sessionId: null as string | null, + taskKind: null as string | null, spent: 0, budget: 4, exhausted: false, @@ -13,14 +14,19 @@ export const focus = defineState("crm.focus", () => ({ export type CurrentFocus = { contactId: string | null; sessionId: string | null; + taskKind: string | null; }; export function currentFocus(): CurrentFocus { try { const state = focus.get(); - return { contactId: state.contactId, sessionId: state.sessionId }; + return { + contactId: state.contactId, + sessionId: state.sessionId, + taskKind: state.taskKind, + }; } catch { - return { contactId: null, sessionId: null }; + return { contactId: null, sessionId: null, taskKind: null }; } } @@ -28,12 +34,14 @@ export function focusOn(input: { contactId?: string | null; companyId?: string | null; sessionId?: string | null; + taskKind?: string | null; }): void { focus.update((current) => ({ ...current, contactId: input.contactId ?? current.contactId, companyId: input.companyId ?? current.companyId, sessionId: input.sessionId ?? current.sessionId, + taskKind: input.taskKind ?? current.taskKind, })); } diff --git a/apps/agent/agent/lib/preamble.ts b/apps/agent/agent/lib/preamble.ts index 10afea4ac..1b93f383d 100644 --- a/apps/agent/agent/lib/preamble.ts +++ b/apps/agent/agent/lib/preamble.ts @@ -8,6 +8,8 @@ export type Opened = { kind?: string | null; reason?: string | null; budget?: number | null; + /** Set only for a `field-backfill` task — the custom field key(s) still blank on this record. */ + fieldKeys?: string[] | null; }; export type Preamble = { @@ -42,6 +44,19 @@ async function closing(): Promise { return composeClosing(await identity()); } +function fieldBackfillLine(opened: Opened): string { + if (!opened.fieldKeys || opened.fieldKeys.length === 0) return ""; + + return [ + `**This is a field-backfill task.** This record is missing a value for`, + `the custom field key(s) ${opened.fieldKeys.map((key) => `\`${key}\``).join(", ")}.`, + "Call `list_fields` for this record's entity type to read each field's", + "label, options and brief (what counts as an answer), then call", + "`set_field_value` with this record's id for any you find real evidence", + "for. Leave one blank rather than guess.", + ].join(" "); +} + function opening(opened: Opened, questions: string): string { if (opened.dispatched) { return [ @@ -113,6 +128,7 @@ export async function contactPreamble( opened.budget ? `Budget: **${opened.budget}** vendor calls. Spend them where they matter.` : "", + fieldBackfillLine(opened), "", opening( opened, @@ -197,6 +213,7 @@ export async function companyPreamble( `You are working on **${company.name}**${ company.domain ? ` (${company.domain})` : "" }${company.industry ? `, ${company.industry}` : ""} — company id \`${companyId}\`.`, + fieldBackfillLine(opened), "", opening( opened, @@ -285,6 +302,7 @@ export async function dealPreamble( ? [`The rep's own description of it: "${deal.description}"`] : []), people ? `People on it: ${people}` : "Nobody is attached to it yet.", + fieldBackfillLine(opened), "", opening( opened, @@ -293,10 +311,14 @@ export async function dealPreamble( "", "Start with `read_deal_history` on this deal id. It returns the stage clock, every stage this deal has moved through, the last reply from their side and the next meeting — which is how you answer *where does this stand* rather than reciting the stage field back.", "", - "You can research the people and the company behind it with the usual tools — a deal itself has no fields to enrich, so anything you learn is recorded against them.", + opened.fieldKeys && opened.fieldKeys.length > 0 + ? "You can research the people and the company behind it with the usual tools too — most of what you learn about them is recorded against them, not the deal." + : "You can research the people and the company behind it with the usual tools — a deal itself has no fields to enrich, so anything you learn is recorded against them.", "", await closing(), - ].join("\n"); + ] + .filter(Boolean) + .join("\n"); return { markdown, focus: { companyId: deal.company?.id ?? null } }; } diff --git a/apps/agent/agent/lib/stale-tasks.ts b/apps/agent/agent/lib/stale-tasks.ts index 1d2ade288..e43dab470 100644 --- a/apps/agent/agent/lib/stale-tasks.ts +++ b/apps/agent/agent/lib/stale-tasks.ts @@ -12,6 +12,9 @@ const LANDED_OUTCOME = const RETIRED_ERROR = "Research was attempted several times and never completed."; +const UNTARGETED_OUTCOME = + "No record was ever attached to this task, so it can never be worked. Retired."; + export type StaleTaskSweep = { scanned: number; closed: number; @@ -27,6 +30,7 @@ type OpenTask = { kind: string; contactId: string | null; companyId: string | null; + dealId: string | null; attempts: number; leasedUntil: Date | null; startedAt: Date | null; @@ -96,6 +100,7 @@ async function runSweep(sweep: StaleTaskSweep): Promise { kind: true, contactId: true, companyId: true, + dealId: true, attempts: true, leasedUntil: true, startedAt: true, @@ -109,9 +114,15 @@ async function runSweep(sweep: StaleTaskSweep): Promise { const completed = await completedSubjects(tasks); const landed: string[] = []; + const untargeted: string[] = []; const dead: string[] = []; for (const task of tasks) { + if (isUntargetedFieldBackfill(task)) { + untargeted.push(task.id); + continue; + } + if (finishedElsewhere(task, completed)) { landed.push(task.id); continue; @@ -136,6 +147,19 @@ async function runSweep(sweep: StaleTaskSweep): Promise { sweep.closed = count; } + 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; + } + sweep.retired = (await retireAbandoned()).length; if (dead.length > 0) { @@ -202,6 +226,15 @@ function finishedElsewhere( return enrichedAt.getTime() >= task.startedAt.getTime(); } +function isUntargetedFieldBackfill(task: OpenTask): boolean { + return ( + task.kind === "field-backfill" && + !task.contactId && + !task.companyId && + !task.dealId + ); +} + function unique(ids: readonly (string | null)[]): string[] { return [...new Set(ids.filter((id): id is string => id !== null))]; } diff --git a/apps/api/package.json b/apps/api/package.json index 73076b5f0..151aec0f8 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -34,6 +34,7 @@ "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", + "@nestjs/swagger": "^11.4.7", "@thallesp/nestjs-better-auth": "^2.7.0", "@trpc/server": "^11.18.0", "@vercel/blob": "^2.6.1", @@ -46,6 +47,7 @@ "nestjs-trpc": "^2.13.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "trpc-to-openapi": "^3.3.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/apps/api/src/activities/activities.contracts.ts b/apps/api/src/activities/activities.contracts.ts index a7a19f193..ad984fded 100644 --- a/apps/api/src/activities/activities.contracts.ts +++ b/apps/api/src/activities/activities.contracts.ts @@ -1,4 +1,5 @@ import { ActivityType } from "@crm/db"; +import { activityMeta } from "@crm/validation/activity-meta"; import { z } from "zod"; const COMPOSABLE_TYPES = [ @@ -11,6 +12,18 @@ const COMPOSABLE_TYPES = [ const composableEnum = z.enum(COMPOSABLE_TYPES); +const ALL_ACTIVITY_TYPES = [ + ActivityType.NOTE, + ActivityType.CALL, + ActivityType.EMAIL, + ActivityType.MEETING, + ActivityType.TASK, + ActivityType.STAGE_CHANGE, + ActivityType.ENRICHMENT, +] as const; + +const activityTypeOutput = z.enum(ALL_ACTIVITY_TYPES); + const TIMELINE_FILTERS = [ "all", "history", @@ -75,3 +88,96 @@ export const myTasksInput = z.object({ }); export type MyTasksInput = z.infer; + +const activityAuthorOutput = z.object({ + id: z.string(), + name: z.string(), + email: z.string(), + image: z.string().nullable(), +}); + +const activityCompanyRefOutput = z + .object({ + id: z.string(), + name: z.string(), + }) + .nullable(); + +const activityContactRefOutput = z + .object({ + id: z.string(), + firstName: z.string(), + lastName: z.string().nullable(), + }) + .nullable(); + +const activityDealRefOutput = z + .object({ + id: z.string(), + name: z.string(), + }) + .nullable(); + +const activityEmailThreadOutput = z + .object({ + id: z.string(), + messageCount: z.number(), + lastMessageAt: z.string(), + }) + .nullable(); + +const activityCalendarEventOutput = z + .object({ + id: z.string(), + startsAt: z.string(), + endsAt: z.string(), + isAllDay: z.boolean(), + location: z.string().nullable(), + conferenceUrl: z.string().nullable(), + attendeeCount: z.number(), + }) + .nullable(); + +export const activityEntryOutput = z.object({ + id: z.string(), + type: activityTypeOutput, + subject: z.string().nullable(), + body: z.string().nullable(), + occurredAt: z.string().nullable(), + dueAt: z.string().nullable(), + completedAt: z.string().nullable(), + meta: activityMeta, + createdAt: z.string(), + createdBy: activityAuthorOutput, + company: activityCompanyRefOutput, + contact: activityContactRefOutput, + deal: activityDealRefOutput, + emailThread: activityEmailThreadOutput, + calendarEvent: activityCalendarEventOutput, +}); + +export type ActivityEntry = z.infer; + +export const timelineOutput = z.object({ + entries: z.array(activityEntryOutput), + nextCursor: z.string().nullable(), +}); + +export type TimelineResult = z.infer; + +export const timelineCountsOutput = z.object({ + all: z.number(), + notes: z.number(), + upcoming: z.number(), + done: z.number(), + email: z.number(), + meetings: z.number(), +}); + +export type TimelineCounts = z.infer; + +export const myTasksOutput = z.array(activityEntryOutput); + +export const activityCreateOutput = activityEntryOutput; + +export const completeOutput = activityEntryOutput; diff --git a/apps/api/src/activities/activities.router.ts b/apps/api/src/activities/activities.router.ts index d75f6218c..6b1fd81b7 100644 --- a/apps/api/src/activities/activities.router.ts +++ b/apps/api/src/activities/activities.router.ts @@ -10,12 +10,18 @@ import { import type { z } from "zod"; import type { AuthedTrpcContext } from "../trpc/context.types"; import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; +import { restMeta } from "../trpc/openapi"; import { activityCreateInput, + activityCreateOutput, completeInput, + completeOutput, myTasksInput, + myTasksOutput, timelineCountsInput, + timelineCountsOutput, timelineInput, + timelineOutput, } from "./activities.contracts"; import { ActivitiesService } from "./activities.service"; @@ -26,17 +32,29 @@ export class ActivitiesRouter { @Inject(ActivitiesService) private readonly activities: ActivitiesService, ) {} - @Query({ input: timelineInput }) + @Query({ + input: timelineInput, + output: timelineOutput, + meta: restMeta("GET", "/activities", ["Activities"]), + }) async timeline(@Input() input: z.infer) { return this.activities.timeline(input); } - @Query({ input: timelineCountsInput }) + @Query({ + input: timelineCountsInput, + output: timelineCountsOutput, + meta: restMeta("GET", "/activities/counts", ["Activities"]), + }) async timelineCounts(@Input() input: z.infer) { return this.activities.timelineCounts(input); } - @Query({ input: myTasksInput }) + @Query({ + input: myTasksInput, + output: myTasksOutput, + meta: restMeta("GET", "/activities/my-tasks", ["Activities"]), + }) async myTasks( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -44,7 +62,11 @@ export class ActivitiesRouter { return this.activities.myTasks(input, ctx.user.id); } - @Mutation({ input: activityCreateInput }) + @Mutation({ + input: activityCreateInput, + output: activityCreateOutput, + meta: restMeta("POST", "/activities", ["Activities"]), + }) async create( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -52,7 +74,11 @@ export class ActivitiesRouter { return this.activities.create(input, ctx.user.id); } - @Mutation({ input: completeInput }) + @Mutation({ + input: completeInput, + output: completeOutput, + meta: restMeta("PATCH", "/activities/{id}/complete", ["Activities"]), + }) async complete(@Input() input: z.infer) { return this.activities.complete(input.id, input.completed); } diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index 5fc2353bc..50439fcbc 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -11,9 +11,12 @@ import { blankToNull } from "../crm/values"; import { InjectDatabase } from "../database/database.constants"; import type { ActivityCreateInput, + ActivityEntry, MyTasksInput, + TimelineCounts, TimelineFilter, TimelineInput, + TimelineResult, } from "./activities.contracts"; const AUTHOR_SELECT = { @@ -74,7 +77,7 @@ export class ActivitiesService { private readonly stamp: ActivityStampService, ) {} - async timeline(input: TimelineInput) { + async timeline(input: TimelineInput): Promise { const where = this.anchor(input); Object.assign(where, filterClause(input.filter)); @@ -101,7 +104,7 @@ export class ActivitiesService { async timelineCounts( input: Pick, - ) { + ): Promise { const anchor = this.anchor(input); const [all, notes, upcoming, done, email, meetings] = await Promise.all([ @@ -124,7 +127,10 @@ export class ActivitiesService { return { all, notes, upcoming, done, email, meetings }; } - async create(input: ActivityCreateInput, actingUserId: string) { + async create( + input: ActivityCreateInput, + actingUserId: string, + ): Promise { const companyId = await this.resolveCompanyId(input); const isTask = input.type === ActivityType.TASK; @@ -158,7 +164,7 @@ export class ActivitiesService { return serializeEntry(activity); } - async complete(id: string, completed: boolean) { + async complete(id: string, completed: boolean): Promise { const activity = await this.db.activity.findUnique({ where: { id }, select: { type: true }, @@ -181,7 +187,10 @@ export class ActivitiesService { return serializeEntry(updated); } - async myTasks(input: MyTasksInput, actingUserId: string) { + async myTasks( + input: MyTasksInput, + actingUserId: string, + ): Promise { const now = new Date(); const where: Prisma.ActivityWhereInput = { type: ActivityType.TASK, diff --git a/apps/api/src/agent/agent-dispatch.config.ts b/apps/api/src/agent/agent-dispatch.config.ts index 9861a2e66..82c8f1f76 100644 --- a/apps/api/src/agent/agent-dispatch.config.ts +++ b/apps/api/src/agent/agent-dispatch.config.ts @@ -10,4 +10,5 @@ export const AGENT_DISPATCH = { redeliverWithinMs: 10 * MINUTE_MS, redeliverBatch: 20, }, + fieldBackfill: { concurrency: 8 }, } as const; diff --git a/apps/api/src/agent/agent-trigger.service.ts b/apps/api/src/agent/agent-trigger.service.ts index 4b8005138..4ef47b0fe 100644 --- a/apps/api/src/agent/agent-trigger.service.ts +++ b/apps/api/src/agent/agent-trigger.service.ts @@ -1,7 +1,9 @@ import { type Db, type FieldEntity, Prisma } 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"; import { lockIdempotencyKey } from "@crm/db/idempotency"; +import { fieldBackfillPayload } from "@crm/validation/field-backfill"; import { Injectable, Logger } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; import { AGENT_DISPATCH } from "./agent-dispatch.config"; @@ -26,6 +28,21 @@ export type AgentTaskQueue = { ) => Promise; }; +async function runWithConcurrency( + items: readonly T[], + concurrency: number, + run: (item: T) => Promise, +): Promise { + const queue = items[Symbol.iterator](); + const width = Math.max(1, Math.min(concurrency, items.length)); + + await Promise.all( + Array.from({ length: width }, async () => { + for (const item of queue) await run(item); + }), + ); +} + @Injectable() export class AgentTriggerService { private readonly logger = new Logger(AgentTriggerService.name); @@ -186,54 +203,103 @@ export class AgentTriggerService { return result; } - async fieldBackfill( + async fieldBackfillRecords( entity: FieldEntity, - key: string, + keys: string[], + ids: string[], reason: string, - ): Promise { - const subject = `${entity.toLowerCase()}.${key}`; + ): Promise<{ queued: number; merged: number }> { + if (ids.length === 0 || keys.length === 0) { + return { queued: 0, merged: 0 }; + } - try { - const pending = await this.db.agentTask.findFirst({ - where: { - kind: "field-backfill", - finishedAt: null, - reason: { startsWith: `${subject}: ` }, - }, - select: { id: true }, - }); + const column = RECORD_ID_COLUMNS[entity]; + let queued = 0; + let merged = 0; - if (pending) return; + const queueOne = async (id: string): Promise => { + try { + const outcome = await this.db.$transaction(async (tx) => { + await lockIdempotencyKey( + tx, + `agent-task:field-backfill:${entity}:${id}`, + ); - await this.db.agentTask.create({ - data: { - kind: "field-backfill", - reason: `${subject}: ${reason}`, - priority: PRIORITY.fieldBackfill, - budget: 8, - dueAt: new Date(), - }, - }); + const pending = await tx.agentTask.findFirst({ + where: { + kind: "field-backfill", + finishedAt: null, + [column]: id, + } as Prisma.AgentTaskWhereInput, + select: { id: true, payload: true }, + }); + + 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, + }, + }); + return "queued" as const; + } + + const parsed = fieldBackfillPayload.safeParse(pending.payload); + const priorKeys = parsed.success ? parsed.data.keys : []; + 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, + }, + }); + return "merged" as const; + }); - this.logger.log({ - message: "Agent task queued", - kind: "field-backfill", - entity, - key, - }); + if (outcome === "queued") queued += 1; + if (outcome === "merged") merged += 1; + } catch (error) { + this.logger.error( + { + message: "Could not queue agent task", + kind: "field-backfill", + entity, + keys, + recordId: id, + }, + error instanceof Error ? error.stack : String(error), + ); + } + }; - this.poke(); - } catch (error) { - this.logger.error( - { - message: "Could not queue agent task", - kind: "field-backfill", - entity, - key, - }, - error instanceof Error ? error.stack : String(error), - ); - } + await runWithConcurrency( + ids, + AGENT_DISPATCH.fieldBackfill.concurrency, + queueOne, + ); + + this.logger.log({ + message: "Agent task queued", + kind: "field-backfill", + entity, + keys, + queued, + merged, + }); + + if (queued > 0 || merged > 0) this.poke(); + + return { queued, merged }; } async meetingSoon(contactId: string, when: Date): Promise { diff --git a/apps/api/src/agent/agents.contracts.ts b/apps/api/src/agent/agents.contracts.ts index 606904a7b..844e16dc0 100644 --- a/apps/api/src/agent/agents.contracts.ts +++ b/apps/api/src/agent/agents.contracts.ts @@ -3,6 +3,64 @@ import { z } from "zod"; export const agentManifest = schemas.agents.capabilities.loose(); +const agentManifestSummaryOutput = z.object({ + name: z.string().optional(), + description: z.string().optional(), + access: z.array(z.string()), + triggers: z.array( + z.object({ type: z.string().optional(), summary: z.string().optional() }), + ), + actions: z.array(z.object({ summary: z.string().optional() })), + dataScope: z.object({ summary: z.string().optional() }), +}); + +const agentDefinitionStatus = z.enum([ + "DRAFT", + "DEPLOYING", + "LIVE", + "PAUSED", + "ARCHIVED", + "DELETED", +]); + +const agentVersionStatus = z.enum([ + "DRAFT", + "VALIDATING", + "READY", + "DEPLOYED", + "REJECTED", +]); + +const agentRunStatus = z.enum([ + "QUEUED", + "RUNNING", + "WAITING_FOR_APPROVAL", + "SUCCEEDED", + "FAILED", + "CANCELLED", +]); + +const agentActionStatus = z.enum([ + "PLANNED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED", +]); + +const agentTriggerType = z.enum(["MANUAL", "SCHEDULE", "EVENT", "WEBHOOK"]); + +const agentUserSummaryOutput = z.object({ + id: z.string(), + name: z.string(), + image: z.string().nullable(), +}); + +const agentVersionRefOutput = z.object({ + id: z.string(), + number: z.number(), +}); + export const agentIdInput = z.object({ id: z.string().min(1) }); export const agentHistoryInput = agentIdInput.extend({ @@ -72,3 +130,251 @@ export const agentSaveFileInput = agentIdInput.extend({ }); export type AgentSaveFileInput = z.infer; + +const agentListItemOutput = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + status: agentDefinitionStatus, + createdAt: z.string(), + updatedAt: z.string(), + createdBy: agentUserSummaryOutput, + currentVersion: z + .object({ + id: z.string(), + number: z.number(), + deployedAt: z.string().nullable(), + }) + .nullable(), + triggers: z.array( + z.object({ + id: z.string(), + type: agentTriggerType, + name: z.string(), + nextRunAt: z.string().nullable(), + }), + ), + runCount: z.number(), +}); + +export const agentListOutput = z.array(agentListItemOutput); + +export type AgentListItem = z.infer; + +export const agentReviseOutput = z.object({ versionId: z.string() }); + +export const agentFilesOutput = z.object({ + versionId: z.string().nullable(), + files: z.array( + z.object({ + path: z.string(), + language: z.string(), + content: z.string(), + previousContent: z.string().nullable(), + revision: z.number(), + }), + ), +}); + +export const agentSaveFileOutput = z.object({ + saved: z.boolean(), + versionId: z.string().nullable(), +}); + +const agentCapabilitiesResultOutput = z.discriminatedUnion("readable", [ + z.object({ + readable: z.literal(false), + problem: z.string(), + actions: z.array(schemas.agents.capabilityAction), + dataScope: z.null(), + channel: z.null(), + }), + z.object({ + readable: z.literal(true), + problem: z.null(), + actions: z.array(schemas.agents.capabilityAction), + dataScope: schemas.agents.capabilities.shape.dataScope, + channel: schemas.agents.capabilityDestination.nullable(), + }), +]); + +export const agentByIdOutput = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + status: agentDefinitionStatus, + createdById: z.string(), + createdBy: agentUserSummaryOutput, + canManage: z.boolean(), + createdAt: z.string(), + updatedAt: z.string(), + currentVersion: z + .object({ + id: z.string(), + number: z.number(), + status: agentVersionStatus, + manifest: z.unknown(), + modelId: z.string(), + sandboxPolicy: z.unknown(), + approvedAt: z.string().nullable(), + deployedAt: z.string().nullable(), + }) + .nullable(), + reviewVersion: z + .object({ + id: z.string(), + number: z.number(), + status: z.enum(["DRAFT", "READY"]), + manifest: agentManifestSummaryOutput, + modelId: z.string(), + sandboxPolicy: z.unknown(), + sourceConversationId: z.string().nullable(), + }) + .nullable(), + triggers: z.array( + z.object({ + id: z.string(), + type: agentTriggerType, + name: z.string(), + config: z.unknown(), + enabled: z.boolean(), + nextRunAt: z.string().nullable(), + lastRunAt: z.string().nullable(), + }), + ), + runCount: z.number(), + capabilities: agentCapabilitiesResultOutput, +}); + +export type AgentDetail = z.infer; + +const agentRunEventOutput = z.object({ + id: z.string(), + sequence: z.number(), + type: z.string(), + data: z.unknown(), + emittedAt: z.string(), +}); + +const agentRunActionOutput = z.object({ + id: z.string(), + type: z.string(), + provider: z.string(), + targetType: z.string().nullable(), + targetId: z.string().nullable(), + targetLabel: z.string().nullable(), + summary: z.string(), + status: agentActionStatus, + externalId: z.string().nullable(), + attemptCount: z.number(), + errorCode: z.string().nullable(), + errorMessage: z.string().nullable(), + plannedAt: z.string(), + startedAt: z.string().nullable(), + completedAt: z.string().nullable(), +}); + +const agentRunSummaryOutput = z.object({ + id: z.string(), + status: agentRunStatus, + triggerType: agentTriggerType, + summary: z.string().nullable(), + modelId: z.string().nullable(), + inputTokens: z.number().nullable(), + outputTokens: z.number().nullable(), + costUsd: z.string().nullable(), + errorCode: z.string().nullable(), + errorMessage: z.string().nullable(), + createdAt: z.string(), + startedAt: z.string().nullable(), + finishedAt: z.string().nullable(), + initiatedBy: agentUserSummaryOutput.nullable(), + version: agentVersionRefOutput, + totalEvents: z.number(), + eventsTruncated: z.boolean(), + canCancel: z.boolean(), + events: z.array(agentRunEventOutput), + actions: z.array(agentRunActionOutput), +}); + +export const agentHistoryOutput = z.array(agentRunSummaryOutput); + +export type AgentRunSummary = z.infer; + +const agentAuditEventOutput = z.object({ + id: z.string(), + type: z.string(), + summary: z.string(), + before: z.unknown().nullable(), + after: z.unknown().nullable(), + requestId: z.string().nullable(), + emittedAt: z.string(), + actorType: z.string(), + actorId: z.string().nullable(), + actorUser: agentUserSummaryOutput.nullable(), + version: agentVersionRefOutput.nullable(), +}); + +export const agentActivityOutput = z.array(agentAuditEventOutput); + +export type AgentAuditEventSummary = z.infer; + +export const agentUpdateOutput = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + status: agentDefinitionStatus, +}); + +export const agentDeployOutput = z.object({ + id: z.string(), + versionId: z.string(), + status: z.literal("LIVE"), +}); + +export const agentPauseOutput = z.object({ + id: z.string(), + name: z.string(), + status: z.literal("PAUSED"), + updatedAt: z.string(), +}); + +export const agentResumeOutput = z.object({ + id: z.string(), + name: z.string(), + status: z.literal("LIVE"), + updatedAt: z.string(), +}); + +export const agentArchiveOutput = z.object({ + id: z.string(), + name: z.string(), + status: z.literal("ARCHIVED"), + updatedAt: z.string(), +}); + +export const agentRestoreOutput = z.object({ + id: z.string(), + name: z.string(), + status: z.literal("PAUSED"), + updatedAt: z.string(), +}); + +export const agentRemoveOutput = z.object({ + id: z.string(), + name: z.string(), + status: z.literal("DELETED"), + updatedAt: z.string(), + disabledTriggers: z.number(), + cancelledRuns: z.number(), +}); + +export const agentRunNowOutput = z.object({ id: z.string() }); + +export const agentRetryRunOutput = z.object({ id: z.string() }); + +export const agentCancelRunOutput = z.object({ + id: z.string(), + status: agentRunStatus, + cancelled: z.boolean(), +}); diff --git a/apps/api/src/agent/agents.router.ts b/apps/api/src/agent/agents.router.ts index 6e4f37859..fdec1ec9a 100644 --- a/apps/api/src/agent/agents.router.ts +++ b/apps/api/src/agent/agents.router.ts @@ -10,18 +10,36 @@ import { import type { z } from "zod"; import type { AuthedTrpcContext } from "../trpc/context.types"; import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; +import { restMeta } from "../trpc/openapi"; import { AgentDefinitionsService } from "./agent-definitions.service"; import { AgentRunsService } from "./agent-runs.service"; import { + agentActivityOutput, + agentArchiveOutput, + agentByIdOutput, agentCancelRunInput, + agentCancelRunOutput, agentDeployInput, + agentDeployOutput, + agentFilesOutput, agentHistoryInput, + agentHistoryOutput, agentIdInput, + agentListOutput, + agentPauseOutput, + agentRemoveOutput, + agentRestoreOutput, + agentResumeOutput, agentRetryRunInput, + agentRetryRunOutput, agentReviseInput, + agentReviseOutput, agentRunNowInput, + agentRunNowOutput, agentSaveFileInput, + agentSaveFileOutput, agentUpdateInput, + agentUpdateOutput, } from "./agents.contracts"; @Router({ alias: "agents" }) @@ -34,12 +52,19 @@ export class AgentsRouter { private readonly runs: AgentRunsService, ) {} - @Query() + @Query({ + output: agentListOutput, + meta: restMeta("GET", "/agents", ["Agents"]), + }) async list(@Ctx() ctx: AuthedTrpcContext) { return this.agents.list(ctx.user.id); } - @Mutation({ input: agentReviseInput }) + @Mutation({ + input: agentReviseInput, + output: agentReviseOutput, + meta: restMeta("POST", "/agents/{id}/revise", ["Agents"]), + }) async revise( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -47,12 +72,20 @@ export class AgentsRouter { return this.agents.revise(input, ctx.user.id); } - @Query({ input: agentIdInput }) + @Query({ + input: agentIdInput, + output: agentFilesOutput, + meta: restMeta("GET", "/agents/{id}/files", ["Agents"]), + }) async files(@Ctx() ctx: AuthedTrpcContext, @Input("id") id: string) { return this.agents.files(id, ctx.user.id); } - @Mutation({ input: agentSaveFileInput }) + @Mutation({ + input: agentSaveFileInput, + output: agentSaveFileOutput, + meta: restMeta("POST", "/agents/{id}/save-file", ["Agents"]), + }) async saveFile( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -60,12 +93,20 @@ export class AgentsRouter { return this.agents.saveFile(input, ctx.user.id); } - @Query({ input: agentIdInput }) + @Query({ + input: agentIdInput, + output: agentByIdOutput, + meta: restMeta("GET", "/agents/{id}", ["Agents"]), + }) async byId(@Ctx() ctx: AuthedTrpcContext, @Input("id") id: string) { return this.agents.byId(id, ctx.user.id); } - @Query({ input: agentHistoryInput }) + @Query({ + input: agentHistoryInput, + output: agentHistoryOutput, + meta: restMeta("GET", "/agents/{id}/history", ["Agents"]), + }) async history( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -73,7 +114,11 @@ export class AgentsRouter { return this.runs.list(input.id, input.limit, ctx.user.id); } - @Query({ input: agentHistoryInput }) + @Query({ + input: agentHistoryInput, + output: agentActivityOutput, + meta: restMeta("GET", "/agents/{id}/activity", ["Agents"]), + }) async activity( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -81,7 +126,11 @@ export class AgentsRouter { return this.runs.activity(input.id, input.limit, ctx.user.id); } - @Mutation({ input: agentUpdateInput }) + @Mutation({ + input: agentUpdateInput, + output: agentUpdateOutput, + meta: restMeta("PATCH", "/agents/{id}", ["Agents"]), + }) async update( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -89,7 +138,11 @@ export class AgentsRouter { return this.agents.update(input, ctx.user.id); } - @Mutation({ input: agentDeployInput }) + @Mutation({ + input: agentDeployInput, + output: agentDeployOutput, + meta: restMeta("POST", "/agents/{id}/deploy", ["Agents"]), + }) async deploy( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -97,32 +150,56 @@ export class AgentsRouter { return this.agents.deploy(input, ctx.user.id); } - @Mutation({ input: agentIdInput }) + @Mutation({ + input: agentIdInput, + output: agentPauseOutput, + meta: restMeta("POST", "/agents/{id}/pause", ["Agents"]), + }) async pause(@Ctx() ctx: AuthedTrpcContext, @Input("id") id: string) { return this.agents.pause(id, ctx.user.id); } - @Mutation({ input: agentIdInput }) + @Mutation({ + input: agentIdInput, + output: agentResumeOutput, + meta: restMeta("POST", "/agents/{id}/resume", ["Agents"]), + }) async resume(@Ctx() ctx: AuthedTrpcContext, @Input("id") id: string) { return this.agents.resume(id, ctx.user.id); } - @Mutation({ input: agentIdInput }) + @Mutation({ + input: agentIdInput, + output: agentArchiveOutput, + meta: restMeta("POST", "/agents/{id}/archive", ["Agents"]), + }) async archive(@Ctx() ctx: AuthedTrpcContext, @Input("id") id: string) { return this.agents.archive(id, ctx.user.id); } - @Mutation({ input: agentIdInput }) + @Mutation({ + input: agentIdInput, + output: agentRestoreOutput, + meta: restMeta("POST", "/agents/{id}/restore", ["Agents"]), + }) async restore(@Ctx() ctx: AuthedTrpcContext, @Input("id") id: string) { return this.agents.restore(id, ctx.user.id); } - @Mutation({ input: agentIdInput }) + @Mutation({ + input: agentIdInput, + output: agentRemoveOutput, + meta: restMeta("DELETE", "/agents/{id}", ["Agents"]), + }) async remove(@Ctx() ctx: AuthedTrpcContext, @Input("id") id: string) { return this.agents.remove(id, ctx.user.id); } - @Mutation({ input: agentRunNowInput }) + @Mutation({ + input: agentRunNowInput, + output: agentRunNowOutput, + meta: restMeta("POST", "/agents/{id}/run", ["Agents"]), + }) async runNow( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -130,7 +207,11 @@ export class AgentsRouter { return this.runs.runNow(input, ctx.user.id); } - @Mutation({ input: agentRetryRunInput }) + @Mutation({ + input: agentRetryRunInput, + output: agentRetryRunOutput, + meta: restMeta("POST", "/agents/{id}/runs/{runId}/retry", ["Agents"]), + }) async retryRun( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -138,7 +219,11 @@ export class AgentsRouter { return this.runs.retryRun(input, ctx.user.id); } - @Mutation({ input: agentCancelRunInput }) + @Mutation({ + input: agentCancelRunInput, + output: agentCancelRunOutput, + meta: restMeta("POST", "/agents/{id}/runs/{runId}/cancel", ["Agents"]), + }) async cancelRun( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, diff --git a/apps/api/src/api-keys/api-keys.contracts.ts b/apps/api/src/api-keys/api-keys.contracts.ts new file mode 100644 index 000000000..6c6dad497 --- /dev/null +++ b/apps/api/src/api-keys/api-keys.contracts.ts @@ -0,0 +1,47 @@ +import { API_KEY_EXPIRATION } from "@crm/auth"; +import { z } from "zod"; +import { listInput } from "../trpc/list-input"; + +export const apiKeyListInput = listInput; + +export const createApiKeyInput = z.object({ + name: z.string().trim().min(1).max(64), + expiresInDays: z + .number() + .int() + .min(API_KEY_EXPIRATION.minDays) + .max(API_KEY_EXPIRATION.maxDays) + .nullable(), +}); + +export const revokeApiKeyInput = z.object({ + id: z.string().trim().min(1), +}); + +export const apiKeySummaryOutput = z.object({ + id: z.string(), + name: z.string().nullable(), + start: z.string().nullable(), + enabled: z.boolean(), + createdAt: z.string(), + lastRequest: z.string().nullable(), + expiresAt: z.string().nullable(), +}); + +export const apiKeyListOutput = z.object({ + rows: z.array(apiKeySummaryOutput), + total: z.number(), + facetCounts: z.record(z.string(), z.record(z.string(), z.number())), +}); + +export const createApiKeyOutput = apiKeySummaryOutput.extend({ + key: z.string(), +}); + +export const revokeApiKeyOutput = z.object({ id: z.string() }); + +export type ApiKeyListInput = z.infer; +export type CreateApiKeyInput = z.infer; +export type RevokeApiKeyInput = z.infer; +export type ApiKeySummary = z.infer; +export type CreatedApiKey = z.infer; diff --git a/apps/api/src/api-keys/api-keys.module.ts b/apps/api/src/api-keys/api-keys.module.ts new file mode 100644 index 000000000..7110404f0 --- /dev/null +++ b/apps/api/src/api-keys/api-keys.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { TrpcModule } from "../trpc/trpc.module"; +import { ApiKeysRouter } from "./api-keys.router"; +import { ApiKeysService } from "./api-keys.service"; + +@Module({ + imports: [TrpcModule], + providers: [ApiKeysService, ApiKeysRouter], + exports: [ApiKeysService], +}) +export class ApiKeysModule {} diff --git a/apps/api/src/api-keys/api-keys.router.ts b/apps/api/src/api-keys/api-keys.router.ts new file mode 100644 index 000000000..190ee60ba --- /dev/null +++ b/apps/api/src/api-keys/api-keys.router.ts @@ -0,0 +1,72 @@ +import { Inject } from "@nestjs/common"; +import { fromNodeHeaders } from "better-auth/node"; +import { + Ctx, + Input, + Mutation, + Query, + Router, + UseMiddlewares, +} from "nestjs-trpc"; +import type { z } from "zod"; +import type { AuthedTrpcContext, BaseTrpcContext } from "../trpc/context.types"; +import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; +import { SessionOnlyMiddleware } from "../trpc/middlewares/session-only.middleware"; +import { restMeta } from "../trpc/openapi"; +import { + apiKeyListInput, + apiKeyListOutput, + createApiKeyInput, + createApiKeyOutput, + revokeApiKeyInput, + revokeApiKeyOutput, +} from "./api-keys.contracts"; +import { ApiKeysService } from "./api-keys.service"; + +function headersOf(ctx: BaseTrpcContext): Headers { + return fromNodeHeaders(ctx.req?.headers ?? {}); +} + +@Router({ alias: "apiKeys" }) +@UseMiddlewares(AuthMiddleware, SessionOnlyMiddleware) +export class ApiKeysRouter { + constructor( + @Inject(ApiKeysService) private readonly apiKeys: ApiKeysService, + ) {} + + @Query({ + input: apiKeyListInput, + output: apiKeyListOutput, + meta: restMeta("GET", "/api-keys", ["API Keys"]), + }) + async list( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.apiKeys.list(ctx.user.id, input); + } + + @Mutation({ + input: createApiKeyInput, + output: createApiKeyOutput, + meta: restMeta("POST", "/api-keys", ["API Keys"]), + }) + async create( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.apiKeys.create(ctx.user.id, headersOf(ctx), input); + } + + @Mutation({ + input: revokeApiKeyInput, + output: revokeApiKeyOutput, + meta: restMeta("DELETE", "/api-keys/{id}", ["API Keys"]), + }) + async revoke( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.apiKeys.revoke(ctx.user.id, headersOf(ctx), input); + } +} diff --git a/apps/api/src/api-keys/api-keys.service.ts b/apps/api/src/api-keys/api-keys.service.ts new file mode 100644 index 000000000..6266368d5 --- /dev/null +++ b/apps/api/src/api-keys/api-keys.service.ts @@ -0,0 +1,181 @@ +import { auth, DAY_SECONDS } from "@crm/auth"; +import type { Db, Prisma } from "@crm/db"; +import { + HttpException, + Injectable, + InternalServerErrorException, + Logger, +} from "@nestjs/common"; +import { APIError } from "better-auth/api"; +import { InjectDatabase } from "../database/database.constants"; +import { + type ListResult, + type OrderByColumns, + paginate, + resolveOrderBy, +} from "../trpc/list-input"; +import type { + ApiKeyListInput, + ApiKeySummary, + CreateApiKeyInput, + CreatedApiKey, + 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 STATUS_BY_CODE = new Map([ + ["BAD_REQUEST", 400], + ["UNAUTHORIZED", 401], + ["FORBIDDEN", 403], + ["NOT_FOUND", 404], + ["CONFLICT", 409], + ["UNPROCESSABLE_ENTITY", 400], +]); + +function toSummary(row: KeyRow): ApiKeySummary { + return { + id: row.id, + name: row.name, + start: row.start, + enabled: row.enabled ?? true, + createdAt: row.createdAt.toISOString(), + lastRequest: row.lastRequest?.toISOString() ?? null, + expiresAt: row.expiresAt?.toISOString() ?? null, + }; +} + +@Injectable() +export class ApiKeysService { + private readonly logger = new Logger(ApiKeysService.name); + + constructor(@InjectDatabase() private readonly db: Db) {} + + async list( + userId: string, + input: ApiKeyListInput, + ): Promise> { + const where = this.searchWhere(userId, input.q); + 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 }), + ]); + + return { rows: rows.map(toSummary), total, facetCounts: {} }; + } + + async create( + userId: string, + headers: Headers, + input: CreateApiKeyInput, + ): Promise { + const created = await this.call(() => + auth.api.createApiKey({ + headers, + body: { + name: input.name, + expiresIn: + input.expiresInDays === null + ? null + : input.expiresInDays * DAY_SECONDS, + }, + }), + ); + + this.logger.log({ + message: "API key created", + userId, + apiKeyId: created.id, + }); + + return { + id: created.id, + name: created.name, + start: created.start, + enabled: created.enabled, + createdAt: created.createdAt.toISOString(), + lastRequest: created.lastRequest?.toISOString() ?? null, + expiresAt: created.expiresAt?.toISOString() ?? null, + key: created.key, + }; + } + + async revoke( + userId: string, + headers: Headers, + input: RevokeApiKeyInput, + ): Promise<{ id: string }> { + await this.call(() => + auth.api.deleteApiKey({ headers, body: { keyId: input.id } }), + ); + + this.logger.log({ + message: "API key revoked", + userId, + apiKeyId: input.id, + }); + + return { id: input.id }; + } + + private searchWhere(userId: string, q: string): Prisma.ApikeyWhereInput { + const where: Prisma.ApikeyWhereInput = { referenceId: userId }; + const term = q.trim(); + + if (term) { + where.name = { contains: term, mode: "insensitive" }; + } + + return where; + } + + private async call(run: () => Promise): Promise { + try { + return await run(); + } catch (error) { + if (error instanceof APIError) { + const status = + STATUS_BY_CODE.get(error.body?.code ?? "") ?? error.statusCode; + + throw new HttpException( + error.body?.message ?? "The API key could not be saved.", + status, + ); + } + + this.logger.error( + { message: "API key call failed" }, + error instanceof Error ? error.stack : String(error), + ); + + throw new InternalServerErrorException( + "Could not reach the auth service.", + ); + } + } +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 74acf8de7..82a345df2 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -4,6 +4,8 @@ import { ConfigModule } from "@nestjs/config"; import { AuthModule as BetterAuthModule } from "@thallesp/nestjs-better-auth"; import { ActivitiesModule } from "./activities/activities.module"; import { AgentModule } from "./agent/agent.module"; +import { ApiKeysModule } from "./api-keys/api-keys.module"; +import { ArchiveModule } from "./archive/archive.module"; import { AuthModule } from "./auth/auth.module"; import { BackfillModule } from "./backfill/backfill.module"; import { AppCacheModule } from "./cache/cache.module"; @@ -24,6 +26,7 @@ import { LoggingModule } from "./logging/logging.module"; import { logAuthRoute } from "./logging/request-logger.middleware"; import { MailboxModule } from "./mailbox/mailbox.module"; import { MicrosoftModule } from "./microsoft/microsoft.module"; +import { SavedViewsModule } from "./saved-views/saved-views.module"; import { SearchModule } from "./search/search.module"; import { SettingsModule } from "./settings/settings.module"; import { SlackModule } from "./slack/slack.module"; @@ -51,6 +54,7 @@ import { WorkspaceModule } from "./workspace/workspace.module"; HealthModule, TrpcModule, UsersModule, + ApiKeysModule, CompaniesModule, ContactsModule, ConversationsModule, @@ -73,6 +77,8 @@ import { WorkspaceModule } from "./workspace/workspace.module"; BackfillModule, TelemetryModule, TrackingModule, + ArchiveModule, + SavedViewsModule, ], }) export class AppModule {} diff --git a/apps/api/src/archive/archive-config.ts b/apps/api/src/archive/archive-config.ts new file mode 100644 index 000000000..41829546c --- /dev/null +++ b/apps/api/src/archive/archive-config.ts @@ -0,0 +1,5 @@ +export const ARCHIVE = { + prune: { + maxBatch: 500, + }, +} as const; diff --git a/apps/api/src/archive/archive-retention.controller.ts b/apps/api/src/archive/archive-retention.controller.ts new file mode 100644 index 000000000..5050b43df --- /dev/null +++ b/apps/api/src/archive/archive-retention.controller.ts @@ -0,0 +1,116 @@ +import type { Db } from "@crm/db"; +import { readArchiveRetentionDays } from "@crm/db/settings"; +import { + Controller, + ForbiddenException, + Get, + Headers, + Logger, + Post, + ServiceUnavailableException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { + ApiExcludeEndpoint, + ApiForbiddenResponse, + ApiHeader, + ApiOkResponse, + ApiOperation, + ApiServiceUnavailableResponse, + ApiTags, +} from "@nestjs/swagger"; +import { AllowAnonymous } from "@thallesp/nestjs-better-auth"; +import { CompaniesService } from "../companies/companies.service"; +import type { EnvironmentVariables } from "../config/env.validation"; +import { ContactsService } from "../contacts/contacts.service"; +import { InjectDatabase } from "../database/database.constants"; +import { DealsService } from "../deals/deals.service"; + +const DAY_MS = 24 * 60 * 60_000; + +@ApiTags("Internal — Cron") +@ApiHeader({ + name: "authorization", + description: "`Bearer `", + required: true, +}) +@ApiForbiddenResponse({ description: "CRON_SECRET did not match." }) +@ApiServiceUnavailableResponse({ description: "CRON_SECRET is not set." }) +@Controller("internal/archive") +export class ArchiveRetentionController { + private readonly logger = new Logger(ArchiveRetentionController.name); + private readonly secret: string | undefined; + + constructor( + @InjectDatabase() private readonly db: Db, + private readonly companies: CompaniesService, + private readonly contacts: ContactsService, + private readonly deals: DealsService, + config: ConfigService, + ) { + this.secret = config.get("CRON_SECRET", { infer: true }); + } + + @Get("prune") + @AllowAnonymous() + @ApiOperation({ + summary: "Purge companies, contacts and deals past the archive window", + }) + @ApiOkResponse({ description: "The prune ran; per-record-type counts." }) + async pruneViaGet(@Headers("authorization") authorization?: string) { + return this.run(authorization); + } + + @Post("prune") + @AllowAnonymous() + @ApiExcludeEndpoint() + async pruneViaPost(@Headers("authorization") authorization?: string) { + return this.run(authorization); + } + + private async run(authorization?: string) { + if (!this.secret) { + this.logger.error({ + message: "CRON_SECRET is not set — refusing to run archive pruning.", + }); + throw new ServiceUnavailableException("Pruning is not configured."); + } + + if (!timingSafeEquals(authorization ?? "", `Bearer ${this.secret}`)) { + throw new ForbiddenException(); + } + + const retentionDays = await readArchiveRetentionDays(this.db); + const before = new Date(Date.now() - retentionDays * DAY_MS); + + const [companies, contacts, deals] = await Promise.all([ + this.companies.purgeExpired(before), + this.contacts.purgeExpired(before), + this.deals.purgeExpired(before), + ]); + + this.logger.log({ + message: "Archive retention swept", + retentionDays, + companies: companies.succeeded, + companiesSkipped: companies.skipped, + contacts: contacts.succeeded, + contactsSkipped: contacts.skipped, + deals: deals.succeeded, + dealsSkipped: deals.skipped, + }); + + return { retentionDays, companies, contacts, deals }; + } +} + +function timingSafeEquals(a: string, b: string): boolean { + if (a.length !== b.length) return false; + + let mismatch = 0; + for (let index = 0; index < a.length; index += 1) { + mismatch |= a.charCodeAt(index) ^ b.charCodeAt(index); + } + + return mismatch === 0; +} diff --git a/apps/api/src/archive/archive.module.ts b/apps/api/src/archive/archive.module.ts new file mode 100644 index 000000000..4e8feb6ad --- /dev/null +++ b/apps/api/src/archive/archive.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { CompaniesModule } from "../companies/companies.module"; +import { ContactsModule } from "../contacts/contacts.module"; +import { DealsModule } from "../deals/deals.module"; +import { ArchiveRetentionController } from "./archive-retention.controller"; + +@Module({ + imports: [CompaniesModule, ContactsModule, DealsModule], + controllers: [ArchiveRetentionController], +}) +export class ArchiveModule {} diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 3bc5f5168..dc283ca70 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -1,5 +1,12 @@ -import type { auth } from "@crm/auth"; +import { type auth, SESSION_COOKIE_NAME } from "@crm/auth"; import { Controller, Get } from "@nestjs/common"; +import { + ApiCookieAuth, + ApiOkResponse, + ApiOperation, + ApiTags, + ApiUnauthorizedResponse, +} from "@nestjs/swagger"; import { OptionalAuth, Session, @@ -9,17 +16,28 @@ import { AuthService } from "./auth.service"; type CrmSession = UserSession; +@ApiTags("Auth") +@ApiCookieAuth(SESSION_COOKIE_NAME) @Controller("auth") export class AuthController { constructor(private readonly authService: AuthService) {} @Get("me") + @ApiOperation({ summary: "Get the signed-in user's profile" }) + @ApiOkResponse({ description: "The signed-in user's profile." }) + @ApiUnauthorizedResponse({ description: "No valid session." }) async getMe(@Session() session: CrmSession) { return { user: await this.authService.getProfile(session.user.id) }; } @Get("session") @OptionalAuth() + @ApiOperation({ + summary: "Check whether the current request carries a valid session", + }) + @ApiOkResponse({ + description: "Whether the request is authenticated, and as whom.", + }) getSession(@Session() session?: CrmSession) { if (!session) { return { authenticated: false, user: null }; diff --git a/apps/api/src/companies/companies.contracts.ts b/apps/api/src/companies/companies.contracts.ts index bbd3b67b7..d90faf426 100644 --- a/apps/api/src/companies/companies.contracts.ts +++ b/apps/api/src/companies/companies.contracts.ts @@ -1,13 +1,18 @@ +import { DealStage, EnrichmentStatus, RecordSource } from "@crm/db"; +import { FIELD_TYPES } from "@crm/db/fields"; import { z } from "zod"; import { bulkIdsInput } from "../crm/bulk"; -import { recordFieldValues } from "../fields/fields.contracts"; -import { listInput } from "../trpc/list-input"; +import { fieldEntity, recordFieldValues } from "../fields/fields.contracts"; +import { activityFacetInput, listInput } from "../trpc/list-input"; export const companyListInput = listInput.extend({ - owner: z.string().default("all"), - industry: z.string().default("all"), - enrichment: z.string().default("all"), - source: z.string().default("all"), + owner: z.array(z.string()).default([]), + industry: z.array(z.string()).default([]), + enrichment: z.array(z.string()).default([]), + source: z.array(z.string()).default([]), + activity: activityFacetInput.default([]), + fields: z.record(z.string(), z.array(z.string())).default({}), + archived: z.boolean().default(false), }); export type CompanyListInput = z.infer; @@ -61,3 +66,192 @@ export const companyBulkOwnerInput = bulkIdsInput.extend({ }); export type CompanyBulkOwnerInput = z.infer; + +const companyEnrichmentStatus = z.enum( + Object.values(EnrichmentStatus) as [EnrichmentStatus, ...EnrichmentStatus[]], +); + +const companyRecordSource = z.enum( + Object.values(RecordSource) as [RecordSource, ...RecordSource[]], +); + +const companyDealStage = z.enum( + Object.values(DealStage) as [DealStage, ...DealStage[]], +); + +const companyFieldType = z.enum(FIELD_TYPES); + +const ownerSummaryOutput = z.object({ + id: z.string(), + name: z.string(), + email: z.string(), + image: z.string().nullable(), +}); + +const companyFieldOptionOutput = z.object({ + id: z.string(), + label: z.string(), + position: z.number(), +}); + +const companyRecordFieldOutput = z.object({ + id: z.string(), + entity: fieldEntity, + key: z.string(), + label: z.string(), + type: companyFieldType, + typeLabel: z.string(), + agentFilled: z.boolean(), + agentBrief: z.string().nullable(), + required: z.boolean(), + showOnSheet: z.boolean(), + showOnTable: z.boolean(), + showOnFilter: z.boolean(), + position: z.number(), + archived: z.boolean(), + options: z.array(companyFieldOptionOutput), + value: z.union([z.string(), z.number(), z.boolean(), z.null()]), +}); + +export const companyRowOutput = z.object({ + id: z.string(), + name: z.string(), + domain: z.string().nullable(), + iconUrl: z.string().nullable(), + iconDarkUrl: z.string().nullable(), + iconTone: z.string().nullable(), + logoUrl: z.string().nullable(), + brandColor: z.string().nullable(), + industry: z.string().nullable(), + enrichmentStatus: companyEnrichmentStatus, + queued: z.boolean(), + source: companyRecordSource, + owner: ownerSummaryOutput.nullable(), + contactCount: z.number(), + openDealCount: z.number(), + lastActivityAt: z.string().nullable(), + createdAt: z.string(), + archivedAt: z.string().nullable(), + fields: recordFieldValues, +}); + +export type CompanyRow = z.infer; + +export const companyListOutput = z.object({ + rows: z.array(companyRowOutput), + total: z.number(), + facetCounts: z.record(z.string(), z.record(z.string(), z.number())), +}); + +const companyDetailContactOutput = z.object({ + id: z.string(), + firstName: z.string(), + lastName: z.string().nullable(), + email: z.string().nullable(), + title: z.string().nullable(), + imageUrl: z.string().nullable(), + owner: ownerSummaryOutput.nullable(), +}); + +const companyDetailDealOutput = z.object({ + id: z.string(), + name: z.string(), + stage: companyDealStage, + currency: z.string(), + expectedCloseDate: z.string().nullable(), + owner: ownerSummaryOutput.nullable(), + amountCents: z.number().nullable(), + baseAmountCents: z.number().nullable(), +}); + +const companyDetailPrimaryContactOutput = z.object({ + id: z.string(), + firstName: z.string(), + lastName: z.string().nullable(), + email: z.string().nullable(), + phone: z.string().nullable(), + title: z.string().nullable(), +}); + +export const companyDetailOutput = z.object({ + id: z.string(), + name: z.string(), + domain: z.string().nullable(), + website: z.string().nullable(), + description: z.string().nullable(), + logoUrl: z.string().nullable(), + logoDarkUrl: z.string().nullable(), + iconUrl: z.string().nullable(), + iconDarkUrl: z.string().nullable(), + iconTone: z.string().nullable(), + brandColor: z.string().nullable(), + industry: z.string().nullable(), + subIndustry: z.string().nullable(), + city: z.string().nullable(), + stateCode: z.string().nullable(), + country: z.string().nullable(), + countryCode: z.string().nullable(), + phone: z.string().nullable(), + email: z.string().nullable(), + linkedinUrl: z.string().nullable(), + twitterUrl: z.string().nullable(), + githubUrl: z.string().nullable(), + pricingUrl: z.string().nullable(), + careersUrl: z.string().nullable(), + enrichmentStatus: companyEnrichmentStatus, + enrichmentError: z.string().nullable(), + source: companyRecordSource, + owner: ownerSummaryOutput.nullable(), + contacts: z.array(companyDetailContactOutput), + fields: z.array(companyRecordFieldOutput), + queued: z.boolean(), + createdAt: z.string(), + archivedAt: z.string().nullable(), + enrichedAt: z.string().nullable(), + primaryContactId: z.string().nullable(), + primaryContact: companyDetailPrimaryContactOutput.nullable(), + reportingCurrency: z.string(), + deals: z.array(companyDetailDealOutput), +}); + +export const companyOptionOutput = z.array( + z.object({ + id: z.string(), + name: z.string(), + domain: z.string().nullable(), + iconUrl: z.string().nullable(), + }), +); + +export const companySummaryOutput = z.object({ + id: z.string(), + name: z.string(), + domain: z.string().nullable(), +}); + +export const companyArchiveResultOutput = z.object({ + id: z.string(), + name: z.string(), +}); + +export const companyBulkResultOutput = z.object({ + requested: z.number(), + succeeded: z.number(), + failed: z.number(), + message: z.string().nullable(), +}); + +export const companyEnrichOutput = z.object({ + id: z.string(), + queued: z.boolean(), +}); + +export const companyResearchOutput = z.object({ + ok: z.literal(true), + queued: z.literal(true), +}); + +export const companySetPrimaryContactOutput = z.object({ + id: z.string(), + primaryContactId: z.string().nullable(), +}); diff --git a/apps/api/src/companies/companies.router.ts b/apps/api/src/companies/companies.router.ts index 42f0c68f5..ac11ccd35 100644 --- a/apps/api/src/companies/companies.router.ts +++ b/apps/api/src/companies/companies.router.ts @@ -10,13 +10,23 @@ import { import type { z } from "zod"; import type { AuthedTrpcContext } from "../trpc/context.types"; import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; +import { restMeta } from "../trpc/openapi"; import { + companyArchiveResultOutput, companyBulkInput, companyBulkOwnerInput, + companyBulkResultOutput, companyCreateInput, + companyDetailOutput, + companyEnrichOutput, companyIdInput, companyListInput, + companyListOutput, + companyOptionOutput, companyOptionsInput, + companyResearchOutput, + companySetPrimaryContactOutput, + companySummaryOutput, companyUpdateArgs, setPrimaryContactInput, } from "./companies.contracts"; @@ -29,62 +39,148 @@ export class CompaniesRouter { @Inject(CompaniesService) private readonly companies: CompaniesService, ) {} - @Query({ input: companyListInput }) + @Query({ + input: companyListInput, + output: companyListOutput, + meta: restMeta("POST", "/companies/search", ["Companies"]), + }) async list(@Input() input: z.infer) { return this.companies.list(input); } - @Query({ input: companyIdInput }) + @Query({ + input: companyIdInput, + output: companyDetailOutput, + meta: restMeta("GET", "/companies/{id}", ["Companies"]), + }) async byId(@Input("id") id: string) { return this.companies.byId(id); } - @Query({ input: companyOptionsInput }) + @Query({ + input: companyOptionsInput, + output: companyOptionOutput, + meta: restMeta("GET", "/companies/options", ["Companies"]), + }) async options(@Input("q") q: string) { return this.companies.options(q); } - @Mutation({ input: companyCreateInput }) + @Mutation({ + input: companyCreateInput, + output: companySummaryOutput, + meta: restMeta("POST", "/companies", ["Companies"]), + }) async create(@Input() input: z.infer) { return this.companies.create(input); } - @Mutation({ input: companyUpdateArgs }) + @Mutation({ + input: companyUpdateArgs, + output: companySummaryOutput, + meta: restMeta("PATCH", "/companies/{id}", ["Companies"]), + }) async update(@Input() input: z.infer) { return this.companies.update(input.id, input.data); } - @Mutation({ input: companyIdInput }) - async delete(@Input("id") id: string) { - return this.companies.delete(id); + @Mutation({ + input: companyIdInput, + output: companyArchiveResultOutput, + meta: restMeta("POST", "/companies/{id}/archive", ["Companies"]), + }) + async archive(@Input("id") id: string) { + return this.companies.archive(id); } - @Mutation({ input: companyBulkOwnerInput }) + @Mutation({ + input: companyIdInput, + output: companyArchiveResultOutput, + meta: restMeta("POST", "/companies/{id}/restore", ["Companies"]), + }) + async restore(@Input("id") id: string) { + return this.companies.restore(id); + } + + @Mutation({ + input: companyIdInput, + output: companyArchiveResultOutput, + meta: restMeta("DELETE", "/companies/{id}", ["Companies"]), + }) + async purge(@Input("id") id: string) { + return this.companies.purge(id); + } + + @Mutation({ + input: companyBulkOwnerInput, + output: companyBulkResultOutput, + meta: restMeta("POST", "/companies/bulk-assign-owner", ["Companies"]), + }) async bulkAssignOwner(@Input() input: z.infer) { return this.companies.bulkAssignOwner(input); } - @Mutation({ input: companyBulkInput }) + @Mutation({ + input: companyBulkInput, + output: companyBulkResultOutput, + meta: restMeta("POST", "/companies/bulk-enrich", ["Companies"]), + }) async bulkEnrich(@Input("ids") ids: string[]) { return this.companies.bulkEnrich(ids); } - @Mutation({ input: companyBulkInput }) - async bulkDelete(@Input("ids") ids: string[]) { - return this.companies.bulkDelete(ids); + @Mutation({ + input: companyBulkInput, + output: companyBulkResultOutput, + meta: restMeta("POST", "/companies/bulk-archive", ["Companies"]), + }) + async bulkArchive(@Input("ids") ids: string[]) { + return this.companies.bulkArchive(ids); + } + + @Mutation({ + input: companyBulkInput, + output: companyBulkResultOutput, + meta: restMeta("POST", "/companies/bulk-restore", ["Companies"]), + }) + async bulkRestore(@Input("ids") ids: string[]) { + return this.companies.bulkRestore(ids); + } + + @Mutation({ + input: companyBulkInput, + output: companyBulkResultOutput, + meta: restMeta("POST", "/companies/bulk-purge", ["Companies"]), + }) + async bulkPurge(@Input("ids") ids: string[]) { + return this.companies.bulkPurge(ids); } - @Mutation({ input: companyIdInput }) + @Mutation({ + input: companyIdInput, + output: companyEnrichOutput, + meta: restMeta("POST", "/companies/{id}/enrich", ["Companies"]), + }) async enrich(@Input("id") id: string) { return this.companies.enrich(id); } - @Mutation({ input: companyIdInput }) + @Mutation({ + input: companyIdInput, + output: companyResearchOutput, + meta: restMeta("POST", "/companies/{id}/research", ["Companies"]), + }) async research(@Ctx() ctx: AuthedTrpcContext, @Input("id") id: string) { return this.companies.research(id, ctx.user.id); } - @Mutation({ input: setPrimaryContactInput }) + @Mutation({ + input: setPrimaryContactInput, + output: companySetPrimaryContactOutput, + meta: restMeta("POST", "/companies/{companyId}/set-primary-contact", [ + "Companies", + ]), + }) async setPrimaryContact( @Input() input: z.infer, ) { diff --git a/apps/api/src/companies/companies.service.ts b/apps/api/src/companies/companies.service.ts index cc70757f8..e9ec9d890 100644 --- a/apps/api/src/companies/companies.service.ts +++ b/apps/api/src/companies/companies.service.ts @@ -6,6 +6,7 @@ import { type RecordSource, } from "@crm/db"; import { OPEN_DEAL_STAGES } from "@crm/db/deal-stage"; +import type { FieldDefinitionWithOptions } from "@crm/db/fields"; import { BadRequestException, ConflictException, @@ -15,6 +16,7 @@ import { } from "@nestjs/common"; import { AgentQueueService } from "../agent/agent-queue.service"; import { AgentTriggerService } from "../agent/agent-trigger.service"; +import { ARCHIVE } from "../archive/archive-config"; import { ActivityStampService, type StampTargets, @@ -25,8 +27,10 @@ import { ConversionService } from "../currency/conversion.service"; import { InjectDatabase } from "../database/database.constants"; import { FieldsService } from "../fields/fields.service"; import { + activityFacetCounts, + activityFilter, + archivedFilter, countsByKey, - FACET_ALL, FACET_UNASSIGNED, type ListResult, type OrderByColumns, @@ -38,6 +42,7 @@ import type { CompanyBulkOwnerInput, CompanyCreateInput, CompanyListInput, + CompanyRow, CompanyUpdateInput, } from "./companies.contracts"; import { normalizeDomain } from "./domain"; @@ -50,32 +55,6 @@ const OWNER_SELECT = { image: true, } as const; -export type CompanyRow = { - id: string; - name: string; - domain: string | null; - iconUrl: string | null; - iconDarkUrl: string | null; - iconTone: string | null; - logoUrl: string | null; - brandColor: string | null; - industry: string | null; - enrichmentStatus: EnrichmentStatus; - queued: boolean; - source: RecordSource; - owner: { - id: string; - name: string; - email: string; - image: string | null; - } | null; - contactCount: number; - openDealCount: number; - lastActivityAt: string | null; - createdAt: string; - fields: Record; -}; - const SORTABLE: OrderByColumns = { name: (dir) => ({ name: dir }), domain: (dir) => ({ domain: dir }), @@ -85,6 +64,7 @@ const SORTABLE: OrderByColumns = { deals: (dir) => ({ deals: { _count: dir } }), owner: (dir) => ({ owner: { name: dir } }), lastActivity: (dir) => ({ lastActivityAt: { sort: dir, nulls: "last" } }), + archivedAt: (dir) => ({ archivedAt: { sort: dir, nulls: "last" } }), }; @Injectable() @@ -102,7 +82,8 @@ export class CompaniesService { ) {} async list(input: CompanyListInput): Promise> { - const where = this.buildWhere(input); + const filterableFields = await this.fields.filterableFieldsFor("COMPANY"); + const where = this.buildWhere(input, filterableFields); const { skip, take } = paginate(input); const [rows, total, facetCounts] = await Promise.all([ @@ -134,10 +115,11 @@ export class CompaniesService { }, lastActivityAt: true, createdAt: true, + archivedAt: true, }, }), this.db.company.count({ where }), - this.facetCounts(input), + this.facetCounts(input, filterableFields), ]); const ids = rows.map((row) => row.id); @@ -165,6 +147,7 @@ export class CompaniesService { openDealCount: row._count.deals, lastActivityAt: row.lastActivityAt?.toISOString() ?? null, createdAt: row.createdAt.toISOString(), + archivedAt: row.archivedAt?.toISOString() ?? null, fields: tableFields.get(row.id) ?? {}, })), total, @@ -205,6 +188,7 @@ export class CompaniesService { enrichmentError: true, source: true, createdAt: true, + archivedAt: true, owner: { select: OWNER_SELECT }, primaryContact: { select: { @@ -248,13 +232,21 @@ export class CompaniesService { throw new NotFoundException(`No company with id ${id}.`); } - const { deals, primaryContact, enrichedAt, createdAt, ...rest } = company; + const { + deals, + primaryContact, + enrichedAt, + createdAt, + archivedAt, + ...rest + } = company; return { ...rest, fields: await this.fields.valuesFor("COMPANY", id), queued: await this.queue.isQueued({ companyId: id }), createdAt: createdAt.toISOString(), + archivedAt: archivedAt?.toISOString() ?? null, enrichedAt: enrichedAt?.toISOString() ?? null, primaryContactId: primaryContact?.id ?? null, primaryContact, @@ -283,8 +275,8 @@ export class CompaniesService { const domain = normalizeDomain(input.domain); if (domain) { - const existing = await this.db.company.findUnique({ - where: { domain }, + const existing = await this.db.company.findFirst({ + where: { domain, archivedAt: null }, select: { id: true, name: true }, }); if (existing) { @@ -322,6 +314,7 @@ export class CompaniesService { await this.agent.companyCreated(company.id); void this.favicon.backfill(company.id, company.domain); + void this.fields.queueBackfillForNewRecord("COMPANY", company.id); return { id: company.id, name: company.name, domain: company.domain }; } @@ -400,17 +393,84 @@ export class CompaniesService { } } - async delete(id: string): Promise<{ id: string; name: string }> { - let deleted: { targets: StampTargets; name: string }; + 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); + } + } + + 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 }); + + return { id, name: company.name }; + } catch (error) { + throw this.translate(error, id); + } + } + + async purge(id: string): Promise<{ id: string; name: string }>; + async purge( + id: string, + guard: { archivedBefore: Date }, + ): Promise<{ id: string; name: string } | null>; + async purge( + id: string, + guard?: { archivedBefore: Date }, + ): Promise<{ id: string; name: string } | null> { + 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 + `; + + if (!row) { + if (guard) return null; + throw new NotFoundException(`No company with id ${id}.`); + } + if ( + guard && + (!row.archivedAt || row.archivedAt > guard.archivedBefore) + ) { + return null; + } + const targets = await this.stamp.targetsOf( { OR: [{ companyId: id }, { deal: { companyId: id } }] }, tx, ); - await tx.agentTask.deleteMany({ where: { companyId: id } }); + 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) } }, + ], + }, + }); const company = await tx.company.delete({ where: { id }, @@ -423,10 +483,12 @@ export class CompaniesService { throw this.translate(error, id); } + if (!deleted) return null; + await this.stamp.recomputeAfterDelete(deleted.targets, { companyId: id }); this.logger.log({ - message: "Company deleted", + message: "Company purged", companyId: id, name: deleted.name, }); @@ -434,6 +496,19 @@ export class CompaniesService { return { id, name: deleted.name }; } + async purgeExpired(before: Date): Promise { + const expired = await this.db.company.findMany({ + where: { archivedAt: { lte: before } }, + select: { id: true }, + take: ARCHIVE.prune.maxBatch, + }); + + return runBulk( + expired.map((row) => row.id), + (id) => this.purge(id, { archivedBefore: before }), + ); + } + async bulkAssignOwner(input: CompanyBulkOwnerInput): Promise { const ownerId = input.ownerId || null; @@ -454,6 +529,7 @@ export class CompaniesService { return { requested: ids.length, succeeded: count, + skipped: 0, failed: ids.length - count, message: null, }; @@ -463,8 +539,16 @@ export class CompaniesService { return runBulk(ids, (id) => this.enrich(id)); } - async bulkDelete(ids: string[]): Promise { - return runBulk(ids, (id) => this.delete(id)); + async bulkArchive(ids: string[]): Promise { + return runBulk(ids, (id) => this.archive(id)); + } + + async bulkRestore(ids: string[]): Promise { + return runBulk(ids, (id) => this.restore(id)); + } + + async bulkPurge(ids: string[]): Promise { + return runBulk(ids, (id) => this.purge(id)); } async enrich(id: string): Promise<{ id: string; queued: boolean }> { @@ -549,58 +633,84 @@ export class CompaniesService { }; } - private buildWhere(input: CompanyListInput): Prisma.CompanyWhereInput { - const where: Prisma.CompanyWhereInput = { - ...this.searchFilter(input.q), - ...ownerFilter(input.owner), - }; - - if (input.industry !== FACET_ALL) { - where.industry = input.industry; + private buildWhere( + input: CompanyListInput, + filterableFields: FieldDefinitionWithOptions[], + ): Prisma.CompanyWhereInput { + const and: Prisma.CompanyWhereInput[] = [ + this.searchFilter(input.q), + archivedFilter(input.archived), + ...this.fields.fieldFilters(filterableFields, input.fields), + ]; + + const owner = ownerFilter(input.owner); + if (owner) and.push(owner); + + if (input.industry.length > 0) + and.push({ industry: { in: input.industry } }); + if (input.enrichment.length > 0) { + and.push({ + enrichmentStatus: { in: input.enrichment as EnrichmentStatus[] }, + }); } - - if (input.enrichment !== FACET_ALL) { - where.enrichmentStatus = input.enrichment as EnrichmentStatus; + if (input.source.length > 0) { + and.push({ source: { in: input.source as RecordSource[] } }); } - if (input.source !== FACET_ALL) { - where.source = input.source as RecordSource; - } + const activity = activityFilter(input.activity); + if (activity) and.push(activity); - return where; + return { AND: and }; } - private async facetCounts(input: CompanyListInput) { - const where = this.searchFilter(input.q); + private async facetCounts( + input: CompanyListInput, + filterableFields: FieldDefinitionWithOptions[], + ) { + const where = { + AND: [this.searchFilter(input.q), archivedFilter(input.archived)], + }; - const [owners, industries, enrichment, sources] = 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 }, - }), - ]); + 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] } }), + ), + 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"), + activity, + ...Object.fromEntries( + Object.entries(fieldFacets).map(([key, counts]) => [ + `field:${key}`, + counts, + ]), + ), }; } diff --git a/apps/api/src/companies/company-directory.service.ts b/apps/api/src/companies/company-directory.service.ts index abb3e97e8..b60f09f15 100644 --- a/apps/api/src/companies/company-directory.service.ts +++ b/apps/api/src/companies/company-directory.service.ts @@ -19,8 +19,8 @@ export class CompanyDirectoryService { const outcome = await this.agent.withCrmEvents(async (tx, emit) => { await lockIdempotencyKey(tx, `company-directory:${domain}`); - const existing = await tx.company.findUnique({ - where: { domain }, + const existing = await tx.company.findFirst({ + where: { domain, archivedAt: null }, select: { id: true }, }); if (existing) return { id: existing.id, created: false as const }; diff --git a/apps/api/src/contacts/contacts.contracts.ts b/apps/api/src/contacts/contacts.contracts.ts index 295dce939..c0d81ff82 100644 --- a/apps/api/src/contacts/contacts.contracts.ts +++ b/apps/api/src/contacts/contacts.contracts.ts @@ -1,12 +1,26 @@ +import { + DealStage, + EnrichmentStatus, + FactBand, + FactStatus, + RecordSource, +} from "@crm/db"; +import { FIELD_ENTITIES, FIELD_TYPES } from "@crm/db/fields"; import { z } from "zod"; import { bulkIdsInput } from "../crm/bulk"; import { recordFieldValues } from "../fields/fields.contracts"; -import { listInput } from "../trpc/list-input"; +import { activityFacetInput, listInput } from "../trpc/list-input"; export const contactListInput = listInput.extend({ - owner: z.string().default("all"), - company: z.string().default("all"), - source: z.string().default("all"), + owner: z.array(z.string()).default([]), + company: z.array(z.string()).default([]), + source: z.array(z.string()).default([]), + title: z.array(z.string()).default([]), + seniority: z.array(z.string()).default([]), + persona: z.array(z.string()).default([]), + activity: activityFacetInput.default([]), + fields: z.record(z.string(), z.array(z.string())).default({}), + archived: z.boolean().default(false), }); export type ContactListInput = z.infer; @@ -66,3 +80,210 @@ export const factDecisionInput = z.object({ }); export type FactDecisionInput = z.infer; + +const fieldValueOutput = z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), +]); + +const fieldOptionOutput = z.object({ + id: z.string(), + label: z.string(), + position: z.number(), +}); + +const recordFieldOutput = z.object({ + id: z.string(), + entity: z.enum(FIELD_ENTITIES), + key: z.string(), + label: z.string(), + type: z.enum(FIELD_TYPES), + typeLabel: z.string(), + agentFilled: z.boolean(), + agentBrief: z.string().nullable(), + required: z.boolean(), + showOnSheet: z.boolean(), + showOnTable: z.boolean(), + showOnFilter: z.boolean(), + position: z.number(), + archived: z.boolean(), + options: z.array(fieldOptionOutput), + value: fieldValueOutput, +}); + +const contactCompanyOutput = z.object({ + id: z.string(), + name: z.string(), + domain: z.string().nullable(), + iconUrl: z.string().nullable(), + iconDarkUrl: z.string().nullable(), + iconTone: z.string().nullable(), + logoUrl: z.string().nullable(), +}); + +const contactOwnerOutput = z.object({ + id: z.string(), + name: z.string(), + email: z.string(), + image: z.string().nullable(), +}); + +export const contactRowOutput = z.object({ + id: z.string(), + firstName: z.string(), + lastName: z.string().nullable(), + email: z.string().nullable(), + title: z.string().nullable(), + imageUrl: z.string().nullable(), + source: z.enum( + Object.values(RecordSource) as [RecordSource, ...RecordSource[]], + ), + company: contactCompanyOutput.nullable(), + owner: contactOwnerOutput.nullable(), + lastActivityAt: z.string().nullable(), + createdAt: z.string(), + archivedAt: z.string().nullable(), + fields: z.record(z.string(), fieldValueOutput), +}); + +export type ContactRow = z.infer; + +export const contactListOutput = z.object({ + rows: z.array(contactRowOutput), + total: z.number(), + facetCounts: z.record(z.string(), z.record(z.string(), z.number())), +}); + +const contactBriefSectionsOutput = z.object({ + currentRole: z.string().optional(), + tenure: z.string().optional(), + previousRoles: z.array(z.string()).optional(), + seniority: z.string().optional(), + function: z.string().optional(), + location: z.string().optional(), +}); + +const contactBriefOutput = z.object({ + narrative: z.string(), + sections: contactBriefSectionsOutput, + score: z.number(), + sourceUrl: z.string().nullable(), + refreshedAt: z.string(), +}); + +const contactFactEvidenceOutput = z.object({ + kind: z.string(), + detail: z.string(), + sourceUrl: z.string().optional(), +}); + +const contactFactOutput = z.object({ + id: z.string(), + field: z.string(), + value: z.string(), + score: z.number(), + band: z.enum(Object.values(FactBand) as [FactBand, ...FactBand[]]), + evidence: z.array(contactFactEvidenceOutput), + method: z.string(), + sourceUrl: z.string().nullable(), + status: z.enum(Object.values(FactStatus) as [FactStatus, ...FactStatus[]]), + observedAt: z.string(), +}); + +const contactRelationshipMeetingOutput = z.object({ + title: z.string(), + startsAt: z.string(), +}); + +const contactRelationshipColleagueOutput = z.object({ + id: z.string(), + name: z.string(), + title: z.string().nullable(), +}); + +const contactRelationshipOutput = z.object({ + emails: z.number(), + threads: z.number(), + lastReplyAt: z.string().nullable(), + meetings: z.number(), + nextMeeting: contactRelationshipMeetingOutput.nullable(), + colleagues: z.array(contactRelationshipColleagueOutput), +}); + +const contactDealOutput = z.object({ + id: z.string(), + name: z.string(), + stage: z.enum(Object.values(DealStage) as [DealStage, ...DealStage[]]), + currency: z.string(), + expectedCloseDate: z.string().nullable(), + owner: contactOwnerOutput, + role: z.string().nullable(), + amountCents: z.number().nullable(), +}); + +export const contactByIdOutput = z.object({ + id: z.string(), + firstName: z.string(), + lastName: z.string().nullable(), + email: z.string().nullable(), + phone: z.string().nullable(), + title: z.string().nullable(), + linkedinUrl: z.string().nullable(), + twitterUrl: z.string().nullable(), + githubUrl: z.string().nullable(), + imageUrl: z.string().nullable(), + enrichmentStatus: z.enum( + Object.values(EnrichmentStatus) as [ + EnrichmentStatus, + ...EnrichmentStatus[], + ], + ), + enrichmentError: z.string().nullable(), + owner: contactOwnerOutput.nullable(), + company: contactCompanyOutput + .extend({ + industry: z.string().nullable(), + primaryContactId: z.string().nullable(), + }) + .nullable(), + fields: z.array(recordFieldOutput), + queued: z.boolean(), + createdAt: z.string(), + archivedAt: z.string().nullable(), + brief: contactBriefOutput.nullable(), + facts: z.array(contactFactOutput), + relationship: contactRelationshipOutput, + isPrimaryContact: z.boolean(), + deals: z.array(contactDealOutput), +}); + +export const contactBasicOutput = z.object({ + id: z.string(), + firstName: z.string(), + lastName: z.string().nullable(), +}); + +export const contactNameOutput = z.object({ + id: z.string(), + name: z.string(), +}); + +export const contactEnrichOutput = z.object({ + id: z.string(), + queued: z.literal(true), +}); + +export const bulkResultOutput = z.object({ + requested: z.number(), + succeeded: z.number(), + failed: z.number(), + message: z.string().nullable(), +}); + +export const decideFactOutput = z.object({ + contactId: z.string(), + field: z.string(), + applied: z.boolean(), +}); diff --git a/apps/api/src/contacts/contacts.router.ts b/apps/api/src/contacts/contacts.router.ts index d87f5a81a..025ff6710 100644 --- a/apps/api/src/contacts/contacts.router.ts +++ b/apps/api/src/contacts/contacts.router.ts @@ -10,14 +10,22 @@ import { import type { z } from "zod"; import type { AuthedTrpcContext } from "../trpc/context.types"; import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; +import { restMeta } from "../trpc/openapi"; import { + bulkResultOutput, + contactBasicOutput, contactBulkCompanyInput, contactBulkInput, contactBulkOwnerInput, + contactByIdOutput, contactCreateInput, + contactEnrichOutput, contactIdInput, contactListInput, + contactListOutput, + contactNameOutput, contactUpdateArgs, + decideFactOutput, factDecisionInput, } from "./contacts.contracts"; import { ContactsService } from "./contacts.service"; @@ -29,59 +37,139 @@ export class ContactsRouter { @Inject(ContactsService) private readonly contacts: ContactsService, ) {} - @Query({ input: contactListInput }) + @Query({ + input: contactListInput, + output: contactListOutput, + meta: restMeta("POST", "/contacts/search", ["Contacts"]), + }) async list(@Input() input: z.infer) { return this.contacts.list(input); } - @Query({ input: contactIdInput }) + @Query({ + input: contactIdInput, + output: contactByIdOutput, + meta: restMeta("GET", "/contacts/{id}", ["Contacts"]), + }) async byId(@Input("id") id: string) { return this.contacts.byId(id); } - @Mutation({ input: contactCreateInput }) + @Mutation({ + input: contactCreateInput, + output: contactBasicOutput, + meta: restMeta("POST", "/contacts", ["Contacts"]), + }) async create(@Input() input: z.infer) { return this.contacts.create(input); } - @Mutation({ input: contactUpdateArgs }) + @Mutation({ + input: contactUpdateArgs, + output: contactBasicOutput, + meta: restMeta("PATCH", "/contacts/{id}", ["Contacts"]), + }) async update(@Input() input: z.infer) { return this.contacts.update(input.id, input.data); } - @Mutation({ input: contactIdInput }) - async delete(@Input("id") id: string) { - return this.contacts.delete(id); + @Mutation({ + input: contactIdInput, + output: contactNameOutput, + meta: restMeta("POST", "/contacts/{id}/archive", ["Contacts"]), + }) + async archive(@Input("id") id: string) { + return this.contacts.archive(id); } - @Mutation({ input: contactIdInput }) + @Mutation({ + input: contactIdInput, + output: contactNameOutput, + meta: restMeta("POST", "/contacts/{id}/restore", ["Contacts"]), + }) + async restore(@Input("id") id: string) { + return this.contacts.restore(id); + } + + @Mutation({ + input: contactIdInput, + output: contactNameOutput, + meta: restMeta("DELETE", "/contacts/{id}", ["Contacts"]), + }) + async purge(@Input("id") id: string) { + return this.contacts.purge(id); + } + + @Mutation({ + input: contactIdInput, + output: contactEnrichOutput, + meta: restMeta("POST", "/contacts/{id}/enrich", ["Contacts"]), + }) async enrich(@Input("id") id: string) { return this.contacts.enrich(id); } - @Mutation({ input: contactBulkOwnerInput }) + @Mutation({ + input: contactBulkOwnerInput, + output: bulkResultOutput, + meta: restMeta("POST", "/contacts/bulk-assign-owner", ["Contacts"]), + }) async bulkAssignOwner(@Input() input: z.infer) { return this.contacts.bulkAssignOwner(input); } - @Mutation({ input: contactBulkCompanyInput }) + @Mutation({ + input: contactBulkCompanyInput, + output: bulkResultOutput, + meta: restMeta("POST", "/contacts/bulk-set-company", ["Contacts"]), + }) async bulkSetCompany( @Input() input: z.infer, ) { return this.contacts.bulkSetCompany(input); } - @Mutation({ input: contactBulkInput }) + @Mutation({ + input: contactBulkInput, + output: bulkResultOutput, + meta: restMeta("POST", "/contacts/bulk-enrich", ["Contacts"]), + }) async bulkEnrich(@Input("ids") ids: string[]) { return this.contacts.bulkEnrich(ids); } - @Mutation({ input: contactBulkInput }) - async bulkDelete(@Input("ids") ids: string[]) { - return this.contacts.bulkDelete(ids); + @Mutation({ + input: contactBulkInput, + output: bulkResultOutput, + meta: restMeta("POST", "/contacts/bulk-archive", ["Contacts"]), + }) + async bulkArchive(@Input("ids") ids: string[]) { + return this.contacts.bulkArchive(ids); + } + + @Mutation({ + input: contactBulkInput, + output: bulkResultOutput, + meta: restMeta("POST", "/contacts/bulk-restore", ["Contacts"]), + }) + async bulkRestore(@Input("ids") ids: string[]) { + return this.contacts.bulkRestore(ids); + } + + @Mutation({ + input: contactBulkInput, + output: bulkResultOutput, + meta: restMeta("POST", "/contacts/bulk-purge", ["Contacts"]), + }) + async bulkPurge(@Input("ids") ids: string[]) { + return this.contacts.bulkPurge(ids); } - @Mutation({ input: factDecisionInput }) + @Mutation({ + input: factDecisionInput, + output: decideFactOutput, + meta: restMeta("POST", "/contacts/decide-fact", ["Contacts"]), + }) async decideFact( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, diff --git a/apps/api/src/contacts/contacts.service.ts b/apps/api/src/contacts/contacts.service.ts index fabdbd56a..3e1565f4a 100644 --- a/apps/api/src/contacts/contacts.service.ts +++ b/apps/api/src/contacts/contacts.service.ts @@ -7,6 +7,7 @@ import { Prisma as PrismaNamespace, type RecordSource, } from "@crm/db"; +import type { FieldDefinitionWithOptions } from "@crm/db/fields"; import { ConflictException, Injectable, @@ -15,6 +16,7 @@ import { } from "@nestjs/common"; import { AgentQueueService } from "../agent/agent-queue.service"; import { AgentTriggerService } from "../agent/agent-trigger.service"; +import { ARCHIVE } from "../archive/archive-config"; import { CompanyDirectoryService } from "../companies/company-directory.service"; import { ActivityStampService, @@ -25,20 +27,24 @@ import { blankToNull, normalizeEmail, toCents } from "../crm/values"; import { InjectDatabase } from "../database/database.constants"; import { FieldsService } from "../fields/fields.service"; import { + activityFacetCounts, + activityFilter, + archivedFilter, countsByKey, - FACET_ALL, FACET_UNASSIGNED, type ListResult, type OrderByColumns, ownerFilter, paginate, resolveOrderBy, + splitSentinel, } from "../trpc/list-input"; import type { ContactBulkCompanyInput, ContactBulkOwnerInput, ContactCreateInput, ContactListInput, + ContactRow, ContactUpdateInput, FactDecisionInput, } from "./contacts.contracts"; @@ -66,38 +72,13 @@ type FactColumns = Record; const FACT_COLUMNS: FactColumns = { title: "title", + seniority: "seniority", + function: "function", linkedinUrl: "linkedinUrl", twitterUrl: "twitterUrl", githubUrl: "githubUrl", }; -export type ContactRow = { - id: string; - firstName: string; - lastName: string | null; - email: string | null; - title: string | null; - imageUrl: string | null; - company: { - id: string; - name: string; - domain: string | null; - iconUrl: string | null; - iconDarkUrl: string | null; - iconTone: string | null; - logoUrl: string | null; - } | null; - owner: { - id: string; - name: string; - email: string; - image: string | null; - } | null; - lastActivityAt: string | null; - createdAt: string; - fields: Record; -}; - const SORTABLE: OrderByColumns = { name: (dir) => [{ lastName: dir }, { firstName: dir }], email: (dir) => [{ email: dir }], @@ -106,6 +87,7 @@ const SORTABLE: OrderByColumns = { 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" } }], }; @Injectable() @@ -122,7 +104,8 @@ export class ContactsService { ) {} async list(input: ContactListInput): Promise> { - const where = this.buildWhere(input); + const filterableFields = await this.fields.filterableFieldsFor("CONTACT"); + const where = this.buildWhere(input, filterableFields); const { skip, take } = paginate(input); const [rows, total, facetCounts] = await Promise.all([ @@ -143,10 +126,11 @@ export class ContactsService { owner: { select: OWNER_SELECT }, lastActivityAt: true, createdAt: true, + archivedAt: true, }, }), this.db.contact.count({ where }), - this.facetCounts(input), + this.facetCounts(input, filterableFields), ]); const tableFields = await this.fields.tableValuesFor( @@ -159,6 +143,7 @@ export class ContactsService { ...row, lastActivityAt: row.lastActivityAt?.toISOString() ?? null, createdAt: row.createdAt.toISOString(), + archivedAt: row.archivedAt?.toISOString() ?? null, fields: tableFields.get(row.id) ?? {}, })), total, @@ -183,6 +168,7 @@ export class ContactsService { enrichmentStatus: true, enrichmentError: true, createdAt: true, + archivedAt: true, brief: { select: { narrative: true, @@ -240,7 +226,8 @@ export class ContactsService { contact.company?.id ?? null, ); - const { deals, createdAt, brief, facts, company, ...rest } = contact; + const { deals, createdAt, archivedAt, brief, facts, company, ...rest } = + contact; return { ...rest, @@ -248,6 +235,7 @@ export class ContactsService { fields: await this.fields.valuesFor("CONTACT", id), queued: await this.queue.isQueued({ contactId: id }), createdAt: createdAt.toISOString(), + archivedAt: archivedAt?.toISOString() ?? null, brief: brief ? { ...brief, @@ -277,7 +265,10 @@ export class ContactsService { if (email) { const existing = await this.db.contact.findFirst({ - where: { email: { equals: email, mode: "insensitive" } }, + where: { + email: { equals: email, mode: "insensitive" }, + archivedAt: null, + }, select: { id: true, firstName: true, lastName: true }, }); if (existing) { @@ -337,6 +328,17 @@ export class ContactsService { contact.id, "Added by a rep, with nothing on the record yet", ); + this.fields + .queueBackfillForNewRecord("CONTACT", contact.id) + .catch((error: Error) => { + this.logger.error( + { + message: "Contact backfill queueing failed", + contactId: contact.id, + }, + error.stack, + ); + }); return { id: contact.id, @@ -345,15 +347,70 @@ export class ContactsService { }; } - async delete(id: string): Promise<{ id: string; name: string }> { + 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); + } + } + + 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 }); + + return { id, name: nameOf(contact) }; + } catch (error) { + throw this.translate(error, id); + } + } + + async purge(id: string): Promise<{ id: string; name: string }>; + async purge( + id: string, + guard: { archivedBefore: Date }, + ): Promise<{ id: string; name: string } | null>; + async purge( + id: string, + guard?: { archivedBefore: Date }, + ): Promise<{ id: string; name: string } | null> { let deleted: { targets: StampTargets; name: string; suppressed: boolean; - }; + } | null; try { deleted = await this.db.$transaction(async (tx) => { + const [row] = await tx.$queryRaw>` + SELECT "archivedAt" FROM contact WHERE id = ${id} FOR UPDATE + `; + + if (!row) { + if (guard) return null; + throw new NotFoundException(`No contact with id ${id}.`); + } + if ( + guard && + (!row.archivedAt || row.archivedAt > guard.archivedBefore) + ) { + return null; + } + const targets = await this.stamp.targetsOf({ contactId: id }, tx); await tx.agentTask.deleteMany({ where: { contactId: id } }); @@ -364,9 +421,7 @@ export class ContactsService { select: { firstName: true, lastName: true, email: true }, }); - const name = [contact.firstName, contact.lastName] - .filter(Boolean) - .join(" "); + const name = nameOf(contact); const suppress = normalizeEmail(contact.email ?? ""); if (suppress) { @@ -386,10 +441,12 @@ export class ContactsService { throw this.translate(error, id); } + if (!deleted) return null; + await this.stamp.recomputeAfterDelete(deleted.targets, { contactId: id }); this.logger.log({ - message: "Contact deleted", + message: "Contact purged", contactId: id, suppressed: deleted.suppressed, }); @@ -397,6 +454,19 @@ export class ContactsService { return { id, name: deleted.name }; } + async purgeExpired(before: Date): Promise { + const expired = await this.db.contact.findMany({ + where: { archivedAt: { lte: before } }, + select: { id: true }, + take: ARCHIVE.prune.maxBatch, + }); + + return runBulk( + expired.map((row) => row.id), + (id) => this.purge(id, { archivedBefore: before }), + ); + } + async update(id: string, input: ContactUpdateInput) { const data: Prisma.ContactUpdateInput = {}; @@ -471,6 +541,7 @@ export class ContactsService { return { requested: ids.length, succeeded: count, + skipped: 0, failed: ids.length - count, message: null, }; @@ -504,6 +575,7 @@ export class ContactsService { return { requested: ids.length, succeeded: count, + skipped: 0, failed: ids.length - count, message: null, }; @@ -513,8 +585,16 @@ export class ContactsService { return runBulk(ids, (id) => this.enrich(id)); } - async bulkDelete(ids: string[]): Promise { - return runBulk(ids, (id) => this.delete(id)); + async bulkArchive(ids: string[]): Promise { + return runBulk(ids, (id) => this.archive(id)); + } + + async bulkRestore(ids: string[]): Promise { + return runBulk(ids, (id) => this.restore(id)); + } + + async bulkPurge(ids: string[]): Promise { + return runBulk(ids, (id) => this.purge(id)); } private async allowAgain( @@ -710,27 +790,66 @@ export class ContactsService { }; } - private buildWhere(input: ContactListInput): Prisma.ContactWhereInput { - const where: Prisma.ContactWhereInput = { - ...this.searchFilter(input.q), - ...ownerFilter(input.owner), - }; + private companyFilter( + values: string[], + ): Prisma.ContactWhereInput | undefined { + if (values.length === 0) return undefined; - if (input.company !== FACET_ALL) { - where.companyId = input.company === NO_COMPANY ? null : input.company; - } + 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 }] }; + } + + private buildWhere( + input: ContactListInput, + filterableFields: FieldDefinitionWithOptions[], + ): Prisma.ContactWhereInput { + const and: Prisma.ContactWhereInput[] = [ + this.searchFilter(input.q), + archivedFilter(input.archived), + ...this.fields.fieldFilters(filterableFields, input.fields), + ]; + + const owner = ownerFilter(input.owner); + if (owner) and.push(owner); - if (input.source !== FACET_ALL) { - where.source = input.source as RecordSource; + const company = this.companyFilter(input.company); + if (company) and.push(company); + + if (input.source.length > 0) { + and.push({ source: { in: input.source as RecordSource[] } }); + } + if (input.title.length > 0) and.push({ title: { in: input.title } }); + if (input.seniority.length > 0) { + and.push({ seniority: { in: input.seniority } }); } + if (input.persona.length > 0) and.push({ function: { in: input.persona } }); - return where; + const activity = activityFilter(input.activity); + if (activity) and.push(activity); + + return { AND: and }; } - private async facetCounts(input: ContactListInput) { - const where = this.searchFilter(input.q); + private async facetCounts( + input: ContactListInput, + filterableFields: FieldDefinitionWithOptions[], + ) { + const where = { + AND: [this.searchFilter(input.q), archivedFilter(input.archived)], + }; - const [owners, companies, sources] = await Promise.all([ + const [ + owners, + companies, + sources, + titles, + seniorities, + personas, + activity, + fieldFacets, + ] = await Promise.all([ this.db.contact.groupBy({ by: ["ownerId"], where, @@ -746,12 +865,41 @@ export class ContactsService { 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] } }), + ), + 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"), + activity, + ...Object.fromEntries( + Object.entries(fieldFacets).map(([key, counts]) => [ + `field:${key}`, + counts, + ]), + ), }; } @@ -769,3 +917,10 @@ export class ContactsService { throw cause; } } + +function nameOf(contact: { + firstName: string; + lastName: string | null; +}): string { + return [contact.firstName, contact.lastName].filter(Boolean).join(" "); +} diff --git a/apps/api/src/conversations/conversation-attachments.controller.ts b/apps/api/src/conversations/conversation-attachments.controller.ts index 1da698f73..fe1d08609 100644 --- a/apps/api/src/conversations/conversation-attachments.controller.ts +++ b/apps/api/src/conversations/conversation-attachments.controller.ts @@ -1,4 +1,4 @@ -import type { auth } from "@crm/auth"; +import { type auth, SESSION_COOKIE_NAME } from "@crm/auth"; import { Controller, Get, @@ -7,17 +7,35 @@ import { Res, StreamableFile, } from "@nestjs/common"; +import { + ApiCookieAuth, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiQuery, + ApiTags, +} from "@nestjs/swagger"; import { Session, type UserSession } from "@thallesp/nestjs-better-auth"; import type { Response } from "express"; import { ConversationsService } from "./conversations.service"; type CrmSession = UserSession; +@ApiTags("Conversations") +@ApiCookieAuth(SESSION_COOKIE_NAME) @Controller("api/conversations/attachments") export class ConversationAttachmentsController { constructor(private readonly conversations: ConversationsService) {} @Get(":id") + @ApiOperation({ summary: "Download a conversation attachment" }) + @ApiParam({ name: "id", description: "Attachment id." }) + @ApiQuery({ + name: "share", + required: false, + description: "Share token, for a link opened outside a session.", + }) + @ApiOkResponse({ description: "The attachment's raw bytes." }) async read( @Param("id") id: string, @Query("share") shareToken: string | undefined, diff --git a/apps/api/src/conversations/conversations.contracts.ts b/apps/api/src/conversations/conversations.contracts.ts index 1e8e9ad0a..80c810039 100644 --- a/apps/api/src/conversations/conversations.contracts.ts +++ b/apps/api/src/conversations/conversations.contracts.ts @@ -1,5 +1,43 @@ +import { + AgentBuilderArtifactStatus, + AgentConversationCommandType, + AgentConversationSubmissionStatus, + AgentDefinitionStatus, + AgentResponseRating, + AgentTriggerType, + AgentVersionStatus, +} from "@crm/db"; import { z } from "zod"; +const agentManifestSummaryOutput = z.object({ + name: z.string().optional(), + description: z.string().optional(), + access: z.array(z.string()), + triggers: z.array( + z.object({ type: z.string().optional(), summary: z.string().optional() }), + ), + actions: z.array(z.object({ summary: z.string().optional() })), + dataScope: z.object({ summary: z.string().optional() }), +}); + +const builderQuestionOutput = z + .object({ + kind: z.literal("question"), + requestId: z.string(), + prompt: z.string(), + display: z.enum(["confirmation", "select", "text"]).optional(), + options: z.array( + z.object({ + id: z.string(), + label: z.string(), + description: z.string().optional(), + style: z.enum(["danger", "default", "primary"]).optional(), + }), + ), + allowFreeform: z.boolean().optional(), + }) + .nullable(); + const recordShape = { contactId: z.string().trim().min(1).optional(), companyId: z.string().trim().min(1).optional(), @@ -142,3 +180,237 @@ function decodedBase64Size(value: string): number { const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0; return (value.length / 4) * 3 - padding; } + +const agentDefinitionStatusOutput = z.enum( + Object.values(AgentDefinitionStatus) as [ + AgentDefinitionStatus, + ...AgentDefinitionStatus[], + ], +); + +const agentVersionStatusOutput = z.enum( + Object.values(AgentVersionStatus) as [ + AgentVersionStatus, + ...AgentVersionStatus[], + ], +); + +const agentBuilderArtifactStatusOutput = z.enum( + Object.values(AgentBuilderArtifactStatus) as [ + AgentBuilderArtifactStatus, + ...AgentBuilderArtifactStatus[], + ], +); + +const agentTriggerTypeOutput = z.enum( + Object.values(AgentTriggerType) as [AgentTriggerType, ...AgentTriggerType[]], +); + +const agentConversationCommandTypeOutput = z.enum( + Object.values(AgentConversationCommandType) as [ + AgentConversationCommandType, + ...AgentConversationCommandType[], + ], +); + +const agentConversationSubmissionStatusOutput = z.enum( + Object.values(AgentConversationSubmissionStatus) as [ + AgentConversationSubmissionStatus, + ...AgentConversationSubmissionStatus[], + ], +); + +const agentResponseRatingOutput = z.enum( + Object.values(AgentResponseRating) as [ + AgentResponseRating, + ...AgentResponseRating[], + ], +); + +export const conversationIdOutput = z.object({ id: z.string() }); + +export const conversationSummaryOutput = z.object({ + id: z.string(), + sessionId: z.string(), + continuationToken: z.string().nullable(), + streamIndex: z.number(), + title: z.string().nullable(), + messageCount: z.number(), + lastMessageAt: z.string(), +}); + +export const conversationListOutput = z.array(conversationSummaryOutput); + +export type ConversationSummary = z.infer; + +const builderConversationAgentSummaryOutput = z.object({ + id: z.string(), + name: z.string(), + status: agentDefinitionStatusOutput, +}); + +export const builderConversationSummaryOutput = z.object({ + id: z.string(), + sessionId: z.string().nullable(), + continuationToken: z.string().nullable(), + streamIndex: z.number(), + title: z.string().nullable(), + messageCount: z.number(), + lastMessageAt: z.string(), + lastAssistantAt: z.string().nullable(), + unread: z.boolean(), + state: z.enum(["working", "unread", "deployed", "idle"]), + agent: builderConversationAgentSummaryOutput.nullable(), +}); + +export const builderListOutput = z.array(builderConversationSummaryOutput); + +export type BuilderConversationSummary = z.infer< + typeof builderConversationSummaryOutput +>; + +export const builderResourceOutput = z.object({ + kind: z.enum(["integration", "company", "contact", "deal"]), + id: z.string(), + label: z.string(), + detail: z.string().nullable(), + imageUrl: z.string().nullable(), +}); + +export const builderResourcesOutput = z.array(builderResourceOutput); + +export const agentBuilderArtifactOutput = z.object({ + id: z.string(), + versionId: z.string().nullable(), + path: z.string(), + language: z.string(), + content: z.string(), + previousContent: z.string().nullable(), + revision: z.number(), + status: agentBuilderArtifactStatusOutput, + createdAt: z.string(), +}); + +const agentTriggerSummaryOutput = z.object({ + id: z.string(), + type: agentTriggerTypeOutput, + name: z.string(), + config: z.unknown(), + enabled: z.boolean(), + nextRunAt: z.string().nullable(), +}); + +const agentCurrentVersionSummaryOutput = z.object({ + id: z.string(), + number: z.number(), + status: agentVersionStatusOutput, + manifest: z.unknown(), + modelId: z.string(), + sandboxPolicy: z.unknown(), + deployedAt: z.string().nullable(), +}); + +const builderAgentDetailOutput = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + status: agentDefinitionStatusOutput, + createdBy: z.object({ id: z.string(), name: z.string() }), + currentVersion: agentCurrentVersionSummaryOutput.nullable(), + triggers: z.array(agentTriggerSummaryOutput), +}); + +const builderCreatedVersionOutput = z.object({ + id: z.string(), + number: z.number(), + status: agentVersionStatusOutput, + instructions: z.string(), + manifest: agentManifestSummaryOutput, + modelId: z.string(), + sandboxPolicy: z.unknown(), + validation: z.unknown().nullable(), + createdAt: z.string(), +}); + +const builderFeedbackOutput = z.object({ + messageId: z.string(), + rating: agentResponseRatingOutput, +}); + +const builderSubmissionOutput = z.object({ + id: z.string(), + clientRequestId: z.string(), + commandType: agentConversationCommandTypeOutput, + message: z.record(z.string(), z.unknown()), + status: agentConversationSubmissionStatusOutput, + errorCode: z.string().nullable(), + errorMessage: z.string().nullable(), + createdAt: z.string(), + sentAt: z.string().nullable(), + acceptedAt: z.string().nullable(), +}); + +export const builderConversationDetailOutput = z.object({ + id: z.string(), + sessionId: z.string().nullable(), + continuationToken: z.string().nullable(), + streamIndex: z.number(), + title: z.string().nullable(), + messageCount: z.number(), + lastMessageAt: z.string(), + lastAssistantAt: z.string().nullable(), + lastReadAt: z.string().nullable(), + pendingQuestion: builderQuestionOutput, + agent: builderAgentDetailOutput.nullable(), + createdVersions: z.array(builderCreatedVersionOutput), + builderArtifacts: z.array(agentBuilderArtifactOutput), + feedback: z.array(builderFeedbackOutput), + submissions: z.array(builderSubmissionOutput), +}); + +export const conversationEventOutput = z.object({ + type: z.string(), + data: z.unknown(), + meta: z.object({ id: z.string(), at: z.string() }), +}); + +export const conversationEventsOutput = z.array(conversationEventOutput); + +export const builderResponseRatingOutput = z.object({ + id: z.string(), + rating: agentResponseRatingOutput.nullable(), +}); + +export const conversationShareStatusOutput = z.object({ + enabled: z.boolean(), + createdAt: z.string().nullable(), + expiresAt: z.string().nullable(), +}); + +export const conversationShareTokenOutput = z.object({ token: z.string() }); + +const sharedConversationAgentOutput = z.object({ + id: z.string(), + name: z.string(), + status: agentDefinitionStatusOutput, +}); + +const sharedConversationSubmissionOutput = z.object({ + id: z.string(), + commandType: agentConversationCommandTypeOutput, + message: z.record(z.string(), z.unknown()), + status: agentConversationSubmissionStatusOutput, + errorMessage: z.string().nullable(), + createdAt: z.string(), +}); + +export const sharedConversationOutput = z.object({ + id: z.string(), + title: z.string().nullable(), + ownerName: z.string(), + lastMessageAt: z.string(), + agent: sharedConversationAgentOutput.nullable(), + builderArtifacts: z.array(agentBuilderArtifactOutput), + submissions: z.array(sharedConversationSubmissionOutput), + events: conversationEventsOutput, +}); diff --git a/apps/api/src/conversations/conversations.router.ts b/apps/api/src/conversations/conversations.router.ts index e65987eed..6307e046d 100644 --- a/apps/api/src/conversations/conversations.router.ts +++ b/apps/api/src/conversations/conversations.router.ts @@ -10,18 +10,29 @@ import { import type { z } from "zod"; import type { AuthedTrpcContext } from "../trpc/context.types"; import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; +import { restMeta } from "../trpc/openapi"; import { ConversationSharingService } from "./conversation-sharing.service"; import { builderConversationCreateInput, + builderConversationDetailOutput, builderConversationSubmitInput, + builderListOutput, builderQuestionResponseInput, builderResourceSearchInput, + builderResourcesOutput, builderResponseRatingInput, + builderResponseRatingOutput, conversationEventsInput, + conversationEventsOutput, conversationIdInput, + conversationIdOutput, conversationListInput, + conversationListOutput, conversationSaveInput, + conversationShareStatusOutput, + conversationShareTokenOutput, sharedConversationInput, + sharedConversationOutput, } from "./conversations.contracts"; import { ConversationsService } from "./conversations.service"; @@ -35,7 +46,11 @@ export class ConversationsRouter { private readonly sharing: ConversationSharingService, ) {} - @Query({ input: conversationListInput }) + @Query({ + input: conversationListInput, + output: conversationListOutput, + meta: restMeta("GET", "/conversations", ["Conversations"]), + }) async list( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -43,22 +58,39 @@ export class ConversationsRouter { return this.conversations.list(input, ctx.user.id); } - @Query() + @Query({ + output: builderListOutput, + meta: restMeta("GET", "/conversations/builder", ["Conversations"]), + }) async builderList(@Ctx() ctx: AuthedTrpcContext) { return this.conversations.listBuilder(ctx.user.id); } - @Query({ input: builderResourceSearchInput }) + @Query({ + input: builderResourceSearchInput, + output: builderResourcesOutput, + meta: restMeta("GET", "/conversations/builder-resources", [ + "Conversations", + ]), + }) async builderResources(@Ctx() ctx: AuthedTrpcContext, @Input("q") q: string) { return this.conversations.builderResources(q, ctx.user.id); } - @Query({ input: conversationIdInput }) + @Query({ + input: conversationIdInput, + output: builderConversationDetailOutput, + meta: restMeta("GET", "/conversations/builder/{id}", ["Conversations"]), + }) async builderById(@Ctx() ctx: AuthedTrpcContext, @Input("id") id: string) { return this.conversations.builderById(id, ctx.user.id); } - @Query({ input: conversationEventsInput }) + @Query({ + input: conversationEventsInput, + output: conversationEventsOutput, + meta: restMeta("GET", "/conversations/{id}/events", ["Conversations"]), + }) async events( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -66,7 +98,11 @@ export class ConversationsRouter { return this.conversations.events(input, ctx.user.id); } - @Mutation({ input: conversationSaveInput }) + @Mutation({ + input: conversationSaveInput, + output: conversationIdOutput, + meta: restMeta("POST", "/conversations", ["Conversations"]), + }) async save( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -74,7 +110,11 @@ export class ConversationsRouter { return this.conversations.save(input, ctx.user.id); } - @Mutation({ input: builderConversationCreateInput }) + @Mutation({ + input: builderConversationCreateInput, + output: conversationIdOutput, + meta: restMeta("POST", "/conversations/builder", ["Conversations"]), + }) async createBuilder( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -82,7 +122,13 @@ export class ConversationsRouter { return this.conversations.createBuilder(input, ctx.user.id); } - @Mutation({ input: builderConversationSubmitInput }) + @Mutation({ + input: builderConversationSubmitInput, + output: conversationIdOutput, + meta: restMeta("POST", "/conversations/{id}/submit-builder", [ + "Conversations", + ]), + }) async submitBuilder( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -90,7 +136,13 @@ export class ConversationsRouter { return this.conversations.submitBuilder(input, ctx.user.id); } - @Mutation({ input: builderQuestionResponseInput }) + @Mutation({ + input: builderQuestionResponseInput, + output: conversationIdOutput, + meta: restMeta("POST", "/conversations/{id}/answer-builder-question", [ + "Conversations", + ]), + }) async answerBuilderQuestion( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -98,7 +150,13 @@ export class ConversationsRouter { return this.conversations.answerBuilderQuestion(input, ctx.user.id); } - @Mutation({ input: builderResponseRatingInput }) + @Mutation({ + input: builderResponseRatingInput, + output: builderResponseRatingOutput, + meta: restMeta("POST", "/conversations/{id}/rate-builder-response", [ + "Conversations", + ]), + }) async rateBuilderResponse( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -106,32 +164,56 @@ export class ConversationsRouter { return this.conversations.rateBuilderResponse(input, ctx.user.id); } - @Mutation({ input: conversationIdInput }) + @Mutation({ + input: conversationIdInput, + output: conversationIdOutput, + meta: restMeta("PATCH", "/conversations/{id}/read", ["Conversations"]), + }) async markRead(@Ctx() ctx: AuthedTrpcContext, @Input("id") id: string) { return this.conversations.markRead(id, ctx.user.id); } - @Query({ input: conversationIdInput }) + @Query({ + input: conversationIdInput, + output: conversationShareStatusOutput, + meta: restMeta("GET", "/conversations/{id}/share", ["Conversations"]), + }) async shareStatus(@Ctx() ctx: AuthedTrpcContext, @Input("id") id: string) { return this.sharing.status(id, ctx.user.id); } - @Mutation({ input: conversationIdInput }) + @Mutation({ + input: conversationIdInput, + output: conversationShareTokenOutput, + meta: restMeta("POST", "/conversations/{id}/share", ["Conversations"]), + }) async createShare(@Ctx() ctx: AuthedTrpcContext, @Input("id") id: string) { return this.sharing.create(id, ctx.user.id); } - @Mutation({ input: conversationIdInput }) + @Mutation({ + input: conversationIdInput, + output: conversationIdOutput, + meta: restMeta("DELETE", "/conversations/{id}/share", ["Conversations"]), + }) async revokeShare(@Ctx() ctx: AuthedTrpcContext, @Input("id") id: string) { return this.sharing.revoke(id, ctx.user.id); } - @Query({ input: sharedConversationInput }) + @Query({ + input: sharedConversationInput, + output: sharedConversationOutput, + meta: restMeta("GET", "/conversations/shared/{token}", ["Conversations"]), + }) async shared(@Ctx() ctx: AuthedTrpcContext, @Input("token") token: string) { return this.sharing.resolve(token, ctx.user.id); } - @Mutation({ input: conversationIdInput }) + @Mutation({ + input: conversationIdInput, + output: conversationIdOutput, + meta: restMeta("DELETE", "/conversations/{id}", ["Conversations"]), + }) async remove(@Ctx() ctx: AuthedTrpcContext, @Input("id") id: string) { return this.conversations.remove(id, ctx.user.id); } diff --git a/apps/api/src/conversations/conversations.service.ts b/apps/api/src/conversations/conversations.service.ts index 7ee80a4cf..d025c9e26 100644 --- a/apps/api/src/conversations/conversations.service.ts +++ b/apps/api/src/conversations/conversations.service.ts @@ -22,40 +22,14 @@ import { conversationShareTokenHash } from "./conversation-share-token"; import type { BuilderConversationCreateInput, BuilderConversationSubmitInput, + BuilderConversationSummary, BuilderQuestionResponseInput, ConversationEventsInput, ConversationListInput, ConversationSaveInput, + ConversationSummary, } from "./conversations.contracts"; -export interface ConversationSummary { - id: string; - sessionId: string; - continuationToken: string | null; - streamIndex: number; - title: string | null; - messageCount: number; - lastMessageAt: string; -} - -export interface BuilderConversationSummary { - id: string; - sessionId: string | null; - continuationToken: string | null; - streamIndex: number; - title: string | null; - messageCount: number; - lastMessageAt: string; - lastAssistantAt: string | null; - unread: boolean; - state: "working" | "unread" | "deployed" | "idle"; - agent: { - id: string; - name: string; - status: string; - } | null; -} - type ReplayedRequest = { id: string; }; diff --git a/apps/api/src/create-app.ts b/apps/api/src/create-app.ts index 98cee688d..c14a57bd6 100644 --- a/apps/api/src/create-app.ts +++ b/apps/api/src/create-app.ts @@ -1,12 +1,22 @@ +import { API_KEY_HEADER, apiUrl, SESSION_COOKIE_NAME } from "@crm/auth"; import { ValidationPipe } from "@nestjs/common"; import { NestFactory } from "@nestjs/core"; import { ExpressAdapter, type NestExpressApplication, } from "@nestjs/platform-express"; +import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; +import type { NextFunction, Request, Response } from "express"; import helmet from "helmet"; +import { AppRouterHost } from "nestjs-trpc"; +import { + createOpenApiExpressMiddleware, + generateOpenApiDocument, +} from "trpc-to-openapi"; import { AppModule } from "./app.module"; import { ContextLogger } from "./logging/context-logger"; +import { REST_BRIDGE_PATH } from "./trpc/openapi"; +import { createBaseTrpcContext } from "./trpc/trpc.context"; export async function createApp(): Promise { const app = await NestFactory.create( @@ -25,5 +35,77 @@ export async function createApp(): Promise { }), ); + let restBridge: ((req: Request, res: Response) => Promise) | undefined; + app.use( + REST_BRIDGE_PATH, + (req: Request, res: Response, next: NextFunction) => { + if (!restBridge) { + next(); + return; + } + void restBridge(req, res); + }, + ); + + const apiKeySecurityScheme = { + type: "apiKey", + in: "header", + name: API_KEY_HEADER, + } as const; + + // SwaggerModule.setup() registers its Express routes synchronously, so it must + // happen before app.init() the same way the REST bridge does — Nest's own + // routing (wired up during init) otherwise shadows anything registered after + // it. The factory form defers building the document (which needs the tRPC + // router, only available post-init) to first request instead. + SwaggerModule.setup("", app, () => { + const { appRouter } = app.get(AppRouterHost); + + const trpcDocument = generateOpenApiDocument(appRouter, { + title: "CRM API — tRPC bridge", + description: + "Every tRPC procedure, reachable over REST for tooling that cannot speak tRPC. Same validation, same middlewares, same services as the tRPC transport — this only translates the wire format.", + version: "1.0", + baseUrl: `${apiUrl}${REST_BRIDGE_PATH}`, + securitySchemes: { apiKey: apiKeySecurityScheme }, + }); + + const swaggerConfig = new DocumentBuilder() + .setTitle("CRM API") + .setDescription( + `REST surface of the CRM API — auth, health, the internal cron routes, and a generated REST bridge (under ${REST_BRIDGE_PATH}) for every tRPC procedure.`, + ) + .setVersion("1.0") + .addCookieAuth(SESSION_COOKIE_NAME) + .addApiKey(apiKeySecurityScheme, "apiKey") + .build(); + const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig); + + swaggerDocument.paths = { + ...swaggerDocument.paths, + ...(trpcDocument.paths as typeof swaggerDocument.paths), + }; + swaggerDocument.components = { + ...swaggerDocument.components, + schemas: { + ...swaggerDocument.components?.schemas, + ...(trpcDocument.components?.schemas as NonNullable< + typeof swaggerDocument.components + >["schemas"]), + }, + }; + + return swaggerDocument; + }); + + await app.init(); + + const { appRouter } = app.get(AppRouterHost); + + restBridge = createOpenApiExpressMiddleware({ + router: appRouter, + createContext: ({ req }) => createBaseTrpcContext(req), + }); + return app; } diff --git a/apps/api/src/crm/bulk.ts b/apps/api/src/crm/bulk.ts index 086da5998..d5b7b8980 100644 --- a/apps/api/src/crm/bulk.ts +++ b/apps/api/src/crm/bulk.ts @@ -14,6 +14,7 @@ export const bulkIdsInput = z.object({ export type BulkResult = { requested: number; succeeded: number; + skipped: number; failed: number; message: string | null; }; @@ -40,12 +41,17 @@ export async function runBulk( ): Promise { const unique = [...new Set(ids)]; let succeeded = 0; + let skipped = 0; let message: string | null = null; for (const id of unique) { try { - await act(id); - succeeded += 1; + const outcome = await act(id); + if (outcome === null) { + skipped += 1; + } else { + succeeded += 1; + } } catch (error) { message ??= error instanceof Error ? error.message : "Something went wrong."; @@ -55,7 +61,8 @@ export async function runBulk( return { requested: unique.length, succeeded, - failed: unique.length - succeeded, + skipped, + failed: unique.length - succeeded - skipped, message, }; } diff --git a/apps/api/src/currency/currency.contracts.ts b/apps/api/src/currency/currency.contracts.ts index 8530d966c..ae2c1b954 100644 --- a/apps/api/src/currency/currency.contracts.ts +++ b/apps/api/src/currency/currency.contracts.ts @@ -1,3 +1,4 @@ +import { RateSource } from "@crm/db"; import { isCurrencyCode } from "@crm/db/currency"; import { z } from "zod"; @@ -30,3 +31,47 @@ export const removeManualRateInput = z.object({ }); export type RemoveManualRateInput = z.infer; + +const rateSourceOutput = z.enum([RateSource.FETCHED, RateSource.MANUAL]); + +export const currencyRateOutput = z.object({ + currency: z.string(), + name: z.string().nullable(), + rate: z.number(), + asOf: z.string(), + source: rateSourceOutput, + provider: z.string().nullable(), + overriding: z.boolean(), +}); + +export const currencyInUseOutput = z.object({ + currency: z.string(), + name: z.string().nullable(), + deals: z.number(), + convertible: z.boolean(), +}); + +export const unconvertedOutput = z.object({ + count: z.number(), + currencies: z.array(z.string()), +}); + +export const currencyMetaOutput = z.object({ + code: z.string(), + name: z.string(), + minorUnits: z.number(), +}); + +export const currencySettingsOutput = z.object({ + reportingCurrency: z.string(), + refreshedAt: z.string().nullable(), + rates: z.array(currencyRateOutput), + inUse: z.array(currencyInUseOutput), + unconverted: unconvertedOutput, + catalog: z.array(currencyMetaOutput), + canManage: z.boolean(), +}); + +export type CurrencyRate = z.infer; +export type CurrencyInUse = z.infer; +export type CurrencySettings = z.infer; diff --git a/apps/api/src/currency/currency.router.ts b/apps/api/src/currency/currency.router.ts index e0a7cc0bb..a1b36bea3 100644 --- a/apps/api/src/currency/currency.router.ts +++ b/apps/api/src/currency/currency.router.ts @@ -10,7 +10,9 @@ import { import type { z } from "zod"; import type { AuthedTrpcContext } from "../trpc/context.types"; import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; +import { restMeta } from "../trpc/openapi"; import { + currencySettingsOutput, removeManualRateInput, setManualRateInput, setReportingCurrencyInput, @@ -24,12 +26,19 @@ export class CurrencyRouter { @Inject(CurrencyService) private readonly currency: CurrencyService, ) {} - @Query() + @Query({ + output: currencySettingsOutput, + meta: restMeta("GET", "/currency/settings", ["Currency"]), + }) async settings(@Ctx() ctx: AuthedTrpcContext) { return this.currency.settings(ctx.user.id); } - @Mutation({ input: setReportingCurrencyInput }) + @Mutation({ + input: setReportingCurrencyInput, + output: currencySettingsOutput, + meta: restMeta("PATCH", "/currency/reporting-currency", ["Currency"]), + }) async setReportingCurrency( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -37,7 +46,11 @@ export class CurrencyRouter { return this.currency.setReportingCurrency(ctx.user.id, input.currency); } - @Mutation({ input: setManualRateInput }) + @Mutation({ + input: setManualRateInput, + output: currencySettingsOutput, + meta: restMeta("PUT", "/currency/rates/{currency}", ["Currency"]), + }) async setManualRate( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -45,7 +58,11 @@ export class CurrencyRouter { return this.currency.setManualRate(ctx.user.id, input.currency, input.rate); } - @Mutation({ input: removeManualRateInput }) + @Mutation({ + input: removeManualRateInput, + output: currencySettingsOutput, + meta: restMeta("DELETE", "/currency/rates/{currency}", ["Currency"]), + }) async removeManualRate( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -53,7 +70,10 @@ export class CurrencyRouter { return this.currency.removeManualRate(ctx.user.id, input.currency); } - @Mutation() + @Mutation({ + output: currencySettingsOutput, + meta: restMeta("POST", "/currency/rates/refresh", ["Currency"]), + }) async refreshRates(@Ctx() ctx: AuthedTrpcContext) { return this.currency.refresh(ctx.user.id); } diff --git a/apps/api/src/currency/currency.service.ts b/apps/api/src/currency/currency.service.ts index 20bd74a34..5dae1d1b6 100644 --- a/apps/api/src/currency/currency.service.ts +++ b/apps/api/src/currency/currency.service.ts @@ -1,12 +1,7 @@ import { canManageCurrency, workspaceRoleOf } from "@crm/auth"; import type { Db } from "@crm/db"; import { Prisma, RateSource } from "@crm/db"; -import { - CURRENCIES, - type CurrencyMeta, - currencyName, - normalizeCurrency, -} from "@crm/db/currency"; +import { CURRENCIES, currencyName, normalizeCurrency } from "@crm/db/currency"; import { writeReportingCurrency } from "@crm/db/settings"; import { BadRequestException, @@ -15,36 +10,10 @@ import { Logger, } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; -import { ConversionService, type Unconverted } from "./conversion.service"; +import { ConversionService } from "./conversion.service"; +import type { CurrencyRate, CurrencySettings } from "./currency.contracts"; import { RatesService } from "./rates.service"; -export interface CurrencyRate { - currency: string; - name: string | null; - rate: number; - asOf: string; - source: RateSource; - provider: string | null; - overriding: boolean; -} - -export interface CurrencyInUse { - currency: string; - name: string | null; - deals: number; - convertible: boolean; -} - -export interface CurrencySettings { - reportingCurrency: string; - refreshedAt: string | null; - rates: CurrencyRate[]; - inUse: CurrencyInUse[]; - unconverted: Unconverted; - catalog: CurrencyMeta[]; - canManage: boolean; -} - @Injectable() export class CurrencyService { private readonly logger = new Logger(CurrencyService.name); diff --git a/apps/api/src/currency/rates.controller.ts b/apps/api/src/currency/rates.controller.ts index 2ac65291d..1f99af8f5 100644 --- a/apps/api/src/currency/rates.controller.ts +++ b/apps/api/src/currency/rates.controller.ts @@ -8,11 +8,28 @@ import { ServiceUnavailableException, } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; +import { + ApiExcludeEndpoint, + ApiForbiddenResponse, + ApiHeader, + ApiOkResponse, + ApiOperation, + ApiServiceUnavailableResponse, + ApiTags, +} from "@nestjs/swagger"; import { AllowAnonymous } from "@thallesp/nestjs-better-auth"; import type { EnvironmentVariables } from "../config/env.validation"; import { ConversionService } from "./conversion.service"; import { RatesService } from "./rates.service"; +@ApiTags("Internal — Cron") +@ApiHeader({ + name: "authorization", + description: "`Bearer `", + required: true, +}) +@ApiForbiddenResponse({ description: "CRON_SECRET did not match." }) +@ApiServiceUnavailableResponse({ description: "CRON_SECRET is not set." }) @Controller("internal/sync") export class RatesController { private readonly logger = new Logger(RatesController.name); @@ -28,12 +45,17 @@ export class RatesController { @Get("rates") @AllowAnonymous() + @ApiOperation({ + summary: "Refresh exchange rates and convert amounts left pending", + }) + @ApiOkResponse({ description: "Rates refreshed; conversion counts." }) async ratesViaGet(@Headers("authorization") authorization?: string) { return this.run(authorization); } @Post("rates") @AllowAnonymous() + @ApiExcludeEndpoint() async ratesViaPost(@Headers("authorization") authorization?: string) { return this.run(authorization); } diff --git a/apps/api/src/dashboard/dashboard.contracts.ts b/apps/api/src/dashboard/dashboard.contracts.ts index 3cc8ac4ac..be4331b40 100644 --- a/apps/api/src/dashboard/dashboard.contracts.ts +++ b/apps/api/src/dashboard/dashboard.contracts.ts @@ -1,3 +1,5 @@ +import { ActivityType, DealStage } from "@crm/db"; +import { activityMeta } from "@crm/validation/activity-meta"; import { z } from "zod"; const DASHBOARD_SCOPES = ["me", "everyone"] as const; @@ -7,3 +9,105 @@ export const dashboardSummaryInput = z.object({ }); export type DashboardSummaryInput = z.infer; + +const stageEnum = z.enum( + Object.values(DealStage) as [DealStage, ...DealStage[]], +); + +const ownerOutput = z.object({ + id: z.string(), + name: z.string(), + email: z.string(), + image: z.string().nullable(), +}); + +const companyBriefOutput = z.object({ + id: z.string(), + name: z.string(), + iconUrl: z.string().nullable(), + iconDarkUrl: z.string().nullable(), + iconTone: z.string().nullable(), +}); + +const linkedRecordOutput = z.object({ id: z.string(), name: z.string() }); + +const monthlyTotalOutput = z.object({ + count: z.number(), + valueCents: z.number(), +}); + +const stageBucketOutput = z.object({ + stage: stageEnum, + count: z.number(), + valueCents: z.number(), +}); + +const trendPointOutput = z.object({ + month: z.string(), + won: z.number(), + created: z.number(), +}); + +const unconvertedOutput = z.object({ + count: z.number(), + currencies: z.array(z.string()), +}); + +const biggestOpenDealOutput = z.object({ + id: z.string(), + name: z.string(), + stage: stageEnum, + currency: z.string(), + company: companyBriefOutput, + owner: ownerOutput, + amountCents: z.number().nullable(), + baseAmountCents: z.number().nullable(), + expectedCloseDate: z.string().nullable(), + stageChangedAt: z.string(), +}); + +const overdueTaskOutput = z.object({ + id: z.string(), + subject: z.string().nullable(), + company: linkedRecordOutput.nullable(), + deal: linkedRecordOutput.nullable(), + dueAt: z.string().nullable(), +}); + +const recentActivityOutput = z.object({ + id: z.string(), + type: z.nativeEnum(ActivityType), + subject: z.string().nullable(), + body: z.string().nullable(), + createdBy: ownerOutput, + company: linkedRecordOutput.nullable(), + deal: linkedRecordOutput.nullable(), + createdAt: z.string(), + meta: activityMeta, +}); + +export const dashboardSummaryOutput = z.object({ + scope: z.enum(DASHBOARD_SCOPES), + reportingCurrency: z.string(), + unconverted: unconvertedOutput, + pipeline: z.object({ + stages: z.array(stageBucketOutput), + totalCents: z.number(), + totalDeals: z.number(), + }), + wonThisMonth: monthlyTotalOutput, + wonPrevMonth: monthlyTotalOutput, + performance: z.object({ + windowDays: z.number(), + wins: z.number(), + losses: z.number(), + winRate: z.number().nullable(), + avgDealCents: z.number().nullable(), + avgCycleDays: z.number().nullable(), + }), + trend: z.array(trendPointOutput), + closingThisMonthTotal: monthlyTotalOutput, + biggestOpen: z.array(biggestOpenDealOutput), + overdueTasks: z.array(overdueTaskOutput), + recentActivity: z.array(recentActivityOutput), +}); diff --git a/apps/api/src/dashboard/dashboard.router.ts b/apps/api/src/dashboard/dashboard.router.ts index ae7aa3fa6..1fce4ab54 100644 --- a/apps/api/src/dashboard/dashboard.router.ts +++ b/apps/api/src/dashboard/dashboard.router.ts @@ -3,7 +3,11 @@ import { Ctx, Input, 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 { dashboardSummaryInput } from "./dashboard.contracts"; +import { restMeta } from "../trpc/openapi"; +import { + dashboardSummaryInput, + dashboardSummaryOutput, +} from "./dashboard.contracts"; import { DashboardService } from "./dashboard.service"; @Router({ alias: "dashboard" }) @@ -13,7 +17,11 @@ export class DashboardRouter { @Inject(DashboardService) private readonly dashboard: DashboardService, ) {} - @Query({ input: dashboardSummaryInput }) + @Query({ + input: dashboardSummaryInput, + output: dashboardSummaryOutput, + meta: restMeta("GET", "/dashboard/summary", ["Dashboard"]), + }) async summary( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, diff --git a/apps/api/src/deals/deals.contracts.ts b/apps/api/src/deals/deals.contracts.ts index ef8ee4326..a80b38b56 100644 --- a/apps/api/src/deals/deals.contracts.ts +++ b/apps/api/src/deals/deals.contracts.ts @@ -1,4 +1,5 @@ import { DealStage } from "@crm/db"; +import { FIELD_ENTITIES, FIELD_TYPES } from "@crm/db/fields"; import { z } from "zod"; import { bulkIdsInput } from "../crm/bulk"; import { currencyCode } from "../currency/currency.contracts"; @@ -27,9 +28,11 @@ export type ClosingWindow = (typeof CLOSING_WINDOWS)[number]; export const dealListInput = listInput.extend({ status: z.string().default("all"), - owner: z.string().default("all"), - stage: z.string().default("all"), - closing: z.string().default("all"), + owner: z.array(z.string()).default([]), + stage: z.array(z.string()).default([]), + closing: z.array(z.string()).default([]), + fields: z.record(z.string(), z.array(z.string())).default({}), + archived: z.boolean().default(false), }); export type DealListInput = z.infer; @@ -123,3 +126,176 @@ export const dealBulkStageInput = bulkIdsInput.extend({ }); export type DealBulkStageInput = z.infer; + +const fieldValueOutput = z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), +]); + +const fieldOptionOutput = z.object({ + id: z.string(), + label: z.string(), + position: z.number(), +}); + +const recordFieldOutput = z.object({ + id: z.string(), + entity: z.enum(FIELD_ENTITIES), + key: z.string(), + label: z.string(), + type: z.enum(FIELD_TYPES), + typeLabel: z.string(), + agentFilled: z.boolean(), + agentBrief: z.string().nullable(), + required: z.boolean(), + showOnSheet: z.boolean(), + showOnTable: z.boolean(), + showOnFilter: z.boolean(), + position: z.number(), + archived: z.boolean(), + options: z.array(fieldOptionOutput), + value: fieldValueOutput, +}); + +const dealOwnerOutput = z.object({ + id: z.string(), + name: z.string(), + email: z.string(), + image: z.string().nullable(), +}); + +const dealCompanyOutput = z.object({ + id: z.string(), + name: z.string(), + domain: z.string().nullable(), + iconUrl: z.string().nullable(), + iconDarkUrl: z.string().nullable(), + iconTone: z.string().nullable(), + logoUrl: z.string().nullable(), +}); + +const dealCompanyDetailOutput = dealCompanyOutput.extend({ + industry: z.string().nullable(), +}); + +const dealContactSummaryOutput = z.object({ + id: z.string(), + firstName: z.string(), + lastName: z.string().nullable(), + email: z.string().nullable(), + title: z.string().nullable(), + imageUrl: z.string().nullable(), +}); + +const dealContactOutput = dealContactSummaryOutput.extend({ + role: z.string().nullable(), +}); + +const dealListRowOutput = z.object({ + id: z.string(), + name: z.string(), + stage: stageEnum, + currency: z.string(), + company: dealCompanyOutput, + owner: dealOwnerOutput, + amountCents: z.number().nullable(), + baseAmountCents: z.number().nullable(), + expectedCloseDate: z.string().nullable(), + closedAt: z.string().nullable(), + lastActivityAt: z.string().nullable(), + createdAt: z.string(), + archivedAt: z.string().nullable(), + fields: z.record(z.string(), fieldValueOutput), +}); + +export const dealListOutput = z.object({ + rows: z.array(dealListRowOutput), + total: z.number(), + facetCounts: z.record(z.string(), z.record(z.string(), z.number())), + openValueCents: z.number().nullable(), + reportingCurrency: z.string(), + unconverted: z.object({ + count: z.number(), + currencies: z.array(z.string()), + }), +}); + +export type DealListResult = z.infer; + +export const dealDetailOutput = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + stage: stageEnum, + currency: z.string(), + closedReason: z.string().nullable(), + company: dealCompanyDetailOutput, + owner: dealOwnerOutput, + fields: z.array(recordFieldOutput), + amountCents: z.number().nullable(), + baseAmountCents: z.number().nullable(), + reportingCurrency: z.string(), + fxRate: z.number().nullable(), + fxRateAt: z.string().nullable(), + stageChangedAt: z.string(), + expectedCloseDate: z.string().nullable(), + closedAt: z.string().nullable(), + createdAt: z.string(), + archivedAt: z.string().nullable(), + contacts: z.array(dealContactOutput), +}); + +export type DealDetail = z.infer; + +export const dealCreateOutput = z.object({ + id: z.string(), + name: z.string(), + companyId: z.string(), +}); + +export type DealCreated = z.infer; + +export const dealMutateOutput = z.object({ + id: z.string(), + name: z.string(), +}); + +export type DealMutated = z.infer; + +export const dealSetStageOutput = z.object({ + id: z.string(), + stage: stageEnum, + changed: z.boolean(), +}); + +export type DealSetStageResult = z.infer; + +export const dealContactOptionsOutput = z.array(dealContactSummaryOutput); + +export type DealContactOption = z.infer; + +export const dealContactLinkOutput = z.object({ + dealId: z.string(), + contactId: z.string(), +}); + +export type DealContactLink = z.infer; + +export const dealContactRoleOutput = z.object({ + dealId: z.string(), + contactId: z.string(), + role: z.string().nullable(), +}); + +export type DealContactRoleResult = z.infer; + +export const dealBulkResultOutput = z.object({ + requested: z.number(), + succeeded: z.number(), + failed: z.number(), + message: z.string().nullable(), +}); + +export type DealBulkResult = z.infer; diff --git a/apps/api/src/deals/deals.router.ts b/apps/api/src/deals/deals.router.ts index ccb3f8504..8494f10a9 100644 --- a/apps/api/src/deals/deals.router.ts +++ b/apps/api/src/deals/deals.router.ts @@ -10,17 +10,27 @@ import { import type { z } from "zod"; import type { AuthedTrpcContext } from "../trpc/context.types"; import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; +import { restMeta } from "../trpc/openapi"; import { dealAttachContactInput, dealBulkInput, dealBulkOwnerInput, + dealBulkResultOutput, dealBulkStageInput, + dealContactLinkOutput, + dealContactOptionsOutput, dealContactRoleInput, + dealContactRoleOutput, dealContactsInput, dealCreateInput, + dealCreateOutput, dealDetachContactInput, + dealDetailOutput, dealIdInput, dealListInput, + dealListOutput, + dealMutateOutput, + dealSetStageOutput, dealUpdateArgs, setStageInput, } from "./deals.contracts"; @@ -31,32 +41,74 @@ import { DealsService } from "./deals.service"; export class DealsRouter { constructor(@Inject(DealsService) private readonly deals: DealsService) {} - @Query({ input: dealListInput }) + @Query({ + input: dealListInput, + output: dealListOutput, + meta: restMeta("POST", "/deals/search", ["Deals"]), + }) async list(@Input() input: z.infer) { return this.deals.list(input); } - @Query({ input: dealIdInput }) + @Query({ + input: dealIdInput, + output: dealDetailOutput, + meta: restMeta("GET", "/deals/{id}", ["Deals"]), + }) async byId(@Input("id") id: string) { return this.deals.byId(id); } - @Mutation({ input: dealCreateInput }) + @Mutation({ + input: dealCreateInput, + output: dealCreateOutput, + meta: restMeta("POST", "/deals", ["Deals"]), + }) async create(@Input() input: z.infer) { return this.deals.create(input); } - @Mutation({ input: dealUpdateArgs }) + @Mutation({ + input: dealUpdateArgs, + output: dealMutateOutput, + meta: restMeta("PATCH", "/deals/{id}", ["Deals"]), + }) async update(@Input() input: z.infer) { return this.deals.update(input.id, input.data); } - @Mutation({ input: dealIdInput }) - async delete(@Input("id") id: string) { - return this.deals.delete(id); + @Mutation({ + input: dealIdInput, + output: dealMutateOutput, + meta: restMeta("POST", "/deals/{id}/archive", ["Deals"]), + }) + async archive(@Input("id") id: string) { + return this.deals.archive(id); } - @Mutation({ input: setStageInput }) + @Mutation({ + input: dealIdInput, + output: dealMutateOutput, + meta: restMeta("POST", "/deals/{id}/restore", ["Deals"]), + }) + async restore(@Input("id") id: string) { + return this.deals.restore(id); + } + + @Mutation({ + input: dealIdInput, + output: dealMutateOutput, + meta: restMeta("DELETE", "/deals/{id}", ["Deals"]), + }) + async purge(@Input("id") id: string) { + return this.deals.purge(id); + } + + @Mutation({ + input: setStageInput, + output: dealSetStageOutput, + meta: restMeta("PATCH", "/deals/{id}/stage", ["Deals"]), + }) async setStage( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -64,32 +116,58 @@ export class DealsRouter { return this.deals.setStage(input, ctx.user.id); } - @Query({ input: dealContactsInput }) + @Query({ + input: dealContactsInput, + output: dealContactOptionsOutput, + meta: restMeta("GET", "/deals/{dealId}/contact-options", ["Deals"]), + }) async contactOptions(@Input("dealId") dealId: string) { return this.deals.contactOptions(dealId); } - @Mutation({ input: dealAttachContactInput }) + @Mutation({ + input: dealAttachContactInput, + output: dealContactLinkOutput, + meta: restMeta("POST", "/deals/{dealId}/contacts", ["Deals"]), + }) async attachContact(@Input() input: z.infer) { return this.deals.attachContact(input); } - @Mutation({ input: dealDetachContactInput }) + @Mutation({ + input: dealDetachContactInput, + output: dealContactLinkOutput, + meta: restMeta("DELETE", "/deals/{dealId}/contacts/{contactId}", ["Deals"]), + }) async detachContact(@Input() input: z.infer) { return this.deals.detachContact(input); } - @Mutation({ input: dealContactRoleInput }) + @Mutation({ + input: dealContactRoleInput, + output: dealContactRoleOutput, + meta: restMeta("PATCH", "/deals/{dealId}/contacts/{contactId}/role", [ + "Deals", + ]), + }) async setContactRole(@Input() input: z.infer) { return this.deals.setContactRole(input); } - @Mutation({ input: dealBulkOwnerInput }) + @Mutation({ + input: dealBulkOwnerInput, + output: dealBulkResultOutput, + meta: restMeta("POST", "/deals/bulk-assign-owner", ["Deals"]), + }) async bulkAssignOwner(@Input() input: z.infer) { return this.deals.bulkAssignOwner(input); } - @Mutation({ input: dealBulkStageInput }) + @Mutation({ + input: dealBulkStageInput, + output: dealBulkResultOutput, + meta: restMeta("POST", "/deals/bulk-set-stage", ["Deals"]), + }) async bulkSetStage( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -97,8 +175,30 @@ export class DealsRouter { return this.deals.bulkSetStage(input, ctx.user.id); } - @Mutation({ input: dealBulkInput }) - async bulkDelete(@Input("ids") ids: string[]) { - return this.deals.bulkDelete(ids); + @Mutation({ + input: dealBulkInput, + output: dealBulkResultOutput, + meta: restMeta("POST", "/deals/bulk-archive", ["Deals"]), + }) + async bulkArchive(@Input("ids") ids: string[]) { + return this.deals.bulkArchive(ids); + } + + @Mutation({ + input: dealBulkInput, + output: dealBulkResultOutput, + meta: restMeta("POST", "/deals/bulk-restore", ["Deals"]), + }) + async bulkRestore(@Input("ids") ids: string[]) { + return this.deals.bulkRestore(ids); + } + + @Mutation({ + input: dealBulkInput, + output: dealBulkResultOutput, + meta: restMeta("POST", "/deals/bulk-purge", ["Deals"]), + }) + async bulkPurge(@Input("ids") ids: string[]) { + return this.deals.bulkPurge(ids); } } diff --git a/apps/api/src/deals/deals.service.ts b/apps/api/src/deals/deals.service.ts index 3f5b23d03..4a4d01c86 100644 --- a/apps/api/src/deals/deals.service.ts +++ b/apps/api/src/deals/deals.service.ts @@ -12,6 +12,7 @@ import { LOSING_DEAL_STAGES, OPEN_DEAL_STAGES, } from "@crm/db/deal-stage"; +import type { FieldDefinitionWithOptions } from "@crm/db/fields"; import { BadRequestException, Injectable, @@ -19,6 +20,7 @@ import { NotFoundException, } from "@nestjs/common"; import { AgentTriggerService } from "../agent/agent-trigger.service"; +import { ARCHIVE } from "../archive/archive-config"; import { ActivityStampService, type StampTargets, @@ -34,11 +36,12 @@ import { ConversionService } from "../currency/conversion.service"; import { InjectDatabase } from "../database/database.constants"; import { FieldsService } from "../fields/fields.service"; import { + archivedFilter, countsByKey, - FACET_ALL, FACET_UNASSIGNED, type ListResult, type OrderByColumns, + ownerFilter, paginate, resolveOrderBy, } from "../trpc/list-input"; @@ -93,6 +96,7 @@ const SORTABLE: OrderByColumns = { 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" } }], }; @Injectable() @@ -108,7 +112,8 @@ export class DealsService { ) {} async list(input: DealListInput) { - const where = this.buildWhere(input); + const filterableFields = await this.fields.filterableFieldsFor("DEAL"); + const where = this.buildWhere(input, filterableFields); const { skip, take } = paginate(input); const openWhere = { ...where, stage: { in: [...OPEN_DEAL_STAGES] } }; @@ -134,10 +139,11 @@ export class DealsService { owner: { select: OWNER_SELECT }, lastActivityAt: true, createdAt: true, + archivedAt: true, }, }), this.db.deal.count({ where }), - this.facetCounts(input), + this.facetCounts(input, filterableFields), this.db.deal.aggregate({ where: { AND: [openWhere, this.conversion.countedWhere(base)] }, _sum: { baseAmount: true }, @@ -159,6 +165,7 @@ export class DealsService { closedAt, lastActivityAt, createdAt, + archivedAt, ...row }) => ({ ...row, @@ -168,6 +175,7 @@ export class DealsService { closedAt: closedAt?.toISOString() ?? null, lastActivityAt: lastActivityAt?.toISOString() ?? null, createdAt: createdAt.toISOString(), + archivedAt: archivedAt?.toISOString() ?? null, fields: tableFields.get(row.id) ?? {}, }), ), @@ -201,6 +209,7 @@ export class DealsService { closedAt: true, closedReason: true, createdAt: true, + archivedAt: true, company: { select: { ...COMPANY_SELECT, industry: true } }, owner: { select: OWNER_SELECT }, contacts: { @@ -214,7 +223,15 @@ export class DealsService { throw new NotFoundException(`No deal with id ${id}.`); } - const { contacts, amount, baseAmount, fxRate, fxRateAt, ...rest } = deal; + const { + contacts, + amount, + baseAmount, + fxRate, + fxRateAt, + archivedAt, + ...rest + } = deal; return { ...rest, @@ -228,6 +245,7 @@ export class DealsService { expectedCloseDate: deal.expectedCloseDate?.toISOString() ?? null, closedAt: deal.closedAt?.toISOString() ?? null, createdAt: deal.createdAt.toISOString(), + archivedAt: archivedAt?.toISOString() ?? null, contacts: contacts.map(({ role, contact }) => ({ ...contact, role })), }; } @@ -281,6 +299,8 @@ export class DealsService { this.logger.log({ message: "Deal created", dealId: deal.id, stage }); + void this.fields.queueBackfillForNewRecord("DEAL", deal.id); + return deal; } catch (error) { throw this.translateRelations(error); @@ -350,11 +370,66 @@ export class DealsService { } } - async delete(id: string): Promise<{ id: string; name: string }> { - let deleted: { targets: StampTargets; name: string }; + 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); + } + } + + 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 }, + }); + + this.logger.log({ message: "Deal restored", dealId: id }); + + return { id, name: deal.name }; + } catch (error) { + throw this.translate(error, id); + } + } + + async purge(id: string): Promise<{ id: string; name: string }>; + async purge( + id: string, + guard: { archivedBefore: Date }, + ): Promise<{ id: string; name: string } | null>; + async purge( + id: string, + guard?: { archivedBefore: Date }, + ): Promise<{ id: string; name: string } | null> { + 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 + `; + + if (!row) { + if (guard) return null; + throw new NotFoundException(`No deal with id ${id}.`); + } + if ( + guard && + (!row.archivedAt || row.archivedAt > guard.archivedBefore) + ) { + return null; + } + const targets = await this.stamp.targetsOf({ dealId: id }, tx); await tx.agentTask.deleteMany({ where: { dealId: id } }); @@ -369,10 +444,12 @@ export class DealsService { throw this.translate(error, id); } + if (!deleted) return null; + await this.stamp.recomputeAfterDelete(deleted.targets, { dealId: id }); this.logger.log({ - message: "Deal deleted", + message: "Deal purged", dealId: id, name: deleted.name, }); @@ -380,6 +457,19 @@ export class DealsService { return { id, name: deleted.name }; } + async purgeExpired(before: Date): Promise { + const expired = await this.db.deal.findMany({ + where: { archivedAt: { lte: before } }, + select: { id: true }, + take: ARCHIVE.prune.maxBatch, + }); + + return runBulk( + expired.map((row) => row.id), + (id) => this.purge(id, { archivedBefore: before }), + ); + } + async setStage(input: SetStageInput, actingUserId: string) { const closedReason = input.closedReason?.trim(); const closed = isClosedStage(input.stage); @@ -597,6 +687,7 @@ export class DealsService { return { requested: ids.length, succeeded: count, + skipped: 0, failed: ids.length - count, message: null, }; @@ -619,8 +710,16 @@ export class DealsService { ); } - async bulkDelete(ids: string[]): Promise { - return runBulk(ids, (id) => this.delete(id)); + async bulkArchive(ids: string[]): Promise { + return runBulk(ids, (id) => this.archive(id)); + } + + async bulkRestore(ids: string[]): Promise { + return runBulk(ids, (id) => this.restore(id)); + } + + async bulkPurge(ids: string[]): Promise { + return runBulk(ids, (id) => this.purge(id)); } private async companyOf(dealId: string) { @@ -648,39 +747,54 @@ export class DealsService { }; } - private buildWhere(input: DealListInput): Prisma.DealWhereInput { - const where: Prisma.DealWhereInput = this.searchFilter(input.q); + private buildWhere( + input: DealListInput, + filterableFields: FieldDefinitionWithOptions[], + ): Prisma.DealWhereInput { + const and: Prisma.DealWhereInput[] = [ + this.searchFilter(input.q), + archivedFilter(input.archived), + ...this.fields.fieldFilters(filterableFields, input.fields), + ]; - if (input.owner !== FACET_ALL) { - where.ownerId = - input.owner === FACET_UNASSIGNED ? { in: [] } : input.owner; - } + const owner = ownerFilter(input.owner); + if (owner) and.push(owner); if (input.status === "open") { - where.stage = { in: [...OPEN_DEAL_STAGES] }; + and.push({ stage: { in: [...OPEN_DEAL_STAGES] } }); } else if (input.status === "closed") { - where.stage = { in: [...CLOSED_DEAL_STAGES] }; + and.push({ stage: { in: [...CLOSED_DEAL_STAGES] } }); } - if (input.stage !== FACET_ALL) { - where.stage = input.stage as DealStage; + if (input.stage.length > 0) { + and.push({ stage: { in: input.stage as DealStage[] } }); } - if (input.closing !== FACET_ALL) { - Object.assign(where, closingFilter(input.closing as ClosingWindow)); + if (input.closing.length > 0) { + and.push({ + OR: input.closing.map((window) => + closingFilter(window as ClosingWindow), + ), + }); } - return where; + return { AND: and }; } - private async facetCounts(input: DealListInput) { - const where = this.searchFilter(input.q); + private async facetCounts( + input: DealListInput, + filterableFields: FieldDefinitionWithOptions[], + ) { + const where: Prisma.DealWhereInput = { + AND: [this.searchFilter(input.q), archivedFilter(input.archived)], + }; - const [owners, stages, ...closingCounts] = await Promise.all([ + 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 } }), + this.fields.filterFacetCounts("DEAL", where, filterableFields), ...CLOSING_WINDOWS.map((window) => - this.db.deal.count({ where: { ...where, ...closingFilter(window) } }), + this.db.deal.count({ where: { AND: [where, closingFilter(window)] } }), ), ]); @@ -704,6 +818,12 @@ export class DealsService { closingCounts[index] ?? 0, ]), ), + ...Object.fromEntries( + Object.entries(fieldFacets).map(([key, counts]) => [ + `field:${key}`, + counts, + ]), + ), }; } diff --git a/apps/api/src/enrichment/enrichment.router.ts b/apps/api/src/enrichment/enrichment.router.ts index 9d1e2fae1..e5e488931 100644 --- a/apps/api/src/enrichment/enrichment.router.ts +++ b/apps/api/src/enrichment/enrichment.router.ts @@ -1,10 +1,54 @@ import { enrichmentQueueInput } from "@crm/validation/enrichment-queue"; import { Inject } from "@nestjs/common"; import { Input, Query, Router, UseMiddlewares } from "nestjs-trpc"; -import type { z } from "zod"; +import { z } from "zod"; import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; +import { restMeta } from "../trpc/openapi"; import { EnrichmentService } from "./enrichment.service"; +const enrichmentContactSubjectOutput = z.object({ + kind: z.literal("contact"), + id: z.string(), + name: z.string(), + email: z.string().nullable(), + imageUrl: z.string().nullable(), +}); + +const enrichmentCompanySubjectOutput = z.object({ + kind: z.literal("company"), + id: z.string(), + name: z.string(), + logoUrl: z.string().nullable(), + logoDarkUrl: z.string().nullable(), + logoTone: z.string().nullable(), +}); + +const enrichmentQueueSubjectOutput = z.discriminatedUnion("kind", [ + enrichmentContactSubjectOutput, + enrichmentCompanySubjectOutput, +]); + +const enrichmentQueueRowOutput = z.object({ + id: z.string(), + state: z.enum(["running", "queued", "failed"]), + line: z.string(), + startedAt: z.string().nullable(), + subject: enrichmentQueueSubjectOutput, +}); + +const enrichmentScheduledRowOutput = z.object({ + id: z.string(), + due: z.string(), + subject: enrichmentQueueSubjectOutput, +}); + +const enrichmentQueueOutput = z.object({ + rows: z.array(enrichmentQueueRowOutput), + total: z.number(), + scheduled: z.array(enrichmentScheduledRowOutput), + scheduledTotal: z.number(), +}); + @Router({ alias: "enrichment" }) @UseMiddlewares(AuthMiddleware) export class EnrichmentRouter { @@ -12,7 +56,11 @@ export class EnrichmentRouter { @Inject(EnrichmentService) private readonly enrichment: EnrichmentService, ) {} - @Query({ input: enrichmentQueueInput }) + @Query({ + input: enrichmentQueueInput, + output: enrichmentQueueOutput, + meta: restMeta("GET", "/enrichment/queue", ["Enrichment"]), + }) async queue(@Input() input: z.infer) { return this.enrichment.queue(input.limit); } diff --git a/apps/api/src/fields/fields-config.ts b/apps/api/src/fields/fields-config.ts new file mode 100644 index 000000000..493bf9985 --- /dev/null +++ b/apps/api/src/fields/fields-config.ts @@ -0,0 +1,5 @@ +export const FIELDS_CONFIG = { + backfill: { + maxRecordsPerRun: 500, + }, +} as const; diff --git a/apps/api/src/fields/fields.contracts.ts b/apps/api/src/fields/fields.contracts.ts index babdff012..9335d3550 100644 --- a/apps/api/src/fields/fields.contracts.ts +++ b/apps/api/src/fields/fields.contracts.ts @@ -15,6 +15,10 @@ export const fieldByKeyInput = z.object({ key: z.string().trim().min(1), }); +export const fieldEntityInput = z.object({ + entity: fieldEntity, +}); + const fieldOptionInput = z.object({ id: z.string().optional(), label: z.string().trim().min(1, "An option needs a label."), @@ -30,6 +34,7 @@ export const fieldCreateInput = z.object({ required: z.boolean().default(false), showOnSheet: z.boolean().default(true), showOnTable: z.boolean().default(false), + showOnFilter: z.boolean().default(false), }); export type FieldCreateInput = z.infer; @@ -43,6 +48,7 @@ const fieldUpdateData = z.object({ required: z.boolean().optional(), showOnSheet: z.boolean().optional(), showOnTable: z.boolean().optional(), + showOnFilter: z.boolean().optional(), }); export type FieldUpdateData = z.infer; @@ -67,3 +73,42 @@ const recordFieldValue = z.union( ); export const recordFieldValues = z.record(z.string(), recordFieldValue); + +const fieldOptionOutput = z.object({ + id: z.string(), + label: z.string(), + position: z.number(), +}); + +export const serializedFieldOutput = z.object({ + id: z.string(), + entity: fieldEntity, + key: z.string(), + label: z.string(), + type: z.enum(FIELD_TYPES), + typeLabel: z.string(), + agentFilled: z.boolean(), + agentBrief: z.string().nullable(), + required: z.boolean(), + showOnSheet: z.boolean(), + showOnTable: z.boolean(), + showOnFilter: z.boolean(), + position: z.number(), + archived: z.boolean(), + options: z.array(fieldOptionOutput), +}); + +export const fieldListOutput = z.array(serializedFieldOutput); + +export const fieldFiltersOutput = z.array(serializedFieldOutput); + +export const fieldReorderOutput = z.array(serializedFieldOutput); + +export const fieldCoverageOutput = z.object({ + filled: z.number(), + total: z.number(), +}); + +export const fieldDeleteOutput = z.object({ id: z.string() }); + +export const fieldBackfillOutput = z.object({ queued: z.boolean() }); diff --git a/apps/api/src/fields/fields.router.ts b/apps/api/src/fields/fields.router.ts index a92ae4e6c..c3d4baac5 100644 --- a/apps/api/src/fields/fields.router.ts +++ b/apps/api/src/fields/fields.router.ts @@ -2,13 +2,22 @@ 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 { restMeta } from "../trpc/openapi"; import { + fieldBackfillOutput, fieldByKeyInput, + fieldCoverageOutput, fieldCreateInput, + fieldDeleteOutput, + fieldEntityInput, + fieldFiltersOutput, fieldIdInput, fieldListInput, + fieldListOutput, fieldReorderInput, + fieldReorderOutput, fieldUpdateArgs, + serializedFieldOutput, } from "./fields.contracts"; import { FieldsService } from "./fields.service"; @@ -17,52 +26,101 @@ import { FieldsService } from "./fields.service"; export class FieldsRouter { constructor(@Inject(FieldsService) private readonly fields: FieldsService) {} - @Query({ input: fieldListInput }) + @Query({ + input: fieldListInput, + output: fieldListOutput, + meta: restMeta("GET", "/fields", ["Fields"]), + }) async list(@Input() input: z.infer) { return this.fields.list(input.entity, input.includeArchived); } - @Query({ input: fieldByKeyInput }) + @Query({ + input: fieldByKeyInput, + output: serializedFieldOutput, + meta: restMeta("GET", "/fields/{entity}/{key}", ["Fields"]), + }) async byKey(@Input() input: z.infer) { return this.fields.byKey(input.entity, input.key); } - @Query({ input: fieldIdInput }) + @Query({ + input: fieldEntityInput, + output: fieldFiltersOutput, + meta: restMeta("GET", "/fields/{entity}/filterable", ["Fields"]), + }) + async filters(@Input() input: z.infer) { + return this.fields.filters(input.entity); + } + + @Query({ + input: fieldIdInput, + output: fieldCoverageOutput, + meta: restMeta("GET", "/fields/{id}/coverage", ["Fields"]), + }) async coverage(@Input("id") id: string) { return this.fields.coverage(id); } - @Mutation({ input: fieldCreateInput }) + @Mutation({ + input: fieldCreateInput, + output: serializedFieldOutput, + meta: restMeta("POST", "/fields", ["Fields"]), + }) async create(@Input() input: z.infer) { return this.fields.create(input); } - @Mutation({ input: fieldUpdateArgs }) + @Mutation({ + input: fieldUpdateArgs, + output: serializedFieldOutput, + meta: restMeta("PATCH", "/fields/{id}", ["Fields"]), + }) async update(@Input() input: z.infer) { return this.fields.update(input.id, input.data); } - @Mutation({ input: fieldReorderInput }) + @Mutation({ + input: fieldReorderInput, + output: fieldReorderOutput, + meta: restMeta("POST", "/fields/reorder", ["Fields"]), + }) async reorder(@Input() input: z.infer) { return this.fields.reorder(input); } - @Mutation({ input: fieldIdInput }) + @Mutation({ + input: fieldIdInput, + output: serializedFieldOutput, + meta: restMeta("POST", "/fields/{id}/archive", ["Fields"]), + }) async archive(@Input("id") id: string) { return this.fields.archive(id); } - @Mutation({ input: fieldIdInput }) + @Mutation({ + input: fieldIdInput, + output: serializedFieldOutput, + meta: restMeta("POST", "/fields/{id}/restore", ["Fields"]), + }) async restore(@Input("id") id: string) { return this.fields.restore(id); } - @Mutation({ input: fieldIdInput }) + @Mutation({ + input: fieldIdInput, + output: fieldDeleteOutput, + meta: restMeta("DELETE", "/fields/{id}", ["Fields"]), + }) async delete(@Input("id") id: string) { return this.fields.delete(id); } - @Mutation({ input: fieldIdInput }) + @Mutation({ + input: fieldIdInput, + output: fieldBackfillOutput, + meta: restMeta("POST", "/fields/{id}/backfill", ["Fields"]), + }) async backfill(@Input("id") id: string) { return this.fields.backfill(id); } diff --git a/apps/api/src/fields/fields.service.ts b/apps/api/src/fields/fields.service.ts index 0f589dfb7..be1855ff3 100644 --- a/apps/api/src/fields/fields.service.ts +++ b/apps/api/src/fields/fields.service.ts @@ -31,11 +31,18 @@ import type { FieldReorderInput, FieldUpdateData, } from "./fields.contracts"; +import { FIELDS_CONFIG } from "./fields-config"; const WITH_OPTIONS = { options: { orderBy: { position: "asc" } }, } as const satisfies Prisma.FieldDefinitionInclude; +const RELATIONS = { + COMPANY: "company", + CONTACT: "contact", + DEAL: "deal", +} as const satisfies Record; + @Injectable() export class FieldsService { constructor( @@ -104,6 +111,7 @@ export class FieldsService { required: input.required, showOnSheet: input.showOnSheet, showOnTable: input.showOnTable, + showOnFilter: input.showOnFilter, position: (last?.position ?? -1) + 1, options: usesOptions(input.type) ? { @@ -118,10 +126,16 @@ export class FieldsService { }); if (definition.agentFilled) { - await this.agent.fieldBackfill( + const ids = await this.missingRecordIds( + definition.entity, + definition.id, + FIELDS_CONFIG.backfill.maxRecordsPerRun, + ); + await this.agent.fieldBackfillRecords( definition.entity, - definition.key, - "New field", + [definition.key], + ids, + `New field: ${definition.label}`, ); } @@ -196,6 +210,7 @@ export class FieldsService { required: data.required, showOnSheet: data.showOnSheet, showOnTable: data.showOnTable, + showOnFilter: data.showOnFilter, }, include: WITH_OPTIONS, }); @@ -206,10 +221,18 @@ export class FieldsService { const turnedOn = data.agentFilled === true && !existing.agentFilled; if (definition.agentFilled && (briefChanged || turnedOn)) { - await this.agent.fieldBackfill( + const ids = await this.missingRecordIds( definition.entity, - definition.key, - "Brief changed", + definition.id, + FIELDS_CONFIG.backfill.maxRecordsPerRun, + ); + await this.agent.fieldBackfillRecords( + definition.entity, + [definition.key], + ids, + briefChanged + ? `Brief changed: ${definition.label}` + : `Turned on: ${definition.label}`, ); } @@ -284,6 +307,7 @@ export class FieldsService { select: { entity: true, key: true, + label: true, agentFilled: true, archivedAt: true, }, @@ -297,13 +321,68 @@ export class FieldsService { ); } - await this.agent.fieldBackfill( + const ids = await this.missingRecordIds( + definition.entity, + id, + FIELDS_CONFIG.backfill.maxRecordsPerRun, + ); + const result = await this.agent.fieldBackfillRecords( definition.entity, - definition.key, + [definition.key], + ids, "Asked to fill the rest", ); - return { queued: true }; + return { queued: result.queued > 0 || result.merged > 0 }; + } + + /** Every agent-filled field is inherently blank on a record that was just created. */ + async queueBackfillForNewRecord( + entity: FieldEntity, + recordId: string, + ): Promise { + const definitions = await this.db.fieldDefinition.findMany({ + where: { entity, archivedAt: null, agentFilled: true }, + select: { key: true }, + }); + + if (definitions.length === 0) return; + + await this.agent.fieldBackfillRecords( + entity, + definitions.map((definition) => definition.key), + [recordId], + "New record", + ); + } + + private async missingRecordIds( + entity: FieldEntity, + 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, + }); + + return rows.map((row) => row.id); } async coverage(id: string): Promise<{ filled: number; total: number }> { @@ -400,6 +479,133 @@ export class FieldsService { return byRecord; } + /** + * SELECT and USER fields are the only types with a fixed, small + * vocabulary a facet can count, but only the ones an admin turned on + * (`showOnFilter`) actually render — same opt-in as `showOnTable`, so the + * filter bar doesn't fill up with every field ever created. + */ + async filters(entity: FieldEntity): Promise { + const definitions = await this.filterableFieldsFor(entity); + return definitions.map(serializeField); + } + + 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" }, + }); + } + + /** + * Counted by joining straight to the entity table through `FieldValue`'s + * relation, filtered by the same search/archived condition as the rest of + * that list's facets — never by first fetching every matching id, which + * would not scale past a few thousand records. + */ + async filterFacetCounts( + entity: FieldEntity, + relationWhere: + | Prisma.CompanyWhereInput + | Prisma.ContactWhereInput + | Prisma.DealWhereInput, + definitions: FieldDefinitionWithOptions[], + ): Promise>> { + const facetCounts: Record> = {}; + if (definitions.length === 0) return facetCounts; + + const relation = RELATIONS[entity]; + const byId = new Map( + definitions.map((definition) => [definition.id, definition]), + ); + const selectIds = definitions + .filter((definition) => definition.type === "SELECT") + .map((definition) => definition.id); + const userIds = definitions + .filter((definition) => definition.type === "USER") + .map((definition) => definition.id); + + 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 }, + }) + : [], + 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 }, + }) + : [], + ]); + + for (const group of selectGroups) { + const definition = byId.get(group.fieldId); + if (!definition || !group.optionId) continue; + const bucket = facetCounts[definition.key] ?? {}; + facetCounts[definition.key] = bucket; + bucket[group.optionId] = + (bucket[group.optionId] ?? 0) + group._count._all; + } + + for (const group of userGroups) { + const definition = byId.get(group.fieldId); + if (!definition || !group.userId) continue; + const bucket = facetCounts[definition.key] ?? {}; + facetCounts[definition.key] = bucket; + bucket[group.userId] = (bucket[group.userId] ?? 0) + group._count._all; + } + + return facetCounts; + } + + /** Translates selected filter values (by field key) into `FieldValue` `AND` conditions. */ + fieldFilters( + definitions: FieldDefinitionWithOptions[], + selected: Record, + ): Array<{ fieldValues: { some: Prisma.FieldValueWhereInput } }> { + const byKey = new Map( + definitions.map((definition) => [definition.key, definition]), + ); + + return Object.entries(selected) + .filter(([, values]) => values.length > 0) + .flatMap(([key, values]) => { + const definition = byKey.get(key); + if (!definition) return []; + + return [ + { + fieldValues: { + some: + definition.type === "USER" + ? { fieldId: definition.id, userId: { in: values } } + : { fieldId: definition.id, optionId: { in: values } }, + }, + }, + ]; + }); + } + async applyValues( tx: Prisma.TransactionClient, entity: FieldEntity, diff --git a/apps/api/src/generated/server.ts b/apps/api/src/generated/server.ts index e4369caf2..b77da4001 100644 --- a/apps/api/src/generated/server.ts +++ b/apps/api/src/generated/server.ts @@ -13,468 +13,743 @@ import { z } from "zod"; const t = initTRPC.create(); const publicProcedure = t.procedure; -import { timelineInput, timelineCountsInput, myTasksInput, activityCreateInput, completeInput } from "../activities/activities.contracts"; -import { agentReviseInput, agentIdInput, agentSaveFileInput, agentHistoryInput, agentUpdateInput, agentDeployInput, agentRunNowInput, agentRetryRunInput, agentCancelRunInput } from "../agent/agents.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 { timelineInput, timelineOutput, timelineCountsInput, timelineCountsOutput, myTasksInput, myTasksOutput, activityCreateInput, activityCreateOutput, completeInput, completeOutput } from "../activities/activities.contracts"; +import { agentListOutput, agentReviseInput, agentReviseOutput, agentIdInput, agentFilesOutput, agentSaveFileInput, agentSaveFileOutput, agentByIdOutput, agentHistoryInput, agentHistoryOutput, agentActivityOutput, agentUpdateInput, agentUpdateOutput, agentDeployInput, agentDeployOutput, agentPauseOutput, agentResumeOutput, agentArchiveOutput, agentRestoreOutput, agentRemoveOutput, agentRunNowInput, agentRunNowOutput, agentRetryRunInput, agentRetryRunOutput, agentCancelRunInput, agentCancelRunOutput } from "../agent/agents.contracts"; +import { apiKeyListInput, apiKeyListOutput, createApiKeyInput, createApiKeyOutput, revokeApiKeyInput, revokeApiKeyOutput } from "../api-keys/api-keys.contracts"; +import { companyListInput, companyListOutput, companyIdInput, companyDetailOutput, companyOptionsInput, companyOptionOutput, companyCreateInput, companySummaryOutput, companyUpdateArgs, companyArchiveResultOutput, companyBulkOwnerInput, companyBulkResultOutput, companyBulkInput, companyEnrichOutput, companyResearchOutput, setPrimaryContactInput, companySetPrimaryContactOutput } from "../companies/companies.contracts"; +import { contactListInput, contactListOutput, contactIdInput, contactByIdOutput, contactCreateInput, contactBasicOutput, contactUpdateArgs, contactNameOutput, contactEnrichOutput, contactBulkOwnerInput, bulkResultOutput, contactBulkCompanyInput, contactBulkInput, factDecisionInput, decideFactOutput } from "../contacts/contacts.contracts"; +import { conversationListInput, conversationListOutput, builderListOutput, builderResourceSearchInput, builderResourcesOutput, conversationIdInput, builderConversationDetailOutput, conversationEventsInput, conversationEventsOutput, conversationSaveInput, conversationIdOutput, builderConversationCreateInput, builderConversationSubmitInput, builderQuestionResponseInput, builderResponseRatingInput, builderResponseRatingOutput, conversationShareStatusOutput, conversationShareTokenOutput, sharedConversationInput, sharedConversationOutput } from "../conversations/conversations.contracts"; +import { currencySettingsOutput, setReportingCurrencyInput, setManualRateInput, removeManualRateInput } from "../currency/currency.contracts"; +import { dashboardSummaryInput, dashboardSummaryOutput } from "../dashboard/dashboard.contracts"; +import { dealListInput, dealListOutput, dealIdInput, dealDetailOutput, dealCreateInput, dealCreateOutput, dealUpdateArgs, dealMutateOutput, setStageInput, dealSetStageOutput, dealContactsInput, dealContactOptionsOutput, dealAttachContactInput, dealContactLinkOutput, dealDetachContactInput, dealContactRoleInput, dealContactRoleOutput, dealBulkOwnerInput, dealBulkResultOutput, dealBulkStageInput, dealBulkInput } from "../deals/deals.contracts"; import { enrichmentQueueInput } from "@crm/validation/enrichment-queue"; -import { fieldListInput, fieldByKeyInput, fieldIdInput, fieldCreateInput, fieldUpdateArgs, fieldReorderInput } from "../fields/fields.contracts"; -import { setAutoCreateInput, suppressDomainInput, threadInput, calendarEventInput } from "../google/google.contracts"; -import { setOutlookAutoCreateInput } from "../microsoft/microsoft.contracts"; -import { setAgentModelInput, setResearchKeyInput } from "../settings/settings.contracts"; -import { slackChannelsInput, slackJoinChannelInput, slackCreateChannelInput } from "../slack/slack.contracts"; -import { ssoProviderListInput, registerSsoProviderInput, deleteSsoProviderInput } from "../sso/sso.contracts"; -import { trackingFlagInput, cookieLifetimeInput, addDomainInput, removeDomainInput, verifyInput, companyActivityInput, contactActivityInput } from "../tracking/tracking.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 { CompaniesRouter } from "../companies/companies.router"; -import type { ContactsRouter } from "../contacts/contacts.router"; -import type { ConversationsRouter } from "../conversations/conversations.router"; -import type { CurrencyRouter } from "../currency/currency.router"; -import type { DashboardRouter } from "../dashboard/dashboard.router"; -import type { DealsRouter } from "../deals/deals.router"; -import type { EnrichmentRouter } from "../enrichment/enrichment.router"; -import type { FieldsRouter } from "../fields/fields.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 { SlackRouter } from "../slack/slack.router"; -import type { SsoRouter } from "../sso/sso.router"; -import type { TrackingRouter } from "../tracking/tracking.router"; +import { fieldListInput, fieldListOutput, fieldByKeyInput, serializedFieldOutput, fieldEntityInput, fieldFiltersOutput, fieldIdInput, fieldCoverageOutput, fieldCreateInput, fieldUpdateArgs, fieldReorderInput, fieldReorderOutput, fieldDeleteOutput, fieldBackfillOutput } from "../fields/fields.contracts"; +import { googleConnectionStatusOutput, setAutoCreateInput, suppressDomainInput, suppressDomainOutput, threadInput, emailThreadOutput, calendarEventInput, calendarEventOutput } from "../google/google.contracts"; +import { purgeSyncedDataOutput, revokeAccessOutput, microsoftConnectionStatusOutput, setOutlookAutoCreateInput } from "../microsoft/microsoft.contracts"; +import { savedViewListInput, savedViewListOutput, savedViewCreateInput, savedViewOutput, savedViewUpdateArgs, savedViewIdInput, savedViewDeleteOutput } from "../saved-views/saved-views.contracts"; +import { agentModelOutput, modelCatalogOutput, setAgentModelInput, researchKeyOutput, setResearchKeyInput, archiveRetentionOutput, setArchiveRetentionDaysInput } from "../settings/settings.contracts"; +import { slackStatusOutput, slackMatchesOutput, slackChannelsInput, slackChannelsOutput, slackJoinChannelInput, slackJoinChannelOutput, slackRefreshPeopleOutput, slackCreateChannelInput, slackCreateChannelOutput, slackDisconnectOutput } from "../slack/slack.contracts"; +import { ssoSignInOptionsOutput, ssoSettingsOutput, ssoProviderListInput, ssoProviderListOutput, registerSsoProviderInput, ssoProviderOutput, deleteSsoProviderInput, deleteSsoProviderOutput } from "../sso/sso.contracts"; +import { trackingSettingsOutput, trackingFlagInput, cookieLifetimeInput, addDomainInput, trackedDomainOutput, removeDomainInput, rotateSiteIdOutput, verifyInput, verifyOutput, sourcesOutput, companyActivityInput, websiteActivityOutput, contactActivityInput } from "../tracking/tracking.contracts"; +import { workspaceOutput, memberListInput, memberListOutput, updateWorkspaceInput, setMemberRoleInput, workspaceMemberOutput } from "../workspace/workspace.contracts"; import type { UsersRouter } from "../users/users.router"; -import type { WorkspaceRouter } from "../workspace/workspace.router"; const appRouter = t.router({ activities: t.router({ timeline: publicProcedure .input(timelineInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(timelineOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), timelineCounts: publicProcedure .input(timelineCountsInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(timelineCountsOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), myTasks: publicProcedure .input(myTasksInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(myTasksOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), create: publicProcedure .input(activityCreateInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(activityCreateOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), complete: publicProcedure .input(completeInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) + .output(completeOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) }), agents: t.router({ list: publicProcedure - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(agentListOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), revise: publicProcedure .input(agentReviseInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(agentReviseOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), files: publicProcedure .input(agentIdInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(agentFilesOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), saveFile: publicProcedure .input(agentSaveFileInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(agentSaveFileOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), byId: publicProcedure .input(agentIdInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(agentByIdOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), history: publicProcedure .input(agentHistoryInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(agentHistoryOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), activity: publicProcedure .input(agentHistoryInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(agentActivityOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), update: publicProcedure .input(agentUpdateInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(agentUpdateOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), deploy: publicProcedure .input(agentDeployInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(agentDeployOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), pause: publicProcedure .input(agentIdInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(agentPauseOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), resume: publicProcedure .input(agentIdInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(agentResumeOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), archive: publicProcedure .input(agentIdInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(agentArchiveOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), restore: publicProcedure .input(agentIdInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(agentRestoreOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), remove: publicProcedure .input(agentIdInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(agentRemoveOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), runNow: publicProcedure .input(agentRunNowInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(agentRunNowOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), retryRun: publicProcedure .input(agentRetryRunInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(agentRetryRunOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), cancelRun: publicProcedure .input(agentCancelRunInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) + .output(agentCancelRunOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) + }), + apiKeys: t.router({ + list: publicProcedure + .input(apiKeyListInput) + .output(apiKeyListOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + create: publicProcedure + .input(createApiKeyInput) + .output(createApiKeyOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + revoke: publicProcedure + .input(revokeApiKeyInput) + .output(revokeApiKeyOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) }), companies: t.router({ list: publicProcedure .input(companyListInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(companyListOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), byId: publicProcedure .input(companyIdInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(companyDetailOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), options: publicProcedure .input(companyOptionsInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(companyOptionOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), create: publicProcedure .input(companyCreateInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(companySummaryOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), update: publicProcedure .input(companyUpdateArgs) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), - delete: publicProcedure + .output(companySummaryOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + archive: publicProcedure + .input(companyIdInput) + .output(companyArchiveResultOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + restore: publicProcedure + .input(companyIdInput) + .output(companyArchiveResultOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + purge: publicProcedure .input(companyIdInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(companyArchiveResultOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), bulkAssignOwner: publicProcedure .input(companyBulkOwnerInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(companyBulkResultOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), bulkEnrich: publicProcedure .input(companyBulkInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), - bulkDelete: publicProcedure + .output(companyBulkResultOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + bulkArchive: publicProcedure .input(companyBulkInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(companyBulkResultOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + bulkRestore: publicProcedure + .input(companyBulkInput) + .output(companyBulkResultOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + bulkPurge: publicProcedure + .input(companyBulkInput) + .output(companyBulkResultOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), enrich: publicProcedure .input(companyIdInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(companyEnrichOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), research: publicProcedure .input(companyIdInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(companyResearchOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), setPrimaryContact: publicProcedure .input(setPrimaryContactInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) + .output(companySetPrimaryContactOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) }), contacts: t.router({ list: publicProcedure .input(contactListInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(contactListOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), byId: publicProcedure .input(contactIdInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(contactByIdOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), create: publicProcedure .input(contactCreateInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(contactBasicOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), update: publicProcedure .input(contactUpdateArgs) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), - delete: publicProcedure + .output(contactBasicOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + archive: publicProcedure + .input(contactIdInput) + .output(contactNameOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + restore: publicProcedure + .input(contactIdInput) + .output(contactNameOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + purge: publicProcedure .input(contactIdInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(contactNameOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), enrich: publicProcedure .input(contactIdInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(contactEnrichOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), bulkAssignOwner: publicProcedure .input(contactBulkOwnerInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(bulkResultOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), bulkSetCompany: publicProcedure .input(contactBulkCompanyInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(bulkResultOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), bulkEnrich: publicProcedure .input(contactBulkInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), - bulkDelete: publicProcedure + .output(bulkResultOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + bulkArchive: publicProcedure .input(contactBulkInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(bulkResultOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + bulkRestore: publicProcedure + .input(contactBulkInput) + .output(bulkResultOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + bulkPurge: publicProcedure + .input(contactBulkInput) + .output(bulkResultOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), decideFact: publicProcedure .input(factDecisionInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) + .output(decideFactOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) }), conversations: t.router({ list: publicProcedure .input(conversationListInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(conversationListOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), builderList: publicProcedure - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(builderListOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), builderResources: publicProcedure .input(builderResourceSearchInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(builderResourcesOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), builderById: publicProcedure .input(conversationIdInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(builderConversationDetailOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), events: publicProcedure .input(conversationEventsInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(conversationEventsOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), save: publicProcedure .input(conversationSaveInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(conversationIdOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), createBuilder: publicProcedure .input(builderConversationCreateInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(conversationIdOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), submitBuilder: publicProcedure .input(builderConversationSubmitInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(conversationIdOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), answerBuilderQuestion: publicProcedure .input(builderQuestionResponseInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(conversationIdOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), rateBuilderResponse: publicProcedure .input(builderResponseRatingInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(builderResponseRatingOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), markRead: publicProcedure .input(conversationIdInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(conversationIdOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), shareStatus: publicProcedure .input(conversationIdInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(conversationShareStatusOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), createShare: publicProcedure .input(conversationIdInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(conversationShareTokenOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), revokeShare: publicProcedure .input(conversationIdInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(conversationIdOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), shared: publicProcedure .input(sharedConversationInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(sharedConversationOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), remove: publicProcedure .input(conversationIdInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) + .output(conversationIdOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) }), currency: t.router({ settings: publicProcedure - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(currencySettingsOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), setReportingCurrency: publicProcedure .input(setReportingCurrencyInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(currencySettingsOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), setManualRate: publicProcedure .input(setManualRateInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(currencySettingsOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), removeManualRate: publicProcedure .input(removeManualRateInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(currencySettingsOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), refreshRates: publicProcedure - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) + .output(currencySettingsOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) }), dashboard: t.router({ summary: publicProcedure .input(dashboardSummaryInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) + .output(dashboardSummaryOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) }), deals: t.router({ list: publicProcedure .input(dealListInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(dealListOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), byId: publicProcedure .input(dealIdInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(dealDetailOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), create: publicProcedure .input(dealCreateInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(dealCreateOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), update: publicProcedure .input(dealUpdateArgs) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), - delete: publicProcedure + .output(dealMutateOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + archive: publicProcedure + .input(dealIdInput) + .output(dealMutateOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + restore: publicProcedure + .input(dealIdInput) + .output(dealMutateOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + purge: publicProcedure .input(dealIdInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(dealMutateOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), setStage: publicProcedure .input(setStageInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(dealSetStageOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), contactOptions: publicProcedure .input(dealContactsInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(dealContactOptionsOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), attachContact: publicProcedure .input(dealAttachContactInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(dealContactLinkOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), detachContact: publicProcedure .input(dealDetachContactInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(dealContactLinkOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), setContactRole: publicProcedure .input(dealContactRoleInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(dealContactRoleOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), bulkAssignOwner: publicProcedure .input(dealBulkOwnerInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(dealBulkResultOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), bulkSetStage: publicProcedure .input(dealBulkStageInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), - bulkDelete: publicProcedure + .output(dealBulkResultOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + bulkArchive: publicProcedure .input(dealBulkInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) + .output(dealBulkResultOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + bulkRestore: publicProcedure + .input(dealBulkInput) + .output(dealBulkResultOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + bulkPurge: publicProcedure + .input(dealBulkInput) + .output(dealBulkResultOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) }), enrichment: t.router({ queue: publicProcedure .input(enrichmentQueueInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) + .output(z.object({ + rows: z.array(z.object({ + id: z.string(), + state: z.enum(["running", "queued", "failed"]), + line: z.string(), + startedAt: z.string().nullable(), + subject: z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("contact"), + id: z.string(), + name: z.string(), + email: z.string().nullable(), + imageUrl: z.string().nullable(), +}), + z.object({ + kind: z.literal("company"), + id: z.string(), + name: z.string(), + logoUrl: z.string().nullable(), + logoDarkUrl: z.string().nullable(), + logoTone: z.string().nullable(), +}), +]), +})), + total: z.number(), + scheduled: z.array(z.object({ + id: z.string(), + due: z.string(), + subject: z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("contact"), + id: z.string(), + name: z.string(), + email: z.string().nullable(), + imageUrl: z.string().nullable(), +}), + z.object({ + kind: z.literal("company"), + id: z.string(), + name: z.string(), + logoUrl: z.string().nullable(), + logoDarkUrl: z.string().nullable(), + logoTone: z.string().nullable(), +}), +]), +})), + scheduledTotal: z.number(), +})) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) }), fields: t.router({ list: publicProcedure .input(fieldListInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(fieldListOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), byKey: publicProcedure .input(fieldByKeyInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(serializedFieldOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + filters: publicProcedure + .input(fieldEntityInput) + .output(fieldFiltersOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), coverage: publicProcedure .input(fieldIdInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(fieldCoverageOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), create: publicProcedure .input(fieldCreateInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(serializedFieldOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), update: publicProcedure .input(fieldUpdateArgs) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(serializedFieldOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), reorder: publicProcedure .input(fieldReorderInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(fieldReorderOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), archive: publicProcedure .input(fieldIdInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(serializedFieldOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), restore: publicProcedure .input(fieldIdInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(serializedFieldOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), delete: publicProcedure .input(fieldIdInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(fieldDeleteOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), backfill: publicProcedure .input(fieldIdInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) + .output(fieldBackfillOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) }), google: t.router({ status: publicProcedure - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(googleConnectionStatusOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), purgeSyncedData: publicProcedure - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(purgeSyncedDataOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), revokeAccess: publicProcedure - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(revokeAccessOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), syncNow: publicProcedure - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(googleConnectionStatusOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), setAutoCreate: publicProcedure .input(setAutoCreateInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(googleConnectionStatusOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), suppressDomain: publicProcedure .input(suppressDomainInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(suppressDomainOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), thread: publicProcedure .input(threadInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(emailThreadOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), event: publicProcedure .input(calendarEventInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) + .output(calendarEventOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) }), microsoft: t.router({ status: publicProcedure - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(microsoftConnectionStatusOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), purgeSyncedData: publicProcedure - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(purgeSyncedDataOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), revokeAccess: publicProcedure - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(revokeAccessOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), syncNow: publicProcedure - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(microsoftConnectionStatusOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), setAutoCreate: publicProcedure .input(setOutlookAutoCreateInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) + .output(microsoftConnectionStatusOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) + }), + savedViews: t.router({ + list: publicProcedure + .input(savedViewListInput) + .output(savedViewListOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + create: publicProcedure + .input(savedViewCreateInput) + .output(savedViewOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + update: publicProcedure + .input(savedViewUpdateArgs) + .output(savedViewOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + delete: publicProcedure + .input(savedViewIdInput) + .output(savedViewDeleteOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) }), search: t.router({ quick: publicProcedure .input(z.object({ q: z.string().default("") })) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) + .output(z.object({ hits: z.array(z.object({ + kind: z.enum(["company", "contact", "deal"]), + id: z.string(), + label: z.string(), + detail: z.string().nullable(), + iconUrl: z.string().nullable(), + iconDarkUrl: z.string().nullable(), + iconTone: z.string().nullable(), + imageUrl: z.string().nullable(), +})) })) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) }), settings: t.router({ agentModel: publicProcedure - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(agentModelOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), modelCatalog: publicProcedure - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(modelCatalogOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), setAgentModel: publicProcedure .input(setAgentModelInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(agentModelOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), researchKey: publicProcedure - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(researchKeyOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), setResearchKey: publicProcedure .input(setResearchKeyInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) + .output(researchKeyOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + archiveRetention: publicProcedure + .output(archiveRetentionOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + setArchiveRetention: publicProcedure + .input(setArchiveRetentionDaysInput) + .output(archiveRetentionOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) }), slack: t.router({ status: publicProcedure - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(slackStatusOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), matches: publicProcedure - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(slackMatchesOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), channels: publicProcedure .input(slackChannelsInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(slackChannelsOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), joinChannel: publicProcedure .input(slackJoinChannelInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(slackJoinChannelOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), refreshPeople: publicProcedure - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(slackRefreshPeopleOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), createChannel: publicProcedure .input(slackCreateChannelInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(slackCreateChannelOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), disconnect: publicProcedure - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) + .output(slackDisconnectOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) }), sso: t.router({ signInOptions: publicProcedure - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(ssoSignInOptionsOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), settings: publicProcedure - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(ssoSettingsOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), list: publicProcedure .input(ssoProviderListInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(ssoProviderListOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), register: publicProcedure .input(registerSsoProviderInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(ssoProviderOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), remove: publicProcedure .input(deleteSsoProviderInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) + .output(deleteSsoProviderOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) }), tracking: t.router({ settings: publicProcedure - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(trackingSettingsOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), setFlag: publicProcedure .input(trackingFlagInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(z.void()) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), setCookieLifetime: publicProcedure .input(cookieLifetimeInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(z.void()) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), addDomain: publicProcedure .input(addDomainInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(trackedDomainOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), removeDomain: publicProcedure .input(removeDomainInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(z.void()) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), rotateSiteId: publicProcedure - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(rotateSiteIdOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), verify: publicProcedure .input(verifyInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(verifyOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), sources: publicProcedure - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(sourcesOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), companyActivity: publicProcedure .input(companyActivityInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(websiteActivityOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), contactActivity: publicProcedure .input(contactActivityInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) + .output(websiteActivityOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) }), users: t.router({ me: publicProcedure .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), list: publicProcedure - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) + .output(z.array( + z.object({ + id: z.string(), + name: z.string(), + email: z.string(), + image: z.string().nullable(), + }), +)) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) }), workspace: t.router({ get: publicProcedure - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(workspaceOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), members: publicProcedure .input(memberListInput) - .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(memberListOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), update: publicProcedure .input(updateWorkspaceInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + .output(workspaceOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), setMemberRole: publicProcedure .input(setMemberRoleInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) + .output(workspaceMemberOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) }) }); diff --git a/apps/api/src/google/google-connection.service.ts b/apps/api/src/google/google-connection.service.ts index 86c0d3621..7c583b8e1 100644 --- a/apps/api/src/google/google-connection.service.ts +++ b/apps/api/src/google/google-connection.service.ts @@ -1,5 +1,5 @@ import { isGoogleConfigured, signsInWithGoogle } from "@crm/auth"; -import { type Db, GoogleSyncStatus, type Prisma } from "@crm/db"; +import type { Db, Prisma } from "@crm/db"; import { Injectable, Logger, NotFoundException } from "@nestjs/common"; import { normalizeDomain } from "../companies/domain"; import { ActivityStampService } from "../crm/activity-stamp.service"; @@ -13,26 +13,16 @@ import { type GoogleSyncSource, SCOPE_FOR_SOURCE, } from "./google.constants"; +import type { + GoogleConnectionStatus, + GoogleSourceStatus, + PurgeSyncedDataOutput, + RevokeAccessOutput, + SuppressDomainOutput, +} from "./google.contracts"; const PURGE_TIMEOUT_MS = 60_000; -export type SourceStatus = { - source: GoogleSyncSource; - connected: boolean; - status: GoogleSyncStatus | null; - lastSyncedAt: string | null; - lastError: string | null; - autoCreate: boolean; -}; - -export type ConnectionStatus = { - configured: boolean; - linked: boolean; - required: boolean; - hasRefreshToken: boolean; - sources: SourceStatus[]; -}; - @Injectable() export class GoogleConnectionService { private readonly logger = new Logger(GoogleConnectionService.name); @@ -45,7 +35,7 @@ export class GoogleConnectionService { private readonly stamp: ActivityStampService, ) {} - async status(userId: string): Promise { + async status(userId: string): Promise { await this.onConnected(userId); const [granted, rows, hasRefreshToken, accounts] = await Promise.all([ @@ -57,7 +47,7 @@ export class GoogleConnectionService { const bySource = new Map(rows.map((row) => [row.source, row])); - const sources = GOOGLE_SYNC_SOURCES.map((source): SourceStatus => { + const sources = GOOGLE_SYNC_SOURCES.map((source): GoogleSourceStatus => { const row = bySource.get(source); const connected = granted.has(SCOPE_FOR_SOURCE[source]); @@ -124,7 +114,7 @@ export class GoogleConnectionService { } } - async purgeSyncedData(userId: string): Promise<{ purged: number }> { + async purgeSyncedData(userId: string): Promise { const mine: Prisma.EmailMessageWhereInput = { syncedByUserId: userId, gmailMessageId: { not: null }, @@ -163,7 +153,7 @@ export class GoogleConnectionService { return { purged }; } - async revoke(userId: string): Promise<{ revoked: boolean }> { + async revoke(userId: string): Promise { for (const source of GOOGLE_SYNC_SOURCES) { await this.state.remove(userId, source); } @@ -188,7 +178,7 @@ export class GoogleConnectionService { async suppressDomain( domain: string, options: { reason?: string; purge: boolean }, - ): Promise<{ domain: string; purged: number }> { + ): Promise { const normalised = normalizeDomain(domain); if (!normalised) { throw new NotFoundException(`"${domain}" is not a domain.`); diff --git a/apps/api/src/google/google.contracts.ts b/apps/api/src/google/google.contracts.ts index 672ac71bf..4aea716db 100644 --- a/apps/api/src/google/google.contracts.ts +++ b/apps/api/src/google/google.contracts.ts @@ -1,3 +1,4 @@ +import { EmailDirection, GoogleSyncStatus } from "@crm/db"; import { z } from "zod"; import { GOOGLE_SYNC_SOURCES } from "./google.constants"; @@ -22,3 +23,133 @@ export const calendarEventInput = z.object({ export type SetAutoCreateInput = z.infer; export type SuppressDomainInput = z.infer; + +const googleSyncStatusOutput = z.enum( + Object.values(GoogleSyncStatus) as [GoogleSyncStatus, ...GoogleSyncStatus[]], +); + +export const googleSourceStatusOutput = z.object({ + source: z.enum(GOOGLE_SYNC_SOURCES), + connected: z.boolean(), + status: googleSyncStatusOutput.nullable(), + lastSyncedAt: z.string().nullable(), + lastError: z.string().nullable(), + autoCreate: z.boolean(), +}); + +export const googleConnectionStatusOutput = z.object({ + configured: z.boolean(), + linked: z.boolean(), + required: z.boolean(), + hasRefreshToken: z.boolean(), + sources: z.array(googleSourceStatusOutput), +}); + +export const purgeSyncedDataOutput = z.object({ + purged: z.number(), +}); + +export const revokeAccessOutput = z.object({ + revoked: z.boolean(), +}); + +export const suppressDomainOutput = z.object({ + domain: z.string(), + purged: z.number(), +}); + +const emailThreadCompanyOutput = z.object({ + id: z.string(), + name: z.string(), +}); + +const emailThreadContactOutput = z.object({ + id: z.string(), + firstName: z.string(), + lastName: z.string().nullable(), +}); + +const emailThreadRecipientOutput = z.object({ + email: z.string(), + name: z.string().nullable(), + kind: z.string(), +}); + +const emailDirectionOutput = z.enum( + Object.values(EmailDirection) as [EmailDirection, ...EmailDirection[]], +); + +const emailThreadMessageOutput = z.object({ + id: z.string(), + direction: emailDirectionOutput, + fromEmail: z.string(), + fromName: z.string().nullable(), + recipients: z.array(emailThreadRecipientOutput), + subject: z.string().nullable(), + body: z.string().nullable(), + snippet: z.string().nullable(), + sentAt: z.string(), + gmailMessageId: z.string().nullable(), + outlookWebLink: z.string().nullable(), + fromImageUrl: z.string().nullable(), + mailboxUrl: z.string().nullable(), + mailboxName: z.string().nullable(), +}); + +export const emailThreadOutput = z.object({ + id: z.string(), + subject: z.string().nullable(), + messageCount: z.number(), + firstMessageAt: z.string(), + lastMessageAt: z.string(), + company: emailThreadCompanyOutput.nullable(), + contact: emailThreadContactOutput.nullable(), + messages: z.array(emailThreadMessageOutput), +}); + +const calendarEventCompanyOutput = z.object({ + id: z.string(), + name: z.string(), +}); + +const calendarEventContactOutput = z.object({ + id: z.string(), + firstName: z.string(), + lastName: z.string().nullable(), +}); + +const calendarAttendeeOutput = z.object({ + id: z.string(), + email: z.string(), + name: z.string().nullable(), + responseStatus: z.string().nullable(), + isOrganizer: z.boolean(), + contactId: z.string().nullable(), + imageUrl: z.string().nullable(), +}); + +export const calendarEventOutput = z.object({ + id: z.string(), + title: z.string().nullable(), + description: z.string().nullable(), + location: z.string().nullable(), + conferenceUrl: z.string().nullable(), + startsAt: z.string(), + endsAt: z.string(), + isAllDay: z.boolean(), + status: z.string(), + organizerEmail: z.string().nullable(), + company: calendarEventCompanyOutput.nullable(), + contact: calendarEventContactOutput.nullable(), + attendees: z.array(calendarAttendeeOutput), +}); + +export type GoogleSourceStatus = z.infer; +export type GoogleConnectionStatus = z.infer< + typeof googleConnectionStatusOutput +>; +export type PurgeSyncedDataOutput = z.infer; +export type RevokeAccessOutput = z.infer; +export type SuppressDomainOutput = z.infer; +export type EmailThreadOutput = z.infer; +export type CalendarEventOutput = z.infer; diff --git a/apps/api/src/google/google.router.ts b/apps/api/src/google/google.router.ts index b16cf15d1..e038085a0 100644 --- a/apps/api/src/google/google.router.ts +++ b/apps/api/src/google/google.router.ts @@ -10,11 +10,18 @@ import { import type { z } from "zod"; import type { AuthedTrpcContext } from "../trpc/context.types"; import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; +import { restMeta } from "../trpc/openapi"; import { ConversationService } from "./conversation.service"; import { calendarEventInput, + calendarEventOutput, + emailThreadOutput, + googleConnectionStatusOutput, + purgeSyncedDataOutput, + revokeAccessOutput, setAutoCreateInput, suppressDomainInput, + suppressDomainOutput, threadInput, } from "./google.contracts"; import { GoogleConnectionService } from "./google-connection.service"; @@ -31,28 +38,44 @@ export class GoogleRouter { private readonly conversations: ConversationService, ) {} - @Query() + @Query({ + output: googleConnectionStatusOutput, + meta: restMeta("GET", "/google/status", ["Google"]), + }) async status(@Ctx() ctx: AuthedTrpcContext) { return this.connection.status(ctx.user.id); } - @Mutation() + @Mutation({ + output: purgeSyncedDataOutput, + meta: restMeta("POST", "/google/purge-synced-data", ["Google"]), + }) async purgeSyncedData(@Ctx() ctx: AuthedTrpcContext) { return this.connection.purgeSyncedData(ctx.user.id); } - @Mutation() + @Mutation({ + output: revokeAccessOutput, + meta: restMeta("POST", "/google/revoke", ["Google"]), + }) async revokeAccess(@Ctx() ctx: AuthedTrpcContext) { return this.connection.revoke(ctx.user.id); } - @Mutation() + @Mutation({ + output: googleConnectionStatusOutput, + meta: restMeta("POST", "/google/sync", ["Google"]), + }) async syncNow(@Ctx() ctx: AuthedTrpcContext) { await this.sync.runForUser(ctx.user.id); return this.connection.status(ctx.user.id); } - @Mutation({ input: setAutoCreateInput }) + @Mutation({ + input: setAutoCreateInput, + output: googleConnectionStatusOutput, + meta: restMeta("PATCH", "/google/auto-create", ["Google"]), + }) async setAutoCreate( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -65,7 +88,11 @@ export class GoogleRouter { return this.connection.status(ctx.user.id); } - @Mutation({ input: suppressDomainInput }) + @Mutation({ + input: suppressDomainInput, + output: suppressDomainOutput, + meta: restMeta("POST", "/google/suppress-domain", ["Google"]), + }) async suppressDomain(@Input() input: z.infer) { return this.connection.suppressDomain(input.domain, { reason: input.reason, @@ -73,12 +100,20 @@ export class GoogleRouter { }); } - @Query({ input: threadInput }) + @Query({ + input: threadInput, + output: emailThreadOutput, + meta: restMeta("GET", "/google/threads/{threadId}", ["Google"]), + }) async thread(@Input("threadId") threadId: string) { return this.conversations.thread(threadId); } - @Query({ input: calendarEventInput }) + @Query({ + input: calendarEventInput, + output: calendarEventOutput, + meta: restMeta("GET", "/google/events/{eventId}", ["Google"]), + }) async event(@Input("eventId") eventId: string) { return this.conversations.event(eventId); } diff --git a/apps/api/src/health/health.controller.ts b/apps/api/src/health/health.controller.ts index a97642229..32346a80f 100644 --- a/apps/api/src/health/health.controller.ts +++ b/apps/api/src/health/health.controller.ts @@ -5,11 +5,18 @@ import { Logger, ServiceUnavailableException, } from "@nestjs/common"; +import { + ApiOkResponse, + ApiOperation, + ApiServiceUnavailableResponse, + ApiTags, +} from "@nestjs/swagger"; import { AllowAnonymous } from "@thallesp/nestjs-better-auth"; import { InjectDatabase } from "../database/database.constants"; const SLOW_PROBE_MS = 250; +@ApiTags("Health") @Controller("health") export class HealthController { private readonly logger = new Logger(HealthController.name); @@ -18,6 +25,15 @@ export class HealthController { @Get() @AllowAnonymous() + @ApiOperation({ summary: "Report API and database liveness" }) + @ApiOkResponse({ + description: "The API and its database are reachable.", + schema: { example: { status: "ok", database: "up" } }, + }) + @ApiServiceUnavailableResponse({ + description: "The database did not respond.", + schema: { example: { status: "error", database: "down" } }, + }) async check() { const startedAt = process.hrtime.bigint(); diff --git a/apps/api/src/microsoft/microsoft-connection.service.ts b/apps/api/src/microsoft/microsoft-connection.service.ts index a531a86b6..73b1c6e25 100644 --- a/apps/api/src/microsoft/microsoft-connection.service.ts +++ b/apps/api/src/microsoft/microsoft-connection.service.ts @@ -1,5 +1,5 @@ import { isMicrosoftConfigured, signsInWithMicrosoft } from "@crm/auth"; -import { type Db, GoogleSyncStatus, type Prisma } from "@crm/db"; +import type { Db, Prisma } from "@crm/db"; import { Injectable, Logger, NotFoundException } from "@nestjs/common"; import { ActivityStampService } from "../crm/activity-stamp.service"; import { InjectDatabase } from "../database/database.constants"; @@ -11,26 +11,15 @@ import { type MicrosoftSyncSource, SCOPE_FOR_SOURCE, } from "./microsoft.constants"; +import type { + MicrosoftConnectionStatus, + MicrosoftSourceStatus, + PurgeSyncedDataOutput, + RevokeAccessOutput, +} from "./microsoft.contracts"; const PURGE_TIMEOUT_MS = 60_000; -export type SourceStatus = { - source: MicrosoftSyncSource; - connected: boolean; - status: GoogleSyncStatus | null; - lastSyncedAt: string | null; - lastError: string | null; - autoCreate: boolean; -}; - -export type ConnectionStatus = { - configured: boolean; - linked: boolean; - required: boolean; - hasRefreshToken: boolean; - sources: SourceStatus[]; -}; - @Injectable() export class MicrosoftConnectionService { private readonly logger = new Logger(MicrosoftConnectionService.name); @@ -42,7 +31,7 @@ export class MicrosoftConnectionService { private readonly stamp: ActivityStampService, ) {} - async status(userId: string): Promise { + async status(userId: string): Promise { await this.onConnected(userId); const [granted, rows, hasRefreshToken, accounts] = await Promise.all([ @@ -54,18 +43,20 @@ export class MicrosoftConnectionService { const bySource = new Map(rows.map((row) => [row.source, row])); - const sources = MICROSOFT_SYNC_SOURCES.map((source): SourceStatus => { - const row = bySource.get(source); - - return { - source, - connected: granted.has(SCOPE_FOR_SOURCE[source]), - status: row?.status ?? null, - lastSyncedAt: row?.lastSyncedAt?.toISOString() ?? null, - lastError: row?.lastError ?? null, - autoCreate: row?.autoCreate ?? false, - }; - }); + const sources = MICROSOFT_SYNC_SOURCES.map( + (source): MicrosoftSourceStatus => { + const row = bySource.get(source); + + return { + source, + connected: granted.has(SCOPE_FOR_SOURCE[source]), + status: row?.status ?? null, + lastSyncedAt: row?.lastSyncedAt?.toISOString() ?? null, + lastError: row?.lastError ?? null, + autoCreate: row?.autoCreate ?? false, + }; + }, + ); return { configured: isMicrosoftConfigured(), @@ -123,7 +114,7 @@ export class MicrosoftConnectionService { } } - async purgeSyncedData(userId: string): Promise<{ purged: number }> { + async purgeSyncedData(userId: string): Promise { const mine: Prisma.EmailMessageWhereInput = { syncedByUserId: userId, outlookMessageId: { not: null }, @@ -158,7 +149,7 @@ export class MicrosoftConnectionService { return { purged }; } - async revoke(userId: string): Promise<{ revoked: boolean }> { + async revoke(userId: string): Promise { for (const source of MICROSOFT_SYNC_SOURCES) { await this.state.remove(userId, source); } diff --git a/apps/api/src/microsoft/microsoft.contracts.ts b/apps/api/src/microsoft/microsoft.contracts.ts index 75b74b93e..043577574 100644 --- a/apps/api/src/microsoft/microsoft.contracts.ts +++ b/apps/api/src/microsoft/microsoft.contracts.ts @@ -1,3 +1,4 @@ +import { GoogleSyncStatus } from "@crm/db"; import { z } from "zod"; import { MICROSOFT_SYNC_SOURCES } from "./microsoft.constants"; @@ -9,3 +10,39 @@ export const setOutlookAutoCreateInput = z.object({ export type SetOutlookAutoCreateInput = z.infer< typeof setOutlookAutoCreateInput >; + +const microsoftSyncStatusOutput = z.enum( + Object.values(GoogleSyncStatus) as [GoogleSyncStatus, ...GoogleSyncStatus[]], +); + +export const microsoftSourceStatusOutput = z.object({ + source: z.enum(MICROSOFT_SYNC_SOURCES), + connected: z.boolean(), + status: microsoftSyncStatusOutput.nullable(), + lastSyncedAt: z.string().nullable(), + lastError: z.string().nullable(), + autoCreate: z.boolean(), +}); + +export const microsoftConnectionStatusOutput = z.object({ + configured: z.boolean(), + linked: z.boolean(), + required: z.boolean(), + hasRefreshToken: z.boolean(), + sources: z.array(microsoftSourceStatusOutput), +}); + +export const purgeSyncedDataOutput = z.object({ + purged: z.number(), +}); + +export const revokeAccessOutput = z.object({ + revoked: z.boolean(), +}); + +export type MicrosoftSourceStatus = z.infer; +export type MicrosoftConnectionStatus = z.infer< + typeof microsoftConnectionStatusOutput +>; +export type PurgeSyncedDataOutput = z.infer; +export type RevokeAccessOutput = z.infer; diff --git a/apps/api/src/microsoft/microsoft.router.ts b/apps/api/src/microsoft/microsoft.router.ts index f8da1ca8d..b07afeb24 100644 --- a/apps/api/src/microsoft/microsoft.router.ts +++ b/apps/api/src/microsoft/microsoft.router.ts @@ -10,7 +10,13 @@ import { import type { z } from "zod"; import type { AuthedTrpcContext } from "../trpc/context.types"; import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; -import { setOutlookAutoCreateInput } from "./microsoft.contracts"; +import { restMeta } from "../trpc/openapi"; +import { + microsoftConnectionStatusOutput, + purgeSyncedDataOutput, + revokeAccessOutput, + setOutlookAutoCreateInput, +} from "./microsoft.contracts"; import { MicrosoftConnectionService } from "./microsoft-connection.service"; import { MicrosoftSyncService } from "./microsoft-sync.service"; @@ -24,28 +30,44 @@ export class MicrosoftRouter { private readonly sync: MicrosoftSyncService, ) {} - @Query() + @Query({ + output: microsoftConnectionStatusOutput, + meta: restMeta("GET", "/microsoft/status", ["Microsoft"]), + }) async status(@Ctx() ctx: AuthedTrpcContext) { return this.connection.status(ctx.user.id); } - @Mutation() + @Mutation({ + output: purgeSyncedDataOutput, + meta: restMeta("POST", "/microsoft/purge-synced-data", ["Microsoft"]), + }) async purgeSyncedData(@Ctx() ctx: AuthedTrpcContext) { return this.connection.purgeSyncedData(ctx.user.id); } - @Mutation() + @Mutation({ + output: revokeAccessOutput, + meta: restMeta("POST", "/microsoft/revoke", ["Microsoft"]), + }) async revokeAccess(@Ctx() ctx: AuthedTrpcContext) { return this.connection.revoke(ctx.user.id); } - @Mutation() + @Mutation({ + output: microsoftConnectionStatusOutput, + meta: restMeta("POST", "/microsoft/sync", ["Microsoft"]), + }) async syncNow(@Ctx() ctx: AuthedTrpcContext) { await this.sync.runForUser(ctx.user.id); return this.connection.status(ctx.user.id); } - @Mutation({ input: setOutlookAutoCreateInput }) + @Mutation({ + input: setOutlookAutoCreateInput, + output: microsoftConnectionStatusOutput, + meta: restMeta("PATCH", "/microsoft/auto-create", ["Microsoft"]), + }) async setAutoCreate( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, diff --git a/apps/api/src/saved-views/saved-views.contracts.ts b/apps/api/src/saved-views/saved-views.contracts.ts new file mode 100644 index 000000000..963338b6b --- /dev/null +++ b/apps/api/src/saved-views/saved-views.contracts.ts @@ -0,0 +1,52 @@ +import { FIELD_ENTITIES } from "@crm/db/fields"; +import { savedViewFilters } from "@crm/validation/saved-view"; +import { z } from "zod"; + +export const savedViewEntity = z.enum(FIELD_ENTITIES); + +export const savedViewListInput = z.object({ + entity: savedViewEntity, +}); + +export type SavedViewListInput = z.infer; + +export const savedViewCreateInput = z.object({ + entity: savedViewEntity, + name: z.string().trim().min(1, "A view needs a name.").max(120), + shared: z.boolean().default(false), + filters: savedViewFilters, +}); + +export type SavedViewCreateInput = z.infer; + +const savedViewUpdateData = z.object({ + name: z.string().trim().min(1).max(120).optional(), + shared: z.boolean().optional(), + filters: savedViewFilters.optional(), +}); + +export type SavedViewUpdateData = z.infer; + +export const savedViewUpdateArgs = z.object({ + id: z.string(), + data: savedViewUpdateData, +}); + +export const savedViewIdInput = z.object({ id: z.string() }); + +export const savedViewOutput = z.object({ + id: z.string(), + entity: savedViewEntity, + name: z.string(), + shared: z.boolean(), + filters: savedViewFilters, + mine: z.boolean(), + createdAt: z.string(), + updatedAt: z.string(), +}); + +export type SavedView = z.infer; + +export const savedViewListOutput = z.array(savedViewOutput); + +export const savedViewDeleteOutput = z.object({ id: z.string() }); diff --git a/apps/api/src/saved-views/saved-views.module.ts b/apps/api/src/saved-views/saved-views.module.ts new file mode 100644 index 000000000..8d48ed70d --- /dev/null +++ b/apps/api/src/saved-views/saved-views.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { TrpcModule } from "../trpc/trpc.module"; +import { SavedViewsRouter } from "./saved-views.router"; +import { SavedViewsService } from "./saved-views.service"; + +@Module({ + imports: [TrpcModule], + providers: [SavedViewsService, SavedViewsRouter], + exports: [SavedViewsService], +}) +export class SavedViewsModule {} diff --git a/apps/api/src/saved-views/saved-views.router.ts b/apps/api/src/saved-views/saved-views.router.ts new file mode 100644 index 000000000..a6f8d221d --- /dev/null +++ b/apps/api/src/saved-views/saved-views.router.ts @@ -0,0 +1,76 @@ +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 { restMeta } from "../trpc/openapi"; +import { + savedViewCreateInput, + savedViewDeleteOutput, + savedViewIdInput, + savedViewListInput, + savedViewListOutput, + savedViewOutput, + savedViewUpdateArgs, +} from "./saved-views.contracts"; +import { SavedViewsService } from "./saved-views.service"; + +@Router({ alias: "savedViews" }) +@UseMiddlewares(AuthMiddleware) +export class SavedViewsRouter { + constructor( + @Inject(SavedViewsService) private readonly savedViews: SavedViewsService, + ) {} + + @Query({ + input: savedViewListInput, + output: savedViewListOutput, + meta: restMeta("GET", "/saved-views", ["Saved Views"]), + }) + async list( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.savedViews.list(input.entity, ctx.user.id); + } + + @Mutation({ + input: savedViewCreateInput, + output: savedViewOutput, + meta: restMeta("POST", "/saved-views", ["Saved Views"]), + }) + async create( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.savedViews.create(input, ctx.user.id); + } + + @Mutation({ + input: savedViewUpdateArgs, + output: savedViewOutput, + meta: restMeta("PATCH", "/saved-views/{id}", ["Saved Views"]), + }) + async update( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.savedViews.update(input.id, input.data, ctx.user.id); + } + + @Mutation({ + input: savedViewIdInput, + output: savedViewDeleteOutput, + meta: restMeta("DELETE", "/saved-views/{id}", ["Saved Views"]), + }) + async delete(@Ctx() ctx: AuthedTrpcContext, @Input("id") id: string) { + return this.savedViews.delete(id, ctx.user.id); + } +} diff --git a/apps/api/src/saved-views/saved-views.service.ts b/apps/api/src/saved-views/saved-views.service.ts new file mode 100644 index 000000000..afd870507 --- /dev/null +++ b/apps/api/src/saved-views/saved-views.service.ts @@ -0,0 +1,126 @@ +import { type Db, type FieldEntity, Prisma as PrismaNamespace } from "@crm/db"; +import { parseSavedViewFilters } from "@crm/validation/saved-view"; +import { + ConflictException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { InjectDatabase } from "../database/database.constants"; +import type { + SavedView, + SavedViewCreateInput, + SavedViewUpdateData, +} from "./saved-views.contracts"; + +@Injectable() +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" }, + }); + + return rows.map((row) => this.serialize(row, userId)); + } + + async create( + input: SavedViewCreateInput, + userId: string, + ): Promise { + try { + const row = await this.db.savedView.create({ + data: { + entity: input.entity, + name: input.name, + shared: input.shared, + filters: input.filters, + ownerId: userId, + }, + }); + + return this.serialize(row, userId); + } catch (error) { + throw this.translate(error); + } + } + + async update( + id: string, + data: SavedViewUpdateData, + userId: string, + ): Promise { + const existing = await this.db.savedView.findUnique({ where: { 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, + }, + }); + + return this.serialize(row, userId); + } catch (error) { + throw this.translate(error, id); + } + } + + async delete(id: string, userId: string): Promise<{ id: string }> { + const existing = await this.db.savedView.findUnique({ where: { 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); + } + + return { id }; + } + + private serialize( + row: { + id: string; + entity: FieldEntity; + name: string; + shared: boolean; + filters: unknown; + ownerId: string; + createdAt: Date; + updatedAt: Date; + }, + userId: string, + ): SavedView { + return { + id: row.id, + entity: row.entity, + name: row.name, + shared: row.shared, + filters: parseSavedViewFilters(row.filters), + mine: row.ownerId === userId, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + } + + 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}.`); + } + } + throw cause; + } +} diff --git a/apps/api/src/search/search.router.ts b/apps/api/src/search/search.router.ts index 1230a6d0f..e4a7e9f16 100644 --- a/apps/api/src/search/search.router.ts +++ b/apps/api/src/search/search.router.ts @@ -2,16 +2,34 @@ import { Inject } from "@nestjs/common"; import { Input, Query, Router, UseMiddlewares } from "nestjs-trpc"; import { z } from "zod"; import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; +import { restMeta } from "../trpc/openapi"; import { SearchService } from "./search.service"; const quickInput = z.object({ q: z.string().default("") }); +const searchHitOutput = z.object({ + kind: z.enum(["company", "contact", "deal"]), + id: z.string(), + label: z.string(), + detail: z.string().nullable(), + iconUrl: z.string().nullable(), + iconDarkUrl: z.string().nullable(), + iconTone: z.string().nullable(), + imageUrl: z.string().nullable(), +}); + +const quickOutput = z.object({ hits: z.array(searchHitOutput) }); + @Router({ alias: "search" }) @UseMiddlewares(AuthMiddleware) export class SearchRouter { constructor(@Inject(SearchService) private readonly search: SearchService) {} - @Query({ input: quickInput }) + @Query({ + input: quickInput, + output: quickOutput, + meta: restMeta("GET", "/search", ["Search"]), + }) async quick(@Input("q") q: string) { return this.search.quick(q); } diff --git a/apps/api/src/settings/settings.contracts.ts b/apps/api/src/settings/settings.contracts.ts index b144f8783..aa8c91ffb 100644 --- a/apps/api/src/settings/settings.contracts.ts +++ b/apps/api/src/settings/settings.contracts.ts @@ -1,5 +1,49 @@ +import { + MAX_ARCHIVE_RETENTION_DAYS, + MIN_ARCHIVE_RETENTION_DAYS, +} from "@crm/db/settings"; import { z } from "zod"; +export const catalogModelOutput = z.object({ + id: z.string(), + name: z.string(), + provider: z.string(), + contextWindowTokens: z.number(), + pricing: z.object({ input: z.number(), output: z.number() }).nullable(), +}); + +export type CatalogModel = z.infer; + +export const agentModelOutput = z.object({ + selectedId: z.string().nullable(), + effectiveId: z.string(), + defaultId: z.string(), + effective: catalogModelOutput.nullable(), + updatedAt: z.string().nullable(), +}); + +export type AgentModelSettings = z.infer; + +export const modelCatalogOutput = z.object({ + models: z.array(catalogModelOutput), + available: z.boolean(), +}); + +export type ModelCatalogResult = z.infer; + +export const researchKeyOutput = z.object({ + configured: z.boolean(), + hint: z.string().nullable(), +}); + +export type ResearchKeySettings = z.infer; + +export const archiveRetentionOutput = z.object({ + days: z.number(), +}); + +export type ArchiveRetentionSettings = z.infer; + export const setAgentModelInput = z.object({ modelId: z.string().trim().min(1).max(200).nullable(), }); @@ -19,3 +63,21 @@ export const setResearchKeyInput = z.object({ }); export type SetResearchKeyInput = z.infer; + +export const setArchiveRetentionDaysInput = z.object({ + days: z + .number() + .int() + .min( + MIN_ARCHIVE_RETENTION_DAYS, + `Retention has to be at least ${MIN_ARCHIVE_RETENTION_DAYS} day.`, + ) + .max( + MAX_ARCHIVE_RETENTION_DAYS, + `Retention cannot be longer than ${MAX_ARCHIVE_RETENTION_DAYS} days.`, + ), +}); + +export type SetArchiveRetentionDaysInput = z.infer< + typeof setArchiveRetentionDaysInput +>; diff --git a/apps/api/src/settings/settings.router.ts b/apps/api/src/settings/settings.router.ts index f4d56bf69..ca4e31307 100644 --- a/apps/api/src/settings/settings.router.ts +++ b/apps/api/src/settings/settings.router.ts @@ -2,7 +2,16 @@ 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 { setAgentModelInput, setResearchKeyInput } from "./settings.contracts"; +import { restMeta } from "../trpc/openapi"; +import { + agentModelOutput, + archiveRetentionOutput, + modelCatalogOutput, + researchKeyOutput, + setAgentModelInput, + setArchiveRetentionDaysInput, + setResearchKeyInput, +} from "./settings.contracts"; import { SettingsService } from "./settings.service"; @Router({ alias: "settings" }) @@ -12,28 +21,64 @@ export class SettingsRouter { @Inject(SettingsService) private readonly settings: SettingsService, ) {} - @Query() + @Query({ + output: agentModelOutput, + meta: restMeta("GET", "/settings/agent-model", ["Settings"]), + }) async agentModel() { return this.settings.agentModel(); } - @Query() + @Query({ + output: modelCatalogOutput, + meta: restMeta("GET", "/settings/model-catalog", ["Settings"]), + }) async modelCatalog() { return this.settings.modelCatalog(); } - @Mutation({ input: setAgentModelInput }) + @Mutation({ + input: setAgentModelInput, + output: agentModelOutput, + meta: restMeta("PATCH", "/settings/agent-model", ["Settings"]), + }) async setAgentModel(@Input() input: z.infer) { return this.settings.setAgentModel(input.modelId); } - @Query() + @Query({ + output: researchKeyOutput, + meta: restMeta("GET", "/settings/research-key", ["Settings"]), + }) async researchKey() { return this.settings.researchKey(); } - @Mutation({ input: setResearchKeyInput }) + @Mutation({ + input: setResearchKeyInput, + output: researchKeyOutput, + meta: restMeta("PATCH", "/settings/research-key", ["Settings"]), + }) async setResearchKey(@Input() input: z.infer) { return this.settings.setResearchKey(input.apiKey); } + + @Query({ + output: archiveRetentionOutput, + meta: restMeta("GET", "/settings/archive-retention", ["Settings"]), + }) + async archiveRetention() { + return this.settings.archiveRetention(); + } + + @Mutation({ + input: setArchiveRetentionDaysInput, + output: archiveRetentionOutput, + meta: restMeta("PATCH", "/settings/archive-retention", ["Settings"]), + }) + async setArchiveRetention( + @Input() input: z.infer, + ) { + return this.settings.setArchiveRetention(input.days); + } } diff --git a/apps/api/src/settings/settings.service.ts b/apps/api/src/settings/settings.service.ts index 71c55e24d..1e8cfe857 100644 --- a/apps/api/src/settings/settings.service.ts +++ b/apps/api/src/settings/settings.service.ts @@ -3,36 +3,23 @@ import { DEFAULT_AGENT_MODEL, maskKey, readAgentModel, + readArchiveRetentionDays, readContextDevKey, writeAgentModel, + writeArchiveRetentionDays, writeContextDevKey, } from "@crm/db/settings"; import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { ResearchKeyService } from "../agent/research-key.service"; import { BackfillService } from "../backfill/backfill.service"; import { InjectDatabase } from "../database/database.constants"; -import { - type CatalogModel, - ModelCatalogService, -} from "./model-catalog.service"; - -export interface AgentModelSettings { - selectedId: string | null; - effectiveId: string; - defaultId: string; - effective: CatalogModel | null; - updatedAt: string | null; -} - -export interface ModelCatalogResult { - models: CatalogModel[]; - available: boolean; -} - -export interface ResearchKeySettings { - configured: boolean; - hint: string | null; -} +import { ModelCatalogService } from "./model-catalog.service"; +import type { + AgentModelSettings, + ArchiveRetentionSettings, + ModelCatalogResult, + ResearchKeySettings, +} from "./settings.contracts"; @Injectable() export class SettingsService { @@ -142,4 +129,19 @@ export class SettingsService { return this.researchKey(); } + + async archiveRetention(): Promise { + return { days: await readArchiveRetentionDays(this.db) }; + } + + async setArchiveRetention(days: number): Promise { + const saved = await writeArchiveRetentionDays(this.db, days); + + this.logger.log({ + message: "Archive retention changed", + days: saved, + }); + + return { days: saved }; + } } diff --git a/apps/api/src/slack/slack-connection.service.ts b/apps/api/src/slack/slack-connection.service.ts index ab8d63213..a71492bb0 100644 --- a/apps/api/src/slack/slack-connection.service.ts +++ b/apps/api/src/slack/slack-connection.service.ts @@ -16,8 +16,15 @@ import { AgentTriggerService } from "../agent/agent-trigger.service"; import { InjectDatabase } from "../database/database.constants"; import type { SlackChannelsInput, + SlackChannelsResult, SlackCreateChannelInput, + SlackCreateChannelResult, + SlackDisconnectResult, SlackJoinChannelInput, + SlackJoinChannelResult, + SlackMatches, + SlackRefreshPeopleResult, + SlackStatus, } from "./slack.contracts"; import { SlackChannelsService } from "./slack-channels.service"; import { SLACK, type SlackSyncState } from "./slack-config"; @@ -34,7 +41,7 @@ export class SlackConnectionService { private readonly access: AgentAccessService, ) {} - async status(userId: string) { + 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({ @@ -98,7 +105,7 @@ export class SlackConnectionService { }; } - async matches(userId: string) { + async matches(userId: string): Promise { await this.access.assertMember(userId); const [members, syncing] = await Promise.all([ this.db.member.findMany({ @@ -154,7 +161,7 @@ export class SlackConnectionService { : "stalled"; } - async refreshPeople(userId: string) { + async refreshPeople(userId: string): Promise { await this.access.assertMember(userId); const account = await this.db.account.findFirst({ where: { providerId: "slack", accessToken: { not: null } }, @@ -171,7 +178,10 @@ export class SlackConnectionService { return { requested: true }; } - async channels(input: SlackChannelsInput, userId: string) { + async channels( + input: SlackChannelsInput, + userId: string, + ): Promise { await this.access.assertMember(userId); const take = input.limit ?? SLACK.channels.pageSize; @@ -215,7 +225,10 @@ export class SlackConnectionService { }; } - async joinChannel(input: SlackJoinChannelInput, userId: string) { + async joinChannel( + input: SlackJoinChannelInput, + userId: string, + ): Promise { await this.access.assertMember(userId); const channel = await this.db.slackChannel.findUnique({ where: { id: input.channelId }, @@ -239,7 +252,10 @@ export class SlackConnectionService { return { queued: true, alreadyJoined: false }; } - async createChannel(input: SlackCreateChannelInput, userId: string) { + async createChannel( + input: SlackCreateChannelInput, + userId: string, + ): Promise { await this.access.assertMember(userId); const existing = await this.db.slackChannel.findFirst({ where: { name: input.name }, @@ -252,7 +268,7 @@ export class SlackConnectionService { return this.slackChannels.create(input.name, input.isPrivate); } - async disconnect(userId: string) { + async disconnect(userId: string): Promise { const role = await this.access.assertMember(userId); if (!canManageConnections(role)) { diff --git a/apps/api/src/slack/slack.contracts.ts b/apps/api/src/slack/slack.contracts.ts index 4e1ec82ed..80d6b7064 100644 --- a/apps/api/src/slack/slack.contracts.ts +++ b/apps/api/src/slack/slack.contracts.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { SLACK } from "./slack-config"; +import { SLACK, SLACK_SYNC_STATES } from "./slack-config"; export const slackChannelsInput = z.object({ cursor: z.string().trim().min(1).max(64).nullish(), @@ -38,3 +38,103 @@ export const slackCreateChannelReply = z.union([ ]); export type SlackCreateChannelReply = z.infer; + +const slackSyncStateOutput = z.enum(SLACK_SYNC_STATES); + +const slackAgentSummaryOutput = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + status: z.enum([ + "DRAFT", + "DEPLOYING", + "LIVE", + "PAUSED", + "ARCHIVED", + "DELETED", + ]), +}); + +export const slackStatusOutput = z.object({ + configured: z.boolean(), + connected: z.boolean(), + workspace: z.string().nullable(), + lastConnectedAt: z.string().nullable(), + scopes: z.array(z.string()), + canInviteItself: z.boolean(), + canManage: z.boolean(), + agents: z.array(slackAgentSummaryOutput), + people: z.object({ + matched: z.number(), + reviewed: z.number(), + }), +}); + +export type SlackStatus = z.infer; + +const slackMemberMatchOutput = z.object({ + slackUserId: z.string().nullable(), + slackHandle: z.string().nullable(), + slackEmail: z.string().nullable(), +}); + +export const slackMatchesOutput = z.object({ + rows: z.array( + z.object({ + crmUserId: z.string(), + name: z.string(), + email: z.string(), + match: slackMemberMatchOutput.nullable(), + }), + ), + sync: slackSyncStateOutput, +}); + +export type SlackMatches = z.infer; + +export const slackChannelsOutput = z.object({ + canInviteItself: z.boolean(), + sync: slackSyncStateOutput, + nextCursor: z.string().nullable(), + rows: z.array( + z.object({ + id: z.string(), + name: z.string(), + memberCount: z.number().nullable(), + isPrivate: z.boolean(), + isMember: z.boolean(), + classified: z.boolean(), + inviteRequestedAt: z.string().nullable(), + }), + ), +}); + +export type SlackChannelsResult = z.infer; + +export const slackJoinChannelOutput = z.object({ + queued: z.boolean(), + alreadyJoined: z.boolean(), +}); + +export type SlackJoinChannelResult = z.infer; + +export const slackRefreshPeopleOutput = z.object({ + requested: z.boolean(), +}); + +export type SlackRefreshPeopleResult = z.infer; + +export const slackCreateChannelOutput = z.object({ + channel: z.object({ + id: z.string(), + name: z.string(), + }), +}); + +export type SlackCreateChannelResult = z.infer; + +export const slackDisconnectOutput = z.object({ + disconnected: z.boolean(), +}); + +export type SlackDisconnectResult = z.infer; diff --git a/apps/api/src/slack/slack.router.ts b/apps/api/src/slack/slack.router.ts index bee3f2af1..9d8914a5c 100644 --- a/apps/api/src/slack/slack.router.ts +++ b/apps/api/src/slack/slack.router.ts @@ -10,10 +10,18 @@ import { import type { z } from "zod"; import type { AuthedTrpcContext } from "../trpc/context.types"; import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; +import { restMeta } from "../trpc/openapi"; import { slackChannelsInput, + slackChannelsOutput, slackCreateChannelInput, + slackCreateChannelOutput, + slackDisconnectOutput, slackJoinChannelInput, + slackJoinChannelOutput, + slackMatchesOutput, + slackRefreshPeopleOutput, + slackStatusOutput, } from "./slack.contracts"; import { SlackConnectionService } from "./slack-connection.service"; @@ -25,17 +33,27 @@ export class SlackRouter { private readonly connection: SlackConnectionService, ) {} - @Query() + @Query({ + output: slackStatusOutput, + meta: restMeta("GET", "/slack/status", ["Slack"]), + }) status(@Ctx() ctx: AuthedTrpcContext) { return this.connection.status(ctx.user.id); } - @Query() + @Query({ + output: slackMatchesOutput, + meta: restMeta("GET", "/slack/matches", ["Slack"]), + }) matches(@Ctx() ctx: AuthedTrpcContext) { return this.connection.matches(ctx.user.id); } - @Query({ input: slackChannelsInput }) + @Query({ + input: slackChannelsInput, + output: slackChannelsOutput, + meta: restMeta("GET", "/slack/channels", ["Slack"]), + }) channels( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -43,7 +61,11 @@ export class SlackRouter { return this.connection.channels(input, ctx.user.id); } - @Mutation({ input: slackJoinChannelInput }) + @Mutation({ + input: slackJoinChannelInput, + output: slackJoinChannelOutput, + meta: restMeta("POST", "/slack/channels/{channelId}/join", ["Slack"]), + }) joinChannel( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -51,12 +73,19 @@ export class SlackRouter { return this.connection.joinChannel(input, ctx.user.id); } - @Mutation() + @Mutation({ + output: slackRefreshPeopleOutput, + meta: restMeta("POST", "/slack/people/refresh", ["Slack"]), + }) refreshPeople(@Ctx() ctx: AuthedTrpcContext) { return this.connection.refreshPeople(ctx.user.id); } - @Mutation({ input: slackCreateChannelInput }) + @Mutation({ + input: slackCreateChannelInput, + output: slackCreateChannelOutput, + meta: restMeta("POST", "/slack/channels", ["Slack"]), + }) createChannel( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -64,7 +93,10 @@ export class SlackRouter { return this.connection.createChannel(input, ctx.user.id); } - @Mutation() + @Mutation({ + output: slackDisconnectOutput, + meta: restMeta("DELETE", "/slack/connection", ["Slack"]), + }) disconnect(@Ctx() ctx: AuthedTrpcContext) { return this.connection.disconnect(ctx.user.id); } diff --git a/apps/api/src/sso/sso.contracts.ts b/apps/api/src/sso/sso.contracts.ts index d56207908..d36d63e5c 100644 --- a/apps/api/src/sso/sso.contracts.ts +++ b/apps/api/src/sso/sso.contracts.ts @@ -26,3 +26,45 @@ export const deleteSsoProviderInput = z.object({ export type SsoProviderListInput = z.infer; export type RegisterSsoProviderInput = z.infer; export type DeleteSsoProviderInput = z.infer; + +const ssoPublicProviderOutput = z.object({ + providerId: z.string(), + name: z.string(), +}); + +export const ssoSignInOptionsOutput = z.object({ + google: z.boolean(), + microsoft: z.boolean(), + providers: z.array(ssoPublicProviderOutput), +}); + +export type SignInOptions = z.infer; + +export const ssoSettingsOutput = z.object({ + canConfigure: z.boolean(), + callbackBase: z.string(), +}); + +export type SsoSettings = z.infer; + +export const ssoProviderOutput = z.object({ + providerId: z.string(), + name: z.string(), + type: z.enum(["oidc", "saml"]), + issuer: z.string(), + domains: z.array(z.string()), + clientIdLastFour: z.string().nullable(), + callbackURL: z.string(), +}); + +export type SsoProvider = z.infer; + +export const ssoProviderListOutput = z.object({ + rows: z.array(ssoProviderOutput), + total: z.number(), + facetCounts: z.record(z.string(), z.record(z.string(), z.number())), +}); + +export const deleteSsoProviderOutput = z.object({ + providerId: z.string(), +}); diff --git a/apps/api/src/sso/sso.router.ts b/apps/api/src/sso/sso.router.ts index 84ba0ca73..978a0711c 100644 --- a/apps/api/src/sso/sso.router.ts +++ b/apps/api/src/sso/sso.router.ts @@ -11,10 +11,16 @@ import { import type { z } from "zod"; import type { AuthedTrpcContext, BaseTrpcContext } from "../trpc/context.types"; import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; +import { restMeta } from "../trpc/openapi"; import { deleteSsoProviderInput, + deleteSsoProviderOutput, registerSsoProviderInput, ssoProviderListInput, + ssoProviderListOutput, + ssoProviderOutput, + ssoSettingsOutput, + ssoSignInOptionsOutput, } from "./sso.contracts"; import { SsoService } from "./sso.service"; @@ -26,24 +32,40 @@ function headersOf(ctx: BaseTrpcContext): Headers { export class SsoRouter { constructor(@Inject(SsoService) private readonly sso: SsoService) {} - @Query() + @Query({ + output: ssoSignInOptionsOutput, + meta: restMeta("GET", "/sso/sign-in-options", ["SSO"], { + protect: false, + }), + }) async signInOptions() { return this.sso.signInOptions(); } - @Query() + @Query({ + output: ssoSettingsOutput, + meta: restMeta("GET", "/sso/settings", ["SSO"]), + }) @UseMiddlewares(AuthMiddleware) async settings(@Ctx() ctx: AuthedTrpcContext) { return this.sso.settings(ctx.user.id); } - @Query({ input: ssoProviderListInput }) + @Query({ + input: ssoProviderListInput, + output: ssoProviderListOutput, + meta: restMeta("GET", "/sso", ["SSO"]), + }) @UseMiddlewares(AuthMiddleware) async list(@Input() input: z.infer) { return this.sso.list(input); } - @Mutation({ input: registerSsoProviderInput }) + @Mutation({ + input: registerSsoProviderInput, + output: ssoProviderOutput, + meta: restMeta("POST", "/sso", ["SSO"]), + }) @UseMiddlewares(AuthMiddleware) async register( @Ctx() ctx: AuthedTrpcContext, @@ -52,7 +74,11 @@ export class SsoRouter { return this.sso.register(ctx.user.id, headersOf(ctx), input); } - @Mutation({ input: deleteSsoProviderInput }) + @Mutation({ + input: deleteSsoProviderInput, + output: deleteSsoProviderOutput, + meta: restMeta("DELETE", "/sso/{providerId}", ["SSO"]), + }) @UseMiddlewares(AuthMiddleware) async remove( @Ctx() ctx: AuthedTrpcContext, diff --git a/apps/api/src/sso/sso.service.ts b/apps/api/src/sso/sso.service.ts index bdb09d26c..340efcb65 100644 --- a/apps/api/src/sso/sso.service.ts +++ b/apps/api/src/sso/sso.service.ts @@ -30,35 +30,12 @@ import { import type { DeleteSsoProviderInput, RegisterSsoProviderInput, + SignInOptions, + SsoProvider, SsoProviderListInput, + SsoSettings, } from "./sso.contracts"; -export interface PublicSsoProvider { - providerId: string; - name: string; -} - -export interface SignInOptions { - google: boolean; - microsoft: boolean; - providers: PublicSsoProvider[]; -} - -export interface SsoProvider { - providerId: string; - name: string; - type: "oidc" | "saml"; - issuer: string; - domains: string[]; - clientIdLastFour: string | null; - callbackURL: string; -} - -export interface SsoSettings { - canConfigure: boolean; - callbackBase: string; -} - const PROVIDER_SELECT = { providerId: true, issuer: true, diff --git a/apps/api/src/sync/sync.controller.ts b/apps/api/src/sync/sync.controller.ts index 9c8191818..7464f607c 100644 --- a/apps/api/src/sync/sync.controller.ts +++ b/apps/api/src/sync/sync.controller.ts @@ -8,10 +8,27 @@ import { ServiceUnavailableException, } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; +import { + ApiExcludeEndpoint, + ApiForbiddenResponse, + ApiHeader, + ApiOkResponse, + ApiOperation, + ApiServiceUnavailableResponse, + ApiTags, +} from "@nestjs/swagger"; import { AllowAnonymous } from "@thallesp/nestjs-better-auth"; import type { EnvironmentVariables } from "../config/env.validation"; import { MailboxSyncService } from "./mailbox-sync.service"; +@ApiTags("Internal — Cron") +@ApiHeader({ + name: "authorization", + description: "`Bearer `", + required: true, +}) +@ApiForbiddenResponse({ description: "CRON_SECRET did not match." }) +@ApiServiceUnavailableResponse({ description: "CRON_SECRET is not set." }) @Controller("internal/sync") export class SyncController { private readonly logger = new Logger(SyncController.name); @@ -26,24 +43,32 @@ export class SyncController { @Get("mailboxes") @AllowAnonymous() + @ApiOperation({ summary: "Run any due Gmail, Outlook or calendar sync" }) + @ApiOkResponse({ description: "The sync ran; per-mailbox results." }) async mailboxesViaGet(@Headers("authorization") authorization?: string) { return this.run(authorization); } @Post("mailboxes") @AllowAnonymous() + @ApiExcludeEndpoint() async mailboxesViaPost(@Headers("authorization") authorization?: string) { return this.run(authorization); } @Get("google") @AllowAnonymous() + @ApiOperation({ + summary: "Alias of `mailboxes`, kept for existing cron deployments", + }) + @ApiOkResponse({ description: "The sync ran; per-mailbox results." }) async googleViaGet(@Headers("authorization") authorization?: string) { return this.run(authorization); } @Post("google") @AllowAnonymous() + @ApiExcludeEndpoint() async googleViaPost(@Headers("authorization") authorization?: string) { return this.run(authorization); } diff --git a/apps/api/src/telemetry/telemetry.controller.ts b/apps/api/src/telemetry/telemetry.controller.ts index 0ccd1b851..90ba0aaa7 100644 --- a/apps/api/src/telemetry/telemetry.controller.ts +++ b/apps/api/src/telemetry/telemetry.controller.ts @@ -8,10 +8,27 @@ import { ServiceUnavailableException, } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; +import { + ApiExcludeEndpoint, + ApiForbiddenResponse, + ApiHeader, + ApiOkResponse, + ApiOperation, + ApiServiceUnavailableResponse, + ApiTags, +} from "@nestjs/swagger"; import { AllowAnonymous } from "@thallesp/nestjs-better-auth"; import type { EnvironmentVariables } from "../config/env.validation"; import { RollupService } from "./rollup.service"; +@ApiTags("Internal — Cron") +@ApiHeader({ + name: "authorization", + description: "`Bearer `", + required: true, +}) +@ApiForbiddenResponse({ description: "CRON_SECRET did not match." }) +@ApiServiceUnavailableResponse({ description: "CRON_SECRET is not set." }) @Controller("internal/telemetry") export class TelemetryController { private readonly logger = new Logger(TelemetryController.name); @@ -26,12 +43,15 @@ export class TelemetryController { @Get("rollup") @AllowAnonymous() + @ApiOperation({ summary: "Roll up raw telemetry events into daily counts" }) + @ApiOkResponse({ description: "The rollup ran." }) async rollupViaGet(@Headers("authorization") authorization?: string) { return this.run(authorization); } @Post("rollup") @AllowAnonymous() + @ApiExcludeEndpoint() async rollupViaPost(@Headers("authorization") authorization?: string) { return this.run(authorization); } diff --git a/apps/api/src/tracking/tracking-filing.service.ts b/apps/api/src/tracking/tracking-filing.service.ts index 16aa5427e..257307a62 100644 --- a/apps/api/src/tracking/tracking-filing.service.ts +++ b/apps/api/src/tracking/tracking-filing.service.ts @@ -86,8 +86,8 @@ export class TrackingFilingService { const suppressed = await this.suppressed(email, domain); if (suppressed) return this.skip(submission.id, suppressed); - const existing = await this.db.contact.findUnique({ - where: { email }, + const existing = await this.db.contact.findFirst({ + where: { email, archivedAt: null }, select: { id: true }, }); @@ -156,8 +156,8 @@ export class TrackingFilingService { return null; } - return this.db.contact.findUnique({ - where: { email }, + return this.db.contact.findFirst({ + where: { email, archivedAt: null }, select: { id: true }, }); } diff --git a/apps/api/src/tracking/tracking.contracts.ts b/apps/api/src/tracking/tracking.contracts.ts index 40b4b91ef..c9b714fd2 100644 --- a/apps/api/src/tracking/tracking.contracts.ts +++ b/apps/api/src/tracking/tracking.contracts.ts @@ -36,3 +36,110 @@ export const companyActivityInput = z.object({ export const contactActivityInput = z.object({ contactId: z.string().min(1), }); + +const domainScopeOutput = z.enum(["SITE_AND_SUBDOMAINS", "EXACT_HOST"]); + +export const trackedDomainOutput = z.object({ + id: z.string(), + host: z.string(), + scope: domainScopeOutput, + pageViews: z.number(), + lastSeenAt: z.string().nullable(), +}); + +export const trackingSettingsOutput = z.object({ + siteId: z.string().nullable(), + ready: z.boolean(), + scriptUrl: z.string(), + snippet: z.string().nullable(), + tagManagerSnippet: z.string().nullable(), + crossDomain: z.boolean(), + limitToDomains: z.boolean(), + cookieSubdomains: z.boolean(), + secureCookies: z.boolean(), + honourDnt: z.boolean(), + cookieDays: z.number(), + paused: z.boolean(), + cookieLifetimes: z.array(z.object({ days: z.number(), label: z.string() })), + domains: z.array(trackedDomainOutput), + receivingSince: z.string().nullable(), + pageViews: z.number(), + submissions: z.number(), + canManage: z.boolean(), +}); + +export const rotateSiteIdOutput = z.object({ + siteId: z.string(), +}); + +const foundInContainerOutput = z.object({ + id: z.string(), + carriesSiteId: z.boolean(), +}); + +export const verifyOutput = z.discriminatedUnion("status", [ + z.object({ + status: z.literal("found"), + host: z.string(), + responseMs: z.number(), + allowed: z.boolean(), + pageView: z.boolean(), + container: foundInContainerOutput.nullable(), + }), + z.object({ + status: z.literal("missing"), + host: z.string(), + responseMs: z.number(), + containers: z.array(z.string()), + }), + z.object({ + status: z.literal("unreachable"), + host: z.string(), + detail: z.string(), + }), +]); + +export const sourceRowOutput = z.object({ + source: z.string(), + medium: z.string().nullable(), + views: z.number(), + contacts: z.number(), +}); + +export const sourcesOutput = z.array(sourceRowOutput); + +const touchSummaryOutput = z.object({ + label: z.string(), + source: z.string(), + medium: z.string().nullable(), + campaign: z.string().nullable(), + landing: z.string().nullable(), + referrer: z.string().nullable(), + at: z.string().nullable(), +}); + +const visitedPageOutput = z.object({ + host: z.string(), + path: z.string(), + views: z.number(), + lastSeenAt: z.string(), +}); + +export const websiteActivityOutput = z.object({ + identified: z.boolean(), + visitors: z.number(), + views: z.number(), + lastSeenAt: z.string().nullable(), + pages: z.array(visitedPageOutput), + firstTouch: touchSummaryOutput.nullable(), + lastTouch: touchSummaryOutput.nullable(), +}); + +export type TrackedDomainRow = z.infer; +export type TrackingSettings = z.infer; +export type FoundInContainer = z.infer; +export type VerifyResult = z.infer; +export type SourceRow = z.infer; +export type TouchSummary = z.infer; +export type VisitedPage = z.infer; +export type WebsiteActivity = z.infer; diff --git a/apps/api/src/tracking/tracking.controller.ts b/apps/api/src/tracking/tracking.controller.ts index 21ccbf238..2a3603e79 100644 --- a/apps/api/src/tracking/tracking.controller.ts +++ b/apps/api/src/tracking/tracking.controller.ts @@ -19,6 +19,17 @@ import { ServiceUnavailableException, } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; +import { + ApiExcludeEndpoint, + ApiForbiddenResponse, + ApiHeader, + ApiNoContentResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiServiceUnavailableResponse, + ApiTags, +} from "@nestjs/swagger"; import { AllowAnonymous } from "@thallesp/nestjs-better-auth"; import type { Response } from "express"; import { z } from "zod"; @@ -48,6 +59,7 @@ const parsedBody = z const trackingRequest = z.object({ body: parsedBody }).catch({ body: null }); +@ApiTags("Tracking") @Controller("api/t") export class TrackingController { private readonly logger = new Logger(TrackingController.name); @@ -59,6 +71,13 @@ export class TrackingController { @Get("config/:siteId") @AllowAnonymous() + @ApiOperation({ + summary: "Fetch a site's compiled tracking config, for the tracking script", + }) + @ApiParam({ name: "siteId", description: "Public site identifier." }) + @ApiOkResponse({ + description: "The compiled config, or null if the site is unknown.", + }) async publicConfig(@Param("siteId") siteId: string) { if (!isSiteId(siteId)) return { config: null }; @@ -72,6 +91,13 @@ export class TrackingController { @Post("e") @AllowAnonymous() @HttpCode(204) + @ApiOperation({ + summary: "Ingest a batch of events from the tracking script", + }) + @ApiNoContentResponse({ + description: + "Always returned, even when the batch was rejected or unreadable.", + }) async collect( @Req() request: IncomingMessage, @Res({ passthrough: true }) response: Response, @@ -106,6 +132,14 @@ export class TrackingController { } } +@ApiTags("Internal — Cron") +@ApiHeader({ + name: "authorization", + description: "`Bearer `", + required: true, +}) +@ApiForbiddenResponse({ description: "CRON_SECRET did not match." }) +@ApiServiceUnavailableResponse({ description: "CRON_SECRET is not set." }) @Controller("internal/tracking") export class TrackingRetentionController { private readonly logger = new Logger(TrackingRetentionController.name); @@ -122,12 +156,17 @@ export class TrackingRetentionController { @Get("retention") @AllowAnonymous() + @ApiOperation({ + summary: "Roll up and sweep tracking data older than the retention window", + }) + @ApiOkResponse({ description: "The sweep ran; removed and rolled counts." }) async viaGet(@Headers("authorization") authorization?: string) { return this.run(authorization); } @Post("retention") @AllowAnonymous() + @ApiExcludeEndpoint() async viaPost(@Headers("authorization") authorization?: string) { return this.run(authorization); } diff --git a/apps/api/src/tracking/tracking.router.ts b/apps/api/src/tracking/tracking.router.ts index 7b1b38534..36fe28876 100644 --- a/apps/api/src/tracking/tracking.router.ts +++ b/apps/api/src/tracking/tracking.router.ts @@ -7,17 +7,24 @@ import { Router, UseMiddlewares, } from "nestjs-trpc"; -import type { z } from "zod"; +import { z } from "zod"; import type { AuthedTrpcContext } from "../trpc/context.types"; import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; +import { restMeta } from "../trpc/openapi"; import { addDomainInput, companyActivityInput, contactActivityInput, cookieLifetimeInput, removeDomainInput, + rotateSiteIdOutput, + sourcesOutput, + trackedDomainOutput, trackingFlagInput, + trackingSettingsOutput, verifyInput, + verifyOutput, + websiteActivityOutput, } from "./tracking.contracts"; import { TrackingService } from "./tracking.service"; @@ -28,12 +35,19 @@ export class TrackingRouter { @Inject(TrackingService) private readonly tracking: TrackingService, ) {} - @Query() + @Query({ + output: trackingSettingsOutput, + meta: restMeta("GET", "/tracking/settings", ["Tracking"]), + }) async settings(@Ctx() ctx: AuthedTrpcContext) { return this.tracking.settings(ctx.user.id); } - @Mutation({ input: trackingFlagInput }) + @Mutation({ + input: trackingFlagInput, + output: z.void(), + meta: restMeta("PATCH", "/tracking/flags", ["Tracking"]), + }) async setFlag( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -41,7 +55,11 @@ export class TrackingRouter { return this.tracking.setFlag(ctx.user.id, input.flag, input.enabled); } - @Mutation({ input: cookieLifetimeInput }) + @Mutation({ + input: cookieLifetimeInput, + output: z.void(), + meta: restMeta("PATCH", "/tracking/cookie-lifetime", ["Tracking"]), + }) async setCookieLifetime( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -49,7 +67,11 @@ export class TrackingRouter { return this.tracking.setCookieDays(ctx.user.id, input.days); } - @Mutation({ input: addDomainInput }) + @Mutation({ + input: addDomainInput, + output: trackedDomainOutput, + meta: restMeta("POST", "/tracking/domains", ["Tracking"]), + }) async addDomain( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -57,7 +79,11 @@ export class TrackingRouter { return this.tracking.addDomain(ctx.user.id, input); } - @Mutation({ input: removeDomainInput }) + @Mutation({ + input: removeDomainInput, + output: z.void(), + meta: restMeta("DELETE", "/tracking/domains/{id}", ["Tracking"]), + }) async removeDomain( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -65,12 +91,19 @@ export class TrackingRouter { return this.tracking.removeDomain(ctx.user.id, input.id); } - @Mutation() + @Mutation({ + output: rotateSiteIdOutput, + meta: restMeta("POST", "/tracking/site-id/rotate", ["Tracking"]), + }) async rotateSiteId(@Ctx() ctx: AuthedTrpcContext) { return this.tracking.rotateSiteId(ctx.user.id); } - @Mutation({ input: verifyInput }) + @Mutation({ + input: verifyInput, + output: verifyOutput, + meta: restMeta("POST", "/tracking/verify", ["Tracking"]), + }) async verify( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -78,17 +111,32 @@ export class TrackingRouter { return this.tracking.verify(ctx.user.id, input.url); } - @Query() + @Query({ + output: sourcesOutput, + meta: restMeta("GET", "/tracking/sources", ["Tracking"]), + }) async sources(@Ctx() ctx: AuthedTrpcContext) { return this.tracking.sources(ctx.user.id); } - @Query({ input: companyActivityInput }) + @Query({ + input: companyActivityInput, + output: websiteActivityOutput, + meta: restMeta("GET", "/tracking/companies/{companyId}/activity", [ + "Tracking", + ]), + }) async companyActivity(@Input() input: z.infer) { return this.tracking.activityForCompany(input.companyId); } - @Query({ input: contactActivityInput }) + @Query({ + input: contactActivityInput, + output: websiteActivityOutput, + meta: restMeta("GET", "/tracking/contacts/{contactId}/activity", [ + "Tracking", + ]), + }) async contactActivity(@Input() input: z.infer) { return this.tracking.activityForContact(input.contactId); } diff --git a/apps/api/src/tracking/tracking.service.ts b/apps/api/src/tracking/tracking.service.ts index 982df1cdf..7b67416cb 100644 --- a/apps/api/src/tracking/tracking.service.ts +++ b/apps/api/src/tracking/tracking.service.ts @@ -30,93 +30,18 @@ import { NotFoundException, } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; +import type { + FoundInContainer, + SourceRow, + TouchSummary, + TrackedDomainRow, + TrackingSettings, + VerifyResult, + VisitedPage, + WebsiteActivity, +} from "./tracking.contracts"; import { TrackingConfigService } from "./tracking-config.service"; -export interface TrackedDomainRow { - id: string; - host: string; - scope: DomainScope; - pageViews: number; - lastSeenAt: string | null; -} - -export interface TrackingSettings { - siteId: string | null; - ready: boolean; - scriptUrl: string; - snippet: string | null; - tagManagerSnippet: string | null; - crossDomain: boolean; - limitToDomains: boolean; - cookieSubdomains: boolean; - secureCookies: boolean; - honourDnt: boolean; - cookieDays: number; - paused: boolean; - cookieLifetimes: { days: number; label: string }[]; - domains: TrackedDomainRow[]; - receivingSince: string | null; - pageViews: number; - submissions: number; - canManage: boolean; -} - -export interface VisitedPage { - host: string; - path: string; - views: number; - lastSeenAt: string; -} - -export interface TouchSummary { - label: string; - source: string; - medium: string | null; - campaign: string | null; - landing: string | null; - referrer: string | null; - at: string | null; -} - -export interface WebsiteActivity { - identified: boolean; - visitors: number; - views: number; - lastSeenAt: string | null; - pages: VisitedPage[]; - firstTouch: TouchSummary | null; - lastTouch: TouchSummary | null; -} - -export interface SourceRow { - source: string; - medium: string | null; - views: number; - contacts: number; -} - -export interface FoundInContainer { - id: string; - carriesSiteId: boolean; -} - -export type VerifyResult = - | { - status: "found"; - host: string; - responseMs: number; - allowed: boolean; - pageView: boolean; - container: FoundInContainer | null; - } - | { - status: "missing"; - host: string; - responseMs: number; - containers: string[]; - } - | { status: "unreachable"; host: string; detail: string }; - @Injectable() export class TrackingService { constructor( diff --git a/apps/api/src/trpc/list-input.ts b/apps/api/src/trpc/list-input.ts index e207d96eb..c2325f192 100644 --- a/apps/api/src/trpc/list-input.ts +++ b/apps/api/src/trpc/list-input.ts @@ -68,9 +68,62 @@ export const FACET_ALL = "all"; export const FACET_UNASSIGNED = "unassigned"; -export function ownerFilter( - value: string, -): { ownerId: string | null } | undefined { - if (value === FACET_ALL) return undefined; - return { ownerId: value === FACET_UNASSIGNED ? null : value }; +export function splitSentinel(values: string[], sentinel: string) { + const ids = values.filter((value) => value !== sentinel); + return { ids, includesSentinel: ids.length !== values.length }; +} + +export function ownerFilter< + TWhere = + | { ownerId: { in: string[] } } + | { ownerId: null } + | { OR: unknown[] }, +>(values: string[]): TWhere | 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; +} + +export function archivedFilter(archived: boolean) { + return { archivedAt: archived ? { not: null } : null }; +} + +export const ACTIVITY_WINDOWS = ["7", "30", "90"] as const; + +export type ActivityWindow = (typeof ACTIVITY_WINDOWS)[number]; + +const activityWindowSet: ReadonlySet = new Set(ACTIVITY_WINDOWS); + +export const activityFacetInput = z + .array(z.string()) + .refine((values) => values.every((value) => activityWindowSet.has(value)), { + message: `Activity must be one of: ${ACTIVITY_WINDOWS.join(", ")}.`, + }); + +function activityCutoff(days: number): Date { + return new Date(Date.now() - days * 24 * 60 * 60 * 1000); +} + +export function activityFilter( + values: string[], +): { lastActivityAt: { gte: Date } } | undefined { + if (values.length === 0) return undefined; + const days = values.reduce((max, value) => Math.max(max, Number(value)), 0); + return { lastActivityAt: { gte: activityCutoff(days) } }; +} + +export async function activityFacetCounts( + countWhere: (where: { lastActivityAt: { gte: Date } }) => Promise, +): Promise { + const counts = await Promise.all( + ACTIVITY_WINDOWS.map((days) => + countWhere({ lastActivityAt: { gte: activityCutoff(Number(days)) } }), + ), + ); + return Object.fromEntries( + ACTIVITY_WINDOWS.map((days, index) => [days, counts[index]]), + ) as FacetCount; } diff --git a/apps/api/src/trpc/middlewares/session-only.middleware.ts b/apps/api/src/trpc/middlewares/session-only.middleware.ts new file mode 100644 index 000000000..1216ad998 --- /dev/null +++ b/apps/api/src/trpc/middlewares/session-only.middleware.ts @@ -0,0 +1,20 @@ +import { API_KEY_HEADER } from "@crm/auth"; +import { Injectable } from "@nestjs/common"; +import { TRPCError } from "@trpc/server"; +import type { + MiddlewareOptions, + MiddlewareResponse, + TRPCMiddleware, +} from "nestjs-trpc"; +import type { BaseTrpcContext } from "../context.types"; + +@Injectable() +export class SessionOnlyMiddleware implements TRPCMiddleware { + async use(opts: MiddlewareOptions): Promise { + const ctx = opts.ctx as BaseTrpcContext; + if (ctx.req?.headers[API_KEY_HEADER]) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + return opts.next(); + } +} diff --git a/apps/api/src/trpc/openapi.ts b/apps/api/src/trpc/openapi.ts new file mode 100644 index 000000000..7cca22ca0 --- /dev/null +++ b/apps/api/src/trpc/openapi.ts @@ -0,0 +1,21 @@ +import type { OpenApiMeta } from "trpc-to-openapi"; + +export const REST_BRIDGE_PATH = "/rest"; + +export type RestMethod = "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; + +export function restMeta( + method: RestMethod, + path: `/${string}`, + tags: string[], + options: { protect?: boolean } = {}, +): OpenApiMeta { + return { + openapi: { + method, + path, + tags, + protect: options.protect ?? true, + }, + }; +} diff --git a/apps/api/src/trpc/trpc.context.ts b/apps/api/src/trpc/trpc.context.ts index 55ecc7a77..873d5a801 100644 --- a/apps/api/src/trpc/trpc.context.ts +++ b/apps/api/src/trpc/trpc.context.ts @@ -1,18 +1,25 @@ import { auth } from "@crm/auth"; import { Injectable } from "@nestjs/common"; import { fromNodeHeaders } from "better-auth/node"; +import type { Request } from "express"; import type { ContextOptions, TRPCContext } from "nestjs-trpc"; import type { BaseTrpcContext } from "./context.types"; +export async function createBaseTrpcContext( + req: Request | undefined, +): Promise { + const session = req + ? await auth.api + .getSession({ headers: fromNodeHeaders(req.headers) }) + .catch(() => null) + : null; + return { req, session }; +} + @Injectable() export class TrpcContext implements TRPCContext { async create(opts: ContextOptions): Promise { const req = "req" in opts ? opts.req : undefined; - const session = req - ? await auth.api - .getSession({ headers: fromNodeHeaders(req.headers) }) - .catch(() => null) - : null; - return { req, session }; + return createBaseTrpcContext(req); } } diff --git a/apps/api/src/trpc/trpc.module.ts b/apps/api/src/trpc/trpc.module.ts index 4aa38a258..a4ffa5602 100644 --- a/apps/api/src/trpc/trpc.module.ts +++ b/apps/api/src/trpc/trpc.module.ts @@ -5,6 +5,7 @@ import { formatTrpcError } from "./error-formatter"; import { AuthMiddleware } from "./middlewares/auth.middleware"; import { DomainErrorMiddleware } from "./middlewares/domain-error.middleware"; import { LoggingMiddleware } from "./middlewares/logging.middleware"; +import { SessionOnlyMiddleware } from "./middlewares/session-only.middleware"; import { TrpcContext } from "./trpc.context"; import { TrpcErrorHandler } from "./trpc-error.handler"; @@ -25,7 +26,8 @@ import { TrpcErrorHandler } from "./trpc-error.handler"; LoggingMiddleware, DomainErrorMiddleware, AuthMiddleware, + SessionOnlyMiddleware, ], - exports: [AuthMiddleware], + exports: [AuthMiddleware, SessionOnlyMiddleware], }) export class TrpcModule {} diff --git a/apps/api/src/users/users.router.ts b/apps/api/src/users/users.router.ts index 8a6034333..1a49e6d54 100644 --- a/apps/api/src/users/users.router.ts +++ b/apps/api/src/users/users.router.ts @@ -1,10 +1,21 @@ import { Inject } from "@nestjs/common"; import { Ctx, Query, Router, UseMiddlewares } from "nestjs-trpc"; +import { z } from "zod"; import { AuthService } from "../auth/auth.service"; import type { AuthedTrpcContext } from "../trpc/context.types"; import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; +import { restMeta } from "../trpc/openapi"; import { UsersService } from "./users.service"; +const usersListOutput = z.array( + z.object({ + id: z.string(), + name: z.string(), + email: z.string(), + image: z.string().nullable(), + }), +); + @Router({ alias: "users" }) @UseMiddlewares(AuthMiddleware) export class UsersRouter { @@ -18,7 +29,10 @@ export class UsersRouter { return this.auth.getProfile(ctx.user.id); } - @Query() + @Query({ + output: usersListOutput, + meta: restMeta("GET", "/users", ["Users"]), + }) async list() { return this.users.list(); } diff --git a/apps/api/src/workspace/workspace.contracts.ts b/apps/api/src/workspace/workspace.contracts.ts index 55d577840..a3f50172b 100644 --- a/apps/api/src/workspace/workspace.contracts.ts +++ b/apps/api/src/workspace/workspace.contracts.ts @@ -4,7 +4,7 @@ import { z } from "zod"; import { listInput } from "../trpc/list-input"; export const memberListInput = listInput.extend({ - role: z.string().default("all"), + role: z.array(z.string()).default([]), }); export type MemberListInput = z.infer; @@ -28,3 +28,35 @@ export const setMemberRoleInput = z.object({ export type UpdateWorkspaceInput = z.infer; export type SetMemberRoleInput = z.infer; + +export const workspaceOutput = z.object({ + id: z.string(), + slug: z.string(), + name: z.string(), + website: z.string().nullable(), + onboarded: z.boolean(), + viewerRole: z.enum(WORKSPACE_ROLES).nullable(), + canRename: z.boolean(), + canChangeRoles: z.boolean(), +}); + +export type Workspace = z.infer; + +export const workspaceMemberOutput = z.object({ + id: z.string(), + userId: z.string(), + name: z.string(), + email: z.string(), + image: z.string().nullable(), + role: z.enum(WORKSPACE_ROLES), + joinedAt: z.string(), + isViewer: z.boolean(), +}); + +export type WorkspaceMember = z.infer; + +export const memberListOutput = z.object({ + rows: z.array(workspaceMemberOutput), + total: z.number(), + facetCounts: z.record(z.string(), z.record(z.string(), z.number())), +}); diff --git a/apps/api/src/workspace/workspace.router.ts b/apps/api/src/workspace/workspace.router.ts index ea8e03241..1dddb91c9 100644 --- a/apps/api/src/workspace/workspace.router.ts +++ b/apps/api/src/workspace/workspace.router.ts @@ -10,10 +10,14 @@ import { import type { z } from "zod"; import type { AuthedTrpcContext } from "../trpc/context.types"; import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; +import { restMeta } from "../trpc/openapi"; import { memberListInput, + memberListOutput, setMemberRoleInput, updateWorkspaceInput, + workspaceMemberOutput, + workspaceOutput, } from "./workspace.contracts"; import { WorkspaceService } from "./workspace.service"; @@ -24,12 +28,19 @@ export class WorkspaceRouter { @Inject(WorkspaceService) private readonly workspace: WorkspaceService, ) {} - @Query() + @Query({ + output: workspaceOutput, + meta: restMeta("GET", "/workspace", ["Workspace"]), + }) async get(@Ctx() ctx: AuthedTrpcContext) { return this.workspace.get(ctx.user.id); } - @Query({ input: memberListInput }) + @Query({ + input: memberListInput, + output: memberListOutput, + meta: restMeta("POST", "/workspace/members/search", ["Workspace"]), + }) async members( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -37,7 +48,11 @@ export class WorkspaceRouter { return this.workspace.members(ctx.user.id, input); } - @Mutation({ input: updateWorkspaceInput }) + @Mutation({ + input: updateWorkspaceInput, + output: workspaceOutput, + meta: restMeta("PATCH", "/workspace", ["Workspace"]), + }) async update( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, @@ -45,7 +60,13 @@ export class WorkspaceRouter { return this.workspace.update(ctx.user.id, input); } - @Mutation({ input: setMemberRoleInput }) + @Mutation({ + input: setMemberRoleInput, + output: workspaceMemberOutput, + meta: restMeta("PATCH", "/workspace/members/{memberId}/role", [ + "Workspace", + ]), + }) async setMemberRole( @Ctx() ctx: AuthedTrpcContext, @Input() input: z.infer, diff --git a/apps/api/src/workspace/workspace.service.ts b/apps/api/src/workspace/workspace.service.ts index 8e49e6f29..65cc99ab6 100644 --- a/apps/api/src/workspace/workspace.service.ts +++ b/apps/api/src/workspace/workspace.service.ts @@ -22,7 +22,6 @@ import { normalizeDomain } from "../companies/domain"; import { InjectDatabase } from "../database/database.constants"; import { countsByKey, - FACET_ALL, type ListResult, type OrderByColumns, paginate, @@ -32,30 +31,10 @@ import type { MemberListInput, SetMemberRoleInput, UpdateWorkspaceInput, + Workspace, + WorkspaceMember, } from "./workspace.contracts"; -export interface Workspace { - id: string; - slug: string; - name: string; - website: string | null; - onboarded: boolean; - viewerRole: WorkspaceRole | null; - canRename: boolean; - canChangeRoles: boolean; -} - -export interface WorkspaceMember { - id: string; - userId: string; - name: string; - email: string; - image: string | null; - role: WorkspaceRole; - joinedAt: string; - isViewer: boolean; -} - const MEMBER_SELECT = { id: true, role: true, @@ -278,8 +257,8 @@ export class WorkspaceService { private buildWhere(input: MemberListInput): Prisma.MemberWhereInput { const where = this.searchWhere(input.q); - if (input.role !== FACET_ALL) { - where.role = input.role; + if (input.role.length > 0) { + where.role = { in: input.role }; } return where; diff --git a/apps/api/test/bulk.spec.ts b/apps/api/test/bulk.spec.ts index b357c2d2d..3b8871836 100644 --- a/apps/api/test/bulk.spec.ts +++ b/apps/api/test/bulk.spec.ts @@ -105,7 +105,13 @@ describe("assigning an owner to a selection", () => { ids: [first.id, second.id], ownerId: secondOwnerId, }), - ).toEqual({ requested: 2, succeeded: 2, failed: 0, message: null }); + ).toEqual({ + requested: 2, + succeeded: 2, + skipped: 0, + failed: 0, + message: null, + }); expect( await db.contact.count({ @@ -125,7 +131,13 @@ describe("assigning an owner to a selection", () => { ids: [only.id, only.id], ownerId, }), - ).toEqual({ requested: 1, succeeded: 1, failed: 0, message: null }); + ).toEqual({ + requested: 1, + succeeded: 1, + skipped: 0, + failed: 0, + message: null, + }); }); it("refuses an owner who does not work here", async () => { @@ -154,7 +166,7 @@ describe("assigning an owner to a selection", () => { }); }); -describe("deleting a selection", () => { +describe("purging a selection", () => { it("suppresses every address, exactly as deleting them one by one would", async () => { const first = await contacts.create({ firstName: "Gone", @@ -165,9 +177,10 @@ describe("deleting a selection", () => { email: `also-gone@${domain}`, }); - expect(await contacts.bulkDelete([first.id, second.id])).toEqual({ + expect(await contacts.bulkPurge([first.id, second.id])).toEqual({ requested: 2, succeeded: 2, + skipped: 0, failed: 0, message: null, }); @@ -185,10 +198,7 @@ describe("deleting a selection", () => { email: `doomed@${domain}`, }); - const result = await contacts.bulkDelete([ - survivor.id, - `missing-${suffix}`, - ]); + const result = await contacts.bulkPurge([survivor.id, `missing-${suffix}`]); expect(result.succeeded).toBe(1); expect(result.failed).toBe(1); @@ -209,9 +219,10 @@ describe("deleting a selection", () => { ownerId, }); - expect(await companies.bulkDelete([doomed.id])).toEqual({ + expect(await companies.bulkPurge([doomed.id])).toEqual({ requested: 1, succeeded: 1, + skipped: 0, failed: 0, message: null, }); @@ -268,7 +279,13 @@ describe("moving a selection of deals to a stage", () => { }, ownerId, ), - ).toEqual({ requested: 2, succeeded: 2, failed: 0, message: null }); + ).toEqual({ + requested: 2, + succeeded: 2, + skipped: 0, + failed: 0, + message: null, + }); const closed = await db.deal.findMany({ where: { id: { in: [first.id, second.id] } }, diff --git a/apps/api/test/currency-totals.integration.spec.ts b/apps/api/test/currency-totals.integration.spec.ts index f51b637e6..5cb97c7a4 100644 --- a/apps/api/test/currency-totals.integration.spec.ts +++ b/apps/api/test/currency-totals.integration.spec.ts @@ -234,9 +234,11 @@ describe("the deals list", () => { sort: "amount", dir: "desc", status: "open", - owner: userId, - stage: "all", - closing: "all", + owner: [userId], + stage: [], + closing: [], + fields: {}, + archived: false, }); expect(list.reportingCurrency).toBe("USD"); diff --git a/apps/api/test/fields.spec.ts b/apps/api/test/fields.spec.ts index 9a824991a..73e83a1d1 100644 --- a/apps/api/test/fields.spec.ts +++ b/apps/api/test/fields.spec.ts @@ -23,15 +23,26 @@ const suffix = process.env.TEST_RUN_ID ?? "fields-spec"; const domain = `fields-${suffix}.test`; const ownerId = `owner-${suffix}`; -const queued: { entity: FieldEntity; key: string; reason: string }[] = []; +const queued: { + entity: FieldEntity; + keys: string[]; + ids: string[]; + reason: string; +}[] = []; const agent = { contactCreated: async () => undefined, companyCreated: async () => undefined, companyRequested: async () => undefined, withCrmEvents: withDiscardedCrmEvents, - fieldBackfill: async (entity: FieldEntity, key: string, reason: string) => { - queued.push({ entity, key, reason }); + fieldBackfillRecords: async ( + entity: FieldEntity, + keys: string[], + ids: string[], + reason: string, + ) => { + queued.push({ entity, keys, ids, reason }); + return { queued: ids.length, merged: 0 }; }, } as unknown as AgentTriggerService; @@ -68,9 +79,21 @@ async function clean() { select: { id: true }, }); const companyIds = owned.map((row) => row.id); + const ownedContacts = await db.contact.findMany({ + where: { companyId: { in: companyIds } }, + select: { id: true }, + }); + const contactIds = ownedContacts.map((row) => row.id); await db.agentTask.deleteMany({ - where: { kind: "field-backfill", reason: { contains: "spec_" } }, + 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 } } }); @@ -132,6 +155,7 @@ describe("field definitions", () => { required: false, showOnSheet: true, showOnTable: false, + showOnFilter: false, }); expect(field.key).toBe("spec_runs_on"); @@ -139,9 +163,13 @@ describe("field definitions", () => { "AWS", "Azure", ]); - expect(queued).toEqual([ - { entity: "COMPANY", key: "spec_runs_on", reason: "New field" }, - ]); + expect(queued).toHaveLength(1); + expect(queued[0]).toMatchObject({ + entity: "COMPANY", + keys: ["spec_runs_on"], + reason: "New field: Spec runs on", + }); + expect(queued[0]?.ids).toContain(companyId); }); it("refuses a duplicate key", async () => { @@ -156,6 +184,7 @@ describe("field definitions", () => { required: false, showOnSheet: true, showOnTable: false, + showOnFilter: false, }), ).rejects.toThrow(/already a field/); }); @@ -196,6 +225,7 @@ describe("field definitions", () => { required: false, showOnSheet: true, showOnTable: false, + showOnFilter: false, }); let refused: Error | null = null; @@ -242,6 +272,7 @@ describe("field definitions", () => { required: false, showOnSheet: true, showOnTable: false, + showOnFilter: false, }); const first = await fields.byKey("COMPANY", "spec_runs_on"); @@ -280,6 +311,7 @@ describe("field values", () => { required: false, showOnSheet: true, showOnTable: false, + showOnFilter: false, }); await fields.applyValues(db, "COMPANY", companyId, { @@ -364,6 +396,7 @@ describe("field values", () => { required: false, showOnSheet: true, showOnTable: false, + showOnFilter: false, }); let refused: Error | null = null; @@ -426,6 +459,7 @@ describe("a select option that was taken away", () => { required: false, showOnSheet: true, showOnTable: false, + showOnFilter: false, }); const gold = field.options.find((option) => option.label === "Gold"); @@ -474,6 +508,7 @@ describe("a select option that was taken away", () => { required: false, showOnSheet: true, showOnTable: true, + showOnFilter: false, }); const rollout = field.options.find((option) => option.label === "Rollout"); @@ -520,6 +555,7 @@ describe("a record update that fails", () => { required: false, showOnSheet: true, showOnTable: false, + showOnFilter: false, }); const contact = await db.contact.create({ @@ -558,6 +594,7 @@ describe("a record update that fails", () => { required: false, showOnSheet: true, showOnTable: false, + showOnFilter: false, }); const deal = await db.deal.create({ @@ -585,21 +622,65 @@ describe("a record update that fails", () => { }); describe("queueing a backfill", () => { - it("keeps one entity's field apart from another's with the same key", async () => { + it("targets the record, and merges a second field into the same pending task", async () => { const trigger = new AgentTriggerService(db); + const otherCompanyId = await makeCompany("fields-co-2"); + + const first = await trigger.fieldBackfillRecords( + "COMPANY", + ["spec_website"], + [companyId, otherCompanyId], + "New field", + ); + expect(first).toEqual({ queued: 2, merged: 0 }); - await trigger.fieldBackfill("COMPANY", "spec_website", "New field"); - await trigger.fieldBackfill("CONTACT", "spec_website", "New field"); - await trigger.fieldBackfill("COMPANY", "spec_website", "Brief changed"); + const second = await trigger.fieldBackfillRecords( + "COMPANY", + ["spec_segment"], + [companyId], + "New field", + ); + expect(second).toEqual({ queued: 0, merged: 1 }); const tasks = await db.agentTask.findMany({ - where: { kind: "field-backfill", reason: { contains: "spec_website" } }, - select: { reason: true }, + where: { + kind: "field-backfill", + companyId: { in: [companyId, otherCompanyId] }, + }, + select: { companyId: true, payload: true }, }); - expect(tasks.map((task) => task.reason).sort()).toEqual([ - "company.spec_website: New field", - "contact.spec_website: New field", - ]); + expect(tasks).toHaveLength(2); + const mine = tasks.find((task) => task.companyId === companyId); + expect(mine?.payload).toEqual({ + entity: "COMPANY", + keys: ["spec_website", "spec_segment"], + }); + }); + + 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 }, + }); + + await trigger.fieldBackfillRecords( + "CONTACT", + ["spec_website"], + [contact.id], + "New field", + ); + + const tasks = await db.agentTask.findMany({ + where: { kind: "field-backfill", contactId: contact.id }, + select: { payload: true }, + }); + + expect(tasks).toHaveLength(1); + expect(tasks[0]?.payload).toEqual({ + entity: "CONTACT", + keys: ["spec_website"], + }); }); }); diff --git a/apps/api/test/logging.spec.ts b/apps/api/test/logging.spec.ts index 20664959e..f5ca0308c 100644 --- a/apps/api/test/logging.spec.ts +++ b/apps/api/test/logging.spec.ts @@ -9,13 +9,14 @@ import { import { RequestLoggerMiddleware } from "../src/logging/request-logger.middleware"; function withNodeEnv(value: string, fn: () => void): void { - const previous = process.env.NODE_ENV; - process.env.NODE_ENV = value; + const env = process.env as { NODE_ENV: string }; + const previous = env.NODE_ENV; + env.NODE_ENV = value; try { fn(); } finally { - process.env.NODE_ENV = previous; + env.NODE_ENV = previous; } } diff --git a/apps/api/test/record-delete.spec.ts b/apps/api/test/record-delete.spec.ts index 15c03b241..244920a70 100644 --- a/apps/api/test/record-delete.spec.ts +++ b/apps/api/test/record-delete.spec.ts @@ -120,7 +120,7 @@ beforeAll(async () => { afterAll(clean); -describe("deleting a contact", () => { +describe("purging a contact", () => { let contactId: string; it("takes the record, its queued research and its transcript with it", async () => { @@ -145,7 +145,7 @@ describe("deleting a contact", () => { }, }); - expect(await contacts.delete(contactId)).toEqual({ + expect(await contacts.purge(contactId)).toEqual({ id: contactId, name: "Gone Person", }); @@ -222,7 +222,7 @@ describe("deleting a contact", () => { }), ).toEqual({ email: asSynced }); - await contacts.delete(created.id); + await contacts.purge(created.id); expect( await db.suppressedContact.findUnique({ where: { email: asSynced } }), @@ -245,7 +245,7 @@ describe("deleting a contact", () => { }); }); -describe("deleting a company", () => { +describe("purging a company", () => { it("takes its deals and leaves its people without a company", async () => { const company = await companies.create({ name: "Doomed", @@ -264,7 +264,7 @@ describe("deleting a company", () => { await parked({ companyId: company.id }); - expect(await companies.delete(company.id)).toEqual({ + expect(await companies.purge(company.id)).toEqual({ id: company.id, name: "Doomed", }); @@ -284,7 +284,7 @@ describe("deleting a company", () => { }); }); -describe("the activity stamps a delete leaves behind", () => { +describe("the activity stamps a purge leaves behind", () => { it("are recomputed on every record the deleted one's activities touched", async () => { const company = await companies.create({ name: "Stamped", @@ -317,7 +317,7 @@ describe("the activity stamps a delete leaves behind", () => { at, ); - await contacts.delete(contact.id); + await contacts.purge(contact.id); expect( await db.company.findUnique({ @@ -361,7 +361,7 @@ describe("the activity stamps a delete leaves behind", () => { }); await stamp.touch({ contactId: contact.id, dealId: deal.id }, at); - await companies.delete(company.id); + await companies.purge(company.id); expect( await db.contact.findUnique({ diff --git a/apps/api/vercel.json b/apps/api/vercel.json index 289733996..ffce1ccc0 100644 --- a/apps/api/vercel.json +++ b/apps/api/vercel.json @@ -16,6 +16,10 @@ { "path": "/internal/tracking/retention", "schedule": "0 4 * * *" + }, + { + "path": "/internal/archive/prune", + "schedule": "0 5 * * *" } ] } diff --git a/apps/app/app/(app)/[slug]/companies/companies-bulk-actions.tsx b/apps/app/app/(app)/[slug]/companies/companies-bulk-actions.tsx index 9658fe74f..3ac14002e 100644 --- a/apps/app/app/(app)/[slug]/companies/companies-bulk-actions.tsx +++ b/apps/app/app/(app)/[slug]/companies/companies-bulk-actions.tsx @@ -1,7 +1,8 @@ "use client"; +import Archive from "@carbon/icons-react/es/Archive"; import Renew from "@carbon/icons-react/es/Renew"; -import TrashCan from "@carbon/icons-react/es/TrashCan"; +import Undo from "@carbon/icons-react/es/Undo"; import { DropdownMenuGroup, DropdownMenuItem, @@ -27,9 +28,11 @@ function companies(count: number): string { export function CompaniesBulkActions({ ids, onDone, + archived, }: { ids: string[]; onDone: () => void; + archived: boolean; }) { const trpc = useTRPC(); const cache = useCrmCache(); @@ -63,11 +66,33 @@ export function CompaniesBulkActions({ }), ); - const remove = useMutation( - trpc.companies.bulkDelete.mutationOptions({ + const archive = useMutation( + trpc.companies.bulkArchive.mutationOptions({ onSuccess: async (result, variables) => { await cache.removedMany({ kind: "company", ids: variables.ids }); - reportBulk(result, (count) => `${companies(count)} deleted.`); + reportBulk(result, (count) => `${companies(count)} archived.`); + onDone(); + }, + onError, + }), + ); + + const restore = useMutation( + trpc.companies.bulkRestore.mutationOptions({ + onSuccess: async (result) => { + await cache.company(); + reportBulk(result, (count) => `${companies(count)} restored.`); + onDone(); + }, + onError, + }), + ); + + const purge = useMutation( + trpc.companies.bulkPurge.mutationOptions({ + onSuccess: async (result, variables) => { + await cache.removedMany({ kind: "company", ids: variables.ids }); + reportBulk(result, (count) => `${companies(count)} deleted forever.`); setConfirming(false); onDone(); }, @@ -75,41 +100,63 @@ export function CompaniesBulkActions({ }), ); - const pending = assignOwner.isPending || enrich.isPending || remove.isPending; + if (archived) { + const pending = restore.isPending || purge.isPending; - return ( - <> - - assignOwner.mutate({ ids, ownerId })} + return ( + <> + + + restore.mutate({ ids })}> + + Restore + + + + + setConfirming(true)} + > + Delete forever + + + + + purge.mutate({ ids })} /> - - enrich.mutate({ ids })}> - - Re-enrich - - - - - setConfirming(true)} - > - - Delete - - - + + ); + } - remove.mutate({ ids })} + const pending = + assignOwner.isPending || enrich.isPending || archive.isPending; + + return ( + + assignOwner.mutate({ ids, ownerId })} /> - + + enrich.mutate({ ids })}> + + Re-enrich + + + + + archive.mutate({ ids })}> + + Archive + + + ); } diff --git a/apps/app/app/(app)/[slug]/companies/companies-search-params.ts b/apps/app/app/(app)/[slug]/companies/companies-search-params.ts index fa38ad0c0..122a9824c 100644 --- a/apps/app/app/(app)/[slug]/companies/companies-search-params.ts +++ b/apps/app/app/(app)/[slug]/companies/companies-search-params.ts @@ -3,5 +3,5 @@ import { createListSearchParams } from "@/components/data-table/list-search-para export const companiesSearchParams = createListSearchParams({ defaultSort: "createdAt", defaultDir: "desc", - facetIds: ["owner", "industry", "enrichment"] as const, + facetIds: ["owner", "industry", "enrichment", "activity"] as const, }); diff --git a/apps/app/app/(app)/[slug]/companies/companies-table.tsx b/apps/app/app/(app)/[slug]/companies/companies-table.tsx index 094397832..b2356b13f 100644 --- a/apps/app/app/(app)/[slug]/companies/companies-table.tsx +++ b/apps/app/app/(app)/[slug]/companies/companies-table.tsx @@ -1,5 +1,7 @@ "use client"; +import Archive from "@carbon/icons-react/es/Archive"; +import { Button } from "@crm/ui/components/button"; import { DataTable, type DataTableColumn, @@ -15,12 +17,15 @@ import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; import { EnrichmentIndicator } from "@/components/crm/enrichment-status"; import { useFieldColumns } from "@/components/crm/fields/field-columns"; +import { useFieldFacets } from "@/components/crm/fields/field-facets"; import { OwnerCell } from "@/components/crm/owner-cell"; import { usePrefetchRecord } from "@/components/crm/record-sheet/record-prefetch"; import { useOpenRecord } from "@/components/crm/record-sheet/record-stack"; import { ListSearch } from "@/components/data-table/list-search"; +import { SavedViewsMenu } from "@/components/data-table/saved-views-menu"; import { useTableQuery } from "@/components/data-table/use-table-query"; import { LocalRelativeTime } from "@/components/local-date-time"; +import { ACTIVITY_FACET_OPTIONS } from "@/lib/activity-recency"; import { ENRICHMENT_FACET_OPTIONS, ENRICHMENT_POLL_MS, @@ -147,11 +152,30 @@ const COLUMNS: DataTableColumn[] = [ }, ]; +const ARCHIVED_COLUMN: DataTableColumn = { + id: "archivedAt", + header: "Archived", + label: "Archived date", + sortable: true, + align: "right", + width: "w-[12%]", + cell: (row) => ( + + {row.archivedAt ? ( + + ) : ( + + )} + + ), +}; + export function CompaniesTable() { const openRecord = useOpenRecord(); const trpc = useTRPC(); const prefetchRecord = usePrefetchRecord(); - const { query, input } = useTableQuery(companiesSearchParams); + const table = useTableQuery(companiesSearchParams); + const { query, input, setArchived } = table; const companies = useQuery({ ...trpc.companies.list.queryOptions(input), @@ -171,6 +195,7 @@ export function CompaniesTable() { ); const facetCounts = companies.data?.facetCounts; + const fieldFacets = useFieldFacets("COMPANY", facetCounts); const facets: DataTableFacet[] = [ { @@ -198,15 +223,43 @@ export function CompaniesTable() { (option) => (facetCounts?.enrichment?.[option.value] ?? 0) > 0, ), }, + { + id: "activity", + label: "Activity", + options: ACTIVITY_FACET_OPTIONS.filter( + (option) => (facetCounts?.activity?.[option.value] ?? 0) > 0, + ), + }, + ...fieldFacets, ]; const fieldColumns = useFieldColumns("COMPANY"); - const columns = useMemo(() => [...COLUMNS, ...fieldColumns], [fieldColumns]); + const columns = useMemo( + () => + input.archived + ? [...COLUMNS, ARCHIVED_COLUMN, ...fieldColumns] + : [...COLUMNS, ...fieldColumns], + [fieldColumns, input.archived], + ); return ( } + actions={ + <> + + + + } columns={columns} rows={rows} total={companies.data?.total ?? 0} @@ -215,7 +268,11 @@ export function CompaniesTable() { selection={{ state: selection, actions: ( - + ), rowLabel: (row) => row.name, }} @@ -223,7 +280,11 @@ export function CompaniesTable() { loading={companies.isFetching} onRowHover={(row) => prefetchRecord({ kind: "company", id: row.id })} onRowClick={(row) => openRecord({ kind: "company", id: row.id })} - empty="No companies match this view." + empty={ + input.archived + ? "No archived companies." + : "No companies match this view." + } /> ); } diff --git a/apps/app/app/(app)/[slug]/contacts/contacts-bulk-actions.tsx b/apps/app/app/(app)/[slug]/contacts/contacts-bulk-actions.tsx index 50139c4df..5fae7fb98 100644 --- a/apps/app/app/(app)/[slug]/contacts/contacts-bulk-actions.tsx +++ b/apps/app/app/(app)/[slug]/contacts/contacts-bulk-actions.tsx @@ -1,7 +1,8 @@ "use client"; +import Archive from "@carbon/icons-react/es/Archive"; import Renew from "@carbon/icons-react/es/Renew"; -import TrashCan from "@carbon/icons-react/es/TrashCan"; +import Undo from "@carbon/icons-react/es/Undo"; import { DropdownMenuGroup, DropdownMenuItem, @@ -31,15 +32,17 @@ function contacts(count: number): string { export function ContactsBulkActions({ ids, onDone, + archived, }: { ids: string[]; onDone: () => void; + archived: boolean; }) { const trpc = useTRPC(); const cache = useCrmCache(); const users = useQuery(trpc.users.list.queryOptions()); - const [confirming, setConfirming] = useState(false); const [menuOpen, setMenuOpen] = useState(false); + const [confirming, setConfirming] = useState(false); const companySearch = useRef(null); const onError = (error: { message: string }) => toast.error(error.message); @@ -80,11 +83,33 @@ export function ContactsBulkActions({ }), ); - const remove = useMutation( - trpc.contacts.bulkDelete.mutationOptions({ + const archive = useMutation( + trpc.contacts.bulkArchive.mutationOptions({ + onSuccess: async (result, variables) => { + await cache.removedMany({ kind: "contact", ids: variables.ids }); + reportBulk(result, (count) => `${contacts(count)} archived.`); + onDone(); + }, + onError, + }), + ); + + const restore = useMutation( + trpc.contacts.bulkRestore.mutationOptions({ + onSuccess: async (result) => { + await cache.contact(); + reportBulk(result, (count) => `${contacts(count)} restored.`); + onDone(); + }, + onError, + }), + ); + + const purge = useMutation( + trpc.contacts.bulkPurge.mutationOptions({ onSuccess: async (result, variables) => { await cache.removedMany({ kind: "contact", ids: variables.ids }); - reportBulk(result, (count) => `${contacts(count)} deleted.`); + reportBulk(result, (count) => `${contacts(count)} deleted forever.`); setConfirming(false); onDone(); }, @@ -92,69 +117,90 @@ export function ContactsBulkActions({ }), ); + if (archived) { + const pending = restore.isPending || purge.isPending; + + return ( + <> + + + restore.mutate({ ids })}> + + Restore + + + + + setConfirming(true)} + > + Delete forever + + + + + purge.mutate({ ids })} + /> + + ); + } + const pending = assignOwner.isPending || setCompany.isPending || enrich.isPending || - remove.isPending; + archive.isPending; return ( - <> - - assignOwner.mutate({ ids, ownerId })} - /> - - Move to company - { - if (event.target === event.currentTarget) { - companySearch.current?.focus(); - } - }} - > - { - setMenuOpen(false); - setCompany.mutate({ ids, companyId }); - }} - /> - - - - enrich.mutate({ ids })}> - - Re-enrich - - - - - setConfirming(true)} - > - - Delete - - - - - remove.mutate({ ids })} + + assignOwner.mutate({ ids, ownerId })} /> - + + Move to company + { + if (event.target === event.currentTarget) { + companySearch.current?.focus(); + } + }} + > + { + setMenuOpen(false); + setCompany.mutate({ ids, companyId }); + }} + /> + + + + enrich.mutate({ ids })}> + + Re-enrich + + + + + archive.mutate({ ids })}> + + Archive + + + ); } diff --git a/apps/app/app/(app)/[slug]/contacts/contacts-search-params.ts b/apps/app/app/(app)/[slug]/contacts/contacts-search-params.ts index 24da1e779..24e0cff39 100644 --- a/apps/app/app/(app)/[slug]/contacts/contacts-search-params.ts +++ b/apps/app/app/(app)/[slug]/contacts/contacts-search-params.ts @@ -3,5 +3,12 @@ import { createListSearchParams } from "@/components/data-table/list-search-para export const contactsSearchParams = createListSearchParams({ defaultSort: "createdAt", defaultDir: "desc", - facetIds: ["owner", "company"] as const, + facetIds: [ + "owner", + "company", + "title", + "seniority", + "persona", + "activity", + ] as const, }); diff --git a/apps/app/app/(app)/[slug]/contacts/contacts-table.tsx b/apps/app/app/(app)/[slug]/contacts/contacts-table.tsx index 8b7ccb90e..1847de03b 100644 --- a/apps/app/app/(app)/[slug]/contacts/contacts-table.tsx +++ b/apps/app/app/(app)/[slug]/contacts/contacts-table.tsx @@ -1,5 +1,7 @@ "use client"; +import Archive from "@carbon/icons-react/es/Archive"; +import { Button } from "@crm/ui/components/button"; import { DataTable, type DataTableColumn, @@ -10,16 +12,19 @@ import { PersonAvatar } from "@crm/ui/components/person-avatar"; import { useSearchInput } from "@crm/ui/hooks/use-search-input"; import { useTableSelection } from "@crm/ui/hooks/use-table-selection"; import { useQuery } from "@tanstack/react-query"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { CompanyCell } from "@/components/crm/company-cell"; import { contactName } from "@/components/crm/contact-name"; import { useFieldColumns } from "@/components/crm/fields/field-columns"; +import { useFieldFacets } from "@/components/crm/fields/field-facets"; import { OwnerCell } from "@/components/crm/owner-cell"; import { usePrefetchRecord } from "@/components/crm/record-sheet/record-prefetch"; import { useOpenRecord } from "@/components/crm/record-sheet/record-stack"; import { ListSearch } from "@/components/data-table/list-search"; +import { SavedViewsMenu } from "@/components/data-table/saved-views-menu"; import { useTableQuery } from "@/components/data-table/use-table-query"; import { LocalRelativeTime } from "@/components/local-date-time"; +import { ACTIVITY_FACET_OPTIONS } from "@/lib/activity-recency"; import { useTRPC } from "@/lib/trpc/client"; import type { RouterOutputs } from "@/lib/trpc/types"; import { ContactsBulkActions } from "./contacts-bulk-actions"; @@ -120,11 +125,30 @@ const COLUMNS: DataTableColumn[] = [ }, ]; +const ARCHIVED_COLUMN: DataTableColumn = { + id: "archivedAt", + header: "Archived", + label: "Archived date", + sortable: true, + align: "right", + width: "w-[12%]", + cell: (row) => ( + + {row.archivedAt ? ( + + ) : ( + + )} + + ), +}; + export function ContactsTable() { const openRecord = useOpenRecord(); const trpc = useTRPC(); const prefetchRecord = usePrefetchRecord(); - const { query, input } = useTableQuery(contactsSearchParams); + const table = useTableQuery(contactsSearchParams); + const { query, input, setArchived } = table; const contacts = useQuery({ ...trpc.contacts.list.queryOptions(input), @@ -146,8 +170,28 @@ export function ContactsTable() { const selection = useTableSelection( useMemo(() => rows.map((row) => row.id), [rows]), ); + const settledIds = useMemo(() => { + const matching = new Set( + rows + .filter((row) => Boolean(row.archivedAt) === input.archived) + .map((row) => row.id), + ); + return selection.ids.filter((id) => matching.has(id)); + }, [rows, input.archived, selection.ids]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: clearing on archived-mode change is the entire purpose of this effect. + useEffect(() => { + selection.clear(); + }, [input.archived]); + + const toggleArchived = (next: boolean) => { + selection.clear(); + if (!next && query.sort === "archivedAt") query.setSort(""); + setArchived(next); + }; const facetCounts = contacts.data?.facetCounts; + const fieldFacets = useFieldFacets("CONTACT", facetCounts); const facets: DataTableFacet[] = [ { @@ -179,15 +223,64 @@ export function ContactsTable() { })), ].filter((option) => (facetCounts?.company?.[option.value] ?? 0) > 0), }, + { + id: "title", + label: "Title", + options: Object.keys(facetCounts?.title ?? {}) + .sort() + .map((value) => ({ value, label: value })), + }, + { + id: "seniority", + label: "Seniority", + options: Object.keys(facetCounts?.seniority ?? {}) + .sort() + .map((value) => ({ value, label: value })), + }, + { + id: "persona", + label: "Persona", + options: Object.keys(facetCounts?.persona ?? {}) + .sort() + .map((value) => ({ value, label: value })), + }, + { + id: "activity", + label: "Activity", + options: ACTIVITY_FACET_OPTIONS.filter( + (option) => (facetCounts?.activity?.[option.value] ?? 0) > 0, + ), + }, + ...fieldFacets, ]; const fieldColumns = useFieldColumns("CONTACT"); - const columns = useMemo(() => [...COLUMNS, ...fieldColumns], [fieldColumns]); + const columns = useMemo( + () => + input.archived + ? [...COLUMNS, ARCHIVED_COLUMN, ...fieldColumns] + : [...COLUMNS, ...fieldColumns], + [fieldColumns, input.archived], + ); return ( } + actions={ + <> + + + + } columns={columns} rows={rows} total={contacts.data?.total ?? 0} @@ -196,7 +289,11 @@ export function ContactsTable() { selection={{ state: selection, actions: ( - + ), rowLabel: (row) => contactName(row), }} @@ -204,7 +301,11 @@ export function ContactsTable() { loading={contacts.isFetching} onRowHover={(row) => prefetchRecord({ kind: "contact", id: row.id })} onRowClick={(row) => openRecord({ kind: "contact", id: row.id })} - empty="No contacts match this view." + empty={ + input.archived + ? "No archived contacts." + : "No contacts match this view." + } /> ); } diff --git a/apps/app/app/(app)/[slug]/deals/deals-bulk-actions.tsx b/apps/app/app/(app)/[slug]/deals/deals-bulk-actions.tsx index 4ea27817a..be440e9c7 100644 --- a/apps/app/app/(app)/[slug]/deals/deals-bulk-actions.tsx +++ b/apps/app/app/(app)/[slug]/deals/deals-bulk-actions.tsx @@ -1,6 +1,7 @@ "use client"; -import TrashCan from "@carbon/icons-react/es/TrashCan"; +import Archive from "@carbon/icons-react/es/Archive"; +import Undo from "@carbon/icons-react/es/Undo"; import type { DealStage } from "@crm/db/enums"; import { Button } from "@crm/ui/components/button"; import { @@ -43,17 +44,19 @@ function deals(count: number): string { export function DealsBulkActions({ ids, onDone, + archived, }: { ids: string[]; onDone: () => void; + archived: boolean; }) { const trpc = useTRPC(); const cache = useCrmCache(); const users = useQuery(trpc.users.list.queryOptions()); const reasonId = useId(); - const [confirming, setConfirming] = useState(false); const [closing, setClosing] = useState(null); const [reason, setReason] = useState(""); + const [confirming, setConfirming] = useState(false); const onError = (error: { message: string }) => toast.error(error.message); @@ -81,11 +84,33 @@ export function DealsBulkActions({ }), ); - const remove = useMutation( - trpc.deals.bulkDelete.mutationOptions({ + const archive = useMutation( + trpc.deals.bulkArchive.mutationOptions({ onSuccess: async (result, variables) => { await cache.removedMany({ kind: "deal", ids: variables.ids }); - reportBulk(result, (count) => `${deals(count)} deleted.`); + reportBulk(result, (count) => `${deals(count)} archived.`); + onDone(); + }, + onError, + }), + ); + + const restore = useMutation( + trpc.deals.bulkRestore.mutationOptions({ + onSuccess: async (result) => { + await cache.deal(); + reportBulk(result, (count) => `${deals(count)} restored.`); + onDone(); + }, + onError, + }), + ); + + const purge = useMutation( + trpc.deals.bulkPurge.mutationOptions({ + onSuccess: async (result, variables) => { + await cache.removedMany({ kind: "deal", ids: variables.ids }); + reportBulk(result, (count) => `${deals(count)} deleted forever.`); setConfirming(false); onDone(); }, @@ -93,8 +118,42 @@ export function DealsBulkActions({ }), ); + if (archived) { + const archivedPending = restore.isPending || purge.isPending; + + return ( + <> + + + restore.mutate({ ids })}> + + Restore + + + + + setConfirming(true)} + > + Delete forever + + + + + purge.mutate({ ids })} + /> + + ); + } + const pending = - assignOwner.isPending || setStage.isPending || remove.isPending; + assignOwner.isPending || setStage.isPending || archive.isPending; return ( <> @@ -128,12 +187,9 @@ export function DealsBulkActions({ - setConfirming(true)} - > - - Delete + archive.mutate({ ids })}> + + Archive @@ -201,14 +257,6 @@ export function DealsBulkActions({ - - remove.mutate({ ids })} - /> ); } diff --git a/apps/app/app/(app)/[slug]/deals/deals-table.tsx b/apps/app/app/(app)/[slug]/deals/deals-table.tsx index dc9ac3f2e..0fb19ccb4 100644 --- a/apps/app/app/(app)/[slug]/deals/deals-table.tsx +++ b/apps/app/app/(app)/[slug]/deals/deals-table.tsx @@ -1,5 +1,7 @@ "use client"; +import Archive from "@carbon/icons-react/es/Archive"; +import { Button } from "@crm/ui/components/button"; import { DataTable, type DataTableColumn, @@ -9,10 +11,11 @@ import { EmptyCellValue } from "@crm/ui/components/empty-cell"; import { useTableSelection } from "@crm/ui/hooks/use-table-selection"; import { formatMoney } from "@crm/ui/lib/format"; import { useQuery } from "@tanstack/react-query"; -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import { CLOSING_OPTIONS } from "@/components/crm/closing-window"; import { CompanyCell } from "@/components/crm/company-cell"; import { useFieldColumns } from "@/components/crm/fields/field-columns"; +import { useFieldFacets } from "@/components/crm/fields/field-facets"; import { OwnerCell } from "@/components/crm/owner-cell"; import { usePrefetchRecord } from "@/components/crm/record-sheet/record-prefetch"; import { useOpenRecord } from "@/components/crm/record-sheet/record-stack"; @@ -123,11 +126,29 @@ const COLUMNS: DataTableColumn[] = [ }, ]; +const ARCHIVED_COLUMN: DataTableColumn = { + id: "archivedAt", + header: "Archived", + label: "Archived date", + sortable: true, + align: "right", + width: "w-[12%]", + cell: (row) => ( + + {row.archivedAt ? ( + + ) : ( + + )} + + ), +}; + export function DealsTable() { const openRecord = useOpenRecord(); const trpc = useTRPC(); const prefetchRecord = usePrefetchRecord(); - const { query, input } = useTableQuery(dealsSearchParams); + const { query, input, setArchived } = useTableQuery(dealsSearchParams); const deals = useQuery({ ...trpc.deals.list.queryOptions(input), @@ -139,8 +160,28 @@ export function DealsTable() { const selection = useTableSelection( useMemo(() => rows.map((row) => row.id), [rows]), ); + const settledIds = useMemo(() => { + const matching = new Set( + rows + .filter((row) => Boolean(row.archivedAt) === input.archived) + .map((row) => row.id), + ); + return selection.ids.filter((id) => matching.has(id)); + }, [rows, input.archived, selection.ids]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: clearing on archived-mode change is the entire purpose of this effect. + useEffect(() => { + selection.clear(); + }, [input.archived]); + + const toggleArchived = (next: boolean) => { + selection.clear(); + if (!next && query.sort === "archivedAt") query.setSort(""); + setArchived(next); + }; const facetCounts = deals.data?.facetCounts; + const fieldFacets = useFieldFacets("DEAL", facetCounts); const facets: DataTableFacet[] = [ { @@ -168,6 +209,7 @@ export function DealsTable() { : [], ), }, + ...fieldFacets, ]; const openValueCents = deals.data?.openValueCents; @@ -177,12 +219,29 @@ export function DealsTable() { const openPipelineCents = openValueCents ?? (uncounted > 0 ? 0 : null); const fieldColumns = useFieldColumns("DEAL"); - const columns = useMemo(() => [...COLUMNS, ...fieldColumns], [fieldColumns]); + const columns = useMemo( + () => + input.archived + ? [...COLUMNS, ARCHIVED_COLUMN, ...fieldColumns] + : [...COLUMNS, ...fieldColumns], + [fieldColumns, input.archived], + ); return ( } + actions={ + + } columns={columns} rows={rows} total={deals.data?.total ?? 0} @@ -199,7 +258,11 @@ export function DealsTable() { selection={{ state: selection, actions: ( - + ), rowLabel: (row) => row.name, }} @@ -207,9 +270,11 @@ export function DealsTable() { loading={deals.isFetching} onRowHover={(row) => prefetchRecord({ kind: "deal", id: row.id })} onRowClick={(row) => openRecord({ kind: "deal", id: row.id })} - empty="No deals match this view." + empty={ + input.archived ? "No archived deals." : "No deals match this view." + } meta={ - openPipelineCents === null ? undefined : ( + input.archived || openPipelineCents === null ? undefined : ( {deals.data?.total ?? 0} deals ·{" "} diff --git a/apps/app/app/(app)/[slug]/settings/api-keys/api-keys-search-params.ts b/apps/app/app/(app)/[slug]/settings/api-keys/api-keys-search-params.ts new file mode 100644 index 000000000..ba14fcd7c --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/api-keys/api-keys-search-params.ts @@ -0,0 +1,6 @@ +import { createListSearchParams } from "@/components/data-table/list-search-params"; + +export const apiKeysSearchParams = createListSearchParams({ + defaultSort: "createdAt", + defaultDir: "desc", +}); diff --git a/apps/app/app/(app)/[slug]/settings/api-keys/api-keys-table.tsx b/apps/app/app/(app)/[slug]/settings/api-keys/api-keys-table.tsx new file mode 100644 index 000000000..a6737ee7b --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/api-keys/api-keys-table.tsx @@ -0,0 +1,194 @@ +"use client"; + +import TrashCan from "@carbon/icons-react/es/TrashCan"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@crm/ui/components/alert-dialog"; +import { Badge } from "@crm/ui/components/badge"; +import { Button } from "@crm/ui/components/button"; +import { DataTable, type DataTableColumn } from "@crm/ui/components/data-table"; +import { Icon } from "@crm/ui/components/icon"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { ListSearch } from "@/components/data-table/list-search"; +import { useTableQuery } from "@/components/data-table/use-table-query"; +import { LocalRelativeTime } from "@/components/local-date-time"; +import { useCrmCache } from "@/lib/trpc/cache"; +import { useTRPC } from "@/lib/trpc/client"; +import type { RouterOutputs } from "@/lib/trpc/types"; +import { apiKeysSearchParams } from "./api-keys-search-params"; + +type ApiKeyRow = RouterOutputs["apiKeys"]["list"]["rows"][number]; + +function isExpired(expiresAt: string | null): boolean { + return expiresAt !== null && new Date(expiresAt).getTime() < Date.now(); +} + +function columns( + onRevoke: (apiKey: ApiKeyRow) => void, + pending: boolean, +): DataTableColumn[] { + return [ + { + id: "name", + header: "Name", + sortable: true, + hideable: false, + width: "w-[28%]", + cell: (row) => ( + + {row.name ?? "Untitled key"} + + ), + }, + { + id: "start", + header: "Key", + width: "w-[20%]", + hideBelow: "sm", + cell: (row) => ( + {row.start ? `${row.start}…` : "—"} + ), + }, + { + id: "createdAt", + header: "Created", + sortable: true, + width: "w-[16%]", + hideBelow: "md", + cell: (row) => ( + + + + ), + }, + { + id: "lastRequest", + header: "Last used", + label: "Last used date", + sortable: true, + width: "w-[16%]", + hideBelow: "lg", + cell: (row) => ( + + {row.lastRequest ? ( + + ) : ( + "Never" + )} + + ), + }, + { + id: "expiresAt", + header: "Expires", + sortable: true, + width: "w-[14%]", + hideBelow: "lg", + cell: (row) => + row.expiresAt ? ( + + + + ) : ( + Never + ), + }, + { + id: "actions", + header: Actions, + label: "Actions", + hideable: false, + align: "right", + width: "w-[6%]", + cell: (row) => ( + + + + + + + + + Revoke {row.name ?? "this API key"}? + + + Anything using it stops working immediately. This cannot be + undone. + + + + + Cancel + onRevoke(row)} + > + Revoke + + + + + ), + }, + ]; +} + +export function ApiKeysTable() { + const trpc = useTRPC(); + const cache = useCrmCache(); + const { query, input } = useTableQuery(apiKeysSearchParams); + + const apiKeys = useQuery({ + ...trpc.apiKeys.list.queryOptions(input), + placeholderData: (previous) => previous, + }); + + const revoke = useMutation( + trpc.apiKeys.revoke.mutationOptions({ + onSuccess: async () => { + if (apiKeys.data?.rows.length === 1 && query.page > 1) { + await query.setPage(query.page - 1); + } + await cache.apiKeys(); + toast.success("API key revoked."); + }, + onError: (error) => toast.error(error.message), + }), + ); + + return ( + } + columns={columns( + (apiKey) => revoke.mutate({ id: apiKey.id }), + revoke.isPending, + )} + rows={apiKeys.data?.rows ?? []} + total={apiKeys.data?.total ?? 0} + getRowId={(row) => row.id} + loading={apiKeys.isFetching} + empty="No API keys yet." + /> + ); +} diff --git a/apps/app/app/(app)/[slug]/settings/api-keys/create-api-key-sheet.tsx b/apps/app/app/(app)/[slug]/settings/api-keys/create-api-key-sheet.tsx new file mode 100644 index 000000000..d35e56771 --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/api-keys/create-api-key-sheet.tsx @@ -0,0 +1,193 @@ +"use client"; + +import Add from "@carbon/icons-react/es/Add"; +import { Button } from "@crm/ui/components/button"; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from "@crm/ui/components/field"; +import { Icon } from "@crm/ui/components/icon"; +import { Input } from "@crm/ui/components/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@crm/ui/components/select"; +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@crm/ui/components/sheet"; +import { Spinner } from "@crm/ui/components/spinner"; +import { useMutation } from "@tanstack/react-query"; +import { parseAsBoolean, useQueryState } from "nuqs"; +import { type ComponentProps, Suspense, useId, useState } from "react"; +import { toast } from "sonner"; +import { useCrmCache } from "@/lib/trpc/cache"; +import { useTRPC } from "@/lib/trpc/client"; +import type { RouterOutputs } from "@/lib/trpc/types"; +import { CreatedApiKeyDialog } from "./created-api-key-dialog"; + +const FORM = "create-api-key"; + +const EXPIRATION_OPTIONS = [ + { value: "30", label: "30 days" }, + { value: "90", label: "90 days" }, + { value: "365", label: "1 year" }, + { value: "never", label: "No expiration" }, +] as const; + +type ExpirationValue = (typeof EXPIRATION_OPTIONS)[number]["value"]; + +type CreatedApiKey = RouterOutputs["apiKeys"]["create"]; + +function NewApiKeyButton(props: ComponentProps) { + return ( + + ); +} + +export function CreateApiKeySheet() { + return ( + }> + + + ); +} + +function CreateApiKeyForm() { + const trpc = useTRPC(); + const cache = useCrmCache(); + + const nameId = useId(); + const expirationId = useId(); + + const [open, setOpen] = useQueryState( + "new", + parseAsBoolean.withDefault(false), + ); + const [name, setName] = useState(""); + const [expiration, setExpiration] = useState("90"); + const [created, setCreated] = useState(null); + + const create = useMutation( + trpc.apiKeys.create.mutationOptions({ + onSuccess: async (apiKey) => { + await cache.apiKeys(); + await setOpen(null); + setName(""); + setExpiration("90"); + setCreated(apiKey); + }, + onError: (error) => toast.error(error.message), + }), + ); + + return ( + <> + setOpen(next || null)}> + + + + + + + New API key + + Acts as you. Anything it can read or change is exactly what you + can. + + + +
{ + event.preventDefault(); + create.mutate({ + name: name.trim(), + expiresInDays: + expiration === "never" ? null : Number(expiration), + }); + }} + > + + + Name + setName(event.target.value)} + placeholder="CI pipeline" + maxLength={64} + autoComplete="off" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + required + /> + + Something you will recognise later, like where it runs. + + + + + Expires + + + +
+ + + + + + + +
+
+ + { + if (!next) setCreated(null); + }} + /> + + ); +} diff --git a/apps/app/app/(app)/[slug]/settings/api-keys/created-api-key-dialog.tsx b/apps/app/app/(app)/[slug]/settings/api-keys/created-api-key-dialog.tsx new file mode 100644 index 000000000..1baa59c40 --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/api-keys/created-api-key-dialog.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { Button } from "@crm/ui/components/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@crm/ui/components/dialog"; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from "@crm/ui/components/input-group"; +import type { RouterOutputs } from "@/lib/trpc/types"; +import { CopyValue } from "../copy-value"; + +type CreatedApiKey = RouterOutputs["apiKeys"]["create"]; + +export function CreatedApiKeyDialog({ + apiKey, + onOpenChange, +}: { + apiKey: CreatedApiKey | null; + onOpenChange: (open: boolean) => void; +}) { + return ( + + + + {apiKey?.name ?? "API key"} created + + Copy it now. Nobody, including us, can show it to you again. + + + + + + + + + + + + + + + + ); +} diff --git a/apps/app/app/(app)/[slug]/settings/api-keys/page.tsx b/apps/app/app/(app)/[slug]/settings/api-keys/page.tsx new file mode 100644 index 000000000..9503fda86 --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/api-keys/page.tsx @@ -0,0 +1,71 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; +import { + PageShell, + PageShellActions, + PageShellContent, + PageShellDescription, + PageShellHeader, + PageShellHeading, + PageShellLoading, + PageShellTitle, +} from "@/components/page-shell"; +import { requireSession } from "@/lib/session"; +import { HydrateClient } from "@/lib/trpc/hydrate"; +import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; +import { apiKeysSearchParams } from "./api-keys-search-params"; +import { ApiKeysTable } from "./api-keys-table"; +import { CreateApiKeySheet } from "./create-api-key-sheet"; + +export const metadata: Metadata = { + title: "API Keys", +}; + +export default function ApiKeysSettingsPage({ + searchParams, +}: PageProps<"/[slug]/settings/api-keys">) { + return ( + + + + API Keys + + Personal keys for calling the CRM API. Each one acts as you — + anything it can read or change is exactly what you can. + + + + + + + + + + }> + + + + + ); +} + +async function ApiKeys({ + searchParams, +}: Pick, "searchParams">) { + await requireSession(); + + const values = await apiKeysSearchParams.load(searchParams); + + const trpc = getServerTrpc(); + const queryClient = getServerQueryClient(); + + await queryClient.prefetchQuery( + trpc.apiKeys.list.queryOptions(apiKeysSearchParams.toInput(values)), + ); + + return ( + + + + ); +} diff --git a/apps/app/app/(app)/[slug]/settings/archive-retention.tsx b/apps/app/app/(app)/[slug]/settings/archive-retention.tsx new file mode 100644 index 000000000..d43eef321 --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/archive-retention.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { Button } from "@crm/ui/components/button"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@crm/ui/components/card"; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from "@crm/ui/components/field"; +import { Input } from "@crm/ui/components/input"; +import { Spinner } from "@crm/ui/components/spinner"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useEffect, useId, useState } from "react"; +import { toast } from "sonner"; +import { useCrmCache } from "@/lib/trpc/cache"; +import { useTRPC } from "@/lib/trpc/client"; + +export function ArchiveRetention() { + const trpc = useTRPC(); + const cache = useCrmCache(); + const daysId = useId(); + const [draft, setDraft] = useState(""); + + const retention = useQuery(trpc.settings.archiveRetention.queryOptions()); + + useEffect(() => { + if (retention.data) setDraft(String(retention.data.days)); + }, [retention.data]); + + const save = useMutation( + trpc.settings.setArchiveRetention.mutationOptions({ + onSuccess: async () => { + await cache.settings(); + toast.success("Archive retention saved."); + }, + onError: (error) => toast.error(error.message), + }), + ); + + if (!retention.data) return null; + + const days = Number.parseInt(draft, 10); + const unchanged = days === retention.data.days; + + return ( + + + Archived records + + Deleted records are archived and hidden, then pruned for good. + + + + + + + + +
{ + event.preventDefault(); + if (!Number.isFinite(days)) { + toast.error("Enter a number of days."); + return; + } + save.mutate({ days }); + }} + > + + + + Prune archived records after + + setDraft(event.target.value)} + /> + Days. 180 is the default. + + +
+
+
+ ); +} diff --git a/apps/app/app/(app)/[slug]/settings/sso/copy-value.tsx b/apps/app/app/(app)/[slug]/settings/copy-value.tsx similarity index 100% rename from apps/app/app/(app)/[slug]/settings/sso/copy-value.tsx rename to apps/app/app/(app)/[slug]/settings/copy-value.tsx diff --git a/apps/app/app/(app)/[slug]/settings/page.tsx b/apps/app/app/(app)/[slug]/settings/page.tsx index 4a6a3d66d..284c9f0e8 100644 --- a/apps/app/app/(app)/[slug]/settings/page.tsx +++ b/apps/app/app/(app)/[slug]/settings/page.tsx @@ -13,6 +13,7 @@ import { requireSession } from "@/lib/session"; import { HydrateClient } from "@/lib/trpc/hydrate"; import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; import { AgentModel } from "./agent-model"; +import { ArchiveRetention } from "./archive-retention"; import { ResearchKey } from "./research-key"; import { WorkspaceForm } from "./workspace-form"; @@ -52,6 +53,7 @@ async function Settings() { queryClient.prefetchQuery(trpc.settings.agentModel.queryOptions()), queryClient.prefetchQuery(trpc.settings.modelCatalog.queryOptions()), queryClient.prefetchQuery(trpc.settings.researchKey.queryOptions()), + queryClient.prefetchQuery(trpc.settings.archiveRetention.queryOptions()), ]); return ( @@ -59,6 +61,7 @@ async function Settings() {
+
diff --git a/apps/app/app/(app)/[slug]/settings/settings-sidebar.tsx b/apps/app/app/(app)/[slug]/settings/settings-sidebar.tsx index 34a647f4f..cdce91b4f 100644 --- a/apps/app/app/(app)/[slug]/settings/settings-sidebar.tsx +++ b/apps/app/app/(app)/[slug]/settings/settings-sidebar.tsx @@ -20,6 +20,7 @@ const ITEMS: SettingsNavItem[] = [ { title: "Connections", href: `${ROOT}/connections` }, { title: "Currencies", href: `${ROOT}/currencies` }, { title: "Members", href: `${ROOT}/members` }, + { title: "API Keys", href: `${ROOT}/api-keys` }, { title: "SSO", href: `${ROOT}/sso` }, ]; diff --git a/apps/app/app/(app)/[slug]/settings/sso/add-sso-provider-sheet.tsx b/apps/app/app/(app)/[slug]/settings/sso/add-sso-provider-sheet.tsx index ef5ca645d..bbe04110f 100644 --- a/apps/app/app/(app)/[slug]/settings/sso/add-sso-provider-sheet.tsx +++ b/apps/app/app/(app)/[slug]/settings/sso/add-sso-provider-sheet.tsx @@ -32,7 +32,7 @@ import { type ComponentProps, Suspense, useId, useState } from "react"; import { toast } from "sonner"; import { useCrmCache } from "@/lib/trpc/cache"; import { useTRPC } from "@/lib/trpc/client"; -import { CopyValue } from "./copy-value"; +import { CopyValue } from "../copy-value"; const FORM = "add-sso-provider"; diff --git a/apps/app/app/(app)/[slug]/settings/sso/sso-table.tsx b/apps/app/app/(app)/[slug]/settings/sso/sso-table.tsx index 928a13532..010a7f5be 100644 --- a/apps/app/app/(app)/[slug]/settings/sso/sso-table.tsx +++ b/apps/app/app/(app)/[slug]/settings/sso/sso-table.tsx @@ -22,7 +22,7 @@ import { useTableQuery } from "@/components/data-table/use-table-query"; import { useCrmCache } from "@/lib/trpc/cache"; import { useTRPC } from "@/lib/trpc/client"; import type { RouterOutputs } from "@/lib/trpc/types"; -import { CopyValue } from "./copy-value"; +import { CopyValue } from "../copy-value"; import { ssoSearchParams } from "./sso-search-params"; type ProviderRow = RouterOutputs["sso"]["list"]["rows"][number]; diff --git a/apps/app/components/agent-builder/team-agent-detail.tsx b/apps/app/components/agent-builder/team-agent-detail.tsx index 4bfb67771..fa4c0e3e1 100644 --- a/apps/app/components/agent-builder/team-agent-detail.tsx +++ b/apps/app/components/agent-builder/team-agent-detail.tsx @@ -349,8 +349,7 @@ function DraftAgentActions({ const trpc = useTRPC(); const queryClient = useQueryClient(); const workspaceUrl = useWorkspaceUrl(); - const deployable = - version?.status === "READY" || version?.status === "DEPLOYED"; + const deployable = version?.status === "READY"; const deploy = useMutation( trpc.agents.deploy.mutationOptions({ onSuccess: async () => { diff --git a/apps/app/components/app-icon-rail.tsx b/apps/app/components/app-icon-rail.tsx index bb5b90639..ad3440e00 100644 --- a/apps/app/components/app-icon-rail.tsx +++ b/apps/app/components/app-icon-rail.tsx @@ -1,15 +1,15 @@ "use client"; import Building from "@carbon/icons-react/es/Building"; -import type { CarbonIconType } from "@carbon/icons-react/es/CarbonIcon"; -import Chat from "@carbon/icons-react/es/Chat"; import Close from "@carbon/icons-react/es/Close"; import Dashboard from "@carbon/icons-react/es/Dashboard"; import Partnership from "@carbon/icons-react/es/Partnership"; import Settings from "@carbon/icons-react/es/Settings"; import UserMultiple from "@carbon/icons-react/es/UserMultiple"; import { Button } from "@crm/ui/components/button"; +import type { CarbonIcon } from "@crm/ui/components/icon"; import { Icon } from "@crm/ui/components/icon"; +import Bot from "@crm/ui/components/icons/bot"; import { Sheet, SheetContent, @@ -33,7 +33,8 @@ import { useWorkspaceUrl } from "@/lib/use-workspace-url"; type RailItem = { title: string; href: string; - icon: CarbonIconType; + icon: CarbonIcon; + iconClassName?: string; match: "exact" | "prefix"; related?: string[]; }; @@ -43,7 +44,8 @@ const ITEMS: RailItem[] = [ { title: "Chat", href: "/chat", - icon: Chat, + icon: Bot, + iconClassName: "size-5", match: "prefix", related: ["/agents"], }, @@ -96,7 +98,7 @@ function RailLink({ aria-current={active ? "page" : undefined} transitionTypes={["nav-lateral"]} > - + {item.title} @@ -138,7 +140,7 @@ function MobileRailLink({ item.title === "Chat" ? "nav-forward" : "nav-lateral", ]} > - + {item.title} @@ -175,7 +177,7 @@ function MobileRailIconLink({ aria-current={active ? "page" : undefined} onClick={onNavigate} > - + {item.title} @@ -197,7 +199,7 @@ export function AppIconRailFallback() { disabled className="text-muted-foreground" > - + {item.title} ))} diff --git a/apps/app/components/crm/company-picker.tsx b/apps/app/components/crm/company-picker.tsx index 31c870bbd..8847a9a11 100644 --- a/apps/app/components/crm/company-picker.tsx +++ b/apps/app/components/crm/company-picker.tsx @@ -143,9 +143,9 @@ export function CompanyMenuSearch({ disabled={stale} onSelect={() => onSelect(company.id)} > - {company.name} + {company.name} {company.domain ? ( - + {company.domain} ) : null} diff --git a/apps/app/components/crm/fields/field-editor.tsx b/apps/app/components/crm/fields/field-editor.tsx index e5afe92d8..ab0e5ef9c 100644 --- a/apps/app/components/crm/fields/field-editor.tsx +++ b/apps/app/components/crm/fields/field-editor.tsx @@ -55,6 +55,7 @@ import { BRIEF_LABEL, CANCEL, FILL_REST, + filterPlacement, KEY_HELP, KEY_LABEL, LABEL_LABEL, @@ -83,6 +84,7 @@ type Draft = { agentBrief: string; showOnSheet: boolean; showOnTable: boolean; + showOnFilter: boolean; }; const TYPE_HINTS = { @@ -117,6 +119,7 @@ function draftFrom(field: FieldRecord | undefined): Draft { agentBrief: field?.agentBrief ?? "", showOnSheet: field?.showOnSheet ?? true, showOnTable: field?.showOnTable ?? false, + showOnFilter: field?.showOnFilter ?? false, }; } @@ -220,6 +223,7 @@ export function FieldEditor({ const key = field?.key ?? fieldKeyFromLabel(draft.label); const saving = create.isPending || update.isPending; + const filterable = draft.type === "SELECT" || draft.type === "USER"; const save = () => { const payload = { @@ -230,6 +234,7 @@ export function FieldEditor({ agentBrief: draft.agentBrief.trim() || null, showOnSheet: draft.showOnSheet, showOnTable: draft.showOnTable, + showOnFilter: filterable && draft.showOnFilter, }; if (field) { @@ -402,6 +407,17 @@ export function FieldEditor({ /> {tablePlacement(entity)} + {filterable ? ( + + + patch({ showOnFilter: checked === true }) + } + /> + {filterPlacement(entity)} + + ) : null} diff --git a/apps/app/components/crm/fields/field-facets.tsx b/apps/app/components/crm/fields/field-facets.tsx new file mode 100644 index 000000000..76e4b081a --- /dev/null +++ b/apps/app/components/crm/fields/field-facets.tsx @@ -0,0 +1,44 @@ +"use client"; + +import type { DataTableFacet } from "@crm/ui/components/data-table"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { useTRPC } from "@/lib/trpc/client"; +import type { FieldEntity } from "./fields-entity"; + +/** + * SELECT and USER admin fields become filter facets automatically — no + * per-field opt-in, mirroring `fields.filterableFieldsFor` on the API. + */ +export function useFieldFacets( + entity: FieldEntity, + facetCounts: Record> | undefined, +): DataTableFacet[] { + const trpc = useTRPC(); + const fields = useQuery(trpc.fields.filters.queryOptions({ entity })); + const users = useQuery(trpc.users.list.queryOptions()); + + return useMemo(() => { + if (!fields.data) return []; + + return fields.data.map((field) => { + const counts = facetCounts?.[`field:${field.key}`]; + const options = + field.type === "USER" + ? (users.data ?? []).map((user) => ({ + value: user.id, + label: user.name, + })) + : field.options.map((option) => ({ + value: option.id, + label: option.label, + })); + + return { + id: `field:${field.key}`, + label: field.label, + options: options.filter((option) => (counts?.[option.value] ?? 0) > 0), + }; + }); + }, [fields.data, users.data, facetCounts]); +} diff --git a/apps/app/components/crm/fields/fields-copy.ts b/apps/app/components/crm/fields/fields-copy.ts index 9c2648f62..7f209b58b 100644 --- a/apps/app/components/crm/fields/fields-copy.ts +++ b/apps/app/components/crm/fields/fields-copy.ts @@ -15,6 +15,9 @@ export function subtitleFor(kind: RecordKind): string { export const STANDARD_ROW = "Standard fields"; export const STANDARD_NOTE = "reorder and hide only"; +export const SUGGESTED_ROW = "Suggested fields"; +export const SUGGESTED_NOTE = "one click to add"; +export const ADD = "Add"; export const CUSTOM_GROUP = "Custom fields"; export const DRAG_NOTE = "Drag to order"; export const ARCHIVED_ROW = "Archived"; @@ -23,6 +26,7 @@ export const NEW_FIELD = "New field"; export const ORDER_NOTE = "Order here is the order on the sheet"; export const MANUAL_ONLY = "Manual only"; export const TABLE_NOTE = "also a column on the table"; +export const FILTER_NOTE = "also a filter"; export const EMPTY_TITLE = "No custom fields yet"; export const EMPTY_BODY = @@ -69,6 +73,12 @@ const TABLE_PLACEMENT = { DEAL: "Offer as a column on the Deals table", } satisfies Record; +const FILTER_PLACEMENT = { + COMPANY: "Offer as a filter on the Companies table", + CONTACT: "Offer as a filter on the Contacts table", + DEAL: "Offer as a filter on the Deals table", +} satisfies Record; + export function sheetPlacement(entity: FieldEntity): string { return SHEET_PLACEMENT[entity]; } @@ -77,6 +87,10 @@ export function tablePlacement(entity: FieldEntity): string { return TABLE_PLACEMENT[entity]; } +export function filterPlacement(entity: FieldEntity): string { + return FILTER_PLACEMENT[entity]; +} + export const ENTITY_TABS = [ { kind: "company", label: "Companies" }, { kind: "contact", label: "Contacts" }, diff --git a/apps/app/components/crm/fields/fields-list.tsx b/apps/app/components/crm/fields/fields-list.tsx index b95b18c04..83521701c 100644 --- a/apps/app/components/crm/fields/fields-list.tsx +++ b/apps/app/components/crm/fields/fields-list.tsx @@ -5,6 +5,7 @@ import ChevronRight from "@carbon/icons-react/es/ChevronRight"; import OverflowMenuVertical from "@carbon/icons-react/es/OverflowMenuVertical"; import Renew from "@carbon/icons-react/es/Renew"; import Warning from "@carbon/icons-react/es/Warning"; +import { fieldKeyFromLabel, typeLabel } from "@crm/db/fields-shape"; import { Badge } from "@crm/ui/components/badge"; import { Button } from "@crm/ui/components/button"; import { @@ -30,12 +31,14 @@ import { import { Icon } from "@crm/ui/components/icon"; import { SortableItem, SortableList } from "@crm/ui/components/sortable-list"; import { Spinner } from "@crm/ui/components/spinner"; +import { FIELD_TEMPLATES } from "@crm/validation/field-templates"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { useCrmCache } from "@/lib/trpc/cache"; import { useTRPC } from "@/lib/trpc/client"; import type { RouterOutputs } from "@/lib/trpc/types"; import { + ADD, ARCHIVED_NOTE, ARCHIVED_ROW, CUSTOM_GROUP, @@ -44,12 +47,15 @@ import { EMPTY_TITLE, ERROR_BODY, ERROR_TITLE, + FILTER_NOTE, MANUAL_ONLY, NEW_FIELD, ORDER_NOTE, RETRY, STANDARD_NOTE, STANDARD_ROW, + SUGGESTED_NOTE, + SUGGESTED_ROW, TABLE_NOTE, } from "./fields-copy"; import { type FieldEntity, kindOf } from "./fields-entity"; @@ -75,6 +81,7 @@ function summaryOf(field: Field): string { } if (field.showOnTable) parts.push(TABLE_NOTE); + if (field.showOnFilter) parts.push(FILTER_NOTE); return parts.join(" · "); } @@ -177,11 +184,23 @@ export function FieldsList({ }), ); + const addSuggested = useMutation( + trpc.fields.create.mutationOptions({ + onSuccess: () => cache.fields(kindOf(entity)), + onError: (error) => toast.error(error.message), + }), + ); + const all = query.data ?? []; const live = all.filter((field) => !field.archived); const archived = all.filter((field) => field.archived); const standard = STANDARD_FIELDS[entity]; + const existingKeys = new Set(all.map((field) => field.key)); + const suggestions = FIELD_TEMPLATES[entity].filter( + (template) => !existingKeys.has(fieldKeyFromLabel(template.label)), + ); + return ( <>
@@ -201,6 +220,54 @@ export function FieldsList({ + {suggestions.length > 0 && ( + +
    + {suggestions.map((template) => ( +
  • + + {template.label} + {template.options && template.options.length > 0 + ? ` · ${template.options.join(", ")}` + : ` · ${typeLabel(template.type)}`} + + +
  • + ))} +
+
+ )} + {query.isPending ? (
diff --git a/apps/app/components/crm/record-sheet/company-sheet.tsx b/apps/app/components/crm/record-sheet/company-sheet.tsx index bddb23686..90d89214a 100644 --- a/apps/app/components/crm/record-sheet/company-sheet.tsx +++ b/apps/app/components/crm/record-sheet/company-sheet.tsx @@ -259,6 +259,7 @@ export function CompanySheet({ companyId }: { companyId: string }) { record={{ kind: "company", id: company.id }} name={company.name} consequence={companyConsequence(company)} + archivedAt={company.archivedAt} /> ) : null diff --git a/apps/app/components/crm/record-sheet/contact-sheet.tsx b/apps/app/components/crm/record-sheet/contact-sheet.tsx index bd665b661..1eb78e1aa 100644 --- a/apps/app/components/crm/record-sheet/contact-sheet.tsx +++ b/apps/app/components/crm/record-sheet/contact-sheet.tsx @@ -208,6 +208,7 @@ export function ContactSheet({ contactId }: { contactId: string }) { record={{ kind: "contact", id: contact.id }} name={contactName(contact)} consequence={`Their notes, agent conversations and everything the agent found go too; emails and meetings stay filed against the company.${contact.email ? ` The sync will not bring ${contact.email} back — only adding them yourself will.` : ""}`} + archivedAt={contact.archivedAt} /> ) : null diff --git a/apps/app/components/crm/record-sheet/deal-sheet.tsx b/apps/app/components/crm/record-sheet/deal-sheet.tsx index e8228e75b..681a15ec6 100644 --- a/apps/app/components/crm/record-sheet/deal-sheet.tsx +++ b/apps/app/components/crm/record-sheet/deal-sheet.tsx @@ -206,6 +206,7 @@ export function DealSheet({ dealId }: { dealId: string }) { record={{ kind: "deal", id: deal.id }} name={deal.name} consequence={`Its stage history, notes and agent conversations go too. ${deal.company.name} and the ${deal.contacts.length === 1 ? "person" : "people"} on it stay in the CRM.`} + archivedAt={deal.archivedAt} /> ) : null diff --git a/apps/app/components/crm/record-sheet/record-actions.tsx b/apps/app/components/crm/record-sheet/record-actions.tsx index d826c36f2..a82a43cf7 100644 --- a/apps/app/components/crm/record-sheet/record-actions.tsx +++ b/apps/app/components/crm/record-sheet/record-actions.tsx @@ -1,7 +1,9 @@ "use client"; +import Archive from "@carbon/icons-react/es/Archive"; import OverflowMenuVertical from "@carbon/icons-react/es/OverflowMenuVertical"; import TrashCan from "@carbon/icons-react/es/TrashCan"; +import Undo from "@carbon/icons-react/es/Undo"; import { AlertDialog, AlertDialogAction, @@ -37,15 +39,59 @@ const NOUN = { deal: "deal", } satisfies Record; -function useDeleteRecord(record: RecordRef) { +const RECORD_PROCEDURES = { + company: "companies", + contact: "contacts", + deal: "deals", +} satisfies Record; + +function useArchiveRecord(record: RecordRef) { + const trpc = useTRPC(); + const cache = useCrmCache(); + + const handlers = { + onSuccess: (archived: { name: string }) => { + toast.success( + `${archived.name || `The ${NOUN[record.kind]}`} was archived.`, + ); + void cache[record.kind](record.id); + }, + onError: (error: { message: string }) => toast.error(error.message), + }; + + return useMutation( + trpc[RECORD_PROCEDURES[record.kind]].archive.mutationOptions(handlers), + ); +} + +function useRestoreRecord(record: RecordRef) { + const trpc = useTRPC(); + const cache = useCrmCache(); + + const handlers = { + onSuccess: (restored: { name: string }) => { + toast.success( + `${restored.name || `The ${NOUN[record.kind]}`} was restored.`, + ); + void cache[record.kind](record.id); + }, + onError: (error: { message: string }) => toast.error(error.message), + }; + + return useMutation( + trpc[RECORD_PROCEDURES[record.kind]].restore.mutationOptions(handlers), + ); +} + +function usePurgeRecord(record: RecordRef) { const trpc = useTRPC(); const cache = useCrmCache(); const { close } = useRecordStack(); const handlers = { - onSuccess: (deleted: { name: string }) => { + onSuccess: (purged: { name: string }) => { toast.success( - `${deleted.name || `The ${NOUN[record.kind]}`} was deleted.`, + `${purged.name || `The ${NOUN[record.kind]}`} was deleted forever.`, ); void cache.removed(record); close(); @@ -53,52 +99,70 @@ function useDeleteRecord(record: RecordRef) { onError: (error: { message: string }) => toast.error(error.message), }; - const options = - record.kind === "contact" - ? trpc.contacts.delete.mutationOptions(handlers) - : record.kind === "company" - ? trpc.companies.delete.mutationOptions(handlers) - : trpc.deals.delete.mutationOptions(handlers); - - return useMutation(options); + return useMutation( + trpc[RECORD_PROCEDURES[record.kind]].purge.mutationOptions(handlers), + ); } export function RecordActions({ record, name, consequence, + archivedAt, }: { record: RecordRef; name: string; consequence: string; + archivedAt: string | null; }) { const [confirming, setConfirming] = useState(false); - const remove = useDeleteRecord(record); + const archive = useArchiveRecord(record); + const restore = useRestoreRecord(record); + const purge = usePurgeRecord(record); + + const pending = archive.isPending || restore.isPending || purge.isPending; return ( <> - - setConfirming(true)} - > - - Delete {NOUN[record.kind]} - + {archivedAt ? ( + <> + restore.mutate({ id: record.id })} + > + + Restore {NOUN[record.kind]} + + setConfirming(true)} + > + + Delete {NOUN[record.kind]} forever + + + ) : ( + archive.mutate({ id: record.id })} + > + + Archive {NOUN[record.kind]} + + )} - Delete {name}? + Delete {name} forever? {consequence} @@ -106,9 +170,9 @@ export function RecordActions({ Cancel remove.mutate({ id: record.id })} + onClick={() => purge.mutate({ id: record.id })} > - Delete + Delete forever diff --git a/apps/app/components/data-table/list-search-params.ts b/apps/app/components/data-table/list-search-params.ts index ae28102fb..b0fd45921 100644 --- a/apps/app/components/data-table/list-search-params.ts +++ b/apps/app/components/data-table/list-search-params.ts @@ -1,43 +1,61 @@ import type { SortDirection } from "@crm/ui/lib/table-query"; import { createLoader, + type GenericParserBuilder, type LoaderFunction, type ParserBuilder, + parseAsBoolean, parseAsInteger, + parseAsJson, + parseAsNativeArrayOf, parseAsString, parseAsStringLiteral, } from "nuqs/server"; +import { z } from "zod"; const SORT_DIRECTIONS = ["asc", "desc"] as const; type StringParser = ParserBuilder & { defaultValue: string }; +type ArrayParser = GenericParserBuilder & { defaultValue: string[] }; + +const fieldFiltersSchema = z.record(z.string(), z.array(z.string())); + +export type FieldFilters = z.infer; export const searchParsers = { q: parseAsString.withDefault(""), page: parseAsInteger.withDefault(1).withOptions({ history: "push" }), + fields: parseAsJson(fieldFiltersSchema.parse).withDefault({}), + archived: parseAsBoolean.withDefault(false), }; -type ListParsers = { +type ListParsers = { q: StringParser; sort: StringParser; dir: ParserBuilder & { defaultValue: SortDirection }; page: ParserBuilder & { defaultValue: number }; -} & { [K in TKey]: StringParser }; + fields: ParserBuilder & { defaultValue: FieldFilters }; + archived: ParserBuilder & { defaultValue: boolean }; +} & { [K in TTab]: StringParser } & { [K in TFacet]: ArrayParser }; -export type ListSearchValues = { +export type ListSearchValues = { q: string; sort: string; dir: SortDirection; page: number; -} & { [K in TKey]: string }; + fields: FieldFilters; + archived: boolean; +} & { [K in TTab]: string } & { [K in TFacet]: string[] }; -export type ListInput = { +export type ListInput = { q: string; sort: string; dir: SortDirection; page: number; pageSize: number; -} & { [K in TKey]: string }; + fields: FieldFilters; + archived: boolean; +} & { [K in TTab]: string } & { [K in TFacet]: string[] }; export type ListTableConfig = { defaultSort?: string; @@ -45,7 +63,7 @@ export type ListTableConfig = { pageSize?: number; tabId?: TTab; facetIds?: readonly TFacet[]; - facetDefaults?: Partial>; + facetDefaults?: Partial>; }; export type ListSearchParams = { @@ -54,12 +72,10 @@ export type ListSearchParams = { defaultDir: SortDirection; pageSize: number; }; - parsers: ListParsers; - load: LoaderFunction>; - toInput: ( - values: ListSearchValues, - ) => ListInput; - defaultInput: () => ListInput; + parsers: ListParsers; + load: LoaderFunction>; + toInput: (values: ListSearchValues) => ListInput; + defaultInput: () => ListInput; }; export function createListSearchParams< @@ -75,30 +91,35 @@ export function createListSearchParams< facetDefaults, } = config; - const extras: Record = {}; + const tabExtras: Record = {}; + if (tabId) tabExtras[tabId] = parseAsString.withDefault("all"); - if (tabId) extras[tabId] = parseAsString.withDefault("all"); + const facetExtras: Record = {}; for (const id of facetIds) { - extras[id] = parseAsString.withDefault(facetDefaults?.[id] ?? "all"); + facetExtras[id] = parseAsNativeArrayOf(parseAsString).withDefault( + facetDefaults?.[id] ?? [], + ); } const parsers = { ...searchParsers, sort: parseAsString.withDefault(defaultSort), dir: parseAsStringLiteral(SORT_DIRECTIONS).withDefault(defaultDir), - ...extras, - } as ListParsers; - - const keys = [...(tabId ? [tabId] : []), ...facetIds] as (TTab | TFacet)[]; + ...tabExtras, + ...facetExtras, + } as ListParsers; const defaults = Object.fromEntries( Object.entries(parsers).map(([key, parser]) => [key, parser.defaultValue]), - ) as ListSearchValues; + ) as ListSearchValues; + + const toInput = (values: ListSearchValues) => { + const selectedTab: Record = {}; + if (tabId) selectedTab[tabId] = values[tabId] ?? "all"; - const toInput = (values: ListSearchValues) => { - const selected: Record = {}; - for (const key of keys) { - selected[key] = values[key] ?? "all"; + const selectedFacets: Record = {}; + for (const id of facetIds) { + selectedFacets[id] = values[id] ?? facetDefaults?.[id] ?? []; } return { @@ -107,8 +128,11 @@ export function createListSearchParams< dir: values.dir, page: values.page > 0 ? values.page : 1, pageSize, - ...selected, - } as ListInput; + fields: values.fields, + archived: values.archived, + ...selectedTab, + ...selectedFacets, + } as ListInput; }; return { diff --git a/apps/app/components/data-table/saved-views-menu.tsx b/apps/app/components/data-table/saved-views-menu.tsx new file mode 100644 index 000000000..a17342396 --- /dev/null +++ b/apps/app/components/data-table/saved-views-menu.tsx @@ -0,0 +1,207 @@ +"use client"; + +import Bookmark from "@carbon/icons-react/es/Bookmark"; +import Close from "@carbon/icons-react/es/Close"; +import { Button } from "@crm/ui/components/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@crm/ui/components/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@crm/ui/components/dropdown-menu"; +import { Field, FieldLabel } from "@crm/ui/components/field"; +import { Input } from "@crm/ui/components/input"; +import { Switch } from "@crm/ui/components/switch"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "sonner"; +import type { FieldEntity } from "@/components/crm/fields/fields-entity"; +import { useCrmCache } from "@/lib/trpc/cache"; +import { useTRPC } from "@/lib/trpc/client"; +import type { RouterOutputs } from "@/lib/trpc/types"; +import type { TableQuery } from "./use-table-query"; + +type SavedView = RouterOutputs["savedViews"]["list"][number]; + +const RECORD_KIND = { + COMPANY: "company", + CONTACT: "contact", + DEAL: "deal", +} as const satisfies Record; + +export function SavedViewsMenu({ + entity, + table, +}: { + entity: FieldEntity; + table: Pick, "currentView" | "applyView">; +}) { + const trpc = useTRPC(); + const cache = useCrmCache(); + const views = useQuery(trpc.savedViews.list.queryOptions({ entity })); + + const [dialogOpen, setDialogOpen] = useState(false); + const [name, setName] = useState(""); + const [shared, setShared] = useState(false); + + const closeDialog = () => { + setDialogOpen(false); + setName(""); + setShared(false); + }; + + const settle = () => cache.savedViews(RECORD_KIND[entity]); + + const create = useMutation( + trpc.savedViews.create.mutationOptions({ + onSuccess: async () => { + await settle(); + toast.success("View saved."); + closeDialog(); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const remove = useMutation( + trpc.savedViews.delete.mutationOptions({ + onSuccess: async () => { + await settle(); + toast.success("View deleted."); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const list = views.data ?? []; + const mine = list.filter((view) => view.mine); + const shared_ = list.filter((view) => !view.mine); + + return ( + <> + + + + + + setDialogOpen(true)}> + Save current view… + + {list.length > 0 && } + {mine.length > 0 && My views} + {mine.map((view) => ( + table.applyView(view.filters)} + onDelete={() => remove.mutate({ id: view.id })} + /> + ))} + {shared_.length > 0 && ( + Shared with the team + )} + {shared_.map((view) => ( + table.applyView(view.filters)} + > + {view.name} + + ))} + + + + (open ? setDialogOpen(true) : closeDialog())} + > + + + Save this view + +
+ + Name + setName(event.target.value)} + autoFocus + /> + + + + Share with the team + + + +
+ + + + +
+
+ + ); +} + +function ViewItem({ + view, + onApply, + onDelete, +}: { + view: SavedView; + onApply: () => void; + onDelete: () => void; +}) { + return ( +
+ + {view.name} + + + + Delete {view.name} + +
+ ); +} diff --git a/apps/app/components/data-table/use-table-query.ts b/apps/app/components/data-table/use-table-query.ts index e3e98df42..ef0a2c075 100644 --- a/apps/app/components/data-table/use-table-query.ts +++ b/apps/app/components/data-table/use-table-query.ts @@ -1,60 +1,183 @@ "use client"; -import type { TableQueryState } from "@crm/ui/lib/table-query"; +import type { SortDirection, TableQueryState } from "@crm/ui/lib/table-query"; +import type { SavedViewFilters } from "@crm/validation/saved-view"; +import type { Nullable, Values } from "nuqs"; import { useQueryStates } from "nuqs"; +import { z } from "zod"; import type { + FieldFilters, ListInput, ListSearchParams, ListSearchValues, } from "./list-search-params"; -export type TableQuery = { +export type TableQuery = { query: TableQueryState; - input: ListInput; + input: ListInput; + setArchived: (value: boolean) => void; + currentView: SavedViewFilters; + applyView: (view: SavedViewFilters) => void; }; +const FIELD_FILTER_PREFIX = "field:"; + +const fieldFiltersValueSchema = z.record(z.string(), z.array(z.string())); + +const queryValueSchema = z.union([ + z.string(), + z.number(), + z.boolean(), + z.array(z.string()), + fieldFiltersValueSchema, + z.null(), +]); + +type QueryValue = z.infer; + +const rawValuesSchema = z + .object({ + q: z.string().default(""), + sort: z.string().default(""), + dir: z.string().default("asc"), + page: z.number().default(1), + fields: fieldFiltersValueSchema.default({}), + archived: z.boolean().default(false), + }) + .catchall(queryValueSchema); + +type RawUpdate = Record; + +const stringSchema = z.string(); +const stringArraySchema = z.array(z.string()); + +function asString(value: QueryValue, fallback = ""): string { + const result = stringSchema.safeParse(value); + return result.success ? result.data : fallback; +} + +function asStringArray(value: QueryValue): string[] { + const result = stringArraySchema.safeParse(value); + return result.success ? result.data : []; +} + export function useTableQuery( searchParams: ListSearchParams, -): TableQuery { +): TableQuery { const { parsers, config, toInput } = searchParams; - const { defaultDir, pageSize, tabId, facetIds, facetDefaults } = config; + const { defaultDir, pageSize, tabId, facetIds } = config; + + const [rawState, rawSetState] = useQueryStates(parsers); + const values = rawValuesSchema.parse(rawState); - const [state, setState] = useQueryStates(parsers); - const values = state as ListSearchValues; + type NuqsValues = Partial>>; - const page = values.page > 0 ? values.page : 1; - const tab = tabId ? values[tabId] : "all"; + function setValues(update: Partial): Promise { + return rawSetState(update as NuqsValues).then(() => undefined); + } - const filters: Record = {}; - if (tabId) filters[tabId] = tab; + const q = values.q; + const sort = values.sort; + const dir = values.dir === "desc" ? "desc" : "asc"; + const rawPage = values.page; + const page = rawPage > 0 ? rawPage : 1; + const fields = values.fields; + const archived = values.archived; + const tab = tabId ? asString(values[tabId]) : "all"; + + const filters: Record = {}; + if (tabId) filters[tabId] = [tab]; for (const id of facetIds ?? []) { - filters[id] = values[id] ?? facetDefaults?.[id] ?? "all"; + filters[id] = asStringArray(values[id]); + } + for (const [key, selected] of Object.entries(fields)) { + filters[`${FIELD_FILTER_PREFIX}${key}`] = selected; } const query: TableQueryState = { - sort: values.sort, - dir: values.dir, + sort, + dir, page, pageSize, tab, tabId, filters, toggleSort: (id) => - setState((prev) => - prev.sort === id - ? { ...prev, dir: prev.dir === "asc" ? "desc" : "asc", page: 1 } - : { ...prev, sort: id, dir: defaultDir, page: 1 }, - ), - setSort: (id) => setState((prev) => ({ ...prev, sort: id, page: 1 })), - setDir: (dir) => setState((prev) => ({ ...prev, dir, page: 1 })), - setPage: (next) => setState((prev) => ({ ...prev, page: next })), + rawSetState((old) => { + const current = rawValuesSchema.parse(old); + const nextDir: SortDirection = current.dir === "asc" ? "desc" : "asc"; + const update: RawUpdate = + current.sort === id + ? { dir: nextDir, page: 1 } + : { sort: id, dir: defaultDir, page: 1 }; + return update as NuqsValues; + }), + setSort: (id) => setValues({ sort: id, page: 1 }), + setDir: (nextDir) => setValues({ dir: nextDir, page: 1 }), + setPage: (next) => setValues({ page: next }), setTab: (value) => { if (!tabId) return; - setState((prev) => ({ ...prev, [tabId]: value, page: 1 })); + setValues({ [tabId]: value, page: 1 }); + }, + setFilter: (id, selected) => { + if (id.startsWith(FIELD_FILTER_PREFIX)) { + const key = id.slice(FIELD_FILTER_PREFIX.length); + const nextFields: FieldFilters = { ...fields }; + if (selected.length === 0) delete nextFields[key]; + else nextFields[key] = selected; + setValues({ fields: nextFields, page: 1 }); + return; + } + + setValues({ [id]: selected, page: 1 }); }, - setFilter: (id, value) => - setState((prev) => ({ ...prev, [id]: value, page: 1 })), }; - return { query, input: toInput(values) }; + const input = toInput({ + q, + sort, + dir, + page: rawPage, + fields, + archived, + ...Object.fromEntries( + [...(tabId ? [tabId] : []), ...(facetIds ?? [])].map((id) => [ + id, + id === tabId ? tab : (filters[id] ?? []), + ]), + ), + } as ListSearchValues); + + const setArchived = (value: boolean) => + setValues({ archived: value, page: 1 }); + + const currentView: SavedViewFilters = { q, sort, dir, archived, filters }; + + const applyView = (view: SavedViewFilters) => { + const update: RawUpdate = { + q: view.q, + sort: view.sort, + dir: view.dir, + archived: view.archived, + page: 1, + }; + + for (const id of facetIds ?? []) update[id] = []; + + const nextFields: FieldFilters = {}; + for (const [key, selected] of Object.entries(view.filters)) { + if (key.startsWith(FIELD_FILTER_PREFIX)) { + nextFields[key.slice(FIELD_FILTER_PREFIX.length)] = selected; + } else if (key === tabId) { + update[key] = selected[0] ?? "all"; + } else { + update[key] = selected; + } + } + update.fields = nextFields; + + setValues(update); + }; + + return { query, input, setArchived, currentView, applyView }; } diff --git a/apps/app/lib/activity-recency.ts b/apps/app/lib/activity-recency.ts new file mode 100644 index 000000000..bb1177258 --- /dev/null +++ b/apps/app/lib/activity-recency.ts @@ -0,0 +1,5 @@ +export const ACTIVITY_FACET_OPTIONS = [ + { value: "7", label: "Active within 7 days" }, + { value: "30", label: "Active within 30 days" }, + { value: "90", label: "Active within 90 days" }, +]; diff --git a/apps/app/lib/trpc/cache.ts b/apps/app/lib/trpc/cache.ts index 574574fdb..790e37211 100644 --- a/apps/app/lib/trpc/cache.ts +++ b/apps/app/lib/trpc/cache.ts @@ -11,6 +11,12 @@ type Options = { type RecordKind = "company" | "contact" | "deal"; +const ENTITY_FOR = { + company: "COMPANY", + contact: "CONTACT", + deal: "DEAL", +} as const satisfies Record; + type RemovedRecord = { kind: RecordKind; id: string }; type RemovedRecords = { kind: RecordKind; ids: string[] }; @@ -21,6 +27,7 @@ export type CrmCache = { deal(id?: string, options?: Options): Promise; fields(entity?: RecordKind, options?: Options): Promise; fieldCoverage(id?: string, options?: Options): Promise; + savedViews(entity?: RecordKind, options?: Options): Promise; removed(record: RemovedRecord): Promise; removedMany(records: RemovedRecords): Promise; conversationRemoved(id: string): Promise; @@ -32,6 +39,7 @@ export type CrmCache = { workspace(options?: Options): Promise; slack(options?: Options): Promise; sso(options?: Options): Promise; + apiKeys(options?: Options): Promise; tracking(options?: Options): Promise; everything(): Promise; }; @@ -119,7 +127,12 @@ export function useCrmCache(): CrmCache { return { fields: (entity, options) => run( - [trpc.fields.list.queryKey()], + [ + trpc.fields.list.queryKey(), + entity + ? trpc.fields.filters.queryKey({ entity: ENTITY_FOR[entity] }) + : trpc.fields.filters.queryKey(), + ], entity ? [RECORD_BY_ID[entity](), RECORD_LIST[entity]()] : [...Object.values(RECORD_BY_ID).map((key) => key()), ...listKeys()], @@ -137,6 +150,19 @@ export function useCrmCache(): CrmCache { options, ), + savedViews: (entity, options) => + run( + [ + entity + ? trpc.savedViews.list.queryKey({ + entity: ENTITY_FOR[entity], + }) + : trpc.savedViews.list.queryKey(), + ], + [], + options, + ), + company: (id, options) => run( [ @@ -146,6 +172,7 @@ export function useCrmCache(): CrmCache { ], [ ...listKeys(), + ...activityKeys(), trpc.contacts.byId.queryKey(), trpc.deals.byId.queryKey(), trpc.dashboard.summary.queryKey(), @@ -162,6 +189,7 @@ export function useCrmCache(): CrmCache { ], [ ...listKeys(), + ...activityKeys(), trpc.companies.byId.queryKey(), trpc.deals.byId.queryKey(), trpc.deals.contactOptions.queryKey(), @@ -248,6 +276,7 @@ export function useCrmCache(): CrmCache { [ trpc.settings.agentModel.queryKey(), trpc.settings.researchKey.queryKey(), + trpc.settings.archiveRetention.queryKey(), ], [], options, @@ -286,6 +315,8 @@ export function useCrmCache(): CrmCache { options, ), + apiKeys: (options) => run([trpc.apiKeys.list.pathKey()], [], options), + tracking: (options) => run( [trpc.tracking.settings.queryKey()], diff --git a/bun.lock b/bun.lock index 3176e6ce9..1c6df3e63 100644 --- a/bun.lock +++ b/bun.lock @@ -48,6 +48,7 @@ "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", + "@nestjs/swagger": "^11.4.7", "@thallesp/nestjs-better-auth": "^2.7.0", "@trpc/server": "^11.18.0", "@vercel/blob": "^2.6.1", @@ -60,6 +61,7 @@ "nestjs-trpc": "^2.13.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "trpc-to-openapi": "^3.3.0", "zod": "^4.4.3", }, "devDependencies": { @@ -119,6 +121,7 @@ "name": "@crm/auth", "version": "0.0.0", "dependencies": { + "@better-auth/api-key": "1.6.25", "@better-auth/sso": "1.6.25", "@crm/db": "workspace:*", "@crm/env": "workspace:*", @@ -327,6 +330,8 @@ "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + "@better-auth/api-key": ["@better-auth/api-key@1.6.25", "", { "dependencies": { "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.6.25", "@better-auth/utils": "0.4.2", "better-auth": "^1.6.25", "better-call": "1.3.7" } }, "sha512-A6f3YLN8Ve+D4R7f8jrSKh8Mpu9+bmc8PIb9BFgGgspcedM1PjpSEI5JKAWPUhiGgMeJdCGeCpRP+27jQmW9/w=="], + "@better-auth/cli": ["@better-auth/cli@1.4.22", "", { "dependencies": { "@babel/core": "^7.28.4", "@babel/preset-react": "^7.27.1", "@babel/preset-typescript": "^7.27.1", "@better-auth/core": "1.4.22", "@better-auth/telemetry": "1.4.22", "@better-auth/utils": "0.3.0", "@clack/prompts": "^0.11.0", "@mrleebo/prisma-ast": "^0.13.0", "@prisma/client": "^5.22.0", "@types/pg": "^8.15.5", "better-auth": "1.4.22", "better-sqlite3": "^12.2.0", "c12": "^3.2.0", "chalk": "^5.6.2", "commander": "^12.1.0", "dotenv": "^17.2.2", "drizzle-orm": "^0.41.0", "open": "^10.2.0", "pg": "^8.16.3", "prettier": "^3.6.2", "prompts": "^2.4.2", "semver": "^7.7.2", "yocto-spinner": "^0.2.3", "zod": "^4.3.5" }, "bin": { "better-auth": "dist/index.mjs" } }, "sha512-7azgrNiP1zJXMLqoLgCVj3KsZeYWLHeaGMapYlLblS6yU/o5n//sn1HBasRD2z4HBy2Etz/C7V483/mYvRHI2g=="], "@better-auth/core": ["@better-auth/core@1.4.22", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.3.5" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-call": "1.1.8", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-l20Ia10lI9iGL+bkjggamQP9lQuiAeB/EYfEx5EQ4AcPrLojG6Doc0UDw5VZM66VXcMGs3bgC8P7WiaJv4Walg=="], @@ -439,6 +444,8 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="], + "@hapi/bourne": ["@hapi/bourne@3.0.0", "", {}, "sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w=="], + "@hono/node-server": ["@hono/node-server@2.0.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg=="], "@ibm/telemetry-js": ["@ibm/telemetry-js@1.11.0", "", { "bin": { "ibmtelemetry": "dist/collect.js" } }, "sha512-RO/9j+URJnSfseWg9ZkEX9p+a3Ousd33DBU7rOafoZB08RqdzxFVYJ2/iM50dkBuD0o7WX7GYt1sLbNgCoE+pA=="], @@ -529,6 +536,8 @@ "@mermaid-js/parser": ["@mermaid-js/parser@1.2.0", "", { "dependencies": { "@chevrotain/types": "~11.1.2" } }, "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA=="], + "@microsoft/tsdoc": ["@microsoft/tsdoc@0.16.0", "", {}, "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA=="], + "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="], @@ -547,8 +556,12 @@ "@nestjs/core": ["@nestjs/core@11.1.28", "", { "dependencies": { "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", "path-to-regexp": "8.4.2", "tslib": "2.8.1", "uid": "2.0.2" }, "peerDependencies": { "@nestjs/common": "^11.0.0", "@nestjs/microservices": "^11.0.0", "@nestjs/platform-express": "^11.0.0", "@nestjs/websockets": "^11.0.0", "reflect-metadata": "^0.1.12 || ^0.2.0", "rxjs": "^7.1.0" }, "optionalPeers": ["@nestjs/microservices", "@nestjs/platform-express", "@nestjs/websockets"] }, "sha512-06m63xIRj8+l8uOeh/8LnYupGubkyu4f+bPKIadaSui6vK9KpXgoz7HveT1yOVLcEt0M0oCOEW5EuEXZkEmBBQ=="], + "@nestjs/mapped-types": ["@nestjs/mapped-types@2.1.1", "", { "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "class-transformer": "^0.4.0 || ^0.5.0", "class-validator": "^0.13.0 || ^0.14.0 || ^0.15.0", "reflect-metadata": "^0.1.12 || ^0.2.0" }, "optionalPeers": ["class-transformer", "class-validator"] }, "sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A=="], + "@nestjs/platform-express": ["@nestjs/platform-express@11.1.28", "", { "dependencies": { "cors": "2.8.6", "express": "5.2.1", "multer": "2.2.0", "path-to-regexp": "8.4.2", "tslib": "2.8.1" }, "peerDependencies": { "@nestjs/common": "^11.0.0", "@nestjs/core": "^11.0.0" } }, "sha512-hU+9Sz4m+onHrR5AmelI59QKmY/Re546bPnygnpqqeQdHDiJpBgjWbL4t6Jr73CBpS60cpyng7WzjgphNB9iwA=="], + "@nestjs/swagger": ["@nestjs/swagger@11.4.7", "", { "dependencies": { "@microsoft/tsdoc": "0.16.0", "@nestjs/mapped-types": "2.1.1", "js-yaml": "5.3.0", "lodash": "4.18.1", "path-to-regexp": "8.4.2", "swagger-ui-dist": "5.32.13" }, "peerDependencies": { "@fastify/static": "^8.0.0 || ^9.0.0 || ^10.0.0", "@nestjs/common": "^11.0.1", "@nestjs/core": "^11.0.1", "class-transformer": "*", "class-validator": "*", "reflect-metadata": "^0.1.12 || ^0.2.0" }, "optionalPeers": ["@fastify/static", "class-transformer", "class-validator"] }, "sha512-QyDYnmfP4IRucgmtQxMqzgRBdWtjFoDp8eFvvgf92+3wdLCL+Q0xOFO1948j/ntW/Wi7qT2dyck6ka8ADzPWQQ=="], + "@nestjs/testing": ["@nestjs/testing@11.1.28", "", { "dependencies": { "tslib": "2.8.1" }, "peerDependencies": { "@nestjs/common": "^11.0.0", "@nestjs/core": "^11.0.0", "@nestjs/microservices": "^11.0.0", "@nestjs/platform-express": "^11.0.0" }, "optionalPeers": ["@nestjs/microservices", "@nestjs/platform-express"] }, "sha512-B+VgRxeLaH7jkOMgAyUP3N3rpFlisQ7JRxixRbgHvG6a0VgKbbkNSofKExexCgKmQQak80undb3+2kE1lUBmRQ=="], "@next/env": ["@next/env@16.2.12", "", {}, "sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg=="], @@ -901,6 +914,10 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.6.1", "", { "os": "linux", "cpu": "x64" }, "sha512-DNGZvZDO5YF7jN5fX8ZqmGLjZEXIJRdJEdTFMhiyXqyXubBa0WVLDWSNlQ5JR2PNgDbEV1VQowhVRUh+74D+RA=="], + + "@scarf/scarf": ["@scarf/scarf@1.4.0", "", {}, "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ=="], + "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], "@shadcn/react": ["@shadcn/react@0.3.0", "", { "peerDependencies": { "@types/react": ">=19", "react": ">=19" }, "optionalPeers": ["@types/react", "react"] }, "sha512-iKN0NuYe850VDHlxEfvppFrpa7CMV3zArTHusXlaSmeFeQhohGbIqclv6WwPTs/44I8EkXQpcuRt6sXEVKkWqQ=="], @@ -1301,6 +1318,8 @@ "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], + "co-body": ["co-body@6.2.0", "", { "dependencies": { "@hapi/bourne": "^3.0.0", "inflation": "^2.0.0", "qs": "^6.5.2", "raw-body": "^2.3.3", "type-is": "^1.6.16" } }, "sha512-Kbpv2Yd1NdL1V/V4cwLVxraHDV6K8ayohr2rmH0J87Er8+zJjcTa6dAn9QMPC9CRgU8+aNajKbSf1TzDB1yKPA=="], + "code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -1335,6 +1354,8 @@ "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "cookie-es": ["cookie-es@1.2.3", "", {}, "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw=="], + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], "cookiejar": ["cookiejar@2.1.4", "", {}, "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw=="], @@ -1661,7 +1682,7 @@ "graphmatch": ["graphmatch@1.1.1", "", {}, "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg=="], - "h3": ["h3@2.0.1-rc.22", "", { "dependencies": { "rou3": "^0.8.1", "srvx": "^0.11.15" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"], "bin": { "h3": "bin/h3.mjs" } }, "sha512-Esv0DMIuPkCTSWCA0vO73vcTqwzH1wjSrAO1TXNu/K3up1sZHa9EKMapbmxCDYBeymC3fVTk4qxp7ogQWQ+KgA=="], + "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=="], "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], @@ -1723,6 +1744,8 @@ "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], + "inflation": ["inflation@2.1.0", "", {}, "sha512-t54PPJHG1Pp7VQvxyVCJ9mBbjG3Hqryges9bXoOO6GExCPa+//i/d5GSuFtpx3ALLd7lgIAur6zrIlBQyJuMlQ=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], @@ -1735,6 +1758,8 @@ "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="], + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], @@ -1793,7 +1818,7 @@ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + "js-yaml": ["js-yaml@5.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.mjs" } }, "sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], @@ -2049,6 +2074,8 @@ "node-liblzma": ["node-liblzma@2.2.0", "", { "dependencies": { "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4" }, "bin": { "nxz": "lib/cli/nxz.js" } }, "sha512-s0KzNOWwOJJgPG6wxg6cKohnAl9Wk/oW1KrQaVzJBjQwVcUGPQCzpR46Ximygjqj/3KhOrtJXnYMp/xYAXp75g=="], + "node-mock-http": ["node-mock-http@1.0.5", "", {}, "sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw=="], + "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="], "node-rsa": ["node-rsa@1.1.1", "", { "dependencies": { "asn1": "^0.2.4" } }, "sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw=="], @@ -2081,6 +2108,8 @@ "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "openapi3-ts": ["openapi3-ts@4.4.0", "", { "dependencies": { "yaml": "^2.5.0" } }, "sha512-9asTNB9IkKEzWMcHmVZE7Ts3kC9G7AFHfs8i7caD8HbI76gEjdkId4z/AkP83xdZsH7PLAnnbl47qZkXuxpArw=="], + "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], "os-paths": ["os-paths@4.4.0", "", {}, "sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg=="], @@ -2217,6 +2246,8 @@ "radix-ui": ["radix-ui@1.6.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-accessible-icon": "1.1.15", "@radix-ui/react-accordion": "1.2.20", "@radix-ui/react-alert-dialog": "1.1.23", "@radix-ui/react-arrow": "1.1.15", "@radix-ui/react-aspect-ratio": "1.1.15", "@radix-ui/react-avatar": "1.2.6", "@radix-ui/react-checkbox": "1.3.11", "@radix-ui/react-collapsible": "1.1.20", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-context-menu": "2.3.7", "@radix-ui/react-dialog": "1.1.23", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-dropdown-menu": "2.1.24", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-form": "0.1.16", "@radix-ui/react-hover-card": "1.1.23", "@radix-ui/react-label": "2.1.15", "@radix-ui/react-menu": "2.1.24", "@radix-ui/react-menubar": "1.1.24", "@radix-ui/react-navigation-menu": "1.2.22", "@radix-ui/react-one-time-password-field": "0.1.16", "@radix-ui/react-password-toggle-field": "0.1.11", "@radix-ui/react-popover": "1.1.23", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-progress": "1.1.16", "@radix-ui/react-radio-group": "1.4.7", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-scroll-area": "1.2.18", "@radix-ui/react-select": "2.3.7", "@radix-ui/react-separator": "1.1.15", "@radix-ui/react-slider": "1.4.7", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-switch": "1.3.7", "@radix-ui/react-tabs": "1.1.21", "@radix-ui/react-toast": "1.2.23", "@radix-ui/react-toggle": "1.1.18", "@radix-ui/react-toggle-group": "1.1.19", "@radix-ui/react-toolbar": "1.1.19", "@radix-ui/react-tooltip": "1.2.16", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-escape-keydown": "1.1.5", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-size": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.11" }, "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-QBdhh1arIEUvPC0dQ5+nwWAxt7+N+oP/9jPwjJkGFoSk/sqxg32gJtSXGtFh8frAIcS6oC9cx2Q+7KYCQLOAeA=="], + "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="], + "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], @@ -2435,6 +2466,8 @@ "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + "swagger-ui-dist": ["swagger-ui-dist@5.32.13", "", { "dependencies": { "@scarf/scarf": "=1.4.0" } }, "sha512-qQobzb3DeC2LeK0j3E8812Ef4aIq1y9flJxvZkimkqUC/w4u7wS+yCc+VakqGJLweUUBrI24effhwo8OsAvNAw=="], + "systeminformation": ["systeminformation@5.33.1", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-DEN6ICHk3Tk0Uf/hrAHh7xlt7iL5CJFBtPZinA0H62DrGG/KPKqq/Nzj6lCXPS4Ay/sf/14zNnk9LpqKzBIc+w=="], "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], @@ -2471,6 +2504,8 @@ "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + "trpc-to-openapi": ["trpc-to-openapi@3.3.0", "", { "dependencies": { "co-body": "6.2.0", "h3": "^1.15.5", "openapi3-ts": "4.4.0" }, "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "4.6.1" }, "peerDependencies": { "@trpc/server": "^11.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-openapi": "^5.4.4" } }, "sha512-PNMXq0K37Ott5ysNACYN1XLaM5xZGNJrx1QiQVTlk4hmgJomoyiAKhVrpwrK02JW3joSQmHYBOZ7pK6Hdioekw=="], + "ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="], "ts-morph": ["ts-morph@26.0.0", "", { "dependencies": { "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug=="], @@ -2493,12 +2528,16 @@ "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], + "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], + "uid": ["uid@2.0.2", "", { "dependencies": { "@lukeed/csprng": "^1.0.0" } }, "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g=="], "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], "unbash": ["unbash@4.0.10", "", {}, "sha512-b7zoBQvpWp0vuN5q2vK2RRBR2SvuruQAs50DApdDveBSn3eSYd84IaHodFqQIMlvY9K2VnyBUEXgwOBuGU9GBg=="], + "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], + "undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], @@ -2605,6 +2644,8 @@ "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "zod-openapi": ["zod-openapi@5.4.6", "", { "peerDependencies": { "zod": "^3.25.74 || ^4.0.0" } }, "sha512-P2jsOOBAq/6hCwUsMCjUATZ8szkMsV5VAwZENfyxp2Hc/XPJQpVwAgevWZc65xZauCwWB9LAn7zYeiCJFAEL+A=="], + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], @@ -2621,6 +2662,10 @@ "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@better-auth/api-key/@better-auth/core": ["@better-auth/core@1.6.25", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.7", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-lMTlhtwyK4NpY9kPF+2rQCRKYpg136d3gM2xl8esxT1PjJx5Nh5YwZvxcYCIjDuO759sx6TCloJTuwcZGG6ZBw=="], + + "@better-auth/api-key/@better-auth/utils": ["@better-auth/utils@0.4.2", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A=="], + "@better-auth/cli/@prisma/client": ["@prisma/client@5.22.0", "", { "peerDependencies": { "prisma": "*" }, "optionalPeers": ["prisma"] }, "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA=="], "@better-auth/cli/better-auth": ["better-auth@1.4.22", "", { "dependencies": { "@better-auth/core": "1.4.22", "@better-auth/telemetry": "1.4.22", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.8", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.3.5" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-CXQ7ZLDkf/I9iaVTNuejJ7FlWal50hRPIv1n0lqMipvthEoMx+2RQyNXUvzGRjltSe5d9rcZPI3IxdtS1A5+YA=="], @@ -2891,12 +2936,18 @@ "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "co-body/raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], + + "co-body/type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], + "concurrently/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "conf/ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], "conf/json-schema-typed": ["json-schema-typed@7.0.3", "", {}, "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A=="], + "cosmiconfig/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], @@ -2919,7 +2970,7 @@ "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "h3/rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="], + "h3/crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="], "just-bash/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], @@ -2937,6 +2988,8 @@ "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + "nitro/h3": ["h3@2.0.1-rc.22", "", { "dependencies": { "rou3": "^0.8.1", "srvx": "^0.11.15" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"], "bin": { "h3": "bin/h3.mjs" } }, "sha512-Esv0DMIuPkCTSWCA0vO73vcTqwzH1wjSrAO1TXNu/K3up1sZHa9EKMapbmxCDYBeymC3fVTk4qxp7ogQWQ+KgA=="], + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], "onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], @@ -3065,6 +3118,12 @@ "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "co-body/raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + + "co-body/type-is/media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], + + "co-body/type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "concurrently/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -3083,6 +3142,8 @@ "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@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="], + "shadcn/open/wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], "wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -3147,6 +3208,8 @@ "app/next/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="], + "co-body/type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "multer/type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], diff --git a/docs/api.md b/docs/api.md index 56c9e7d5d..99f1d009a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -207,12 +207,38 @@ picker reads. - **`role` is blanked to null, never stored as `""`** — `blankToNull`, as everywhere else. -## Deleting a record - -`contacts.delete`, `companies.delete`, `deals.delete`. No soft delete, no archive. - -- **A deleted contact is suppressed by address**, or the sync recreates them from the - next thread. `ContactsService.delete` writes `SuppressedContact`, and +## Deleting a record is archive first, purge later + +`contacts.archive`, `companies.archive`, `deals.archive` set `archivedAt`. Nothing +else changes — no suppression, no cascade cleanup, no stamp recompute — because the +row is still there, just filtered out of every list. + +- **`archivedFilter(input.archived)` is in every `buildWhere` and every `facetCounts` + where.** A list call defaults to `archived: false`; the Archived view is the same + procedure with `archived: true`. There is no third "everything" mode — mixing + active and archived rows in one table is exactly what this feature removes. +- **`archive`/`restore` are a bare `update({ archivedAt })`, nothing more.** They do + not touch `AgentTask`, `SuppressedContact`, or `lastActivityAt` — the record is + unchanged, only hidden. +- **`purge` is the old `delete`.** Same transaction, same suppression, same + `AgentTask`/`AgentEvent` cleanup, same `ActivityStampService.recomputeAfterDelete`. + Read the rest of this section as `purge`'s contract, not `archive`'s. +- **A rep can `purge` straight from the Archived view — retention is a ceiling, not a + wait.** `bulkPurge`/the per-record "Delete forever" action call it directly, no + different from the cron. + +**Pruning**: `ArchiveRetentionController` (`internal/archive/prune`, `CRON_SECRET`-gated, +`apps/api/vercel.json`) reads `AppSetting.archiveRetentionDays` +(`readArchiveRetentionDays`, `@crm/db/settings`, default 180) and calls each service's +`purgeExpired(before)` — `findMany({ archivedAt: { lte: before } })` capped at +`ARCHIVE.prune.maxBatch` (`archive/archive-config.ts`), then `purge` per row through +the ordinary `runBulk`. Settings → General has the day count +(`settings.archiveRetention` / `setArchiveRetention`). + +Below is `purge`'s contract — everything that used to be `delete`'s: + +- **A purged contact is suppressed by address**, or the sync recreates them from the + next thread. `ContactsService.purge` writes `SuppressedContact`, and `externalParticipants` drops it like a `SuppressedDomain` — one filter covering contact creation, company auto-creation and attribution. - **Keyed lower case.** `normalizeEmail` (`crm/values.ts`) is the one canonicaliser, @@ -223,11 +249,11 @@ picker reads. 404 is that statement's own `P2025` through `translate`. - **Adding them back lifts the suppression** via `allowAgain` **inside the write's transaction**. Never automatic. -- **Deleting a company does not suppress its domain** — its people survive with no +- **Purging a company does not suppress its domain** — its people survive with no company, and domain suppression stays the explicit Settings → Connections control. - **Clear `AgentTask` and `AgentEvent` yourself** — they carry `contactId`/`companyId` with no foreign key, so nothing cascades. -- **Recompute `lastActivityAt` on exactly the records the delete reached.** +- **Recompute `lastActivityAt` on exactly the records the purge reached.** `ActivityStampService.targetsOf(where)` collects them *inside* the transaction (the evidence is what gets deleted); `recomputeMany` restamps. A company's `where` must follow its deals: `{ OR: [{ companyId }, { deal: { companyId } }] }`. diff --git a/docs/list-building-roadmap.md b/docs/list-building-roadmap.md new file mode 100644 index 000000000..762d02066 --- /dev/null +++ b/docs/list-building-roadmap.md @@ -0,0 +1,147 @@ +# List-Building Roadmap + +A plan to answer the Director of Sales Development's read on the CRM: filtering +looks thin for real BDR list-building. Integrations are out of scope, by his own +note — this is about fields, filters, and views inside the CRM itself. + +## 1. What he saw + +> "As it stands it looks like a research/automation layer more than a system of +> record — I'd be concerned about moving off HubSpot/Salesforce-caliber +> filtering without seeing those gaps closed first." +> +> — Director of Sales Development, first-look feedback + +He is right about the current state. Today's filtering is this small: + +- **3** Company filters — owner, industry, enrichment +- **2** Contact filters — owner, company +- **1** value per filter — no "is any of" yet +- **0** saved or shared list views + +## 2. Why this is cheaper than it looks + +Two systems already in the CRM cover most of the fields on his list. Neither is +exposed as a filter yet — that is the actual gap, not missing intelligence. + +**Already built — admin fields.** An admin-configurable field system already +backs every record sheet and table: `FieldDefinition` + `FieldOption` + +`FieldValue`, with a `SELECT` type for picklists and a `USER` type for a second +owner. Account type, segment/tier, territory, lifecycle stage, and lead source +all fit this today — no database migration required. + +**Already built — agent inference.** The agent already scores `seniority` and +`function` (persona) per contact from evidence, the same pipeline that fills +title and LinkedIn today. It just isn't wired to a column, so it never reaches +the table or a filter. + +## 3. The gap map + +Every field from his note, what backs it today, and the plan to close it. + +### Companies view + +All seven rows below are already seeded as `FieldDefinition` rows +(`seedCompanyFields` in `packages/db/prisma/seed.ts`) with real option sets. The +gap is not the field — it is that none of them show on the table or reach the +filter bar yet. + +| Field he wants | Where it stands today | Plan | Effort | +| --- | --- | --- | --- | +| Account type (prospect / customer / partner / churned) | Seeded `SELECT` field — Prospect, Customer, Partner, Churned | Turn on table + filter | Small | +| Inside Sales / BDR owner (separate from AE / account owner) | Seeded `USER` field, sits beside Owner | Turn on table + filter | Small | +| Segment / tier | Seeded `SELECT` field — Enterprise, Mid-Market, SMB | Turn on table + filter | Small | +| ICP fit score | Seeded `NUMBER` field; agent scoring is a natural next step, not a v1 requirement | Turn on table + filter | Small | +| Territory | Seeded `SELECT` field — AMER, EMEA, APAC | Turn on table + filter | Small | +| Lifecycle stage | Seeded `SELECT` field — Lead, MQL, SQL, Opportunity, Customer, … | Turn on table + filter | Small | +| Lead source (IB / OB / Event) | Seeded `SELECT` field — Inbound, Outbound, Event, kept separate from the `source` enum that records how a row entered the CRM | Turn on table + filter | Small | + +### Contacts view + +| Field he wants | Where it stands today | Plan | Effort | +| --- | --- | --- | --- | +| Title filter | Column exists, shown on the table, no filter | Add it as a filter | Small | +| Seniority | The agent already infers and scores this per contact — never synced anywhere filterable | Sync into a real column the same way title already syncs; then filter on it | Medium | +| Persona | Agent infers this as "function" — same situation as seniority | Same fix as seniority, same pipeline | Medium | +| Engagement / activity recency | Last-activity date is tracked and shown, not filterable | Add an "active within 7 / 30 / 90 days" filter | Medium | +| Sequence status | No source of truth — this CRM does not run sequences | Needs an outreach-tool connection. Deferred, per your note to set integrations aside. | Deferred | + +## 4. The part that actually blocks list-building + +Adding every field above still will not give a BDR a HubSpot-style +list-builder. The filter bar itself is one value at a time. This is the real +foundation, and it comes before the fields above pay off. + +1. **Filters are single-select** *(Medium)* — a BDR cannot say "VP or Director" + or "SF Bay Area or NYC" in one filter today — each filter holds exactly one + value. +2. **Admin fields are not filterable at all** *(Medium)* — every custom + field — present or future — shows up as a column only. None of it reaches + the filter bar without new plumbing. +3. **No saved or shared views** *(Large)* — a working list a BDR builds today + lives only in the URL. There is no way to name it, save it, or hand it to a + teammate. + +## 5. Sequence + +Four phases, in order — each phase's fields are inert without the phase before +it. + +### Phase 1 — Filter engine foundation *(ships first)* + +Multi-select ("is any of") on every filter. Any admin-defined `SELECT` or +`USER` field becomes a filter automatically. An activity-recency filter goes in +here too. + +Resolves: unlocks every field below · engagement / activity recency + +### Phase 2 — Company segmentation fields + +Turn on table and filter visibility for the seven Company fields from the gap +map — account type, BDR owner, segment/tier, territory, lifecycle stage, lead +source, ICP fit score. All seven are already seeded; nothing new to create. + +Resolves: account type · BDR owner · segment/tier · territory · lifecycle +stage · lead source · ICP fit score + +### Phase 3 — Contact intelligence, surfaced + +Sync the agent's existing seniority and persona facts into real, filterable +fields. Add the title filter. + +Resolves: seniority · persona · title filter + +### Phase 4 — Saved & shared views *(later, on request)* + +Name a filter combination, save it, share it with the team. Sequence status +waits here too, tied to an outreach-tool connection if that scope reopens. + +Resolves: reusable BDR lists · sequence status (needs an integration) + +## 6. Issues + +Reported in full, including risks this plan does not remove. + +1. RISK — New fields alone do not fix list-building. Filters stay single-select + without phase 1. + Fix: build multi-select filtering first. Sequence, above, already orders + this first. +2. NOT DONE — Custom fields are not wired into any filter today. They render as + table columns only. + Fix: not done. Planned in phase 1 as automatic facet generation. +3. NOT DONE — Sequence status has no source of truth inside this CRM. + Fix: not done. Needs an outreach-tool integration — out of scope now, per + your note. +4. RISK — A BDR owner shipped as an admin field will not appear in + owner-based reports until someone decides it should. + Fix: ship as an admin field first. Promote to a first-class column only if + reporting needs it later. +5. UNKNOWN — No user role distinguishes an AE from a BDR today, so no field + can default itself by role. + Fix: not investigated. Confirm before phase 2 whether role-based defaults + matter to him. + +--- + +Scoped to the CRM's own data model and filter UI. Integrations, per your note, +are out of scope for this plan. diff --git a/packages/auth/package.json b/packages/auth/package.json index 8c501eda4..ea671ffcd 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -18,6 +18,7 @@ "clean": "rm -rf .turbo node_modules" }, "dependencies": { + "@better-auth/api-key": "1.6.25", "@better-auth/sso": "1.6.25", "@crm/db": "workspace:*", "@crm/env": "workspace:*", diff --git a/packages/auth/src/api-keys.ts b/packages/auth/src/api-keys.ts new file mode 100644 index 000000000..ce3a5c0d5 --- /dev/null +++ b/packages/auth/src/api-keys.ts @@ -0,0 +1,9 @@ +export const API_KEY_HEADER = "x-api-key"; +export const API_KEY_PREFIX = "crm_"; + +export const DAY_SECONDS = 24 * 60 * 60; + +export const API_KEY_EXPIRATION = { + minDays: 1, + maxDays: 365, +} as const; diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts index 5bdc5120a..c59c98254 100644 --- a/packages/auth/src/auth.ts +++ b/packages/auth/src/auth.ts @@ -1,3 +1,4 @@ +import { apiKey } from "@better-auth/api-key"; import { sso } from "@better-auth/sso"; import { db } from "@crm/db"; import { schemas } from "@crm/validation"; @@ -6,6 +7,7 @@ import { prismaAdapter } from "better-auth/adapters/prisma"; import { APIError } from "better-auth/api"; import { genericOAuth } from "better-auth/plugins/generic-oauth"; import { organization } from "better-auth/plugins/organization"; +import { API_KEY_EXPIRATION, API_KEY_HEADER, API_KEY_PREFIX } from "./api-keys"; import { AUTH_COOKIE_PREFIX } from "./cookies"; import { env } from "./env"; import { ensureWorkspaceMembership } from "./organization"; @@ -228,6 +230,20 @@ export const auth = betterAuth({ sso({ organizationProvisioning: { disabled: true }, }), + + apiKey({ + apiKeyHeaders: API_KEY_HEADER, + defaultPrefix: API_KEY_PREFIX, + enableSessionForAPIKeys: true, + requireName: true, + defaultKeyLength: 32, + maximumNameLength: 64, + rateLimit: { enabled: false }, + keyExpiration: { + maxExpiresIn: API_KEY_EXPIRATION.maxDays, + minExpiresIn: API_KEY_EXPIRATION.minDays, + }, + }), ], databaseHooks: { diff --git a/packages/auth/src/client.ts b/packages/auth/src/client.ts index 501596295..980a37b67 100644 --- a/packages/auth/src/client.ts +++ b/packages/auth/src/client.ts @@ -1,10 +1,11 @@ +import { apiKeyClient } from "@better-auth/api-key/client"; import { ssoClient } from "@better-auth/sso/client"; import { genericOAuthClient } from "better-auth/client/plugins"; import { createAuthClient } from "better-auth/react"; export const authClient = createAuthClient({ baseURL: globalThis.window?.location.origin, - plugins: [ssoClient(), genericOAuthClient()], + plugins: [ssoClient(), genericOAuthClient(), apiKeyClient()], }); export const { getSession, signIn, signOut, useSession } = authClient; diff --git a/packages/auth/src/cookies.ts b/packages/auth/src/cookies.ts index 0b00c295b..be62b5228 100644 --- a/packages/auth/src/cookies.ts +++ b/packages/auth/src/cookies.ts @@ -1 +1,2 @@ export const AUTH_COOKIE_PREFIX = "crm"; +export const SESSION_COOKIE_NAME = `${AUTH_COOKIE_PREFIX}.session_token`; diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index f673bff76..31fbcbaad 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -1,6 +1,13 @@ +export { + API_KEY_EXPIRATION, + API_KEY_HEADER, + API_KEY_PREFIX, + DAY_SECONDS, +} from "./api-keys"; export { type Auth, auth, type Session, type SessionUser } from "./auth"; -export { AUTH_COOKIE_PREFIX } from "./cookies"; +export { AUTH_COOKIE_PREFIX, SESSION_COOKIE_NAME } from "./cookies"; export { + apiUrl, appUrl, isGoogleConfigured, isMicrosoftConfigured, diff --git a/packages/db/prisma/migrations/20260820150251_archive_crm_records/migration.sql b/packages/db/prisma/migrations/20260820150251_archive_crm_records/migration.sql new file mode 100644 index 000000000..28e5ee77a --- /dev/null +++ b/packages/db/prisma/migrations/20260820150251_archive_crm_records/migration.sql @@ -0,0 +1,20 @@ +-- 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 new file mode 100644 index 000000000..8979286d6 --- /dev/null +++ b/packages/db/prisma/migrations/20260820150700_list_building_fields/migration.sql @@ -0,0 +1,3 @@ +-- 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 new file mode 100644 index 000000000..b2bba7c79 --- /dev/null +++ b/packages/db/prisma/migrations/20260820152910_add_api_keys/migration.sql @@ -0,0 +1,39 @@ +-- 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 new file mode 100644 index 000000000..a724f2caa --- /dev/null +++ b/packages/db/prisma/migrations/20260820154345_saved_views/migration.sql @@ -0,0 +1,22 @@ +-- 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 new file mode 100644 index 000000000..e7353a4af --- /dev/null +++ b/packages/db/prisma/migrations/20260820154825_field_show_on_filter/migration.sql @@ -0,0 +1,2 @@ +-- 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 new file mode 100644 index 000000000..2ad69c700 --- /dev/null +++ b/packages/db/prisma/migrations/20260820160000_backfill_seniority_function/migration.sql @@ -0,0 +1,30 @@ +-- 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 new file mode 100644 index 000000000..754562e34 --- /dev/null +++ b/packages/db/prisma/migrations/20260820161500_archive_scoped_uniqueness/migration.sql @@ -0,0 +1,11 @@ +-- 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/schema.prisma b/packages/db/prisma/schema.prisma index 28a49c397..82bc1f368 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -31,6 +31,7 @@ model User { factDecisions ContactFact[] @relation("FactDecider") conversations AgentConversation[] @relation("ConversationOwner") fieldValues FieldValue[] @relation("FieldValueUser") + savedViews SavedView[] @relation("SavedViewOwner") createdAgentDefinitions AgentDefinition[] @relation("AgentDefinitionCreator") createdAgentVersions AgentVersion[] @relation("AgentVersionCreator") @@ -47,19 +48,21 @@ model User { 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 + 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") @@ -277,7 +280,7 @@ enum AgentResponseRating { model Company { id String @id @default(cuid()) name String - domain String? @unique + domain String? website String? description String? @@ -316,6 +319,7 @@ model Company { source RecordSource @default(MANUAL) lastActivityAt DateTime? + archivedAt DateTime? contacts Contact[] @relation("CompanyContacts") conversations AgentConversation[] @@ -327,9 +331,11 @@ model Company { 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") } @@ -347,9 +353,11 @@ model Contact { id String @id @default(cuid()) firstName String lastName String? - email String? @unique + email String? phone String? title String? + seniority String? + function String? linkedinUrl String? twitterUrl String? githubUrl String? @@ -374,6 +382,7 @@ model Contact { source RecordSource @default(MANUAL) lastActivityAt DateTime? + archivedAt DateTime? deals DealContact[] activities Activity[] @@ -386,9 +395,11 @@ model Contact { 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") } @@ -926,6 +937,7 @@ model Deal { fxRateAt DateTime? lastActivityAt DateTime? + archivedAt DateTime? contacts DealContact[] activities Activity[] @@ -940,6 +952,7 @@ model Deal { @@index([lastActivityAt]) @@index([baseAmount]) @@index([currency]) + @@index([archivedAt]) @@map("deal") } @@ -1005,10 +1018,11 @@ model FieldDefinition { agentFilled Boolean @default(true) agentBrief String? - required Boolean @default(false) - showOnSheet Boolean @default(true) - showOnTable Boolean @default(false) - position Int + required Boolean @default(false) + showOnSheet Boolean @default(true) + showOnTable Boolean @default(false) + showOnFilter Boolean @default(false) + position Int archivedAt DateTime? @@ -1074,6 +1088,24 @@ model FieldValue { @@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 @@ -1277,15 +1309,17 @@ model AppSetting { ratesRefreshedAt DateTime? - 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) + 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) + trackingPaused Boolean @default(false) updatedAt DateTime @updatedAt @@ -1316,14 +1350,14 @@ model TrackedVisitor { 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? + firstSource String? + firstMedium String? + firstCampaign String? + firstTerm String? + firstContent String? + firstReferrer String? + firstLanding String? + firstTouchAt DateTime? lastSource String? lastMedium String? @@ -1518,3 +1552,34 @@ model SsoProvider { @@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/prisma/seed.ts b/packages/db/prisma/seed.ts index 1ba1dcc07..556f2e2eb 100644 --- a/packages/db/prisma/seed.ts +++ b/packages/db/prisma/seed.ts @@ -2,9 +2,12 @@ import { mirror } from "../src/blob"; import { db } from "../src/client"; import { DEFAULT_REPORTING_CURRENCY } from "../src/currency"; import { resolveFavicon } from "../src/favicon"; +import { fieldKeyFromLabel } from "../src/fields-shape"; import { ActivityType, DealStage, + type FieldEntity, + FieldType, RateSource, } from "../src/generated/prisma/enums"; import { readReportingCurrency, SETTINGS_ID } from "../src/settings"; @@ -422,6 +425,228 @@ async function seedIcons( console.log(`Resolved ${resolved} of ${missing.length} company icons.`); } +const ACCOUNT_TYPES = ["Prospect", "Customer", "Partner", "Churned"] as const; +const SEGMENT_TIERS = ["Enterprise", "Mid-Market", "SMB"] as const; +const TERRITORIES = ["AMER", "EMEA", "APAC"] as const; +const LIFECYCLE_STAGES = [ + "Lead", + "MQL", + "SQL", + "Opportunity", + "Customer", +] as const; +const LEAD_SOURCES = ["Inbound", "Outbound", "Event"] as const; + +type SeededField = { + id: string; + key: string; + options: { id: string; label: string }[]; +}; + +type SeededFieldSet = { + accountType: SeededField; + segment: SeededField; + territory: SeededField; + lifecycleStage: SeededField; + leadSource: SeededField; + icpFitScore: SeededField; + bdrOwner: SeededField; +}; + +async function upsertField( + entity: FieldEntity, + label: string, + type: FieldType, + position: number, + 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) => ({ + label: optionLabel, + position: index, + })), + }, + }, + update: {}, + include: { options: true }, + }); + + return { + id: definition.id, + key: definition.key, + options: definition.options, + }; +} + +async function seedCompanyFields(): Promise { + const [ + accountType, + segment, + territory, + lifecycleStage, + leadSource, + icpFitScore, + bdrOwner, + ] = await Promise.all([ + upsertField("COMPANY", "Account type", FieldType.SELECT, 0, ACCOUNT_TYPES), + upsertField("COMPANY", "Segment", FieldType.SELECT, 1, SEGMENT_TIERS), + upsertField("COMPANY", "Territory", FieldType.SELECT, 2, TERRITORIES), + upsertField( + "COMPANY", + "Lifecycle stage", + FieldType.SELECT, + 3, + LIFECYCLE_STAGES, + ), + upsertField("COMPANY", "Lead source", FieldType.SELECT, 4, LEAD_SOURCES), + upsertField("COMPANY", "ICP fit score", FieldType.NUMBER, 5), + upsertField("COMPANY", "BDR owner", FieldType.USER, 6), + ]); + + return { + accountType, + segment, + territory, + lifecycleStage, + leadSource, + icpFitScore, + bdrOwner, + }; +} + +function optionIdFor(field: SeededField, label: string): string { + const option = field.options.find((entry) => entry.label === label); + if (!option) + throw new Error(`Seed field "${field.key}" has no "${label}" option.`); + return option.id; +} + +async function seedCompanyFieldValues( + fields: SeededFieldSet, + companies: { id: string }[], + ownerIds: string[], +): Promise { + for (const company of companies) { + 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: {}, + }), + 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: {}, + }), + 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: {}, + }), + 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: {}, + }), + 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: {}, + }), + 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: {}, + }), + 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: {}, + }), + ]); + } +} + type SeededContact = { id: string; companyId: string }; async function seedContacts( @@ -773,10 +998,13 @@ async function main() { const contacts = await seedContacts(companies, ownerIds); const deals = await seedDeals(companies, contacts, ownerIds); const activities = await seedActivities(companies, contacts, deals, ownerIds); + const companyFields = await seedCompanyFields(); + await seedCompanyFieldValues(companyFields, companies, ownerIds); console.log( `Seeded ${companies.length} companies, ${contacts.length} contacts, ` + - `${deals.length} deals, ${activities} activities, ${rates} exchange rates.`, + `${deals.length} deals, ${activities} activities, ${rates} exchange rates, ` + + "7 company fields.", ); } diff --git a/packages/db/src/fields.ts b/packages/db/src/fields.ts index 8b0b88eaf..4c34ad0b8 100644 --- a/packages/db/src/fields.ts +++ b/packages/db/src/fields.ts @@ -39,6 +39,7 @@ export type SerializedField = { required: boolean; showOnSheet: boolean; showOnTable: boolean; + showOnFilter: boolean; position: number; archived: boolean; options: SerializedFieldOption[]; @@ -59,6 +60,7 @@ export function serializeField( required: definition.required, showOnSheet: definition.showOnSheet, showOnTable: definition.showOnTable, + showOnFilter: definition.showOnFilter, position: definition.position, archived: definition.archivedAt !== null, options: definition.options diff --git a/packages/db/src/settings.ts b/packages/db/src/settings.ts index 7e90d5ba3..2959bf05b 100644 --- a/packages/db/src/settings.ts +++ b/packages/db/src/settings.ts @@ -121,6 +121,39 @@ export async function writeRatesRefreshedAt( }); } +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 }, + }); + + return row?.archiveRetentionDays ?? DEFAULT_ARCHIVE_RETENTION_DAYS; +} + +export async function writeArchiveRetentionDays( + db: Db, + days: number, +): Promise { + const archiveRetentionDays = Math.min( + Math.max(Math.round(days), MIN_ARCHIVE_RETENTION_DAYS), + MAX_ARCHIVE_RETENTION_DAYS, + ); + + await db.appSetting.upsert({ + where: { id: SETTINGS_ID }, + create: { id: SETTINGS_ID, archiveRetentionDays }, + update: { archiveRetentionDays }, + }); + + return archiveRetentionDays; +} + export function maskKey(key: string): string { const trimmed = key.trim(); return trimmed.length > 4 ? `••••${trimmed.slice(-4)}` : "••••"; diff --git a/packages/ui/src/components/combobox.tsx b/packages/ui/src/components/combobox.tsx index 5014a6abb..8936e0e24 100644 --- a/packages/ui/src/components/combobox.tsx +++ b/packages/ui/src/components/combobox.tsx @@ -114,9 +114,11 @@ function Combobox({ onValueChange(option.value); }} > - {option.label} + + {option.label} + {option.hint ? ( - + {option.hint} ) : null} diff --git a/packages/ui/src/components/data-table.tsx b/packages/ui/src/components/data-table.tsx index dd7819ab1..2f3663b42 100644 --- a/packages/ui/src/components/data-table.tsx +++ b/packages/ui/src/components/data-table.tsx @@ -21,17 +21,16 @@ import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, + DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@crm/ui/components/dropdown-menu"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@crm/ui/components/popover"; import { Spinner } from "@crm/ui/components/spinner"; import { TablePagination } from "@crm/ui/components/table-pagination"; import { @@ -48,7 +47,6 @@ import type { TableQueryState } from "@crm/ui/lib/table-query"; import { cn } from "@crm/ui/lib/utils"; import { parseAsArrayOf, parseAsString, useQueryState } from "nuqs"; import { - type ComponentProps, Fragment, type ReactNode, useDeferredValue, @@ -165,96 +163,103 @@ function SortIndicator({ ); } -function FacetTrigger({ - label, - ...props -}: ComponentProps & { label: string }) { - return ( - - ); +function facetLabel(facet: DataTableFacet, selected: string[]): string { + if (selected.length === 0) return facet.label; + if (selected.length === 1) { + const option = facet.options.find((o) => o.value === selected[0]); + return option?.label ?? facet.label; + } + return `${facet.label} (${selected.length})`; } -function SearchableFacet({ +function toggle(selected: string[], value: string, checked: boolean): string[] { + return checked ? [...selected, value] : selected.filter((v) => v !== value); +} + +function FacetSubmenu({ facet, selected, - activeLabel, - onSelect, + onChange, }: { facet: DataTableFacet; - selected: string; - activeLabel?: string; - onSelect: (value: string) => void; + selected: string[]; + onChange: (values: string[]) => void; }) { - const [open, setOpen] = useState(false); - const [seen, setSeen] = useState<{ value: string; label: string } | null>( - null, - ); - - if (activeLabel && (seen?.value !== selected || seen.label !== activeLabel)) { - setSeen({ value: selected, label: activeLabel }); - } - - const label = - activeLabel ?? - (seen?.value === selected ? seen.label : undefined) ?? - facet.label; - - const choose = (value: string) => { - setOpen(false); - facet.onSearchChange?.(""); - onSelect(value); - }; - return ( - { - setOpen(next); - if (!next) facet.onSearchChange?.(""); - }} - > - - - - - - - - {facet.empty ?? "Nothing matches."} - - {facet.search?.trim() ? null : ( - choose("all")} - > - {facet.label} - - )} - {facet.options.map((option) => ( - + + {facet.label} + {selected.length > 0 && ( + ({selected.length}) + )} + + + {facet.searchable ? ( + + event.stopPropagation()} + /> + + {facet.empty ?? "Nothing matches."} + + {facet.options.map((option) => { + const checked = selected.includes(option.value); + return ( + + onChange(toggle(selected, option.value, !checked)) + } + > + + {option.label} + + ); + })} + + + + ) : ( +
+ {selected.length > 0 && ( + <> + onChange([])}> + Clear + + + + )} + {facet.options.map((option) => { + const checked = selected.includes(option.value); + return ( + choose(option.value)} + checked={checked} + onSelect={(event) => event.preventDefault()} + onCheckedChange={(next) => + onChange(toggle(selected, option.value, next)) + } > - {option.label} - - ))} - - - - - + {option.label} + + ); + })} +
+ )} +
+ ); } @@ -320,18 +325,30 @@ export function DataTable({ const pageSize = query.pageSize; const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const availableFacets = useMemo( + () => + (facets ?? []).filter( + (facet) => + facet.options.length > 0 || + facet.searchable || + (query.filters[facet.id]?.length ?? 0) > 0, + ), + [facets, query.filters], + ); + const hasFilterControls = tabs != null || - (facets?.length ?? 0) > 0 || + availableFacets.length > 0 || sortableColumns.length > 0 || anyExpandable || hideable.length > 0 || actions != null || leadingActions != null; + const activeFacetFilterCount = availableFacets.filter( + (facet) => (query.filters[facet.id]?.length ?? 0) > 0, + ).length; const activeFilterCount = - (tabs && query.tab !== "all" ? 1 : 0) + - (facets?.filter((facet) => (query.filters[facet.id] ?? "all") !== "all") - .length ?? 0); + (tabs && query.tab !== "all" ? 1 : 0) + activeFacetFilterCount; return (
@@ -439,48 +456,35 @@ export function DataTable({ {leadingActions}
- {facets?.map((facet) => { - const selected = query.filters[facet.id] ?? "all"; - const active = facet.options.find((o) => o.value === selected); - - if (facet.searchable) { - return ( - query.setFilter(facet.id, value)} - /> - ); - } - - return ( - - - - - - query.setFilter(facet.id, value)} - > - - {facet.label} - - {facet.options.map((option) => ( - - {option.label} - - ))} - - - - ); - })} + {availableFacets.length > 0 && ( + + + + + + {availableFacets.map((facet) => ( + query.setFilter(facet.id, values)} + /> + ))} + + + )} {(sortableColumns.length > 0 || anyExpandable) && ( diff --git a/packages/ui/src/components/icons/bot.tsx b/packages/ui/src/components/icons/bot.tsx new file mode 100644 index 000000000..1449efffb --- /dev/null +++ b/packages/ui/src/components/icons/bot.tsx @@ -0,0 +1,39 @@ +import type * as React from "react"; + +type BotProps = React.SVGProps & { + size?: number | string; +}; + +export default function Bot({ size = 16, ...rest }: BotProps) { + return ( + + + + + + ); +} diff --git a/packages/ui/src/lib/table-query.ts b/packages/ui/src/lib/table-query.ts index 3b5549eaf..af4c97ff2 100644 --- a/packages/ui/src/lib/table-query.ts +++ b/packages/ui/src/lib/table-query.ts @@ -7,11 +7,11 @@ export type TableQueryState = { pageSize: number; tab: string; tabId?: string; - filters: Record; + filters: Record; toggleSort: (id: string) => void; setSort: (id: string) => void; setDir: (dir: SortDirection) => void; - setPage: (page: number) => void; + setPage: (page: number) => Promise; setTab: (value: string) => void; - setFilter: (id: string, value: string) => void; + setFilter: (id: string, values: string[]) => void; }; diff --git a/packages/validation/package.json b/packages/validation/package.json index 0ab67f1f8..d6fb2c36a 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -11,7 +11,10 @@ "./builder-question": "./src/builder-question.ts", "./enrichment-queue": "./src/enrichment-queue.ts", "./eve-stream": "./src/eve-stream.ts", - "./eve-tool": "./src/eve-tool.ts" + "./eve-tool": "./src/eve-tool.ts", + "./field-backfill": "./src/field-backfill.ts", + "./field-templates": "./src/field-templates.ts", + "./saved-view": "./src/saved-view.ts" }, "scripts": { "check-types": "tsc --noEmit", diff --git a/packages/validation/src/field-backfill.ts b/packages/validation/src/field-backfill.ts new file mode 100644 index 000000000..55e8aaebd --- /dev/null +++ b/packages/validation/src/field-backfill.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const fieldBackfillPayload = z.object({ + entity: z.enum(["COMPANY", "CONTACT", "DEAL"]), + keys: z.array(z.string()).min(1), +}); + +export type FieldBackfillPayload = z.infer; diff --git a/packages/validation/src/field-templates.ts b/packages/validation/src/field-templates.ts new file mode 100644 index 000000000..52f15e623 --- /dev/null +++ b/packages/validation/src/field-templates.ts @@ -0,0 +1,39 @@ +export type FieldTemplate = { + label: string; + type: "SELECT" | "USER" | "NUMBER"; + options?: readonly string[]; +}; + +export const FIELD_TEMPLATES = { + COMPANY: [ + { + label: "Account type", + type: "SELECT", + options: ["Prospect", "Customer", "Partner", "Churned"], + }, + { + label: "Segment", + type: "SELECT", + options: ["Enterprise", "Mid-Market", "SMB"], + }, + { + label: "Territory", + type: "SELECT", + options: ["AMER", "EMEA", "APAC"], + }, + { + label: "Lifecycle stage", + type: "SELECT", + options: ["Lead", "MQL", "SQL", "Opportunity", "Customer"], + }, + { + label: "Lead source", + type: "SELECT", + options: ["Inbound", "Outbound", "Event"], + }, + { label: "ICP fit score", type: "NUMBER" }, + { label: "BDR owner", type: "USER" }, + ], + CONTACT: [], + DEAL: [], +} satisfies Record<"COMPANY" | "CONTACT" | "DEAL", readonly FieldTemplate[]>; diff --git a/packages/validation/src/saved-view.ts b/packages/validation/src/saved-view.ts new file mode 100644 index 000000000..f92a708e3 --- /dev/null +++ b/packages/validation/src/saved-view.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; +import { parse } from "./index"; + +export const savedViewFilters = z.object({ + q: z.string().default(""), + sort: z.string().default(""), + dir: z.enum(["asc", "desc"]).default("asc"), + archived: z.boolean().default(false), + filters: z.record(z.string(), z.array(z.string())).default({}), +}); + +export type SavedViewFilters = z.infer; + +export function parseSavedViewFilters(value: unknown): SavedViewFilters { + return parse(savedViewFilters, value, "saved view filters"); +} diff --git a/turbo.json b/turbo.json index bdfff23f4..110f9bd94 100644 --- a/turbo.json +++ b/turbo.json @@ -1,37 +1,7 @@ { "$schema": "https://turborepo.dev/schema.json", "ui": "tui", - "globalEnv": ["NODE_ENV"], - - /** - * Turborepo hides environment variables from tasks unless they are declared, - * and the failure is silent: the build succeeds having read `undefined`. - * - * That is not hypothetical here. `API_URL` was stripped from the Next.js - * build, so the browser bundle was compiled with the localhost fallback and - * every request from a deployed page went to the reader's own machine; and - * `DATABASE_URL` was stripped from the agent build, where `eve build` - * evaluates the authored modules and `@crm/db` throws without it. The - * `MICROSOFT_*` trio was the same omission again: undeclared, so - * `microsoftCredentials()` read nothing, `isMicrosoftConfigured()` was - * false, and the sign-in page dropped the Microsoft button with no error - * anywhere. A configured `MICROSOFT_TENANT_ID` was ignored the same way. - * - * `globalPassThroughEnv` is the right list for all of them, because none - * change what a build *produces* — they are read at runtime, or inlined from - * the separately-declared public copy — so they must not become part of the - * cache key. A secret in the cache key means a cache miss every rotation and - * a secret in the cache metadata. - * - * `TEST_DATABASE_URL` is here for the same reason and learned the same way. - * `NODE_ENV` is in `globalEnv`, so `bun test` sets it to `test` in every - * package, and `@crm/db` then demands the test database and refuses to fall - * back. Undeclared, it was stripped from every test task — so the suite went - * red on a machine that had the variable set, complaining it was not set. - * Locally it passed throughout, because `@crm/env/load` reads the root `.env` - * from disk and CI has no such file. - */ "globalPassThroughEnv": [ "DATABASE_URL", "DATABASE_URL_UNPOOLED", @@ -72,12 +42,6 @@ }, "build": { "dependsOn": ["^build"], - /** - * These two *are* cache keys, because they are inlined into the - * browser bundle at build time — the same source compiled against a - * different `API_URL` is a different artefact, and reusing a cached - * one would ship the wrong origin. - */ "env": ["API_URL", "APP_URL", "NEXT_PUBLIC_API_URL"], "inputs": ["$TURBO_DEFAULT$", ".env*"], "outputs": [ From 03a63c04d9a29e4681bb150db9efefee810f1e6f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:16:21 -0400 Subject: [PATCH 2/2] chore(main): release 1.15.0 (#164) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/.release-please-manifest.json | 2 +- CHANGELOG.md | 7 +++++++ package.json | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json index b57776035..8c47fd945 100644 --- a/.github/.release-please-manifest.json +++ b/.github/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.14.0" + ".": "1.15.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index d58f665f8..9157f76df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.15.0](https://github.com/trycompai/crm/compare/v1.14.0...v1.15.0) (2026-08-20) + + +### Features + +* **agent:** scope field backfill tasks to records missing values ([#163](https://github.com/trycompai/crm/issues/163)) ([3be7bbd](https://github.com/trycompai/crm/commit/3be7bbd69fe8b814ee2599a29b33e73f02a66245)) + ## [1.14.0](https://github.com/trycompai/crm/compare/v1.13.0...v1.14.0) (2026-08-18) diff --git a/package.json b/package.json index 680bfbd1d..c89609a18 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "crm", "private": true, "license": "MIT", - "version": "1.14.0", + "version": "1.15.0", "scripts": { "prepare": "git config core.hooksPath .githooks 2>/dev/null || true", "build": "turbo run build",