diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json index f325cbc23..e6da26c5a 100644 --- a/.github/.release-please-manifest.json +++ b/.github/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.15.2" + ".": "1.15.3" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 89936f101..538b3a9c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.15.3](https://github.com/trycompai/crm/compare/v1.15.2...v1.15.3) (2026-08-21) + + +### Fixes + +* **app:** prevent url param collision between fields sheet and table filter ([#175](https://github.com/trycompai/crm/issues/175)) ([1cebe1e](https://github.com/trycompai/crm/commit/1cebe1e5b3087007c4eac8b18add38ff9e8dd69b)) +* stop a finished enrichment reading as failed ([#173](https://github.com/trycompai/crm/issues/173)) ([2946580](https://github.com/trycompai/crm/commit/2946580afdd43419ce08165a0d55ba9d084c554e)) + ## [1.15.2](https://github.com/trycompai/crm/compare/v1.15.1...v1.15.2) (2026-08-20) diff --git a/apps/agent/agent/lib/brand.ts b/apps/agent/agent/lib/brand.ts index d9b30a3a7..fdd7740bd 100644 --- a/apps/agent/agent/lib/brand.ts +++ b/apps/agent/agent/lib/brand.ts @@ -2,6 +2,7 @@ import { db, EnrichmentStatus } from "@crm/db"; import { mirrorBrandImages } from "./brand-images"; import { brandToUpdate, filledFields, stillFillable } from "./brand-mapping"; import { brandByDomain, contextDevEnabled } from "./context-dev"; +import { UNLESS_COMPLETE } from "./enrichment"; export type BrandResult = { enriched: boolean; @@ -65,15 +66,20 @@ export async function runBrand({ } if (!company.domain) { - await settle(companyId, EnrichmentStatus.SKIPPED, "No domain to look up."); + await settle( + companyId, + EnrichmentStatus.SKIPPED, + "No domain to look up.", + UNLESS_COMPLETE, + ); return { enriched: false, reason: "No domain on this company." }; } const charge = spend(2); if (!charge.ok) return { enriched: false, reason: charge.reason }; - await db.company.update({ - where: { id: companyId }, + await db.company.updateMany({ + where: { id: companyId, ...UNLESS_COMPLETE }, data: { enrichmentStatus: EnrichmentStatus.RUNNING, enrichmentError: null, @@ -157,13 +163,18 @@ export function brandOutcome(result: BrandResult): string { return `Filled ${filled.join(", ")}.${mirrored.length > 0 ? ` Copied ${mirrored.length} image(s) in-house.` : ""}`; } +type SettleGuard = + | typeof UNLESS_COMPLETE + | { enrichmentStatus: EnrichmentStatus }; + async function settle( companyId: string, status: EnrichmentStatus, error: string, + guard: SettleGuard = { enrichmentStatus: EnrichmentStatus.RUNNING }, ): Promise { - await db.company.update({ - where: { id: companyId }, + await db.company.updateMany({ + where: { id: companyId, ...guard }, data: { enrichmentStatus: status, enrichmentError: error }, }); } diff --git a/apps/agent/agent/lib/enrichment.ts b/apps/agent/agent/lib/enrichment.ts index 06efad02d..5155f75e8 100644 --- a/apps/agent/agent/lib/enrichment.ts +++ b/apps/agent/agent/lib/enrichment.ts @@ -1,16 +1,46 @@ import { db, EnrichmentStatus, type Prisma } from "@crm/db"; +import { ownsCompanyStatus, ownsContactStatus } from "@crm/db/agent-tasks"; import type { TaskSubject } from "./tasks"; +type StatusGuard = + | EnrichmentStatus + | { not: EnrichmentStatus } + | { in: EnrichmentStatus[] }; + type SettleGuard = { - enrichmentStatus?: EnrichmentStatus; + enrichmentStatus?: StatusGuard; OR?: Array<{ enrichmentStatus: EnrichmentStatus; updatedAt?: { lt: Date }; }>; }; +type OwnedColumns = { + contactId: string | null; + companyId: string | null; +}; + +export const UNLESS_COMPLETE = { + enrichmentStatus: { not: EnrichmentStatus.COMPLETE }, +} as const; + +function ownedColumns(subject: TaskSubject): OwnedColumns { + return { + contactId: + subject.contactId && ownsContactStatus(subject.kind) + ? subject.contactId + : null, + companyId: + subject.companyId && ownsCompanyStatus(subject.kind) + ? subject.companyId + : null, + }; +} + export async function markRunning(subject: TaskSubject): Promise { - await write(subject, EnrichmentStatus.RUNNING, null, false); + await write(ownedColumns(subject), EnrichmentStatus.RUNNING, null, { + ...UNLESS_COMPLETE, + }); } export async function settle( @@ -18,16 +48,19 @@ export async function settle( status: EnrichmentStatus, error?: string, ): Promise { - await write(subject, status, error ?? null, true); + const owned = ownedColumns(subject); + if (!owned.contactId && !owned.companyId) return; + + await write(owned, status, error ?? null, await settleable(subject, status)); } async function write( - subject: TaskSubject, + owned: OwnedColumns, status: EnrichmentStatus, error: string | null, - onlyIfRunning: boolean, + guard: SettleGuard, ): Promise { - if (!subject.contactId && !subject.companyId) return; + if (!owned.contactId && !owned.companyId) return; const data = { enrichmentStatus: status, @@ -35,20 +68,16 @@ async function write( enrichedAt: status === EnrichmentStatus.COMPLETE ? new Date() : undefined, }; - const guard: SettleGuard = onlyIfRunning - ? await settleable(subject, status) - : {}; - - if (subject.contactId) { + if (owned.contactId) { await db.contact.updateMany({ - where: { id: subject.contactId, ...guard }, + where: { id: owned.contactId, ...guard }, data, }); } - if (subject.companyId) { + if (owned.companyId) { await db.company.updateMany({ - where: { id: subject.companyId, ...guard }, + where: { id: owned.companyId, ...guard }, data, }); } @@ -59,6 +88,30 @@ async function settleable( status: EnrichmentStatus, ): Promise { const running = { enrichmentStatus: EnrichmentStatus.RUNNING }; + + if (status === EnrichmentStatus.COMPLETE) { + const done = { + enrichmentStatus: { + in: [EnrichmentStatus.RUNNING, EnrichmentStatus.COMPLETE], + }, + }; + + const endedAt = await taskEndedAt(subject.id); + if (!endedAt) return done; + if (await hasOpenRequest(subject)) return done; + + return { + OR: [ + running, + { enrichmentStatus: EnrichmentStatus.COMPLETE }, + { + enrichmentStatus: EnrichmentStatus.PENDING, + updatedAt: { lt: endedAt }, + }, + ], + }; + } + if (status !== EnrichmentStatus.FAILED) return running; const endedAt = await taskEndedAt(subject.id); diff --git a/apps/agent/agent/lib/stale-tasks.ts b/apps/agent/agent/lib/stale-tasks.ts index e43dab470..c85d51fcc 100644 --- a/apps/agent/agent/lib/stale-tasks.ts +++ b/apps/agent/agent/lib/stale-tasks.ts @@ -1,5 +1,9 @@ import { db, EnrichmentStatus, type Prisma } from "@crm/db"; -import { isEnrichmentKind, MAX_ATTEMPTS } from "@crm/db/agent-tasks"; +import { + MAX_ATTEMPTS, + ownsCompanyStatus, + ownsContactStatus, +} from "@crm/db/agent-tasks"; import { DISPATCH } from "./dispatch-config"; import { settle } from "./enrichment"; import { retireExhausted, type TaskSubject } from "./tasks"; @@ -214,12 +218,16 @@ function finishedElsewhere( task: OpenTask, completed: Map, ): boolean { - if (!isEnrichmentKind(task.kind)) return false; if (task.attempts === 0 || task.startedAt === null) return false; const subjectId = task.contactId ?? task.companyId; if (!subjectId) return false; + const owns = task.contactId + ? ownsContactStatus(task.kind) + : ownsCompanyStatus(task.kind); + if (!owns) return false; + const enrichedAt = completed.get(subjectId); if (!enrichedAt) return false; diff --git a/apps/agent/test/close-task.integration.spec.ts b/apps/agent/test/close-task.integration.spec.ts index a92746df0..08ef60806 100644 --- a/apps/agent/test/close-task.integration.spec.ts +++ b/apps/agent/test/close-task.integration.spec.ts @@ -2,11 +2,15 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { db, EnrichmentStatus } from "@crm/db"; import { closeTask, taskToken } from "../agent/channels/crm"; -const kind = "test-close-task"; +const kind = "identify"; const email = `close-task-${crypto.randomUUID()}@example.test`; +const taskIds: string[] = []; + async function clear() { - await db.agentTask.deleteMany({ where: { kind } }); + if (taskIds.length > 0) { + await db.agentTask.deleteMany({ where: { id: { in: taskIds.splice(0) } } }); + } await db.contact.deleteMany({ where: { email } }); } @@ -36,6 +40,8 @@ async function running() { select: { id: true }, }); + taskIds.push(task.id); + return { contactId: contact.id, taskId: task.id }; } diff --git a/apps/agent/test/enrichment.integration.spec.ts b/apps/agent/test/enrichment.integration.spec.ts index 06eec5a0f..3db33938d 100644 --- a/apps/agent/test/enrichment.integration.spec.ts +++ b/apps/agent/test/enrichment.integration.spec.ts @@ -35,7 +35,7 @@ async function contact() { function subjectOf(ids: { contactId?: string; companyId?: string }) { return { id: "task", - kind: "test", + kind: ids.contactId ? "identify" : "brand", contactId: ids.contactId ?? null, companyId: ids.companyId ?? null, }; @@ -258,4 +258,182 @@ describe("the record follows the task", () => { await settle(subject, EnrichmentStatus.COMPLETE); }); + + it("leaves a company the brand task already finished alone", async () => { + const org = await company(); + const brand = subjectOf({ companyId: org.id }); + + await markRunning(brand); + await settle(brand, EnrichmentStatus.COMPLETE); + + const profile = { + ...brand, + id: "company-profile", + kind: "company-profile", + }; + await markRunning(profile); + + const row = await db.company.findUnique({ + where: { id: org.id }, + select: { enrichmentStatus: true, enrichedAt: true }, + }); + expect(row?.enrichmentStatus).toBe("COMPLETE"); + expect(row?.enrichedAt).not.toBeNull(); + }); + + it("still stamps the hour a recheck finished on a complete contact", async () => { + const person = await contact(); + const identify = subjectOf({ contactId: person.id }); + + await markRunning(identify); + await settle(identify, EnrichmentStatus.COMPLETE); + + const first = await db.contact.findUniqueOrThrow({ + where: { id: person.id }, + select: { enrichedAt: true }, + }); + + const recheck = { ...identify, id: "recheck", kind: "recheck" }; + await settle(recheck, EnrichmentStatus.COMPLETE); + + const second = await db.contact.findUniqueOrThrow({ + where: { id: person.id }, + select: { enrichmentStatus: true, enrichedAt: true }, + }); + expect(second.enrichmentStatus).toBe("COMPLETE"); + expect(second.enrichedAt?.getTime()).toBeGreaterThanOrEqual( + first.enrichedAt?.getTime() ?? 0, + ); + }); + + it("lets a finished run land on the fresh look that asked for it", async () => { + const person = await contact(); + + const row = await db.contact.findUniqueOrThrow({ + where: { id: person.id }, + select: { updatedAt: true }, + }); + + const task = await db.agentTask.create({ + data: { + contactId: person.id, + kind: "identify", + reason: "lifecycle", + attempts: 1, + dueAt: row.updatedAt, + finishedAt: new Date(row.updatedAt.getTime() + 1), + }, + select: { id: true }, + }); + + await settle( + { ...subjectOf({ contactId: person.id }), id: task.id }, + EnrichmentStatus.COMPLETE, + ); + + const done = await statusOfContact(person.id); + expect(done?.enrichmentStatus).toBe("COMPLETE"); + expect(done?.enrichedAt).not.toBeNull(); + }); + + it("leaves a fresh look to the newer task a late run cannot satisfy", async () => { + const person = await contact(); + + const row = await db.contact.findUniqueOrThrow({ + where: { id: person.id }, + select: { updatedAt: true }, + }); + + const ended = await db.agentTask.create({ + data: { + contactId: person.id, + kind: "identify", + reason: "lifecycle", + attempts: 1, + dueAt: row.updatedAt, + finishedAt: new Date(row.updatedAt.getTime() + 1), + }, + select: { id: true }, + }); + + await db.agentTask.create({ + data: { + contactId: person.id, + kind: "recheck", + reason: "lifecycle", + dueAt: new Date(), + }, + select: { id: true }, + }); + + await settle( + { ...subjectOf({ contactId: person.id }), id: ended.id }, + EnrichmentStatus.COMPLETE, + ); + + expect((await statusOfContact(person.id))?.enrichmentStatus).toBe( + "PENDING", + ); + }); + + it("never lets a company-profile session touch the company column", async () => { + const org = await company(); + const brand = subjectOf({ companyId: org.id }); + + await markRunning(brand); + + const profile = { + ...brand, + id: "company-profile", + kind: "company-profile", + }; + await settle(profile, EnrichmentStatus.FAILED, "the agent turn failed"); + + const row = await db.company.findUnique({ + where: { id: org.id }, + select: { enrichmentStatus: true, enrichmentError: true }, + }); + expect(row?.enrichmentStatus).toBe("RUNNING"); + expect(row?.enrichmentError).toBeNull(); + }); + + it("never lets a portrait timeout mark a person failed", async () => { + const person = await contact(); + const identify = subjectOf({ contactId: person.id }); + + await markRunning(identify); + + const portrait = { ...identify, id: "portrait", kind: "portrait" }; + await settle(portrait, EnrichmentStatus.FAILED, "the picture timed out"); + + const row = await db.contact.findUnique({ + where: { id: person.id }, + select: { enrichmentStatus: true, enrichmentError: true }, + }); + expect(row?.enrichmentStatus).toBe("RUNNING"); + expect(row?.enrichmentError).toBeNull(); + }); + + it("keeps that company complete when the later session fails", async () => { + const org = await company(); + const brand = subjectOf({ companyId: org.id }); + + await markRunning(brand); + await settle(brand, EnrichmentStatus.COMPLETE); + + const profile = { + ...brand, + id: "company-profile", + kind: "company-profile", + }; + await markRunning(profile); + await settle(profile, EnrichmentStatus.FAILED, "the agent turn failed"); + + const row = await db.company.findUnique({ + where: { id: org.id }, + select: { enrichmentStatus: true, enrichmentError: true }, + }); + expect(row?.enrichmentStatus).toBe("COMPLETE"); + expect(row?.enrichmentError).toBeNull(); + }); }); diff --git a/apps/agent/test/keyless-brand.integration.spec.ts b/apps/agent/test/keyless-brand.integration.spec.ts index 7b2902b35..d6487b6b9 100644 --- a/apps/agent/test/keyless-brand.integration.spec.ts +++ b/apps/agent/test/keyless-brand.integration.spec.ts @@ -1,5 +1,7 @@ -import { afterEach, describe, expect, it } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; import { db, EnrichmentStatus } from "@crm/db"; +import { readContextDevKey, writeContextDevKey } from "@crm/db/settings"; +import { runBrand } from "../agent/lib/brand"; import { settle } from "../agent/lib/enrichment"; /** @@ -132,3 +134,48 @@ describe("a brand task with no key", () => { expect(await statusOf(id)).toBe(EnrichmentStatus.COMPLETE); }); }); + +async function domainlessCompany(status: EnrichmentStatus) { + const row = await db.company.create({ + data: { + name: `Keyless Probe ${created.length}`, + enrichmentStatus: status, + }, + select: { id: true }, + }); + + created.push(row.id); + return row.id; +} + +describe("a brand task on a company with no domain", () => { + let key: string | null; + + beforeAll(async () => { + key = await readContextDevKey(db); + }); + + afterAll(async () => { + await writeContextDevKey(db, key ?? ""); + }); + + it("marks the company skipped, because no sweep will find it again", async () => { + await writeContextDevKey(db, "ctx-test-key"); + const id = await domainlessCompany(EnrichmentStatus.PENDING); + + const result = await runBrand({ companyId: id }); + + expect(result.enriched).toBe(false); + expect(await statusOf(id)).toBe(EnrichmentStatus.SKIPPED); + }); + + it("still leaves a keyless install's company pending for the sweep", async () => { + await writeContextDevKey(db, ""); + const id = await domainlessCompany(EnrichmentStatus.PENDING); + + const result = await runBrand({ companyId: id }); + + expect(result.enriched).toBe(false); + expect(await statusOf(id)).toBe(EnrichmentStatus.PENDING); + }); +}); diff --git a/apps/agent/test/stale-tasks.integration.spec.ts b/apps/agent/test/stale-tasks.integration.spec.ts index 4bf530ae0..adc78db18 100644 --- a/apps/agent/test/stale-tasks.integration.spec.ts +++ b/apps/agent/test/stale-tasks.integration.spec.ts @@ -52,7 +52,7 @@ async function queue(overrides: { }) { return db.agentTask.create({ data: { - kind: overrides.kind ?? kind, + kind: overrides.kind ?? (overrides.companyId ? "brand" : kind), reason: REASON, dueAt: overrides.dueAt ?? new Date(Date.now() - MINUTE_MS), priority: 0, @@ -168,6 +168,48 @@ describe("a task whose record is already done", () => { expect(closed?.outcome).toContain("already up to date"); }); + it("keeps research work on a company the brand task just finished", async () => { + const account = await anAccount( + EnrichmentStatus.COMPLETE, + new Date(Date.now() - 29 * MINUTE_MS), + ); + const task = await queue({ + kind: "company-profile", + companyId: account.id, + attempts: 1, + startedAt: new Date(Date.now() - 30 * MINUTE_MS), + leasedUntil: new Date(Date.now() - MINUTE_MS), + }); + + const sweep = await reconcileStaleTasks(); + expect(sweep.released).toBeGreaterThanOrEqual(1); + + const kept = await row(task.id); + expect(kept?.finishedAt).toBeNull(); + expect(kept?.leasedUntil).toBeNull(); + }); + + it("keeps meeting prep for a contact another task just enriched", async () => { + const contact = await someone( + EnrichmentStatus.COMPLETE, + new Date(Date.now() - 29 * MINUTE_MS), + ); + const task = await queue({ + kind: "meeting-prep", + contactId: contact.id, + attempts: 1, + startedAt: new Date(Date.now() - 30 * MINUTE_MS), + leasedUntil: new Date(Date.now() - MINUTE_MS), + }); + + const sweep = await reconcileStaleTasks(); + expect(sweep.released).toBeGreaterThanOrEqual(1); + + const kept = await row(task.id); + expect(kept?.finishedAt).toBeNull(); + expect(kept?.leasedUntil).toBeNull(); + }); + it("keeps event work that names a record the agent just enriched", async () => { const account = await anAccount( EnrichmentStatus.COMPLETE, diff --git a/apps/api/src/agent/agent-trigger.service.ts b/apps/api/src/agent/agent-trigger.service.ts index 4ef47b0fe..3b2143302 100644 --- a/apps/api/src/agent/agent-trigger.service.ts +++ b/apps/api/src/agent/agent-trigger.service.ts @@ -71,22 +71,30 @@ export class AgentTriggerService { }); } - async companyRequested(companyId: string, reason: string): Promise { - await this.enqueue({ - companyId, - kind: "brand", - reason, - priority: PRIORITY.brand, - budget: 2, - }); + async companyRequested(companyId: string, reason: string): Promise { + const brand = await this.enqueue( + { + companyId, + kind: "brand", + reason, + priority: PRIORITY.brand, + budget: 2, + }, + true, + ); - await this.enqueue({ - companyId, - kind: "company-profile", - reason, - priority: PRIORITY.requested, - budget: 8, - }); + const profile = await this.enqueue( + { + companyId, + kind: "company-profile", + reason, + priority: PRIORITY.requested, + budget: 8, + }, + true, + ); + + return brand || profile; } async workspaceChanged(website: string, reason: string): Promise { @@ -98,14 +106,21 @@ export class AgentTriggerService { }); } - async contactCreated(contactId: string, reason: string): Promise { - await this.enqueue({ - contactId, - kind: "identify", - reason, - priority: PRIORITY.identify, - budget: 4, - }); + async contactCreated( + contactId: string, + reason: string, + required = false, + ): Promise { + return this.enqueue( + { + contactId, + kind: "identify", + reason, + priority: PRIORITY.identify, + budget: 4, + }, + required, + ); } async slackPeopleRequested(reason: string, required = false): Promise { diff --git a/apps/api/src/companies/companies.contracts.ts b/apps/api/src/companies/companies.contracts.ts index d90faf426..d2157334e 100644 --- a/apps/api/src/companies/companies.contracts.ts +++ b/apps/api/src/companies/companies.contracts.ts @@ -248,7 +248,7 @@ export const companyEnrichOutput = z.object({ export const companyResearchOutput = z.object({ ok: z.literal(true), - queued: z.literal(true), + queued: z.boolean(), }); export const companySetPrimaryContactOutput = z.object({ diff --git a/apps/api/src/companies/companies.service.ts b/apps/api/src/companies/companies.service.ts index e9ec9d890..3960dc33c 100644 --- a/apps/api/src/companies/companies.service.ts +++ b/apps/api/src/companies/companies.service.ts @@ -554,20 +554,26 @@ export class CompaniesService { async enrich(id: string): Promise<{ id: string; queued: boolean }> { const company = await this.db.company.findUnique({ where: { id }, - select: { id: true }, + select: { id: true, updatedAt: true }, }); if (!company) { throw new NotFoundException(`No company with id ${id}.`); } - await this.db.company.update({ - where: { id }, - data: { enrichmentStatus: "PENDING", enrichmentError: null }, - }); - await this.agent.companyRequested(id, "A rep asked for a fresh look"); + const queued = await this.agent.companyRequested( + id, + "A rep asked for a fresh look", + ); + + if (queued) { + await this.db.company.updateMany({ + where: { id, updatedAt: company.updatedAt }, + data: { enrichmentStatus: "PENDING", enrichmentError: null }, + }); + } - return { id, queued: true }; + return { id, queued }; } async research(id: string, actingUserId: string) { @@ -586,12 +592,12 @@ export class CompaniesService { ); } - await this.agent.companyRequested( + const queued = await this.agent.companyRequested( id, `Briefing requested by a rep (${actingUserId})`, ); - return { ok: true as const, queued: true as const }; + return { ok: true as const, queued }; } async setPrimaryContact(companyId: string, contactId: string | null) { diff --git a/apps/api/src/contacts/contacts.contracts.ts b/apps/api/src/contacts/contacts.contracts.ts index c0d81ff82..78d0aeb17 100644 --- a/apps/api/src/contacts/contacts.contracts.ts +++ b/apps/api/src/contacts/contacts.contracts.ts @@ -272,7 +272,7 @@ export const contactNameOutput = z.object({ export const contactEnrichOutput = z.object({ id: z.string(), - queued: z.literal(true), + queued: z.boolean(), }); export const bulkResultOutput = z.object({ diff --git a/apps/api/src/contacts/contacts.service.ts b/apps/api/src/contacts/contacts.service.ts index 3e1565f4a..043fd4b78 100644 --- a/apps/api/src/contacts/contacts.service.ts +++ b/apps/api/src/contacts/contacts.service.ts @@ -671,29 +671,32 @@ export class ContactsService { }; } - async enrich(id: string): Promise<{ id: string; queued: true }> { + async enrich(id: string): Promise<{ id: string; queued: boolean }> { const contact = await this.db.contact.findUnique({ where: { id }, - select: { id: true, imageUrl: true, linkedinUrl: true }, + select: { id: true, imageUrl: true, linkedinUrl: true, updatedAt: true }, }); if (!contact) { throw new NotFoundException(`No contact with id ${id}.`); } - await this.db.contact.update({ - where: { id }, - data: { enrichmentStatus: "PENDING", enrichmentError: null }, - }); - - await this.agent.contactCreated( + const queued = await this.agent.contactCreated( id, contact.linkedinUrl && !contact.imageUrl ? "A rep asked for a fresh look — they have a LinkedIn profile on file but no picture" : "A rep asked for a fresh look", + true, ); - return { id, queued: true }; + if (queued) { + await this.db.contact.updateMany({ + where: { id, updatedAt: contact.updatedAt }, + data: { enrichmentStatus: "PENDING", enrichmentError: null }, + }); + } + + return { id, queued }; } async decideFact( diff --git a/apps/api/test/bulk.spec.ts b/apps/api/test/bulk.spec.ts index 3b8871836..9a0315188 100644 --- a/apps/api/test/bulk.spec.ts +++ b/apps/api/test/bulk.spec.ts @@ -19,9 +19,9 @@ const secondOwnerId = `second-owner-${suffix}`; const ours = { OR: [{ email: { endsWith: `@${domain}` } }] }; const agent = { - contactCreated: async () => undefined, + contactCreated: async () => true, companyCreated: async () => undefined, - companyRequested: async () => undefined, + companyRequested: async () => true, withCrmEvents: withDiscardedCrmEvents, } as unknown as AgentTriggerService; diff --git a/apps/api/test/company-requested.spec.ts b/apps/api/test/company-requested.spec.ts new file mode 100644 index 000000000..138a1d36d --- /dev/null +++ b/apps/api/test/company-requested.spec.ts @@ -0,0 +1,54 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { db } from "@crm/db"; +import { AgentTriggerService } from "../src/agent/agent-trigger.service"; + +const suffix = process.env.TEST_RUN_ID ?? "company-requested-spec"; +const name = `Requested Co ${suffix}`; +const reason = `A rep asked for a fresh look (${suffix})`; + +const agent = new AgentTriggerService(db); + +let companyId: string; +let bridgeSecret: string | undefined; + +async function clean() { + if (companyId) await db.agentTask.deleteMany({ where: { companyId } }); + await db.company.deleteMany({ where: { name } }); +} + +beforeAll(async () => { + bridgeSecret = process.env.AGENT_BRIDGE_SECRET; + process.env.AGENT_BRIDGE_SECRET = ""; + + await db.company.deleteMany({ where: { name } }); + const company = await db.company.create({ + data: { name, domain: `requested-${suffix}.test`.toLowerCase() }, + select: { id: true }, + }); + companyId = company.id; +}); + +afterAll(async () => { + await clean(); + + if (bridgeSecret === undefined) { + delete process.env.AGENT_BRIDGE_SECRET; + } else { + process.env.AGENT_BRIDGE_SECRET = bridgeSecret; + } +}); + +describe("asking for a fresh look", () => { + it("says what it actually queued", async () => { + expect(await agent.companyRequested(companyId, reason)).toBe(true); + + expect(await agent.companyRequested(companyId, reason)).toBe(false); + + await db.agentTask.updateMany({ + where: { companyId, reason, finishedAt: null }, + data: { finishedAt: new Date(), outcome: "done" }, + }); + + expect(await agent.companyRequested(companyId, reason)).toBe(true); + }); +}); diff --git a/apps/api/test/fields.spec.ts b/apps/api/test/fields.spec.ts index 73e83a1d1..acecd1376 100644 --- a/apps/api/test/fields.spec.ts +++ b/apps/api/test/fields.spec.ts @@ -31,9 +31,9 @@ const queued: { }[] = []; const agent = { - contactCreated: async () => undefined, + contactCreated: async () => true, companyCreated: async () => undefined, - companyRequested: async () => undefined, + companyRequested: async () => true, withCrmEvents: withDiscardedCrmEvents, fieldBackfillRecords: async ( entity: FieldEntity, diff --git a/apps/api/test/mailbox-thread-writer.spec.ts b/apps/api/test/mailbox-thread-writer.spec.ts index 92ba96b9b..da6b34862 100644 --- a/apps/api/test/mailbox-thread-writer.spec.ts +++ b/apps/api/test/mailbox-thread-writer.spec.ts @@ -20,10 +20,10 @@ const rootId = ``; const movedRoot = `outlook-conversation:${suffix}`; const agent = { - contactCreated: async () => undefined, + contactCreated: async () => true, companyCreated: async () => undefined, withCrmEvents: withDiscardedCrmEvents, - companyRequested: async () => undefined, + companyRequested: async () => true, } as unknown as AgentTriggerService; const stamp = new ActivityStampService(db); diff --git a/apps/api/test/record-delete.spec.ts b/apps/api/test/record-delete.spec.ts index 244920a70..cac084ab1 100644 --- a/apps/api/test/record-delete.spec.ts +++ b/apps/api/test/record-delete.spec.ts @@ -25,10 +25,10 @@ const userId = `user-${suffix}`; const stamp = new ActivityStampService(db); const agent = { - contactCreated: async () => undefined, + contactCreated: async () => true, companyCreated: async () => undefined, withCrmEvents: withDiscardedCrmEvents, - companyRequested: async () => undefined, + companyRequested: async () => true, } as unknown as AgentTriggerService; const directory = new CompanyDirectoryService(agent); diff --git a/apps/api/test/tracking-filing.integration.spec.ts b/apps/api/test/tracking-filing.integration.spec.ts index 2c152cb7d..f9da07670 100644 --- a/apps/api/test/tracking-filing.integration.spec.ts +++ b/apps/api/test/tracking-filing.integration.spec.ts @@ -28,9 +28,10 @@ const queued: string[] = []; const agent = { contactCreated: async (id: string) => { queued.push(id); + return true; }, companyCreated: async () => undefined, - companyRequested: async () => undefined, + companyRequested: async () => true, withCrmEvents: withDiscardedCrmEvents, } as unknown as AgentTriggerService; diff --git a/apps/app/app/(app)/[slug]/companies/create-company-sheet.tsx b/apps/app/app/(app)/[slug]/companies/create-company-sheet.tsx index 1040c8ea0..59405fad7 100644 --- a/apps/app/app/(app)/[slug]/companies/create-company-sheet.tsx +++ b/apps/app/app/(app)/[slug]/companies/create-company-sheet.tsx @@ -33,6 +33,7 @@ import { parseAsBoolean, useQueryState } from "nuqs"; import { type ComponentProps, Suspense, useId, useState } from "react"; import { toast } from "sonner"; import { useOpenRecord } from "@/components/crm/record-sheet/record-stack"; +import { SEARCH_PARAM } from "@/lib/search-param-keys"; import { useCrmCache } from "@/lib/trpc/cache"; import { useTRPC } from "@/lib/trpc/client"; @@ -61,7 +62,7 @@ function CreateCompanyForm() { const cache = useCrmCache(); const [open, setOpen] = useQueryState( - "new", + SEARCH_PARAM.dialog.create, parseAsBoolean.withDefault(false), ); const [name, setName] = useState(""); diff --git a/apps/app/app/(app)/[slug]/contacts/create-contact-sheet.tsx b/apps/app/app/(app)/[slug]/contacts/create-contact-sheet.tsx index 1494143e4..adbe905d0 100644 --- a/apps/app/app/(app)/[slug]/contacts/create-contact-sheet.tsx +++ b/apps/app/app/(app)/[slug]/contacts/create-contact-sheet.tsx @@ -29,6 +29,7 @@ import { type ComponentProps, Suspense, useId, useState } from "react"; import { toast } from "sonner"; import { CompanyPicker } from "@/components/crm/company-picker"; import { useOpenRecord } from "@/components/crm/record-sheet/record-stack"; +import { SEARCH_PARAM } from "@/lib/search-param-keys"; import { useCrmCache } from "@/lib/trpc/cache"; import { useTRPC } from "@/lib/trpc/client"; @@ -57,7 +58,7 @@ function CreateContactForm({ companyId }: { companyId?: string }) { const cache = useCrmCache(); const [open, setOpen] = useQueryState( - "new", + SEARCH_PARAM.dialog.create, parseAsBoolean.withDefault(false), ); const [firstName, setFirstName] = useState(""); diff --git a/apps/app/app/(app)/[slug]/dashboard-summary.tsx b/apps/app/app/(app)/[slug]/dashboard-summary.tsx index f1d5138ae..a14e0428c 100644 --- a/apps/app/app/(app)/[slug]/dashboard-summary.tsx +++ b/apps/app/app/(app)/[slug]/dashboard-summary.tsx @@ -37,6 +37,7 @@ import { useOpenRecord } from "@/components/crm/record-sheet/record-stack"; import { LocalRelativeTime } from "@/components/local-date-time"; import { activityLabel } from "@/lib/activity-presentation"; import { dealStageColor } from "@/lib/deal-stage"; +import { SEARCH_PARAM } from "@/lib/search-param-keys"; import { useCrmCache } from "@/lib/trpc/cache"; import { useTRPC } from "@/lib/trpc/client"; import { useWorkspaceUrl } from "@/lib/use-workspace-url"; @@ -94,7 +95,10 @@ export function DashboardSummary() { const openRecord = useOpenRecord(); const workspaceUrl = useWorkspaceUrl(); - const [scope] = useQueryState("scope", overviewParsers.scope); + const [scope] = useQueryState( + SEARCH_PARAM.overview.scope, + overviewParsers[SEARCH_PARAM.overview.scope], + ); const summaryQuery = useQuery({ ...trpc.dashboard.summary.queryOptions({ scope }), diff --git a/apps/app/app/(app)/[slug]/deals/create-deal-sheet.tsx b/apps/app/app/(app)/[slug]/deals/create-deal-sheet.tsx index 6d3e57b85..fcdd96fe8 100644 --- a/apps/app/app/(app)/[slug]/deals/create-deal-sheet.tsx +++ b/apps/app/app/(app)/[slug]/deals/create-deal-sheet.tsx @@ -37,6 +37,7 @@ import { toast } from "sonner"; import { CompanyPicker } from "@/components/crm/company-picker"; import { useOpenRecord } from "@/components/crm/record-sheet/record-stack"; import { dealStageLabel, OPEN_STAGES } from "@/lib/deal-stage"; +import { SEARCH_PARAM } from "@/lib/search-param-keys"; import { useCrmCache } from "@/lib/trpc/cache"; import { useTRPC } from "@/lib/trpc/client"; @@ -65,7 +66,7 @@ function CreateDealForm({ companyId }: { companyId?: string }) { const cache = useCrmCache(); const [open, setOpen] = useQueryState( - "new", + SEARCH_PARAM.dialog.create, parseAsBoolean.withDefault(false), ); const [name, setName] = useState(""); diff --git a/apps/app/app/(app)/[slug]/overview-greeting.tsx b/apps/app/app/(app)/[slug]/overview-greeting.tsx index a8a1f06af..ff92369ed 100644 --- a/apps/app/app/(app)/[slug]/overview-greeting.tsx +++ b/apps/app/app/(app)/[slug]/overview-greeting.tsx @@ -2,6 +2,7 @@ import { useQueryState } from "nuqs"; import { PageShellDescription, PageShellTitle } from "@/components/page-shell"; +import { SEARCH_PARAM } from "@/lib/search-param-keys"; import { overviewParsers } from "./overview-search-params"; export function OverviewGreetingFallback() { @@ -16,7 +17,10 @@ export function OverviewGreetingFallback() { } export function OverviewGreeting() { - const [scope] = useQueryState("scope", overviewParsers.scope); + const [scope] = useQueryState( + SEARCH_PARAM.overview.scope, + overviewParsers[SEARCH_PARAM.overview.scope], + ); return ( <> diff --git a/apps/app/app/(app)/[slug]/overview-scope.tsx b/apps/app/app/(app)/[slug]/overview-scope.tsx index dcc0632c2..e1993fd2c 100644 --- a/apps/app/app/(app)/[slug]/overview-scope.tsx +++ b/apps/app/app/(app)/[slug]/overview-scope.tsx @@ -2,6 +2,7 @@ import { ToggleGroup, ToggleGroupItem } from "@crm/ui/components/toggle-group"; import { useQueryState } from "nuqs"; +import { SEARCH_PARAM } from "@/lib/search-param-keys"; import { OVERVIEW_SCOPES, type OverviewScope, @@ -37,7 +38,10 @@ export function OverviewScopeToggleFallback() { } export function OverviewScopeToggle() { - const [scope, setScope] = useQueryState("scope", overviewParsers.scope); + const [scope, setScope] = useQueryState( + SEARCH_PARAM.overview.scope, + overviewParsers[SEARCH_PARAM.overview.scope], + ); return ( { + onSuccess: async (result) => { await cache.activity(); - toast.success("Brief added to the timeline."); + toast.success( + result.queued + ? "Researching — the brief lands on the timeline when it finishes." + : "Already researching.", + ); }, onError: (error) => toast.error(error.message), }), @@ -82,10 +86,12 @@ export function ContactEnrichmentAction({ contactId }: { contactId: string }) { const enrich = useMutation( trpc.contacts.enrich.mutationOptions({ - onSuccess: async () => { + onSuccess: async (result) => { await cache.contact(contactId); toast.success( - "Taking another look — this page will update when it finishes.", + result.queued + ? "Taking another look — this page will update when it finishes." + : "Already running.", ); }, onError: (error) => toast.error(error.message), diff --git a/apps/app/components/crm/quick-switcher.tsx b/apps/app/components/crm/quick-switcher.tsx index 6c1c6037d..2fb31bc24 100644 --- a/apps/app/components/crm/quick-switcher.tsx +++ b/apps/app/components/crm/quick-switcher.tsx @@ -18,6 +18,7 @@ import { useQuery } from "@tanstack/react-query"; import { parseAsBoolean, useQueryState } from "nuqs"; import { useEffect, useState } from "react"; import { useOpenRecord } from "@/components/crm/record-sheet/record-stack"; +import { SEARCH_PARAM } from "@/lib/search-param-keys"; import { useTRPC } from "@/lib/trpc/client"; const GROUP_LABEL = { @@ -32,7 +33,10 @@ export function QuickSwitcher() { const openRecord = useOpenRecord(); const trpc = useTRPC(); - const [open, setOpen] = useQueryState("k", parseAsBoolean.withDefault(false)); + const [open, setOpen] = useQueryState( + SEARCH_PARAM.dialog.switcher, + parseAsBoolean.withDefault(false), + ); const [query, setQuery] = useState(""); useEffect(() => { diff --git a/apps/app/components/crm/record-sheet/record-stack.ts b/apps/app/components/crm/record-sheet/record-stack.ts index 31dcaff3d..d33244434 100644 --- a/apps/app/components/crm/record-sheet/record-stack.ts +++ b/apps/app/components/crm/record-sheet/record-stack.ts @@ -7,10 +7,8 @@ import { useQueryStates, } from "nuqs"; import { useCallback, useMemo } from "react"; -import { - TIMELINE_PARAM, - timelineTabParser, -} from "@/components/crm/timeline/timeline-search-params"; +import { timelineTabParser } from "@/components/crm/timeline/timeline-search-params"; +import { SEARCH_PARAM } from "@/lib/search-param-keys"; const RECORD_KINDS = ["company", "contact", "deal"] as const; @@ -28,13 +26,15 @@ const FORM_TAB = { } satisfies Record; const params = { - record: parseAsArrayOf(parseAsString, ",").withDefault([]), - tab: parseAsString, - add: parseAsStringLiteral(RECORD_FORMS), - thread: parseAsString, - fields: parseAsStringLiteral(RECORD_KINDS), - field: parseAsString, - [TIMELINE_PARAM]: timelineTabParser, + [SEARCH_PARAM.record.stack]: parseAsArrayOf(parseAsString, ",").withDefault( + [], + ), + [SEARCH_PARAM.record.tab]: parseAsString, + [SEARCH_PARAM.record.add]: parseAsStringLiteral(RECORD_FORMS), + [SEARCH_PARAM.record.thread]: parseAsString, + [SEARCH_PARAM.fieldsSheet.entity]: parseAsStringLiteral(RECORD_KINDS), + [SEARCH_PARAM.fieldsSheet.field]: parseAsString, + [SEARCH_PARAM.record.timeline]: timelineTabParser, }; export function recordKey(ref: RecordRef): string { @@ -51,7 +51,8 @@ function parseRef(raw: string): RecordRef | null { } export function useRecordStack() { - const [{ record }, setParams] = useQueryStates(params); + const [values, setParams] = useQueryStates(params); + const record = values[SEARCH_PARAM.record.stack]; const stack = useMemo( () => record.map(parseRef).filter((ref): ref is RecordRef => ref !== null), @@ -62,13 +63,14 @@ export function useRecordStack() { (next: RecordRef[], history: "push" | "replace") => { void setParams( { - record: next.length === 0 ? null : next.map(recordKey), - tab: null, - add: null, - thread: null, - fields: null, - field: null, - [TIMELINE_PARAM]: null, + [SEARCH_PARAM.record.stack]: + next.length === 0 ? null : next.map(recordKey), + [SEARCH_PARAM.record.tab]: null, + [SEARCH_PARAM.record.add]: null, + [SEARCH_PARAM.record.thread]: null, + [SEARCH_PARAM.fieldsSheet.entity]: null, + [SEARCH_PARAM.fieldsSheet.field]: null, + [SEARCH_PARAM.record.timeline]: null, }, { history }, ); @@ -108,50 +110,66 @@ export function useOpenRecord() { } export function useFieldsSheet() { - const [{ fields, field }, setParams] = useQueryStates(params); + const [values, setParams] = useQueryStates(params); + const entity = values[SEARCH_PARAM.fieldsSheet.entity]; + const field = values[SEARCH_PARAM.fieldsSheet.field]; const open = useCallback( - (kind: RecordKind) => void setParams({ fields: kind, field: null }), + (kind: RecordKind) => + void setParams({ + [SEARCH_PARAM.fieldsSheet.entity]: kind, + [SEARCH_PARAM.fieldsSheet.field]: null, + }), [setParams], ); const close = useCallback( - () => void setParams({ fields: null, field: null }), + () => + void setParams({ + [SEARCH_PARAM.fieldsSheet.entity]: null, + [SEARCH_PARAM.fieldsSheet.field]: null, + }), [setParams], ); const edit = useCallback( - (key: string | null) => void setParams({ field: key }), + (key: string | null) => + void setParams({ [SEARCH_PARAM.fieldsSheet.field]: key }), [setParams], ); - return { entity: fields, field, open, close, edit }; + return { entity, field, open, close, edit }; } export function useRecordSheetView(fallbackTab: string) { - const [{ tab, add, thread }, setParams] = useQueryStates(params); + const [values, setParams] = useQueryStates(params); + const tab = values[SEARCH_PARAM.record.tab]; + const add = values[SEARCH_PARAM.record.add]; + const thread = values[SEARCH_PARAM.record.thread]; const active = add ? FORM_TAB[add] : (tab ?? fallbackTab); const setTab = useCallback( (next: string) => { void setParams({ - tab: next === fallbackTab ? null : next, - add: null, - thread: null, - [TIMELINE_PARAM]: null, + [SEARCH_PARAM.record.tab]: next === fallbackTab ? null : next, + [SEARCH_PARAM.record.add]: null, + [SEARCH_PARAM.record.thread]: null, + [SEARCH_PARAM.record.timeline]: null, }); }, [setParams, fallbackTab], ); const setForm = useCallback( - (next: RecordForm | null) => void setParams({ add: next }), + (next: RecordForm | null) => + void setParams({ [SEARCH_PARAM.record.add]: next }), [setParams], ); const setThread = useCallback( - (next: string | null) => void setParams({ thread: next }), + (next: string | null) => + void setParams({ [SEARCH_PARAM.record.thread]: next }), [setParams], ); diff --git a/apps/app/components/crm/stage-change.tsx b/apps/app/components/crm/stage-change.tsx index e7a580d5a..4c4dc590e 100644 --- a/apps/app/components/crm/stage-change.tsx +++ b/apps/app/components/crm/stage-change.tsx @@ -27,13 +27,14 @@ import { parseAsString, useQueryStates } from "nuqs"; import { useId, useState } from "react"; import { toast } from "sonner"; import { DEAL_STAGE_OPTIONS, LOSING_STAGES } from "@/lib/deal-stage"; +import { SEARCH_PARAM } from "@/lib/search-param-keys"; import { useCrmCache } from "@/lib/trpc/cache"; import { useTRPC } from "@/lib/trpc/client"; import { DealStageIndicator } from "./deal-stage"; const closeReasonParams = { - closing: parseAsString, - closingStage: parseAsString, + [SEARCH_PARAM.dialog.closeDeal]: parseAsString, + [SEARCH_PARAM.dialog.closeStage]: parseAsString, }; function useStageMutation(onDone?: () => void) { @@ -99,8 +100,8 @@ export function DealStageMenu({ if (chosen === stage) return; if (LOSING_STAGES.includes(chosen)) { void setCloseParams({ - closing: dealId, - closingStage: chosen, + [SEARCH_PARAM.dialog.closeDeal]: dealId, + [SEARCH_PARAM.dialog.closeStage]: chosen, }); return; } @@ -120,13 +121,17 @@ export function DealStageMenu({ export function CloseReasonDialog() { const reasonId = useId(); - const [{ closing, closingStage }, setCloseParams] = - useQueryStates(closeReasonParams); + const [closeValues, setCloseParams] = useQueryStates(closeReasonParams); + const closing = closeValues[SEARCH_PARAM.dialog.closeDeal]; + const closingStage = closeValues[SEARCH_PARAM.dialog.closeStage]; const [reason, setReason] = useState(""); const close = () => { setReason(""); - void setCloseParams({ closing: null, closingStage: null }); + void setCloseParams({ + [SEARCH_PARAM.dialog.closeDeal]: null, + [SEARCH_PARAM.dialog.closeStage]: null, + }); }; const setStage = useStageMutation(() => { diff --git a/apps/app/components/crm/timeline/timeline-search-params.ts b/apps/app/components/crm/timeline/timeline-search-params.ts index 0fbe4e48a..d31e56cc6 100644 --- a/apps/app/components/crm/timeline/timeline-search-params.ts +++ b/apps/app/components/crm/timeline/timeline-search-params.ts @@ -11,8 +11,6 @@ export const TIMELINE_TABS = [ export type TimelineTab = (typeof TIMELINE_TABS)[number]; -export const TIMELINE_PARAM = "timeline"; - export const timelineTabParser = parseAsStringLiteral(TIMELINE_TABS).withDefault("all"); diff --git a/apps/app/components/crm/timeline/timeline.tsx b/apps/app/components/crm/timeline/timeline.tsx index e84d12c81..a19a11ebd 100644 --- a/apps/app/components/crm/timeline/timeline.tsx +++ b/apps/app/components/crm/timeline/timeline.tsx @@ -14,13 +14,13 @@ import { cn } from "@crm/ui/lib/utils"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useQueryState } from "nuqs"; import { DetailSheetEmpty, SECTION_TITLE } from "@/components/detail-sheet"; +import { SEARCH_PARAM } from "@/lib/search-param-keys"; import { useTRPC } from "@/lib/trpc/client"; import { useHydrated } from "@/lib/use-hydrated"; import { ActivityComposer } from "./activity-composer"; import { TimelineEntry, type TimelineEntryData } from "./timeline-entry"; import { historyFilter, - TIMELINE_PARAM, TIMELINE_TABS, type TimelineTab, timelineTabParser, @@ -158,7 +158,10 @@ export function Timeline({ anchor }: { anchor: TimelineAnchor }) { const trpc = useTRPC(); const hydrated = useHydrated(); - const [tab, setTab] = useQueryState(TIMELINE_PARAM, timelineTabParser); + const [tab, setTab] = useQueryState( + SEARCH_PARAM.record.timeline, + timelineTabParser, + ); const counts = useQuery(trpc.activities.timelineCounts.queryOptions(anchor)); diff --git a/apps/app/components/data-table/list-search-params.ts b/apps/app/components/data-table/list-search-params.ts index b0fd45921..d5a899f19 100644 --- a/apps/app/components/data-table/list-search-params.ts +++ b/apps/app/components/data-table/list-search-params.ts @@ -12,6 +12,10 @@ import { parseAsStringLiteral, } from "nuqs/server"; import { z } from "zod"; +import { + assertUnreservedSearchParamKeys, + SEARCH_PARAM, +} from "@/lib/search-param-keys"; const SORT_DIRECTIONS = ["asc", "desc"] as const; @@ -23,10 +27,14 @@ 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), + [SEARCH_PARAM.list.q]: parseAsString.withDefault(""), + [SEARCH_PARAM.list.page]: parseAsInteger + .withDefault(1) + .withOptions({ history: "push" }), + [SEARCH_PARAM.list.fields]: parseAsJson( + fieldFiltersSchema.parse, + ).withDefault({}), + [SEARCH_PARAM.list.archived]: parseAsBoolean.withDefault(false), }; type ListParsers = { @@ -91,6 +99,11 @@ export function createListSearchParams< facetDefaults, } = config; + assertUnreservedSearchParamKeys( + [...(tabId ? [tabId] : []), ...facetIds], + "createListSearchParams", + ); + const tabExtras: Record = {}; if (tabId) tabExtras[tabId] = parseAsString.withDefault("all"); @@ -103,8 +116,9 @@ export function createListSearchParams< const parsers = { ...searchParsers, - sort: parseAsString.withDefault(defaultSort), - dir: parseAsStringLiteral(SORT_DIRECTIONS).withDefault(defaultDir), + [SEARCH_PARAM.list.sort]: parseAsString.withDefault(defaultSort), + [SEARCH_PARAM.list.dir]: + parseAsStringLiteral(SORT_DIRECTIONS).withDefault(defaultDir), ...tabExtras, ...facetExtras, } as ListParsers; diff --git a/apps/app/lib/search-param-keys.test.ts b/apps/app/lib/search-param-keys.test.ts new file mode 100644 index 000000000..123a040d4 --- /dev/null +++ b/apps/app/lib/search-param-keys.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "bun:test"; +import { companiesSearchParams } from "@/app/(app)/[slug]/companies/companies-search-params"; +import { contactsSearchParams } from "@/app/(app)/[slug]/contacts/contacts-search-params"; +import { dealsSearchParams } from "@/app/(app)/[slug]/deals/deals-search-params"; +import { membersSearchParams } from "@/app/(app)/[slug]/settings/members/members-search-params"; +import { + assertUnreservedSearchParamKeys, + RESERVED_SEARCH_PARAM_KEYS, + SEARCH_PARAM, +} from "./search-param-keys"; + +const registeredKeys = Object.values(SEARCH_PARAM).flatMap((group) => + Object.values(group), +); + +describe("SEARCH_PARAM", () => { + it("gives every feature its own url key", () => { + expect(registeredKeys.length).toBe(RESERVED_SEARCH_PARAM_KEYS.size); + }); + + it("keeps the fields sheet off the table's fields filter", () => { + expect(SEARCH_PARAM.fieldsSheet.entity).not.toBe(SEARCH_PARAM.list.fields); + }); +}); + +describe("assertUnreservedSearchParamKeys", () => { + it("accepts keys no other feature owns", () => { + expect(() => + assertUnreservedSearchParamKeys(["owner", "industry"], "test"), + ).not.toThrow(); + }); + + it("rejects a facet that shadows a reserved key", () => { + expect(() => + assertUnreservedSearchParamKeys( + ["owner", SEARCH_PARAM.dialog.closeDeal], + "test", + ), + ).toThrow(/closeDeal/); + }); + + it("rejects a facet that shadows a list key", () => { + expect(() => assertUnreservedSearchParamKeys(["q"], "test")).toThrow(/q/); + }); +}); + +describe("list tables", () => { + it("builds every table without a key collision", () => { + for (const table of [ + companiesSearchParams, + contactsSearchParams, + dealsSearchParams, + membersSearchParams, + ]) { + expect(Object.keys(table.parsers)).toContain(SEARCH_PARAM.list.fields); + } + }); +}); diff --git a/apps/app/lib/search-param-keys.ts b/apps/app/lib/search-param-keys.ts new file mode 100644 index 000000000..db9d288fc --- /dev/null +++ b/apps/app/lib/search-param-keys.ts @@ -0,0 +1,45 @@ +export const SEARCH_PARAM = { + list: { + q: "q", + sort: "sort", + dir: "dir", + page: "page", + fields: "fields", + archived: "archived", + }, + record: { + stack: "record", + tab: "tab", + add: "add", + thread: "thread", + timeline: "timeline", + }, + fieldsSheet: { + entity: "manageFields", + field: "manageField", + }, + dialog: { + create: "new", + switcher: "k", + closeDeal: "closeDeal", + closeStage: "closeStage", + }, + overview: { + scope: "scope", + }, +} as const; + +export const RESERVED_SEARCH_PARAM_KEYS: ReadonlySet = new Set( + Object.values(SEARCH_PARAM).flatMap((group) => Object.values(group)), +); + +export function assertUnreservedSearchParamKeys( + keys: readonly string[], + owner: string, +): void { + const clashes = keys.filter((key) => RESERVED_SEARCH_PARAM_KEYS.has(key)); + if (clashes.length === 0) return; + throw new Error( + `[${owner}] search param keys already belong to another feature: ${clashes.join(", ")}. Two parsers on one key corrupt each other. Rename the key or add it to SEARCH_PARAM in lib/search-param-keys.ts.`, + ); +} diff --git a/docs/plan/dynamic-fields-build.md b/docs/plan/dynamic-fields-build.md index d50b11742..2d708e0ff 100644 --- a/docs/plan/dynamic-fields-build.md +++ b/docs/plan/dynamic-fields-build.md @@ -41,11 +41,11 @@ Paper file **CRM**, page **crm - lewis**: | `/companies?record=company:abcd` | Cog in the DETAILS header; custom fields inline; a pending agent suggestion | | `/contacts?record=contact:abcd` | Cog placement only | | `/deals?record=deal:abcd` | Cog placement only | -| `…&fields=company` | The fields sheet — list state | -| `…&fields=company (first run)` | Empty state | -| `…&fields=company&field=new` | Create a field | -| `…&fields=company&field=runs_on` | Edit a field, with coverage | -| `…&fields=company&field=runs_on (archive)` | Archive confirmation | +| `…&manageFields=company` | The fields sheet — list state | +| `…&manageFields=company (first run)` | Empty state | +| `…&manageFields=company&manageField=new` | Create a field | +| `…&manageFields=company&manageField=runs_on` | Edit a field, with coverage | +| `…&manageFields=company&manageField=runs_on (archive)` | Archive confirmation | **Take values from the file, not from screenshots.** `get_jsx`, `get_computed_styles`, `get_node_info`. A screenshot will not tell you whether a diff --git a/docs/plan/dynamic-fields.md b/docs/plan/dynamic-fields.md index 40b1f5849..3fbc73b19 100644 --- a/docs/plan/dynamic-fields.md +++ b/docs/plan/dynamic-fields.md @@ -4,7 +4,7 @@ Fields a workspace defines for itself, on companies, contacts and deals, edited from one sheet that opens from any record. The visual design is in Paper, file **CRM**, page **crm - lewis**, artboards -`Dynamic fields — cog placement`, `/companies?record=company:abcd&fields=company`, +`Dynamic fields — cog placement`, `/companies?record=company:abcd&manageFields=company`, `Dynamic fields — new field & empty` and `Dynamic fields — on the record`. This document is the half Paper cannot hold: the model, the API and the agent. @@ -39,7 +39,7 @@ deal can still fix a contact field without closing anything. State lives in the URL beside `record`, in `record-stack.ts`: ``` -/companies?record=company:abcd&fields=company +/companies?record=company:abcd&manageFields=company ``` so it is shareable, Escape and Back close it in the right order, and the record diff --git a/docs/setup.md b/docs/setup.md index 28c1410d3..03b8912f7 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -148,3 +148,30 @@ never reuse one from an example, a tutorial, or another environment. bun run --filter=api test bun run --filter=agent test # integration specs need DATABASE_URL + real Postgres ``` + +### The test database rebuilds itself when it drifts + +`bun run db:test` creates `crm_test` and runs `migrate deploy` on it. The database +name must end in `_test`; the suite deletes rows it expects to put back, so it +refuses anything else. + +**`migrate deploy` only applies migrations that are missing. It never removes a +table, a column or a constraint the database has and the schema does not.** A +`crm_test` built on a branch that was later abandoned therefore keeps that branch's +objects forever, and `db:test` used to report `already exists` and move on. The +extra objects are invisible until one of them rejects a write, and then the failure +names a constraint that appears in no migration and in no schema — a stray +`trackedEvent_visitorId_fkey` once failed seven tracking specs this way, on every +branch, for as long as the database survived. + +So `db:test` now checks the database it found and rebuilds it when either is true: + +- **It holds a migration this branch does not have.** The database came from + another branch. The name of the first one is printed. +- **It no longer matches `schema.prisma`**, by `prisma migrate diff`. Something + was pushed or altered by hand. + +A rebuild drops the database and re-runs every migration, and it says which of the +two reasons fired. Force one with `bun run db:test --reset`. Nothing else in the +repo may drop a database, and this may only because the `_test` suffix is checked +first. diff --git a/package.json b/package.json index 9ec54c190..534ec9d00 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "crm", "private": true, "license": "MIT", - "version": "1.15.2", + "version": "1.15.3", "scripts": { "prepare": "git config core.hooksPath .githooks 2>/dev/null || true", "build": "turbo run build", diff --git a/packages/db/scripts/test-db.ts b/packages/db/scripts/test-db.ts index e132c65da..7deb36e5f 100644 --- a/packages/db/scripts/test-db.ts +++ b/packages/db/scripts/test-db.ts @@ -1,6 +1,11 @@ import { spawnSync } from "node:child_process"; +import { existsSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; import pg from "pg"; +const SCHEMA = join(dirname(import.meta.dirname), "prisma", "schema.prisma"); +const MIGRATIONS = join(dirname(import.meta.dirname), "prisma", "migrations"); + const url = resolve(); if (!url) { @@ -20,7 +25,7 @@ if (!name.endsWith("_test")) { ]); } -await create(url, name); +await create(url, name, process.argv.includes("--reset")); migrate(url); if (!process.env.TEST_DATABASE_URL) { @@ -35,7 +40,11 @@ if (!process.env.TEST_DATABASE_URL) { ); } -async function create(target: string, database: string): Promise { +async function create( + target: string, + database: string, + forced: boolean, +): Promise { const maintenance = new URL(target); maintenance.pathname = "/postgres"; maintenance.search = ""; @@ -60,8 +69,17 @@ async function create(target: string, database: string): Promise { ); if (existing.rowCount) { - console.log(` ${database} already exists`); - return; + const reason = forced + ? "you asked for --reset" + : await stale(target, database); + + if (!reason) { + console.log(` ${database} already exists`); + return; + } + + console.log(` rebuilding ${database}: ${reason}`); + await drop(client, database); } await client.query(`CREATE DATABASE "${database}"`); @@ -71,12 +89,92 @@ async function create(target: string, database: string): Promise { } } +async function drop(client: pg.Client, database: string): Promise { + await client.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database], + ); + await client.query(`DROP DATABASE IF EXISTS "${database}"`); +} + +async function stale(target: string, database: string): Promise { + const applied = await appliedMigrations(target); + + if (applied === null) return null; + + const onDisk = new Set( + existsSync(MIGRATIONS) + ? readdirSync(MIGRATIONS, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + : [], + ); + + const foreign = applied.filter((migration) => !onDisk.has(migration)); + + if (foreign.length > 0) { + return `${database} holds ${foreign.length} migration(s) this branch does not have, starting with ${foreign[0]}`; + } + + return drifted(target) ? `${database} no longer matches schema.prisma` : null; +} + +async function appliedMigrations(target: string): Promise { + const client = new pg.Client({ connectionString: target }); + + try { + await client.connect(); + } catch { + return null; + } + + try { + const rows = await client.query<{ migration_name: string }>( + `SELECT migration_name FROM _prisma_migrations WHERE finished_at IS NOT NULL`, + ); + return rows.rows.map((row) => row.migration_name); + } catch { + return null; + } finally { + await client.end(); + } +} + +function drifted(target: string): boolean { + const result = spawnSync( + "prisma", + [ + "migrate", + "diff", + "--from-config-datasource", + "--to-schema", + SCHEMA, + "--exit-code", + ], + { stdio: "ignore", env: { ...process.env, DATABASE_URL: target } }, + ); + + return result.status === 2; +} + function migrate(target: string): void { const result = spawnSync("prisma", ["migrate", "deploy"], { stdio: "inherit", env: { ...process.env, DATABASE_URL: target }, }); + if (result.error) { + fail([ + "Could not run prisma migrate deploy.", + "Run this through the package script, which puts prisma on PATH:", + "", + " bun run db:test", + "", + result.error.message, + ]); + } + if (result.status !== 0) process.exit(result.status ?? 1); } diff --git a/packages/db/src/agent-tasks.ts b/packages/db/src/agent-tasks.ts index 4f65eec67..c7137ccff 100644 --- a/packages/db/src/agent-tasks.ts +++ b/packages/db/src/agent-tasks.ts @@ -29,20 +29,16 @@ export function isDirectKind(kind: string): kind is DirectKind { return (DIRECT_KINDS as readonly string[]).includes(kind); } -export const ENRICHMENT_KINDS = [ - "brand", - "portrait", - "identify", - "profile", - "recheck", - "company-profile", - "workspace-profile", -] as const; +export const CONTACT_STATUS_KINDS = ["identify", "profile", "recheck"] as const; -export type EnrichmentKind = (typeof ENRICHMENT_KINDS)[number]; +export const COMPANY_STATUS_KINDS = ["brand"] as const; + +export function ownsContactStatus(kind: string): boolean { + return (CONTACT_STATUS_KINDS as readonly string[]).includes(kind); +} -export function isEnrichmentKind(kind: string): kind is EnrichmentKind { - return (ENRICHMENT_KINDS as readonly string[]).includes(kind); +export function ownsCompanyStatus(kind: string): boolean { + return (COMPANY_STATUS_KINDS as readonly string[]).includes(kind); } export const MAX_ATTEMPTS = 3;