diff --git a/.env.example b/.env.example index 9dd784cdd..a643dd52c 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,13 @@ BETTER_AUTH_SECRET="" # Subdomains count, so "acme.com" also admits "you@mail.acme.com". ALLOWED_SIGN_IN="" +# Google is the sign-in method a clone starts with, and the same client is what +# reads Gmail and Calendar. Set both or neither — half a pair is a sign-in +# button that fails at Google. +# +# Leave them empty only if you sign in with your own identity provider, added +# on Settings → SSO. Then there is no Google button and no mail sync, and the +# sign-in page says as much rather than showing you nothing. GOOGLE_CLIENT_ID="" GOOGLE_CLIENT_SECRET="" @@ -40,7 +47,9 @@ GOOGLE_CLIENT_SECRET="" # session cookie covers both. # AUTH_COOKIE_DOMAIN="" -# The research agent, which is its own deployment. +# The research agent, which is its own deployment. The API reads this too, to +# tell the agent a logo or a photograph is waiting rather than letting it find +# out on its next minute. # AGENT_URL="http://127.0.0.1:2000" # Lets a signed-in rep talk to the agent from the contact sheet. @@ -50,8 +59,12 @@ GOOGLE_CLIENT_SECRET="" # secret; the agent verifies it and learns *which rep* is asking. Set the same # value for both processes. openssl rand -base64 32 # -# Leave it unset and the Agent tab reports that it is not configured. Nothing -# else changes: the agent still runs on its own schedule. +# It also authorises the API's dispatch poke, which is what makes a new +# company's logo appear as it is added instead of on the next minute's tick. +# +# Leave it unset and the Agent tab reports that it is not configured, and the +# poke is skipped rather than sent unauthenticated. Nothing else changes: the +# agent still runs on its own schedule. # AGENT_BRIDGE_SECRET="" # PORT="3001" diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json new file mode 100644 index 000000000..1c56072d6 --- /dev/null +++ b/.github/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.0" +} diff --git a/.github/release-please-config.json b/.github/release-please-config.json new file mode 100644 index 000000000..69e301b4a --- /dev/null +++ b/.github/release-please-config.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "node", + "changelog-path": "CHANGELOG.md", + "bump-minor-pre-major": true, + "draft": false, + "prerelease": false, + "include-component-in-tag": false, + "changelog-sections": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Fixes" }, + { "type": "perf", "section": "Performance" }, + { "type": "refactor", "section": "Refactors" }, + { "type": "docs", "section": "Documentation" }, + { "type": "revert", "section": "Reverts" }, + { "type": "deps", "section": "Dependencies" }, + { "type": "chore", "hidden": true }, + { "type": "test", "hidden": true }, + { "type": "ci", "hidden": true }, + { "type": "build", "hidden": true }, + { "type": "style", "hidden": true } + ] + } + }, + "bootstrap-sha": "64f154b086cbad6cac232357dc21c954543ef217", + "separate-pull-requests": false, + "pull-request-title-pattern": "chore(release): ${version}", + "pull-request-header": "The changelog below is what will be published as the release notes. Edit the commit subjects, not this PR body — the body is regenerated on every push to main." +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..8df2dd798 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +env: + TURBO_TELEMETRY_DISABLED: 1 + DO_NOT_TRACK: 1 + +jobs: + check: + name: check-types, lint, test + runs-on: ubuntu-24.04 + timeout-minutes: 20 + + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: crm + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d crm" + --health-interval 3s + --health-timeout 5s + --health-retries 20 + + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/crm?schema=public + ALLOWED_SIGN_IN: example.com + BETTER_AUTH_SECRET: ci-only-secret-regenerate-for-any-real-deployment + API_URL: http://localhost:3001 + APP_URL: http://localhost:3000 + GOOGLE_CLIENT_ID: ci-only-google-client-id + GOOGLE_CLIENT_SECRET: ci-only-google-client-secret + + steps: + - uses: actions/checkout@v5 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - run: bun install --frozen-lockfile + + - name: Apply migrations + run: bun run db:deploy + + - run: bun run check-types + + - run: bun run lint + + - run: bun run test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..8390b3661 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,29 @@ +name: Release + +on: + push: + branches: [main] + +concurrency: + group: release + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + outputs: + released: ${{ steps.release.outputs.release_created }} + tag: ${{ steps.release.outputs.tag_name }} + version: ${{ steps.release.outputs.version }} + steps: + - uses: googleapis/release-please-action@v4 + id: release + with: + config-file: .github/release-please-config.json + manifest-file: .github/.release-please-manifest.json + token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index 5b87af21f..9d102bf60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,9 @@ You should always check and see if there are any relevant skill files you should Please check below, if you're working on anything related review the rules and let the user know you've read them: +## Code Comments +Do not add code comments to the code you write, ever. + ## Design Read @docs/design.md @@ -31,11 +34,3 @@ what it does — and if the API reads it, declare it in Anything a self-hoster might not have is optional, and the code must work without it: a missing key removes a capability, it never throws. See `apps/agent/agent/lib/capabilities.ts` for the pattern. - -## Contributing / licence - -This repository is public and MIT-licensed. Before writing anything that ships: -no real customer names, addresses or company data in fixtures, tests, -screenshots or docs — the seed in `packages/db/prisma/seed.ts` is the source of -demo data. See @CONTRIBUTING.md and @SECURITY.md. - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5256c291d..5e80daff5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,6 +51,43 @@ A few things that trip people up: to `apps/api/src/config/env.validation.ts`. A variable that only exists in someone's shell is a variable that breaks the next person's clone. +## Releases + +Releases are cut by [release-please](https://github.com/googleapis/release-please), so **your commit +subject is the release note**. Write it for somebody reading the changelog six months from now, not +for the diff. + +Subjects follow [Conventional Commits](https://www.conventionalcommits.org/), which the history +already does — `feat(api):`, `fix(db):`, `refactor(agent):`. The type decides both the version bump +and the heading it appears under: + +| Subject | Bump | Appears under | +| --- | --- | --- | +| `feat(app): …` | minor | Features | +| `fix(db): …` | patch | Fixes | +| `perf:`, `refactor:`, `docs:`, `revert:` | patch | their own heading | +| `chore:`, `ci:`, `test:`, `build:`, `style:` | none | nothing — deliberately invisible | + +Nothing is released by the merge itself. Merging to `main` opens or updates a single +`chore(release): 0.2.0` pull request that accumulates the changelog and bumps the version; **merging +that PR** is what tags `v0.2.0` and publishes the GitHub Release. So the notes are reviewable before +they are public, and a stack of merges is one release rather than five. + +Two consequences worth knowing: + +- **A release PR with nothing in it is not a bug.** A run of `chore:` and `test:` commits bumps + nothing, so no PR appears. That is the type doing its job. +- **The release PR does not run CI.** A PR opened by `GITHUB_TOKEN` cannot trigger workflows — that + is GitHub's own loop guard, not something to work around. It is safe because the PR only ever + touches `CHANGELOG.md`, the root `version`, and the release manifest, and because CI runs again on + `main` after it lands. Setting a `RELEASE_PLEASE_TOKEN` secret (a PAT or a GitHub App token) makes + the PR run CI like any other; the workflow already prefers it and falls back to `GITHUB_TOKEN`, so + it is an upgrade rather than a requirement. + +`main` is expected to be green when a tag is cut, and the thing that guarantees that is **branch +protection requiring the `check-types, lint, test` check** — not the release workflow, which cannot +wait on a run in another workflow. + ## House style The repo has opinions, and they're written down where the work happens rather than in a style diff --git a/apps/agent/agent/channels/crm.ts b/apps/agent/agent/channels/crm.ts index 27872f10b..90eba837f 100644 --- a/apps/agent/agent/channels/crm.ts +++ b/apps/agent/agent/channels/crm.ts @@ -1,24 +1,50 @@ import { EnrichmentStatus } from "@crm/db"; import { defineChannel, POST } from "eve/channels"; +import { brief, drainAll, taskAuth } from "../lib/dispatch"; import { settle } from "../lib/enrichment"; import { completeTask, taskSubject } from "../lib/tasks"; -function taskToken(taskId: string): string { - return `crm:task:${taskId}`; +const TASK_MARKER = "task:"; + +function authorised(request: Request): boolean { + const secret = process.env.AGENT_BRIDGE_SECRET?.trim(); + if (!secret) return false; + + return request.headers.get("authorization") === `Bearer ${secret}`; +} + +export function taskToken(taskId: string): string { + return `${TASK_MARKER}${taskId}`; } -function taskFromToken(token: string | undefined): string | null { +export function taskFromToken(token: string | undefined): string | null { if (!token) return null; - const prefix = "crm:task:"; - return token.startsWith(prefix) ? token.slice(prefix.length) : null; + + const marker = token.lastIndexOf(TASK_MARKER); + if (marker === -1) return null; + + const id = token.slice(marker + TASK_MARKER.length); + return id.length > 0 ? id : null; } export default defineChannel({ routes: [ - POST( - "/internal/crm/dispatch-only", - async () => new Response("Not found", { status: 404 }), - ), + POST("/internal/crm/dispatch", async (request, { send, waitUntil }) => { + if (!authorised(request)) { + return new Response("Unauthorized", { status: 401 }); + } + + waitUntil( + drainAll((task) => + send(brief(task), { + auth: taskAuth(task), + continuationToken: taskToken(task.id), + }), + ), + ); + + return new Response(null, { status: 202 }); + }), ], events: { diff --git a/apps/agent/agent/hooks/activity.ts b/apps/agent/agent/hooks/activity.ts new file mode 100644 index 000000000..ac6e5105a --- /dev/null +++ b/apps/agent/agent/hooks/activity.ts @@ -0,0 +1,173 @@ +import { defineHook, type HookEvent } from "eve/hooks"; + +type ActionRequest = HookEvent<"actions.requested">["data"]["actions"][number]; +type ActionResult = HookEvent<"action.result">["data"]["result"]; + +const SHOW_CONTENT = process.env.NODE_ENV !== "production"; +const MAX_IN_FLIGHT = 256; + +const inFlight = new Map(); + +function truncate(text: string, limit: number): string { + return text.length > limit ? `${text.slice(0, limit - 1)}…` : text; +} + +function line(symbol: string, text: string): void { + console.error(`[agent] ${symbol} ${text}`); +} + +function preview(input: unknown): string { + if (!SHOW_CONTENT || typeof input !== "object" || input === null) return ""; + + const parts: string[] = []; + + for (const [key, value] of Object.entries(input)) { + if (value === null || value === undefined) continue; + const text = typeof value === "string" ? value : JSON.stringify(value); + parts.push(`${key}=${truncate(text ?? String(value), 48)}`); + } + + return truncate(parts.join(" "), 160); +} + +function requestName(action: ActionRequest): string { + switch (action.kind) { + case "tool-call": + return action.toolName; + case "subagent-call": + return `subagent ${action.subagentName}`; + case "remote-agent-call": + return `remote ${action.remoteAgentName}`; + case "load-skill": + return "load_skill"; + } +} + +function resultName(result: ActionResult): string { + switch (result.kind) { + case "tool-result": + return result.toolName; + case "subagent-result": + return `subagent ${result.subagentName}`; + case "load-skill-result": + return result.name ? `skill ${result.name}` : "load_skill"; + } +} + +function count(tokens: number): string { + return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens); +} + +export default defineHook({ + events: { + "session.started"(event, ctx) { + const on = ctx.channel.kind ? ` on ${ctx.channel.kind}` : ""; + const as = event.data.invocation?.name; + + line( + "▸", + `session ${ctx.session.id}${on}${as ? ` as subagent ${as}` : ""}`, + ); + }, + + "message.received"(event) { + line( + "»", + SHOW_CONTENT + ? truncate(event.data.message, 200) + : `${event.data.message.length} chars`, + ); + }, + + "actions.requested"(event) { + for (const action of event.data.actions) { + if (inFlight.size >= MAX_IN_FLIGHT) { + const oldest = inFlight.keys().next(); + if (!oldest.done) inFlight.delete(oldest.value); + } + + const name = requestName(action); + + inFlight.set(action.callId, { name, at: Date.now() }); + line("→", `${name} ${preview(action.input)}`.trimEnd()); + } + }, + + "action.result"(event) { + const { error, result, status } = event.data; + const call = inFlight.get(result.callId); + + inFlight.delete(result.callId); + + const name = call?.name ?? resultName(result); + const took = call + ? ` ${((Date.now() - call.at) / 1000).toFixed(1)}s` + : ""; + + if (status === "completed") { + line("✓", `${name}${took}`); + return; + } + + const why = error ? `: ${truncate(error.message, 140)}` : ""; + line("✗", `${name}${took} ${status}${why}`); + }, + + "message.completed"(event) { + const reply = event.data.message ?? ""; + + line( + "◂", + SHOW_CONTENT + ? truncate(reply, 300) + : `replied ${reply.length} chars (${event.data.finishReason})`, + ); + }, + + "step.completed"(event) { + const usage = event.data.usage; + const spend = usage + ? [ + usage.inputTokens === undefined + ? null + : `in ${count(usage.inputTokens)}`, + usage.outputTokens === undefined + ? null + : `out ${count(usage.outputTokens)}`, + usage.cacheReadTokens + ? `cached ${count(usage.cacheReadTokens)}` + : null, + usage.costUsd === undefined ? null : `$${usage.costUsd.toFixed(4)}`, + ] + .filter(Boolean) + .join(" ") + : ""; + + line( + "·", + `step ${event.data.stepIndex} ${event.data.finishReason}${spend ? ` ${spend}` : ""}`, + ); + }, + + "step.failed"(event) { + line( + "⨯", + `step ${event.data.stepIndex} ${event.data.code}: ${truncate(event.data.message, 200)}`, + ); + }, + + "turn.failed"(event) { + line( + "⨯", + `turn ${event.data.code}: ${truncate(event.data.message, 200)}`, + ); + }, + + "session.failed"(event) { + line( + "⨯", + `session ${event.data.code}: ${truncate(event.data.message, 200)}`, + ); + }, + }, +}); diff --git a/apps/agent/agent/instructions.md b/apps/agent/agent/instructions.md index 38ae3c64d..4aa0ab641 100644 --- a/apps/agent/agent/instructions.md +++ b/apps/agent/agent/instructions.md @@ -42,6 +42,11 @@ and give you its id. Read that record before anything else: All three are free — our own database, no vendor, no budget — and they are the best evidence in the system besides. +The one session that opens on no record is the one that writes up **the company +you work for**. Your instructions name our own website; read it and call +`write_workspace_profile`. Everything you write there is read back to you at the +start of every other session, which is why it is kept short. + ## The three records are joined, and so are your tools A contact works somewhere. A company has people and deals. A deal has a company diff --git a/apps/agent/agent/lib/app-auth.ts b/apps/agent/agent/lib/app-auth.ts new file mode 100644 index 000000000..f76bef31c --- /dev/null +++ b/apps/agent/agent/lib/app-auth.ts @@ -0,0 +1,13 @@ +export const APP_AUTH = { + attributes: {}, + authenticator: "app", + principalId: "eve:app", + principalType: "runtime", +} as const; + +export type AppAuth = { + attributes: Readonly>; + authenticator: string; + principalId: string; + principalType: string; +}; diff --git a/apps/agent/agent/lib/approval.ts b/apps/agent/agent/lib/approval.ts index 4fb05c6a8..04f9707c1 100644 --- a/apps/agent/agent/lib/approval.ts +++ b/apps/agent/agent/lib/approval.ts @@ -1,4 +1,5 @@ import type { Approval } from "eve/tools"; +import { APP_AUTH } from "./app-auth"; export function isAutomated(session: { auth: { @@ -11,9 +12,9 @@ export function isAutomated(session: { }): boolean { const auth = session.auth.current; return ( - auth?.authenticator === "app" && - auth.principalId === "eve:app" && - auth.principalType === "runtime" + auth?.authenticator === APP_AUTH.authenticator && + auth.principalId === APP_AUTH.principalId && + auth.principalType === APP_AUTH.principalType ); } diff --git a/apps/agent/agent/lib/brand-mapping.ts b/apps/agent/agent/lib/brand-mapping.ts index 7c3f426c6..1d6994d0e 100644 --- a/apps/agent/agent/lib/brand-mapping.ts +++ b/apps/agent/agent/lib/brand-mapping.ts @@ -98,6 +98,12 @@ function clean(value: string | null | undefined): string | null { return trimmed ? trimmed : null; } +function fillable(key: string, current: CompanySnapshot): boolean { + if (key === "iconUrl") return true; + if (key === "name") return current.nameIsPlaceholder; + return current[key as keyof CompanySnapshot] === null; +} + export function brandToUpdate( brand: Brand, current: CompanySnapshot, @@ -108,23 +114,19 @@ export function brandToUpdate( key: K, value: string | null, ) => { - if (value && current[key as keyof CompanySnapshot] === null) { + if (value && fillable(key, current)) { (update as Record)[key] = value; } }; - const title = clean(brand.title); - if (title && current.nameIsPlaceholder) { - update.name = title; - } + fill("name", clean(brand.title)); fill("description", clean(brand.description) ?? clean(brand.slogan)); fill("logoUrl", pickLogo(brand.logos, "logo", "light")); fill("logoDarkUrl", pickLogo(brand.logos, "logo", "dark")); - const icon = pickIcon(brand.logos)?.url ?? null; - if (icon) update.iconUrl = icon; + fill("iconUrl", pickIcon(brand.logos)?.url ?? null); fill("iconDarkUrl", pickLogo(brand.logos, "icon", "dark")); fill("iconTone", iconTone(brand.logos)); @@ -156,6 +158,21 @@ export function brandToUpdate( return update; } +export function stillFillable( + update: BrandUpdate, + current: CompanySnapshot, +): BrandUpdate { + const next: BrandUpdate = {}; + + for (const [key, value] of Object.entries(update)) { + if (fillable(key, current)) { + (next as Record)[key] = value; + } + } + + return next; +} + export function filledFields(update: BrandUpdate): string[] { return Object.keys(update); } diff --git a/apps/agent/agent/lib/brand.ts b/apps/agent/agent/lib/brand.ts new file mode 100644 index 000000000..c0efd8bae --- /dev/null +++ b/apps/agent/agent/lib/brand.ts @@ -0,0 +1,169 @@ +import { db, EnrichmentStatus } from "@crm/db"; +import { mirrorBrandImages } from "./brand-images"; +import { brandToUpdate, filledFields, stillFillable } from "./brand-mapping"; +import { brandByDomain, contextDevEnabled } from "./context-dev"; + +export type BrandResult = { + enriched: boolean; + filled?: string[]; + mirrored?: string[]; + reason?: string; + retryable?: boolean; +}; + +export type Spend = (units?: number) => { ok: boolean; reason?: string }; + +export const FREE: Spend = () => ({ ok: true }); + +const COMPANY_FIELDS = { + id: true, + name: true, + domain: true, + description: true, + logoUrl: true, + logoDarkUrl: true, + iconUrl: true, + iconDarkUrl: true, + iconTone: true, + brandColor: true, + industry: true, + subIndustry: true, + city: true, + stateCode: true, + country: true, + countryCode: true, + phone: true, + email: true, + linkedinUrl: true, + twitterUrl: true, + githubUrl: true, + pricingUrl: true, + careersUrl: true, +} as const; + +export async function runBrand({ + companyId, + fresh = false, + spend = FREE, +}: { + companyId: string; + fresh?: boolean; + spend?: Spend; +}): Promise { + const company = await db.company.findUnique({ + where: { id: companyId }, + select: COMPANY_FIELDS, + }); + + if (!company) return { enriched: false, reason: "No such company." }; + + if (!contextDevEnabled()) { + const reason = + "Context.dev is not configured, so there is nowhere to look."; + await settle(companyId, EnrichmentStatus.SKIPPED, reason); + return { enriched: false, reason }; + } + + if (!company.domain) { + await settle(companyId, EnrichmentStatus.SKIPPED, "No domain to look up."); + 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 }, + data: { + enrichmentStatus: EnrichmentStatus.RUNNING, + enrichmentError: null, + }, + }); + + const result = await brandByDomain(company.domain, fresh ? 0 : undefined); + + if (result.outcome === "skipped") { + await settle(companyId, EnrichmentStatus.SKIPPED, result.reason); + return { enriched: false, reason: result.reason }; + } + + if (result.outcome === "failed") { + await settle(companyId, EnrichmentStatus.FAILED, result.reason); + return { + enriched: false, + reason: result.reason, + retryable: result.retryable, + }; + } + + const update = brandToUpdate(result.brand, snapshot(company)); + + const { mirrored } = await mirrorBrandImages(companyId, update); + + const filled = await db.$transaction(async (tx) => { + const current = await tx.company.findUnique({ + where: { id: companyId }, + select: COMPANY_FIELDS, + }); + + if (!current) return null; + + const data = stillFillable(update, snapshot(current)); + + await tx.company.update({ + where: { id: companyId }, + data: { + ...data, + enrichmentStatus: EnrichmentStatus.COMPLETE, + enrichedAt: new Date(), + enrichmentError: null, + }, + }); + + await tx.companyEnrichment.upsert({ + where: { companyId }, + create: { companyId, raw: result.raw as object }, + update: { raw: result.raw as object, fetchedAt: new Date() }, + }); + + return filledFields(data); + }); + + if (!filled) return { enriched: false, reason: "No such company." }; + + return { + enriched: true, + filled, + mirrored: mirrored.filter((slot) => filled.includes(slot)), + }; +} + +function snapshot( + company: T, +) { + return { ...company, nameIsPlaceholder: company.name === company.domain }; +} + +export function brandOutcome(result: BrandResult): string { + if (!result.enriched) return result.reason ?? "Nothing to fill."; + + const filled = result.filled ?? []; + const mirrored = result.mirrored ?? []; + + if (filled.length === 0) { + return "Everything Context.dev returned was already on the record."; + } + + return `Filled ${filled.join(", ")}.${mirrored.length > 0 ? ` Copied ${mirrored.length} image(s) in-house.` : ""}`; +} + +async function settle( + companyId: string, + status: EnrichmentStatus, + error: string, +): Promise { + await db.company.update({ + where: { id: companyId }, + data: { enrichmentStatus: status, enrichmentError: error }, + }); +} diff --git a/apps/agent/agent/lib/dispatch.ts b/apps/agent/agent/lib/dispatch.ts new file mode 100644 index 000000000..ee0f6bda7 --- /dev/null +++ b/apps/agent/agent/lib/dispatch.ts @@ -0,0 +1,164 @@ +import { EnrichmentStatus } from "@crm/db"; +import { APP_AUTH, type AppAuth } from "./app-auth"; +import { brandOutcome, runBrand } from "./brand"; +import { markRunning, settle } from "./enrichment"; +import { collapsing, runLimited } from "./pool"; +import { runPortrait } from "./portrait"; +import { + claimDue, + completeTask, + DIRECT_KINDS, + type LeasedTask, + noteSession, + retireExhausted, + type TaskSubject, +} from "./tasks"; + +export const VISIBLE_BATCH = 60; +export const VISIBLE_CONCURRENCY = 6; +export const VISIBLE_LEASE_MS = 2 * 60_000; + +export const RESEARCH_BATCH = 12; +export const RESEARCH_LEASE_MS = 30 * 60_000; + +export async function retireAbandoned(): Promise { + let abandoned: TaskSubject[] = []; + + try { + abandoned = await retireExhausted(); + } catch { + return; + } + + for (const task of abandoned) { + await settle( + task, + EnrichmentStatus.FAILED, + "Research was attempted several times and never completed.", + ).catch(() => {}); + } +} + +export async function runVisibleLane(): Promise { + let handled = 0; + + while (handled < VISIBLE_BATCH) { + const tasks = await claimDue( + Math.min(VISIBLE_CONCURRENCY, VISIBLE_BATCH - handled), + { only: DIRECT_KINDS }, + VISIBLE_LEASE_MS, + ); + + if (tasks.length === 0) break; + + await runLimited(VISIBLE_CONCURRENCY, tasks, runDirect); + handled += tasks.length; + } + + return handled; +} + +async function runDirect(task: LeasedTask): Promise { + try { + if (task.kind === "brand" && task.companyId) { + const result = await runBrand({ companyId: task.companyId }); + if (result.retryable) return; + + await completeTask(task.id, brandOutcome(result)); + return; + } + + if (task.kind === "portrait" && task.contactId) { + const portrait = await runPortrait({ + contactId: task.contactId, + spend: () => ({ ok: true }), + }); + + await completeTask( + task.id, + portrait.stored + ? `Picture stored from ${portrait.source}.` + : (portrait.reason ?? "No picture found."), + ); + return; + } + + await completeTask(task.id, "The record this names is gone."); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + await settle(task, EnrichmentStatus.FAILED, reason).catch(() => {}); + } +} + +export async function runResearchLane( + start: (task: LeasedTask) => Promise<{ id: string }>, +): Promise { + const tasks = await claimDue( + RESEARCH_BATCH, + { except: DIRECT_KINDS }, + RESEARCH_LEASE_MS, + ); + if (tasks.length === 0) return 0; + + await Promise.all( + tasks.map(async (task) => { + try { + await markRunning(task); + const session = await start(task); + await noteSession(task.id, session.id); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + await settle(task, EnrichmentStatus.FAILED, reason).catch(() => {}); + } + }), + ); + + return tasks.length; +} + +export function taskAuth(task: LeasedTask, base: AppAuth = APP_AUTH): AppAuth { + return { + ...base, + attributes: { + taskKind: task.kind, + reason: task.reason, + budget: String(task.budget), + ...(task.contactId ? { contactId: task.contactId } : {}), + ...(task.companyId ? { companyId: task.companyId } : {}), + }, + }; +} + +export const drainAll = collapsing( + async (start: (task: LeasedTask) => Promise<{ id: string }>) => { + await retireAbandoned(); + await Promise.all([runVisibleLane(), runResearchLane(start)]); + }, +); + +export function brief(task: LeasedTask): string { + const again = + task.attempts > 1 + ? `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); +} + +function work(kind: string, reason: string): 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."; + case "profile": + case "recheck": + return "Bring this contact's record up to date: their background, their current role, and anything that has changed since we last looked."; + case "meeting-prep": + return "There is a meeting with this person soon. Make sure whoever is taking it opens the record knowing who they are dealing with."; + case "company-profile": + 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."; + default: + return `Handle this: ${reason}`; + } +} diff --git a/apps/agent/agent/lib/pool.ts b/apps/agent/agent/lib/pool.ts new file mode 100644 index 000000000..93a1ef672 --- /dev/null +++ b/apps/agent/agent/lib/pool.ts @@ -0,0 +1,52 @@ +export function collapsing( + run: (...args: A) => Promise, +): (...args: A) => Promise { + let active: Promise | null = null; + let trailing: A | null = null; + + const invoke = async (...args: A): Promise => { + if (active) { + trailing = args; + return active; + } + + active = run(...args); + + let failure: { error: unknown } | null = null; + + try { + await active; + } catch (error) { + failure = { error }; + } finally { + active = null; + } + + const next = trailing; + trailing = null; + + if (next) { + const catchUp = invoke(...next); + await (failure ? catchUp.catch(() => {}) : catchUp); + } + + if (failure) throw failure.error; + }; + + return invoke; +} + +export async function runLimited( + concurrency: number, + items: readonly T[], + run: (item: T) => Promise, +): Promise { + const width = Math.max(1, Math.min(concurrency, items.length)); + const queue = items[Symbol.iterator](); + + const workers = Array.from({ length: width }, async () => { + for (const item of queue) await run(item); + }); + + await Promise.all(workers); +} diff --git a/apps/agent/agent/lib/preamble.ts b/apps/agent/agent/lib/preamble.ts index a9ee133a5..91abe7e25 100644 --- a/apps/agent/agent/lib/preamble.ts +++ b/apps/agent/agent/lib/preamble.ts @@ -1,5 +1,6 @@ import { db } from "@crm/db"; import { capabilitiesMarkdown } from "./capabilities"; +import { identity, usMarkdown, type WorkspaceIdentity } from "./workspace"; export type Opened = { dispatched: boolean; @@ -21,12 +22,21 @@ export async function sessionPreamble( }, opened: Opened, ): Promise { + if (opened.kind === "workspace-profile") return workspacePreamble(); if (record.contactId) return contactPreamble(record.contactId, opened); if (record.companyId) return companyPreamble(record.companyId, opened); if (record.dealId) return dealPreamble(record.dealId, opened); return noRecordPreamble(); } +export function composeClosing(us: WorkspaceIdentity | null): string { + return [usMarkdown(us), capabilitiesMarkdown()].filter(Boolean).join("\n\n"); +} + +async function closing(): Promise { + return composeClosing(await identity()); +} + function opening(opened: Opened, questions: string): string { if (opened.dispatched) { return [ @@ -70,7 +80,7 @@ export async function contactPreamble( }); if (!contact) { - return { markdown: capabilitiesMarkdown(), focus: { contactId } }; + return { markdown: await closing(), focus: { contactId } }; } const name = [contact.firstName, contact.lastName].filter(Boolean).join(" "); @@ -118,7 +128,7 @@ export async function contactPreamble( "", "Start with `read_crm_history` on this contact id.", "", - capabilitiesMarkdown(), + await closing(), ] .filter(Boolean) .join("\n"); @@ -155,7 +165,7 @@ export async function companyPreamble( }); if (!company) { - return { markdown: capabilitiesMarkdown(), focus: { companyId } }; + return { markdown: await closing(), focus: { companyId } }; } const people = company.contacts @@ -199,7 +209,7 @@ export async function companyPreamble( "", "Start with `read_company_history` on this company id — it returns the people, the deals, the correspondence and the notes in one free call.", "", - capabilitiesMarkdown(), + await closing(), ] .filter(Boolean) .join("\n"); @@ -232,7 +242,7 @@ export async function dealPreamble( }, }); - if (!deal) return { markdown: capabilitiesMarkdown(), focus: {} }; + if (!deal) return { markdown: await closing(), focus: {} }; const people = deal.contacts .map(({ role, contact }) => { @@ -276,13 +286,13 @@ export async function dealPreamble( "", "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.", "", - capabilitiesMarkdown(), + await closing(), ].join("\n"); return { markdown, focus: { companyId: deal.company?.id ?? null } }; } -export function noRecordPreamble(): Preamble { +export async function noRecordPreamble(): Promise { return { markdown: [ "## This session", @@ -292,8 +302,58 @@ export function noRecordPreamble(): Preamble { "`search_crm` finds any contact, company or deal by name, email address or", "domain. Look the record up rather than asking for an id.", "", - capabilitiesMarkdown(), + await closing(), ].join("\n"), focus: {}, }; } + +export async function workspacePreamble( + known?: WorkspaceIdentity | null, +): Promise { + const us = known === undefined ? await identity() : known; + + if (!us?.website) { + return { + markdown: [ + "## This session", + "", + "You were asked to write the profile of the company you work for, and", + "nobody has told this install what its website is. There is nothing to", + "read. Stop — do not guess at it from the email addresses in the CRM.", + ].join("\n"), + focus: {}, + }; + } + + const site = us.website.startsWith("http") + ? us.website + : `https://${us.website}`; + + const markdown = [ + "## This session", + "", + `You are writing the profile of **the company you work for** — ${us.name} (${us.website}).`, + us.profile + ? `One already exists, written ${us.profile.refreshedAt.toDateString()}. Replace it only if the site now says something different.` + : "There is no profile of us yet.", + "", + `Read ${site} with \`web_fetch\` — the home page, and the pricing or product`, + "page if there is one — and search the web only if the site does not say who", + "the customer is. Then call `write_workspace_profile`.", + "", + "**Every other session opens with what you write here**, in front of the", + "record a rep is asking about, so it has to be short and it has to be", + "substance. The tool enforces that: 320 characters of narrative and one", + "short line each for what we sell, who we sell to, and what we are picked", + "over. Leave a line out rather than padding it. No marketing adjectives —", + '"leading", "innovative" and "best-in-class" say nothing a rep can use.', + "", + "You are describing us to a colleague who has just joined, not writing our", + "home page back to us.", + "", + capabilitiesMarkdown(), + ].join("\n"); + + return { markdown, focus: {} }; +} diff --git a/apps/agent/agent/lib/tasks.ts b/apps/agent/agent/lib/tasks.ts index 0680a0ac1..e95ea2364 100644 --- a/apps/agent/agent/lib/tasks.ts +++ b/apps/agent/agent/lib/tasks.ts @@ -1,4 +1,4 @@ -import { db, type Prisma } from "@crm/db"; +import { db, Prisma } from "@crm/db"; export type LeasedTask = { id: string; @@ -8,6 +8,8 @@ export type LeasedTask = { reason: string; budget: number; attempts: number; + priority: number; + dueAt: Date; }; export type TaskSubject = { @@ -21,28 +23,45 @@ const LEASE_MS = 10 * 60_000; export const MAX_ATTEMPTS = 3; -export async function claimDue(limit: number): Promise { +export { DIRECT_KINDS } from "@crm/db/agent-tasks"; + +export async function claimDue( + limit: number, + kinds: { only: readonly string[] } | { except: readonly string[] }, + leaseMs = LEASE_MS, +): Promise { const now = new Date(); - const until = new Date(now.getTime() + LEASE_MS); + const until = new Date(now.getTime() + leaseMs); + + const list = "only" in kinds ? kinds.only : kinds.except; + if ("only" in kinds && list.length === 0) return []; - return db.$queryRaw` + const match = Prisma.sql`t2.kind ${"only" in kinds ? Prisma.sql`IN` : Prisma.sql`NOT IN`} (${Prisma.join(list)})`; + + const claimed = await db.$queryRaw` UPDATE "agentTask" AS t SET "leasedUntil" = ${until}, "startedAt" = COALESCE(t."startedAt", ${now}), "attempts" = t."attempts" + 1 FROM ( - SELECT id FROM "agentTask" - WHERE "finishedAt" IS NULL - AND "dueAt" <= ${now} - AND ("leasedUntil" IS NULL OR "leasedUntil" < ${now}) - AND "attempts" < ${MAX_ATTEMPTS} - ORDER BY "priority" DESC, "dueAt" ASC + SELECT t2.id FROM "agentTask" AS t2 + WHERE t2."finishedAt" IS NULL + AND t2."dueAt" <= ${now} + AND (t2."leasedUntil" IS NULL OR t2."leasedUntil" < ${now}) + AND t2."attempts" < ${MAX_ATTEMPTS} + AND ${match} + ORDER BY t2."priority" DESC, t2."dueAt" ASC LIMIT ${limit} FOR UPDATE SKIP LOCKED ) AS due WHERE t.id = due.id - RETURNING t.id, t."contactId", t."companyId", t.kind, t.reason, t.budget, t.attempts; + RETURNING t.id, t."contactId", t."companyId", t.kind, t.reason, + t.budget, t.attempts, t.priority, t."dueAt"; `; + + return claimed.sort( + (a, b) => b.priority - a.priority || a.dueAt.getTime() - b.dueAt.getTime(), + ); } export async function retireExhausted(): Promise { diff --git a/apps/agent/agent/lib/workspace.ts b/apps/agent/agent/lib/workspace.ts new file mode 100644 index 000000000..548e4087d --- /dev/null +++ b/apps/agent/agent/lib/workspace.ts @@ -0,0 +1,54 @@ +import { db } from "@crm/db"; +import { + readWorkspaceIdentity, + type WorkspaceIdentity, +} from "@crm/db/workspace"; + +export type { WorkspaceIdentity }; + +export async function identity(): Promise { + try { + return await readWorkspaceIdentity(db); + } catch (error) { + console.error("[agent] could not read who we are", error); + return null; + } +} + +export function usMarkdown(us: WorkspaceIdentity | null): string { + if (!us) return ""; + + const lines = ["## Who we are", ""]; + + lines.push( + `You work for **${us.name}**${us.website ? ` (${us.website})` : ""}.`, + ); + + if (!us.profile) { + lines.push( + "Nothing else about us has been researched yet, so do not guess at what", + "we sell.", + ); + return lines.join("\n"); + } + + lines.push(us.profile.narrative, ""); + + const { sells, sellsTo, edge } = us.profile.sections; + if (sells) lines.push(`- **We sell:** ${sells}`); + if (sellsTo) lines.push(`- **To:** ${sellsTo}`); + if (edge) lines.push(`- **Picked over the alternatives for:** ${edge}`); + + lines.push( + "", + "That is context, not a script. When you brief a rep, say what this record", + "means for us — a fit, a competitor, a partner, or nothing worth saying —", + "and never write a pitch: the rep already knows what we sell.", + ); + + return lines.join("\n"); +} + +export async function ourWebsite(): Promise { + return (await identity())?.website ?? null; +} diff --git a/apps/agent/agent/schedules/dispatch.ts b/apps/agent/agent/schedules/dispatch.ts index 11eb9e6f8..e1d81d652 100644 --- a/apps/agent/agent/schedules/dispatch.ts +++ b/apps/agent/agent/schedules/dispatch.ts @@ -1,118 +1,18 @@ -import { EnrichmentStatus } from "@crm/db"; import { defineSchedule } from "eve/schedules"; import crm from "../channels/crm"; -import { markRunning, settle } from "../lib/enrichment"; -import { runPortrait } from "../lib/portrait"; -import { - claimDue, - completeTask, - noteSession, - retireExhausted, -} from "../lib/tasks"; - -const BATCH = 5; +import { brief, drainAll, taskAuth } from "../lib/dispatch"; export default defineSchedule({ cron: "* * * * *", async run({ receive, waitUntil, appAuth }) { waitUntil( - (async () => { - try { - for (const abandoned of await retireExhausted()) { - await settle( - abandoned, - EnrichmentStatus.FAILED, - "Research was attempted several times and never completed.", - ); - } - } catch {} - - const tasks = await claimDue(BATCH); - if (tasks.length === 0) return; - - await Promise.all( - tasks.map(async (task) => { - try { - if (task.kind === "portrait" && task.contactId) { - const portrait = await runPortrait({ - contactId: task.contactId, - spend: () => ({ ok: true }), - }); - - // The outcome is the answer, not a tick. The backfill reads - // these rows to decide who not to look for again, so "no - // picture on LinkedIn, not on the company's site" has to - // survive here — otherwise the only record of a month's worth - // of paid lookups is that they happened. - await completeTask( - task.id, - portrait.stored - ? `Picture stored from ${portrait.source}.` - : (portrait.reason ?? "No picture found."), - ); - return; - } - - await markRunning(task); - - const session = await receive(crm, { - message: brief(task), - target: { taskId: task.id }, - auth: { - ...appAuth, - attributes: { - taskKind: task.kind, - reason: task.reason, - budget: String(task.budget), - ...(task.contactId ? { contactId: task.contactId } : {}), - ...(task.companyId ? { companyId: task.companyId } : {}), - }, - }, - }); - - await noteSession(task.id, session.id); - } catch (error) { - const reason = - error instanceof Error ? error.message : String(error); - - await settle(task, EnrichmentStatus.FAILED, reason).catch( - () => {}, - ); - } - }), - ); - })(), + drainAll((task) => + receive(crm, { + message: brief(task), + target: { taskId: task.id }, + auth: taskAuth(task, appAuth), + }), + ), ); }, }); - -function brief(task: { - kind: string; - reason: string; - contactId: string | null; - companyId: string | null; - attempts: number; -}): string { - const again = - task.attempts > 1 - ? `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); -} - -function work(kind: string, reason: string): 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."; - case "profile": - case "recheck": - return "Bring this contact's record up to date: their background, their current role, and anything that has changed since we last looked."; - case "meeting-prep": - return "There is a meeting with this person soon. Make sure whoever is taking it opens the record knowing who they are dealing with."; - case "company-profile": - return "Fill in what we know about this company: brand, industry, location, links. Write a brief if there is something worth saying."; - default: - return `Handle this: ${reason}`; - } -} diff --git a/apps/agent/agent/tools/enrich_company.ts b/apps/agent/agent/tools/enrich_company.ts index 8ec6a7905..5b034c568 100644 --- a/apps/agent/agent/tools/enrich_company.ts +++ b/apps/agent/agent/tools/enrich_company.ts @@ -1,9 +1,6 @@ -import { db, EnrichmentStatus } from "@crm/db"; import { defineTool } from "eve/tools"; import { z } from "zod"; -import { mirrorBrandImages } from "../lib/brand-images"; -import { brandToUpdate, filledFields } from "../lib/brand-mapping"; -import { brandByDomain, contextDevEnabled } from "../lib/context-dev"; +import { runBrand } from "../lib/brand"; import { spend } from "../lib/focus"; export default defineTool({ @@ -19,109 +16,24 @@ export default defineTool({ ), }), async execute({ companyId, fresh }) { - if (!contextDevEnabled()) { - return { - enriched: false as const, - reason: "Context.dev is not configured.", - }; - } - - const company = await db.company.findUnique({ - where: { id: companyId }, - select: { - id: true, - name: true, - domain: true, - description: true, - logoUrl: true, - logoDarkUrl: true, - iconUrl: true, - iconDarkUrl: true, - iconTone: true, - brandColor: true, - industry: true, - subIndustry: true, - city: true, - stateCode: true, - country: true, - countryCode: true, - phone: true, - email: true, - linkedinUrl: true, - twitterUrl: true, - githubUrl: true, - pricingUrl: true, - careersUrl: true, - }, - }); - - if (!company) - return { enriched: false as const, reason: "No such company." }; - if (!company.domain) { - await settle( - companyId, - EnrichmentStatus.SKIPPED, - "No domain to look up.", - ); - return { enriched: false as const, reason: "No domain on this company." }; - } - - const charge = spend(2); - if (!charge.ok) return { enriched: false as const, reason: charge.reason }; + const result = await runBrand({ companyId, fresh, spend }); - await db.company.update({ - where: { id: companyId }, - data: { - enrichmentStatus: EnrichmentStatus.RUNNING, - enrichmentError: null, - }, - }); - - const result = await brandByDomain(company.domain, fresh ? 0 : undefined); - - if (result.outcome === "skipped") { - await settle(companyId, EnrichmentStatus.SKIPPED, result.reason); - return { enriched: false as const, reason: result.reason }; - } - - if (result.outcome === "failed") { - await settle(companyId, EnrichmentStatus.FAILED, result.reason); + if (!result.enriched) { return { enriched: false as const, reason: result.reason, - retryable: result.retryable, + ...(result.retryable === undefined + ? {} + : { retryable: result.retryable }), }; } - const update = brandToUpdate(result.brand, { - ...company, - nameIsPlaceholder: company.name === company.domain, - }); - const filled = filledFields(update); - - const { mirrored } = await mirrorBrandImages(companyId, update); - - await db.$transaction([ - db.company.update({ where: { id: companyId }, data: update }), - db.companyEnrichment.upsert({ - where: { companyId }, - create: { companyId, raw: result.raw as object }, - update: { raw: result.raw as object, fetchedAt: new Date() }, - }), - db.company.update({ - where: { id: companyId }, - data: { - enrichmentStatus: EnrichmentStatus.COMPLETE, - enrichedAt: new Date(), - enrichmentError: null, - }, - }), - ]); + const filled = result.filled ?? []; return { enriched: true as const, filled, - mirrored, + mirrored: result.mirrored ?? [], note: filled.length === 0 ? "Everything it returned was already on the record." @@ -129,14 +41,3 @@ export default defineTool({ }; }, }); - -async function settle( - companyId: string, - status: EnrichmentStatus, - error: string, -): Promise { - await db.company.update({ - where: { id: companyId }, - data: { enrichmentStatus: status, enrichmentError: error }, - }); -} diff --git a/apps/agent/agent/tools/schedule_recheck.ts b/apps/agent/agent/tools/schedule_recheck.ts index af05c2e6a..53af62b0b 100644 --- a/apps/agent/agent/tools/schedule_recheck.ts +++ b/apps/agent/agent/tools/schedule_recheck.ts @@ -1,3 +1,4 @@ +import { PRIORITY } from "@crm/db/agent-tasks"; import { defineTool } from "eve/tools"; import { z } from "zod"; import { scheduleTask } from "../lib/tasks"; @@ -41,7 +42,7 @@ export default defineTool({ reason, dueAt, budget, - priority: 0, + priority: PRIORITY.recheck, }); return { scheduled: true as const, dueAt: dueAt.toISOString(), reason }; diff --git a/apps/agent/agent/tools/write_workspace_profile.ts b/apps/agent/agent/tools/write_workspace_profile.ts new file mode 100644 index 000000000..0c94a3137 --- /dev/null +++ b/apps/agent/agent/tools/write_workspace_profile.ts @@ -0,0 +1,78 @@ +import { db } from "@crm/db"; +import { + MAX_LINE, + MAX_NARRATIVE, + writeWorkspaceProfile, +} from "@crm/db/workspace"; +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { currentFocus } from "../lib/focus"; +import { identity } from "../lib/workspace"; + +const line = (what: string) => + z.string().max(MAX_LINE).optional().describe(what); + +export default defineTool({ + description: + "Write the short profile of the company we work for. Every other session opens with it, so it is deliberately small: a few sentences and three one-line facts. Replaces the previous one.", + inputSchema: z.object({ + narrative: z + .string() + .max(MAX_NARRATIVE) + .describe( + "Two or three sentences a new colleague would need on their first day: " + + "what this company does and how it makes money. Plain, factual, no " + + "adjectives from the marketing site.", + ), + sells: line( + 'What we sell, in a few words. e.g. "Compliance automation for SOC 2, ISO 27001 and GDPR"', + ), + sellsTo: line( + 'Who we sell it to. e.g. "Series A–C startups that need a framework audit"', + ), + edge: line( + "What customers pick us over the alternatives for, if the site says.", + ), + sourceUrl: z.string().optional(), + }), + async execute(input) { + const us = await identity(); + + if (!us?.website) { + return { + written: false as const, + reason: + "This install has not been told its own website, so there is nothing to file a profile against.", + }; + } + + const narrative = input.narrative.trim(); + + if (narrative.length < 40) { + return { + written: false as const, + reason: + "Too short to tell anybody anything. Say what we sell and to whom, or say nothing.", + }; + } + + const profile = await writeWorkspaceProfile(db, { + website: us.website, + narrative, + sections: { + sells: input.sells, + sellsTo: input.sellsTo, + edge: input.edge, + }, + sourceUrl: input.sourceUrl, + sessionId: currentFocus().sessionId, + }); + + return { + written: true as const, + website: profile.website, + narrative: profile.narrative, + sections: profile.sections, + }; + }, +}); diff --git a/apps/agent/package.json b/apps/agent/package.json index 97214c0a0..ab68a8a5b 100644 --- a/apps/agent/package.json +++ b/apps/agent/package.json @@ -6,7 +6,9 @@ "license": "MIT", "scripts": { "backfill:images": "bun scripts/backfill-brand-images.ts", - "dev": "eve dev", + "dev": "eve dev --no-ui", + "dev:tui": "eve dev", + "dispatch": "curl -fsS -X POST \"${AGENT_URL:-http://127.0.0.1:2000}/eve/v1/dev/schedules/dispatch\"", "build": "eve build", "start": "eve start", "check-types": "tsc --noEmit", diff --git a/apps/agent/test/brand-mapping.spec.ts b/apps/agent/test/brand-mapping.spec.ts index fdfdf9974..a73a9feca 100644 --- a/apps/agent/test/brand-mapping.spec.ts +++ b/apps/agent/test/brand-mapping.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test"; import { brandToUpdate, type CompanySnapshot, + stillFillable, } from "../agent/lib/brand-mapping"; import type { Brand } from "../agent/lib/context-dev"; @@ -142,3 +143,50 @@ describe("brandToUpdate", () => { expect(update.description).toBe("Payments, simplified."); }); }); + +describe("stillFillable", () => { + it("drops a field a rep typed while the lookup was in flight", () => { + const update = brandToUpdate( + { + description: "Payments infrastructure for the internet.", + phone: "+1 888 963 8955", + }, + emptyCompany(), + ); + + const data = stillFillable( + update, + emptyCompany({ description: "Our biggest account — handle with care." }), + ); + + expect(data.description).toBeUndefined(); + expect(data.phone).toBe("+1 888 963 8955"); + }); + + it("keeps the icon, which the agent owns rather than shares", () => { + const update = brandToUpdate( + { logos: [{ url: "https://cdn/icon.svg", mode: "light", type: "icon" }] }, + emptyCompany(), + ); + + const data = stillFillable( + update, + emptyCompany({ iconUrl: "https://acme.test/favicon.ico" }), + ); + + expect(data.iconUrl).toBe("https://cdn/icon.svg"); + }); + + it("drops the name once the placeholder has been answered", () => { + const update = brandToUpdate( + { title: "Stripe" }, + emptyCompany({ name: "stripe.com", nameIsPlaceholder: true }), + ); + + expect(update.name).toBe("Stripe"); + + expect( + stillFillable(update, emptyCompany({ name: "Stripe Inc" })).name, + ).toBeUndefined(); + }); +}); diff --git a/apps/agent/test/crm-token.spec.ts b/apps/agent/test/crm-token.spec.ts new file mode 100644 index 000000000..4f2047b37 --- /dev/null +++ b/apps/agent/test/crm-token.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "bun:test"; +import { taskFromToken, taskToken } from "../agent/channels/crm"; + +const TASK_ID = "cmsdc0a6j004cz96ddzpcgwqr"; + +describe("taskFromToken", () => { + it("reads back a token this channel minted", () => { + expect(taskFromToken(taskToken(TASK_ID))).toBe(TASK_ID); + }); + + it("reads the token as the channel context presents it", () => { + expect(taskFromToken(`crm:${taskToken(TASK_ID)}`)).toBe(TASK_ID); + }); + + it("still reads the doubly-prefixed form written before the fix", () => { + expect(taskFromToken(`crm:crm:task:${TASK_ID}`)).toBe(TASK_ID); + }); + + it("ignores a token that is not a task", () => { + expect( + taskFromToken("crm:adhoc:0f6c1e2a-1111-2222-3333-444455556666"), + ).toBeNull(); + expect( + taskFromToken("eve:9ebb3820-ee00-4f35-bd38-d5147b89bd71"), + ).toBeNull(); + expect(taskFromToken(undefined)).toBeNull(); + expect(taskFromToken("crm:task:")).toBeNull(); + }); +}); diff --git a/apps/agent/test/drain.spec.ts b/apps/agent/test/drain.spec.ts new file mode 100644 index 000000000..cecb3f586 --- /dev/null +++ b/apps/agent/test/drain.spec.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "bun:test"; +import { APP_AUTH } from "../agent/lib/app-auth"; +import { isAutomated } from "../agent/lib/approval"; +import { taskAuth } from "../agent/lib/dispatch"; +import { collapsing } from "../agent/lib/pool"; +import type { LeasedTask } from "../agent/lib/tasks"; + +function deferred() { + let release!: () => void; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} + +describe("collapsing", () => { + it("a burst of pokes is one drain and one catch-up, not one drain each", async () => { + const gate = deferred(); + let runs = 0; + + const drain = collapsing(async () => { + runs += 1; + await gate.promise; + }); + + const all = [drain(), drain(), drain(), drain()]; + expect(runs).toBe(1); + + gate.release(); + await Promise.all(all); + + expect(runs).toBe(2); + }); + + it("work queued mid-drain gets a trailing run rather than waiting for the cron", async () => { + const first = deferred(); + const second = deferred(); + const gates = [first, second]; + let runs = 0; + + const drain = collapsing(async () => { + const gate = gates[runs]; + runs += 1; + await gate?.promise; + }); + + const initial = drain(); + const during = drain(); + + first.release(); + second.release(); + await Promise.all([initial, during]); + + expect(runs).toBe(2); + }); + + it("a failed drain does not wedge the next one", async () => { + let runs = 0; + + const drain = collapsing(async () => { + runs += 1; + throw new Error("lane blew up"); + }); + + await expect(drain()).rejects.toThrow("lane blew up"); + await expect(drain()).rejects.toThrow("lane blew up"); + + expect(runs).toBe(2); + }); + + it("hands the trailing run the arguments of the poke that asked for it", async () => { + const gate = deferred(); + const seen: string[] = []; + + const drain = collapsing(async (label: string) => { + seen.push(label); + if (seen.length === 1) await gate.promise; + }); + + const first = drain("cron"); + const second = drain("poke"); + + gate.release(); + await Promise.all([first, second]); + + expect(seen).toEqual(["cron", "poke"]); + }); +}); + +function task(overrides: Partial = {}): LeasedTask { + return { + id: "task_1", + contactId: "contact_1", + companyId: null, + kind: "identify", + reason: "A new contact", + budget: 4, + attempts: 1, + priority: 100, + dueAt: new Date(), + ...overrides, + }; +} + +describe("taskAuth", () => { + it("reads as the app principal, so an unattended turn is not asked to approve itself", () => { + const auth = taskAuth(task()); + + expect(isAutomated({ auth: { current: auth } })).toBe(true); + }); + + it("carries the record and the budget the preamble needs", () => { + const auth = taskAuth(task({ companyId: "company_1" })); + + expect(auth.attributes).toMatchObject({ + taskKind: "identify", + budget: "4", + contactId: "contact_1", + companyId: "company_1", + }); + }); + + it("omits the id of a record the task does not name", () => { + const auth = taskAuth(task({ contactId: null, companyId: "company_1" })); + + expect(auth.attributes).not.toHaveProperty("contactId"); + }); + + it("prefers the principal eve hands the schedule over our own copy", () => { + const fromEve = { ...APP_AUTH, principalId: "eve:app", issuer: "eve" }; + const auth = taskAuth(task(), fromEve); + + expect(auth).toMatchObject({ issuer: "eve" }); + expect(isAutomated({ auth: { current: auth } })).toBe(true); + }); +}); diff --git a/apps/agent/test/lanes.integration.spec.ts b/apps/agent/test/lanes.integration.spec.ts new file mode 100644 index 000000000..5bd365fed --- /dev/null +++ b/apps/agent/test/lanes.integration.spec.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { db } from "@crm/db"; +import { DIRECT_KINDS, isDirectKind, PRIORITY } from "@crm/db/agent-tasks"; +import { claimDue } from "../agent/lib/tasks"; + +const REASON = "lane-test"; + +const VISIBLE = { only: DIRECT_KINDS } as const; +const RESEARCH = { except: DIRECT_KINDS } as const; + +async function clear() { + await db.agentTask.deleteMany({ where: { reason: REASON } }); +} + +beforeEach(clear); +afterEach(clear); + +async function queue(kind: string, priority: number) { + return db.agentTask.create({ + data: { + kind, + reason: REASON, + dueAt: new Date(Date.now() - 1000), + priority, + budget: 2, + }, + select: { id: true }, + }); +} + +describe("dispatch lanes", () => { + it("keeps a logo out of the research lane and a brief out of the visible one", async () => { + const brand = await queue("brand", PRIORITY.brand); + const profile = await queue("company-profile", PRIORITY.companyProfile); + + const visible = await claimDue(10, VISIBLE); + const research = await claimDue(10, RESEARCH); + + const visibleIds = visible.map((t) => t.id); + const researchIds = research.map((t) => t.id); + + expect(visibleIds).toContain(brand.id); + expect(visibleIds).not.toContain(profile.id); + + expect(researchIds).toContain(profile.id); + expect(researchIds).not.toContain(brand.id); + }); + + it("a logo is never starved by a queue full of research", async () => { + for (let i = 0; i < 30; i += 1) { + await queue("identify", PRIORITY.identify); + } + + const brand = await queue("brand", PRIORITY.brand); + + const visible = await claimDue(5, VISIBLE); + + expect(visible.map((t) => t.id)).toContain(brand.id); + }); + + it("takes the visible work in priority order", async () => { + const portrait = await queue("portrait", PRIORITY.portrait); + const brand = await queue("brand", PRIORITY.brand); + + const claimed = await claimDue(10, VISIBLE); + const ordered = claimed + .filter((t) => t.id === brand.id || t.id === portrait.id) + .map((t) => t.id); + + expect(ordered).toEqual([brand.id, portrait.id]); + }); + + it("sends the who-are-we pass to the research lane, ahead of the contacts", async () => { + const identify = await queue("identify", PRIORITY.identify); + const us = await queue("workspace-profile", PRIORITY.workspace); + + const visible = await claimDue(10, VISIBLE); + const research = await claimDue(10, RESEARCH); + + expect(visible.map((t) => t.id)).not.toContain(us.id); + + const ordered = research + .filter((t) => t.id === us.id || t.id === identify.id) + .map((t) => t.id); + + expect(ordered).toEqual([us.id, identify.id]); + }); + + it("leases the two lanes independently", async () => { + const brand = await queue("brand", PRIORITY.brand); + + await claimDue(10, VISIBLE); + const again = await claimDue(10, RESEARCH); + + expect(again.map((t) => t.id)).not.toContain(brand.id); + }); +}); + +describe("kind vocabulary", () => { + it("agrees on which kinds skip the model", () => { + expect(isDirectKind("brand")).toBe(true); + expect(isDirectKind("portrait")).toBe(true); + expect(isDirectKind("company-profile")).toBe(false); + expect(isDirectKind("identify")).toBe(false); + expect(isDirectKind("workspace-profile")).toBe(false); + }); + + it("puts what a rep sees first above what they have to click for", () => { + expect(PRIORITY.brand).toBeGreaterThan(PRIORITY.requested); + expect(PRIORITY.portrait).toBeGreaterThan(PRIORITY.requested); + expect(PRIORITY.requested).toBeGreaterThan(PRIORITY.companyProfile); + expect(PRIORITY.companyProfile).toBeGreaterThan(PRIORITY.recheck); + }); +}); diff --git a/apps/agent/test/pool.spec.ts b/apps/agent/test/pool.spec.ts new file mode 100644 index 000000000..3a35df7be --- /dev/null +++ b/apps/agent/test/pool.spec.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "bun:test"; +import { collapsing, runLimited } from "../agent/lib/pool"; + +describe("runLimited", () => { + it("runs every item", async () => { + const seen: number[] = []; + + await runLimited(3, [1, 2, 3, 4, 5, 6, 7], async (n) => { + seen.push(n); + }); + + expect(seen.sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5, 6, 7]); + }); + + it("runs an item whose value is undefined", async () => { + const seen: (number | undefined)[] = []; + + await runLimited(2, [1, undefined, 3], async (n) => { + seen.push(n); + }); + + expect(seen).toHaveLength(3); + expect(seen).toContain(3); + }); + + it("never exceeds the width it was given", async () => { + let running = 0; + let peak = 0; + + await runLimited( + 3, + Array.from({ length: 20 }, (_, i) => i), + async () => { + running += 1; + peak = Math.max(peak, running); + await new Promise((resolve) => setTimeout(resolve, 5)); + running -= 1; + }, + ); + + expect(peak).toBe(3); + }); + + it("does nothing with nothing", async () => { + let calls = 0; + + await runLimited(4, [], async () => { + calls += 1; + }); + + expect(calls).toBe(0); + }); + + it("does not spawn more workers than items", async () => { + let peak = 0; + let running = 0; + + await runLimited(10, [1, 2], async () => { + running += 1; + peak = Math.max(peak, running); + await new Promise((resolve) => setTimeout(resolve, 5)); + running -= 1; + }); + + expect(peak).toBeLessThanOrEqual(2); + }); +}); + +describe("collapsing", () => { + it("still runs a poke that arrived during a failed drain", async () => { + const runs: number[] = []; + let release = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + + const drain = collapsing(async (n: number) => { + runs.push(n); + if (n === 1) { + await gate; + throw new Error("the drain failed"); + } + }); + + const first = drain(1); + const second = drain(2).catch(() => {}); + release(); + + await expect(first).rejects.toThrow("the drain failed"); + await second; + + expect(runs).toEqual([1, 2]); + }); + + it("folds every poke during a drain into one trailing run", async () => { + const runs: number[] = []; + let release = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + + const drain = collapsing(async (n: number) => { + runs.push(n); + if (n === 1) await gate; + }); + + const first = drain(1); + void drain(2); + void drain(3); + release(); + await first; + + expect(runs).toEqual([1, 3]); + }); +}); diff --git a/apps/agent/test/preamble.integration.spec.ts b/apps/agent/test/preamble.integration.spec.ts index 9c857a138..ffa450ff3 100644 --- a/apps/agent/test/preamble.integration.spec.ts +++ b/apps/agent/test/preamble.integration.spec.ts @@ -2,11 +2,14 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { DealStage, db } from "@crm/db"; import { companyPreamble, + composeClosing, contactPreamble, dealPreamble, noRecordPreamble, sessionPreamble, + workspacePreamble, } from "../agent/lib/preamble"; +import { identity } from "../agent/lib/workspace"; const suffix = process.env.TEST_RUN_ID ?? "preamble-spec"; const domain = `fernhill-${suffix}.test`; @@ -206,7 +209,54 @@ describe("sessionPreamble", () => { it("tells a session with no record that the CRM is searchable", async () => { const { markdown } = await sessionPreamble({}, rep); - expect(markdown).toBe(noRecordPreamble().markdown); + expect(markdown).toBe((await noRecordPreamble()).markdown); expect(markdown).toContain("`search_crm`"); }); }); + +describe("every session is told who we are", () => { + it("ends each preamble with the same account of us", async () => { + const expected = composeClosing(await identity()); + + for (const { markdown } of [ + await contactPreamble(paulaId, rep), + await companyPreamble(companyId, rep), + await dealPreamble(dealId, rep), + await noRecordPreamble(), + ]) { + expect(markdown.endsWith(expected)).toBe(true); + } + }); +}); + +describe("the workspace profile session", () => { + it("is routed by the task kind, with no record of its own", async () => { + const { markdown, focus } = await sessionPreamble( + {}, + { dispatched: true, kind: "workspace-profile" }, + ); + + expect(focus).toEqual({}); + expect(markdown).toContain("the company you work for"); + expect(markdown).not.toContain("`search_crm` finds any contact"); + }); + + it("sends the session to our own site, and holds it to a size", async () => { + const { markdown } = await workspacePreamble({ + name: "Comp AI", + website: "trycomp.ai", + profile: null, + }); + + expect(markdown).toContain("https://trycomp.ai"); + expect(markdown).toContain("`write_workspace_profile`"); + expect(markdown).toContain("320 characters"); + }); + + it("refuses to guess when nobody has said what our website is", async () => { + const { markdown } = await workspacePreamble(null); + + expect(markdown).toContain("do not guess"); + expect(markdown).not.toContain("`write_workspace_profile`"); + }); +}); diff --git a/apps/agent/test/tasks.integration.spec.ts b/apps/agent/test/tasks.integration.spec.ts index 0c61db125..431c93b66 100644 --- a/apps/agent/test/tasks.integration.spec.ts +++ b/apps/agent/test/tasks.integration.spec.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { db } from "@crm/db"; +import { DIRECT_KINDS } from "@crm/db/agent-tasks"; import { claimDue, completeTask, @@ -10,6 +11,8 @@ import { const kind = "test-lease"; +const RESEARCH = { except: DIRECT_KINDS } as const; + async function clear() { await db.agentTask.deleteMany({ where: { kind } }); await db.contact.deleteMany({ where: { email: { startsWith: "lease-" } } }); @@ -55,7 +58,7 @@ describe("claimDue", () => { it("claims due work and leases it", async () => { const task = await queue(); - const claimed = await claimDue(10); + const claimed = await claimDue(10, RESEARCH); expect(claimed.map((t) => t.id)).toContain(task.id); const row = await db.agentTask.findUnique({ where: { id: task.id } }); @@ -66,7 +69,10 @@ describe("claimDue", () => { it("does not hand the same row to two dispatchers", async () => { await Promise.all([queue(), queue(), queue()]); - const [first, second] = await Promise.all([claimDue(3), claimDue(3)]); + const [first, second] = await Promise.all([ + claimDue(3, RESEARCH), + claimDue(3, RESEARCH), + ]); const ids = [...first, ...second].map((t) => t.id); expect(new Set(ids).size).toBe(ids.length); @@ -75,7 +81,7 @@ describe("claimDue", () => { it("leaves work that is not due yet", async () => { await queue({ dueAt: new Date(Date.now() + 60_000) }); - const claimed = await claimDue(10); + const claimed = await claimDue(10, RESEARCH); expect(claimed).toHaveLength(0); }); @@ -83,47 +89,49 @@ describe("claimDue", () => { const low = await queue({ priority: 0 }); const high = await queue({ priority: 100 }); - const claimed = await claimDue(1); + const claimed = await claimDue(1, RESEARCH); expect(claimed[0]?.id).toBe(high.id); expect(claimed[0]?.id).not.toBe(low.id); }); it("does not re-claim a leased row, and does re-claim an expired one", async () => { const task = await queue(); - await claimDue(10); + await claimDue(10, RESEARCH); - expect(await claimDue(10)).toHaveLength(0); + expect(await claimDue(10, RESEARCH)).toHaveLength(0); await db.agentTask.update({ where: { id: task.id }, data: { leasedUntil: new Date(Date.now() - 1000) }, }); - expect((await claimDue(10)).map((t) => t.id)).toContain(task.id); + expect((await claimDue(10, RESEARCH)).map((t) => t.id)).toContain(task.id); }); it("stops handing out a row that has spent its attempts", async () => { const task = await queue(); for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { - expect((await claimDue(10)).map((t) => t.id)).toContain(task.id); + expect((await claimDue(10, RESEARCH)).map((t) => t.id)).toContain( + task.id, + ); await expire(task.id); } - expect(await claimDue(10)).toHaveLength(0); + expect(await claimDue(10, RESEARCH)).toHaveLength(0); }); it("counts the attempts it has handed out", async () => { const task = await queue(); - expect((await claimDue(10))[0]?.attempts).toBe(1); + expect((await claimDue(10, RESEARCH))[0]?.attempts).toBe(1); await expire(task.id); - expect((await claimDue(10))[0]?.attempts).toBe(2); + expect((await claimDue(10, RESEARCH))[0]?.attempts).toBe(2); }); it("stops claiming once the work is finished", async () => { const task = await queue(); - await claimDue(10); + await claimDue(10, RESEARCH); await completeTask(task.id, "ran"); await db.agentTask.update({ @@ -131,7 +139,7 @@ describe("claimDue", () => { data: { leasedUntil: null }, }); - expect(await claimDue(10)).toHaveLength(0); + expect(await claimDue(10, RESEARCH)).toHaveLength(0); }); }); @@ -141,7 +149,7 @@ describe("retireExhausted", () => { const task = await queue({ contactId: contact.id }); for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { - await claimDue(10); + await claimDue(10, RESEARCH); await expire(task.id); } @@ -158,7 +166,7 @@ describe("retireExhausted", () => { const task = await queue(); for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { - await claimDue(10); + await claimDue(10, RESEARCH); if (attempt < MAX_ATTEMPTS - 1) await expire(task.id); } @@ -167,7 +175,7 @@ describe("retireExhausted", () => { it("leaves work that still has attempts left", async () => { await queue(); - await claimDue(10); + await claimDue(10, RESEARCH); expect(await retireExhausted()).toHaveLength(0); }); @@ -177,7 +185,7 @@ describe("completeTask", () => { it("retires a row once, and reports who it was about", async () => { const contact = await someone(); const task = await queue({ contactId: contact.id }); - await claimDue(10); + await claimDue(10, RESEARCH); const subject = await completeTask(task.id, "ran"); expect(subject?.contactId).toBe(contact.id); diff --git a/apps/agent/test/workspace.spec.ts b/apps/agent/test/workspace.spec.ts new file mode 100644 index 000000000..6483afee2 --- /dev/null +++ b/apps/agent/test/workspace.spec.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "bun:test"; +import { + MAX_LINE, + MAX_NARRATIVE, + profileOf, + trimSections, +} from "@crm/db/workspace"; +import { composeClosing } from "../agent/lib/preamble"; +import { usMarkdown, type WorkspaceIdentity } from "../agent/lib/workspace"; + +const us: WorkspaceIdentity = { + name: "Comp AI", + website: "trycomp.ai", + profile: { + website: "trycomp.ai", + narrative: + "Comp AI takes a startup from nothing to a SOC 2 or ISO 27001 audit by automating the evidence collection, and sells the platform on an annual subscription.", + sections: { + sells: "Compliance automation for SOC 2, ISO 27001 and GDPR", + sellsTo: "Series A to C startups facing their first framework audit", + edge: "Getting there in weeks rather than months", + }, + sourceUrl: "https://trycomp.ai", + refreshedAt: new Date("2026-08-01T00:00:00.000Z"), + }, +}; + +describe("who we are", () => { + it("says nothing at all when the install has no workspace", () => { + expect(usMarkdown(null)).toBe(""); + }); + + it("names us, and refuses to invent the rest", () => { + const markdown = usMarkdown({ + name: "Comp AI", + website: "trycomp.ai", + profile: null, + }); + + expect(markdown).toContain("Comp AI"); + expect(markdown).toContain("trycomp.ai"); + expect(markdown).toContain("do not guess"); + }); + + it("states what we sell and who to", () => { + const markdown = usMarkdown(us); + + expect(markdown).toContain("Comp AI"); + expect(markdown).toContain("takes a startup from nothing"); + expect(markdown).toContain("Compliance automation"); + expect(markdown).toContain("Series A to C startups"); + }); + + it("tells the agent what the context is for, and what it is not for", () => { + const markdown = usMarkdown(us); + + expect(markdown).toContain("means for us"); + expect(markdown).toContain("never write a pitch"); + }); + + it("stays small enough to sit in front of every session", () => { + expect(usMarkdown(us).length).toBeLessThan(1_000); + }); +}); + +describe("every session is told who we are", () => { + it("carries the block in front of the capabilities", () => { + const closing = composeClosing(us); + + expect(closing).toContain("## Who we are"); + expect(closing.indexOf("## Who we are")).toBeLessThan( + closing.indexOf("## What you can use here"), + ); + }); + + it("degrades to the capabilities alone", () => { + expect(composeClosing(null)).not.toContain("## Who we are"); + expect(composeClosing(null)).toContain("## What you can use here"); + }); +}); + +describe("a profile belongs to the website it was read from", () => { + const profile = us.profile; + + it("is ours while the website is unchanged", () => { + expect(profileOf(profile, "trycomp.ai")).toBe(profile); + }); + + it("is dropped the moment the website changes", () => { + expect(profileOf(profile, "somewhere-else.com")).toBeNull(); + }); + + it("is dropped when the website is taken away", () => { + expect(profileOf(profile, null)).toBeNull(); + }); +}); + +describe("the profile cannot grow", () => { + it("clamps a line that runs on", () => { + const { sells } = trimSections({ sells: "a".repeat(MAX_LINE + 50) }); + + expect(sells).toHaveLength(MAX_LINE); + expect(sells?.endsWith("…")).toBe(true); + }); + + it("drops what the site did not say", () => { + expect(trimSections({ sells: " ", sellsTo: undefined })).toEqual({}); + }); + + it("keeps the narrative shorter than a paragraph", () => { + expect(MAX_NARRATIVE).toBeLessThanOrEqual(400); + }); +}); diff --git a/apps/api/src/agent/agent-trigger.service.ts b/apps/api/src/agent/agent-trigger.service.ts index b3ad1669c..ddd1966d0 100644 --- a/apps/api/src/agent/agent-trigger.service.ts +++ b/apps/api/src/agent/agent-trigger.service.ts @@ -1,7 +1,10 @@ import type { Db } from "@crm/db"; +import { PRIORITY } from "@crm/db/agent-tasks"; import { Injectable, Logger } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; +const POKE_TIMEOUT_MS = 2_000; + @Injectable() export class AgentTriggerService { private readonly logger = new Logger(AgentTriggerService.name); @@ -12,31 +15,56 @@ export class AgentTriggerService { companyId: string, reason = "New company", ): Promise { + await this.enqueue({ + companyId, + kind: "brand", + reason, + priority: PRIORITY.brand, + budget: 2, + }); + await this.enqueue({ companyId, kind: "company-profile", reason, - priority: 10, + priority: PRIORITY.companyProfile, budget: 4, }); } async companyRequested(companyId: string, reason: string): Promise { + await this.enqueue({ + companyId, + kind: "brand", + reason, + priority: PRIORITY.brand, + budget: 2, + }); + await this.enqueue({ companyId, kind: "company-profile", reason, - priority: 100, + priority: PRIORITY.requested, budget: 8, }); } + async workspaceChanged(website: string, reason: string): Promise { + await this.enqueue({ + kind: "workspace-profile", + reason: `${reason} (${website})`, + priority: PRIORITY.workspace, + budget: 4, + }); + } + async contactCreated(contactId: string, reason: string): Promise { await this.enqueue({ contactId, kind: "identify", reason, - priority: 20, + priority: PRIORITY.identify, budget: 4, }); } @@ -46,7 +74,7 @@ export class AgentTriggerService { contactId, kind: "meeting-prep", reason: `Meeting on ${when.toDateString()} with someone we know nothing about`, - priority: 200, + priority: PRIORITY.meeting, budget: 10, }); } @@ -57,6 +85,7 @@ export class AgentTriggerService { contactIds?: string[]; companyIds?: string[]; budget?: number; + priority?: number; }): Promise<{ queued: number; alreadyQueued: number }> { const subject = input.contactIds ? "contactId" : "companyId"; const ids = [...new Set(input.contactIds ?? input.companyIds ?? [])]; @@ -84,7 +113,7 @@ export class AgentTriggerService { companyId: input.companyIds ? id : null, kind: input.kind, reason: input.reason, - priority: 50, + priority: input.priority ?? PRIORITY.sweep, budget: input.budget ?? 4, dueAt: new Date(), })), @@ -98,6 +127,8 @@ export class AgentTriggerService { alreadyQueued: ids.length - fresh.length, }); + if (fresh.length > 0) this.poke(); + return { queued: fresh.length, alreadyQueued: ids.length - fresh.length, @@ -150,6 +181,8 @@ export class AgentTriggerService { contactId: task.contactId, companyId: task.companyId, }); + + this.poke(); } catch (error) { this.logger.error( { message: "Could not queue agent task", kind: task.kind }, @@ -157,4 +190,22 @@ export class AgentTriggerService { ); } } + + private poke(): void { + const secret = process.env.AGENT_BRIDGE_SECRET?.trim(); + if (!secret) return; + + const base = process.env.AGENT_URL?.trim() || "http://127.0.0.1:2000"; + + void fetch(new URL("/internal/crm/dispatch", base), { + method: "POST", + headers: { authorization: `Bearer ${secret}` }, + signal: AbortSignal.timeout(POKE_TIMEOUT_MS), + }).catch((error) => { + this.logger.debug({ + message: "Agent poke did not land; the cron will pick this up", + reason: error instanceof Error ? error.message : String(error), + }); + }); + } } diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index ee2d875cb..66ecce7d2 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -20,8 +20,10 @@ import { LoggingModule } from "./logging/logging.module"; import { logAuthRoute } from "./logging/request-logger.middleware"; import { SearchModule } from "./search/search.module"; import { SettingsModule } from "./settings/settings.module"; +import { SsoModule } from "./sso/sso.module"; import { TrpcModule } from "./trpc/trpc.module"; import { UsersModule } from "./users/users.module"; +import { WorkspaceModule } from "./workspace/workspace.module"; @Module({ imports: [ @@ -48,6 +50,8 @@ import { UsersModule } from "./users/users.module"; SearchModule, GoogleModule, SettingsModule, + WorkspaceModule, + SsoModule, BackfillModule, ], }) diff --git a/apps/api/src/backfill/backfill.service.ts b/apps/api/src/backfill/backfill.service.ts index f8a749fa1..8668d3944 100644 --- a/apps/api/src/backfill/backfill.service.ts +++ b/apps/api/src/backfill/backfill.service.ts @@ -1,5 +1,7 @@ import { onSignedIn } from "@crm/auth"; import { type Db, EnrichmentStatus, type Prisma } from "@crm/db"; +import { PRIORITY } from "@crm/db/agent-tasks"; +import { readWorkspaceIdentity } from "@crm/db/workspace"; import { CACHE_MANAGER } from "@nestjs/cache-manager"; import { Inject, Injectable, Logger, type OnModuleInit } from "@nestjs/common"; import type { Cache } from "cache-manager"; @@ -38,6 +40,8 @@ const AUTO_EVERY_MS = 5 * 60_000; */ const RECHECK_PHOTO_AFTER_MS = 30 * 24 * 60 * 60_000; +const RECHECK_BRAND_AFTER_MS = 30 * 24 * 60 * 60_000; + @Injectable() export class BackfillService implements OnModuleInit { private readonly logger = new Logger(BackfillService.name); @@ -62,6 +66,8 @@ export class BackfillService implements OnModuleInit { void (async () => { try { + await this.sweepWorkspace(); + const companies = await this.runCompanies(false); const contacts = await this.runContacts(); @@ -85,6 +91,17 @@ export class BackfillService implements OnModuleInit { return { started: true }; } + private async sweepWorkspace(): Promise { + const us = await readWorkspaceIdentity(this.db); + + if (!us?.website || us.profile) return; + + await this.agent.workspaceChanged( + us.website, + "We still have no profile of the company using this CRM", + ); + } + async run(scope: BackfillScope): Promise { if (scope === "contacts") return this.runContacts(); @@ -107,12 +124,42 @@ export class BackfillService implements OnModuleInit { }), ]); - const queued = await this.agent.backfill({ + const artwork: Prisma.CompanyWhereInput = { + ...(await this.companiesNeedingArtwork()), + ...(dealsOnly ? { deals: { some: {} } } : {}), + }; + + const artworkRows = await this.db.company.findMany({ + where: artwork, + orderBy: { createdAt: "asc" }, + take: MAX_PER_RUN, + select: { id: true }, + }); + + const brand = await this.agent.backfill({ + kind: "brand", + reason: "Backfill — this company has no logo or icon", + companyIds: [ + ...new Set([ + ...rows.map((row) => row.id), + ...artworkRows.map((row) => row.id), + ]), + ], + budget: 2, + priority: PRIORITY.brand, + }); + + const profile = await this.agent.backfill({ kind: "company-profile", reason: "Backfill — this company was never successfully looked up", companyIds: rows.map((row) => row.id), }); + const queued = { + queued: brand.queued + profile.queued, + alreadyQueued: brand.alreadyQueued + profile.alreadyQueued, + }; + const iconsResolving = dealsOnly ? 0 : await this.sweepFavicons(); return { @@ -140,6 +187,7 @@ export class BackfillService implements OnModuleInit { reason: "Backfill — somewhere to look for a picture, and no picture", contactIds: photoRows.map((row) => row.id), budget: 1, + priority: PRIORITY.portrait, }); const headroom = MAX_PER_RUN - photoRows.length; @@ -201,6 +249,26 @@ export class BackfillService implements OnModuleInit { return { domain: { not: null }, enrichmentStatus: NEVER_SUCCEEDED }; } + private async companiesNeedingArtwork(): Promise { + const since = new Date(Date.now() - RECHECK_BRAND_AFTER_MS); + + const checked = await this.db.agentTask.findMany({ + where: { kind: "brand", finishedAt: { gte: since } }, + select: { companyId: true }, + }); + + const recentlyChecked = checked + .map((row) => row.companyId) + .filter((id): id is string => id !== null); + + return { + domain: { not: null }, + logoUrl: null, + iconUrl: null, + ...(recentlyChecked.length > 0 ? { id: { notIn: recentlyChecked } } : {}), + }; + } + /** * Contacts with a face to fetch and nowhere it has been put yet. * diff --git a/apps/api/src/config/env.validation.ts b/apps/api/src/config/env.validation.ts index 4ff62b569..97a9047a1 100644 --- a/apps/api/src/config/env.validation.ts +++ b/apps/api/src/config/env.validation.ts @@ -48,16 +48,13 @@ export class EnvironmentVariables { }) ALLOWED_SIGN_IN!: string; + @IsOptional() @IsString() - @MinLength(1, { - message: - "GOOGLE_CLIENT_ID is required — Google is the only sign-in method. Create an OAuth client ID (web) in the Google Cloud console.", - }) - GOOGLE_CLIENT_ID!: string; + GOOGLE_CLIENT_ID?: string; + @IsOptional() @IsString() - @MinLength(1, { message: "GOOGLE_CLIENT_SECRET is required." }) - GOOGLE_CLIENT_SECRET!: string; + GOOGLE_CLIENT_SECRET?: string; @IsOptional() @IsUrl({ require_tld: false }) @@ -91,6 +88,14 @@ export class EnvironmentVariables { @IsOptional() @IsString() BLOB_READ_WRITE_TOKEN?: string; + + @IsOptional() + @IsString() + AGENT_URL?: string; + + @IsOptional() + @IsString() + AGENT_BRIDGE_SECRET?: string; } export function validateEnv( diff --git a/apps/api/src/generated/server.ts b/apps/api/src/generated/server.ts index c393aab7a..c9b01f60e 100644 --- a/apps/api/src/generated/server.ts +++ b/apps/api/src/generated/server.ts @@ -21,6 +21,8 @@ import { dashboardSummaryInput } from "../dashboard/dashboard.contracts"; import { dealListInput, dealIdInput, dealCreateInput, dealUpdateArgs, setStageInput } from "../deals/deals.contracts"; import { setAutoCreateInput, suppressDomainInput, threadInput, calendarEventInput } from "../google/google.contracts"; import { setAgentModelInput } from "../settings/settings.contracts"; +import { ssoProviderListInput, registerSsoProviderInput, deleteSsoProviderInput } from "../sso/sso.contracts"; +import { memberListInput, updateWorkspaceInput, setMemberRoleInput } from "../workspace/workspace.contracts"; import type { ActivitiesRouter } from "../activities/activities.router"; import type { CompaniesRouter } from "../companies/companies.router"; import type { ContactsRouter } from "../contacts/contacts.router"; @@ -30,7 +32,9 @@ import type { DealsRouter } from "../deals/deals.router"; import type { GoogleRouter } from "../google/google.router"; import type { SearchRouter } from "../search/search.router"; import type { SettingsRouter } from "../settings/settings.router"; +import type { SsoRouter } from "../sso/sso.router"; import type { UsersRouter } from "../users/users.router"; +import type { WorkspaceRouter } from "../workspace/workspace.router"; const appRouter = t.router({ activities: t.router({ @@ -168,11 +172,39 @@ const appRouter = t.router({ .input(setAgentModelInput) .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) }), + sso: t.router({ + signInOptions: publicProcedure + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + settings: publicProcedure + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + list: publicProcedure + .input(ssoProviderListInput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + register: publicProcedure + .input(registerSsoProviderInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + remove: publicProcedure + .input(deleteSsoProviderInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) + }), 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>) + }), + workspace: t.router({ + get: publicProcedure + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + members: publicProcedure + .input(memberListInput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + update: publicProcedure + .input(updateWorkspaceInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + setMemberRole: publicProcedure + .input(setMemberRoleInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) }) }); diff --git a/apps/api/src/google/google-connection.service.ts b/apps/api/src/google/google-connection.service.ts index 01479d077..839bb938a 100644 --- a/apps/api/src/google/google-connection.service.ts +++ b/apps/api/src/google/google-connection.service.ts @@ -1,9 +1,11 @@ +import { isGoogleConfigured, signsInWithGoogle } from "@crm/auth"; import { type Db, GoogleSyncStatus } from "@crm/db"; import { Injectable, Logger, NotFoundException } from "@nestjs/common"; import { normalizeDomain } from "../companies/domain"; import { ActivityStampService } from "../crm/activity-stamp.service"; import { InjectDatabase } from "../database/database.constants"; import { + GOOGLE_PROVIDER_ID, SCOPE_FOR_SOURCE, SYNC_SOURCES, type SyncSource, @@ -22,6 +24,9 @@ export type SourceStatus = { }; export type ConnectionStatus = { + configured: boolean; + linked: boolean; + required: boolean; hasRefreshToken: boolean; sources: SourceStatus[]; }; @@ -41,10 +46,11 @@ export class GoogleConnectionService { async status(userId: string): Promise { await this.onConnected(userId); - const [granted, rows, hasRefreshToken] = await Promise.all([ + const [granted, rows, hasRefreshToken, accounts] = await Promise.all([ this.tokens.grantedScopes(userId), this.state.listForUser(userId), this.tokens.hasRefreshToken(userId), + this.tokens.signInAccounts(userId), ]); const bySource = new Map(rows.map((row) => [row.source, row])); @@ -63,7 +69,15 @@ export class GoogleConnectionService { }; }); - return { hasRefreshToken, sources }; + return { + configured: isGoogleConfigured(), + linked: accounts.some( + (account) => account.providerId === GOOGLE_PROVIDER_ID, + ), + required: signsInWithGoogle(accounts), + hasRefreshToken, + sources, + }; } async onConnected(userId: string): Promise { diff --git a/apps/api/src/google/google-token.service.ts b/apps/api/src/google/google-token.service.ts index f2442be5f..41586fd22 100644 --- a/apps/api/src/google/google-token.service.ts +++ b/apps/api/src/google/google-token.service.ts @@ -1,4 +1,4 @@ -import { auth } from "@crm/auth"; +import { auth, type SignInAccount } from "@crm/auth"; import { type Db } from "@crm/db"; import { Injectable, Logger } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; @@ -39,6 +39,13 @@ export class GoogleTokenService { return scopes.includes(SCOPE_FOR_SOURCE[source]); } + async signInAccounts(userId: string): Promise { + return this.db.account.findMany({ + where: { userId }, + select: { providerId: true, scope: true }, + }); + } + async hasRefreshToken(userId: string): Promise { const account = await this.db.account.findFirst({ where: { userId, providerId: GOOGLE_PROVIDER_ID }, diff --git a/apps/api/src/google/google.constants.ts b/apps/api/src/google/google.constants.ts index f8dc20874..3d48d833f 100644 --- a/apps/api/src/google/google.constants.ts +++ b/apps/api/src/google/google.constants.ts @@ -1,6 +1,11 @@ import { CALENDAR_SCOPE, GMAIL_SCOPE } from "@crm/auth"; -export { CALENDAR_SCOPE, GMAIL_SCOPE, SYNC_SCOPES } from "@crm/auth"; +export { + CALENDAR_SCOPE, + GMAIL_SCOPE, + GOOGLE_PROVIDER_ID, + SYNC_SCOPES, +} from "@crm/auth"; export const SYNC_SOURCES = ["calendar", "gmail"] as const; export type SyncSource = (typeof SYNC_SOURCES)[number]; @@ -9,5 +14,3 @@ export const SCOPE_FOR_SOURCE: Record = { calendar: CALENDAR_SCOPE, gmail: GMAIL_SCOPE, }; - -export const GOOGLE_PROVIDER_ID = "google"; diff --git a/apps/api/src/sso/sso.contracts.ts b/apps/api/src/sso/sso.contracts.ts new file mode 100644 index 000000000..d56207908 --- /dev/null +++ b/apps/api/src/sso/sso.contracts.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; +import { listInput } from "../trpc/list-input"; + +export const ssoProviderListInput = listInput; + +export const registerSsoProviderInput = z.object({ + providerId: z + .string() + .trim() + .min(1) + .max(64) + .regex( + /^[a-z0-9][a-z0-9-]*$/, + "Use lower-case letters, numbers and hyphens.", + ), + issuer: z.string().trim().url().max(512), + domain: z.string().trim().min(1).max(255), + clientId: z.string().trim().min(1).max(512), + clientSecret: z.string().trim().min(1).max(1024), +}); + +export const deleteSsoProviderInput = z.object({ + providerId: z.string().trim().min(1).max(64), +}); + +export type SsoProviderListInput = z.infer; +export type RegisterSsoProviderInput = z.infer; +export type DeleteSsoProviderInput = z.infer; diff --git a/apps/api/src/sso/sso.module.ts b/apps/api/src/sso/sso.module.ts new file mode 100644 index 000000000..bb1716b9d --- /dev/null +++ b/apps/api/src/sso/sso.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { TrpcModule } from "../trpc/trpc.module"; +import { SsoRouter } from "./sso.router"; +import { SsoService } from "./sso.service"; + +@Module({ + imports: [TrpcModule], + providers: [SsoService, SsoRouter], + exports: [SsoService], +}) +export class SsoModule {} diff --git a/apps/api/src/sso/sso.router.ts b/apps/api/src/sso/sso.router.ts new file mode 100644 index 000000000..84ba0ca73 --- /dev/null +++ b/apps/api/src/sso/sso.router.ts @@ -0,0 +1,63 @@ +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 { + deleteSsoProviderInput, + registerSsoProviderInput, + ssoProviderListInput, +} from "./sso.contracts"; +import { SsoService } from "./sso.service"; + +function headersOf(ctx: BaseTrpcContext): Headers { + return fromNodeHeaders(ctx.req?.headers ?? {}); +} + +@Router({ alias: "sso" }) +export class SsoRouter { + constructor(@Inject(SsoService) private readonly sso: SsoService) {} + + @Query() + async signInOptions() { + return this.sso.signInOptions(); + } + + @Query() + @UseMiddlewares(AuthMiddleware) + async settings(@Ctx() ctx: AuthedTrpcContext) { + return this.sso.settings(ctx.user.id); + } + + @Query({ input: ssoProviderListInput }) + @UseMiddlewares(AuthMiddleware) + async list(@Input() input: z.infer) { + return this.sso.list(input); + } + + @Mutation({ input: registerSsoProviderInput }) + @UseMiddlewares(AuthMiddleware) + async register( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.sso.register(ctx.user.id, headersOf(ctx), input); + } + + @Mutation({ input: deleteSsoProviderInput }) + @UseMiddlewares(AuthMiddleware) + async remove( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.sso.remove(ctx.user.id, headersOf(ctx), input); + } +} diff --git a/apps/api/src/sso/sso.service.ts b/apps/api/src/sso/sso.service.ts new file mode 100644 index 000000000..b0783bfc1 --- /dev/null +++ b/apps/api/src/sso/sso.service.ts @@ -0,0 +1,310 @@ +import { + auth, + canConfigureSso, + isGoogleConfigured, + isWorkspaceRole, + ssoCallbackBase, + ssoCallbackURL, + ssoProviderName, + WORKSPACE_ID, + type WorkspaceRole, +} from "@crm/auth"; +import type { Db, Prisma } from "@crm/db"; +import { + BadRequestException, + ForbiddenException, + HttpException, + Injectable, + InternalServerErrorException, + Logger, +} from "@nestjs/common"; +import { APIError } from "better-auth/api"; +import { InjectDatabase } from "../database/database.constants"; +import { type ListResult, paginate, resolveOrderBy } from "../trpc/list-input"; +import type { + DeleteSsoProviderInput, + RegisterSsoProviderInput, + SsoProviderListInput, +} from "./sso.contracts"; + +export interface PublicSsoProvider { + providerId: string; + name: string; +} + +export interface SignInOptions { + google: 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, + domain: true, + oidcConfig: true, + samlConfig: true, +} satisfies Prisma.SsoProviderSelect; + +type ProviderRow = Prisma.SsoProviderGetPayload<{ + select: typeof PROVIDER_SELECT; +}>; + +const SORTABLE: Record< + string, + (dir: "asc" | "desc") => Prisma.SsoProviderOrderByWithRelationInput +> = { + providerId: (dir) => ({ providerId: dir }), + domain: (dir) => ({ domain: dir }), + issuer: (dir) => ({ issuer: dir }), +}; + +const STATUS_BY_CODE: Record = { + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + CONFLICT: 409, + UNPROCESSABLE_ENTITY: 400, +}; + +function splitDomains(value: string): string[] { + return value + .split(",") + .map((part) => + part + .trim() + .replace(/^https?:\/\//i, "") + .replace(/\/.*$/, ""), + ) + .map((part) => part.toLowerCase()) + .filter(Boolean); +} + +function lastFour(clientId: unknown): string | null { + return typeof clientId === "string" && clientId.length >= 4 + ? clientId.slice(-4) + : null; +} + +function parseConfig(value: string | null): Record | null { + if (!value) return null; + + try { + const parsed: unknown = JSON.parse(value); + return parsed && typeof parsed === "object" + ? (parsed as Record) + : null; + } catch { + return null; + } +} + +function toProvider(row: ProviderRow): SsoProvider { + const oidc = parseConfig(row.oidcConfig); + + return { + providerId: row.providerId, + name: ssoProviderName(row.providerId), + type: row.samlConfig ? "saml" : "oidc", + issuer: row.issuer, + domains: splitDomains(row.domain), + clientIdLastFour: oidc ? lastFour(oidc.clientId) : null, + callbackURL: ssoCallbackURL(row.providerId), + }; +} + +@Injectable() +export class SsoService { + private readonly logger = new Logger(SsoService.name); + + constructor(@InjectDatabase() private readonly db: Db) {} + + async signInOptions(): Promise { + const rows = await this.db.ssoProvider.findMany({ + where: { organizationId: WORKSPACE_ID }, + select: { providerId: true }, + orderBy: { providerId: "asc" }, + }); + + return { + google: isGoogleConfigured(), + providers: rows.map((row) => ({ + providerId: row.providerId, + name: ssoProviderName(row.providerId), + })), + }; + } + + async settings(userId: string): Promise { + return { + canConfigure: canConfigureSso(await this.roleOf(userId)), + callbackBase: ssoCallbackBase(), + }; + } + + async list(input: SsoProviderListInput): Promise> { + const where = this.searchWhere(input.q); + const { skip, take } = paginate(input); + + const [rows, total] = await Promise.all([ + this.db.ssoProvider.findMany({ + where, + skip, + take, + select: PROVIDER_SELECT, + orderBy: resolveOrderBy(input, SORTABLE, { providerId: "asc" }), + }), + this.db.ssoProvider.count({ where }), + ]); + + return { rows: rows.map(toProvider), total, facetCounts: {} }; + } + + async register( + userId: string, + headers: Headers, + input: RegisterSsoProviderInput, + ): Promise { + await this.requireConfigurer(userId); + + const domains = splitDomains(input.domain); + + if (domains.length === 0) { + throw new BadRequestException( + "Give the email domain your people sign in with, for example acme.com.", + ); + } + + await this.call(() => + auth.api.registerSSOProvider({ + headers, + body: { + providerId: input.providerId, + issuer: input.issuer, + domain: domains.join(","), + organizationId: WORKSPACE_ID, + oidcConfig: { + clientId: input.clientId, + clientSecret: input.clientSecret, + pkce: true, + }, + }, + }), + ); + + this.logger.log({ + message: "SSO provider registered", + userId, + providerId: input.providerId, + issuer: input.issuer, + }); + + const row = await this.db.ssoProvider.findUniqueOrThrow({ + where: { providerId: input.providerId }, + select: PROVIDER_SELECT, + }); + + return toProvider(row); + } + + async remove( + userId: string, + headers: Headers, + input: DeleteSsoProviderInput, + ): Promise<{ providerId: string }> { + await this.requireConfigurer(userId); + + await this.call(() => + auth.api.deleteSSOProvider({ + headers, + body: { providerId: input.providerId }, + }), + ); + + this.logger.log({ + message: "SSO provider removed", + userId, + providerId: input.providerId, + }); + + return { providerId: input.providerId }; + } + + private searchWhere(q: string): Prisma.SsoProviderWhereInput { + const term = q.trim(); + const where: Prisma.SsoProviderWhereInput = { + organizationId: WORKSPACE_ID, + }; + + if (term) { + where.OR = [ + { providerId: { contains: term, mode: "insensitive" } }, + { domain: { contains: term, mode: "insensitive" } }, + { issuer: { contains: term, mode: "insensitive" } }, + ]; + } + + return where; + } + + private async call(run: () => Promise): Promise { + try { + return await run(); + } catch (error) { + if (error instanceof APIError) { + const status = + STATUS_BY_CODE[error.body?.code ?? ""] ?? error.statusCode; + + throw new HttpException( + error.body?.message ?? "The identity provider could not be saved.", + typeof status === "number" ? status : 400, + ); + } + + this.logger.error( + { message: "SSO provider call failed" }, + error instanceof Error ? error.stack : String(error), + ); + + throw new InternalServerErrorException( + "Could not reach the identity provider.", + ); + } + } + + private async requireConfigurer(userId: string): Promise { + if (!canConfigureSso(await this.roleOf(userId))) { + throw new ForbiddenException( + "Only an owner or an admin can change how people sign in.", + ); + } + } + + private async roleOf(userId: string): Promise { + const member = await this.db.member.findUnique({ + where: { + organizationId_userId: { organizationId: WORKSPACE_ID, userId }, + }, + select: { role: true }, + }); + + if (!member) return null; + + return isWorkspaceRole(member.role) ? member.role : "member"; + } +} diff --git a/apps/api/src/workspace/workspace.contracts.ts b/apps/api/src/workspace/workspace.contracts.ts new file mode 100644 index 000000000..a79a53da4 --- /dev/null +++ b/apps/api/src/workspace/workspace.contracts.ts @@ -0,0 +1,22 @@ +import { WORKSPACE_ROLES } from "@crm/auth"; +import { z } from "zod"; +import { listInput } from "../trpc/list-input"; + +export const memberListInput = listInput.extend({ + role: z.string().default("all"), +}); + +export type MemberListInput = z.infer; + +export const updateWorkspaceInput = z.object({ + name: z.string().trim().min(1).max(120), + website: z.string().trim().min(1).max(255), +}); + +export const setMemberRoleInput = z.object({ + memberId: z.string().min(1), + role: z.enum(WORKSPACE_ROLES), +}); + +export type UpdateWorkspaceInput = z.infer; +export type SetMemberRoleInput = z.infer; diff --git a/apps/api/src/workspace/workspace.module.ts b/apps/api/src/workspace/workspace.module.ts new file mode 100644 index 000000000..f16a701a7 --- /dev/null +++ b/apps/api/src/workspace/workspace.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; +import { AgentModule } from "../agent/agent.module"; +import { TrpcModule } from "../trpc/trpc.module"; +import { WorkspaceRouter } from "./workspace.router"; +import { WorkspaceService } from "./workspace.service"; + +@Module({ + imports: [AgentModule, TrpcModule], + providers: [WorkspaceService, WorkspaceRouter], + exports: [WorkspaceService], +}) +export class WorkspaceModule {} diff --git a/apps/api/src/workspace/workspace.router.ts b/apps/api/src/workspace/workspace.router.ts new file mode 100644 index 000000000..ea8e03241 --- /dev/null +++ b/apps/api/src/workspace/workspace.router.ts @@ -0,0 +1,55 @@ +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 { + memberListInput, + setMemberRoleInput, + updateWorkspaceInput, +} from "./workspace.contracts"; +import { WorkspaceService } from "./workspace.service"; + +@Router({ alias: "workspace" }) +@UseMiddlewares(AuthMiddleware) +export class WorkspaceRouter { + constructor( + @Inject(WorkspaceService) private readonly workspace: WorkspaceService, + ) {} + + @Query() + async get(@Ctx() ctx: AuthedTrpcContext) { + return this.workspace.get(ctx.user.id); + } + + @Query({ input: memberListInput }) + async members( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.workspace.members(ctx.user.id, input); + } + + @Mutation({ input: updateWorkspaceInput }) + async update( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.workspace.update(ctx.user.id, input); + } + + @Mutation({ input: setMemberRoleInput }) + async setMemberRole( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.workspace.setMemberRole(ctx.user.id, input); + } +} diff --git a/apps/api/src/workspace/workspace.service.ts b/apps/api/src/workspace/workspace.service.ts new file mode 100644 index 000000000..ed42927df --- /dev/null +++ b/apps/api/src/workspace/workspace.service.ts @@ -0,0 +1,300 @@ +import { + canChangeRole, + canRenameWorkspace, + ensureWorkspaceMembership, + isWorkspaceRole, + WORKSPACE_ID, + type WorkspaceRole, +} from "@crm/auth"; +import type { Db, Prisma } from "@crm/db"; +import { isOnboarded, markOnboarded } from "@crm/db/workspace"; +import { + BadRequestException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { AgentTriggerService } from "../agent/agent-trigger.service"; +import { InjectDatabase } from "../database/database.constants"; +import { + countsByKey, + FACET_ALL, + type ListResult, + paginate, + resolveOrderBy, +} from "../trpc/list-input"; +import type { + MemberListInput, + SetMemberRoleInput, + UpdateWorkspaceInput, +} from "./workspace.contracts"; + +export interface Workspace { + id: 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, + createdAt: true, + userId: true, + user: { select: { name: true, email: true, image: true } }, +} as const; + +type MemberRow = Prisma.MemberGetPayload<{ select: typeof MEMBER_SELECT }>; + +const SORTABLE: Record< + string, + (dir: Prisma.SortOrder) => Prisma.MemberOrderByWithRelationInput +> = { + name: (dir) => ({ user: { name: dir } }), + email: (dir) => ({ user: { email: dir } }), + role: (dir) => ({ role: dir }), + joinedAt: (dir) => ({ createdAt: dir }), +}; + +function normalizeWebsite(value: string | null): string | null { + const trimmed = value?.trim(); + + if (!trimmed) return null; + + const bare = trimmed + .replace(/^https?:\/\//i, "") + .replace(/\/+$/, "") + .trim(); + + return bare || null; +} + +function toRole(value: string): WorkspaceRole { + return isWorkspaceRole(value) ? value : "member"; +} + +@Injectable() +export class WorkspaceService { + private readonly logger = new Logger(WorkspaceService.name); + + constructor( + @InjectDatabase() private readonly db: Db, + private readonly agent: AgentTriggerService, + ) {} + + async get(userId: string): Promise { + const row = await this.db.organization.findUnique({ + where: { id: WORKSPACE_ID }, + select: { id: true, name: true, website: true, metadata: true }, + }); + + if (!row) { + await ensureWorkspaceMembership(userId); + return this.get(userId); + } + + const role = await this.roleOf(userId); + + return { + id: row.id, + name: row.name, + website: row.website, + onboarded: isOnboarded(row.metadata), + viewerRole: role, + canRename: canRenameWorkspace(role), + canChangeRoles: canChangeRole(role), + }; + } + + async update( + userId: string, + input: UpdateWorkspaceInput, + ): Promise { + const role = await this.roleOf(userId); + + if (!canRenameWorkspace(role)) { + throw new ForbiddenException( + "Only an owner or an admin can change the workspace.", + ); + } + + const before = await this.db.organization.findUnique({ + where: { id: WORKSPACE_ID }, + select: { website: true, metadata: true }, + }); + + const website = normalizeWebsite(input.website); + + if (!website) { + throw new BadRequestException( + "That is not a website. Enter the domain, like acme.com.", + ); + } + + await this.db.organization.update({ + where: { id: WORKSPACE_ID }, + data: { + name: input.name, + website, + metadata: markOnboarded(before?.metadata ?? null, new Date()), + }, + }); + + this.logger.log({ message: "Workspace updated", userId }); + + if (website && website !== before?.website) { + await this.agent.workspaceChanged( + website, + before?.website + ? "The company using this CRM changed its website" + : "The company using this CRM said what its website is", + ); + } + + return this.get(userId); + } + + async members( + userId: string, + input: MemberListInput, + ): Promise> { + const where = this.buildWhere(input); + const { skip, take } = paginate(input); + + const [rows, total, roles] = await Promise.all([ + this.db.member.findMany({ + where, + skip, + take, + select: MEMBER_SELECT, + orderBy: resolveOrderBy(input, SORTABLE, { createdAt: "asc" }), + }), + this.db.member.count({ where }), + this.db.member.groupBy({ + by: ["role"], + where: this.searchWhere(input.q), + _count: { _all: true }, + }), + ]); + + return { + rows: rows.map((row) => this.toMember(row, userId)), + total, + facetCounts: { role: countsByKey(roles, "role") }, + }; + } + + async setMemberRole( + userId: string, + input: SetMemberRoleInput, + ): Promise { + const role = await this.roleOf(userId); + + if (!canChangeRole(role)) { + throw new ForbiddenException( + "Only an owner or an admin can change a member's role.", + ); + } + + const target = await this.db.member.findFirst({ + where: { id: input.memberId, organizationId: WORKSPACE_ID }, + select: { id: true, role: true }, + }); + + if (!target) { + throw new NotFoundException("That person is not in this workspace."); + } + + if (target.role === "owner" && input.role !== "owner") { + const owners = await this.db.member.count({ + where: { organizationId: WORKSPACE_ID, role: "owner" }, + }); + + if (owners <= 1) { + throw new ForbiddenException( + "The workspace needs an owner. Make someone else an owner first.", + ); + } + } + + const updated = await this.db.member.update({ + where: { id: target.id }, + data: { role: input.role }, + select: MEMBER_SELECT, + }); + + this.logger.log({ + message: "Workspace role changed", + userId, + memberId: target.id, + role: input.role, + }); + + return this.toMember(updated, userId); + } + + private toMember(row: MemberRow, userId: string): WorkspaceMember { + return { + id: row.id, + userId: row.userId, + name: row.user.name, + email: row.user.email, + image: row.user.image, + role: toRole(row.role), + joinedAt: row.createdAt.toISOString(), + isViewer: row.userId === userId, + }; + } + + private searchWhere(q: string): Prisma.MemberWhereInput { + const term = q.trim(); + const where: Prisma.MemberWhereInput = { organizationId: WORKSPACE_ID }; + + if (term) { + where.user = { + OR: [ + { name: { contains: term, mode: "insensitive" } }, + { email: { contains: term, mode: "insensitive" } }, + ], + }; + } + + return where; + } + + private buildWhere(input: MemberListInput): Prisma.MemberWhereInput { + const where = this.searchWhere(input.q); + + if (input.role !== FACET_ALL) { + where.role = input.role; + } + + return where; + } + + private async roleOf(userId: string): Promise { + const member = await this.db.member.findUnique({ + where: { + organizationId_userId: { organizationId: WORKSPACE_ID, userId }, + }, + select: { role: true }, + }); + + return member ? toRole(member.role) : null; + } +} diff --git a/apps/api/test/auth.e2e.spec.ts b/apps/api/test/auth.e2e.spec.ts index 7d7292cfa..54c11f99d 100644 --- a/apps/api/test/auth.e2e.spec.ts +++ b/apps/api/test/auth.e2e.spec.ts @@ -54,4 +54,20 @@ describe("Auth (e2e)", () => { expect(response.status).not.toBe(404); }); + + it("lets the sign-in page read what it may offer", async () => { + const response = await request(app.getHttpServer()) + .get("/api/trpc/sso.signInOptions") + .expect(200); + + expect(response.body.result.data).toEqual({ google: true, providers: [] }); + }); + + it("keeps the SSO configuration itself behind the session", async () => { + const response = await request(app.getHttpServer()).get( + "/api/trpc/sso.settings", + ); + + expect(response.status).toBe(401); + }); }); diff --git a/apps/api/test/sso.spec.ts b/apps/api/test/sso.spec.ts new file mode 100644 index 000000000..46486d7ec --- /dev/null +++ b/apps/api/test/sso.spec.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "bun:test"; +import { isGoogleConfigured, WORKSPACE_ID } from "@crm/auth"; +import type { Db } from "@crm/db"; +import { ForbiddenException } from "@nestjs/common"; +import { SsoService } from "../src/sso/sso.service"; + +type Row = { + providerId: string; + issuer: string; + domain: string; + oidcConfig: string | null; + samlConfig: string | null; +}; + +const LIST = { + q: "", + sort: "providerId", + dir: "asc" as const, + page: 1, + pageSize: 25, +}; + +function service(role: string | null, rows: Row[] = []) { + const seen: { providerWhere?: unknown } = {}; + + const db = { + member: { + findUnique: async () => (role === null ? null : { role }), + }, + ssoProvider: { + findMany: async ({ where }: { where: unknown }) => { + seen.providerWhere = where; + return rows; + }, + count: async () => rows.length, + }, + } as unknown as Db; + + return { sso: new SsoService(db), seen }; +} + +const OKTA: Row = { + providerId: "okta", + issuer: "https://acme.okta.com", + domain: "acme.com, subsidiary.com", + oidcConfig: JSON.stringify({ + clientId: "0oa1b2c3d4WXYZ", + clientSecret: "shhh", + }), + samlConfig: null, +}; + +describe("who may configure SSO", () => { + it("lets an owner and an admin", async () => { + for (const role of ["owner", "admin"]) { + const { sso } = service(role); + expect((await sso.settings("u1")).canConfigure).toBe(true); + } + }); + + it("refuses a member, and refuses them the writes too", async () => { + const { sso } = service("member"); + + expect((await sso.settings("u1")).canConfigure).toBe(false); + + expect( + sso.remove("u1", new Headers(), { providerId: "okta" }), + ).rejects.toBeInstanceOf(ForbiddenException); + + expect( + sso.register("u1", new Headers(), { + providerId: "okta", + issuer: "https://acme.okta.com", + domain: "acme.com", + clientId: "id", + clientSecret: "secret", + }), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it("refuses somebody who is not in the workspace at all", async () => { + const { sso } = service(null); + expect((await sso.settings("u1")).canConfigure).toBe(false); + }); +}); + +describe("what a provider looks like once it is saved", () => { + it("never hands back the client secret", async () => { + const { sso } = service("owner", [OKTA]); + const [provider] = (await sso.list(LIST)).rows; + + expect(JSON.stringify(provider)).not.toContain("shhh"); + expect(provider?.clientIdLastFour).toBe("WXYZ"); + }); + + it("splits the domains and names the callback the IdP needs", async () => { + const { sso } = service("owner", [OKTA]); + const [provider] = (await sso.list(LIST)).rows; + + expect(provider?.domains).toEqual(["acme.com", "subsidiary.com"]); + expect(provider?.type).toBe("oidc"); + expect(provider?.name).toBe("Okta"); + expect(provider?.callbackURL).toEndWith("/api/auth/sso/callback/okta"); + }); + + it("reads only the one workspace, never an organization it was passed", async () => { + const { sso, seen } = service("owner", [OKTA]); + await sso.list(LIST); + + expect(seen.providerWhere).toEqual({ organizationId: WORKSPACE_ID }); + }); + + it("searches the name, the domain and the issuer", async () => { + const { sso, seen } = service("owner", [OKTA]); + await sso.list({ ...LIST, q: " acme " }); + + expect(seen.providerWhere).toEqual({ + organizationId: WORKSPACE_ID, + OR: [ + { providerId: { contains: "acme", mode: "insensitive" } }, + { domain: { contains: "acme", mode: "insensitive" } }, + { issuer: { contains: "acme", mode: "insensitive" } }, + ], + }); + }); +}); + +describe("the sign-in page's read", () => { + it("carries the name and nothing else", async () => { + const { sso } = service(null, [OKTA]); + + expect((await sso.signInOptions()).providers).toEqual([ + { providerId: "okta", name: "Okta" }, + ]); + }); + + it("says whether Google is configured, so the page can offer nothing", async () => { + const { sso } = service(null, [OKTA]); + + expect((await sso.signInOptions()).google).toBe(isGoogleConfigured()); + }); +}); diff --git a/apps/app/app/(app)/companies/companies-table.tsx b/apps/app/app/(app)/companies/companies-table.tsx index 61aa00894..b87153663 100644 --- a/apps/app/app/(app)/companies/companies-table.tsx +++ b/apps/app/app/(app)/companies/companies-table.tsx @@ -21,6 +21,7 @@ import { 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 { useTableQuery } from "@/components/data-table/use-table-query"; import { useTRPC } from "@/lib/trpc/client"; import type { RouterOutputs } from "@/lib/trpc/types"; @@ -189,6 +190,7 @@ export function CompaniesTable() { return ( } columns={COLUMNS} rows={companies.data?.rows ?? []} total={companies.data?.total ?? 0} diff --git a/apps/app/app/(app)/companies/page.tsx b/apps/app/app/(app)/companies/page.tsx index 6609b03e1..d805a737e 100644 --- a/apps/app/app/(app)/companies/page.tsx +++ b/apps/app/app/(app)/companies/page.tsx @@ -1,6 +1,5 @@ import type { Metadata } from "next"; import type { SearchParams } from "nuqs/server"; -import { ListSearch } from "@/components/data-table/list-search"; import { PageShell, PageShellActions, @@ -47,7 +46,6 @@ export default async function CompaniesPage({ - diff --git a/apps/app/app/(app)/contacts/contacts-table.tsx b/apps/app/app/(app)/contacts/contacts-table.tsx index bf3be4fa4..e61481311 100644 --- a/apps/app/app/(app)/contacts/contacts-table.tsx +++ b/apps/app/app/(app)/contacts/contacts-table.tsx @@ -14,6 +14,7 @@ import { contactName } from "@/components/crm/contact-name"; 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 { useTableQuery } from "@/components/data-table/use-table-query"; import { useTRPC } from "@/lib/trpc/client"; import type { RouterOutputs } from "@/lib/trpc/types"; @@ -153,6 +154,7 @@ export function ContactsTable() { return ( } columns={COLUMNS} rows={contacts.data?.rows ?? []} total={contacts.data?.total ?? 0} diff --git a/apps/app/app/(app)/contacts/page.tsx b/apps/app/app/(app)/contacts/page.tsx index 63f6fd487..60efc7d16 100644 --- a/apps/app/app/(app)/contacts/page.tsx +++ b/apps/app/app/(app)/contacts/page.tsx @@ -1,6 +1,5 @@ import type { Metadata } from "next"; import type { SearchParams } from "nuqs/server"; -import { ListSearch } from "@/components/data-table/list-search"; import { PageShell, PageShellActions, @@ -48,7 +47,6 @@ export default async function ContactsPage({ Everyone in the pipeline. - diff --git a/apps/app/app/(app)/deals/deals-table.tsx b/apps/app/app/(app)/deals/deals-table.tsx index e9c39ce66..01cf6d8f9 100644 --- a/apps/app/app/(app)/deals/deals-table.tsx +++ b/apps/app/app/(app)/deals/deals-table.tsx @@ -15,6 +15,7 @@ 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 { DealStageMenu } from "@/components/crm/stage-change"; +import { ListSearch } from "@/components/data-table/list-search"; import { useTableQuery } from "@/components/data-table/use-table-query"; import { useTRPC } from "@/lib/trpc/client"; import type { RouterOutputs } from "@/lib/trpc/types"; @@ -162,6 +163,7 @@ export function DealsTable() { return ( } columns={COLUMNS} rows={deals.data?.rows ?? []} total={deals.data?.total ?? 0} diff --git a/apps/app/app/(app)/deals/page.tsx b/apps/app/app/(app)/deals/page.tsx index 60387667b..c9f2e0fdc 100644 --- a/apps/app/app/(app)/deals/page.tsx +++ b/apps/app/app/(app)/deals/page.tsx @@ -1,6 +1,5 @@ import type { Metadata } from "next"; import type { SearchParams } from "nuqs/server"; -import { ListSearch } from "@/components/data-table/list-search"; import { PageShell, PageShellActions, @@ -50,7 +49,6 @@ export default async function DealsPage({ - diff --git a/apps/app/app/(app)/layout.tsx b/apps/app/app/(app)/layout.tsx index ce20a7b9b..9b3af3610 100644 --- a/apps/app/app/(app)/layout.tsx +++ b/apps/app/app/(app)/layout.tsx @@ -4,6 +4,7 @@ import { QuickSwitcher } from "@/components/crm/quick-switcher"; import { RecordSheetHost } from "@/components/crm/record-sheet/record-sheet-host"; import { MobileNavProvider } from "@/components/mobile-nav"; import { requireGoogleAccess } from "@/lib/session"; +import { HydrateClient } from "@/lib/trpc/hydrate"; export default async function AppLayout({ children, @@ -15,13 +16,15 @@ export default async function AppLayout({ return (
- + + +
{children} diff --git a/apps/app/app/(app)/settings/google-connection.tsx b/apps/app/app/(app)/settings/connections/google-connection.tsx similarity index 72% rename from apps/app/app/(app)/settings/google-connection.tsx rename to apps/app/app/(app)/settings/connections/google-connection.tsx index 9bf32a58f..c346fd6b4 100644 --- a/apps/app/app/(app)/settings/google-connection.tsx +++ b/apps/app/app/(app)/settings/connections/google-connection.tsx @@ -2,6 +2,8 @@ import Launch from "@carbon/icons-react/es/Launch"; import Warning from "@carbon/icons-react/es/Warning"; +import { authClient } from "@crm/auth/client"; +import { SYNC_SCOPES } from "@crm/auth/scopes"; import { Alert, AlertAction, @@ -19,6 +21,7 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "@crm/ui/components/alert-dialog"; +import GoogleLogo from "@crm/ui/components/brand-logos/google"; import { Button } from "@crm/ui/components/button"; import { Card, @@ -31,6 +34,7 @@ import { } from "@crm/ui/components/card"; import { Icon } from "@crm/ui/components/icon"; import { Label } from "@crm/ui/components/label"; +import { Spinner } from "@crm/ui/components/spinner"; import { StatusIndicator } from "@crm/ui/components/status-indicator"; import { Switch } from "@crm/ui/components/switch"; import { relativeTimeFromIso } from "@crm/ui/lib/format"; @@ -98,7 +102,97 @@ function failureSignature( .join("|"); } -export function GoogleConnection() { +function GoogleUnavailable() { + return ( + + + Google + + Gmail and Calendar sync needs a Google OAuth client, and this install + does not have one. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in + the root .env file and restart. + + + + + + + + ); +} + +const CONNECT_ERRORS: Record = { + "email_doesn't_match": + "That Google account has a different email address to the one you sign in with, so it cannot be attached to your account. Connect the Google account that matches your sign-in address.", +}; + +function ConnectGoogle({ connectError }: { connectError?: string }) { + const [pending, setPending] = useState(false); + + async function handleConnect() { + setPending(true); + + const origin = window.location.origin; + + const { error } = await authClient.linkSocial({ + provider: "google", + scopes: [...SYNC_SCOPES], + callbackURL: `${origin}/settings/connections`, + errorCallbackURL: `${origin}/settings/connections`, + }); + + if (error) { + toast.error(error.message ?? "Could not reach Google."); + setPending(false); + } + } + + return ( + + + Google + + Connect Gmail and Calendar, and new meetings and email threads are + added to the matching company as they happen. It is read-only — + nothing is ever sent on your behalf. + + + + + + + + + {connectError ? ( + + + Google did not finish connecting + + {CONNECT_ERRORS[connectError] ?? + "Google returned an error before the connection was made. Try again."} + + + ) : null} + + + +

+ Only conversations with companies in the CRM are stored. Personal mail + is discarded without being saved. +

+
+
+ ); +} + +export function GoogleConnection({ connectError }: { connectError?: string }) { const trpc = useTRPC(); const cache = useCrmCache(); const queryClient = useQueryClient(); @@ -123,7 +217,10 @@ export function GoogleConnection() { const revoke = useMutation( trpc.google.revokeAccess.mutationOptions({ - onSuccess: () => window.location.assign("/"), + onSuccess: () => + window.location.assign( + status.data?.required ? "/" : "/settings/connections", + ), onError: (error) => toast.error(error.message), }), ); @@ -156,7 +253,11 @@ export function GoogleConnection() { if (!status.data) return null; - const { sources, hasRefreshToken } = status.data; + const { sources, hasRefreshToken, configured, linked, required } = + status.data; + + if (!configured) return ; + if (!linked) return ; const failing = sources.filter( (source) => source.status === "NEEDS_RECONNECT" || source.lastError, @@ -314,8 +415,9 @@ export function GoogleConnection() { Revoke Google access? - You will be signed out, and you cannot use the CRM again - until you grant access. + {required + ? "You will be signed out, and you cannot use the CRM again until you grant access." + : "New email and meetings stop arriving. Everything already synced stays, and you can connect Google again from this page."} diff --git a/apps/app/app/(app)/settings/connections/page.tsx b/apps/app/app/(app)/settings/connections/page.tsx new file mode 100644 index 000000000..e8d7d646c --- /dev/null +++ b/apps/app/app/(app)/settings/connections/page.tsx @@ -0,0 +1,56 @@ +import type { Metadata } from "next"; +import { + PageShell, + PageShellContent, + PageShellDescription, + PageShellHeader, + PageShellHeading, + PageShellTitle, +} from "@/components/page-shell"; +import { requireSession } from "@/lib/session"; +import { HydrateClient } from "@/lib/trpc/hydrate"; +import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; +import { GoogleConnection } from "./google-connection"; + +export const metadata: Metadata = { + title: "Connections", +}; + +export default async function ConnectionsSettingsPage({ + searchParams, +}: { + searchParams: Promise<{ error?: string | string[] }>; +}) { + await requireSession(); + + const trpc = getServerTrpc(); + const queryClient = getServerQueryClient(); + + const [{ error }] = await Promise.all([ + searchParams, + queryClient.prefetchQuery(trpc.google.status.queryOptions()), + ]); + + return ( + + + + Connections + + Your meetings and email, on the companies they belong to. + + + + + + +
+ +
+
+
+
+ ); +} diff --git a/apps/app/app/(app)/settings/layout.tsx b/apps/app/app/(app)/settings/layout.tsx new file mode 100644 index 000000000..1d56458c6 --- /dev/null +++ b/apps/app/app/(app)/settings/layout.tsx @@ -0,0 +1,14 @@ +import { SettingsSidebar } from "./settings-sidebar"; + +export default function SettingsLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( +
+ + {children} +
+ ); +} diff --git a/apps/app/app/(app)/settings/members/members-search-params.ts b/apps/app/app/(app)/settings/members/members-search-params.ts new file mode 100644 index 000000000..0c68e88a0 --- /dev/null +++ b/apps/app/app/(app)/settings/members/members-search-params.ts @@ -0,0 +1,7 @@ +import { createListSearchParams } from "@/components/data-table/list-search-params"; + +export const membersSearchParams = createListSearchParams({ + defaultSort: "joinedAt", + defaultDir: "asc", + facetIds: ["role"] as const, +}); diff --git a/apps/app/app/(app)/settings/members/members-table.tsx b/apps/app/app/(app)/settings/members/members-table.tsx new file mode 100644 index 000000000..4290b9f0e --- /dev/null +++ b/apps/app/app/(app)/settings/members/members-table.tsx @@ -0,0 +1,186 @@ +"use client"; + +import OverflowMenuHorizontal from "@carbon/icons-react/es/OverflowMenuHorizontal"; +import { Button } from "@crm/ui/components/button"; +import { + DataTable, + type DataTableColumn, + type DataTableFacet, +} from "@crm/ui/components/data-table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@crm/ui/components/dropdown-menu"; +import { Icon } from "@crm/ui/components/icon"; +import { PersonAvatar } from "@crm/ui/components/person-avatar"; +import { relativeTimeFromIso } from "@crm/ui/lib/format"; +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 { useCrmCache } from "@/lib/trpc/cache"; +import { useTRPC } from "@/lib/trpc/client"; +import type { RouterOutputs } from "@/lib/trpc/types"; +import { membersSearchParams } from "./members-search-params"; + +const ROLE_LABEL = { + owner: "Owner", + admin: "Admin", + member: "Member", +} as const; + +type Role = keyof typeof ROLE_LABEL; + +type MemberRow = RouterOutputs["workspace"]["members"]["rows"][number]; + +function columns( + canChangeRoles: boolean, + onChangeRole: (member: MemberRow, role: Role) => void, + pending: boolean, +): DataTableColumn[] { + return [ + { + id: "name", + header: "Name", + sortable: true, + hideable: false, + width: "w-[34%]", + cell: (row) => ( + + + {row.name} + {row.isViewer ? ( + You + ) : null} + + ), + }, + { + id: "email", + header: "Email", + sortable: true, + width: "w-[32%]", + hideBelow: "md", + cell: (row) => ( + {row.email} + ), + }, + { + id: "role", + header: "Role", + sortable: true, + width: "w-[14%]", + cell: (row) => ( + {ROLE_LABEL[row.role]} + ), + }, + { + id: "joinedAt", + header: "Joined", + label: "Joined date", + sortable: true, + align: "right", + width: "w-[14%]", + hideBelow: "sm", + cell: (row) => ( + + {relativeTimeFromIso(row.joinedAt)} + + ), + }, + { + id: "actions", + header: Actions, + label: "Actions", + hideable: false, + align: "right", + width: "w-[6%]", + cell: (row) => + canChangeRoles ? ( + + + + + + + {(Object.keys(ROLE_LABEL) as Role[]).map((role) => ( + { + if (row.role === role) return; + onChangeRole(row, role); + }} + > + {ROLE_LABEL[role]} + + ))} + + + ) : null, + }, + ]; +} + +export function MembersTable() { + const trpc = useTRPC(); + const cache = useCrmCache(); + const { query, input } = useTableQuery(membersSearchParams); + + const workspace = useQuery(trpc.workspace.get.queryOptions()); + const members = useQuery({ + ...trpc.workspace.members.queryOptions(input), + placeholderData: (previous) => previous, + }); + + const setRole = useMutation( + trpc.workspace.setMemberRole.mutationOptions({ + onSuccess: async () => { + await cache.workspace(); + toast.success("Role changed."); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const facetCounts = members.data?.facetCounts; + + const facets: DataTableFacet[] = [ + { + id: "role", + label: "Role", + options: (Object.keys(ROLE_LABEL) as Role[]) + .map((role) => ({ value: role, label: ROLE_LABEL[role] })) + .filter((option) => (facetCounts?.role?.[option.value] ?? 0) > 0), + }, + ]; + + return ( + } + columns={columns( + workspace.data?.canChangeRoles ?? false, + (member, role) => setRole.mutate({ memberId: member.id, role }), + setRole.isPending, + )} + rows={members.data?.rows ?? []} + total={members.data?.total ?? 0} + facetCounts={facetCounts} + facets={facets} + getRowId={(row) => row.id} + loading={members.isFetching} + empty="Nobody matches this view." + /> + ); +} diff --git a/apps/app/app/(app)/settings/members/page.tsx b/apps/app/app/(app)/settings/members/page.tsx new file mode 100644 index 000000000..89e9b51c8 --- /dev/null +++ b/apps/app/app/(app)/settings/members/page.tsx @@ -0,0 +1,58 @@ +import type { Metadata } from "next"; +import type { SearchParams } from "nuqs/server"; +import { + PageShell, + PageShellContent, + PageShellDescription, + PageShellHeader, + PageShellHeading, + PageShellTitle, +} from "@/components/page-shell"; +import { requireSession } from "@/lib/session"; +import { HydrateClient } from "@/lib/trpc/hydrate"; +import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; +import { membersSearchParams } from "./members-search-params"; +import { MembersTable } from "./members-table"; + +export const metadata: Metadata = { + title: "Members", +}; + +export default async function MembersSettingsPage({ + searchParams, +}: { + searchParams: Promise; +}) { + await requireSession(); + + const values = await membersSearchParams.load(searchParams); + + const trpc = getServerTrpc(); + const queryClient = getServerQueryClient(); + + await Promise.all([ + queryClient.prefetchQuery(trpc.workspace.get.queryOptions()), + queryClient.prefetchQuery( + trpc.workspace.members.queryOptions(membersSearchParams.toInput(values)), + ), + ]); + + return ( + + + + Members + + Everyone who has access to your CRM. + + + + + + + + + + + ); +} diff --git a/apps/app/app/(app)/settings/page.tsx b/apps/app/app/(app)/settings/page.tsx index 02e992640..c9e007980 100644 --- a/apps/app/app/(app)/settings/page.tsx +++ b/apps/app/app/(app)/settings/page.tsx @@ -11,20 +11,20 @@ import { requireSession } from "@/lib/session"; import { HydrateClient } from "@/lib/trpc/hydrate"; import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; import { AgentModel } from "./agent-model"; -import { GoogleConnection } from "./google-connection"; +import { WorkspaceForm } from "./workspace-form"; export const metadata: Metadata = { - title: "Settings", + title: "General", }; -export default async function SettingsPage() { +export default async function GeneralSettingsPage() { await requireSession(); const trpc = getServerTrpc(); const queryClient = getServerQueryClient(); await Promise.all([ - queryClient.prefetchQuery(trpc.google.status.queryOptions()), + queryClient.prefetchQuery(trpc.workspace.get.queryOptions()), queryClient.prefetchQuery(trpc.settings.agentModel.queryOptions()), queryClient.prefetchQuery(trpc.settings.modelCatalog.queryOptions()), ]); @@ -33,9 +33,9 @@ export default async function SettingsPage() { - Settings + General - Your meetings and email, on the companies they belong to. + Who you are, and the model the research agent thinks with. @@ -43,7 +43,7 @@ export default async function SettingsPage() {
- +
diff --git a/apps/app/app/(app)/settings/settings-sidebar.tsx b/apps/app/app/(app)/settings/settings-sidebar.tsx new file mode 100644 index 000000000..a35587740 --- /dev/null +++ b/apps/app/app/(app)/settings/settings-sidebar.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { Button } from "@crm/ui/components/button"; +import { cn } from "@crm/ui/lib/utils"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +type SettingsNavItem = { + title: string; + href: string; +}; + +const ROOT = "/settings"; + +const ITEMS: SettingsNavItem[] = [ + { title: "General", href: ROOT }, + { title: "Members", href: `${ROOT}/members` }, + { title: "SSO", href: `${ROOT}/sso` }, + { title: "Connections", href: `${ROOT}/connections` }, +]; + +function isActive(href: string, pathname: string): boolean { + return href === ROOT ? pathname === href : pathname.startsWith(href); +} + +function NavLink({ + item, + active, + className, +}: { + item: SettingsNavItem; + active: boolean; + className: string; +}) { + return ( + + ); +} + +export function SettingsSidebar() { + const pathname = usePathname(); + + return ( + <> + + + + + ); +} diff --git a/apps/app/app/(app)/settings/sso/add-sso-provider-sheet.tsx b/apps/app/app/(app)/settings/sso/add-sso-provider-sheet.tsx new file mode 100644 index 000000000..8d8539b2c --- /dev/null +++ b/apps/app/app/(app)/settings/sso/add-sso-provider-sheet.tsx @@ -0,0 +1,229 @@ +"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 { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from "@crm/ui/components/input-group"; +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@crm/ui/components/sheet"; +import { Spinner } from "@crm/ui/components/spinner"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { parseAsBoolean, useQueryState } from "nuqs"; +import { 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"; + +const FORM = "add-sso-provider"; + +const EMPTY = { + providerId: "", + issuer: "", + domain: "", + clientId: "", + clientSecret: "", +}; + +export function AddSsoProviderSheet() { + const trpc = useTRPC(); + const cache = useCrmCache(); + + const settings = useQuery(trpc.sso.settings.queryOptions()); + + const providerIdId = useId(); + const issuerId = useId(); + const domainId = useId(); + const clientIdId = useId(); + const clientSecretId = useId(); + const redirectId = useId(); + + const [open, setOpen] = useQueryState( + "new", + parseAsBoolean.withDefault(false), + ); + const [values, setValues] = useState(EMPTY); + + const register = useMutation( + trpc.sso.register.mutationOptions({ + onSuccess: async (provider) => { + await cache.sso(); + toast.success(`${provider.name} saved.`); + await setOpen(null); + setValues(EMPTY); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const edit = (patch: Partial) => + setValues({ ...values, ...patch }); + + const callbackURL = `${settings.data?.callbackBase ?? ""}/${ + values.providerId || "…" + }`; + + const complete = Object.values(values).every((value) => value.trim() !== ""); + + return ( + setOpen(next || null)}> + + + + + + + Add an identity provider + + Configure an OpenID Connect provider. + + + +
{ + event.preventDefault(); + register.mutate({ + providerId: values.providerId.trim().toLowerCase(), + issuer: values.issuer.trim(), + domain: values.domain.trim(), + clientId: values.clientId.trim(), + clientSecret: values.clientSecret.trim(), + }); + }} + > + + + Name + edit({ providerId: event.target.value })} + placeholder="okta" + autoComplete="off" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + required + /> + + Names the sign-in button. Cannot be changed later. + + + + + Issuer URL + edit({ issuer: event.target.value })} + placeholder="https://acme.okta.com" + autoComplete="off" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + inputMode="url" + required + /> + Where discovery lives. + + + + Email domain + edit({ domain: event.target.value })} + placeholder="acme.com" + autoComplete="off" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + required + /> + Comma-separate several. + + + + Client ID + edit({ clientId: event.target.value })} + autoComplete="off" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + required + /> + + + + Client secret + edit({ clientSecret: event.target.value })} + autoComplete="off" + required + /> + Never shown again. + + + + Redirect URI + + + + + + + + Add this at your provider before saving. + + + +
+ + + + + + + +
+
+ ); +} diff --git a/apps/app/app/(app)/settings/sso/copy-value.tsx b/apps/app/app/(app)/settings/sso/copy-value.tsx new file mode 100644 index 000000000..eae4b98fa --- /dev/null +++ b/apps/app/app/(app)/settings/sso/copy-value.tsx @@ -0,0 +1,27 @@ +"use client"; + +import Copy from "@carbon/icons-react/es/Copy"; +import { Button } from "@crm/ui/components/button"; +import { Icon } from "@crm/ui/components/icon"; +import { toast } from "sonner"; + +export function CopyValue({ value, label }: { value: string; label: string }) { + return ( + + ); +} diff --git a/apps/app/app/(app)/settings/sso/page.tsx b/apps/app/app/(app)/settings/sso/page.tsx new file mode 100644 index 000000000..4a2b68274 --- /dev/null +++ b/apps/app/app/(app)/settings/sso/page.tsx @@ -0,0 +1,67 @@ +import type { Metadata } from "next"; +import type { SearchParams } from "nuqs/server"; +import { + PageShell, + PageShellActions, + PageShellContent, + PageShellDescription, + PageShellHeader, + PageShellHeading, + PageShellTitle, +} from "@/components/page-shell"; +import { requireSession } from "@/lib/session"; +import { HydrateClient } from "@/lib/trpc/hydrate"; +import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; +import { AddSsoProviderSheet } from "./add-sso-provider-sheet"; +import { ssoSearchParams } from "./sso-search-params"; +import { SsoTable } from "./sso-table"; + +export const metadata: Metadata = { + title: "SSO", +}; + +export default async function SsoSettingsPage({ + searchParams, +}: { + searchParams: Promise; +}) { + await requireSession(); + + const values = await ssoSearchParams.load(searchParams); + + const trpc = getServerTrpc(); + const queryClient = getServerQueryClient(); + + await Promise.all([ + queryClient.prefetchQuery(trpc.sso.settings.queryOptions()), + queryClient.prefetchQuery( + trpc.sso.list.queryOptions(ssoSearchParams.toInput(values)), + ), + ]); + + return ( + + + + SSO + + Let your people sign in through your own identity provider. While + one is configured, the sign-in page offers it instead of Google. + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/app/app/(app)/settings/sso/sso-search-params.ts b/apps/app/app/(app)/settings/sso/sso-search-params.ts new file mode 100644 index 000000000..1be54327e --- /dev/null +++ b/apps/app/app/(app)/settings/sso/sso-search-params.ts @@ -0,0 +1,6 @@ +import { createListSearchParams } from "@/components/data-table/list-search-params"; + +export const ssoSearchParams = createListSearchParams({ + defaultSort: "providerId", + defaultDir: "asc", +}); diff --git a/apps/app/app/(app)/settings/sso/sso-table.tsx b/apps/app/app/(app)/settings/sso/sso-table.tsx new file mode 100644 index 000000000..928a13532 --- /dev/null +++ b/apps/app/app/(app)/settings/sso/sso-table.tsx @@ -0,0 +1,165 @@ +"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 { 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 { useCrmCache } from "@/lib/trpc/cache"; +import { useTRPC } from "@/lib/trpc/client"; +import type { RouterOutputs } from "@/lib/trpc/types"; +import { CopyValue } from "./copy-value"; +import { ssoSearchParams } from "./sso-search-params"; + +type ProviderRow = RouterOutputs["sso"]["list"]["rows"][number]; + +function columns( + canConfigure: boolean, + onRemove: (provider: ProviderRow) => void, + pending: boolean, +): DataTableColumn[] { + return [ + { + id: "providerId", + header: "Provider", + sortable: true, + hideable: false, + width: "w-[30%]", + cell: (row) => ( + + {row.name} + + {row.type === "saml" ? "SAML" : "OpenID Connect"} + {row.clientIdLastFour ? ` · client …${row.clientIdLastFour}` : ""} + + + ), + }, + { + id: "domain", + header: "Email domain", + sortable: true, + width: "w-[22%]", + hideBelow: "sm", + cell: (row) => ( + + {row.domains.join(", ")} + + ), + }, + { + id: "issuer", + header: "Issuer", + sortable: true, + width: "w-[22%]", + hideBelow: "md", + cell: (row) => ( + {row.issuer} + ), + }, + { + id: "callbackURL", + header: "Redirect URI", + width: "w-[20%]", + hideBelow: "lg", + cell: (row) => ( + + {row.callbackURL} + + + ), + }, + { + id: "actions", + header: Actions, + label: "Actions", + hideable: false, + align: "right", + width: "w-[6%]", + cell: (row) => + canConfigure ? ( + + + + + + + + Remove {row.name}? + + Nobody can sign in through it again. If this is the only + provider, the sign-in page goes back to Google. + + + + + Cancel + onRemove(row)} + > + Remove + + + + + ) : null, + }, + ]; +} + +export function SsoTable() { + const trpc = useTRPC(); + const cache = useCrmCache(); + const { query, input } = useTableQuery(ssoSearchParams); + + const settings = useQuery(trpc.sso.settings.queryOptions()); + const providers = useQuery({ + ...trpc.sso.list.queryOptions(input), + placeholderData: (previous) => previous, + }); + + const remove = useMutation( + trpc.sso.remove.mutationOptions({ + onSuccess: async () => { + await cache.sso(); + toast.success("Identity provider removed."); + }, + onError: (error) => toast.error(error.message), + }), + ); + + return ( + } + columns={columns( + settings.data?.canConfigure ?? false, + (provider) => remove.mutate({ providerId: provider.providerId }), + remove.isPending, + )} + rows={providers.data?.rows ?? []} + total={providers.data?.total ?? 0} + getRowId={(row) => row.providerId} + loading={providers.isFetching} + empty="No identity provider yet — everyone signs in with Google." + /> + ); +} diff --git a/apps/app/app/(app)/settings/workspace-form.tsx b/apps/app/app/(app)/settings/workspace-form.tsx new file mode 100644 index 000000000..06d9699b3 --- /dev/null +++ b/apps/app/app/(app)/settings/workspace-form.tsx @@ -0,0 +1,152 @@ +"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 { + InputGroup, + InputGroupAddon, + InputGroupInput, + InputGroupText, +} from "@crm/ui/components/input-group"; +import { Spinner } from "@crm/ui/components/spinner"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useId, useState } from "react"; +import { toast } from "sonner"; +import { useCrmCache } from "@/lib/trpc/cache"; +import { useTRPC } from "@/lib/trpc/client"; + +export function WorkspaceForm() { + const trpc = useTRPC(); + const cache = useCrmCache(); + + const nameId = useId(); + const websiteId = useId(); + + const workspace = useQuery(trpc.workspace.get.queryOptions()); + + const [draft, setDraft] = useState<{ name: string; website: string } | null>( + null, + ); + + const save = useMutation( + trpc.workspace.update.mutationOptions({ + onSuccess: async () => { + await cache.workspace(); + setDraft(null); + toast.success("Workspace saved."); + }, + onError: (error) => toast.error(error.message), + }), + ); + + if (!workspace.data) return null; + + const { name, website, canRename } = workspace.data; + + const values = draft ?? { name, website: website ?? "" }; + const dirty = values.name !== name || values.website !== (website ?? ""); + + const edit = (patch: Partial) => + setDraft({ ...values, ...patch }); + + return ( + + + Workspace + + The name and website of the company using this CRM. + + + + + + + + +
{ + event.preventDefault(); + save.mutate({ + name: values.name, + website: values.website.trim(), + }); + }} + > + + + Name + edit({ name: event.target.value })} + placeholder="Acme Inc." + autoComplete="organization" + disabled={!canRename} + required + /> + + Shown wherever the CRM refers to your own company. + + + + + Website + + + https:// + + edit({ website: event.target.value })} + placeholder="acme.com" + autoComplete="off" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + inputMode="url" + disabled={!canRename} + /> + + Your own company's website. + + +
+ + {canRename ? null : ( +

+ Only an owner or an admin can change this. +

+ )} +
+
+ ); +} diff --git a/apps/app/app/(auth)/grant-access/page.tsx b/apps/app/app/(auth)/grant-access/page.tsx index 168b04511..85c1773ad 100644 --- a/apps/app/app/(auth)/grant-access/page.tsx +++ b/apps/app/app/(auth)/grant-access/page.tsx @@ -1,9 +1,8 @@ -import { hasSyncScopes } from "@crm/auth"; -import { db } from "@crm/db"; +import { needsGoogleGrant } from "@crm/auth"; import type { Metadata } from "next"; import { redirect } from "next/navigation"; import { AuthHeading, AuthShell } from "@/components/auth-shell"; -import { requireSession } from "@/lib/session"; +import { requireSession, signInAccounts } from "@/lib/session"; import { GrantAccess } from "./grant-access"; export const metadata: Metadata = { @@ -13,12 +12,7 @@ export const metadata: Metadata = { export default async function GrantAccessPage() { const { user } = await requireSession(); - const account = await db.account.findFirst({ - where: { userId: user.id, providerId: "google" }, - select: { scope: true }, - }); - - if (hasSyncScopes(account?.scope)) { + if (!needsGoogleGrant(await signInAccounts(user.id))) { redirect("/"); } @@ -26,7 +20,7 @@ export default async function GrantAccessPage() { diff --git a/apps/app/app/(auth)/onboarding/onboarding-form.tsx b/apps/app/app/(auth)/onboarding/onboarding-form.tsx new file mode 100644 index 000000000..4bea2fef1 --- /dev/null +++ b/apps/app/app/(auth)/onboarding/onboarding-form.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { Button } from "@crm/ui/components/button"; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from "@crm/ui/components/field"; +import { Input } from "@crm/ui/components/input"; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, + InputGroupText, +} from "@crm/ui/components/input-group"; +import { Spinner } from "@crm/ui/components/spinner"; +import { useMutation } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { useId } from "react"; +import { toast } from "sonner"; +import { useTRPC } from "@/lib/trpc/client"; + +export function OnboardingForm({ placeholder }: { placeholder: string }) { + const trpc = useTRPC(); + const router = useRouter(); + + const nameId = useId(); + const websiteId = useId(); + + const save = useMutation( + trpc.workspace.update.mutationOptions({ + onSuccess: () => { + router.refresh(); + router.replace("/"); + }, + onError: (error) => toast.error(error.message), + }), + ); + + return ( +
{ + event.preventDefault(); + + const form = new FormData(event.currentTarget); + + save.mutate({ + name: String(form.get("name") ?? "").trim(), + website: String(form.get("website") ?? "").trim(), + }); + }} + className="flex flex-col gap-6" + > + + + Company name + + + + + Website + + + https:// + + + + + Read once, so every answer afterwards knows what you sell. + + + + + +
+ ); +} diff --git a/apps/app/app/(auth)/onboarding/page.tsx b/apps/app/app/(auth)/onboarding/page.tsx new file mode 100644 index 000000000..f9dad0ccc --- /dev/null +++ b/apps/app/app/(auth)/onboarding/page.tsx @@ -0,0 +1,24 @@ +import { DEFAULT_WORKSPACE_NAME } from "@crm/auth"; +import type { Metadata } from "next"; +import { AuthHeading, AuthShell } from "@/components/auth-shell"; +import { requireGoogleAccess } from "@/lib/session"; +import { OnboardingForm } from "./onboarding-form"; + +export const metadata: Metadata = { + title: "Set up", +}; + +export default async function OnboardingPage() { + await requireGoogleAccess(); + + return ( + + + + + + ); +} diff --git a/apps/app/app/(auth)/sign-in/page.tsx b/apps/app/app/(auth)/sign-in/page.tsx index 608dbee2a..65b243563 100644 --- a/apps/app/app/(auth)/sign-in/page.tsx +++ b/apps/app/app/(auth)/sign-in/page.tsx @@ -2,35 +2,80 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; import { AuthHeading, AuthShell } from "@/components/auth-shell"; import { getSession } from "@/lib/session"; +import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; import { GoogleSignIn } from "./google-sign-in"; +import { type SsoProvider, SsoSignIn } from "./sso-sign-in"; export const metadata: Metadata = { title: "Sign in", }; -export default async function SignInPage() { - const session = await getSession().catch((error: unknown) => { - console.error("Sign-in: could not read the session.", error); +export const dynamic = "force-dynamic"; + +type SignInOptions = { google: boolean; providers: SsoProvider[] }; + +async function signInOptions(): Promise { + try { + return await getServerQueryClient().fetchQuery( + getServerTrpc().sso.signInOptions.queryOptions(), + ); + } catch (error) { + console.error("Sign-in: could not read the sign-in options.", error); return null; - }); + } +} + +export default async function SignInPage({ + searchParams, +}: { + searchParams: Promise<{ method?: string | string[] }>; +}) { + const [session, options, { method }] = await Promise.all([ + getSession().catch((error: unknown) => { + console.error("Sign-in: could not read the session.", error); + return null; + }), + signInOptions(), + searchParams, + ]); if (session) { redirect("/"); } + const google = options?.google ?? true; + const providers = options?.providers ?? []; + + const insistOnGoogle = method === "google" && google; + const showSso = providers.length > 0 && !insistOnGoogle; + const showGoogle = google && (providers.length === 0 || insistOnGoogle); + + if (!showSso && !showGoogle) { + return ( + + + +

+ Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in the root .env file + and restart. Your own identity provider can be added from Settings + once somebody is signed in. +

+
+ ); + } + return ( - - -

- Comp AI CRM is internal. If you cannot get in, ask an admin to check - your Google account. -

+ {showSso ? : null} + {showGoogle ? : null}
); } diff --git a/apps/app/app/(auth)/sign-in/sso-sign-in.tsx b/apps/app/app/(auth)/sign-in/sso-sign-in.tsx new file mode 100644 index 000000000..623592766 --- /dev/null +++ b/apps/app/app/(auth)/sign-in/sso-sign-in.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { signIn } from "@crm/auth/client"; +import { Button } from "@crm/ui/components/button"; +import { Spinner } from "@crm/ui/components/spinner"; +import { useState } from "react"; +import { toast } from "sonner"; + +export type SsoProvider = { + providerId: string; + name: string; +}; + +export function SsoSignIn({ providers }: { providers: SsoProvider[] }) { + const [pending, setPending] = useState(null); + + async function handleClick(providerId: string) { + setPending(providerId); + + const origin = window.location.origin; + + const { error } = await signIn.sso({ + providerId, + callbackURL: `${origin}/`, + errorCallbackURL: `${origin}/sign-in`, + }); + + if (error) { + toast.error(error.message ?? "Could not reach the sign-in service."); + setPending(null); + } + } + + return ( + <> + {providers.map((provider) => ( + + ))} + + ); +} diff --git a/apps/app/app/api/[...path]/route.ts b/apps/app/app/api/[...path]/route.ts index 71435e7a7..201776c7e 100644 --- a/apps/app/app/api/[...path]/route.ts +++ b/apps/app/app/api/[...path]/route.ts @@ -42,7 +42,21 @@ async function handler(request: Request): Promise { init.duplex = "half"; } - const upstream = await fetch(target, init); + let upstream: Response; + + try { + upstream = await fetch(target, init); + } catch (error) { + console.error( + `API proxy: ${API_URL} is not reachable for ${request.method} ${url.pathname}.`, + error, + ); + + return Response.json( + { error: `The API at ${API_URL} is not reachable.` }, + { status: 502 }, + ); + } const responseHeaders = new Headers(upstream.headers); for (const header of [ diff --git a/apps/app/components/app-header.tsx b/apps/app/components/app-header.tsx index f44d29249..fd652e4e1 100644 --- a/apps/app/components/app-header.tsx +++ b/apps/app/components/app-header.tsx @@ -18,15 +18,20 @@ import { } from "@crm/ui/components/dropdown-menu"; import Logo from "@crm/ui/components/logo"; import { Separator } from "@crm/ui/components/separator"; +import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { useTheme } from "next-themes"; import { toast } from "sonner"; import { useMobileNav } from "@/components/mobile-nav"; +import { useTRPC } from "@/lib/trpc/client"; type User = { name: string; email: string; image: string | null }; export function AppHeader({ user }: { user: User }) { const { setOpen: setMobileNavOpen } = useMobileNav(); + const trpc = useTRPC(); + const workspace = useQuery(trpc.workspace.get.queryOptions()); + const name = workspace.data?.name; async function handleSignOut() { const { error } = await signOut(); @@ -59,7 +64,9 @@ export function AppHeader({ user }: { user: User }) { - Comp AI CRM + + {name ? `${name} CRM` : "CRM"} +
diff --git a/apps/app/components/auth-shell.tsx b/apps/app/components/auth-shell.tsx index a42f65415..72a493866 100644 --- a/apps/app/components/auth-shell.tsx +++ b/apps/app/components/auth-shell.tsx @@ -18,16 +18,24 @@ export function AuthShell({ children }: { children: ReactNode }) {

- Internal tool + CRM

- Every customer, in one place. + Every customer, one place.

- Comp AI · staff access only + Made with love by{" "} + + Comp AI +

diff --git a/apps/app/lib/agent-transcript.ts b/apps/app/lib/agent-transcript.ts index 7c5c2401b..662a955f1 100644 --- a/apps/app/lib/agent-transcript.ts +++ b/apps/app/lib/agent-transcript.ts @@ -33,6 +33,7 @@ const VERBS: Record = { identify_contact: "Put a name to the address", record_fact: "Recorded what it found", write_brief: "Wrote the background", + write_workspace_profile: "Wrote up who we are", research_person: "Researched them on the web", research_company: "Read the company's site", enrich_company: "Looked up the company", diff --git a/apps/app/lib/onboarding.ts b/apps/app/lib/onboarding.ts new file mode 100644 index 000000000..7a797b9a4 --- /dev/null +++ b/apps/app/lib/onboarding.ts @@ -0,0 +1,54 @@ +import type { NextRequest, NextResponse } from "next/server"; +import { API_URL } from "@/lib/env"; + +export const ONBOARDING_PATH = "/onboarding"; + +export const ONBOARDING_COOKIE = "crm.onboarded"; + +const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; + +const GATE_TIMEOUT_MS = 2_000; + +export type OnboardingGate = "settled" | "required" | "unknown"; + +export async function readOnboardingGate( + request: NextRequest, +): Promise { + const cookie = request.headers.get("cookie"); + + if (!cookie) return "unknown"; + + try { + const response = await fetch(`${API_URL}/api/trpc/workspace.get`, { + headers: { cookie }, + signal: AbortSignal.timeout(GATE_TIMEOUT_MS), + }); + + if (!response.ok) return "unknown"; + + const body = (await response.json()) as { + result?: { data?: { onboarded?: boolean; canRename?: boolean } }; + }; + + const workspace = body.result?.data; + + if (typeof workspace?.onboarded !== "boolean") return "unknown"; + + return workspace.onboarded || !workspace.canRename ? "settled" : "required"; + } catch { + return "unknown"; + } +} + +export function settleOnboarding( + request: NextRequest, + response: NextResponse, +): void { + response.cookies.set(ONBOARDING_COOKIE, "1", { + httpOnly: true, + sameSite: "lax", + secure: request.nextUrl.protocol === "https:", + path: "/", + maxAge: COOKIE_MAX_AGE, + }); +} diff --git a/apps/app/lib/session.ts b/apps/app/lib/session.ts index b58edbb44..ab6cf5cf7 100644 --- a/apps/app/lib/session.ts +++ b/apps/app/lib/session.ts @@ -1,4 +1,4 @@ -import { auth, hasSyncScopes, type Session } from "@crm/auth"; +import { auth, needsGoogleGrant, type Session } from "@crm/auth"; import { db } from "@crm/db"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; @@ -19,19 +19,17 @@ export async function requireSession(): Promise { return session; } -const grantedScope = cache(async (userId: string): Promise => { - const account = await db.account.findFirst({ - where: { userId, providerId: "google" }, - select: { scope: true }, - }); - - return account?.scope ?? null; -}); +export const signInAccounts = cache(async (userId: string) => + db.account.findMany({ + where: { userId }, + select: { providerId: true, scope: true }, + }), +); export async function requireGoogleAccess(): Promise { const session = await requireSession(); - if (!hasSyncScopes(await grantedScope(session.user.id))) { + if (needsGoogleGrant(await signInAccounts(session.user.id))) { redirect("/grant-access"); } diff --git a/apps/app/lib/trpc/cache.ts b/apps/app/lib/trpc/cache.ts index c732aaf64..b2a94a7c4 100644 --- a/apps/app/lib/trpc/cache.ts +++ b/apps/app/lib/trpc/cache.ts @@ -16,6 +16,8 @@ export type CrmCache = { activity(options?: Options): Promise; google(options?: Options): Promise; settings(options?: Options): Promise; + workspace(options?: Options): Promise; + sso(options?: Options): Promise; everything(): Promise; }; @@ -126,6 +128,20 @@ export function useCrmCache(): CrmCache { settings: (options) => run([trpc.settings.agentModel.queryKey()], [], options), + workspace: (options) => + run( + [trpc.workspace.get.queryKey(), trpc.workspace.members.queryKey()], + [], + options, + ), + + sso: (options) => + run( + [trpc.sso.list.pathKey()], + [trpc.sso.settings.queryKey(), trpc.sso.signInOptions.queryKey()], + options, + ), + everything: () => queryClient.invalidateQueries(), }; } diff --git a/apps/app/proxy.ts b/apps/app/proxy.ts index 15f0b02be..fc3f0006b 100644 --- a/apps/app/proxy.ts +++ b/apps/app/proxy.ts @@ -1,15 +1,55 @@ import { getSessionCookie } from "better-auth/cookies"; import { type NextRequest, NextResponse } from "next/server"; +import { + ONBOARDING_COOKIE, + ONBOARDING_PATH, + readOnboardingGate, + settleOnboarding, +} from "@/lib/onboarding"; -export function proxy(request: NextRequest) { - const isSignedIn = getSessionCookie(request) !== null; - const isSignInPage = request.nextUrl.pathname === "/sign-in"; +const SIGN_IN_PATH = "/sign-in"; - if (!isSignedIn && !isSignInPage) { - return NextResponse.redirect(new URL("/sign-in", request.nextUrl)); +const UNGATED = [SIGN_IN_PATH, "/grant-access", "/eve"]; + +export async function proxy(request: NextRequest) { + const { pathname } = request.nextUrl; + + if (getSessionCookie(request) === null) { + return pathname === SIGN_IN_PATH + ? NextResponse.next() + : NextResponse.redirect(new URL(SIGN_IN_PATH, request.nextUrl)); } - return NextResponse.next(); + if (isUngated(pathname)) return NextResponse.next(); + + if (request.cookies.has(ONBOARDING_COOKIE)) return beyondOnboarding(request); + + const gate = await readOnboardingGate(request); + + if (gate === "unknown") return NextResponse.next(); + + if (gate === "required") { + return pathname === ONBOARDING_PATH + ? NextResponse.next() + : NextResponse.redirect(new URL(ONBOARDING_PATH, request.nextUrl)); + } + + const response = beyondOnboarding(request); + settleOnboarding(request, response); + + return response; +} + +function isUngated(pathname: string): boolean { + return UNGATED.some( + (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`), + ); +} + +function beyondOnboarding(request: NextRequest): NextResponse { + return request.nextUrl.pathname === ONBOARDING_PATH + ? NextResponse.redirect(new URL("/", request.nextUrl)) + : NextResponse.next(); } export const config = { diff --git a/apps/app/test/onboarding-gate.spec.ts b/apps/app/test/onboarding-gate.spec.ts new file mode 100644 index 000000000..f463b79ee --- /dev/null +++ b/apps/app/test/onboarding-gate.spec.ts @@ -0,0 +1,160 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { NextRequest } from "next/server"; +import { ONBOARDING_COOKIE, readOnboardingGate } from "../lib/onboarding"; +import { proxy } from "../proxy"; + +const SESSION_COOKIE = "better-auth.session_token=abc.def"; + +const realFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +function stub(handler: () => Promise) { + globalThis.fetch = handler as unknown as typeof fetch; +} + +function answerWith(body: unknown, status = 200) { + stub( + async () => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ); +} + +function request(pathname: string, cookies: string[] = []) { + return new NextRequest(new URL(pathname, "http://localhost:3000"), { + headers: cookies.length ? { cookie: cookies.join("; ") } : {}, + }); +} + +function redirectedTo(response: Response): string | null { + const location = response.headers.get("location"); + + return location ? new URL(location).pathname : null; +} + +const workspace = (data: { onboarded: boolean; canRename: boolean }) => ({ + result: { data }, +}); + +describe("readOnboardingGate", () => { + it("reads the answer out of a plain tRPC envelope", async () => { + answerWith(workspace({ onboarded: false, canRename: true })); + + expect(await readOnboardingGate(request("/", [SESSION_COOKIE]))).toBe( + "required", + ); + }); + + it("settles for someone who could not answer the form anyway", async () => { + answerWith(workspace({ onboarded: false, canRename: false })); + + expect(await readOnboardingGate(request("/", [SESSION_COOKIE]))).toBe( + "settled", + ); + }); + + it("is unknown rather than required when the API cannot be read", async () => { + answerWith({ error: { message: "UNAUTHORIZED" } }, 401); + expect(await readOnboardingGate(request("/", [SESSION_COOKIE]))).toBe( + "unknown", + ); + + stub(async () => { + throw new Error("connect ECONNREFUSED"); + }); + expect(await readOnboardingGate(request("/", [SESSION_COOKIE]))).toBe( + "unknown", + ); + + answerWith({ result: { data: { nothing: "useful" } } }); + expect(await readOnboardingGate(request("/", [SESSION_COOKIE]))).toBe( + "unknown", + ); + }); +}); + +describe("proxy", () => { + it("sends a stranger to sign in, and leaves them there", async () => { + expect(redirectedTo(await proxy(request("/companies")))).toBe("/sign-in"); + expect(redirectedTo(await proxy(request("/sign-in")))).toBeNull(); + }); + + it("gates a signed-in rep who has not answered the form", async () => { + answerWith(workspace({ onboarded: false, canRename: true })); + + expect( + redirectedTo(await proxy(request("/companies", [SESSION_COOKIE]))), + ).toBe("/onboarding"); + }); + + it("lets the form itself render", async () => { + answerWith(workspace({ onboarded: false, canRename: true })); + + expect( + redirectedTo(await proxy(request("/onboarding", [SESSION_COOKIE]))), + ).toBeNull(); + }); + + it("asks once, then remembers", async () => { + let calls = 0; + stub(async () => { + calls += 1; + return new Response( + JSON.stringify(workspace({ onboarded: true, canRename: true })), + ); + }); + + const first = await proxy(request("/companies", [SESSION_COOKIE])); + const marker = first.cookies.get(ONBOARDING_COOKIE); + + expect(marker?.value).toBe("1"); + expect(marker?.httpOnly).toBe(true); + expect(calls).toBe(1); + + await proxy( + request("/companies", [SESSION_COOKIE, `${ONBOARDING_COOKIE}=1`]), + ); + + expect(calls).toBe(1); + }); + + it("takes a settled rep off the form", async () => { + answerWith(workspace({ onboarded: true, canRename: true })); + + expect( + redirectedTo(await proxy(request("/onboarding", [SESSION_COOKIE]))), + ).toBe("/"); + }); + + it("never fights /grant-access, which would ping-pong forever", async () => { + answerWith(workspace({ onboarded: false, canRename: true })); + + expect( + redirectedTo(await proxy(request("/grant-access", [SESSION_COOKIE]))), + ).toBeNull(); + }); + + it("leaves the agent bridge alone", async () => { + answerWith(workspace({ onboarded: false, canRename: true })); + + expect( + redirectedTo(await proxy(request("/eve/v1/info", [SESSION_COOKIE]))), + ).toBeNull(); + }); + + it("fails open when the API is unreachable", async () => { + stub(async () => { + throw new Error("connect ECONNREFUSED"); + }); + + const response = await proxy(request("/companies", [SESSION_COOKIE])); + + expect(redirectedTo(response)).toBeNull(); + expect(response.cookies.get(ONBOARDING_COOKIE)).toBeUndefined(); + }); +}); diff --git a/bun.lock b/bun.lock index ace575181..9f7e1dccf 100644 --- a/bun.lock +++ b/bun.lock @@ -105,6 +105,7 @@ "name": "@crm/auth", "version": "0.0.0", "dependencies": { + "@better-auth/sso": "1.6.25", "@crm/db": "workspace:*", "@crm/env": "workspace:*", "better-auth": "^1.6.25", @@ -210,6 +211,8 @@ "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], + "@authenio/xml-encryption": ["@authenio/xml-encryption@2.0.2", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "escape-html": "^1.0.3", "xpath": "0.0.32" } }, "sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg=="], + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], @@ -290,6 +293,8 @@ "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.6.25", "", { "peerDependencies": { "@better-auth/core": "^1.6.25", "@better-auth/utils": "0.4.2", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-ym7B6Iqcry+/4aQnYpFwqP/GBIiXvjrm/5B6+0qmx8mkTY/apHFTpHuGzUYYNf4vPTtzF3eYY2+s2GOsomKaRg=="], + "@better-auth/sso": ["@better-auth/sso@1.6.25", "", { "dependencies": { "fast-xml-parser": "^5.8.0", "jose": "^6.1.3", "samlify": "^2.13.1", "tldts": "^6.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.6.25", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "better-auth": "^1.6.25", "better-call": "1.3.7" } }, "sha512-Svbh1DFrMGlPms4YNuahtNqnro8jwPeOjGfnSEPn1x67+QcP140n9liHxXCWmFgwN1qbpU2VhiqNRQ0goWo85A=="], + "@better-auth/telemetry": ["@better-auth/telemetry@1.4.22", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.4.22" } }, "sha512-ltoRysWQIbVlSgmVvn2EiFDkmLmtLAs9IVBvvwavGNFAklE7UcSlzR4BM5fllx5Vax927sou9MvZgUhgHRI62A=="], "@better-auth/utils": ["@better-auth/utils@0.3.0", "", {}, "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw=="], @@ -488,6 +493,8 @@ "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + "@nodable/entities": ["@nodable/entities@3.0.0", "", {}, "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -928,6 +935,10 @@ "@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="], + "@xmldom/is-dom-node": ["@xmldom/is-dom-node@1.0.1", "", {}, "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q=="], + + "@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "agent": ["agent@workspace:apps/agent"], @@ -944,6 +955,8 @@ "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="], + "api": ["api@workspace:apps/api"], "app": ["app@workspace:apps/app"], @@ -956,6 +969,8 @@ "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], + "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], + "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], "async-retry": ["async-retry@1.3.3", "", { "dependencies": { "retry": "0.13.1" } }, "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw=="], @@ -1332,6 +1347,10 @@ "fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="], + "fast-xml-builder": ["fast-xml-builder@1.3.0", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ=="], + + "fast-xml-parser": ["fast-xml-parser@5.10.1", "", { "dependencies": { "@nodable/entities": "^3.0.0", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", "strnum": "^2.4.1", "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw=="], + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -1518,6 +1537,8 @@ "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + "is-unsafe": ["is-unsafe@2.0.0", "", {}, "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA=="], + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], @@ -1768,6 +1789,8 @@ "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=="], + "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], "nuqs": ["nuqs@2.9.4", "", { "dependencies": { "@standard-schema/spec": "1.1.0" }, "peerDependencies": { "@remix-run/react": ">=2", "@tanstack/react-router": "^1", "next": ">=14.2.0", "react": ">=18.2.0 || ^19.0.0-0", "react-router": "^5 || ^6 || ^7 || ^8", "react-router-dom": "^5 || ^6 || ^7" }, "optionalPeers": ["@remix-run/react", "@tanstack/react-router", "next", "react-router", "react-router-dom"] }, "sha512-lsz3NyCOKmuNAyW052i9RWqcTntoYb2Qm6FxSWnkTDwOJnGS6fzpXDAp0VcwTevw3xgnWebYpDr9rm6+o4DHbw=="], @@ -1822,6 +1845,8 @@ "path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], + "path-expression-matcher": ["path-expression-matcher@1.6.2", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], @@ -2002,6 +2027,8 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "samlify": ["samlify@2.13.1", "", { "dependencies": { "@authenio/xml-encryption": "^2.0.2", "@xmldom/xmldom": "^0.8.11", "node-rsa": "^1.1.1", "xml": "^1.0.1", "xml-crypto": "^6.1.2", "xml-escape": "^1.1.0", "xpath": "^0.0.34" } }, "sha512-vdYr/zohDGBbfWNU4miEzc1jmWOtkLySPViapC6nfGkv9KxzLq4UlGkKyryzwLw4jVlZk88Rw93HaCRVpe+t+g=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], @@ -2082,6 +2109,8 @@ "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + "strnum": ["strnum@2.4.1", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg=="], + "strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="], "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], @@ -2116,6 +2145,10 @@ "tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], + "tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], + + "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], @@ -2222,6 +2255,16 @@ "xdg-portable": ["xdg-portable@7.3.0", "", { "dependencies": { "os-paths": "^4.0.1" } }, "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw=="], + "xml": ["xml@1.0.1", "", {}, "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw=="], + + "xml-crypto": ["xml-crypto@6.1.2", "", { "dependencies": { "@xmldom/is-dom-node": "^1.0.1", "@xmldom/xmldom": "^0.8.10", "xpath": "^0.0.33" } }, "sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w=="], + + "xml-escape": ["xml-escape@1.1.0", "", {}, "sha512-B/T4sDK8Z6aUh/qNr7mjKAwwncIljFuUP+DO/D5hloYFj+90O88z8Wf7oSucZTHxBAsC1/CTP4rtx/x1Uf72Mg=="], + + "xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="], + + "xpath": ["xpath@0.0.34", "", {}, "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], @@ -2248,6 +2291,8 @@ "@ai-sdk/provider-utils/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], + "@authenio/xml-encryption/xpath": ["xpath@0.0.32", "", {}, "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -2284,6 +2329,10 @@ "@better-auth/prisma-adapter/@better-auth/utils": ["@better-auth/utils@0.4.2", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A=="], + "@better-auth/sso/@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/sso/@better-auth/utils": ["@better-auth/utils@0.4.2", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A=="], + "@better-auth/telemetry/@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], "@chevrotain/cst-dts-gen/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], @@ -2584,6 +2633,8 @@ "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "xml-crypto/xpath": ["xpath@0.0.33", "", {}, "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA=="], + "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "@better-auth/cli/better-auth/@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], diff --git a/docs/agent.md b/docs/agent.md index 053a6db48..c266705f7 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -18,8 +18,7 @@ version exactly: apps/agent/node_modules/eve/docs/README.md ``` -Read the relevant guide there before writing eve code. Guessing at this API is -expensive in a specific way: it typechecks, builds, and then behaves differently +Read the relevant guide there before writing eve code and then check your eve skill knowledge-base shipped in .agents/skills/eve. Guessing at this API is expensive in a specific way: it typechecks, builds, and then behaves differently from what you assumed — see the note on principal mapping under [the bridge](#the-bridge). @@ -140,20 +139,110 @@ months. `isSamePerson`, in code, rather than asking the model to remember a follow-up call. -### A portrait is not a research session +### Two lanes: what a rep sees, and what a rep asks for -`schedules/dispatch.ts` runs `portrait` rows **directly, without `receive`**. -This is the one kind that skips the model, and it is worth knowing why: the work -is three reads keyed on identifiers already on the record and a byte copy, with -nothing in it to decide. +`schedules/dispatch.ts` drains the queue in **two independent lanes**, and which +lane a row lands in is decided by one list — `DIRECT_KINDS` in +[`@crm/db/agent-tasks`](../packages/db/src/agent-tasks.ts). -Routed through a session it also did not work. Seven queued faces sat behind -sixty LLM sessions at five a minute and had not landed twenty five minutes -later, each one waiting to pay for a context window in order to make no -decisions with it. +| | Kinds | How it runs | Per tick | +| --- | --- | --- | --- | +| **Visible** | `brand`, `portrait` | Directly, no `receive`, no model | 60, six at a time | +| **Research** | everything else | One eve session per row | 12 | + +**Neither kind in the visible lane has anything in it to decide.** A portrait is +three reads keyed on identifiers already on the record and a byte copy. A brand +is a domain in, Context.dev out, map, mirror, write — `lib/brand.ts`, and there +is not one judgement in the whole path. Routing either through a session buys a +context window in order to make no decisions with it. + +Routed through a session it also did not work, twice, the same way. Seven queued +faces sat behind sixty LLM sessions at five a minute and had not landed twenty +five minutes later. Then `company-profile` — which is how a logo used to get +fetched — sat at the *bottom* of one shared queue at priority 10, behind every +contact task, so `stripe.com` showed as `stripe.com` in a grey square while the +agent wrote paragraphs about people who worked there. + +Lanes are why that cannot recur. A logo does not queue behind research, because +it is not in that queue. `test/lanes.integration.spec.ts` pins it: thirty +`identify` rows do not delay one `brand` row. The schedule still decides nothing, which is the rule it has to keep. The row -says what the work is; the branch only says whether it needs a conversation. +says what the work is; the lane only says whether it needs a conversation. + +### Priority is what a rep sees first + +One table, in `@crm/db/agent-tasks` because the API writes these and the agent +reads them, and two copies of an ordering is two orderings. + +| | | | +| --- | --- | --- | +| `brand` | 900 | the logo and the real name, on every row of the list | +| `portrait` | 800 | the face | +| `workspace` | 500 | who *we* are — every later session opens with it | +| `requested` | 300 | a rep pressed Research | +| `meeting` | 200 | a meeting is coming | +| `identify` | 100 | a new contact | +| `sweep` | 50 | the sign-in backfill | +| `companyProfile` | 40 | the written brief | +| `recheck` | 0 | come back in ninety days | + +The top two are the highest on purpose: they are what a rep reads *before* +deciding whether to open anything, they cost one vendor call each, and they +finish in seconds. Everything below is worth a wait; a grey square with initials +in it is not. + +`claimDue` sorts what it claims. Postgres does **not** order an `UPDATE … +RETURNING` by the `ORDER BY` of its own sub-select — it returns rows in whatever +order it touched them — so the priority that chose the batch would otherwise be +thrown away at the point the batch is handed to a concurrency-limited pool. + +### Starting now instead of on the minute + +`POST /internal/crm/dispatch` on the crm channel drains **both lanes** on demand, +and `AgentTriggerService.poke()` calls it after writing *any* `AgentTask` row. +Add a company and its logo appears; add a contact and the research starts. You do +not wait out the cron. + +The poke is **fire-and-forget and never awaited**: the `AgentTask` row is still +the message, exactly as [`api.md`](./api.md) requires, and the cron still claims +anything the poke missed. An agent that is down, redeploying or unreachable costs +sixty seconds, not the work. + +It used to run the visible lane only, on the reasoning that a lane needing no +`receive` and no session auth keeps eve's principal plumbing out of the route. +That was true and it cost more than it saved, because it made the two lanes +behave differently in the one environment where the cron does not exist. Under +`eve dev` the visible lane ran on every poke and the research lane ran *never* — +so a fresh clone showed every logo resolving within seconds while twenty +`identify` rows sat at `attempts = 0` and `AgentEvent` stayed empty. Nothing +errored. A dead lane is very hard to tell from a slow one when the lane beside it +is visibly working. + +So the route starts sessions too. It calls the channel's own `send` rather than +`receive`, since it is already *on* the crm channel — `receive` is for handing +work to a different one. The principal comes from `APP_AUTH` in +[`lib/app-auth.ts`](../apps/agent/agent/lib/app-auth.ts), which is the one copy of +eve's app principal in this repo: `lib/approval.ts` already hard-coded those three +strings to decide whether a turn may write unattended, and eve does not export +`SCHEDULE_APP_AUTH` publicly. `taskAuth()` builds the attributes both callers +need, so the schedule and the route cannot drift on what a session is told about +its task. The schedule still passes eve's own `appAuth` where it has it. + +**Both callers go through `drainAll`, and it collapses.** The cron ran alone once +a minute, so overlap was never a question it had to answer; the poke fires per +enqueued row, and a sync creating forty contacts calls it forty times in a few +seconds. `claimDue` hands each caller a *disjoint* batch, so without a guard that +is forty research sessions at once instead of the twelve a minute the cron +allows — a cost and rate-limit spike triggered by nothing more than a busy inbox. +`collapsing()` in [`lib/pool.ts`](../apps/agent/agent/lib/pool.ts) keeps one drain +in flight and folds everything that arrives during it into a single trailing run, +so work queued mid-drain is still picked up immediately rather than waiting out +the next tick. It is per-process: cross-process overlap stays the job of leases +and `FOR UPDATE SKIP LOCKED`, which already handle it. + +`AGENT_BRIDGE_SECRET` authorises it, and **unset means the route refuses rather +than opens**, the same rule the rep bridge follows. ### Catching up what was missed @@ -318,6 +407,51 @@ Adding a fourth record kind is an entry in `sessionPreamble`, a read beside the other three, and a line in `TOOL_VERBS` (`apps/app/lib/agent-transcript.ts`), which a test enforces so no tool ever shows a rep a bare slug. +### Every session also knows who *we* are + +A research agent that knows everything about the person and nothing about the +company employing it writes a dossier, not a briefing. Asked about a contact +before a call it returned six accurate paragraphs on him and could not say what +any of it meant for us, because nothing had ever told it what we sell. + +So `composeClosing()` puts a **Who we are** block in front of the capabilities +in *every* preamble — contact, company, deal, and the record-less one — and +`lib/workspace.ts` is the only thing that renders it. + +- **It is deliberately tiny**, and the write path enforces that rather than the + prompt asking nicely: 320 characters of narrative and one short line each for + what we sell, who we sell to, and what we are picked over (`MAX_NARRATIVE` and + `MAX_LINE` in [`@crm/db/workspace`](../packages/db/src/workspace.ts)). It rides + in front of every question a rep asks, so a page of it would crowd out the + record they are actually asking about — and it is prompt-cached, so it is paid + for once and then read on every turn forever. +- **It says what the context is for.** "Say what this record means for us — a + fit, a competitor, a partner, or nothing worth saying — and never write a + pitch: the rep already knows what we sell." Without that line the model has + the facts and no instruction, and starts selling our own product back to us. +- **A workspace with no profile still gets the name line**, followed by *do not + guess at what we sell*. The failure mode of the alternative is a confident + invention drawn from our customers' industries. +- **The profile belongs to a website, and dies with it.** + `readWorkspaceIdentity` returns the profile only when its `website` still + matches the workspace's. Change the website and the block silently drops to + the name line until the new one is researched, rather than describing the + company we used to be. +- **It is not a `Company` row.** A self company would need excluding from every + list, facet, sweep and join in the app — the always-the-same-`organizationId` + trap from [`api.md`](./api.md) wearing a different hat. It is one row in + `WorkspaceProfile`, keyed on `WORKSPACE_ID`, which is why that constant now + lives in `@crm/db` and `@crm/auth` re-exports it: the agent has no dependency + on the auth package and there must not be a second copy of the string. + +The research pass is a `workspace-profile` task with no `contactId` and no +`companyId`, dispatched like everything else. Its preamble sends the session to +our own site with `web_fetch` — no vendor credits — and `write_workspace_profile` +is the only way to file the result. `WorkspaceService.update` queues it when the +website changes, and the sign-in sweep queues it when a website has no profile +behind it, which covers the install that filled the settings page in before any +of this existed and the one whose first attempt failed. + ## What the agent may read, and what may leave It may read **everything**, including full email bodies — single-tenant internal @@ -544,6 +678,133 @@ curl -s -H 'Host: agent.example.com' \ sandbox, and a `diagnostics` count that is the fastest way to find a file eve silently ignored. +## Watching it work + +`eve dev` shows every tool call, every result and every token — but only in its +interactive TUI, and under `bun run dev` that TUI is unreadable. Turbo gives each +task a pty, so eve believes it owns a terminal and paints a full-screen UI into a +pane that turbo is also drawing; the two redraws interleave and what a rep of the +agent's work actually looks like is `Building your agent compiling +agent[agent] on LinkedIn (RAPIDAPI_KEY)`. The prompt at the bottom is real and +you cannot type at it usefully either. + +So **`dev` is `eve dev --no-ui`**, and `dev:tui` keeps the interactive one for +when you run the agent on its own and want to talk to it. `--no-ui` changes +nothing about the server — same port, same routes, same watcher — it only stops +eve taking over the terminal, which is the whole of the problem. + +That leaves nothing narrating the session, and `hooks/activity.ts` is that +narration: a line per tool call with its arguments, a line per result with how +long it took, the finish reason and token spend of each step, and any failure +with its code. + +- **The lines go to stderr, not stdout.** The TUI's default log mode is `stderr` + and it keeps stdout buffered and hidden, so a `console.log` here would be + invisible in the mode it exists to serve. Written to stderr the same lines show + under `--no-ui`, under the TUI, and in `eve logs`. +- **Contents print outside production; the shape prints everywhere.** Which tool + ran, whether it worked, what it cost — none of that is anybody's data, so it + logs wherever the agent runs. Arguments and replies carry names, addresses and + whatever a rep typed, which is the "nothing sensitive logged" rule above, so + they are gated on `NODE_ENV`. In production the durable record is an + `AgentEvent` row, not a log drain. +- **It is not the audit trail.** `hooks/audit.ts` writes every event to + `AgentEvent` whatever this prints, and the panel's transcript is read back from + there. A change to one is not a change to the other. +- **A call is timed by remembering it, because the result event does not carry + the tool name or a duration.** The map of in-flight calls is bounded rather + than trusted: a turn that dies between request and result would otherwise leak + an entry per call, forever, in a process that stays up for days. + +`eve logs` reads the full record back, but only for an **interactive** `eve dev` +— that is the process that writes `.eve/logs/`. Under `--no-ui` the pane is the +record, so keep the turbo scrollback rather than going looking for a file that +was never written. + +Two consequences of running headless, both worth recognising rather than +debugging: + +- **A second `bun run dev` fails the whole turbo run.** An interactive `eve dev` + reconnects to a local server that is already up; a headless one rejects it and + exits non-zero, and turbo then tears down the other tasks in *that* + invocation — the first one keeps running untouched. `A dev server is already + running for this eve agent` means exactly what it says: you already have one. + Use the terminal it is in, or stop it before starting another. +- **An orphaned agent holds the port.** If turbo dies without reaping its child, + nothing on screen says so and every later `bun run dev` fails the same way. + `lsof -nP -iTCP:2000 -sTCP:LISTEN` names the process to kill. + +### Nothing is researching, and the queue only grows + +**`eve dev` never fires schedules on their cron cadence.** It is one line in +eve's own [schedules guide](../apps/agent/node_modules/eve/docs/schedules.mdx), +and it used to be the single most confusing thing about working on this agent, +because every visible part of the loop worked: the Research button wrote its +`AgentTask` row, the sheet said *Queued*, the toast promised the page would +update — and `schedules/dispatch.ts`, the only thing that turns a row into a +session, was never called. Twenty rows sat with `attempts = 0` and `AgentEvent` +was empty. Nothing was broken and nothing reported a problem, because nothing +ran. + +**The poke is what makes dev behave like production now**, which is most of why +it was widened to both lanes — see [starting now](#starting-now-instead-of-on-the-minute). +A row written by the API is dispatched immediately whatever the clock is doing, +so the schedule is a backstop rather than the only door. + +That leaves two cases where the clock's absence still bites, and both look +identical to the above: **a task the API did not write** — `schedule_recheck`, +which books its own `dueAt` weeks out — and **anything queued while the agent was +down**, since a missed poke is not retried. For those, the dev server mounts a +one-shot route that runs the exact dispatch path production cron uses: + +```sh +bun run --filter=agent dispatch +# {"scheduleId":"dispatch","sessionIds":["wrun_01KZ…", …]} +``` + +It claims a batch of `BATCH` tasks and starts a real session per row, so it +spends real credits — that is the point of it, and the reason it is a command +you run rather than a ticker somebody leaves on. Watch the agent pane; the +session ids it returns are also streamable at +`GET /eve/v1/session/:id/stream`. + +`eve start` on a built app *does* run the schedule, and so does Vercel, where +each `defineSchedule` becomes a Cron Job. Dev is the only place the clock is +missing. + +### The continuation token you write is not the one you read + +**eve namespaces a continuation token with the channel's name.** `channels/crm.ts` +mints `task:`; by the time `session.waiting` hands it back on the channel +context it is `crm:task:`. The `eve` channel's own sessions read back as +`eve:` for the same reason. + +This is worth stating because of how it failed, which was silently and +completely. `taskToken()` used to mint `crm:task:` itself, so the handler was +matching `startsWith("crm:task:")` against `crm:crm:task:`, getting `null`, +and returning before `completeTask`. Nothing errored. The research ran, facts +were written, briefs were saved, and the agent pane showed a clean session — but +**no task ever reached `finishedAt`**, so every contact sat on "Researching" +forever and the sweep re-queued work that had already been done. Twenty-eight +tasks, zero finished. + +Two things made it survive a reading: + +- **The event data and the channel accessor disagree.** `session.waiting`'s + `data.continuationToken` carries the token *as stored* — un-namespaced — while + `channel.continuationToken` carries it namespaced. Debugging from the archived + event says the token is fine, because from that angle it is. +- **Nothing downstream depends on settling.** The record's status is the only + thing that notices, and "still researching" is indistinguishable from + "researching slowly" until you look at `attempts` in the table. + +So `taskFromToken` keys on the `task:` marker rather than a fixed prefix, which +reads correctly whoever namespaced it and still settles sessions parked before +the fix. `test/crm-token.spec.ts` pins all three forms. + +The general rule: **a channel handler must not assume the token it receives is +byte-identical to the one it sent.** Parse for your own marker. + ## Tests `bun run --filter=agent test`. The integration specs need `DATABASE_URL` and run diff --git a/docs/api.md b/docs/api.md index 0feb73492..1dfde8785 100644 --- a/docs/api.md +++ b/docs/api.md @@ -65,17 +65,173 @@ slower than the request that produced it. If you are about to add a vendor client to `apps/api`, you want `apps/agent/agent/lib` instead. -## There are no organizations +## There is exactly one organization, and it is not a tenancy boundary This is an internal tool behind Google sign-in, and it is **single tenant**. -There is no `Organization` model, no `x-organization-slug` header, no org -context interceptor, and no org-scoped cache keys. "Signed in" is the entire -authorisation model. - -If you are porting something from the Comp AI MVP, delete the org plumbing -rather than stubbing it — an `organizationId` that is always the same value is -a column, an index and a `where` clause that buy nothing, and a permissions -check that always returns `true` reads like a real one at review time. +There is no `x-organization-slug` header, no org context interceptor, no +org-scoped cache keys, and **no `organizationId` on any CRM record**. A company, +a contact, a deal and an activity are scoped by nothing, because there is +nothing to scope them to. + +What does exist is a **singleton workspace**: the Better Auth `organization` +plugin, holding one row whose id is the literal string `workspace` +(`WORKSPACE_ID`, defined in [`@crm/db`](../packages/db/src/workspace.ts) and +re-exported by `@crm/auth` — the agent reads workspace rows and does not depend +on the auth package, and one id must not be two strings). It is there to answer +three questions a CRM has to answer about *itself* — what is this company +called, who works here, and what do we sell — and for nothing else. + +- **The id is a constant, never a parameter.** Every read says + `where: { id: WORKSPACE_ID }`. The moment a function takes an + `organizationId`, the plugin has become tenancy plumbing and the rule above + is broken. If you are porting something from the Comp AI MVP, delete the org + threading rather than stubbing it — an `organizationId` that is always the + same value is a column, an index and a `where` clause that buy nothing. +- **Signing in is the join, and there is no invite flow.** + `ensureWorkspaceMembership` runs in `databaseHooks.session.create.before`, so + the workspace and the caller's `Member` row exist by the time any request is + served. `ALLOWED_SIGN_IN` already decides who may sign in; an invitation + would be a second, quieter answer to the same question. The plugin's + `invitation` table is created because the plugin owns its own schema — it is + unused, and nothing in this repo writes to it. +- **The first account is the owner; everyone after is a member.** When the + workspace row is created the hook enrols *every user that already exists*, + oldest first as owner — otherwise an install that predates the plugin shows + an empty Members page until each person happens to sign in again, which looks + identical to being broken. +- **`ensureWorkspaceMembership` degrades, it does not throw.** A failure there + would fail the session create, which is to say it would lock everyone out of + the CRM to protect a settings page. It logs, returns `undefined`, and the next + sign-in retries — the hook runs on every session, so it is self-healing. +- **Permissions are read from one place.** `canRenameWorkspace` and + `canChangeRole` in `@crm/auth` are what the service enforces *and* what the + UI disables its controls on, so the button and the 403 can never disagree. + They match the plugin's own default statements — owner and admin — rather + than inventing a second model beside it. `WorkspaceService` adds the one + invariant the plugin has no opinion about: **the last owner cannot be + demoted.** +- **Reads and writes go through tRPC, not `authClient.organization.*`.** + Renaming the workspace is data, not authentication, so it belongs on the data + surface with everything else — see the next rule. +- **The name and the website are asked for once, at the door, and there is no + skipping it.** Both fields are `required` in the form *and* in + `updateWorkspaceInput`, so the website cannot be dropped later from the + settings page either — a CRM that knows what we sell on Monday and not on + Tuesday is worse than one that never knew. The gate only catches somebody who + could *answer* it (`canRename`), so a member never meets a form they are + forbidden to submit, and it posts the same `workspace.update` mutation as the + settings page rather than a second write path. +- **The state is `onboardedAt` inside the organization's `metadata`, not a + column beside it.** The plugin ships that blob and owns the table; a second + timestamp column is a second place the same fact is recorded, and the two + drifted the first time somebody wrote a website through a revision of + `WorkspaceService.update` that predated the column. A row with a name, a + website and a null timestamp is a workspace that has plainly answered the + question and is asked it forever. `isOnboarded` and `markOnboarded` in + [`@crm/db/workspace`](../packages/db/src/workspace.ts) are the only readers + and the only writer; `markOnboarded` keeps the first answer and preserves + every other key, because the blob is the plugin's, not ours. +- **The gate is `proxy.ts`, and it is answered once per browser.** It used to + live in `(app)/layout.tsx`, which meant a `workspace.get` round trip on every + navigation into the app to re-establish a fact that changes once in the life + of an install — and a second, opposing redirect on the `/onboarding` page to + stop the first one looping. Two redirects pointing at each other is not a + gate, it is a latch waiting for the two reads to disagree. + - **`getSessionCookie()` decides signed-in, not a session lookup.** That is + Better Auth's documented optimistic check for proxy, and it is all a + redirect needs; every page behind it still resolves the real session + server-side through `requireGoogleAccess()`. + - **The answer is cached in an httpOnly `crm.onboarded` cookie**, so the + common path costs nothing and the tRPC read happens once — twice for the + person who actually fills the form in, since the cookie lands on the + navigation after the mutation. The proxy is the only writer; the form does + not set it, because two writers is how this went wrong in the first place. + Forging the cookie skips a setup form and grants nothing, which is why it + can be a cookie at all. + - **`/sign-in`, `/grant-access` and `/eve` are ungated.** + `requireGoogleAccess()` redirects to `/grant-access`, so gating it would + ping-pong against the onboarding redirect for anyone who signed in without + both scopes. + - **An unreachable API fails open.** `readOnboardingGate` returns `unknown` + on a non-200, a timeout or a parse failure, and an unknown gate lets the + request through without writing the cookie. The alternative is an install + that cannot reach its own API redirecting every request to a form that + cannot be submitted. +- **The name arrives as a placeholder, not as an answer.** A workspace is + created as `DEFAULT_WORKSPACE_NAME` — the literal string `CRM` — and the field + is empty with that behind it. It used to be derived from the sign-in domain, + which put `Trycomp` in the box as though somebody had typed it, and a guess + presented as an answer is a guess that gets accepted. +- **The website is the field with a consequence.** Saving it queues the agent's + `workspace-profile` task, and what comes back is read into the opening context + of every session the agent runs — see + [the agent's rules](./agent.md#every-session-also-knows-who-we-are). The API + writes the row and decides nothing about it, which is what keeps this on the + right side of the first rule in this file. + +## SSO is a row, not a deployment + +Google is the sign-in method a clone starts with. An install that has its own +identity provider adds one on **Settings → SSO**, and the whole of that +configuration is an `ssoProvider` row written by Better Auth's +[`sso` plugin](https://www.better-auth.com/docs/plugins/sso) — not an +environment variable, because a self-hoster's admin cannot redeploy. + +- **OpenID Connect only.** `apps/api/src/sso` registers a provider from an + issuer, a client id and a client secret; everything else — the authorization, + token, JWKS and userinfo endpoints — is read from the issuer's discovery + document at registration time. The plugin can do SAML as well and there is + deliberately no UI for it: SAML needs an X.509 certificate and an SP signing + key this app has nowhere to generate or keep, and a half-configured SAML + provider fails at the IdP with an error nobody here can read. +- **The provider belongs to the workspace, and the id is still a constant.** + `SsoService` passes `WORKSPACE_ID`; it is never an input. That is also what + gives the plugin's own `sso/register` its permission check for free, and + `canConfigureSso` in [`@crm/auth`](../packages/auth/src/sso.ts) is the second + half — the same owner-or-admin answer the settings page disables its button + on, beside `canRenameWorkspace`. +- **The management surface is tRPC; signing in is not.** Listing, adding and + removing a provider is configuration, so it goes through `sso.*` like every + other read and write. `authClient.signIn.sso()` stays on the auth client, + because that one *is* authentication. +- **`sso.signInOptions` is the one public procedure in the app.** The sign-in + page is unauthenticated and has to know what it may offer, so it returns each + provider's id and the name to print on the button, plus whether a Google + client is configured at all — nothing else. `sso.list` carries the issuer, the + domains and the last four of the client id, and it — like `sso.settings`, + `sso.register` and `sso.remove` — takes `AuthMiddleware` at the method rather + than the router, which is what leaves `sso.signInOptions` open. A client + secret is never read back out of any of them. +- **It is the API's answer, not the app's.** Both processes read one `.env`, but + `/api/auth/*` is served by the API, so whether Google sign-in works is a fact + about *its* environment. The app asking itself would be right until the day + the two are deployed with different configuration, and then it would offer a + button that 500s. +- **An install with neither says so.** No Google client and no provider is not + an empty sign-in page: it is the one state where the reader is the person who + can fix it, so `/sign-in` names the two variables to set. A read that *fails* + is different and must not print that — an unreachable API is not a missing + configuration, so the page falls back to offering Google. +- **A configured provider replaces the Google button, it does not disable + Google.** `/sign-in?method=google` still offers it. Hiding is the point — + locking an admin out of their own CRM because they typed an issuer URL wrong + is not. It only offers it when there *is* a Google client, so the escape hatch + is never a button that cannot work. +- **Signing in with an IdP does not cost you Gmail.** Google is two separate + things here — a way to prove who you are, and a mailbox to read — and an + install that replaced the first still wants the second. So Gmail and Calendar + are a *connection* for an SSO rep, not a condition of entry: `needsGoogleGrant` + in [`@crm/auth`](../packages/auth/src/scopes.ts) walls only an account whose + sole sign-in row is Google, and Settings → Connections carries the button that + links one. See [the sync rules](./environment.md#gmail-and-calendar-sync). +- **`ALLOWED_SIGN_IN` still decides who gets in.** SSO says where someone + authenticates; the allow-list says whether that address may have an account, + and `databaseHooks.user.create.before` enforces it on an SSO sign-up exactly + as it does on a Google one. Two questions, one answer each. +- **The plugin does not do the workspace join.** + `organizationProvisioning: { disabled: true }`, because + `ensureWorkspaceMembership` already runs on every session create. Two things + enrolling the same person is two things to keep in step. ## tRPC is the data surface; REST is for auth and health diff --git a/docs/environment.md b/docs/environment.md index dbc1d73ea..9cb2a3657 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -46,20 +46,36 @@ frames away as a missing variable. `packages/env/test/root.spec.ts` pins this. ## What is required -Five values, and the API refuses to start without them. +Three values, and the API refuses to start without them. | Variable | Why it has no default | | --- | --- | | `DATABASE_URL` | `docker compose up -d` starts a Postgres that matches `.env.example` exactly | | `BETTER_AUTH_SECRET` | Signs session cookies. `openssl rand -base64 32` | | `ALLOWED_SIGN_IN` | The entire authorisation model — see below | -| `GOOGLE_CLIENT_ID` | Google is the only sign-in method | -| `GOOGLE_CLIENT_SECRET` | | Everything else has a working localhost default or is genuinely optional. That is the difference between a clone that runs and a clone that makes you read a table of variables first. +### Google is the fourth value, and it is a pair + +`GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` are what a clone starts with, and +almost every install wants them: they are both the sign-in button and the Gmail +and Calendar sync. They are nonetheless **optional**, because an install that +signs in through [its own identity provider](./api.md#sso-is-a-row-not-a-deployment) +should not have to create a Google Cloud project to do it. + +- **They are set together or not at all.** `packages/auth/src/env.ts` throws on + one without the other, because half a client is a sign-in button that fails at + Google with an error the reader cannot act on. +- **Neither Google nor a provider is a state the sign-in page reports**, naming + the two variables — see [the SSO rules](./api.md#sso-is-a-row-not-a-deployment). + It is the one configuration mistake whose audience is the person who can fix + it, so it must not present as a blank page. +- **Without them, Gmail sync is a capability the install does not have**, and + Settings → Connections says so rather than offering a button that cannot work. + ### `ALLOWED_SIGN_IN` Who may sign in, comma-separated, each entry either a whole email domain or a @@ -163,10 +179,32 @@ Always on. Same OAuth client, same callback — the two read-only scopes are add to the existing Google provider rather than to a second one, so there is no extra redirect URI to register. -The scopes are requested **at sign-in** and are a condition of using the CRM: -`requireGoogleAccess()` gates the app shell on what Google actually granted, -because granular consent lets a user untick a scope and still complete sign-in. -Anyone missing either scope is sent to `/grant-access` to re-consent. +The scopes are requested **at sign-in** and are a condition of using the CRM +*for the person who signed in with Google*: `requireGoogleAccess()` gates the +app shell on what Google actually granted, because granular consent lets a user +untick a scope and still complete sign-in. Anyone missing either scope is sent +to `/grant-access` to re-consent. + +**Someone who signed in through an identity provider is not gated**, and the +distinction is the whole of `needsGoogleGrant` in +[`@crm/auth`](../packages/auth/src/scopes.ts): the wall applies to an account +whose only sign-in row is `google`. Two reasons it cannot be "does this person +have the scopes". + +- **An SSO rep has no Google account to grant them on.** Sending them to + `/grant-access` is sending them to a page whose only other button is *sign + out* — a locked door with a sign on it, on an install that may have no Google + client at all. +- **Linking Gmail must not become a trap.** An SSO rep who connects Google and + later revokes it would, under a scopes-only rule, be locked out of the CRM by + having tried the optional feature. `revoke()` clears the tokens and keeps the + `account` row, so that row is exactly what a scopes-only rule would trip over. + +They connect it from **Settings → Connections** instead, which posts the same +`linkSocial` call `/grant-access` does — one write path, two doors. The card +tells the three states apart, because they need three different sentences: no +Google client on the install, a client but no linked account, and a linked +account that has stopped working. **Sync is forward-only.** Nothing from before a mailbox was first seen is imported: Gmail records the current `historyId` on its first pass and imports @@ -214,7 +252,10 @@ works without any app running. - **Redis** — optional. Without `REDIS_URL` the cache falls back to a per-instance in-memory store, which is fine for local work and wrong for any multi-instance deploy (see `docs/api.md`). -- **Sign-in method** — Google-only, in code, not configurable. +- **Sign-in method** — Google is the built-in one and it is in code. An + install that wants its own identity provider adds one on the SSO settings + page, and that is a row rather than a variable — see + [SSO](./api.md#sso-is-a-row-not-a-deployment). ## `vercel env pull` writes to `.env.local`, which wins diff --git a/package.json b/package.json index 3927c45d0..ec73b9395 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "crm", "private": true, "license": "MIT", + "version": "0.0.0", "scripts": { "build": "turbo run build", "dev": "turbo run dev", @@ -26,6 +27,7 @@ "engines": { "node": ">=22" }, + "packageManager": "bun@1.3.12", "devEngines": { "packageManager": { "name": "bun", diff --git a/packages/auth/package.json b/packages/auth/package.json index 823669e8f..92f6c0c5b 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -17,6 +17,7 @@ "clean": "rm -rf .turbo node_modules" }, "dependencies": { + "@better-auth/sso": "1.6.25", "@crm/db": "workspace:*", "@crm/env": "workspace:*", "better-auth": "^1.6.25" diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts index 619476bb4..a6ca05f33 100644 --- a/packages/auth/src/auth.ts +++ b/packages/auth/src/auth.ts @@ -1,8 +1,11 @@ +import { sso } from "@better-auth/sso"; import { db } from "@crm/db"; import { type BetterAuthOptions, betterAuth } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; import { APIError } from "better-auth/api"; +import { organization } from "better-auth/plugins/organization"; import { env } from "./env"; +import { ensureWorkspaceMembership } from "./organization"; import { SYNC_SCOPES } from "./scopes"; import { notifySignedIn } from "./signed-in"; import { @@ -72,6 +75,29 @@ export const auth = betterAuth({ trustedOrigins: [...env.trustedOrigins], hooks: {}, + plugins: [ + organization({ + allowUserToCreateOrganization: false, + disableOrganizationDeletion: true, + creatorRole: "owner", + + schema: { + organization: { + additionalFields: { + website: { + type: "string", + required: false, + }, + }, + }, + }, + }), + + sso({ + organizationProvisioning: { disabled: true }, + }), + ], + databaseHooks: { user: { create: { @@ -99,6 +125,14 @@ export const auth = betterAuth({ session: { create: { + before: async (session) => { + const workspaceId = await ensureWorkspaceMembership(session.userId); + + return { + data: { ...session, activeOrganizationId: workspaceId ?? null }, + }; + }, + after: async (session) => { const user = await db.user.findUnique({ where: { id: session.userId }, diff --git a/packages/auth/src/client.ts b/packages/auth/src/client.ts index e54eb7cf9..b7c665b35 100644 --- a/packages/auth/src/client.ts +++ b/packages/auth/src/client.ts @@ -1,7 +1,9 @@ +import { ssoClient } from "@better-auth/sso/client"; import { createAuthClient } from "better-auth/react"; export const authClient = createAuthClient({ baseURL: typeof window === "undefined" ? undefined : window.location.origin, + plugins: [ssoClient()], }); export const { getSession, signIn, signOut, useSession } = authClient; diff --git a/packages/auth/src/env.ts b/packages/auth/src/env.ts index 99556bf4b..792b9ecdf 100644 --- a/packages/auth/src/env.ts +++ b/packages/auth/src/env.ts @@ -44,4 +44,8 @@ export const env = { isProduction: process.env.NODE_ENV === "production", } as const; +export function isGoogleConfigured(): boolean { + return env.google !== undefined; +} + export { apiUrl, appUrl }; diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 9b2817344..57ae55b86 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -1,14 +1,36 @@ export { type Auth, auth, type Session, type SessionUser } from "./auth"; +export { isGoogleConfigured } from "./env"; +export { + canChangeRole, + canRenameWorkspace, + DEFAULT_WORKSPACE_NAME, + ensureWorkspaceMembership, + isWorkspaceRole, + WORKSPACE_ID, + WORKSPACE_ROLES, + WORKSPACE_SLUG, + type WorkspaceRole, +} from "./organization"; export { CALENDAR_SCOPE, GMAIL_SCOPE, + GOOGLE_PROVIDER_ID, hasSyncScopes, IDENTITY_SCOPES, + needsGoogleGrant, parseScopes, REQUIRED_SCOPES, + type SignInAccount, SYNC_SCOPES, + signsInWithGoogle, } from "./scopes"; export { onSignedIn, type SignedInHandler } from "./signed-in"; +export { + canConfigureSso, + ssoCallbackBase, + ssoCallbackURL, + ssoProviderName, +} from "./sso"; export { hasSignInAllowList, isWorkspaceEmail, diff --git a/packages/auth/src/organization.ts b/packages/auth/src/organization.ts new file mode 100644 index 000000000..e31d3c9f4 --- /dev/null +++ b/packages/auth/src/organization.ts @@ -0,0 +1,85 @@ +import { db } from "@crm/db"; +import { WORKSPACE_ID } from "@crm/db/workspace"; + +export { WORKSPACE_ID }; + +export const WORKSPACE_SLUG = "workspace"; + +export const DEFAULT_WORKSPACE_NAME = "CRM"; + +export const WORKSPACE_ROLES = ["owner", "admin", "member"] as const; + +export type WorkspaceRole = (typeof WORKSPACE_ROLES)[number]; + +export function isWorkspaceRole(value: string): value is WorkspaceRole { + return (WORKSPACE_ROLES as readonly string[]).includes(value); +} + +export function canRenameWorkspace(role: WorkspaceRole | null): boolean { + return role === "owner" || role === "admin"; +} + +export function canChangeRole(role: WorkspaceRole | null): boolean { + return role === "owner" || role === "admin"; +} + +export async function ensureWorkspaceMembership( + userId: string, +): Promise { + try { + return await db.$transaction(async (tx) => { + const workspace = await tx.organization.upsert({ + where: { id: WORKSPACE_ID }, + create: { + id: WORKSPACE_ID, + name: DEFAULT_WORKSPACE_NAME, + slug: WORKSPACE_SLUG, + createdAt: new Date(), + }, + update: {}, + select: { id: true }, + }); + + const enrolled = await tx.member.count({ + where: { organizationId: workspace.id }, + }); + + if (enrolled === 0) { + const existing = await tx.user.findMany({ + select: { id: true }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + }); + + await tx.member.createMany({ + data: existing.map((user, index) => ({ + id: crypto.randomUUID(), + organizationId: workspace.id, + userId: user.id, + role: index === 0 ? "owner" : "member", + createdAt: new Date(), + })), + skipDuplicates: true, + }); + } + + await tx.member.upsert({ + where: { + organizationId_userId: { organizationId: workspace.id, userId }, + }, + create: { + id: crypto.randomUUID(), + organizationId: workspace.id, + userId, + role: "member", + createdAt: new Date(), + }, + update: {}, + }); + + return workspace.id; + }); + } catch (error) { + console.error("[auth] could not enrol the user in the workspace", error); + return undefined; + } +} diff --git a/packages/auth/src/scopes.ts b/packages/auth/src/scopes.ts index 9a898c19b..ce3ca7ffd 100644 --- a/packages/auth/src/scopes.ts +++ b/packages/auth/src/scopes.ts @@ -1,3 +1,5 @@ +export const GOOGLE_PROVIDER_ID = "google"; + export const IDENTITY_SCOPES = ["openid", "email", "profile"] as const; export const GMAIL_SCOPE = "https://www.googleapis.com/auth/gmail.readonly"; @@ -13,6 +15,24 @@ export function hasSyncScopes(scope: string | null | undefined): boolean { return SYNC_SCOPES.every((needed) => granted.has(needed)); } +export type SignInAccount = { + providerId: string; + scope?: string | null; +}; + +export function signsInWithGoogle(accounts: readonly SignInAccount[]): boolean { + return ( + accounts.length > 0 && + accounts.every((account) => account.providerId === GOOGLE_PROVIDER_ID) + ); +} + +export function needsGoogleGrant(accounts: readonly SignInAccount[]): boolean { + if (!signsInWithGoogle(accounts)) return false; + + return !accounts.some((account) => hasSyncScopes(account.scope)); +} + export function parseScopes(scope: string | null | undefined): Set { return new Set( (scope ?? "") diff --git a/packages/auth/src/sso.ts b/packages/auth/src/sso.ts new file mode 100644 index 000000000..01a1708bd --- /dev/null +++ b/packages/auth/src/sso.ts @@ -0,0 +1,31 @@ +import { apiUrl } from "./env"; +import type { WorkspaceRole } from "./organization"; + +export function canConfigureSso(role: WorkspaceRole | null): boolean { + return role === "owner" || role === "admin"; +} + +export function ssoCallbackBase(): string { + return `${apiUrl}/api/auth/sso/callback`; +} + +export function ssoCallbackURL(providerId: string): string { + return `${ssoCallbackBase()}/${providerId}`; +} + +export function ssoProviderName(providerId: string): string { + const words = providerId + .split(/[-_.\s]+/) + .map((word) => word.trim()) + .filter(Boolean); + + if (words.length === 0) return providerId; + + return words + .map((word) => + word === word.toUpperCase() + ? word + : word.charAt(0).toUpperCase() + word.slice(1), + ) + .join(" "); +} diff --git a/packages/auth/test/google-grant.spec.ts b/packages/auth/test/google-grant.spec.ts new file mode 100644 index 000000000..76cad1a80 --- /dev/null +++ b/packages/auth/test/google-grant.spec.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "bun:test"; +import { + needsGoogleGrant, + SYNC_SCOPES, + signsInWithGoogle, +} from "../src/scopes"; + +const GRANTED = SYNC_SCOPES.join(","); + +describe("who has to grant Gmail and Calendar", () => { + it("walls someone who signed in with Google and granted neither scope", () => { + expect( + needsGoogleGrant([{ providerId: "google", scope: "openid,email" }]), + ).toBe(true); + }); + + it("walls someone whose granular consent dropped one of them", () => { + expect( + needsGoogleGrant([ + { providerId: "google", scope: `openid,${SYNC_SCOPES[0]}` }, + ]), + ).toBe(true); + }); + + it("lets a Google account through once both scopes are there", () => { + expect(needsGoogleGrant([{ providerId: "google", scope: GRANTED }])).toBe( + false, + ); + }); + + it("never walls someone who signed in through their own IdP", () => { + expect(needsGoogleGrant([{ providerId: "okta", scope: null }])).toBe(false); + }); + + it("never walls an SSO rep who has linked Google and then revoked it", () => { + expect( + needsGoogleGrant([ + { providerId: "okta", scope: null }, + { providerId: "google", scope: null }, + ]), + ).toBe(false); + }); + + it("still lets an SSO rep with Gmail connected through", () => { + expect( + needsGoogleGrant([ + { providerId: "okta", scope: null }, + { providerId: "google", scope: GRANTED }, + ]), + ).toBe(false); + }); + + it("does not wall an account with no sign-in rows at all", () => { + expect(needsGoogleGrant([])).toBe(false); + }); +}); + +describe("whether revoking Google costs someone the CRM", () => { + it("is true when Google is the only way in", () => { + expect(signsInWithGoogle([{ providerId: "google", scope: GRANTED }])).toBe( + true, + ); + }); + + it("is false once an IdP is also on the account", () => { + expect( + signsInWithGoogle([ + { providerId: "okta", scope: null }, + { providerId: "google", scope: GRANTED }, + ]), + ).toBe(false); + }); + + it("is false for an account with nothing linked", () => { + expect(signsInWithGoogle([])).toBe(false); + }); +}); diff --git a/packages/auth/test/organization.integration.spec.ts b/packages/auth/test/organization.integration.spec.ts new file mode 100644 index 000000000..8cab24b6e --- /dev/null +++ b/packages/auth/test/organization.integration.spec.ts @@ -0,0 +1,140 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { db } from "@crm/db"; +import { ensureWorkspaceMembership, WORKSPACE_ID } from "../src/organization"; + +const suffix = process.env.TEST_RUN_ID ?? "organization-spec"; + +const emailOf = (label: string) => `${label}.${suffix}@example.test`; + +type Snapshot = { + organization: { name: string; slug: string; website: string | null } | null; + members: { id: string; userId: string; role: string; createdAt: Date }[]; +}; + +let snapshot: Snapshot; +let firstId: string; +let secondId: string; +let laterId: string; + +const seedUser = async (label: string, createdAt: Date): Promise => { + const user = await db.user.create({ + data: { + id: `${suffix}-${label}`, + name: label, + email: emailOf(label), + createdAt, + updatedAt: createdAt, + }, + select: { id: true }, + }); + + return user.id; +}; + +const roleOf = async (userId: string): Promise => { + const member = await db.member.findUnique({ + where: { organizationId_userId: { organizationId: WORKSPACE_ID, userId } }, + select: { role: true }, + }); + + return member?.role ?? null; +}; + +beforeAll(async () => { + const organization = await db.organization.findUnique({ + where: { id: WORKSPACE_ID }, + select: { name: true, slug: true, website: true }, + }); + + snapshot = { + organization, + members: await db.member.findMany({ + where: { organizationId: WORKSPACE_ID }, + select: { id: true, userId: true, role: true, createdAt: true }, + }), + }; + + await db.member.deleteMany({ where: { organizationId: WORKSPACE_ID } }); + await db.organization.deleteMany({ where: { id: WORKSPACE_ID } }); + await db.user.deleteMany({ + where: { email: { endsWith: `.${suffix}@example.test` } }, + }); + + firstId = await seedUser("first", new Date("2020-01-01T00:00:00Z")); + secondId = await seedUser("second", new Date("2021-01-01T00:00:00Z")); +}); + +afterAll(async () => { + await db.member.deleteMany({ where: { organizationId: WORKSPACE_ID } }); + await db.organization.deleteMany({ where: { id: WORKSPACE_ID } }); + await db.user.deleteMany({ + where: { email: { endsWith: `.${suffix}@example.test` } }, + }); + + if (snapshot.organization) { + await db.organization.create({ + data: { + id: WORKSPACE_ID, + createdAt: new Date(), + ...snapshot.organization, + }, + }); + + await db.member.createMany({ + data: snapshot.members.map((member) => ({ + ...member, + organizationId: WORKSPACE_ID, + })), + }); + } +}); + +describe("ensureWorkspaceMembership", () => { + it("creates the one workspace and enrols everyone who already had an account", async () => { + const workspaceId = await ensureWorkspaceMembership(secondId); + + expect(workspaceId).toBe(WORKSPACE_ID); + expect(await roleOf(firstId)).toBe("owner"); + expect(await roleOf(secondId)).toBe("member"); + }); + + it("is idempotent, so signing in again neither duplicates nor re-roles", async () => { + await db.member.update({ + where: { + organizationId_userId: { + organizationId: WORKSPACE_ID, + userId: secondId, + }, + }, + data: { role: "admin" }, + }); + + await ensureWorkspaceMembership(secondId); + await ensureWorkspaceMembership(secondId); + + const rows = await db.member.findMany({ + where: { organizationId: WORKSPACE_ID, userId: secondId }, + }); + + expect(rows).toHaveLength(1); + expect(rows[0]?.role).toBe("admin"); + }); + + it("joins someone who signs up later as a member", async () => { + laterId = await seedUser("later", new Date("2026-01-01T00:00:00Z")); + + await ensureWorkspaceMembership(laterId); + + expect(await roleOf(laterId)).toBe("member"); + }); + + it("leaves the owner alone when a later arrival signs in", async () => { + expect(await roleOf(firstId)).toBe("owner"); + + const owners = await db.member.count({ + where: { organizationId: WORKSPACE_ID, role: "owner" }, + }); + + expect(owners).toBe(1); + }); +}); diff --git a/packages/auth/test/sso.spec.ts b/packages/auth/test/sso.spec.ts new file mode 100644 index 000000000..def7366e0 --- /dev/null +++ b/packages/auth/test/sso.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "bun:test"; +import { + canConfigureSso, + ssoCallbackBase, + ssoCallbackURL, + ssoProviderName, +} from "../src/sso"; + +describe("canConfigureSso", () => { + it("is the same answer as renaming the workspace", () => { + expect(canConfigureSso("owner")).toBe(true); + expect(canConfigureSso("admin")).toBe(true); + expect(canConfigureSso("member")).toBe(false); + expect(canConfigureSso(null)).toBe(false); + }); +}); + +describe("ssoCallbackURL", () => { + it("hangs off the base the settings page shows", () => { + expect(ssoCallbackURL("okta")).toBe(`${ssoCallbackBase()}/okta`); + }); + + it("is the path better-auth mounts the callback on", () => { + expect(new URL(ssoCallbackURL("okta")).pathname).toBe( + "/api/auth/sso/callback/okta", + ); + }); +}); + +describe("ssoProviderName", () => { + it("reads as a button on the sign-in page", () => { + expect(ssoProviderName("okta")).toBe("Okta"); + expect(ssoProviderName("entra-id")).toBe("Entra Id"); + expect(ssoProviderName("jump_cloud")).toBe("Jump Cloud"); + }); + + it("leaves an acronym alone", () => { + expect(ssoProviderName("ADFS")).toBe("ADFS"); + }); +}); diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json index 017066e14..3c44722d2 100644 --- a/packages/auth/tsconfig.json +++ b/packages/auth/tsconfig.json @@ -3,7 +3,9 @@ "extends": "@crm/typescript-config/internal-package.json", "compilerOptions": { "jsx": "react-jsx", - "types": ["node"] + "types": ["node"], + "declaration": false, + "declarationMap": false }, "include": ["src/**/*.ts", "src/**/*.tsx"], "exclude": ["node_modules"] diff --git a/packages/db/package.json b/packages/db/package.json index 0bdd640b2..9ec5fe2d5 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -5,13 +5,15 @@ "type": "module", "exports": { ".": "./src/index.ts", + "./agent-tasks": "./src/agent-tasks.ts", "./blob": "./src/blob.ts", "./client": "./src/client.ts", "./enums": "./src/generated/prisma/enums.ts", "./favicon": "./src/favicon.ts", "./images": "./src/images.ts", "./safe-fetch": "./src/safe-fetch.ts", - "./settings": "./src/settings.ts" + "./settings": "./src/settings.ts", + "./workspace": "./src/workspace.ts" }, "scripts": { "build": "prisma generate", diff --git a/packages/db/prisma/migrations/20260803151440_add_workspace_organization/migration.sql b/packages/db/prisma/migrations/20260803151440_add_workspace_organization/migration.sql new file mode 100644 index 000000000..d71015cd4 --- /dev/null +++ b/packages/db/prisma/migrations/20260803151440_add_workspace_organization/migration.sql @@ -0,0 +1,70 @@ +-- AlterTable +ALTER TABLE "session" ADD COLUMN "activeOrganizationId" TEXT; + +-- CreateTable +CREATE TABLE "organization" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "logo" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL, + "metadata" TEXT, + "website" TEXT, + + CONSTRAINT "organization_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "member" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'member', + "createdAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "member_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "invitation" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "email" TEXT NOT NULL, + "role" TEXT, + "status" TEXT NOT NULL DEFAULT 'pending', + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "inviterId" TEXT NOT NULL, + + CONSTRAINT "invitation_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "organization_slug_key" ON "organization"("slug"); + +-- CreateIndex +CREATE INDEX "member_organizationId_idx" ON "member"("organizationId"); + +-- CreateIndex +CREATE INDEX "member_userId_idx" ON "member"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "member_organizationId_userId_key" ON "member"("organizationId", "userId"); + +-- CreateIndex +CREATE INDEX "invitation_organizationId_idx" ON "invitation"("organizationId"); + +-- CreateIndex +CREATE INDEX "invitation_email_idx" ON "invitation"("email"); + +-- AddForeignKey +ALTER TABLE "member" ADD CONSTRAINT "member_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "member" ADD CONSTRAINT "member_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invitation" ADD CONSTRAINT "invitation_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invitation" ADD CONSTRAINT "invitation_inviterId_fkey" FOREIGN KEY ("inviterId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260803160044_add_sso_provider/migration.sql b/packages/db/prisma/migrations/20260803160044_add_sso_provider/migration.sql new file mode 100644 index 000000000..ba0b26b7d --- /dev/null +++ b/packages/db/prisma/migrations/20260803160044_add_sso_provider/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "ssoProvider" ( + "id" TEXT NOT NULL, + "issuer" TEXT NOT NULL, + "oidcConfig" TEXT, + "samlConfig" TEXT, + "userId" TEXT, + "providerId" TEXT NOT NULL, + "organizationId" TEXT, + "domain" TEXT NOT NULL, + + CONSTRAINT "ssoProvider_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ssoProvider_providerId_key" ON "ssoProvider"("providerId"); + +-- AddForeignKey +ALTER TABLE "ssoProvider" ADD CONSTRAINT "ssoProvider_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260803162518_workspace_profile/migration.sql b/packages/db/prisma/migrations/20260803162518_workspace_profile/migration.sql new file mode 100644 index 000000000..d04ef8809 --- /dev/null +++ b/packages/db/prisma/migrations/20260803162518_workspace_profile/migration.sql @@ -0,0 +1,19 @@ +-- AlterTable +ALTER TABLE "organization" ADD COLUMN "onboardedAt" TIMESTAMP(3); + +-- An install that already told us its website has been onboarded; asking again +-- would be a form it has already filled in. +UPDATE "organization" SET "onboardedAt" = "createdAt" WHERE "website" IS NOT NULL; + +-- CreateTable +CREATE TABLE "workspaceProfile" ( + "id" TEXT NOT NULL, + "website" TEXT NOT NULL, + "narrative" TEXT NOT NULL, + "sections" JSONB NOT NULL, + "sourceUrl" TEXT, + "sessionId" TEXT, + "refreshedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "workspaceProfile_pkey" PRIMARY KEY ("id") +); diff --git a/packages/db/prisma/migrations/20260803190000_workspace_onboarded_metadata/migration.sql b/packages/db/prisma/migrations/20260803190000_workspace_onboarded_metadata/migration.sql new file mode 100644 index 000000000..cdd32e17b --- /dev/null +++ b/packages/db/prisma/migrations/20260803190000_workspace_onboarded_metadata/migration.sql @@ -0,0 +1,19 @@ +-- Onboarding state moves into the organization plugin's own `metadata` blob. +-- A workspace that has a website has answered the form, whether or not the +-- column beside it ever recorded that it had. +UPDATE "organization" +SET "metadata" = ( + COALESCE(NULLIF("metadata", '')::jsonb, '{}'::jsonb) + || jsonb_build_object( + 'onboardedAt', + to_char( + COALESCE("onboardedAt", "createdAt") AT TIME ZONE 'UTC', + 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"' + ) + ) + )::text +WHERE "website" IS NOT NULL + AND COALESCE(NULLIF("metadata", '')::jsonb, '{}'::jsonb) -> 'onboardedAt' IS NULL; + +-- AlterTable +ALTER TABLE "organization" DROP COLUMN "onboardedAt"; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 1755ac29a..551b64e68 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -23,14 +23,19 @@ model User { sessions Session[] accounts Account[] - ownedCompanies Company[] @relation("CompanyOwner") - ownedContacts Contact[] @relation("ContactOwner") - ownedDeals Deal[] @relation("DealOwner") - activities Activity[] @relation("ActivityAuthor") + ownedCompanies Company[] @relation("CompanyOwner") + ownedContacts Contact[] @relation("ContactOwner") + ownedDeals Deal[] @relation("DealOwner") + activities Activity[] @relation("ActivityAuthor") mailboxSyncs MailboxSync[] - factDecisions ContactFact[] @relation("FactDecider") + factDecisions ContactFact[] @relation("FactDecider") conversations AgentConversation[] @relation("ConversationOwner") + members Member[] + invitations Invitation[] + + ssoproviders SsoProvider[] + @@unique([email]) @@map("user") } @@ -46,6 +51,8 @@ model Session { userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) + activeOrganizationId String? + @@unique([token]) @@index([userId]) @@map("session") @@ -162,7 +169,7 @@ model Company { primaryContactId String? @unique primaryContact Contact? @relation("PrimaryContact", fields: [primaryContactId], references: [id], onDelete: SetNull) - enrichmentStatus EnrichmentStatus @default(PENDING) + enrichmentStatus EnrichmentStatus @default(PENDING) enrichedAt DateTime? enrichmentError String? enrichment CompanyEnrichment? @@ -171,14 +178,14 @@ model Company { lastActivityAt DateTime? - contacts Contact[] @relation("CompanyContacts") + contacts Contact[] @relation("CompanyContacts") conversations AgentConversation[] deals Deal[] activities Activity[] emailThreads EmailThread[] calendarEvents CalendarEvent[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([ownerId]) @@index([name]) @@ -197,10 +204,10 @@ model CompanyEnrichment { } model Contact { - id String @id @default(cuid()) + id String @id @default(cuid()) firstName String lastName String? - email String? @unique + email String? @unique phone String? title String? linkedinUrl String? @@ -218,11 +225,11 @@ model Contact { facts ContactFact[] conversations AgentConversation[] - companyId String? - company Company? @relation("CompanyContacts", fields: [companyId], references: [id], onDelete: SetNull) - ownerId String? - owner User? @relation("ContactOwner", fields: [ownerId], references: [id], onDelete: SetNull) - primaryOf Company? @relation("PrimaryContact") + companyId String? + company Company? @relation("CompanyContacts", fields: [companyId], references: [id], onDelete: SetNull) + ownerId String? + owner User? @relation("ContactOwner", fields: [ownerId], references: [id], onDelete: SetNull) + primaryOf Company? @relation("PrimaryContact") source RecordSource @default(MANUAL) @@ -291,7 +298,7 @@ model ContactBrief { contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade) narrative String - sections Json + sections Json score Float sourceUrl String? @@ -311,10 +318,10 @@ model AgentTask { reason String priority Int @default(0) - budget Int @default(4) + budget Int @default(4) attempts Int @default(0) - dueAt DateTime + dueAt DateTime leasedUntil DateTime? sessionId String? @@ -330,7 +337,7 @@ model AgentTask { } model AgentEvent { - id String @id + id String @id sessionId String contactId String? type String @@ -358,7 +365,7 @@ model AgentConversation { sessionId String @unique continuationToken String? - streamIndex Int @default(0) + streamIndex Int @default(0) title String? messageCount Int @default(0) @@ -376,10 +383,10 @@ model Deal { id String @id @default(cuid()) conversations AgentConversation[] name String - companyId String - company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) - ownerId String - owner User @relation("DealOwner", fields: [ownerId], references: [id]) + companyId String + company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) + ownerId String + owner User @relation("DealOwner", fields: [ownerId], references: [id]) stage DealStage @default(DEMO_BOOKED) stageChangedAt DateTime @default(now()) @@ -471,12 +478,12 @@ model MailboxSync { user User @relation(fields: [userId], references: [id], onDelete: Cascade) source String - status GoogleSyncStatus @default(IDLE) - cursor String? + status GoogleSyncStatus @default(IDLE) + cursor String? lastSyncedAt DateTime? lastError String? retryAfter DateTime? - autoCreate Boolean @default(false) + autoCreate Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -487,7 +494,7 @@ model MailboxSync { } model EmailThread { - id String @id @default(cuid()) + id String @id @default(cuid()) rootMessageId String @unique subject String? @@ -516,18 +523,18 @@ model EmailMessage { threadId String thread EmailThread @relation(fields: [threadId], references: [id], onDelete: Cascade) - rfcMessageId String @unique + rfcMessageId String @unique syncedByUserId String? gmailMessageId String? - direction EmailDirection - fromEmail String - fromName String? + direction EmailDirection + fromEmail String + fromName String? recipients Json subject String? snippet String? - body String? - sentAt DateTime + body String? + sentAt DateTime createdAt DateTime @default(now()) @@ -536,10 +543,10 @@ model EmailMessage { } model CalendarEvent { - id String @id @default(cuid()) + id String @id @default(cuid()) iCalUid String originalStartTime DateTime - recurringEventId String? + recurringEventId String? title String? description String? @@ -576,8 +583,8 @@ model CalendarAttendee { eventId String event CalendarEvent @relation(fields: [eventId], references: [id], onDelete: Cascade) - email String - name String? + email String + name String? responseStatus String? isOrganizer Boolean @default(false) contactId String? @@ -607,3 +614,80 @@ model AppSetting { @@map("appSetting") } + +model Organization { + id String @id + name String + slug String + logo String? + createdAt DateTime + metadata String? + website String? + members Member[] + invitations Invitation[] + + @@unique([slug]) + @@map("organization") +} + +model WorkspaceProfile { + id String @id + + website String + narrative String + sections Json + + sourceUrl String? + sessionId String? + + refreshedAt DateTime @default(now()) + + @@map("workspaceProfile") +} + +model Member { + id String @id + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + role String @default("member") + createdAt DateTime + + @@unique([organizationId, userId]) + @@index([organizationId]) + @@index([userId]) + @@map("member") +} + +model Invitation { + id String @id + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + email String + role String? + status String @default("pending") + expiresAt DateTime + createdAt DateTime @default(now()) + inviterId String + user User @relation(fields: [inviterId], references: [id], onDelete: Cascade) + + @@index([organizationId]) + @@index([email]) + @@map("invitation") +} + +model SsoProvider { + id String @id + issuer String + oidcConfig String? + samlConfig String? + userId String? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + providerId String + organizationId String? + domain String + + @@unique([providerId]) + @@map("ssoProvider") +} diff --git a/packages/db/src/agent-tasks.ts b/packages/db/src/agent-tasks.ts new file mode 100644 index 000000000..0be6d6010 --- /dev/null +++ b/packages/db/src/agent-tasks.ts @@ -0,0 +1,32 @@ +export const TASK_KINDS = [ + "brand", + "portrait", + "meeting-prep", + "identify", + "profile", + "recheck", + "company-profile", + "workspace-profile", +] as const; + +export type TaskKind = (typeof TASK_KINDS)[number]; + +export const DIRECT_KINDS = ["brand", "portrait"] as const; + +export type DirectKind = (typeof DIRECT_KINDS)[number]; + +export function isDirectKind(kind: string): kind is DirectKind { + return (DIRECT_KINDS as readonly string[]).includes(kind); +} + +export const PRIORITY = { + brand: 900, + portrait: 800, + workspace: 500, + requested: 300, + meeting: 200, + identify: 100, + sweep: 50, + companyProfile: 40, + recheck: 0, +} as const; diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 7e88fd393..722a426f1 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -8,4 +8,8 @@ export { export { Prisma, PrismaClient } from "./generated/prisma/client"; export * from "./generated/prisma/enums"; export type * from "./generated/prisma/models"; -export type { ContactBriefSections, FactEvidence } from "./json"; +export type { + ContactBriefSections, + FactEvidence, + WorkspaceProfileSections, +} from "./json"; diff --git a/packages/db/src/json.ts b/packages/db/src/json.ts index b65e04c0a..bcc672a8a 100644 --- a/packages/db/src/json.ts +++ b/packages/db/src/json.ts @@ -4,6 +4,12 @@ export type FactEvidence = { sourceUrl?: string; }; +export type WorkspaceProfileSections = { + sells?: string; + sellsTo?: string; + edge?: string; +}; + export type ContactBriefSections = { currentRole?: string; tenure?: string; diff --git a/packages/db/src/workspace.ts b/packages/db/src/workspace.ts new file mode 100644 index 000000000..86da2765b --- /dev/null +++ b/packages/db/src/workspace.ts @@ -0,0 +1,175 @@ +import type { Db } from "./client"; +import type { WorkspaceProfileSections } from "./json"; + +export const WORKSPACE_ID = "workspace"; + +export const MAX_NARRATIVE = 320; + +export const MAX_LINE = 140; + +export function isOnboarded(metadata: string | null): boolean { + return typeof readMetadata(metadata).onboardedAt === "string"; +} + +export function markOnboarded(metadata: string | null, at: Date): string { + const current = readMetadata(metadata); + + return JSON.stringify( + typeof current.onboardedAt === "string" + ? current + : { ...current, onboardedAt: at.toISOString() }, + ); +} + +function readMetadata(metadata: string | null): Record { + if (!metadata) return {}; + + try { + const parsed: unknown = JSON.parse(metadata); + + return typeof parsed === "object" && + parsed !== null && + !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +export type WorkspaceProfile = { + website: string; + narrative: string; + sections: WorkspaceProfileSections; + sourceUrl: string | null; + refreshedAt: Date; +}; + +export type WorkspaceIdentity = { + name: string; + website: string | null; + profile: WorkspaceProfile | null; +}; + +export async function readWorkspaceProfile( + db: Db, +): Promise { + const row = await db.workspaceProfile.findUnique({ + where: { id: WORKSPACE_ID }, + select: { + website: true, + narrative: true, + sections: true, + sourceUrl: true, + refreshedAt: true, + }, + }); + + if (!row) return null; + + return { ...row, sections: readSections(row.sections) }; +} + +export function profileOf( + profile: WorkspaceProfile | null, + website: string | null, +): WorkspaceProfile | null { + if (!profile || !website || profile.website !== website) return null; + + return profile; +} + +export async function readWorkspaceIdentity( + db: Db, +): Promise { + const [workspace, profile] = await Promise.all([ + db.organization.findUnique({ + where: { id: WORKSPACE_ID }, + select: { name: true, website: true }, + }), + readWorkspaceProfile(db), + ]); + + if (!workspace) return null; + + return { + name: workspace.name, + website: workspace.website, + profile: profileOf(profile, workspace.website), + }; +} + +export async function writeWorkspaceProfile( + db: Db, + input: { + website: string; + narrative: string; + sections: WorkspaceProfileSections; + sourceUrl?: string | null; + sessionId?: string | null; + }, +): Promise { + const fields = { + website: input.website, + narrative: clamp(input.narrative, MAX_NARRATIVE) ?? "", + sections: trimSections(input.sections), + sourceUrl: input.sourceUrl ?? null, + sessionId: input.sessionId ?? null, + refreshedAt: new Date(), + }; + + const row = await db.workspaceProfile.upsert({ + where: { id: WORKSPACE_ID }, + create: { id: WORKSPACE_ID, ...fields }, + update: fields, + select: { + website: true, + narrative: true, + sections: true, + sourceUrl: true, + refreshedAt: true, + }, + }); + + return { ...row, sections: readSections(row.sections) }; +} + +export function trimSections( + sections: WorkspaceProfileSections, +): WorkspaceProfileSections { + const trimmed: WorkspaceProfileSections = {}; + + const sells = clamp(sections.sells, MAX_LINE); + if (sells) trimmed.sells = sells; + + const sellsTo = clamp(sections.sellsTo, MAX_LINE); + if (sellsTo) trimmed.sellsTo = sellsTo; + + const edge = clamp(sections.edge, MAX_LINE); + if (edge) trimmed.edge = edge; + + return trimmed; +} + +function clamp(value: string | undefined, max: number): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) return undefined; + + return trimmed.length <= max ? trimmed : `${trimmed.slice(0, max - 1)}…`; +} + +function readSections(value: unknown): WorkspaceProfileSections { + if (typeof value !== "object" || value === null) return {}; + + const record = value as Record; + const text = (key: string) => + typeof record[key] === "string" && record[key].trim() + ? record[key].trim() + : undefined; + + return trimSections({ + sells: text("sells"), + sellsTo: text("sellsTo"), + edge: text("edge"), + }); +} diff --git a/packages/db/test/workspace-onboarded.spec.ts b/packages/db/test/workspace-onboarded.spec.ts new file mode 100644 index 000000000..2e942794d --- /dev/null +++ b/packages/db/test/workspace-onboarded.spec.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "bun:test"; +import { isOnboarded, markOnboarded } from "../src/workspace"; + +const AT = new Date("2026-08-03T17:00:00.000Z"); + +describe("isOnboarded", () => { + it("is false for a workspace nobody has answered for", () => { + expect(isOnboarded(null)).toBe(false); + expect(isOnboarded("")).toBe(false); + expect(isOnboarded("{}")).toBe(false); + }); + + it("is true once the timestamp is in the metadata", () => { + expect(isOnboarded('{"onboardedAt":"2026-08-03T16:59:29.675Z"}')).toBe( + true, + ); + }); + + it("does not throw on metadata written by something else", () => { + expect(isOnboarded("not json")).toBe(false); + expect(isOnboarded("[1,2,3]")).toBe(false); + expect(isOnboarded('{"onboardedAt":true}')).toBe(false); + }); +}); + +describe("markOnboarded", () => { + it("records the moment the form was answered", () => { + expect(JSON.parse(markOnboarded(null, AT))).toEqual({ + onboardedAt: AT.toISOString(), + }); + }); + + it("keeps the first answer, so saving settings again does not move it", () => { + const first = markOnboarded(null, AT); + const second = markOnboarded(first, new Date("2027-01-01T00:00:00.000Z")); + + expect(second).toBe(first); + }); + + it("leaves metadata the plugin owns alone", () => { + expect(JSON.parse(markOnboarded('{"theme":"dark"}', AT))).toEqual({ + theme: "dark", + onboardedAt: AT.toISOString(), + }); + }); +}); diff --git a/packages/ui/src/components/data-table.tsx b/packages/ui/src/components/data-table.tsx index 092b3063d..612f8eb07 100644 --- a/packages/ui/src/components/data-table.tsx +++ b/packages/ui/src/components/data-table.tsx @@ -91,6 +91,7 @@ export type DataTableProps = { expandable?: DataTableExpandable; actions?: ReactNode; leadingActions?: ReactNode; + search?: ReactNode; meta?: ReactNode; empty?: ReactNode; className?: string; @@ -151,6 +152,7 @@ export function DataTable({ expandable, actions, leadingActions, + search, meta, empty, className, @@ -211,7 +213,8 @@ export function DataTable({ return (
-
+
+ {search} {hasFilterControls && ( )} + {/* `lg:contents` so the controls join the search on one row on desktop + while staying a group the Filters button can collapse on mobile — + search itself must never be inside that collapse. */}